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,1117 @@
1
+ import { z } from "zod";
2
+ import { text, json } from "../types/index.js";
3
+ import { createResolver, resolveAll, queryRaw, reverseIp, isValidDomain } from "../utils/dns-client.js";
4
+ import { TAKEOVER_FINGERPRINTS } from "../data/takeover-fingerprints.js";
5
+ // ─── Helpers ───
6
+ const DEFAULT_SUBDOMAINS = [
7
+ "www", "mail", "ftp", "api", "dev", "staging", "blog", "shop", "cdn", "admin",
8
+ "app", "portal", "test", "beta", "m", "mobile", "static", "assets", "media",
9
+ "vpn", "remote", "webmail", "smtp", "pop", "ns1", "ns2", "docs", "wiki",
10
+ "git", "ci", "auth", "login", "sso", "dashboard", "status", "monitor",
11
+ "search", "store", "help", "support", "forum",
12
+ ];
13
+ async function resolveCnameTarget(fqdn) {
14
+ const resolver = createResolver();
15
+ try {
16
+ const cnames = await resolver.resolveCname(fqdn);
17
+ if (cnames.length > 0)
18
+ return { cname: cnames[0] };
19
+ return { cname: null };
20
+ }
21
+ catch (err) {
22
+ const e = err;
23
+ if (e.code === "ENOTFOUND" || e.code === "ENODATA")
24
+ return { cname: null, rcode: "NODATA" };
25
+ return { cname: null, error: e.message, rcode: e.code ?? "ERROR" };
26
+ }
27
+ }
28
+ async function checkCnameTargetResolvable(target) {
29
+ const resolver = createResolver();
30
+ try {
31
+ await resolver.resolve4(target);
32
+ return { resolvable: true, rcode: "NOERROR" };
33
+ }
34
+ catch (err) {
35
+ const e = err;
36
+ if (e.code === "ENOTFOUND")
37
+ return { resolvable: false, rcode: "NXDOMAIN" };
38
+ if (e.code === "ESERVFAIL")
39
+ return { resolvable: false, rcode: "SERVFAIL" };
40
+ if (e.code === "ENODATA")
41
+ return { resolvable: false, rcode: "NODATA" };
42
+ return { resolvable: false, rcode: e.code ?? "ERROR" };
43
+ }
44
+ }
45
+ function matchFingerprint(cnameTarget) {
46
+ const lower = cnameTarget.toLowerCase();
47
+ for (const fp of TAKEOVER_FINGERPRINTS) {
48
+ if (!fp.vulnerable)
49
+ continue;
50
+ for (const pattern of fp.cnames) {
51
+ const regex = new RegExp("^" + pattern.replace(/\./g, "\\.").replace(/\*/g, ".*") + "$", "i");
52
+ if (regex.test(lower)) {
53
+ return { service: fp.service, fingerprint: fp.fingerprint };
54
+ }
55
+ }
56
+ }
57
+ return null;
58
+ }
59
+ async function httpFingerprintCheck(fqdn, expectedText) {
60
+ for (const proto of ["https", "http"]) {
61
+ try {
62
+ const res = await fetch(`${proto}://${fqdn}`, {
63
+ signal: AbortSignal.timeout(5000),
64
+ redirect: "follow",
65
+ });
66
+ const body = await res.text();
67
+ const snippet = body.slice(0, 2000);
68
+ if (body.includes(expectedText)) {
69
+ return { confirmed: true, body: snippet };
70
+ }
71
+ return { confirmed: false, body: snippet };
72
+ }
73
+ catch {
74
+ // Try next protocol
75
+ }
76
+ }
77
+ return { confirmed: false, body: "" };
78
+ }
79
+ async function resolveHostname(hostname) {
80
+ const resolver = createResolver();
81
+ try {
82
+ const ips = await resolver.resolve4(hostname);
83
+ return { ips, rcode: "NOERROR" };
84
+ }
85
+ catch (err) {
86
+ const e = err;
87
+ if (e.code === "ENOTFOUND")
88
+ return { ips: [], rcode: "NXDOMAIN" };
89
+ if (e.code === "ESERVFAIL")
90
+ return { ips: [], rcode: "SERVFAIL" };
91
+ if (e.code === "ENODATA")
92
+ return { ips: [], rcode: "NODATA" };
93
+ return { ips: [], rcode: e.code ?? "ERROR" };
94
+ }
95
+ }
96
+ // ─── Tool 1: Dangling CNAME Detection ───
97
+ const hijackDanglingCname = {
98
+ name: "hijack_dangling_cname",
99
+ description: "Detect dangling CNAME records that could allow subdomain takeover. " +
100
+ "Resolves CNAME for each subdomain, checks if target returns NXDOMAIN/SERVFAIL, " +
101
+ "and matches against known service fingerprints with HTTP confirmation.",
102
+ schema: {
103
+ domain: z.string().describe("The base domain to scan for dangling CNAMEs (e.g. example.com)"),
104
+ subdomains: z
105
+ .array(z.string())
106
+ .optional()
107
+ .describe("List of subdomain prefixes to check. Defaults to common subdomains " +
108
+ "(www, mail, ftp, api, dev, staging, blog, shop, cdn, admin, etc.)"),
109
+ },
110
+ execute: async (args) => {
111
+ const domain = args.domain;
112
+ const subdomains = args.subdomains ?? DEFAULT_SUBDOMAINS;
113
+ if (!isValidDomain(domain)) {
114
+ return text(`Error: Invalid domain "${domain}"`);
115
+ }
116
+ const results = [];
117
+ const concurrency = 10;
118
+ for (let i = 0; i < subdomains.length; i += concurrency) {
119
+ const batch = subdomains.slice(i, i + concurrency);
120
+ const tasks = batch.map(async (sub) => {
121
+ const fqdn = `${sub}.${domain}`;
122
+ const result = {
123
+ subdomain: sub,
124
+ fqdn,
125
+ cnameTarget: "",
126
+ status: "alive",
127
+ takeoverRisk: "none",
128
+ };
129
+ try {
130
+ const { cname, error } = await resolveCnameTarget(fqdn);
131
+ if (!cname) {
132
+ result.status = error ? "error" : "alive";
133
+ result.error = error;
134
+ return result;
135
+ }
136
+ result.cnameTarget = cname;
137
+ // Check if CNAME target resolves
138
+ const targetCheck = await checkCnameTargetResolvable(cname);
139
+ if (targetCheck.resolvable) {
140
+ result.status = "alive";
141
+ return result;
142
+ }
143
+ // Target doesn't resolve — dangling CNAME
144
+ result.status = "dangling";
145
+ result.takeoverRisk = "high";
146
+ // Match against known services
147
+ const match = matchFingerprint(cname);
148
+ if (match) {
149
+ result.matchedService = match.service;
150
+ result.fingerprint = match.fingerprint;
151
+ result.takeoverRisk = "critical";
152
+ // HTTP fingerprint confirmation
153
+ if (match.fingerprint !== "NXDOMAIN") {
154
+ const httpCheck = await httpFingerprintCheck(fqdn, match.fingerprint);
155
+ result.httpConfirmed = httpCheck.confirmed;
156
+ result.httpBody = httpCheck.body.slice(0, 500);
157
+ }
158
+ }
159
+ }
160
+ catch (err) {
161
+ result.status = "error";
162
+ result.error = err.message;
163
+ }
164
+ return result;
165
+ });
166
+ const batchResults = await Promise.allSettled(tasks);
167
+ for (const r of batchResults) {
168
+ if (r.status === "fulfilled")
169
+ results.push(r.value);
170
+ }
171
+ }
172
+ const dangling = results.filter((r) => r.status === "dangling");
173
+ const summary = {
174
+ domain,
175
+ totalChecked: results.length,
176
+ danglingCount: dangling.length,
177
+ criticalCount: dangling.filter((r) => r.takeoverRisk === "critical").length,
178
+ highCount: dangling.filter((r) => r.takeoverRisk === "high").length,
179
+ dangling,
180
+ alive: results.filter((r) => r.status === "alive").length,
181
+ errors: results.filter((r) => r.status === "error").length,
182
+ };
183
+ return json(summary);
184
+ },
185
+ };
186
+ // ─── Tool 2: Dangling NS Detection ───
187
+ const hijackDanglingNs = {
188
+ name: "hijack_dangling_ns",
189
+ description: "Detect dangling NS records that could allow full domain takeover. " +
190
+ "If an NS hostname resolves to NXDOMAIN, an attacker can register that domain " +
191
+ "and serve arbitrary DNS responses for the target zone — a critical vulnerability.",
192
+ schema: {
193
+ domain: z.string().describe("The domain to check for dangling NS records (e.g. example.com)"),
194
+ },
195
+ execute: async (args) => {
196
+ const domain = args.domain;
197
+ if (!isValidDomain(domain)) {
198
+ return text(`Error: Invalid domain "${domain}"`);
199
+ }
200
+ const resolver = createResolver();
201
+ let nsRecords;
202
+ try {
203
+ nsRecords = await resolver.resolveNs(domain);
204
+ }
205
+ catch (err) {
206
+ return text(`Error resolving NS for ${domain}: ${err.message}`);
207
+ }
208
+ if (nsRecords.length === 0) {
209
+ return text(`No NS records found for ${domain}.`);
210
+ }
211
+ const nsChecks = await Promise.allSettled(nsRecords.map(async (ns) => {
212
+ const resolved = await resolveHostname(ns);
213
+ const isDangling = resolved.rcode === "NXDOMAIN";
214
+ const nsDomain = ns.split(".").slice(-2).join(".");
215
+ return {
216
+ nameserver: ns,
217
+ ips: resolved.ips,
218
+ rcode: resolved.rcode,
219
+ isDangling,
220
+ nsDomain,
221
+ severity: isDangling ? "critical" : "info",
222
+ note: isDangling
223
+ ? `NS hostname ${ns} is NXDOMAIN. Register ${nsDomain} to take over the entire zone for ${domain}.`
224
+ : `NS ${ns} resolves to ${resolved.ips.join(", ")}`,
225
+ };
226
+ }));
227
+ const results = nsChecks
228
+ .filter((r) => r.status === "fulfilled")
229
+ .map((r) => r.value);
230
+ const danglingNs = results.filter((r) => r.isDangling);
231
+ return json({
232
+ domain,
233
+ totalNs: nsRecords.length,
234
+ danglingCount: danglingNs.length,
235
+ severity: danglingNs.length > 0 ? "critical" : "safe",
236
+ description: danglingNs.length > 0
237
+ ? "CRITICAL: Dangling NS records detected. An attacker can register the dangling NS domain and serve arbitrary DNS for the target zone."
238
+ : "All NS records resolve successfully. No dangling NS delegation found.",
239
+ nameservers: results,
240
+ });
241
+ },
242
+ };
243
+ // ─── Tool 3: Dangling MX Detection ───
244
+ const hijackDanglingMx = {
245
+ name: "hijack_dangling_mx",
246
+ description: "Detect dangling MX records that could allow email hijacking. " +
247
+ "If an MX hostname resolves to NXDOMAIN, an attacker can register it " +
248
+ "to intercept all email for the domain.",
249
+ schema: {
250
+ domain: z.string().describe("The domain to check for dangling MX records (e.g. example.com)"),
251
+ },
252
+ execute: async (args) => {
253
+ const domain = args.domain;
254
+ if (!isValidDomain(domain)) {
255
+ return text(`Error: Invalid domain "${domain}"`);
256
+ }
257
+ const resolver = createResolver();
258
+ let mxRecords;
259
+ try {
260
+ mxRecords = await resolver.resolveMx(domain);
261
+ }
262
+ catch (err) {
263
+ return text(`Error resolving MX for ${domain}: ${err.message}`);
264
+ }
265
+ if (mxRecords.length === 0) {
266
+ return text(`No MX records found for ${domain}.`);
267
+ }
268
+ // Sort by priority ascending (lower number = higher priority)
269
+ mxRecords.sort((a, b) => a.priority - b.priority);
270
+ const mxChecks = await Promise.allSettled(mxRecords.map(async (mx) => {
271
+ const resolved = await resolveHostname(mx.exchange);
272
+ const isDangling = resolved.rcode === "NXDOMAIN";
273
+ const mxDomain = mx.exchange.split(".").slice(-2).join(".");
274
+ return {
275
+ exchange: mx.exchange,
276
+ priority: mx.priority,
277
+ ips: resolved.ips,
278
+ rcode: resolved.rcode,
279
+ isDangling,
280
+ mxDomain,
281
+ severity: isDangling ? "critical" : "info",
282
+ note: isDangling
283
+ ? `MX ${mx.exchange} (priority ${mx.priority}) is NXDOMAIN. Register ${mxDomain} to intercept email for ${domain}.`
284
+ : `MX ${mx.exchange} (priority ${mx.priority}) resolves to ${resolved.ips.join(", ")}`,
285
+ };
286
+ }));
287
+ const results = mxChecks
288
+ .filter((r) => r.status === "fulfilled")
289
+ .map((r) => r.value);
290
+ const danglingMx = results.filter((r) => r.isDangling);
291
+ const lowestPriorityDangling = danglingMx.some((r) => r.priority === mxRecords[0].priority);
292
+ return json({
293
+ domain,
294
+ totalMx: mxRecords.length,
295
+ danglingCount: danglingMx.length,
296
+ severity: danglingMx.length > 0 ? (lowestPriorityDangling ? "critical" : "high") : "safe",
297
+ description: danglingMx.length > 0
298
+ ? lowestPriorityDangling
299
+ ? "CRITICAL: Primary MX record is dangling. All email can be intercepted."
300
+ : "HIGH: Non-primary MX record(s) dangling. Email may be intercepted if primary MX is unavailable."
301
+ : "All MX records resolve successfully. No dangling MX found.",
302
+ priorityOrder: mxRecords.map((mx) => `${mx.priority} ${mx.exchange}`),
303
+ mailExchangers: results,
304
+ });
305
+ },
306
+ };
307
+ // ─── Tool 4: NS Delegation Chain Verification ───
308
+ const hijackNsDelegation = {
309
+ name: "hijack_ns_delegation",
310
+ description: "Walk the DNS delegation chain and verify consistency. Checks for lame delegation " +
311
+ "(NS doesn't have zone data), missing glue records, and NS mismatch between parent and child zones.",
312
+ schema: {
313
+ domain: z.string().describe("The domain to verify delegation chain for (e.g. example.com)"),
314
+ },
315
+ execute: async (args) => {
316
+ const domain = args.domain;
317
+ if (!isValidDomain(domain)) {
318
+ return text(`Error: Invalid domain "${domain}"`);
319
+ }
320
+ const parts = domain.split(".");
321
+ const tld = parts.slice(-1)[0];
322
+ const findings = [];
323
+ // Step 1: Get NS for TLD from root
324
+ let tldNs = [];
325
+ try {
326
+ const resolver = createResolver("198.41.0.4"); // a.root-servers.net
327
+ tldNs = await resolver.resolveNs(tld);
328
+ }
329
+ catch {
330
+ // Fallback: use default resolver
331
+ try {
332
+ const resolver = createResolver();
333
+ tldNs = await resolver.resolveNs(tld);
334
+ }
335
+ catch (err) {
336
+ return text(`Error resolving TLD NS for .${tld}: ${err.message}`);
337
+ }
338
+ }
339
+ // Step 2: Query parent zone for NS delegation of our domain
340
+ let parentNs = [];
341
+ let parentNsIps = {};
342
+ if (tldNs.length > 0) {
343
+ try {
344
+ const tldNsResolved = await resolveHostname(tldNs[0]);
345
+ if (tldNsResolved.ips.length > 0) {
346
+ const result = await queryRaw(domain, "NS", tldNsResolved.ips[0]);
347
+ // NS records may be in answers or authorities section
348
+ const nsAnswers = [...result.answers, ...result.authorities].filter((a) => a.type === "NS");
349
+ parentNs = nsAnswers.map((a) => a.data.replace(/\.$/, ""));
350
+ // Extract glue records from additionals
351
+ for (const add of result.additionals) {
352
+ if (add.type === "A") {
353
+ const name = add.name?.replace(/\.$/, "") ?? "";
354
+ if (!parentNsIps[name])
355
+ parentNsIps[name] = [];
356
+ parentNsIps[name].push(add.data);
357
+ }
358
+ }
359
+ }
360
+ }
361
+ catch {
362
+ // Fallback: use default resolver
363
+ try {
364
+ const resolver = createResolver();
365
+ parentNs = await resolver.resolveNs(domain);
366
+ }
367
+ catch {
368
+ // Will be flagged below
369
+ }
370
+ }
371
+ }
372
+ // Step 3: Get authoritative NS from the domain itself (child NS)
373
+ let childNs = [];
374
+ try {
375
+ const resolver = createResolver();
376
+ childNs = await resolver.resolveNs(domain);
377
+ }
378
+ catch (err) {
379
+ findings.push({
380
+ severity: "critical",
381
+ title: "Cannot resolve domain NS records",
382
+ description: `Failed to resolve NS for ${domain}: ${err.message}`,
383
+ remediation: "Verify the domain has proper NS records configured.",
384
+ });
385
+ }
386
+ // Step 4: Compare parent and child NS
387
+ if (parentNs.length > 0 && childNs.length > 0) {
388
+ const parentSet = new Set(parentNs.map((n) => n.toLowerCase()));
389
+ const childSet = new Set(childNs.map((n) => n.toLowerCase()));
390
+ const onlyInParent = [...parentSet].filter((n) => !childSet.has(n));
391
+ const onlyInChild = [...childSet].filter((n) => !parentSet.has(n));
392
+ if (onlyInParent.length > 0 || onlyInChild.length > 0) {
393
+ findings.push({
394
+ severity: "high",
395
+ title: "NS mismatch between parent and child zone",
396
+ description: `Parent zone lists: ${[...parentSet].join(", ")}. ` +
397
+ `Child zone lists: ${[...childSet].join(", ")}. ` +
398
+ (onlyInParent.length > 0 ? `Only in parent: ${onlyInParent.join(", ")}. ` : "") +
399
+ (onlyInChild.length > 0 ? `Only in child: ${onlyInChild.join(", ")}. ` : ""),
400
+ remediation: "Ensure parent and child zone NS records match exactly. " +
401
+ "Update the registrar/parent zone or the authoritative zone file.",
402
+ });
403
+ }
404
+ }
405
+ // Step 5: Check each NS for lame delegation
406
+ const allNs = [...new Set([...parentNs, ...childNs].map((n) => n.toLowerCase()))];
407
+ const nsResults = await Promise.allSettled(allNs.map(async (ns) => {
408
+ const resolved = await resolveHostname(ns);
409
+ let isLame = false;
410
+ let hasGlue = !!parentNsIps[ns] && parentNsIps[ns].length > 0;
411
+ if (resolved.ips.length > 0) {
412
+ // Check if NS actually has zone data (authoritative answer)
413
+ try {
414
+ const soaResult = await queryRaw(domain, "SOA", resolved.ips[0]);
415
+ isLame = !soaResult.flags.authoritative && soaResult.answers.length === 0;
416
+ }
417
+ catch {
418
+ isLame = true;
419
+ }
420
+ }
421
+ return {
422
+ nameserver: ns,
423
+ ips: resolved.ips,
424
+ rcode: resolved.rcode,
425
+ isDangling: resolved.rcode === "NXDOMAIN",
426
+ isLame,
427
+ hasGlue,
428
+ };
429
+ }));
430
+ const nsDetails = nsResults
431
+ .filter((r) => r.status === "fulfilled")
432
+ .map((r) => r.value);
433
+ // Step 6: Flag issues
434
+ for (const ns of nsDetails) {
435
+ if (ns.isDangling) {
436
+ findings.push({
437
+ severity: "critical",
438
+ title: `Dangling NS: ${ns.nameserver}`,
439
+ description: `NS ${ns.nameserver} resolves to NXDOMAIN. An attacker can register this domain to hijack DNS.`,
440
+ remediation: `Remove ${ns.nameserver} from NS records or ensure the domain is registered.`,
441
+ });
442
+ }
443
+ if (ns.isLame && !ns.isDangling) {
444
+ findings.push({
445
+ severity: "high",
446
+ title: `Lame delegation: ${ns.nameserver}`,
447
+ description: `NS ${ns.nameserver} does not return authoritative answers for ${domain}. This causes resolution failures.`,
448
+ remediation: `Configure ${ns.nameserver} as authoritative for ${domain} or remove it from NS records.`,
449
+ });
450
+ }
451
+ if (!ns.hasGlue && parentNs.includes(ns.nameserver)) {
452
+ // Check if the NS hostname is within the domain (in-bailiwick)
453
+ if (ns.nameserver.endsWith(`.${domain}`)) {
454
+ findings.push({
455
+ severity: "medium",
456
+ title: `Missing glue record: ${ns.nameserver}`,
457
+ description: `In-bailiwick NS ${ns.nameserver} has no glue record in the parent zone. This may cause resolution loops.`,
458
+ remediation: `Add glue A/AAAA records for ${ns.nameserver} at the registrar/parent zone.`,
459
+ });
460
+ }
461
+ }
462
+ }
463
+ return json({
464
+ domain,
465
+ tld,
466
+ tldNameservers: tldNs.slice(0, 5),
467
+ parentNs,
468
+ childNs,
469
+ glueRecords: parentNsIps,
470
+ nameserverDetails: nsDetails,
471
+ findings,
472
+ overallStatus: findings.some((f) => f.severity === "critical")
473
+ ? "critical"
474
+ : findings.some((f) => f.severity === "high")
475
+ ? "degraded"
476
+ : findings.length > 0
477
+ ? "warnings"
478
+ : "healthy",
479
+ });
480
+ },
481
+ };
482
+ // ─── Tool 5: DNS Rebinding Detection ───
483
+ const hijackDnsRebinding = {
484
+ name: "hijack_dns_rebinding",
485
+ description: "Detect DNS rebinding candidates by resolving a domain multiple times and checking for IP changes " +
486
+ "combined with very low TTL values. DNS rebinding attacks exploit short TTLs to switch from a " +
487
+ "public IP to a private/internal IP after initial browser security checks.",
488
+ schema: {
489
+ domain: z.string().describe("The domain to test for DNS rebinding indicators (e.g. evil.example.com)"),
490
+ samples: z
491
+ .number()
492
+ .optional()
493
+ .describe("Number of DNS resolution samples to collect (default: 5)"),
494
+ delay_ms: z
495
+ .number()
496
+ .optional()
497
+ .describe("Delay in milliseconds between resolution attempts (default: 1000)"),
498
+ },
499
+ execute: async (args) => {
500
+ const domain = args.domain;
501
+ const samples = args.samples ?? 5;
502
+ const delayMs = args.delay_ms ?? 1000;
503
+ if (!isValidDomain(domain)) {
504
+ return text(`Error: Invalid domain "${domain}"`);
505
+ }
506
+ const resolutions = [];
507
+ for (let i = 0; i < samples; i++) {
508
+ if (i > 0) {
509
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
510
+ }
511
+ const resolver = createResolver();
512
+ const timestamp = new Date().toISOString();
513
+ try {
514
+ const records = await resolver.resolve4(domain, { ttl: true });
515
+ resolutions.push({
516
+ attempt: i + 1,
517
+ timestamp,
518
+ ips: records.map((r) => r.address),
519
+ ttl: records.map((r) => r.ttl),
520
+ });
521
+ }
522
+ catch (err) {
523
+ resolutions.push({
524
+ attempt: i + 1,
525
+ timestamp,
526
+ ips: [],
527
+ ttl: [],
528
+ error: err.message,
529
+ });
530
+ }
531
+ }
532
+ // Analyze results
533
+ const allIps = new Set();
534
+ const allTtls = [];
535
+ for (const r of resolutions) {
536
+ for (const ip of r.ips)
537
+ allIps.add(ip);
538
+ for (const ttl of r.ttl)
539
+ allTtls.push(ttl);
540
+ }
541
+ const ipChanged = allIps.size > 1;
542
+ const minTtl = allTtls.length > 0 ? Math.min(...allTtls) : -1;
543
+ const maxTtl = allTtls.length > 0 ? Math.max(...allTtls) : -1;
544
+ const avgTtl = allTtls.length > 0 ? allTtls.reduce((a, b) => a + b, 0) / allTtls.length : -1;
545
+ const lowTtl = minTtl >= 0 && minTtl <= 60;
546
+ // Check for private IPs
547
+ const privateIpPattern = /^(10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.|127\.|0\.)/;
548
+ const hasPrivateIp = [...allIps].some((ip) => privateIpPattern.test(ip));
549
+ const hasPublicIp = [...allIps].some((ip) => !privateIpPattern.test(ip));
550
+ const mixedIpTypes = hasPrivateIp && hasPublicIp;
551
+ let riskLevel;
552
+ let description;
553
+ if (ipChanged && lowTtl && mixedIpTypes) {
554
+ riskLevel = "critical";
555
+ description =
556
+ "CRITICAL: DNS rebinding highly likely. IP changes between resolutions include both public and private IPs with very low TTL. " +
557
+ "This domain is likely configured for DNS rebinding attacks.";
558
+ }
559
+ else if (ipChanged && lowTtl) {
560
+ riskLevel = "high";
561
+ description =
562
+ "HIGH: DNS rebinding possible. IP changes between resolutions with low TTL detected. " +
563
+ "This may indicate a DNS rebinding setup or aggressive load balancing.";
564
+ }
565
+ else if (ipChanged) {
566
+ riskLevel = "medium";
567
+ description =
568
+ "MEDIUM: IP addresses change between resolutions but TTL is not unusually low. " +
569
+ "Likely round-robin DNS or CDN. Less likely rebinding but worth monitoring.";
570
+ }
571
+ else if (lowTtl) {
572
+ riskLevel = "low";
573
+ description =
574
+ "LOW: Very low TTL detected but IP remains stable. Low TTL alone is not sufficient for rebinding " +
575
+ "but may indicate preparation. Could also be normal for dynamic DNS.";
576
+ }
577
+ else {
578
+ riskLevel = "none";
579
+ description =
580
+ "No DNS rebinding indicators detected. IP is stable and TTL is within normal range.";
581
+ }
582
+ return json({
583
+ domain,
584
+ samples,
585
+ delayMs,
586
+ uniqueIps: [...allIps],
587
+ ipChanged,
588
+ hasPrivateIp,
589
+ hasPublicIp,
590
+ mixedIpTypes,
591
+ ttlStats: {
592
+ min: minTtl,
593
+ max: maxTtl,
594
+ avg: Math.round(avgTtl * 100) / 100,
595
+ isLow: lowTtl,
596
+ },
597
+ riskLevel,
598
+ description,
599
+ resolutions,
600
+ });
601
+ },
602
+ };
603
+ // ─── Tool 6: Registrar Security Check ───
604
+ const hijackRegistrarSecurity = {
605
+ name: "hijack_registrar_security",
606
+ description: "Check domain registrar security posture via RDAP. Verifies transfer locks, delete locks, " +
607
+ "registration expiry, and other EPP status codes that protect against unauthorized domain hijacking.",
608
+ schema: {
609
+ domain: z.string().describe("The domain to check registrar security for (e.g. example.com)"),
610
+ },
611
+ execute: async (args) => {
612
+ const domain = args.domain;
613
+ if (!isValidDomain(domain)) {
614
+ return text(`Error: Invalid domain "${domain}"`);
615
+ }
616
+ let rdapData;
617
+ try {
618
+ const res = await fetch(`https://rdap.org/domain/${domain}`, {
619
+ signal: AbortSignal.timeout(10000),
620
+ headers: { Accept: "application/rdap+json" },
621
+ });
622
+ if (!res.ok) {
623
+ return text(`RDAP query failed for ${domain}: HTTP ${res.status} ${res.statusText}`);
624
+ }
625
+ rdapData = await res.json();
626
+ }
627
+ catch (err) {
628
+ return text(`Error querying RDAP for ${domain}: ${err.message}`);
629
+ }
630
+ // Extract status codes
631
+ const statusCodes = rdapData.status ?? [];
632
+ const findings = [];
633
+ // Check transfer protection
634
+ const hasClientTransferLock = statusCodes.some((s) => s.toLowerCase() === "client transfer prohibited");
635
+ const hasServerTransferLock = statusCodes.some((s) => s.toLowerCase() === "server transfer prohibited");
636
+ if (!hasClientTransferLock && !hasServerTransferLock) {
637
+ findings.push({
638
+ severity: "high",
639
+ title: "No transfer lock",
640
+ description: "Domain has neither clientTransferProhibited nor serverTransferProhibited. " +
641
+ "An attacker with registrar access could transfer the domain to another registrar.",
642
+ });
643
+ }
644
+ else if (!hasClientTransferLock) {
645
+ findings.push({
646
+ severity: "medium",
647
+ title: "No client transfer lock",
648
+ description: "Domain lacks clientTransferProhibited. Only serverTransferProhibited is set. " +
649
+ "Consider enabling client-side transfer lock at the registrar.",
650
+ });
651
+ }
652
+ // Check delete protection
653
+ const hasClientDeleteLock = statusCodes.some((s) => s.toLowerCase() === "client delete prohibited");
654
+ const hasServerDeleteLock = statusCodes.some((s) => s.toLowerCase() === "server delete prohibited");
655
+ if (!hasClientDeleteLock && !hasServerDeleteLock) {
656
+ findings.push({
657
+ severity: "medium",
658
+ title: "No delete lock",
659
+ description: "Domain has no delete prohibition status. The domain could be deleted through registrar abuse.",
660
+ });
661
+ }
662
+ // Check update protection
663
+ const hasClientUpdateLock = statusCodes.some((s) => s.toLowerCase() === "client update prohibited");
664
+ if (!hasClientUpdateLock) {
665
+ findings.push({
666
+ severity: "low",
667
+ title: "No client update lock",
668
+ description: "Domain lacks clientUpdateProhibited. DNS records and nameserver changes are not locked at the registry level.",
669
+ });
670
+ }
671
+ // Check expiry
672
+ let expirationDate = null;
673
+ let daysUntilExpiry = null;
674
+ const events = rdapData.events ?? [];
675
+ for (const event of events) {
676
+ if (event.action === "expiration" && event.date) {
677
+ expirationDate = event.date;
678
+ const expiry = new Date(event.date);
679
+ const now = new Date();
680
+ daysUntilExpiry = Math.floor((expiry.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
681
+ if (daysUntilExpiry <= 0) {
682
+ findings.push({
683
+ severity: "critical",
684
+ title: "Domain expired",
685
+ description: `Domain expired on ${expirationDate}. It may be available for re-registration.`,
686
+ });
687
+ }
688
+ else if (daysUntilExpiry <= 30) {
689
+ findings.push({
690
+ severity: "high",
691
+ title: "Domain expiring soon",
692
+ description: `Domain expires in ${daysUntilExpiry} days (${expirationDate}). Renew immediately to prevent hijacking.`,
693
+ });
694
+ }
695
+ else if (daysUntilExpiry <= 90) {
696
+ findings.push({
697
+ severity: "medium",
698
+ title: "Domain expiring within 90 days",
699
+ description: `Domain expires in ${daysUntilExpiry} days (${expirationDate}). Consider enabling auto-renewal.`,
700
+ });
701
+ }
702
+ break;
703
+ }
704
+ }
705
+ // Extract registrar info
706
+ let registrar = null;
707
+ for (const entity of rdapData.entities ?? []) {
708
+ if ((entity.roles ?? []).includes("registrar")) {
709
+ registrar = entity.vcardArray?.[1]?.find((v) => v[0] === "fn")?.[3] ?? entity.handle ?? null;
710
+ break;
711
+ }
712
+ }
713
+ // Detect suspicious statuses
714
+ const holdStatuses = statusCodes.filter((s) => s.toLowerCase().includes("hold") ||
715
+ s.toLowerCase().includes("pending") ||
716
+ s.toLowerCase().includes("redemption"));
717
+ if (holdStatuses.length > 0) {
718
+ findings.push({
719
+ severity: "high",
720
+ title: "Domain has hold/pending status",
721
+ description: `Domain has status: ${holdStatuses.join(", ")}. This may indicate disputes, legal issues, or impending deletion.`,
722
+ });
723
+ }
724
+ const overallSeverity = findings.length === 0
725
+ ? "secure"
726
+ : findings.some((f) => f.severity === "critical")
727
+ ? "critical"
728
+ : findings.some((f) => f.severity === "high")
729
+ ? "high"
730
+ : findings.some((f) => f.severity === "medium")
731
+ ? "medium"
732
+ : "low";
733
+ return json({
734
+ domain,
735
+ registrar,
736
+ statusCodes,
737
+ expirationDate,
738
+ daysUntilExpiry,
739
+ locks: {
740
+ clientTransferProhibited: hasClientTransferLock,
741
+ serverTransferProhibited: hasServerTransferLock,
742
+ clientDeleteProhibited: hasClientDeleteLock,
743
+ serverDeleteProhibited: hasServerDeleteLock,
744
+ clientUpdateProhibited: hasClientUpdateLock,
745
+ },
746
+ overallSeverity,
747
+ findings,
748
+ });
749
+ },
750
+ };
751
+ // ─── Tool 7: DNS Change Monitor ───
752
+ const hijackChangeMonitor = {
753
+ name: "hijack_change_monitor",
754
+ description: "Monitor DNS record changes by comparing current records against a stored baseline. " +
755
+ "On first run (no baseline), returns the current state as a JSON baseline. " +
756
+ "On subsequent runs, diffs against the provided baseline to detect added, removed, or changed records.",
757
+ schema: {
758
+ domain: z.string().describe("The domain to monitor for DNS changes (e.g. example.com)"),
759
+ baseline: z
760
+ .string()
761
+ .optional()
762
+ .describe("JSON string of the previous baseline (output from a prior run). " +
763
+ "If omitted, the tool returns the current DNS state as the initial baseline."),
764
+ },
765
+ execute: async (args) => {
766
+ const domain = args.domain;
767
+ const baselineStr = args.baseline;
768
+ if (!isValidDomain(domain)) {
769
+ return text(`Error: Invalid domain "${domain}"`);
770
+ }
771
+ // Resolve all record types
772
+ const currentRecords = await resolveAll(domain);
773
+ // Normalize records for comparison: key = "TYPE|data"
774
+ const currentMap = new Map();
775
+ for (const r of currentRecords) {
776
+ const key = `${r.type}|${r.data}`;
777
+ currentMap.set(key, { type: r.type, data: r.data, ttl: r.ttl });
778
+ }
779
+ const currentState = {
780
+ domain,
781
+ timestamp: new Date().toISOString(),
782
+ records: currentRecords.map((r) => ({
783
+ type: r.type,
784
+ data: r.data,
785
+ ttl: r.ttl,
786
+ })),
787
+ };
788
+ // First run: no baseline provided
789
+ if (!baselineStr) {
790
+ return json({
791
+ mode: "baseline",
792
+ message: `Initial baseline captured for ${domain}. Provide this JSON as the 'baseline' parameter on the next run to detect changes.`,
793
+ baseline: currentState,
794
+ baselineJson: JSON.stringify(currentState),
795
+ });
796
+ }
797
+ // Parse baseline
798
+ let baselineData;
799
+ try {
800
+ baselineData = JSON.parse(baselineStr);
801
+ }
802
+ catch {
803
+ return text("Error: Could not parse baseline JSON. Ensure you pass the exact JSON from a previous run.");
804
+ }
805
+ // Build baseline map
806
+ const baselineMap = new Map();
807
+ for (const r of baselineData.records ?? []) {
808
+ const key = `${r.type}|${r.data}`;
809
+ baselineMap.set(key, { type: r.type, data: r.data, ttl: r.ttl });
810
+ }
811
+ // Compute diff
812
+ const added = [];
813
+ const removed = [];
814
+ const ttlChanged = [];
815
+ for (const [key, rec] of currentMap) {
816
+ if (!baselineMap.has(key)) {
817
+ added.push(rec);
818
+ }
819
+ else {
820
+ const oldRec = baselineMap.get(key);
821
+ if (oldRec.ttl !== rec.ttl && oldRec.ttl !== 0 && rec.ttl !== 0) {
822
+ ttlChanged.push({ type: rec.type, data: rec.data, oldTtl: oldRec.ttl, newTtl: rec.ttl });
823
+ }
824
+ }
825
+ }
826
+ for (const [key, rec] of baselineMap) {
827
+ if (!currentMap.has(key)) {
828
+ removed.push(rec);
829
+ }
830
+ }
831
+ const hasChanges = added.length > 0 || removed.length > 0 || ttlChanged.length > 0;
832
+ // Assess severity of changes
833
+ let severity = "none";
834
+ if (removed.some((r) => r.type === "NS") || added.some((r) => r.type === "NS")) {
835
+ severity = "critical";
836
+ }
837
+ else if (removed.some((r) => r.type === "MX") || added.some((r) => r.type === "MX")) {
838
+ severity = "high";
839
+ }
840
+ else if (removed.some((r) => r.type === "A" || r.type === "AAAA") || added.some((r) => r.type === "A" || r.type === "AAAA")) {
841
+ severity = "high";
842
+ }
843
+ else if (hasChanges) {
844
+ severity = "medium";
845
+ }
846
+ return json({
847
+ mode: "diff",
848
+ domain,
849
+ baselineTimestamp: baselineData.timestamp,
850
+ currentTimestamp: currentState.timestamp,
851
+ hasChanges,
852
+ severity,
853
+ summary: {
854
+ addedCount: added.length,
855
+ removedCount: removed.length,
856
+ ttlChangedCount: ttlChanged.length,
857
+ },
858
+ added,
859
+ removed,
860
+ ttlChanged,
861
+ currentState,
862
+ currentStateJson: JSON.stringify(currentState),
863
+ });
864
+ },
865
+ };
866
+ // ─── Tool 8: Subdomain Takeover Scanner ───
867
+ const hijackSubdomainTakeover = {
868
+ name: "hijack_subdomain_takeover",
869
+ description: "Full subdomain takeover scan. Optionally discovers subdomains via Certificate Transparency " +
870
+ "(crt.sh), then checks each for dangling CNAMEs, matches against known vulnerable service " +
871
+ "fingerprints, and reports takeover risk with HTTP confirmation.",
872
+ schema: {
873
+ domain: z.string().describe("The base domain to scan for subdomain takeover (e.g. example.com)"),
874
+ use_ct: z
875
+ .boolean()
876
+ .optional()
877
+ .describe("If true, query crt.sh Certificate Transparency logs to discover subdomains. " +
878
+ "Default: false (uses built-in common subdomain list)."),
879
+ },
880
+ execute: async (args) => {
881
+ const domain = args.domain;
882
+ const useCt = args.use_ct ?? false;
883
+ if (!isValidDomain(domain)) {
884
+ return text(`Error: Invalid domain "${domain}"`);
885
+ }
886
+ let subdomains = [];
887
+ // Discover subdomains
888
+ if (useCt) {
889
+ try {
890
+ const res = await fetch(`https://crt.sh/?q=%25.${encodeURIComponent(domain)}&output=json`, { signal: AbortSignal.timeout(15000) });
891
+ if (res.ok) {
892
+ const certs = await res.json();
893
+ const seen = new Set();
894
+ for (const cert of certs) {
895
+ const names = cert.name_value.split("\n");
896
+ for (const name of names) {
897
+ const clean = name.trim().replace(/^\*\./, "").toLowerCase();
898
+ if (clean.endsWith(`.${domain}`) && clean !== domain) {
899
+ seen.add(clean);
900
+ }
901
+ }
902
+ }
903
+ subdomains = [...seen];
904
+ }
905
+ }
906
+ catch {
907
+ // CT failed, fallback to wordlist
908
+ }
909
+ }
910
+ // If CT didn't provide results or wasn't requested, use default wordlist
911
+ if (subdomains.length === 0) {
912
+ subdomains = DEFAULT_SUBDOMAINS.map((s) => `${s}.${domain}`);
913
+ }
914
+ // Ensure subdomains are FQDNs
915
+ subdomains = subdomains.map((s) => (s.endsWith(`.${domain}`) ? s : `${s}.${domain}`));
916
+ // Deduplicate
917
+ subdomains = [...new Set(subdomains)];
918
+ // Scan each subdomain for dangling CNAME
919
+ const results = [];
920
+ const concurrency = 15;
921
+ for (let i = 0; i < subdomains.length; i += concurrency) {
922
+ const batch = subdomains.slice(i, i + concurrency);
923
+ const tasks = batch.map(async (fqdn) => {
924
+ const entry = {
925
+ fqdn,
926
+ hasCname: false,
927
+ isDangling: false,
928
+ takeoverRisk: "none",
929
+ };
930
+ try {
931
+ const { cname } = await resolveCnameTarget(fqdn);
932
+ if (!cname)
933
+ return entry;
934
+ entry.hasCname = true;
935
+ entry.cnameTarget = cname;
936
+ // Check if target resolves
937
+ const targetCheck = await checkCnameTargetResolvable(cname);
938
+ if (targetCheck.resolvable)
939
+ return entry;
940
+ entry.isDangling = true;
941
+ entry.takeoverRisk = "high";
942
+ // Match fingerprint
943
+ const match = matchFingerprint(cname);
944
+ if (match) {
945
+ entry.matchedService = match.service;
946
+ entry.fingerprint = match.fingerprint;
947
+ entry.takeoverRisk = "critical";
948
+ // HTTP confirmation
949
+ if (match.fingerprint !== "NXDOMAIN") {
950
+ try {
951
+ const httpCheck = await httpFingerprintCheck(fqdn, match.fingerprint);
952
+ entry.httpConfirmed = httpCheck.confirmed;
953
+ }
954
+ catch {
955
+ entry.httpConfirmed = false;
956
+ }
957
+ }
958
+ }
959
+ }
960
+ catch {
961
+ // Skip resolution errors
962
+ }
963
+ return entry;
964
+ });
965
+ const batchResults = await Promise.allSettled(tasks);
966
+ for (const r of batchResults) {
967
+ if (r.status === "fulfilled")
968
+ results.push(r.value);
969
+ }
970
+ }
971
+ const vulnerable = results.filter((r) => r.isDangling);
972
+ const critical = vulnerable.filter((r) => r.takeoverRisk === "critical");
973
+ const confirmed = vulnerable.filter((r) => r.httpConfirmed === true);
974
+ return json({
975
+ domain,
976
+ source: useCt ? "certificate_transparency" : "wordlist",
977
+ totalSubdomains: subdomains.length,
978
+ totalScanned: results.length,
979
+ vulnerableCount: vulnerable.length,
980
+ criticalCount: critical.length,
981
+ confirmedCount: confirmed.length,
982
+ summary: vulnerable.length === 0
983
+ ? `No subdomain takeover vulnerabilities found across ${results.length} subdomains.`
984
+ : `Found ${vulnerable.length} dangling CNAME(s): ${critical.length} critical, ${confirmed.length} HTTP-confirmed.`,
985
+ vulnerable,
986
+ // Only include non-vulnerable in a compact form
987
+ scannedSubdomains: results
988
+ .filter((r) => !r.isDangling)
989
+ .map((r) => ({
990
+ fqdn: r.fqdn,
991
+ hasCname: r.hasCname,
992
+ cnameTarget: r.cnameTarget,
993
+ })),
994
+ });
995
+ },
996
+ };
997
+ // ─── Tool 9: BGP Impact Assessment ───
998
+ const hijackBgpImpact = {
999
+ name: "hijack_bgp_impact",
1000
+ description: "Assess BGP-level impact of domain hijacking by querying Team Cymru's DNS interface for ASN, " +
1001
+ "prefix, and AS owner information. Reports on the network infrastructure behind a domain " +
1002
+ "and notes about RPKI/ROA protection.",
1003
+ schema: {
1004
+ domain: z.string().describe("The domain to assess BGP impact for (e.g. example.com)"),
1005
+ },
1006
+ execute: async (args) => {
1007
+ const domain = args.domain;
1008
+ if (!isValidDomain(domain)) {
1009
+ return text(`Error: Invalid domain "${domain}"`);
1010
+ }
1011
+ // Step 1: Resolve domain to IPs
1012
+ const resolver = createResolver();
1013
+ let ips;
1014
+ try {
1015
+ ips = await resolver.resolve4(domain);
1016
+ }
1017
+ catch (err) {
1018
+ return text(`Error resolving ${domain}: ${err.message}`);
1019
+ }
1020
+ if (ips.length === 0) {
1021
+ return text(`No A records found for ${domain}.`);
1022
+ }
1023
+ // Step 2: For each IP, query Team Cymru DNS interface
1024
+ const bgpResults = await Promise.allSettled(ips.map(async (ip) => {
1025
+ const reversed = reverseIp(ip);
1026
+ const cymruQuery = `${reversed}.origin.asn.cymru.com`;
1027
+ let asn = null;
1028
+ let prefix = null;
1029
+ let country = null;
1030
+ let registry = null;
1031
+ let asName = null;
1032
+ // Query origin.asn.cymru.com for ASN info
1033
+ try {
1034
+ const txtRecords = await resolver.resolveTxt(cymruQuery);
1035
+ if (txtRecords.length > 0) {
1036
+ const parts = txtRecords[0].join("").split("|").map((s) => s.trim());
1037
+ // Format: ASN | IP Prefix | Country | Registry | Allocated Date
1038
+ asn = parts[0] ?? null;
1039
+ prefix = parts[1] ?? null;
1040
+ country = parts[2] ?? null;
1041
+ registry = parts[3] ?? null;
1042
+ }
1043
+ }
1044
+ catch {
1045
+ // Team Cymru query failed
1046
+ }
1047
+ // Query AS name
1048
+ if (asn) {
1049
+ // ASN may contain spaces for multi-origin; take the first
1050
+ const primaryAsn = asn.split(/\s+/)[0];
1051
+ try {
1052
+ const asTxtRecords = await resolver.resolveTxt(`AS${primaryAsn}.asn.cymru.com`);
1053
+ if (asTxtRecords.length > 0) {
1054
+ const parts = asTxtRecords[0].join("").split("|").map((s) => s.trim());
1055
+ // Format: ASN | Country | Registry | Allocated | AS Name
1056
+ asName = parts[4] ?? null;
1057
+ }
1058
+ }
1059
+ catch {
1060
+ // AS name lookup failed
1061
+ }
1062
+ }
1063
+ return {
1064
+ ip,
1065
+ asn,
1066
+ prefix,
1067
+ country,
1068
+ registry,
1069
+ asName,
1070
+ };
1071
+ }));
1072
+ const results = bgpResults
1073
+ .filter((r) => r.status === "fulfilled")
1074
+ .map((r) => r.value);
1075
+ // Assess diversity
1076
+ const uniqueAsns = new Set(results.map((r) => r.asn).filter(Boolean));
1077
+ const uniquePrefixes = new Set(results.map((r) => r.prefix).filter(Boolean));
1078
+ const rpkiNotes = [
1079
+ "RPKI/ROA validation status cannot be determined via DNS alone.",
1080
+ "Use RPKI validators (e.g., Cloudflare rpki.cloudflare.com, RIPE RIS) to verify ROA coverage.",
1081
+ "If no ROA exists for the prefix, it is more vulnerable to BGP hijacking.",
1082
+ "Domains relying on a single ASN/prefix are more susceptible to targeted BGP hijacks.",
1083
+ ];
1084
+ const singleAsn = uniqueAsns.size === 1;
1085
+ const riskAssessment = singleAsn
1086
+ ? "Domain resolves to a single ASN. A BGP hijack of this prefix would affect all traffic."
1087
+ : `Domain resolves across ${uniqueAsns.size} ASNs, providing some resilience against BGP hijacking.`;
1088
+ return json({
1089
+ domain,
1090
+ ipCount: ips.length,
1091
+ uniqueAsns: uniqueAsns.size,
1092
+ uniquePrefixes: uniquePrefixes.size,
1093
+ riskAssessment,
1094
+ bgpDetails: results,
1095
+ rpkiNotes,
1096
+ recommendations: [
1097
+ singleAsn ? "Consider using a CDN or multi-homed setup for BGP diversity." : null,
1098
+ "Verify RPKI/ROA records exist for all announced prefixes.",
1099
+ "Monitor BGP announcements for unauthorized origin changes (e.g., BGPStream, RIPE RIS).",
1100
+ "Consider deploying RPKI if you control the announced prefixes.",
1101
+ ].filter(Boolean),
1102
+ });
1103
+ },
1104
+ };
1105
+ // ─── Export All Tools ───
1106
+ export const hijackTools = [
1107
+ hijackDanglingCname,
1108
+ hijackDanglingNs,
1109
+ hijackDanglingMx,
1110
+ hijackNsDelegation,
1111
+ hijackDnsRebinding,
1112
+ hijackRegistrarSecurity,
1113
+ hijackChangeMonitor,
1114
+ hijackSubdomainTakeover,
1115
+ hijackBgpImpact,
1116
+ ];
1117
+ //# sourceMappingURL=index.js.map