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.
- package/LICENSE +21 -0
- package/README.md +723 -0
- package/dist/blocklist/index.d.ts +3 -0
- package/dist/blocklist/index.d.ts.map +1 -0
- package/dist/blocklist/index.js +596 -0
- package/dist/blocklist/index.js.map +1 -0
- package/dist/ct/index.d.ts +3 -0
- package/dist/ct/index.d.ts.map +1 -0
- package/dist/ct/index.js +534 -0
- package/dist/ct/index.js.map +1 -0
- package/dist/data/dkim-selectors.d.ts +2 -0
- package/dist/data/dkim-selectors.d.ts.map +1 -0
- package/dist/data/dkim-selectors.js +60 -0
- package/dist/data/dkim-selectors.js.map +1 -0
- package/dist/data/dnsbl-lists.d.ts +8 -0
- package/dist/data/dnsbl-lists.d.ts.map +1 -0
- package/dist/data/dnsbl-lists.js +54 -0
- package/dist/data/dnsbl-lists.js.map +1 -0
- package/dist/data/takeover-fingerprints.d.ts +8 -0
- package/dist/data/takeover-fingerprints.d.ts.map +1 -0
- package/dist/data/takeover-fingerprints.js +84 -0
- package/dist/data/takeover-fingerprints.js.map +1 -0
- package/dist/data/tunneling-signatures.d.ts +17 -0
- package/dist/data/tunneling-signatures.d.ts.map +1 -0
- package/dist/data/tunneling-signatures.js +85 -0
- package/dist/data/tunneling-signatures.js.map +1 -0
- package/dist/dns/index.d.ts +3 -0
- package/dist/dns/index.d.ts.map +1 -0
- package/dist/dns/index.js +1211 -0
- package/dist/dns/index.js.map +1 -0
- package/dist/dnssec/index.d.ts +3 -0
- package/dist/dnssec/index.d.ts.map +1 -0
- package/dist/dnssec/index.js +1377 -0
- package/dist/dnssec/index.js.map +1 -0
- package/dist/domain/index.d.ts +3 -0
- package/dist/domain/index.d.ts.map +1 -0
- package/dist/domain/index.js +938 -0
- package/dist/domain/index.js.map +1 -0
- package/dist/email/index.d.ts +3 -0
- package/dist/email/index.d.ts.map +1 -0
- package/dist/email/index.js +1188 -0
- package/dist/email/index.js.map +1 -0
- package/dist/hijack/index.d.ts +3 -0
- package/dist/hijack/index.d.ts.map +1 -0
- package/dist/hijack/index.js +1117 -0
- package/dist/hijack/index.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +151 -0
- package/dist/index.js.map +1 -0
- package/dist/infra/index.d.ts +3 -0
- package/dist/infra/index.d.ts.map +1 -0
- package/dist/infra/index.js +797 -0
- package/dist/infra/index.js.map +1 -0
- package/dist/privacy/index.d.ts +3 -0
- package/dist/privacy/index.d.ts.map +1 -0
- package/dist/privacy/index.js +772 -0
- package/dist/privacy/index.js.map +1 -0
- package/dist/protocol/mcp-server.d.ts +4 -0
- package/dist/protocol/mcp-server.d.ts.map +1 -0
- package/dist/protocol/mcp-server.js +32 -0
- package/dist/protocol/mcp-server.js.map +1 -0
- package/dist/protocol/tools.d.ts +3 -0
- package/dist/protocol/tools.d.ts.map +1 -0
- package/dist/protocol/tools.js +29 -0
- package/dist/protocol/tools.js.map +1 -0
- package/dist/report/index.d.ts +3 -0
- package/dist/report/index.d.ts.map +1 -0
- package/dist/report/index.js +1167 -0
- package/dist/report/index.js.map +1 -0
- package/dist/threat/index.d.ts +3 -0
- package/dist/threat/index.d.ts.map +1 -0
- package/dist/threat/index.js +999 -0
- package/dist/threat/index.js.map +1 -0
- package/dist/tunnel/index.d.ts +3 -0
- package/dist/tunnel/index.d.ts.map +1 -0
- package/dist/tunnel/index.js +688 -0
- package/dist/tunnel/index.js.map +1 -0
- package/dist/types/index.d.ts +52 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +8 -0
- package/dist/types/index.js.map +1 -0
- package/dist/typo/index.d.ts +3 -0
- package/dist/typo/index.d.ts.map +1 -0
- package/dist/typo/index.js +625 -0
- package/dist/typo/index.js.map +1 -0
- package/dist/utils/cache.d.ts +11 -0
- package/dist/utils/cache.d.ts.map +1 -0
- package/dist/utils/cache.js +35 -0
- package/dist/utils/cache.js.map +1 -0
- package/dist/utils/dns-client.d.ts +37 -0
- package/dist/utils/dns-client.d.ts.map +1 -0
- package/dist/utils/dns-client.js +359 -0
- package/dist/utils/dns-client.js.map +1 -0
- package/dist/utils/rate-limiter.d.ts +10 -0
- package/dist/utils/rate-limiter.d.ts.map +1 -0
- package/dist/utils/rate-limiter.js +35 -0
- package/dist/utils/rate-limiter.js.map +1 -0
- 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
|