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