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,938 @@
1
+ import { z } from "zod";
2
+ import { text, json } from "../types/index.js";
3
+ import { createResolver, shannonEntropy, reverseIp } 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 RDAP_BASE = "https://rdap.org/domain";
9
+ const CRT_SH_BASE = "https://crt.sh";
10
+ const FETCH_TIMEOUT = 10_000;
11
+ // ─── Rate Limiter & Cache ───
12
+ const rdapLimiter = new RateLimiter(500);
13
+ const crtshLimiter = new RateLimiter(1000);
14
+ const domainCache = new TTLCache(5 * 60 * 1000); // 5 min
15
+ // ─── Parking Page Fingerprints ───
16
+ const PARKING_KEYWORDS = [
17
+ "sedoparking",
18
+ "sedo.com",
19
+ "godaddy",
20
+ "parked domain",
21
+ "parkingcrew",
22
+ "bodis.com",
23
+ "above.com",
24
+ "hugedomains",
25
+ "dan.com",
26
+ "afternic",
27
+ "undeveloped",
28
+ "this domain is for sale",
29
+ "this website is for sale",
30
+ "buy this domain",
31
+ "domain parking",
32
+ "parked free",
33
+ "is parked",
34
+ "domain is available",
35
+ "domain may be for sale",
36
+ "registrar-servers",
37
+ ];
38
+ // ─── Common English Bigrams (for DGA detection) ───
39
+ const COMMON_BIGRAMS = new Set([
40
+ "th", "he", "in", "en", "nt", "re", "er", "an", "ti", "on",
41
+ "es", "st", "or", "te", "of", "ed", "is", "it", "al", "ar",
42
+ "nd", "to", "se", "at", "ha", "ou", "le", "ng", "co", "me",
43
+ "de", "hi", "ri", "ro", "ic", "ne", "ea", "ra", "ce", "li",
44
+ ]);
45
+ const VOWELS = new Set(["a", "e", "i", "o", "u"]);
46
+ // ─── Helpers ───
47
+ async function rdapFetch(domain) {
48
+ await rdapLimiter.acquire();
49
+ const url = `${RDAP_BASE}/${encodeURIComponent(domain)}`;
50
+ const res = await fetch(url, {
51
+ signal: AbortSignal.timeout(FETCH_TIMEOUT),
52
+ headers: { Accept: "application/rdap+json, application/json" },
53
+ });
54
+ if (!res.ok)
55
+ throw new Error(`RDAP error: ${res.status} ${res.statusText}`);
56
+ return res.json();
57
+ }
58
+ function extractRdapEvents(data) {
59
+ const events = data.events ?? [];
60
+ return events.map((e) => ({
61
+ action: e.eventAction,
62
+ date: e.eventDate,
63
+ }));
64
+ }
65
+ function extractRdapRegistrar(data) {
66
+ const entities = data.entities ?? [];
67
+ for (const entity of entities) {
68
+ const roles = entity.roles ?? [];
69
+ if (roles.includes("registrar")) {
70
+ const vcardArray = entity.vcardArray ?? [];
71
+ if (Array.isArray(vcardArray) && vcardArray.length >= 2) {
72
+ const fields = vcardArray[1];
73
+ for (const field of fields) {
74
+ if (Array.isArray(field) && field[0] === "fn") {
75
+ return field[3];
76
+ }
77
+ }
78
+ }
79
+ // Fallback to handle field
80
+ if (entity.handle)
81
+ return entity.handle;
82
+ }
83
+ }
84
+ return "Unknown";
85
+ }
86
+ function extractNameservers(data) {
87
+ const nameservers = data.nameservers ?? [];
88
+ return nameservers.map((ns) => ns.ldhName);
89
+ }
90
+ function extractStatusCodes(data) {
91
+ return data.status ?? [];
92
+ }
93
+ function daysBetween(a, b) {
94
+ return Math.floor((b.getTime() - a.getTime()) / (1000 * 60 * 60 * 24));
95
+ }
96
+ async function queryCrtSh(query) {
97
+ await crtshLimiter.acquire();
98
+ const url = `${CRT_SH_BASE}/?q=${encodeURIComponent(query)}&output=json`;
99
+ const res = await fetch(url, {
100
+ signal: AbortSignal.timeout(FETCH_TIMEOUT),
101
+ headers: { Accept: "application/json" },
102
+ });
103
+ if (!res.ok)
104
+ throw new Error(`crt.sh error: ${res.status} ${res.statusText}`);
105
+ const data = await res.json();
106
+ return data ?? [];
107
+ }
108
+ // ─── Tool 1: domain_whois ───
109
+ const domainWhois = {
110
+ name: "domain_whois",
111
+ description: "Query RDAP (Registration Data Access Protocol) for domain WHOIS information. " +
112
+ "Returns registrar, registration dates, nameservers, and status codes.",
113
+ schema: {
114
+ domain: z.string().describe("The domain to look up (e.g. 'example.com')"),
115
+ },
116
+ async execute(args) {
117
+ const domain = args.domain;
118
+ const cacheKey = `domain_whois:${domain}`;
119
+ const cached = domainCache.get(cacheKey);
120
+ if (cached)
121
+ return json(cached);
122
+ try {
123
+ const data = await rdapFetch(domain);
124
+ const events = extractRdapEvents(data);
125
+ const registrar = extractRdapRegistrar(data);
126
+ const nameservers = extractNameservers(data);
127
+ const statusCodes = extractStatusCodes(data);
128
+ const result = {
129
+ domain,
130
+ registrar,
131
+ nameservers,
132
+ status_codes: statusCodes,
133
+ events,
134
+ handle: data.handle ?? null,
135
+ ldhName: data.ldhName ?? domain,
136
+ };
137
+ domainCache.set(cacheKey, result);
138
+ return json(result);
139
+ }
140
+ catch (err) {
141
+ return text(`Error querying RDAP for ${domain}: ${err.message}`);
142
+ }
143
+ },
144
+ };
145
+ // ─── Tool 2: domain_age ───
146
+ const domainAge = {
147
+ name: "domain_age",
148
+ description: "Determine domain age via RDAP creation date. Classifies as suspicious (<30 days), " +
149
+ "young (<90 days), or established (>365 days).",
150
+ schema: {
151
+ domain: z.string().describe("The domain to check age for (e.g. 'example.com')"),
152
+ },
153
+ async execute(args) {
154
+ const domain = args.domain;
155
+ try {
156
+ const data = await rdapFetch(domain);
157
+ const events = extractRdapEvents(data);
158
+ const registration = events.find((e) => e.action === "registration");
159
+ if (!registration) {
160
+ return json({
161
+ domain,
162
+ error: "No registration date found in RDAP data",
163
+ age_days: null,
164
+ verdict: "unknown",
165
+ });
166
+ }
167
+ const creationDate = new Date(registration.date);
168
+ const now = new Date();
169
+ const ageDays = daysBetween(creationDate, now);
170
+ let verdict;
171
+ let risk;
172
+ if (ageDays < 30) {
173
+ verdict = "suspicious";
174
+ risk = "high";
175
+ }
176
+ else if (ageDays < 90) {
177
+ verdict = "young";
178
+ risk = "medium";
179
+ }
180
+ else if (ageDays < 365) {
181
+ verdict = "moderate";
182
+ risk = "low";
183
+ }
184
+ else {
185
+ verdict = "established";
186
+ risk = "minimal";
187
+ }
188
+ return json({
189
+ domain,
190
+ creation_date: registration.date,
191
+ age_days: ageDays,
192
+ age_human: ageDays >= 365
193
+ ? `${Math.floor(ageDays / 365)} year(s), ${Math.floor((ageDays % 365) / 30)} month(s)`
194
+ : ageDays >= 30
195
+ ? `${Math.floor(ageDays / 30)} month(s), ${ageDays % 30} day(s)`
196
+ : `${ageDays} day(s)`,
197
+ verdict,
198
+ risk,
199
+ });
200
+ }
201
+ catch (err) {
202
+ return text(`Error checking domain age for ${domain}: ${err.message}`);
203
+ }
204
+ },
205
+ };
206
+ // ─── Tool 3: domain_history ───
207
+ const domainHistory = {
208
+ name: "domain_history",
209
+ description: "Retrieve domain event history from RDAP. Returns timeline of registration, expiration, " +
210
+ "last changed, and transfer events.",
211
+ schema: {
212
+ domain: z.string().describe("The domain to get history for (e.g. 'example.com')"),
213
+ },
214
+ async execute(args) {
215
+ const domain = args.domain;
216
+ try {
217
+ const data = await rdapFetch(domain);
218
+ const events = extractRdapEvents(data);
219
+ const registrar = extractRdapRegistrar(data);
220
+ const statusCodes = extractStatusCodes(data);
221
+ // Sort events by date
222
+ const sortedEvents = events
223
+ .map((e) => ({
224
+ ...e,
225
+ timestamp: new Date(e.date).getTime(),
226
+ }))
227
+ .sort((a, b) => a.timestamp - b.timestamp);
228
+ const timeline = sortedEvents.map((e) => ({
229
+ action: e.action,
230
+ date: e.date,
231
+ description: getEventDescription(e.action),
232
+ }));
233
+ return json({
234
+ domain,
235
+ registrar,
236
+ current_status: statusCodes,
237
+ event_count: timeline.length,
238
+ timeline,
239
+ });
240
+ }
241
+ catch (err) {
242
+ return text(`Error getting domain history for ${domain}: ${err.message}`);
243
+ }
244
+ },
245
+ };
246
+ function getEventDescription(action) {
247
+ const descriptions = {
248
+ registration: "Domain was initially registered",
249
+ expiration: "Domain registration expiration date",
250
+ reregistration: "Domain was re-registered after expiration",
251
+ "last changed": "Domain record was last modified",
252
+ "last update of RDAP database": "RDAP database was last updated for this record",
253
+ transfer: "Domain was transferred to a new registrar",
254
+ locked: "Domain was locked by registrar",
255
+ unlocked: "Domain was unlocked by registrar",
256
+ };
257
+ return descriptions[action] ?? `Event: ${action}`;
258
+ }
259
+ // ─── Tool 4: domain_expiry_risk ───
260
+ const domainExpiryRisk = {
261
+ name: "domain_expiry_risk",
262
+ description: "Assess domain expiry risk via RDAP. Checks expiration date and transfer lock status. " +
263
+ "Flags critical (<30 days), warning (<90 days), and missing transfer lock.",
264
+ schema: {
265
+ domain: z.string().describe("The domain to assess expiry risk for (e.g. 'example.com')"),
266
+ },
267
+ async execute(args) {
268
+ const domain = args.domain;
269
+ try {
270
+ const data = await rdapFetch(domain);
271
+ const events = extractRdapEvents(data);
272
+ const statusCodes = extractStatusCodes(data);
273
+ const expiration = events.find((e) => e.action === "expiration");
274
+ const flags = [];
275
+ let daysUntilExpiry = null;
276
+ let risk = "unknown";
277
+ if (expiration) {
278
+ const expiryDate = new Date(expiration.date);
279
+ const now = new Date();
280
+ daysUntilExpiry = daysBetween(now, expiryDate);
281
+ if (daysUntilExpiry < 0) {
282
+ risk = "expired";
283
+ flags.push(`CRITICAL: Domain expired ${Math.abs(daysUntilExpiry)} day(s) ago`);
284
+ }
285
+ else if (daysUntilExpiry < 30) {
286
+ risk = "critical";
287
+ flags.push(`CRITICAL: Domain expires in ${daysUntilExpiry} day(s)`);
288
+ }
289
+ else if (daysUntilExpiry < 90) {
290
+ risk = "warning";
291
+ flags.push(`WARNING: Domain expires in ${daysUntilExpiry} day(s)`);
292
+ }
293
+ else {
294
+ risk = "ok";
295
+ }
296
+ }
297
+ else {
298
+ flags.push("WARNING: No expiration date found in RDAP data");
299
+ }
300
+ // Check transfer lock
301
+ const hasTransferLock = statusCodes.some((s) => s.includes("clientTransferProhibited") ||
302
+ s.includes("serverTransferProhibited"));
303
+ if (!hasTransferLock) {
304
+ flags.push("WARNING: No transfer lock detected — domain may be vulnerable to unauthorized transfers");
305
+ }
306
+ // Check other lock statuses
307
+ const hasDeleteLock = statusCodes.some((s) => s.includes("clientDeleteProhibited") ||
308
+ s.includes("serverDeleteProhibited"));
309
+ if (!hasDeleteLock) {
310
+ flags.push("INFO: No delete lock detected");
311
+ }
312
+ const hasUpdateLock = statusCodes.some((s) => s.includes("clientUpdateProhibited") ||
313
+ s.includes("serverUpdateProhibited"));
314
+ return json({
315
+ domain,
316
+ expiration_date: expiration?.date ?? null,
317
+ days_until_expiry: daysUntilExpiry,
318
+ risk,
319
+ status_codes: statusCodes,
320
+ locks: {
321
+ transfer_lock: hasTransferLock,
322
+ delete_lock: hasDeleteLock,
323
+ update_lock: hasUpdateLock,
324
+ },
325
+ flags,
326
+ });
327
+ }
328
+ catch (err) {
329
+ return text(`Error assessing expiry risk for ${domain}: ${err.message}`);
330
+ }
331
+ },
332
+ };
333
+ // ─── Tool 5: domain_parked_detect ───
334
+ const domainParkedDetect = {
335
+ name: "domain_parked_detect",
336
+ description: "Detect if a domain is a parked/for-sale page. Resolves A record, fetches the page, " +
337
+ "and fingerprints for known parking services (Sedoparking, GoDaddy, Sedo, ParkingCrew, Bodis, etc.).",
338
+ schema: {
339
+ domain: z.string().describe("The domain to check for parking page detection (e.g. 'parked-example.com')"),
340
+ },
341
+ async execute(args) {
342
+ const domain = args.domain;
343
+ try {
344
+ // Resolve A record
345
+ const resolver = createResolver();
346
+ let ips = [];
347
+ try {
348
+ const aRecords = await resolver.resolve4(domain);
349
+ ips = aRecords;
350
+ }
351
+ catch {
352
+ return json({
353
+ domain,
354
+ resolvable: false,
355
+ is_parked: false,
356
+ error: "Domain does not resolve to an IP address",
357
+ });
358
+ }
359
+ // Attempt HTTP fetch
360
+ let html = "";
361
+ let statusCode = 0;
362
+ let redirectUrl = null;
363
+ try {
364
+ const res = await fetch(`https://${domain}`, {
365
+ signal: AbortSignal.timeout(FETCH_TIMEOUT),
366
+ redirect: "follow",
367
+ });
368
+ statusCode = res.status;
369
+ if (res.redirected)
370
+ redirectUrl = res.url;
371
+ html = await res.text();
372
+ }
373
+ catch {
374
+ // Try HTTP if HTTPS fails
375
+ try {
376
+ const res = await fetch(`http://${domain}`, {
377
+ signal: AbortSignal.timeout(FETCH_TIMEOUT),
378
+ redirect: "follow",
379
+ });
380
+ statusCode = res.status;
381
+ if (res.redirected)
382
+ redirectUrl = res.url;
383
+ html = await res.text();
384
+ }
385
+ catch {
386
+ return json({
387
+ domain,
388
+ resolvable: true,
389
+ ips,
390
+ is_parked: false,
391
+ http_reachable: false,
392
+ error: "Could not fetch page via HTTP or HTTPS",
393
+ });
394
+ }
395
+ }
396
+ // Fingerprint parking pages
397
+ const htmlLower = html.toLowerCase();
398
+ const matchedKeywords = [];
399
+ for (const keyword of PARKING_KEYWORDS) {
400
+ if (htmlLower.includes(keyword)) {
401
+ matchedKeywords.push(keyword);
402
+ }
403
+ }
404
+ // Check for minimal content (another sign of parking)
405
+ const textContentLength = html.replace(/<[^>]*>/g, "").trim().length;
406
+ const isMinimalContent = textContentLength < 500;
407
+ // Determine parking provider
408
+ let parkingProvider = null;
409
+ if (matchedKeywords.some((k) => k.includes("sedo")))
410
+ parkingProvider = "Sedo";
411
+ else if (matchedKeywords.some((k) => k.includes("godaddy")))
412
+ parkingProvider = "GoDaddy";
413
+ else if (matchedKeywords.some((k) => k.includes("parkingcrew")))
414
+ parkingProvider = "ParkingCrew";
415
+ else if (matchedKeywords.some((k) => k.includes("bodis")))
416
+ parkingProvider = "Bodis";
417
+ else if (matchedKeywords.some((k) => k.includes("hugedomains")))
418
+ parkingProvider = "HugeDomains";
419
+ else if (matchedKeywords.some((k) => k.includes("dan.com")))
420
+ parkingProvider = "Dan.com";
421
+ else if (matchedKeywords.some((k) => k.includes("afternic")))
422
+ parkingProvider = "Afternic";
423
+ const isParked = matchedKeywords.length >= 1;
424
+ return json({
425
+ domain,
426
+ resolvable: true,
427
+ ips,
428
+ http_reachable: true,
429
+ status_code: statusCode,
430
+ redirect_url: redirectUrl,
431
+ is_parked: isParked,
432
+ confidence: matchedKeywords.length >= 3 ? "high" : matchedKeywords.length >= 1 ? "medium" : "low",
433
+ parking_provider: parkingProvider,
434
+ matched_keywords: matchedKeywords,
435
+ minimal_content: isMinimalContent,
436
+ content_length: textContentLength,
437
+ });
438
+ }
439
+ catch (err) {
440
+ return text(`Error detecting parking for ${domain}: ${err.message}`);
441
+ }
442
+ },
443
+ };
444
+ // ─── Tool 6: domain_dga_detect ───
445
+ const domainDgaDetect = {
446
+ name: "domain_dga_detect",
447
+ description: "Analyze domains for DGA (Domain Generation Algorithm) characteristics. Evaluates consonant ratio, " +
448
+ "bigram frequency, Shannon entropy, length, and pronounceability. Returns per-domain DGA probability score.",
449
+ schema: {
450
+ domains: z
451
+ .array(z.string())
452
+ .describe("List of domain names to analyze for DGA characteristics"),
453
+ },
454
+ async execute(args) {
455
+ const domains = args.domains;
456
+ try {
457
+ const results = domains.map((rawDomain) => {
458
+ // Extract the registrable part (remove TLD)
459
+ const parts = rawDomain.split(".");
460
+ const label = parts.length >= 2 ? parts.slice(0, -1).join("") : parts[0];
461
+ const labelLower = label.toLowerCase();
462
+ // 1. Consonant ratio
463
+ let consonants = 0;
464
+ let vowels = 0;
465
+ for (const ch of labelLower) {
466
+ if (/[a-z]/.test(ch)) {
467
+ if (VOWELS.has(ch))
468
+ vowels++;
469
+ else
470
+ consonants++;
471
+ }
472
+ }
473
+ const totalLetters = consonants + vowels;
474
+ const consonantRatio = totalLetters > 0 ? consonants / totalLetters : 0;
475
+ // 2. Bigram frequency
476
+ let knownBigrams = 0;
477
+ let totalBigrams = 0;
478
+ for (let i = 0; i < labelLower.length - 1; i++) {
479
+ const bigram = labelLower.substring(i, i + 2);
480
+ if (/^[a-z]{2}$/.test(bigram)) {
481
+ totalBigrams++;
482
+ if (COMMON_BIGRAMS.has(bigram))
483
+ knownBigrams++;
484
+ }
485
+ }
486
+ const bigramScore = totalBigrams > 0 ? knownBigrams / totalBigrams : 0;
487
+ // 3. Shannon entropy
488
+ const entropy = shannonEntropy(labelLower);
489
+ // 4. Length analysis
490
+ const length = label.length;
491
+ const lengthScore = length > 20 ? 1.0 : length > 15 ? 0.7 : length > 10 ? 0.4 : 0.1;
492
+ // 5. Digit ratio
493
+ const digits = (label.match(/\d/g) ?? []).length;
494
+ const digitRatio = label.length > 0 ? digits / label.length : 0;
495
+ // 6. Consecutive consonant runs
496
+ let maxConsonantRun = 0;
497
+ let currentRun = 0;
498
+ for (const ch of labelLower) {
499
+ if (/[a-z]/.test(ch) && !VOWELS.has(ch)) {
500
+ currentRun++;
501
+ if (currentRun > maxConsonantRun)
502
+ maxConsonantRun = currentRun;
503
+ }
504
+ else {
505
+ currentRun = 0;
506
+ }
507
+ }
508
+ // 7. Pronounceability (heuristic: long consonant runs are hard to pronounce)
509
+ const pronounceabilityScore = maxConsonantRun >= 5 ? 1.0 : maxConsonantRun >= 4 ? 0.7 : maxConsonantRun >= 3 ? 0.3 : 0.0;
510
+ // Calculate composite DGA probability
511
+ const dgaProbability = Math.min(1.0, (consonantRatio > 0.7 ? 0.2 : 0.0) +
512
+ (bigramScore < 0.3 ? 0.2 : 0.0) +
513
+ (entropy > 3.5 ? 0.15 : 0.0) +
514
+ (entropy > 4.0 ? 0.1 : 0.0) +
515
+ lengthScore * 0.15 +
516
+ (digitRatio > 0.3 ? 0.15 : 0.0) +
517
+ pronounceabilityScore * 0.15);
518
+ let verdict;
519
+ if (dgaProbability >= 0.7)
520
+ verdict = "likely_dga";
521
+ else if (dgaProbability >= 0.4)
522
+ verdict = "suspicious";
523
+ else if (dgaProbability >= 0.2)
524
+ verdict = "possibly_benign";
525
+ else
526
+ verdict = "likely_benign";
527
+ return {
528
+ domain: rawDomain,
529
+ label_analyzed: label,
530
+ metrics: {
531
+ consonant_ratio: parseFloat(consonantRatio.toFixed(3)),
532
+ bigram_score: parseFloat(bigramScore.toFixed(3)),
533
+ entropy: parseFloat(entropy.toFixed(3)),
534
+ length,
535
+ digit_ratio: parseFloat(digitRatio.toFixed(3)),
536
+ max_consonant_run: maxConsonantRun,
537
+ },
538
+ dga_probability: parseFloat(dgaProbability.toFixed(3)),
539
+ verdict,
540
+ };
541
+ });
542
+ const dgaCount = results.filter((r) => r.verdict === "likely_dga").length;
543
+ const suspiciousCount = results.filter((r) => r.verdict === "suspicious").length;
544
+ return json({
545
+ total_analyzed: results.length,
546
+ likely_dga: dgaCount,
547
+ suspicious: suspiciousCount,
548
+ likely_benign: results.length - dgaCount - suspiciousCount,
549
+ results,
550
+ });
551
+ }
552
+ catch (err) {
553
+ return text(`Error analyzing domains for DGA: ${err.message}`);
554
+ }
555
+ },
556
+ };
557
+ // ─── Tool 7: domain_newly_registered ───
558
+ const domainNewlyRegistered = {
559
+ name: "domain_newly_registered",
560
+ description: "Search CT logs for recently issued certificates matching a pattern to discover newly registered domains. " +
561
+ "Returns domains with certificate issuance dates.",
562
+ schema: {
563
+ pattern: z.string().describe("Domain pattern to search in CT logs (e.g. 'paypal' to find phishing domains)"),
564
+ days: z
565
+ .number()
566
+ .optional()
567
+ .describe("Number of days to look back. Default 7."),
568
+ },
569
+ async execute(args) {
570
+ const pattern = args.pattern;
571
+ const days = args.days ?? 7;
572
+ try {
573
+ const entries = await queryCrtSh(`%${pattern}%`);
574
+ const cutoff = new Date();
575
+ cutoff.setDate(cutoff.getDate() - days);
576
+ const recentEntries = entries.filter((e) => {
577
+ const notBefore = new Date(e.not_before);
578
+ return notBefore >= cutoff;
579
+ });
580
+ // Extract unique domains
581
+ const domainSet = new Map();
582
+ for (const entry of recentEntries) {
583
+ const nameValue = entry.name_value ?? "";
584
+ const names = nameValue.split("\n");
585
+ for (const name of names) {
586
+ const trimmed = name.trim().replace(/^\*\./, "");
587
+ if (trimmed && !domainSet.has(trimmed)) {
588
+ const issuerName = entry.issuer_name ?? "";
589
+ const match = issuerName.match(/O=([^,]+)/);
590
+ domainSet.set(trimmed, {
591
+ first_seen: entry.not_before,
592
+ issuer: match ? match[1].trim() : issuerName,
593
+ });
594
+ }
595
+ }
596
+ }
597
+ const domainList = Array.from(domainSet.entries())
598
+ .map(([domain, info]) => ({
599
+ domain,
600
+ first_seen: info.first_seen,
601
+ issuer: info.issuer,
602
+ }))
603
+ .sort((a, b) => new Date(b.first_seen).getTime() - new Date(a.first_seen).getTime());
604
+ return json({
605
+ pattern,
606
+ days_searched: days,
607
+ cutoff_date: cutoff.toISOString(),
608
+ total_certificates: recentEntries.length,
609
+ unique_domains_found: domainList.length,
610
+ domains: domainList.slice(0, 500),
611
+ });
612
+ }
613
+ catch (err) {
614
+ return text(`Error searching newly registered domains for '${pattern}': ${err.message}`);
615
+ }
616
+ },
617
+ };
618
+ // ─── Tool 8: domain_reputation ───
619
+ const domainReputation = {
620
+ name: "domain_reputation",
621
+ description: "Multi-source domain reputation check. Queries DNS blocklists (Spamhaus DBL, SURBL), " +
622
+ "checks CT log presence, and evaluates domain age. Returns a composite reputation score.",
623
+ schema: {
624
+ domain: z.string().describe("The domain to check reputation for (e.g. 'example.com')"),
625
+ },
626
+ async execute(args) {
627
+ const domain = args.domain;
628
+ try {
629
+ const checks = {
630
+ blocklists: [],
631
+ domain_age: { days: null, verdict: "unknown" },
632
+ ct_presence: { has_certificates: false, count: 0 },
633
+ };
634
+ // 1. DNS Blocklist checks
635
+ const dnsblDomains = [
636
+ { name: "Spamhaus DBL", zone: "dbl.spamhaus.org" },
637
+ { name: "SURBL Multi", zone: "multi.surbl.org" },
638
+ { name: "URIBL", zone: "multi.uribl.com" },
639
+ { name: "Spamhaus ZRD", zone: "zrd.spamhaus.org" },
640
+ { name: "SEM Fresh", zone: "fresh.spameatingmonkey.net" },
641
+ ];
642
+ const resolver = createResolver();
643
+ const blResults = await Promise.allSettled(dnsblDomains.map(async (bl) => {
644
+ const lookup = `${domain}.${bl.zone}`;
645
+ try {
646
+ await resolver.resolve4(lookup);
647
+ return { name: bl.name, listed: true };
648
+ }
649
+ catch {
650
+ return { name: bl.name, listed: false };
651
+ }
652
+ }));
653
+ for (const result of blResults) {
654
+ if (result.status === "fulfilled") {
655
+ checks.blocklists.push(result.value);
656
+ }
657
+ }
658
+ // 2. Domain age check via RDAP
659
+ try {
660
+ const rdapData = await rdapFetch(domain);
661
+ const events = extractRdapEvents(rdapData);
662
+ const registration = events.find((e) => e.action === "registration");
663
+ if (registration) {
664
+ const ageDays = daysBetween(new Date(registration.date), new Date());
665
+ checks.domain_age = {
666
+ days: ageDays,
667
+ verdict: ageDays < 30 ? "suspicious" : ageDays < 90 ? "young" : "established",
668
+ };
669
+ }
670
+ }
671
+ catch {
672
+ // RDAP may not be available for all domains
673
+ }
674
+ // 3. CT log presence
675
+ try {
676
+ const ctEntries = await queryCrtSh(domain);
677
+ checks.ct_presence = {
678
+ has_certificates: ctEntries.length > 0,
679
+ count: ctEntries.length,
680
+ };
681
+ }
682
+ catch {
683
+ // CT check is supplementary
684
+ }
685
+ // Calculate reputation score (0-100, higher = better)
686
+ let score = 70; // Base score
687
+ const listedCount = checks.blocklists.filter((b) => b.listed).length;
688
+ score -= listedCount * 25; // Heavy penalty for blocklist listings
689
+ if (checks.domain_age.days !== null) {
690
+ if (checks.domain_age.days < 30)
691
+ score -= 20;
692
+ else if (checks.domain_age.days < 90)
693
+ score -= 10;
694
+ else if (checks.domain_age.days > 365)
695
+ score += 10;
696
+ }
697
+ if (checks.ct_presence.has_certificates)
698
+ score += 5;
699
+ score = Math.max(0, Math.min(100, score));
700
+ let verdict;
701
+ if (score >= 80)
702
+ verdict = "good";
703
+ else if (score >= 60)
704
+ verdict = "neutral";
705
+ else if (score >= 40)
706
+ verdict = "suspicious";
707
+ else
708
+ verdict = "malicious";
709
+ return json({
710
+ domain,
711
+ reputation_score: score,
712
+ verdict,
713
+ checks,
714
+ flags: [
715
+ ...(listedCount > 0 ? [`Listed on ${listedCount} DNS blocklist(s)`] : []),
716
+ ...(checks.domain_age.verdict === "suspicious" ? ["Domain is less than 30 days old"] : []),
717
+ ...(checks.domain_age.verdict === "young" ? ["Domain is less than 90 days old"] : []),
718
+ ...(!checks.ct_presence.has_certificates ? ["No certificates found in CT logs"] : []),
719
+ ],
720
+ });
721
+ }
722
+ catch (err) {
723
+ return text(`Error checking reputation for ${domain}: ${err.message}`);
724
+ }
725
+ },
726
+ };
727
+ // ─── Tool 9: domain_hosting_info ───
728
+ const domainHostingInfo = {
729
+ name: "domain_hosting_info",
730
+ description: "Get hosting infrastructure details for a domain. Resolves A record to IP, performs reverse DNS, " +
731
+ "and queries ASN information via Team Cymru DNS. Returns IP, ASN, AS name, prefix, and hosting provider.",
732
+ schema: {
733
+ domain: z.string().describe("The domain to get hosting info for (e.g. 'example.com')"),
734
+ },
735
+ async execute(args) {
736
+ const domain = args.domain;
737
+ try {
738
+ // Resolve A record
739
+ const resolver = createResolver();
740
+ let ips = [];
741
+ try {
742
+ ips = await resolver.resolve4(domain);
743
+ }
744
+ catch {
745
+ return json({
746
+ domain,
747
+ error: "Domain does not resolve to an A record",
748
+ });
749
+ }
750
+ if (ips.length === 0) {
751
+ return json({ domain, error: "No A records found" });
752
+ }
753
+ const ip = ips[0];
754
+ // Reverse DNS
755
+ let reverseDns = null;
756
+ try {
757
+ const ptrs = await dns.reverse(ip);
758
+ reverseDns = ptrs.length > 0 ? ptrs[0] : null;
759
+ }
760
+ catch {
761
+ // Reverse DNS may not be configured
762
+ }
763
+ // ASN lookup via Team Cymru
764
+ let asn = null;
765
+ let asName = null;
766
+ let prefix = null;
767
+ let country = null;
768
+ try {
769
+ const reversed = reverseIp(ip);
770
+ const originQuery = `${reversed}.origin.asn.cymru.com`;
771
+ const txtRecords = await resolver.resolveTxt(originQuery);
772
+ if (txtRecords.length > 0) {
773
+ // Format: "ASN | Prefix | CC | Registry | Allocated"
774
+ const parts = txtRecords[0].join("").split("|").map((p) => p.trim());
775
+ asn = parts[0] ?? null;
776
+ prefix = parts[1] ?? null;
777
+ country = parts[2] ?? null;
778
+ // Get AS name
779
+ if (asn) {
780
+ try {
781
+ const asQuery = `AS${asn}.asn.cymru.com`;
782
+ const asRecords = await resolver.resolveTxt(asQuery);
783
+ if (asRecords.length > 0) {
784
+ // Format: "ASN | CC | Registry | Allocated | AS Name"
785
+ const asParts = asRecords[0].join("").split("|").map((p) => p.trim());
786
+ asName = asParts[4] ?? null;
787
+ }
788
+ }
789
+ catch {
790
+ // AS name lookup may fail
791
+ }
792
+ }
793
+ }
794
+ }
795
+ catch {
796
+ // Team Cymru lookup may fail
797
+ }
798
+ // All resolved IPs
799
+ const allIps = await Promise.all(ips.map(async (resolvedIp) => {
800
+ let rdns = null;
801
+ try {
802
+ const ptrs = await dns.reverse(resolvedIp);
803
+ rdns = ptrs.length > 0 ? ptrs[0] : null;
804
+ }
805
+ catch {
806
+ // Skip
807
+ }
808
+ return { ip: resolvedIp, reverse_dns: rdns };
809
+ }));
810
+ return json({
811
+ domain,
812
+ primary_ip: ip,
813
+ all_ips: allIps,
814
+ reverse_dns: reverseDns,
815
+ asn: asn ? `AS${asn}` : null,
816
+ as_name: asName,
817
+ prefix,
818
+ country,
819
+ hosting_provider: asName ?? "Unknown",
820
+ });
821
+ }
822
+ catch (err) {
823
+ return text(`Error getting hosting info for ${domain}: ${err.message}`);
824
+ }
825
+ },
826
+ };
827
+ // ─── Tool 10: domain_related ───
828
+ const domainRelated = {
829
+ name: "domain_related",
830
+ description: "Find domains related through shared infrastructure: same nameservers, same MX records, " +
831
+ "same IP via reverse DNS, and CT log co-occurrence. Returns infrastructure-linked domains.",
832
+ schema: {
833
+ domain: z.string().describe("The domain to find related domains for (e.g. 'example.com')"),
834
+ },
835
+ async execute(args) {
836
+ const domain = args.domain;
837
+ try {
838
+ const related = {
839
+ by_nameserver: [],
840
+ by_mx: [],
841
+ by_ip: [],
842
+ by_ct: [],
843
+ };
844
+ const resolver = createResolver();
845
+ // Get current NS, MX, A records
846
+ let nameservers = [];
847
+ let mxRecords = [];
848
+ let ips = [];
849
+ try {
850
+ nameservers = await resolver.resolveNs(domain);
851
+ }
852
+ catch { /* no NS */ }
853
+ try {
854
+ const mx = await resolver.resolveMx(domain);
855
+ mxRecords = mx.map((r) => r.exchange);
856
+ }
857
+ catch { /* no MX */ }
858
+ try {
859
+ ips = await resolver.resolve4(domain);
860
+ }
861
+ catch { /* no A */ }
862
+ // Find domains sharing same IP (via reverse DNS)
863
+ for (const ip of ips) {
864
+ try {
865
+ const ptrs = await dns.reverse(ip);
866
+ for (const ptr of ptrs) {
867
+ const ptrClean = ptr.replace(/\.$/, "");
868
+ if (ptrClean !== domain && ptrClean.includes(".")) {
869
+ related.by_ip.push(ptrClean);
870
+ }
871
+ }
872
+ }
873
+ catch {
874
+ // Reverse DNS may not work
875
+ }
876
+ }
877
+ // Find related domains from CT logs
878
+ try {
879
+ const ctEntries = await queryCrtSh(`%.${domain}`);
880
+ // Collect all unique names from CT entries
881
+ const ctNames = new Set();
882
+ for (const entry of ctEntries.slice(0, 200)) {
883
+ const nameValue = entry.name_value ?? "";
884
+ const names = nameValue.split("\n");
885
+ for (const name of names) {
886
+ const trimmed = name.trim().replace(/^\*\./, "");
887
+ if (trimmed && trimmed !== domain && trimmed.endsWith(`.${domain}`)) {
888
+ ctNames.add(trimmed);
889
+ }
890
+ }
891
+ }
892
+ related.by_ct = [...ctNames].sort().slice(0, 100);
893
+ }
894
+ catch {
895
+ // CT lookup is supplementary
896
+ }
897
+ // Deduplicate
898
+ related.by_ip = [...new Set(related.by_ip)];
899
+ const totalRelated = related.by_nameserver.length +
900
+ related.by_mx.length +
901
+ related.by_ip.length +
902
+ related.by_ct.length;
903
+ return json({
904
+ domain,
905
+ infrastructure: {
906
+ nameservers,
907
+ mx_records: mxRecords,
908
+ ips,
909
+ },
910
+ related_domains: related,
911
+ total_related: totalRelated,
912
+ summary: {
913
+ by_ip_count: related.by_ip.length,
914
+ by_ct_subdomains_count: related.by_ct.length,
915
+ by_nameserver_count: related.by_nameserver.length,
916
+ by_mx_count: related.by_mx.length,
917
+ },
918
+ });
919
+ }
920
+ catch (err) {
921
+ return text(`Error finding related domains for ${domain}: ${err.message}`);
922
+ }
923
+ },
924
+ };
925
+ // ─── Export All Domain Tools ───
926
+ export const domainTools = [
927
+ domainWhois,
928
+ domainAge,
929
+ domainHistory,
930
+ domainExpiryRisk,
931
+ domainParkedDetect,
932
+ domainDgaDetect,
933
+ domainNewlyRegistered,
934
+ domainReputation,
935
+ domainHostingInfo,
936
+ domainRelated,
937
+ ];
938
+ //# sourceMappingURL=index.js.map