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,1211 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { text, json } from "../types/index.js";
|
|
3
|
+
import { resolveAll, queryRaw, queryTcp, createResolver, isValidDomain, PUBLIC_RESOLVERS, SUBDOMAIN_WORDLIST, } from "../utils/dns-client.js";
|
|
4
|
+
import { TTLCache } from "../utils/cache.js";
|
|
5
|
+
import * as dns from "node:dns/promises";
|
|
6
|
+
import * as dgram from "node:dgram";
|
|
7
|
+
import * as dnsPacket from "dns-packet";
|
|
8
|
+
// ─── Caches ───
|
|
9
|
+
const lookupCache = new TTLCache(5 * 60 * 1000);
|
|
10
|
+
const subdomainCache = new TTLCache(10 * 60 * 1000);
|
|
11
|
+
// ─── Tool 1: dns_lookup ───
|
|
12
|
+
const dnsLookup = {
|
|
13
|
+
name: "dns_lookup",
|
|
14
|
+
description: "Resolve all DNS record types for a domain in parallel. Returns A, AAAA, MX, TXT, NS, SOA, CNAME, SRV, and CAA records. " +
|
|
15
|
+
"Optionally specify record types or a custom resolver.",
|
|
16
|
+
schema: {
|
|
17
|
+
domain: z.string().describe("The domain name to resolve (e.g. 'example.com')"),
|
|
18
|
+
types: z
|
|
19
|
+
.array(z.string())
|
|
20
|
+
.optional()
|
|
21
|
+
.describe("Record types to query (e.g. ['A', 'MX', 'TXT']). Defaults to all common types."),
|
|
22
|
+
resolver: z
|
|
23
|
+
.string()
|
|
24
|
+
.optional()
|
|
25
|
+
.describe("Custom DNS resolver IP to use (e.g. '8.8.8.8'). Uses system default if omitted."),
|
|
26
|
+
},
|
|
27
|
+
async execute(args) {
|
|
28
|
+
const domain = args.domain;
|
|
29
|
+
const types = args.types;
|
|
30
|
+
const resolver = args.resolver;
|
|
31
|
+
if (!isValidDomain(domain)) {
|
|
32
|
+
return text(`Invalid domain: ${domain}`);
|
|
33
|
+
}
|
|
34
|
+
const cacheKey = `lookup:${domain}:${(types ?? []).join(",")}:${resolver ?? "default"}`;
|
|
35
|
+
const cached = lookupCache.get(cacheKey);
|
|
36
|
+
if (cached)
|
|
37
|
+
return json(cached);
|
|
38
|
+
try {
|
|
39
|
+
const records = await resolveAll(domain, types, resolver);
|
|
40
|
+
if (records.length === 0) {
|
|
41
|
+
return json({ domain, records: [], message: "No DNS records found." });
|
|
42
|
+
}
|
|
43
|
+
// Group by type for readability
|
|
44
|
+
const grouped = {};
|
|
45
|
+
for (const r of records) {
|
|
46
|
+
if (!grouped[r.type])
|
|
47
|
+
grouped[r.type] = [];
|
|
48
|
+
grouped[r.type].push({ data: r.data, ttl: r.ttl });
|
|
49
|
+
}
|
|
50
|
+
const result = {
|
|
51
|
+
domain,
|
|
52
|
+
resolver: resolver ?? "system default",
|
|
53
|
+
total_records: records.length,
|
|
54
|
+
records: grouped,
|
|
55
|
+
raw: records,
|
|
56
|
+
};
|
|
57
|
+
lookupCache.set(cacheKey, result);
|
|
58
|
+
return json(result);
|
|
59
|
+
}
|
|
60
|
+
catch (err) {
|
|
61
|
+
return text(`DNS lookup failed for ${domain}: ${err.message}`);
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
// ─── Tool 2: dns_reverse ───
|
|
66
|
+
const dnsReverse = {
|
|
67
|
+
name: "dns_reverse",
|
|
68
|
+
description: "Perform PTR (reverse DNS) lookup on an IP address with Forward Confirmed rDNS (FCrDNS) validation. " +
|
|
69
|
+
"Resolves the PTR record, then forward-resolves the resulting hostname to confirm it maps back to the original IP.",
|
|
70
|
+
schema: {
|
|
71
|
+
ip: z.string().describe("The IP address to perform reverse DNS on (e.g. '8.8.8.8')"),
|
|
72
|
+
},
|
|
73
|
+
async execute(args) {
|
|
74
|
+
const ip = args.ip;
|
|
75
|
+
try {
|
|
76
|
+
// Step 1: PTR lookup
|
|
77
|
+
let hostnames;
|
|
78
|
+
try {
|
|
79
|
+
hostnames = await dns.reverse(ip);
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return json({
|
|
83
|
+
ip,
|
|
84
|
+
ptr_records: [],
|
|
85
|
+
fcrdns_validated: false,
|
|
86
|
+
message: "No PTR record found for this IP address.",
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
if (hostnames.length === 0) {
|
|
90
|
+
return json({
|
|
91
|
+
ip,
|
|
92
|
+
ptr_records: [],
|
|
93
|
+
fcrdns_validated: false,
|
|
94
|
+
message: "No PTR record found for this IP address.",
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
// Step 2: FCrDNS — forward-resolve each hostname, check if it maps back
|
|
98
|
+
const results = await Promise.all(hostnames.map(async (hostname) => {
|
|
99
|
+
try {
|
|
100
|
+
const resolver = createResolver();
|
|
101
|
+
const aRecords = await resolver.resolve4(hostname).catch(() => []);
|
|
102
|
+
const aaaaRecords = await resolver.resolve6(hostname).catch(() => []);
|
|
103
|
+
const allIps = [...aRecords, ...aaaaRecords];
|
|
104
|
+
const confirmed = allIps.includes(ip);
|
|
105
|
+
return {
|
|
106
|
+
hostname,
|
|
107
|
+
forward_ips: allIps,
|
|
108
|
+
fcrdns_match: confirmed,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
return {
|
|
113
|
+
hostname,
|
|
114
|
+
forward_ips: [],
|
|
115
|
+
fcrdns_match: false,
|
|
116
|
+
error: "Forward resolution failed",
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
}));
|
|
120
|
+
const anyConfirmed = results.some((r) => r.fcrdns_match);
|
|
121
|
+
return json({
|
|
122
|
+
ip,
|
|
123
|
+
ptr_records: hostnames,
|
|
124
|
+
fcrdns_results: results,
|
|
125
|
+
fcrdns_validated: anyConfirmed,
|
|
126
|
+
security_note: anyConfirmed
|
|
127
|
+
? "FCrDNS validated — the PTR record is confirmed by forward DNS."
|
|
128
|
+
: "FCrDNS FAILED — the hostname does not resolve back to this IP. This may indicate a misconfiguration or spoofed PTR record.",
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
catch (err) {
|
|
132
|
+
return text(`Reverse DNS failed for ${ip}: ${err.message}`);
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
// ─── Tool 3: dns_zone_transfer ───
|
|
137
|
+
const dnsZoneTransfer = {
|
|
138
|
+
name: "dns_zone_transfer",
|
|
139
|
+
description: "Attempt an AXFR (full zone transfer) against a domain's nameserver via TCP. " +
|
|
140
|
+
"If the nameserver allows zone transfers, all DNS records in the zone are returned. " +
|
|
141
|
+
"An open zone transfer is a critical security misconfiguration.",
|
|
142
|
+
schema: {
|
|
143
|
+
domain: z.string().describe("The domain to attempt zone transfer on (e.g. 'example.com')"),
|
|
144
|
+
nameserver: z
|
|
145
|
+
.string()
|
|
146
|
+
.optional()
|
|
147
|
+
.describe("Specific nameserver to target. If omitted, the domain's authoritative NS is used."),
|
|
148
|
+
},
|
|
149
|
+
async execute(args) {
|
|
150
|
+
const domain = args.domain;
|
|
151
|
+
let nameserver = args.nameserver;
|
|
152
|
+
if (!isValidDomain(domain)) {
|
|
153
|
+
return text(`Invalid domain: ${domain}`);
|
|
154
|
+
}
|
|
155
|
+
try {
|
|
156
|
+
// Resolve nameserver if not provided
|
|
157
|
+
if (!nameserver) {
|
|
158
|
+
const resolver = createResolver();
|
|
159
|
+
const nsRecords = await resolver.resolveNs(domain);
|
|
160
|
+
if (nsRecords.length === 0) {
|
|
161
|
+
return text(`No NS records found for ${domain}. Cannot attempt zone transfer.`);
|
|
162
|
+
}
|
|
163
|
+
nameserver = nsRecords[0];
|
|
164
|
+
}
|
|
165
|
+
// Resolve nameserver hostname to IP if needed
|
|
166
|
+
let nsIp = nameserver;
|
|
167
|
+
if (!nsIp.match(/^\d+\.\d+\.\d+\.\d+$/)) {
|
|
168
|
+
const resolver = createResolver();
|
|
169
|
+
const ips = await resolver.resolve4(nameserver);
|
|
170
|
+
if (ips.length === 0) {
|
|
171
|
+
return text(`Cannot resolve nameserver ${nameserver} to an IP address.`);
|
|
172
|
+
}
|
|
173
|
+
nsIp = ips[0];
|
|
174
|
+
}
|
|
175
|
+
// Attempt AXFR
|
|
176
|
+
const answers = await queryTcp(domain, "AXFR", nsIp, 53);
|
|
177
|
+
if (answers.length === 0) {
|
|
178
|
+
return json({
|
|
179
|
+
domain,
|
|
180
|
+
nameserver,
|
|
181
|
+
nameserver_ip: nsIp,
|
|
182
|
+
transfer_allowed: false,
|
|
183
|
+
records: [],
|
|
184
|
+
security_assessment: {
|
|
185
|
+
status: "SECURE",
|
|
186
|
+
message: "Zone transfer was denied (AXFR refused or returned no records). This is the expected secure configuration.",
|
|
187
|
+
},
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
// Format records
|
|
191
|
+
const records = answers.map((a) => ({
|
|
192
|
+
name: a.name,
|
|
193
|
+
type: a.type,
|
|
194
|
+
ttl: a.ttl ?? 0,
|
|
195
|
+
data: a.data?.toString?.() ?? JSON.stringify(a.data),
|
|
196
|
+
}));
|
|
197
|
+
return json({
|
|
198
|
+
domain,
|
|
199
|
+
nameserver,
|
|
200
|
+
nameserver_ip: nsIp,
|
|
201
|
+
transfer_allowed: true,
|
|
202
|
+
record_count: records.length,
|
|
203
|
+
records,
|
|
204
|
+
security_assessment: {
|
|
205
|
+
status: "CRITICAL",
|
|
206
|
+
severity: "critical",
|
|
207
|
+
message: "Zone transfer ALLOWED! This exposes the entire DNS zone, revealing all subdomains, IPs, and mail servers. " +
|
|
208
|
+
"An attacker can map the entire infrastructure.",
|
|
209
|
+
remediation: "Restrict AXFR to authorized secondary nameservers only. " +
|
|
210
|
+
"In BIND: use 'allow-transfer' directive. " +
|
|
211
|
+
"In Windows DNS: configure zone transfer restrictions in DNS Manager.",
|
|
212
|
+
},
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
catch (err) {
|
|
216
|
+
const errMsg = err.message;
|
|
217
|
+
// Connection refused / timeout generally means AXFR is denied
|
|
218
|
+
if (errMsg.includes("timeout") || errMsg.includes("ECONNREFUSED") || errMsg.includes("ECONNRESET")) {
|
|
219
|
+
return json({
|
|
220
|
+
domain,
|
|
221
|
+
nameserver,
|
|
222
|
+
transfer_allowed: false,
|
|
223
|
+
security_assessment: {
|
|
224
|
+
status: "SECURE",
|
|
225
|
+
message: `Zone transfer denied or connection failed: ${errMsg}`,
|
|
226
|
+
},
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
return text(`Zone transfer failed: ${errMsg}`);
|
|
230
|
+
}
|
|
231
|
+
},
|
|
232
|
+
};
|
|
233
|
+
// ─── Tool 4: dns_subdomain_enum ───
|
|
234
|
+
const dnsSubdomainEnum = {
|
|
235
|
+
name: "dns_subdomain_enum",
|
|
236
|
+
description: "Enumerate subdomains using passive CT (Certificate Transparency) log lookups via crt.sh and active DNS brute-force. " +
|
|
237
|
+
"Deduplicates results and resolves each discovered subdomain to get its IP addresses.",
|
|
238
|
+
schema: {
|
|
239
|
+
domain: z.string().describe("The target domain to enumerate subdomains for (e.g. 'example.com')"),
|
|
240
|
+
wordlist: z
|
|
241
|
+
.array(z.string())
|
|
242
|
+
.optional()
|
|
243
|
+
.describe("Custom subdomain wordlist for brute-force. Uses built-in top-200 wordlist if omitted."),
|
|
244
|
+
use_ct: z
|
|
245
|
+
.boolean()
|
|
246
|
+
.optional()
|
|
247
|
+
.describe("Whether to query Certificate Transparency logs via crt.sh (default: true)"),
|
|
248
|
+
limit: z
|
|
249
|
+
.number()
|
|
250
|
+
.optional()
|
|
251
|
+
.describe("Maximum number of subdomains to return (default: 500)"),
|
|
252
|
+
},
|
|
253
|
+
async execute(args) {
|
|
254
|
+
const domain = args.domain;
|
|
255
|
+
const wordlist = args.wordlist ?? SUBDOMAIN_WORDLIST;
|
|
256
|
+
const useCt = args.use_ct ?? true;
|
|
257
|
+
const limit = args.limit ?? 500;
|
|
258
|
+
if (!isValidDomain(domain)) {
|
|
259
|
+
return text(`Invalid domain: ${domain}`);
|
|
260
|
+
}
|
|
261
|
+
const cacheKey = `subenum:${domain}:${useCt}:${limit}`;
|
|
262
|
+
const cached = subdomainCache.get(cacheKey);
|
|
263
|
+
if (cached)
|
|
264
|
+
return json(cached);
|
|
265
|
+
const found = new Set();
|
|
266
|
+
const errors = [];
|
|
267
|
+
// Passive: crt.sh CT log lookup
|
|
268
|
+
if (useCt) {
|
|
269
|
+
try {
|
|
270
|
+
const url = `https://crt.sh/?q=%25.${encodeURIComponent(domain)}&output=json`;
|
|
271
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(10000) });
|
|
272
|
+
if (res.ok) {
|
|
273
|
+
const data = (await res.json());
|
|
274
|
+
for (const entry of data) {
|
|
275
|
+
const names = entry.name_value.split("\n");
|
|
276
|
+
for (const name of names) {
|
|
277
|
+
const clean = name.trim().toLowerCase().replace(/^\*\./, "");
|
|
278
|
+
if (clean.endsWith(`.${domain}`) || clean === domain) {
|
|
279
|
+
found.add(clean);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
catch (err) {
|
|
286
|
+
errors.push(`crt.sh lookup failed: ${err.message}`);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
// Active: DNS brute-force
|
|
290
|
+
const resolver = createResolver();
|
|
291
|
+
const BATCH_SIZE = 50;
|
|
292
|
+
for (let i = 0; i < wordlist.length; i += BATCH_SIZE) {
|
|
293
|
+
const batch = wordlist.slice(i, i + BATCH_SIZE);
|
|
294
|
+
const tasks = batch.map(async (sub) => {
|
|
295
|
+
const fqdn = `${sub}.${domain}`;
|
|
296
|
+
try {
|
|
297
|
+
const addrs = await resolver.resolve4(fqdn);
|
|
298
|
+
if (addrs.length > 0)
|
|
299
|
+
found.add(fqdn);
|
|
300
|
+
}
|
|
301
|
+
catch {
|
|
302
|
+
// NXDOMAIN or timeout — not a valid subdomain
|
|
303
|
+
}
|
|
304
|
+
});
|
|
305
|
+
await Promise.allSettled(tasks);
|
|
306
|
+
// Check limit
|
|
307
|
+
if (found.size >= limit)
|
|
308
|
+
break;
|
|
309
|
+
}
|
|
310
|
+
// Resolve IPs for all found subdomains
|
|
311
|
+
const subdomains = Array.from(found).slice(0, limit);
|
|
312
|
+
const resolvedResults = await Promise.allSettled(subdomains.map(async (sub) => {
|
|
313
|
+
try {
|
|
314
|
+
const ips = await resolver.resolve4(sub);
|
|
315
|
+
return { subdomain: sub, ips };
|
|
316
|
+
}
|
|
317
|
+
catch {
|
|
318
|
+
return { subdomain: sub, ips: [] };
|
|
319
|
+
}
|
|
320
|
+
}));
|
|
321
|
+
const resolved = resolvedResults
|
|
322
|
+
.filter((r) => r.status === "fulfilled")
|
|
323
|
+
.map((r) => r.value);
|
|
324
|
+
// Unique IPs summary
|
|
325
|
+
const allIps = new Set();
|
|
326
|
+
for (const r of resolved) {
|
|
327
|
+
for (const ip of r.ips)
|
|
328
|
+
allIps.add(ip);
|
|
329
|
+
}
|
|
330
|
+
const result = {
|
|
331
|
+
domain,
|
|
332
|
+
total_subdomains: resolved.length,
|
|
333
|
+
unique_ips: allIps.size,
|
|
334
|
+
sources: {
|
|
335
|
+
ct_logs: useCt,
|
|
336
|
+
brute_force: true,
|
|
337
|
+
wordlist_size: wordlist.length,
|
|
338
|
+
},
|
|
339
|
+
subdomains: resolved,
|
|
340
|
+
errors: errors.length > 0 ? errors : undefined,
|
|
341
|
+
};
|
|
342
|
+
subdomainCache.set(cacheKey, result);
|
|
343
|
+
return json(result);
|
|
344
|
+
},
|
|
345
|
+
};
|
|
346
|
+
// ─── Tool 5: dns_cache_snoop ───
|
|
347
|
+
const dnsCacheSnoop = {
|
|
348
|
+
name: "dns_cache_snoop",
|
|
349
|
+
description: "Perform DNS cache snooping by sending a non-recursive query (RD=0) to a nameserver. " +
|
|
350
|
+
"If the server returns an answer without performing recursion, the domain was previously cached, " +
|
|
351
|
+
"revealing that someone behind that resolver recently visited the domain.",
|
|
352
|
+
schema: {
|
|
353
|
+
domain: z.string().describe("The domain to check in the nameserver's cache (e.g. 'google.com')"),
|
|
354
|
+
nameserver: z.string().describe("The DNS nameserver IP to snoop on (e.g. '8.8.8.8')"),
|
|
355
|
+
},
|
|
356
|
+
async execute(args) {
|
|
357
|
+
const domain = args.domain;
|
|
358
|
+
const nameserver = args.nameserver;
|
|
359
|
+
if (!isValidDomain(domain)) {
|
|
360
|
+
return text(`Invalid domain: ${domain}`);
|
|
361
|
+
}
|
|
362
|
+
try {
|
|
363
|
+
// Send non-recursive query (RD=0)
|
|
364
|
+
const result = await queryRaw(domain, "A", nameserver, 53, { rd: false });
|
|
365
|
+
const isCached = result.answers.length > 0;
|
|
366
|
+
const records = result.answers.map((a) => ({
|
|
367
|
+
name: a.name,
|
|
368
|
+
type: a.type,
|
|
369
|
+
ttl: a.ttl ?? 0,
|
|
370
|
+
data: a.data?.toString?.() ?? JSON.stringify(a.data),
|
|
371
|
+
}));
|
|
372
|
+
return json({
|
|
373
|
+
domain,
|
|
374
|
+
nameserver,
|
|
375
|
+
cached: isCached,
|
|
376
|
+
rcode: result.rcode,
|
|
377
|
+
flags: result.flags,
|
|
378
|
+
cached_records: records,
|
|
379
|
+
analysis: isCached
|
|
380
|
+
? `Domain '${domain}' IS cached on ${nameserver}. TTL remaining: ${records[0]?.ttl ?? "unknown"}s. ` +
|
|
381
|
+
"This indicates recent DNS queries for this domain from clients using this resolver."
|
|
382
|
+
: `Domain '${domain}' is NOT cached on ${nameserver}. No recent lookups detected.`,
|
|
383
|
+
security_note: "Cache snooping can reveal which websites users behind a resolver are visiting. " +
|
|
384
|
+
"Recursive resolvers should disable non-recursive queries from external sources.",
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
catch (err) {
|
|
388
|
+
return text(`Cache snooping failed: ${err.message}`);
|
|
389
|
+
}
|
|
390
|
+
},
|
|
391
|
+
};
|
|
392
|
+
// ─── Tool 6: dns_nsec_walk ───
|
|
393
|
+
const dnsNsecWalk = {
|
|
394
|
+
name: "dns_nsec_walk",
|
|
395
|
+
description: "Attempt DNSSEC NSEC zone walking to enumerate domain names in a signed zone. " +
|
|
396
|
+
"NSEC records contain the 'next' domain name in the zone, allowing complete enumeration. " +
|
|
397
|
+
"NSEC3 uses hashed names to mitigate this, which is detected and reported.",
|
|
398
|
+
schema: {
|
|
399
|
+
domain: z.string().describe("The DNSSEC-signed domain to walk (e.g. 'example.com')"),
|
|
400
|
+
limit: z
|
|
401
|
+
.number()
|
|
402
|
+
.optional()
|
|
403
|
+
.describe("Maximum number of names to enumerate (default: 100)"),
|
|
404
|
+
},
|
|
405
|
+
async execute(args) {
|
|
406
|
+
const domain = args.domain;
|
|
407
|
+
const limit = args.limit ?? 100;
|
|
408
|
+
if (!isValidDomain(domain)) {
|
|
409
|
+
return text(`Invalid domain: ${domain}`);
|
|
410
|
+
}
|
|
411
|
+
try {
|
|
412
|
+
// First, determine which NSEC variant is in use
|
|
413
|
+
// Query for a name that likely doesn't exist to trigger NSEC/NSEC3 in authority
|
|
414
|
+
const probeResult = await queryRaw(`_nsec_walk_probe_${Date.now()}.${domain}`, "A", "8.8.8.8", 53, {
|
|
415
|
+
rd: true,
|
|
416
|
+
dnssec: true,
|
|
417
|
+
});
|
|
418
|
+
// Check authority section for NSEC3 vs NSEC
|
|
419
|
+
const nsec3Records = probeResult.authorities.filter((a) => a.type === "NSEC3" || a.type === "NSEC3PARAM");
|
|
420
|
+
const nsecRecords = probeResult.authorities.filter((a) => a.type === "NSEC");
|
|
421
|
+
if (nsec3Records.length > 0) {
|
|
422
|
+
return json({
|
|
423
|
+
domain,
|
|
424
|
+
nsec_type: "NSEC3",
|
|
425
|
+
zone_walkable: false,
|
|
426
|
+
nsec3_records: nsec3Records.map((r) => ({
|
|
427
|
+
name: r.name,
|
|
428
|
+
type: r.type,
|
|
429
|
+
data: r.data,
|
|
430
|
+
})),
|
|
431
|
+
message: "NSEC3 is in use — zone walking is mitigated. NSEC3 hashes domain names, preventing direct enumeration. " +
|
|
432
|
+
"However, offline dictionary attacks against NSEC3 hashes may still be possible depending on the hash iterations and salt.",
|
|
433
|
+
security_assessment: {
|
|
434
|
+
status: "PROTECTED",
|
|
435
|
+
detail: "Zone uses NSEC3, which prevents trivial zone enumeration via NSEC walking.",
|
|
436
|
+
},
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
if (nsecRecords.length === 0) {
|
|
440
|
+
return json({
|
|
441
|
+
domain,
|
|
442
|
+
nsec_type: "NONE",
|
|
443
|
+
zone_walkable: false,
|
|
444
|
+
message: "No NSEC or NSEC3 records found. The zone may not be DNSSEC-signed, " +
|
|
445
|
+
"or the resolver is stripping DNSSEC records.",
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
// NSEC zone walking
|
|
449
|
+
const enumerated = [];
|
|
450
|
+
let currentName = domain;
|
|
451
|
+
const visited = new Set();
|
|
452
|
+
for (let i = 0; i < limit; i++) {
|
|
453
|
+
if (visited.has(currentName))
|
|
454
|
+
break;
|
|
455
|
+
visited.add(currentName);
|
|
456
|
+
try {
|
|
457
|
+
const nsecResult = await queryRaw(currentName, "NSEC", "8.8.8.8", 53, {
|
|
458
|
+
rd: true,
|
|
459
|
+
dnssec: true,
|
|
460
|
+
});
|
|
461
|
+
// Look for NSEC in answers and authorities
|
|
462
|
+
const allNsec = [...nsecResult.answers, ...nsecResult.authorities].filter((a) => a.type === "NSEC");
|
|
463
|
+
if (allNsec.length === 0)
|
|
464
|
+
break;
|
|
465
|
+
for (const nsec of allNsec) {
|
|
466
|
+
const nsecData = nsec;
|
|
467
|
+
const nextDomain = nsecData.data?.nextDomain ?? nsecData.data;
|
|
468
|
+
if (typeof nextDomain === "string" && nextDomain.endsWith(`.${domain}`) || nextDomain === domain) {
|
|
469
|
+
if (!visited.has(nextDomain)) {
|
|
470
|
+
enumerated.push(nextDomain);
|
|
471
|
+
currentName = nextDomain;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
// If we didn't advance, try the first NSEC's next domain
|
|
476
|
+
const firstNsec = allNsec[0];
|
|
477
|
+
const next = firstNsec.data?.nextDomain ?? firstNsec.data;
|
|
478
|
+
if (typeof next === "string" && next !== currentName) {
|
|
479
|
+
if (!visited.has(next)) {
|
|
480
|
+
enumerated.push(next);
|
|
481
|
+
}
|
|
482
|
+
// If next domain wraps back to zone apex, we've completed the walk
|
|
483
|
+
if (next === domain || !next.endsWith(`.${domain}`))
|
|
484
|
+
break;
|
|
485
|
+
currentName = next;
|
|
486
|
+
}
|
|
487
|
+
else {
|
|
488
|
+
break;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
catch {
|
|
492
|
+
break;
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
return json({
|
|
496
|
+
domain,
|
|
497
|
+
nsec_type: "NSEC",
|
|
498
|
+
zone_walkable: true,
|
|
499
|
+
names_found: enumerated.length,
|
|
500
|
+
enumerated_names: enumerated,
|
|
501
|
+
security_assessment: {
|
|
502
|
+
status: "VULNERABLE",
|
|
503
|
+
severity: "medium",
|
|
504
|
+
message: "Zone uses NSEC records, allowing complete zone enumeration via NSEC walking. " +
|
|
505
|
+
"An attacker can discover all domain names in the zone.",
|
|
506
|
+
remediation: "Migrate from NSEC to NSEC3 to prevent zone walking. " +
|
|
507
|
+
"Use 'nsec3param 1 0 10 <salt>' in your DNSSEC configuration.",
|
|
508
|
+
},
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
catch (err) {
|
|
512
|
+
return text(`NSEC walk failed: ${err.message}`);
|
|
513
|
+
}
|
|
514
|
+
},
|
|
515
|
+
};
|
|
516
|
+
// ─── Tool 7: dns_wildcard_detect ───
|
|
517
|
+
const dnsWildcardDetect = {
|
|
518
|
+
name: "dns_wildcard_detect",
|
|
519
|
+
description: "Detect wildcard DNS configurations by resolving multiple random non-existent subdomains. " +
|
|
520
|
+
"If all random names resolve to the same IP, a wildcard record (*.domain) is in place. " +
|
|
521
|
+
"Wildcard DNS can affect subdomain enumeration accuracy and security assessments.",
|
|
522
|
+
schema: {
|
|
523
|
+
domain: z.string().describe("The domain to test for wildcard DNS (e.g. 'example.com')"),
|
|
524
|
+
samples: z
|
|
525
|
+
.number()
|
|
526
|
+
.optional()
|
|
527
|
+
.describe("Number of random subdomains to test (default: 10)"),
|
|
528
|
+
},
|
|
529
|
+
async execute(args) {
|
|
530
|
+
const domain = args.domain;
|
|
531
|
+
const samples = args.samples ?? 10;
|
|
532
|
+
if (!isValidDomain(domain)) {
|
|
533
|
+
return text(`Invalid domain: ${domain}`);
|
|
534
|
+
}
|
|
535
|
+
try {
|
|
536
|
+
const resolver = createResolver();
|
|
537
|
+
const randomNames = [];
|
|
538
|
+
const resolvedIps = new Map();
|
|
539
|
+
let resolveCount = 0;
|
|
540
|
+
let nxdomainCount = 0;
|
|
541
|
+
// Generate random subdomain labels
|
|
542
|
+
for (let i = 0; i < samples; i++) {
|
|
543
|
+
const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
|
|
544
|
+
let label = "";
|
|
545
|
+
for (let j = 0; j < 12; j++) {
|
|
546
|
+
label += chars[Math.floor(Math.random() * chars.length)];
|
|
547
|
+
}
|
|
548
|
+
randomNames.push(`${label}.${domain}`);
|
|
549
|
+
}
|
|
550
|
+
// Resolve all random names
|
|
551
|
+
const results = await Promise.allSettled(randomNames.map(async (name) => {
|
|
552
|
+
try {
|
|
553
|
+
const ips = await resolver.resolve4(name);
|
|
554
|
+
return { name, ips, resolved: true };
|
|
555
|
+
}
|
|
556
|
+
catch {
|
|
557
|
+
return { name, ips: [], resolved: false };
|
|
558
|
+
}
|
|
559
|
+
}));
|
|
560
|
+
const ipCounts = new Map();
|
|
561
|
+
for (const r of results) {
|
|
562
|
+
if (r.status === "fulfilled") {
|
|
563
|
+
if (r.value.resolved && r.value.ips.length > 0) {
|
|
564
|
+
resolveCount++;
|
|
565
|
+
resolvedIps.set(r.value.name, r.value.ips);
|
|
566
|
+
for (const ip of r.value.ips) {
|
|
567
|
+
ipCounts.set(ip, (ipCounts.get(ip) ?? 0) + 1);
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
else {
|
|
571
|
+
nxdomainCount++;
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
const wildcardDetected = resolveCount >= Math.floor(samples * 0.8);
|
|
576
|
+
// Find the most common IP
|
|
577
|
+
let wildcardIp = null;
|
|
578
|
+
let maxCount = 0;
|
|
579
|
+
ipCounts.forEach((count, ip) => {
|
|
580
|
+
if (count > maxCount) {
|
|
581
|
+
maxCount = count;
|
|
582
|
+
wildcardIp = ip;
|
|
583
|
+
}
|
|
584
|
+
});
|
|
585
|
+
return json({
|
|
586
|
+
domain,
|
|
587
|
+
samples_tested: samples,
|
|
588
|
+
resolved_count: resolveCount,
|
|
589
|
+
nxdomain_count: nxdomainCount,
|
|
590
|
+
wildcard_detected: wildcardDetected,
|
|
591
|
+
wildcard_ip: wildcardDetected ? wildcardIp : null,
|
|
592
|
+
ip_distribution: Object.fromEntries(ipCounts),
|
|
593
|
+
analysis: wildcardDetected
|
|
594
|
+
? `Wildcard DNS detected for *.${domain} — random subdomains resolve to ${wildcardIp}. ` +
|
|
595
|
+
"This means ANY subdomain will resolve, which can mask subdomain enumeration results."
|
|
596
|
+
: `No wildcard DNS detected for ${domain}. Random subdomains returned NXDOMAIN as expected.`,
|
|
597
|
+
impact: wildcardDetected
|
|
598
|
+
? [
|
|
599
|
+
"Subdomain brute-force results will contain false positives",
|
|
600
|
+
"Content-based verification needed to distinguish real subdomains",
|
|
601
|
+
"May be used for catch-all web hosting or email",
|
|
602
|
+
]
|
|
603
|
+
: [],
|
|
604
|
+
});
|
|
605
|
+
}
|
|
606
|
+
catch (err) {
|
|
607
|
+
return text(`Wildcard detection failed: ${err.message}`);
|
|
608
|
+
}
|
|
609
|
+
},
|
|
610
|
+
};
|
|
611
|
+
// ─── Tool 8: dns_server_fingerprint ───
|
|
612
|
+
const dnsServerFingerprint = {
|
|
613
|
+
name: "dns_server_fingerprint",
|
|
614
|
+
description: "Fingerprint a DNS server by querying CHAOS class TXT records (version.bind, version.server, hostname.bind, id.server). " +
|
|
615
|
+
"These records can reveal the DNS software type and version (BIND, PowerDNS, Unbound, Knot, dnsmasq, Windows DNS).",
|
|
616
|
+
schema: {
|
|
617
|
+
nameserver: z.string().describe("The DNS server IP or hostname to fingerprint (e.g. '8.8.8.8')"),
|
|
618
|
+
},
|
|
619
|
+
async execute(args) {
|
|
620
|
+
let nameserver = args.nameserver;
|
|
621
|
+
// Resolve hostname to IP if needed
|
|
622
|
+
if (!nameserver.match(/^\d+\.\d+\.\d+\.\d+$/)) {
|
|
623
|
+
try {
|
|
624
|
+
const resolver = createResolver();
|
|
625
|
+
const ips = await resolver.resolve4(nameserver);
|
|
626
|
+
nameserver = ips[0];
|
|
627
|
+
}
|
|
628
|
+
catch {
|
|
629
|
+
return text(`Cannot resolve nameserver hostname: ${nameserver}`);
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
const chaosQueries = [
|
|
633
|
+
{ name: "version.bind", label: "version_bind" },
|
|
634
|
+
{ name: "version.server", label: "version_server" },
|
|
635
|
+
{ name: "hostname.bind", label: "hostname_bind" },
|
|
636
|
+
{ name: "id.server", label: "id_server" },
|
|
637
|
+
];
|
|
638
|
+
const results = {};
|
|
639
|
+
// Send CHAOS class TXT queries using dgram + dns-packet
|
|
640
|
+
for (const query of chaosQueries) {
|
|
641
|
+
try {
|
|
642
|
+
const answer = await sendChaosQuery(query.name, nameserver);
|
|
643
|
+
results[query.label] = answer;
|
|
644
|
+
}
|
|
645
|
+
catch {
|
|
646
|
+
results[query.label] = null;
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
// Identify DNS software from responses
|
|
650
|
+
const versionStr = results.version_bind ?? results.version_server ?? "";
|
|
651
|
+
const softwareGuess = identifyDnsSoftware(versionStr);
|
|
652
|
+
// Also do behavioral fingerprinting: check for recursion support
|
|
653
|
+
let recursionAvailable = false;
|
|
654
|
+
try {
|
|
655
|
+
const probeResult = await queryRaw("example.com", "A", nameserver, 53, { rd: true });
|
|
656
|
+
recursionAvailable = probeResult.flags.recursionAvailable;
|
|
657
|
+
}
|
|
658
|
+
catch {
|
|
659
|
+
// Ignore
|
|
660
|
+
}
|
|
661
|
+
return json({
|
|
662
|
+
nameserver,
|
|
663
|
+
chaos_records: results,
|
|
664
|
+
identified_software: softwareGuess,
|
|
665
|
+
recursion_available: recursionAvailable,
|
|
666
|
+
analysis: versionStr
|
|
667
|
+
? `DNS server at ${nameserver} identified as: ${softwareGuess.software ?? "Unknown"} (version: ${versionStr})`
|
|
668
|
+
: `DNS server at ${nameserver} did not respond to CHAOS queries. Version information is hidden.`,
|
|
669
|
+
security_note: "Exposing DNS version information helps attackers identify known vulnerabilities. " +
|
|
670
|
+
"Best practice: disable version.bind/version.server responses or set them to a generic value.",
|
|
671
|
+
});
|
|
672
|
+
},
|
|
673
|
+
};
|
|
674
|
+
/** Send a CHAOS class TXT query using raw UDP socket + dns-packet */
|
|
675
|
+
async function sendChaosQuery(name, server, port = 53) {
|
|
676
|
+
// dns-packet doesn't directly support CHAOS class in the high-level API,
|
|
677
|
+
// so we build the packet manually
|
|
678
|
+
const id = Math.floor(Math.random() * 65535);
|
|
679
|
+
// Build a DNS query packet manually with CHAOS class (class=3)
|
|
680
|
+
const questionBuf = encodeChaosQuestion(id, name);
|
|
681
|
+
return new Promise((resolve, reject) => {
|
|
682
|
+
const socket = dgram.createSocket("udp4");
|
|
683
|
+
const timer = setTimeout(() => {
|
|
684
|
+
socket.close();
|
|
685
|
+
resolve(null);
|
|
686
|
+
}, 5000);
|
|
687
|
+
socket.on("message", (msg) => {
|
|
688
|
+
clearTimeout(timer);
|
|
689
|
+
socket.close();
|
|
690
|
+
try {
|
|
691
|
+
// Parse the response — dns-packet can decode it even with CHAOS class
|
|
692
|
+
const res = dnsPacket.decode(msg);
|
|
693
|
+
const txtAnswers = (res.answers ?? []).filter((a) => a.type === "TXT");
|
|
694
|
+
if (txtAnswers.length > 0) {
|
|
695
|
+
const data = txtAnswers[0].data;
|
|
696
|
+
if (Buffer.isBuffer(data)) {
|
|
697
|
+
resolve(data.toString("utf-8"));
|
|
698
|
+
}
|
|
699
|
+
else if (Array.isArray(data)) {
|
|
700
|
+
resolve(data.map((d) => (Buffer.isBuffer(d) ? d.toString("utf-8") : String(d))).join(""));
|
|
701
|
+
}
|
|
702
|
+
else if (typeof data === "string") {
|
|
703
|
+
resolve(data);
|
|
704
|
+
}
|
|
705
|
+
else {
|
|
706
|
+
resolve(JSON.stringify(data));
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
else {
|
|
710
|
+
resolve(null);
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
catch {
|
|
714
|
+
resolve(null);
|
|
715
|
+
}
|
|
716
|
+
});
|
|
717
|
+
socket.on("error", () => {
|
|
718
|
+
clearTimeout(timer);
|
|
719
|
+
socket.close();
|
|
720
|
+
resolve(null);
|
|
721
|
+
});
|
|
722
|
+
socket.send(questionBuf, 0, questionBuf.length, port, server);
|
|
723
|
+
});
|
|
724
|
+
}
|
|
725
|
+
/** Manually encode a DNS query with CHAOS class (CH=3) since dns-packet defaults to IN */
|
|
726
|
+
function encodeChaosQuestion(id, name) {
|
|
727
|
+
// Encode the domain name in DNS wire format
|
|
728
|
+
const labels = name.split(".");
|
|
729
|
+
const nameParts = [];
|
|
730
|
+
for (const label of labels) {
|
|
731
|
+
nameParts.push(label.length);
|
|
732
|
+
for (let i = 0; i < label.length; i++) {
|
|
733
|
+
nameParts.push(label.charCodeAt(i));
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
nameParts.push(0); // Root label
|
|
737
|
+
// DNS header: 12 bytes
|
|
738
|
+
const header = Buffer.alloc(12);
|
|
739
|
+
header.writeUInt16BE(id, 0); // ID
|
|
740
|
+
header.writeUInt16BE(0x0100, 2); // Flags: RD=1 (standard recursive query)
|
|
741
|
+
header.writeUInt16BE(1, 4); // QDCOUNT = 1
|
|
742
|
+
header.writeUInt16BE(0, 6); // ANCOUNT = 0
|
|
743
|
+
header.writeUInt16BE(0, 8); // NSCOUNT = 0
|
|
744
|
+
header.writeUInt16BE(0, 10); // ARCOUNT = 0
|
|
745
|
+
// Question section: name + QTYPE(TXT=16) + QCLASS(CH=3)
|
|
746
|
+
const questionSuffix = Buffer.alloc(4);
|
|
747
|
+
questionSuffix.writeUInt16BE(16, 0); // QTYPE = TXT
|
|
748
|
+
questionSuffix.writeUInt16BE(3, 2); // QCLASS = CH (CHAOS)
|
|
749
|
+
return Buffer.concat([header, Buffer.from(nameParts), questionSuffix]);
|
|
750
|
+
}
|
|
751
|
+
/** Identify DNS software from version string */
|
|
752
|
+
function identifyDnsSoftware(version) {
|
|
753
|
+
if (!version)
|
|
754
|
+
return { software: null, confidence: "none" };
|
|
755
|
+
const v = version.toLowerCase();
|
|
756
|
+
if (v.includes("bind") || v.includes("isc")) {
|
|
757
|
+
return { software: `ISC BIND (${version})`, confidence: "high" };
|
|
758
|
+
}
|
|
759
|
+
if (v.includes("powerdns") || v.includes("pdns")) {
|
|
760
|
+
return { software: `PowerDNS (${version})`, confidence: "high" };
|
|
761
|
+
}
|
|
762
|
+
if (v.includes("unbound")) {
|
|
763
|
+
return { software: `Unbound (${version})`, confidence: "high" };
|
|
764
|
+
}
|
|
765
|
+
if (v.includes("knot")) {
|
|
766
|
+
return { software: `Knot DNS (${version})`, confidence: "high" };
|
|
767
|
+
}
|
|
768
|
+
if (v.includes("dnsmasq")) {
|
|
769
|
+
return { software: `dnsmasq (${version})`, confidence: "high" };
|
|
770
|
+
}
|
|
771
|
+
if (v.includes("microsoft") || v.includes("windows")) {
|
|
772
|
+
return { software: `Microsoft DNS (${version})`, confidence: "high" };
|
|
773
|
+
}
|
|
774
|
+
if (v.includes("nsd")) {
|
|
775
|
+
return { software: `NSD (${version})`, confidence: "high" };
|
|
776
|
+
}
|
|
777
|
+
if (v.includes("maradns")) {
|
|
778
|
+
return { software: `MaraDNS (${version})`, confidence: "high" };
|
|
779
|
+
}
|
|
780
|
+
if (v.includes("coredns")) {
|
|
781
|
+
return { software: `CoreDNS (${version})`, confidence: "high" };
|
|
782
|
+
}
|
|
783
|
+
return { software: `Unknown (${version})`, confidence: "low" };
|
|
784
|
+
}
|
|
785
|
+
// ─── Tool 9: dns_recursive_check ───
|
|
786
|
+
const dnsRecursiveCheck = {
|
|
787
|
+
name: "dns_recursive_check",
|
|
788
|
+
description: "Test whether a DNS nameserver is an open recursive resolver by sending a recursive query (RD=1) for an external domain. " +
|
|
789
|
+
"Open recursive resolvers are a security risk — they can be abused for DNS amplification DDoS attacks " +
|
|
790
|
+
"and cache poisoning.",
|
|
791
|
+
schema: {
|
|
792
|
+
nameserver: z.string().describe("The DNS nameserver IP to test for open recursion (e.g. '192.168.1.1')"),
|
|
793
|
+
},
|
|
794
|
+
async execute(args) {
|
|
795
|
+
const nameserver = args.nameserver;
|
|
796
|
+
// Use a well-known external domain to test recursion
|
|
797
|
+
const testDomains = ["www.google.com", "www.cloudflare.com", "www.example.org"];
|
|
798
|
+
const results = [];
|
|
799
|
+
for (const testDomain of testDomains) {
|
|
800
|
+
try {
|
|
801
|
+
const result = await queryRaw(testDomain, "A", nameserver, 53, { rd: true });
|
|
802
|
+
results.push({
|
|
803
|
+
domain: testDomain,
|
|
804
|
+
resolved: result.answers.length > 0,
|
|
805
|
+
answers: result.answers.length,
|
|
806
|
+
recursion_available: result.flags.recursionAvailable,
|
|
807
|
+
});
|
|
808
|
+
}
|
|
809
|
+
catch {
|
|
810
|
+
results.push({
|
|
811
|
+
domain: testDomain,
|
|
812
|
+
resolved: false,
|
|
813
|
+
answers: 0,
|
|
814
|
+
recursion_available: false,
|
|
815
|
+
});
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
const isOpenRecursive = results.some((r) => r.resolved && r.recursion_available);
|
|
819
|
+
const allRecursive = results.every((r) => r.resolved && r.recursion_available);
|
|
820
|
+
return json({
|
|
821
|
+
nameserver,
|
|
822
|
+
is_open_recursive: isOpenRecursive,
|
|
823
|
+
test_results: results,
|
|
824
|
+
security_assessment: isOpenRecursive
|
|
825
|
+
? {
|
|
826
|
+
status: "HIGH_RISK",
|
|
827
|
+
severity: "high",
|
|
828
|
+
message: allRecursive
|
|
829
|
+
? `Nameserver ${nameserver} is an OPEN RECURSIVE RESOLVER. It resolved all test domains with recursion. ` +
|
|
830
|
+
"This server can be abused for DNS amplification attacks and may be susceptible to cache poisoning."
|
|
831
|
+
: `Nameserver ${nameserver} appears to allow recursion for some domains. Partial open recursion detected.`,
|
|
832
|
+
risks: [
|
|
833
|
+
"DNS amplification DDoS attacks (response is 28-54x larger than query)",
|
|
834
|
+
"DNS cache poisoning",
|
|
835
|
+
"Information leakage via cache snooping",
|
|
836
|
+
"Resource consumption from external queries",
|
|
837
|
+
],
|
|
838
|
+
remediation: [
|
|
839
|
+
"Restrict recursion to trusted client IP ranges only",
|
|
840
|
+
"Use 'allow-recursion' ACLs in BIND",
|
|
841
|
+
"Enable Response Rate Limiting (RRL)",
|
|
842
|
+
"Consider deploying a dedicated recursive resolver for internal use only",
|
|
843
|
+
],
|
|
844
|
+
}
|
|
845
|
+
: {
|
|
846
|
+
status: "SECURE",
|
|
847
|
+
message: `Nameserver ${nameserver} does not appear to be an open recursive resolver. Recursion is properly restricted.`,
|
|
848
|
+
},
|
|
849
|
+
});
|
|
850
|
+
},
|
|
851
|
+
};
|
|
852
|
+
// ─── Tool 10: dns_propagation ───
|
|
853
|
+
const dnsPropagation = {
|
|
854
|
+
name: "dns_propagation",
|
|
855
|
+
description: "Check DNS propagation by querying 20+ globally distributed public resolvers. " +
|
|
856
|
+
"Reports per-resolver results and a consistency check to detect incomplete propagation " +
|
|
857
|
+
"or geo-based DNS differences.",
|
|
858
|
+
schema: {
|
|
859
|
+
domain: z.string().describe("The domain name to check propagation for (e.g. 'example.com')"),
|
|
860
|
+
type: z
|
|
861
|
+
.string()
|
|
862
|
+
.optional()
|
|
863
|
+
.describe("DNS record type to query (default: 'A'). Supports A, AAAA, MX, TXT, NS, CNAME."),
|
|
864
|
+
},
|
|
865
|
+
async execute(args) {
|
|
866
|
+
const domain = args.domain;
|
|
867
|
+
const type = args.type ?? "A";
|
|
868
|
+
if (!isValidDomain(domain)) {
|
|
869
|
+
return text(`Invalid domain: ${domain}`);
|
|
870
|
+
}
|
|
871
|
+
const resolverResults = await Promise.allSettled(PUBLIC_RESOLVERS.map(async (r) => {
|
|
872
|
+
try {
|
|
873
|
+
const resolver = createResolver(r.ip);
|
|
874
|
+
let data = [];
|
|
875
|
+
switch (type.toUpperCase()) {
|
|
876
|
+
case "A": {
|
|
877
|
+
data = await resolver.resolve4(domain);
|
|
878
|
+
break;
|
|
879
|
+
}
|
|
880
|
+
case "AAAA": {
|
|
881
|
+
data = await resolver.resolve6(domain);
|
|
882
|
+
break;
|
|
883
|
+
}
|
|
884
|
+
case "MX": {
|
|
885
|
+
const mx = await resolver.resolveMx(domain);
|
|
886
|
+
data = mx.map((m) => `${m.priority} ${m.exchange}`);
|
|
887
|
+
break;
|
|
888
|
+
}
|
|
889
|
+
case "TXT": {
|
|
890
|
+
const txt = await resolver.resolveTxt(domain);
|
|
891
|
+
data = txt.map((t) => t.join(""));
|
|
892
|
+
break;
|
|
893
|
+
}
|
|
894
|
+
case "NS": {
|
|
895
|
+
data = await resolver.resolveNs(domain);
|
|
896
|
+
break;
|
|
897
|
+
}
|
|
898
|
+
case "CNAME": {
|
|
899
|
+
data = await resolver.resolveCname(domain);
|
|
900
|
+
break;
|
|
901
|
+
}
|
|
902
|
+
default: {
|
|
903
|
+
data = await resolver.resolve4(domain);
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
return {
|
|
907
|
+
resolver: r.name,
|
|
908
|
+
ip: r.ip,
|
|
909
|
+
location: r.location,
|
|
910
|
+
status: "ok",
|
|
911
|
+
results: data.sort(),
|
|
912
|
+
};
|
|
913
|
+
}
|
|
914
|
+
catch (err) {
|
|
915
|
+
return {
|
|
916
|
+
resolver: r.name,
|
|
917
|
+
ip: r.ip,
|
|
918
|
+
location: r.location,
|
|
919
|
+
status: "error",
|
|
920
|
+
results: [],
|
|
921
|
+
error: err.message,
|
|
922
|
+
};
|
|
923
|
+
}
|
|
924
|
+
}));
|
|
925
|
+
const results = resolverResults
|
|
926
|
+
.filter((r) => r.status === "fulfilled")
|
|
927
|
+
.map((r) => r.value);
|
|
928
|
+
// Consistency analysis
|
|
929
|
+
const successResults = results.filter((r) => r.status === "ok" && r.results.length > 0);
|
|
930
|
+
const failedResults = results.filter((r) => r.status === "error" || r.results.length === 0);
|
|
931
|
+
// Group by unique response
|
|
932
|
+
const responseGroups = new Map();
|
|
933
|
+
for (const r of successResults) {
|
|
934
|
+
const key = JSON.stringify(r.results);
|
|
935
|
+
if (!responseGroups.has(key))
|
|
936
|
+
responseGroups.set(key, []);
|
|
937
|
+
responseGroups.get(key).push(r.resolver);
|
|
938
|
+
}
|
|
939
|
+
const isConsistent = responseGroups.size <= 1;
|
|
940
|
+
const propagationPercent = successResults.length > 0
|
|
941
|
+
? Math.round((successResults.length / results.length) * 100)
|
|
942
|
+
: 0;
|
|
943
|
+
return json({
|
|
944
|
+
domain,
|
|
945
|
+
record_type: type.toUpperCase(),
|
|
946
|
+
resolvers_queried: results.length,
|
|
947
|
+
successful: successResults.length,
|
|
948
|
+
failed: failedResults.length,
|
|
949
|
+
propagation_percent: propagationPercent,
|
|
950
|
+
consistent: isConsistent,
|
|
951
|
+
unique_responses: responseGroups.size,
|
|
952
|
+
response_groups: Array.from(responseGroups.entries()).map(([response, resolvers]) => ({
|
|
953
|
+
values: JSON.parse(response),
|
|
954
|
+
resolvers,
|
|
955
|
+
count: resolvers.length,
|
|
956
|
+
})),
|
|
957
|
+
per_resolver: results,
|
|
958
|
+
analysis: isConsistent
|
|
959
|
+
? `DNS propagation is complete and consistent. All ${successResults.length} resolvers return the same result.`
|
|
960
|
+
: `DNS propagation is INCONSISTENT. ${responseGroups.size} different responses detected across resolvers. ` +
|
|
961
|
+
"This may indicate ongoing propagation, geo-based DNS, or CDN-based responses.",
|
|
962
|
+
});
|
|
963
|
+
},
|
|
964
|
+
};
|
|
965
|
+
// ─── Tool 11: dns_split_horizon ───
|
|
966
|
+
const dnsSplitHorizon = {
|
|
967
|
+
name: "dns_split_horizon",
|
|
968
|
+
description: "Detect split-horizon (split-brain) DNS configurations by comparing responses from multiple external resolvers " +
|
|
969
|
+
"and an optional internal resolver. Split-horizon DNS returns different answers based on the source of the query, " +
|
|
970
|
+
"commonly used to serve internal IPs to corporate networks and external IPs to the internet.",
|
|
971
|
+
schema: {
|
|
972
|
+
domain: z.string().describe("The domain to test for split-horizon DNS (e.g. 'internal.company.com')"),
|
|
973
|
+
internal_resolver: z
|
|
974
|
+
.string()
|
|
975
|
+
.optional()
|
|
976
|
+
.describe("Internal/corporate DNS resolver IP to compare against external resolvers (e.g. '10.0.0.1')"),
|
|
977
|
+
},
|
|
978
|
+
async execute(args) {
|
|
979
|
+
const domain = args.domain;
|
|
980
|
+
const internalResolver = args.internal_resolver;
|
|
981
|
+
if (!isValidDomain(domain)) {
|
|
982
|
+
return text(`Invalid domain: ${domain}`);
|
|
983
|
+
}
|
|
984
|
+
// Select a subset of external resolvers for comparison
|
|
985
|
+
const externalResolvers = PUBLIC_RESOLVERS.slice(0, 6);
|
|
986
|
+
const allResolvers = [
|
|
987
|
+
...externalResolvers.map((r) => ({ name: r.name, ip: r.ip, type: "external" })),
|
|
988
|
+
];
|
|
989
|
+
if (internalResolver) {
|
|
990
|
+
allResolvers.push({ name: "Internal", ip: internalResolver, type: "internal" });
|
|
991
|
+
}
|
|
992
|
+
// Query all resolvers for A and AAAA records
|
|
993
|
+
const queryResults = await Promise.allSettled(allResolvers.map(async (r) => {
|
|
994
|
+
const resolver = createResolver(r.ip);
|
|
995
|
+
const aRecords = await resolver.resolve4(domain).catch(() => []);
|
|
996
|
+
const aaaaRecords = await resolver.resolve6(domain).catch(() => []);
|
|
997
|
+
return {
|
|
998
|
+
resolver_name: r.name,
|
|
999
|
+
resolver_ip: r.ip,
|
|
1000
|
+
resolver_type: r.type,
|
|
1001
|
+
a_records: aRecords.sort(),
|
|
1002
|
+
aaaa_records: aaaaRecords.sort(),
|
|
1003
|
+
};
|
|
1004
|
+
}));
|
|
1005
|
+
const results = queryResults
|
|
1006
|
+
.filter((r) => r.status === "fulfilled")
|
|
1007
|
+
.map((r) => r.value);
|
|
1008
|
+
// Analyze for split-horizon
|
|
1009
|
+
const externalResults = results.filter((r) => r.resolver_type === "external");
|
|
1010
|
+
const internalResults = results.filter((r) => r.resolver_type === "internal");
|
|
1011
|
+
// Check consistency among external resolvers
|
|
1012
|
+
const externalAResponses = new Set(externalResults.map((r) => JSON.stringify(r.a_records)));
|
|
1013
|
+
const externalConsistent = externalAResponses.size <= 1;
|
|
1014
|
+
// Check for split-horizon between internal and external
|
|
1015
|
+
let splitHorizonDetected = false;
|
|
1016
|
+
let splitDetails = null;
|
|
1017
|
+
if (internalResults.length > 0 && externalResults.length > 0) {
|
|
1018
|
+
const internalA = JSON.stringify(internalResults[0].a_records);
|
|
1019
|
+
const externalA = JSON.stringify(externalResults[0].a_records);
|
|
1020
|
+
if (internalA !== externalA && internalResults[0].a_records.length > 0) {
|
|
1021
|
+
splitHorizonDetected = true;
|
|
1022
|
+
splitDetails = {
|
|
1023
|
+
internal_ips: internalResults[0].a_records,
|
|
1024
|
+
external_ips: externalResults[0].a_records,
|
|
1025
|
+
private_ips_internal: internalResults[0].a_records.filter((ip) => ip.startsWith("10.") || ip.startsWith("172.") || ip.startsWith("192.168.")),
|
|
1026
|
+
};
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
// Even without internal resolver, detect CDN/geo-based differences
|
|
1030
|
+
const geoDifferences = !externalConsistent;
|
|
1031
|
+
return json({
|
|
1032
|
+
domain,
|
|
1033
|
+
internal_resolver: internalResolver ?? "not specified",
|
|
1034
|
+
split_horizon_detected: splitHorizonDetected,
|
|
1035
|
+
geo_differences_detected: geoDifferences,
|
|
1036
|
+
split_details: splitDetails,
|
|
1037
|
+
resolver_results: results,
|
|
1038
|
+
analysis: splitHorizonDetected
|
|
1039
|
+
? `Split-horizon DNS DETECTED for ${domain}. Internal resolver returns different IPs than external resolvers. ` +
|
|
1040
|
+
(splitDetails?.private_ips_internal?.length > 0
|
|
1041
|
+
? "Internal responses contain RFC1918 private IP addresses, confirming a split-DNS configuration."
|
|
1042
|
+
: "The internal and external responses differ, suggesting split-DNS or view-based DNS.")
|
|
1043
|
+
: geoDifferences
|
|
1044
|
+
? `No split-horizon detected, but external resolvers return different results. ` +
|
|
1045
|
+
"This may indicate CDN-based or GeoDNS responses."
|
|
1046
|
+
: `No split-horizon DNS detected for ${domain}. All resolvers return consistent results.` +
|
|
1047
|
+
(internalResolver ? "" : " Tip: provide an internal_resolver for a more thorough check."),
|
|
1048
|
+
});
|
|
1049
|
+
},
|
|
1050
|
+
};
|
|
1051
|
+
// ─── Tool 12: dns_ttl_analysis ───
|
|
1052
|
+
const dnsTtlAnalysis = {
|
|
1053
|
+
name: "dns_ttl_analysis",
|
|
1054
|
+
description: "Analyze DNS TTL (Time-To-Live) values across all record types for a domain. " +
|
|
1055
|
+
"Flags potential security and operational issues: TTL < 60s (fast-flux indicator, common in malware C2), " +
|
|
1056
|
+
"TTL > 86400s (stale cache risk during incident response), and inconsistent TTLs across record types.",
|
|
1057
|
+
schema: {
|
|
1058
|
+
domain: z.string().describe("The domain to analyze TTL values for (e.g. 'example.com')"),
|
|
1059
|
+
},
|
|
1060
|
+
async execute(args) {
|
|
1061
|
+
const domain = args.domain;
|
|
1062
|
+
if (!isValidDomain(domain)) {
|
|
1063
|
+
return text(`Invalid domain: ${domain}`);
|
|
1064
|
+
}
|
|
1065
|
+
try {
|
|
1066
|
+
const records = await resolveAll(domain);
|
|
1067
|
+
if (records.length === 0) {
|
|
1068
|
+
return json({ domain, error: "No DNS records found." });
|
|
1069
|
+
}
|
|
1070
|
+
// Analyze TTLs by type
|
|
1071
|
+
const byType = {};
|
|
1072
|
+
for (const r of records) {
|
|
1073
|
+
if (!byType[r.type])
|
|
1074
|
+
byType[r.type] = { ttls: [], records: [] };
|
|
1075
|
+
byType[r.type].ttls.push(r.ttl);
|
|
1076
|
+
byType[r.type].records.push({ data: r.data, ttl: r.ttl });
|
|
1077
|
+
}
|
|
1078
|
+
const findings = [];
|
|
1079
|
+
const allTtls = records.filter((r) => r.ttl > 0).map((r) => r.ttl);
|
|
1080
|
+
const minTtl = allTtls.length > 0 ? Math.min(...allTtls) : 0;
|
|
1081
|
+
const maxTtl = allTtls.length > 0 ? Math.max(...allTtls) : 0;
|
|
1082
|
+
const avgTtl = allTtls.length > 0 ? Math.round(allTtls.reduce((a, b) => a + b, 0) / allTtls.length) : 0;
|
|
1083
|
+
// Check for fast-flux indicators (TTL < 60)
|
|
1084
|
+
const fastFluxRecords = records.filter((r) => r.ttl > 0 && r.ttl < 60);
|
|
1085
|
+
if (fastFluxRecords.length > 0) {
|
|
1086
|
+
findings.push({
|
|
1087
|
+
severity: "high",
|
|
1088
|
+
title: "Very low TTL detected (possible fast-flux)",
|
|
1089
|
+
description: `${fastFluxRecords.length} record(s) have TTL < 60 seconds. Very low TTLs are a common indicator of ` +
|
|
1090
|
+
"fast-flux networks used by malware, botnets, and phishing operations to rapidly rotate IP addresses.",
|
|
1091
|
+
affected_records: fastFluxRecords.map((r) => `${r.type} ${r.data} (TTL: ${r.ttl}s)`).join(", "),
|
|
1092
|
+
});
|
|
1093
|
+
}
|
|
1094
|
+
// Check for low TTL (60-300) — may be CDN or dynamic
|
|
1095
|
+
const lowTtlRecords = records.filter((r) => r.ttl >= 60 && r.ttl < 300);
|
|
1096
|
+
if (lowTtlRecords.length > 0) {
|
|
1097
|
+
findings.push({
|
|
1098
|
+
severity: "info",
|
|
1099
|
+
title: "Low TTL values (60-300s)",
|
|
1100
|
+
description: `${lowTtlRecords.length} record(s) have TTL between 60-300 seconds. This is common for CDN, ` +
|
|
1101
|
+
"load-balanced, or dynamically updated records. Generally benign but worth noting.",
|
|
1102
|
+
affected_records: lowTtlRecords.map((r) => `${r.type} ${r.data} (TTL: ${r.ttl}s)`).join(", "),
|
|
1103
|
+
});
|
|
1104
|
+
}
|
|
1105
|
+
// Check for very high TTL (> 86400)
|
|
1106
|
+
const highTtlRecords = records.filter((r) => r.ttl > 86400);
|
|
1107
|
+
if (highTtlRecords.length > 0) {
|
|
1108
|
+
findings.push({
|
|
1109
|
+
severity: "medium",
|
|
1110
|
+
title: "Very high TTL detected (stale cache risk)",
|
|
1111
|
+
description: `${highTtlRecords.length} record(s) have TTL > 86400 seconds (1 day). High TTLs mean changes ` +
|
|
1112
|
+
"take longer to propagate, which can delay incident response if IP addresses need to be changed urgently.",
|
|
1113
|
+
affected_records: highTtlRecords.map((r) => `${r.type} ${r.data} (TTL: ${r.ttl}s)`).join(", "),
|
|
1114
|
+
});
|
|
1115
|
+
}
|
|
1116
|
+
// Check for TTL inconsistency within the same record type
|
|
1117
|
+
for (const [type, info] of Object.entries(byType)) {
|
|
1118
|
+
const uniqueTtls = new Set(info.ttls.filter((t) => t > 0));
|
|
1119
|
+
if (uniqueTtls.size > 1) {
|
|
1120
|
+
findings.push({
|
|
1121
|
+
severity: "low",
|
|
1122
|
+
title: `Inconsistent TTLs for ${type} records`,
|
|
1123
|
+
description: `${type} records have ${uniqueTtls.size} different TTL values: ${Array.from(uniqueTtls).join(", ")}s. ` +
|
|
1124
|
+
"Inconsistent TTLs within the same record type may indicate misconfiguration.",
|
|
1125
|
+
affected_records: info.records.map((r) => `${r.data} (TTL: ${r.ttl}s)`).join(", "),
|
|
1126
|
+
});
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
// Check for wide TTL variance across record types
|
|
1130
|
+
if (allTtls.length >= 2 && maxTtl > 0 && minTtl > 0) {
|
|
1131
|
+
const ratio = maxTtl / minTtl;
|
|
1132
|
+
if (ratio > 100) {
|
|
1133
|
+
findings.push({
|
|
1134
|
+
severity: "low",
|
|
1135
|
+
title: "Wide TTL variance across record types",
|
|
1136
|
+
description: `TTL values range from ${minTtl}s to ${maxTtl}s (ratio: ${Math.round(ratio)}x). ` +
|
|
1137
|
+
"Large variance may complicate DNS change propagation planning.",
|
|
1138
|
+
});
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
// Check for zero TTLs (may indicate records where TTL wasn't available)
|
|
1142
|
+
const zeroTtlRecords = records.filter((r) => r.ttl === 0);
|
|
1143
|
+
if (zeroTtlRecords.length > 0 && zeroTtlRecords.length < records.length) {
|
|
1144
|
+
findings.push({
|
|
1145
|
+
severity: "info",
|
|
1146
|
+
title: "Records with zero TTL",
|
|
1147
|
+
description: `${zeroTtlRecords.length} record(s) have TTL=0. This may indicate the resolver did not return TTL ` +
|
|
1148
|
+
"information for these record types, or the records are set to never be cached.",
|
|
1149
|
+
affected_records: zeroTtlRecords.map((r) => `${r.type} ${r.data}`).join(", "),
|
|
1150
|
+
});
|
|
1151
|
+
}
|
|
1152
|
+
return json({
|
|
1153
|
+
domain,
|
|
1154
|
+
summary: {
|
|
1155
|
+
total_records: records.length,
|
|
1156
|
+
min_ttl: minTtl,
|
|
1157
|
+
max_ttl: maxTtl,
|
|
1158
|
+
avg_ttl: avgTtl,
|
|
1159
|
+
ttl_range_human: `${formatTtl(minTtl)} — ${formatTtl(maxTtl)}`,
|
|
1160
|
+
},
|
|
1161
|
+
records_by_type: Object.fromEntries(Object.entries(byType).map(([type, info]) => [
|
|
1162
|
+
type,
|
|
1163
|
+
{
|
|
1164
|
+
count: info.records.length,
|
|
1165
|
+
ttl_values: Array.from(new Set(info.ttls)),
|
|
1166
|
+
records: info.records,
|
|
1167
|
+
},
|
|
1168
|
+
])),
|
|
1169
|
+
findings,
|
|
1170
|
+
findings_count: {
|
|
1171
|
+
critical: findings.filter((f) => f.severity === "critical").length,
|
|
1172
|
+
high: findings.filter((f) => f.severity === "high").length,
|
|
1173
|
+
medium: findings.filter((f) => f.severity === "medium").length,
|
|
1174
|
+
low: findings.filter((f) => f.severity === "low").length,
|
|
1175
|
+
info: findings.filter((f) => f.severity === "info").length,
|
|
1176
|
+
},
|
|
1177
|
+
});
|
|
1178
|
+
}
|
|
1179
|
+
catch (err) {
|
|
1180
|
+
return text(`TTL analysis failed for ${domain}: ${err.message}`);
|
|
1181
|
+
}
|
|
1182
|
+
},
|
|
1183
|
+
};
|
|
1184
|
+
/** Format TTL value into human-readable string */
|
|
1185
|
+
function formatTtl(seconds) {
|
|
1186
|
+
if (seconds === 0)
|
|
1187
|
+
return "0s";
|
|
1188
|
+
if (seconds < 60)
|
|
1189
|
+
return `${seconds}s`;
|
|
1190
|
+
if (seconds < 3600)
|
|
1191
|
+
return `${Math.round(seconds / 60)}m`;
|
|
1192
|
+
if (seconds < 86400)
|
|
1193
|
+
return `${Math.round(seconds / 3600)}h`;
|
|
1194
|
+
return `${Math.round(seconds / 86400)}d`;
|
|
1195
|
+
}
|
|
1196
|
+
// ─── Export All DNS Tools ───
|
|
1197
|
+
export const dnsTools = [
|
|
1198
|
+
dnsLookup,
|
|
1199
|
+
dnsReverse,
|
|
1200
|
+
dnsZoneTransfer,
|
|
1201
|
+
dnsSubdomainEnum,
|
|
1202
|
+
dnsCacheSnoop,
|
|
1203
|
+
dnsNsecWalk,
|
|
1204
|
+
dnsWildcardDetect,
|
|
1205
|
+
dnsServerFingerprint,
|
|
1206
|
+
dnsRecursiveCheck,
|
|
1207
|
+
dnsPropagation,
|
|
1208
|
+
dnsSplitHorizon,
|
|
1209
|
+
dnsTtlAnalysis,
|
|
1210
|
+
];
|
|
1211
|
+
//# sourceMappingURL=index.js.map
|