dns-security-mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +723 -0
  3. package/dist/blocklist/index.d.ts +3 -0
  4. package/dist/blocklist/index.d.ts.map +1 -0
  5. package/dist/blocklist/index.js +596 -0
  6. package/dist/blocklist/index.js.map +1 -0
  7. package/dist/ct/index.d.ts +3 -0
  8. package/dist/ct/index.d.ts.map +1 -0
  9. package/dist/ct/index.js +534 -0
  10. package/dist/ct/index.js.map +1 -0
  11. package/dist/data/dkim-selectors.d.ts +2 -0
  12. package/dist/data/dkim-selectors.d.ts.map +1 -0
  13. package/dist/data/dkim-selectors.js +60 -0
  14. package/dist/data/dkim-selectors.js.map +1 -0
  15. package/dist/data/dnsbl-lists.d.ts +8 -0
  16. package/dist/data/dnsbl-lists.d.ts.map +1 -0
  17. package/dist/data/dnsbl-lists.js +54 -0
  18. package/dist/data/dnsbl-lists.js.map +1 -0
  19. package/dist/data/takeover-fingerprints.d.ts +8 -0
  20. package/dist/data/takeover-fingerprints.d.ts.map +1 -0
  21. package/dist/data/takeover-fingerprints.js +84 -0
  22. package/dist/data/takeover-fingerprints.js.map +1 -0
  23. package/dist/data/tunneling-signatures.d.ts +17 -0
  24. package/dist/data/tunneling-signatures.d.ts.map +1 -0
  25. package/dist/data/tunneling-signatures.js +85 -0
  26. package/dist/data/tunneling-signatures.js.map +1 -0
  27. package/dist/dns/index.d.ts +3 -0
  28. package/dist/dns/index.d.ts.map +1 -0
  29. package/dist/dns/index.js +1211 -0
  30. package/dist/dns/index.js.map +1 -0
  31. package/dist/dnssec/index.d.ts +3 -0
  32. package/dist/dnssec/index.d.ts.map +1 -0
  33. package/dist/dnssec/index.js +1377 -0
  34. package/dist/dnssec/index.js.map +1 -0
  35. package/dist/domain/index.d.ts +3 -0
  36. package/dist/domain/index.d.ts.map +1 -0
  37. package/dist/domain/index.js +938 -0
  38. package/dist/domain/index.js.map +1 -0
  39. package/dist/email/index.d.ts +3 -0
  40. package/dist/email/index.d.ts.map +1 -0
  41. package/dist/email/index.js +1188 -0
  42. package/dist/email/index.js.map +1 -0
  43. package/dist/hijack/index.d.ts +3 -0
  44. package/dist/hijack/index.d.ts.map +1 -0
  45. package/dist/hijack/index.js +1117 -0
  46. package/dist/hijack/index.js.map +1 -0
  47. package/dist/index.d.ts +3 -0
  48. package/dist/index.d.ts.map +1 -0
  49. package/dist/index.js +151 -0
  50. package/dist/index.js.map +1 -0
  51. package/dist/infra/index.d.ts +3 -0
  52. package/dist/infra/index.d.ts.map +1 -0
  53. package/dist/infra/index.js +797 -0
  54. package/dist/infra/index.js.map +1 -0
  55. package/dist/privacy/index.d.ts +3 -0
  56. package/dist/privacy/index.d.ts.map +1 -0
  57. package/dist/privacy/index.js +772 -0
  58. package/dist/privacy/index.js.map +1 -0
  59. package/dist/protocol/mcp-server.d.ts +4 -0
  60. package/dist/protocol/mcp-server.d.ts.map +1 -0
  61. package/dist/protocol/mcp-server.js +32 -0
  62. package/dist/protocol/mcp-server.js.map +1 -0
  63. package/dist/protocol/tools.d.ts +3 -0
  64. package/dist/protocol/tools.d.ts.map +1 -0
  65. package/dist/protocol/tools.js +29 -0
  66. package/dist/protocol/tools.js.map +1 -0
  67. package/dist/report/index.d.ts +3 -0
  68. package/dist/report/index.d.ts.map +1 -0
  69. package/dist/report/index.js +1167 -0
  70. package/dist/report/index.js.map +1 -0
  71. package/dist/threat/index.d.ts +3 -0
  72. package/dist/threat/index.d.ts.map +1 -0
  73. package/dist/threat/index.js +999 -0
  74. package/dist/threat/index.js.map +1 -0
  75. package/dist/tunnel/index.d.ts +3 -0
  76. package/dist/tunnel/index.d.ts.map +1 -0
  77. package/dist/tunnel/index.js +688 -0
  78. package/dist/tunnel/index.js.map +1 -0
  79. package/dist/types/index.d.ts +52 -0
  80. package/dist/types/index.d.ts.map +1 -0
  81. package/dist/types/index.js +8 -0
  82. package/dist/types/index.js.map +1 -0
  83. package/dist/typo/index.d.ts +3 -0
  84. package/dist/typo/index.d.ts.map +1 -0
  85. package/dist/typo/index.js +625 -0
  86. package/dist/typo/index.js.map +1 -0
  87. package/dist/utils/cache.d.ts +11 -0
  88. package/dist/utils/cache.d.ts.map +1 -0
  89. package/dist/utils/cache.js +35 -0
  90. package/dist/utils/cache.js.map +1 -0
  91. package/dist/utils/dns-client.d.ts +37 -0
  92. package/dist/utils/dns-client.d.ts.map +1 -0
  93. package/dist/utils/dns-client.js +359 -0
  94. package/dist/utils/dns-client.js.map +1 -0
  95. package/dist/utils/rate-limiter.d.ts +10 -0
  96. package/dist/utils/rate-limiter.d.ts.map +1 -0
  97. package/dist/utils/rate-limiter.js +35 -0
  98. package/dist/utils/rate-limiter.js.map +1 -0
  99. package/package.json +63 -0
@@ -0,0 +1,1188 @@
1
+ import { z } from "zod";
2
+ import { json } from "../types/index.js";
3
+ import { createResolver, queryRaw } from "../utils/dns-client.js";
4
+ import { DKIM_SELECTORS } from "../data/dkim-selectors.js";
5
+ import * as dns from "node:dns/promises";
6
+ // ─── SPF Check Logic ───
7
+ async function checkSpf(domain) {
8
+ const resolver = createResolver();
9
+ const findings = [];
10
+ let spfRecord = null;
11
+ const mechanisms = [];
12
+ let allQualifier = null;
13
+ let dnsLookups = 0;
14
+ const includes = [];
15
+ try {
16
+ const txtRecords = await resolver.resolveTxt(domain);
17
+ const spfRecords = txtRecords
18
+ .map((r) => r.join(""))
19
+ .filter((r) => r.toLowerCase().startsWith("v=spf1"));
20
+ if (spfRecords.length === 0) {
21
+ findings.push({
22
+ severity: "critical",
23
+ title: "No SPF record found",
24
+ description: `Domain ${domain} has no SPF record. Any server can send email claiming to be from this domain.`,
25
+ remediation: "Add a TXT record with a valid SPF policy, e.g. \"v=spf1 mx -all\"",
26
+ });
27
+ return { found: false, record: null, mechanisms, allQualifier, dnsLookups, includes, findings };
28
+ }
29
+ if (spfRecords.length > 1) {
30
+ findings.push({
31
+ severity: "high",
32
+ title: "Multiple SPF records found",
33
+ description: `Domain ${domain} has ${spfRecords.length} SPF records. RFC 7208 Section 3.2 requires exactly one SPF record; multiple records cause undefined behavior.`,
34
+ remediation: "Merge all SPF records into a single TXT record.",
35
+ });
36
+ }
37
+ spfRecord = spfRecords[0];
38
+ const parts = spfRecord.split(/\s+/).slice(1); // skip "v=spf1"
39
+ for (const part of parts) {
40
+ let qualifier = "+";
41
+ let mechanism = part;
42
+ if (/^[+\-~?]/.test(mechanism)) {
43
+ qualifier = mechanism[0];
44
+ mechanism = mechanism.slice(1);
45
+ }
46
+ const [mType, ...rest] = mechanism.split(":");
47
+ const mValue = rest.join(":") || "";
48
+ const type = mType.toLowerCase();
49
+ mechanisms.push({ qualifier, type, value: mValue });
50
+ // Count DNS lookups for lookup-triggering mechanisms
51
+ if (["include", "a", "mx", "ptr", "exists", "redirect"].includes(type)) {
52
+ dnsLookups++;
53
+ }
54
+ if (type === "include" && mValue) {
55
+ includes.push(mValue);
56
+ }
57
+ if (type === "all") {
58
+ allQualifier = qualifier;
59
+ }
60
+ if (type === "ptr") {
61
+ findings.push({
62
+ severity: "medium",
63
+ title: "Deprecated ptr mechanism in SPF",
64
+ description: "The \"ptr\" mechanism is deprecated per RFC 7208 Section 5.5 due to performance and reliability issues.",
65
+ remediation: "Replace ptr mechanism with explicit ip4/ip6 or a mechanisms.",
66
+ });
67
+ }
68
+ }
69
+ // Follow include chain to count total DNS lookups
70
+ const visited = new Set();
71
+ const includeQueue = [...includes];
72
+ while (includeQueue.length > 0) {
73
+ const inc = includeQueue.shift();
74
+ if (visited.has(inc))
75
+ continue;
76
+ visited.add(inc);
77
+ try {
78
+ const incTxt = await resolver.resolveTxt(inc);
79
+ const incSpf = incTxt.map((r) => r.join("")).find((r) => r.toLowerCase().startsWith("v=spf1"));
80
+ if (incSpf) {
81
+ const incParts = incSpf.split(/\s+/).slice(1);
82
+ for (const p of incParts) {
83
+ let m = p;
84
+ if (/^[+\-~?]/.test(m))
85
+ m = m.slice(1);
86
+ const [t, ...v] = m.split(":");
87
+ const typ = t.toLowerCase();
88
+ if (["include", "a", "mx", "ptr", "exists", "redirect"].includes(typ)) {
89
+ dnsLookups++;
90
+ }
91
+ if (typ === "include" && v.join(":")) {
92
+ includeQueue.push(v.join(":"));
93
+ }
94
+ }
95
+ }
96
+ }
97
+ catch {
98
+ // Include target unreachable
99
+ }
100
+ }
101
+ if (dnsLookups > 10) {
102
+ findings.push({
103
+ severity: "high",
104
+ title: "SPF exceeds 10 DNS lookup limit",
105
+ description: `SPF record triggers ${dnsLookups} DNS lookups (RFC 7208 max is 10). Receivers may return PermError.`,
106
+ remediation: "Flatten SPF record by replacing includes with direct ip4/ip6 mechanisms, or use an SPF flattening service.",
107
+ });
108
+ }
109
+ if (allQualifier === "~") {
110
+ findings.push({
111
+ severity: "high",
112
+ title: "SPF uses softfail (~all)",
113
+ description: "SPF ~all (softfail) marks unauthorized senders as suspicious but does not reject them. Emails from unauthorized sources may still be delivered.",
114
+ remediation: "Change ~all to -all for strict rejection of unauthorized senders.",
115
+ });
116
+ }
117
+ else if (allQualifier === "+") {
118
+ findings.push({
119
+ severity: "critical",
120
+ title: "SPF allows all senders (+all)",
121
+ description: "SPF +all permits any server to send email on behalf of this domain. This is an open relay configuration.",
122
+ remediation: "Change +all to -all immediately.",
123
+ });
124
+ }
125
+ else if (allQualifier === "?") {
126
+ findings.push({
127
+ severity: "high",
128
+ title: "SPF uses neutral (?all)",
129
+ description: "SPF ?all provides no indication about sender authorization. This effectively provides no protection.",
130
+ remediation: "Change ?all to -all for strict rejection.",
131
+ });
132
+ }
133
+ else if (allQualifier === "-") {
134
+ findings.push({
135
+ severity: "info",
136
+ title: "SPF uses hardfail (-all)",
137
+ description: "SPF -all correctly rejects unauthorized senders.",
138
+ });
139
+ }
140
+ }
141
+ catch (err) {
142
+ findings.push({
143
+ severity: "critical",
144
+ title: "SPF lookup failed",
145
+ description: `Failed to query TXT records for ${domain}: ${err.message}`,
146
+ });
147
+ return { found: false, record: null, mechanisms, allQualifier, dnsLookups, includes, findings };
148
+ }
149
+ return { found: true, record: spfRecord, mechanisms, allQualifier, dnsLookups, includes, findings };
150
+ }
151
+ // ─── DKIM Check Logic ───
152
+ async function checkDkim(domain, selectors) {
153
+ const resolver = createResolver();
154
+ const findings = [];
155
+ const selectorsFound = [];
156
+ let checkedCount = 0;
157
+ for (const selector of selectors) {
158
+ checkedCount++;
159
+ const dkimDomain = `${selector}._domainkey.${domain}`;
160
+ const result = {
161
+ selector,
162
+ found: false,
163
+ record: null,
164
+ version: null,
165
+ keyType: null,
166
+ publicKey: null,
167
+ estimatedBits: null,
168
+ testingMode: false,
169
+ revoked: false,
170
+ findings: [],
171
+ };
172
+ try {
173
+ const txtRecords = await resolver.resolveTxt(dkimDomain);
174
+ const dkimTxt = txtRecords.map((r) => r.join("")).find((r) => r.includes("p="));
175
+ if (!dkimTxt)
176
+ continue;
177
+ result.found = true;
178
+ result.record = dkimTxt;
179
+ // Parse DKIM tags
180
+ const tags = new Map();
181
+ for (const part of dkimTxt.split(";")) {
182
+ const trimmed = part.trim();
183
+ const eqIdx = trimmed.indexOf("=");
184
+ if (eqIdx > 0) {
185
+ const key = trimmed.slice(0, eqIdx).trim();
186
+ const value = trimmed.slice(eqIdx + 1).trim();
187
+ tags.set(key, value);
188
+ }
189
+ }
190
+ result.version = tags.get("v") ?? null;
191
+ result.keyType = tags.get("k") ?? "rsa"; // default is rsa per RFC 6376
192
+ result.publicKey = tags.get("p") ?? null;
193
+ // Check for testing mode
194
+ const tFlag = tags.get("t");
195
+ if (tFlag && tFlag.includes("y")) {
196
+ result.testingMode = true;
197
+ result.findings.push({
198
+ severity: "medium",
199
+ title: `DKIM selector '${selector}' is in testing mode`,
200
+ description: "Tag t=y indicates this DKIM key is in testing mode. Verifiers may not enforce failures.",
201
+ remediation: "Remove t=y flag when DKIM is fully deployed.",
202
+ });
203
+ }
204
+ // Check for revoked key (empty p=)
205
+ if (result.publicKey === "" || result.publicKey === null) {
206
+ result.revoked = true;
207
+ result.findings.push({
208
+ severity: "info",
209
+ title: `DKIM selector '${selector}' is revoked`,
210
+ description: "Empty p= tag indicates this DKIM key has been revoked.",
211
+ });
212
+ }
213
+ else {
214
+ // Estimate key size from base64 length
215
+ const rawKey = result.publicKey.replace(/\s/g, "");
216
+ const keyBytes = Math.floor((rawKey.length * 3) / 4);
217
+ // RSA public key DER includes overhead (~38 bytes for header), remaining is the modulus
218
+ const modulusBits = Math.max(0, (keyBytes - 38)) * 8;
219
+ result.estimatedBits = modulusBits > 0 ? modulusBits : keyBytes * 8;
220
+ if (result.keyType?.toLowerCase() === "rsa" && result.estimatedBits < 1024) {
221
+ result.findings.push({
222
+ severity: "critical",
223
+ title: `DKIM selector '${selector}' uses weak RSA key (~${result.estimatedBits} bits)`,
224
+ description: "RSA keys shorter than 1024 bits are considered insecure and can be factored.",
225
+ remediation: "Rotate to a 2048-bit RSA key or use Ed25519.",
226
+ });
227
+ }
228
+ else if (result.keyType?.toLowerCase() === "rsa" && result.estimatedBits < 2048) {
229
+ result.findings.push({
230
+ severity: "medium",
231
+ title: `DKIM selector '${selector}' uses 1024-bit RSA key`,
232
+ description: "1024-bit RSA keys are not yet broken but are considered weak. 2048-bit is the recommended minimum.",
233
+ remediation: "Rotate to a 2048-bit RSA key.",
234
+ });
235
+ }
236
+ }
237
+ selectorsFound.push(result);
238
+ }
239
+ catch {
240
+ // Selector not found — expected for most brute-forced selectors
241
+ continue;
242
+ }
243
+ }
244
+ if (selectorsFound.length === 0) {
245
+ findings.push({
246
+ severity: "high",
247
+ title: "No DKIM records found",
248
+ description: `Checked ${checkedCount} selectors for ${domain} — none returned a DKIM record. Email from this domain cannot be cryptographically verified.`,
249
+ remediation: "Configure DKIM signing with your email provider and publish the public key in DNS.",
250
+ });
251
+ }
252
+ // Aggregate per-selector findings
253
+ for (const s of selectorsFound) {
254
+ findings.push(...s.findings);
255
+ }
256
+ return { selectorsChecked: checkedCount, selectorsFound, findings };
257
+ }
258
+ // ─── DMARC Check Logic ───
259
+ async function checkDmarc(domain) {
260
+ const resolver = createResolver();
261
+ const findings = [];
262
+ const dmarcDomain = `_dmarc.${domain}`;
263
+ const result = {
264
+ found: false,
265
+ record: null,
266
+ policy: null,
267
+ subdomainPolicy: null,
268
+ percentage: null,
269
+ rua: null,
270
+ ruf: null,
271
+ aspf: null,
272
+ adkim: null,
273
+ findings: [],
274
+ };
275
+ try {
276
+ const txtRecords = await resolver.resolveTxt(dmarcDomain);
277
+ const dmarcTxt = txtRecords
278
+ .map((r) => r.join(""))
279
+ .find((r) => r.toLowerCase().startsWith("v=dmarc1"));
280
+ if (!dmarcTxt) {
281
+ findings.push({
282
+ severity: "critical",
283
+ title: "No DMARC record found",
284
+ description: `No DMARC record at ${dmarcDomain}. Without DMARC, there is no policy instructing receivers how to handle SPF/DKIM failures.`,
285
+ remediation: "Add a DMARC TXT record, e.g. \"v=DMARC1; p=reject; rua=mailto:dmarc@yourdomain.com\"",
286
+ });
287
+ result.findings = findings;
288
+ return result;
289
+ }
290
+ result.found = true;
291
+ result.record = dmarcTxt;
292
+ // Parse DMARC tags
293
+ const tags = new Map();
294
+ for (const part of dmarcTxt.split(";")) {
295
+ const trimmed = part.trim();
296
+ const eqIdx = trimmed.indexOf("=");
297
+ if (eqIdx > 0) {
298
+ const key = trimmed.slice(0, eqIdx).trim().toLowerCase();
299
+ const value = trimmed.slice(eqIdx + 1).trim();
300
+ tags.set(key, value);
301
+ }
302
+ }
303
+ result.policy = tags.get("p") ?? null;
304
+ result.subdomainPolicy = tags.get("sp") ?? null;
305
+ result.percentage = tags.has("pct") ? parseInt(tags.get("pct"), 10) : null;
306
+ result.rua = tags.get("rua") ?? null;
307
+ result.ruf = tags.get("ruf") ?? null;
308
+ result.aspf = tags.get("aspf") ?? null;
309
+ result.adkim = tags.get("adkim") ?? null;
310
+ // Evaluate policy
311
+ if (result.policy === "none") {
312
+ findings.push({
313
+ severity: "high",
314
+ title: "DMARC policy is p=none (monitoring only)",
315
+ description: "DMARC p=none does not instruct receivers to reject or quarantine unauthorized emails. It only generates reports.",
316
+ remediation: "Transition to p=quarantine or p=reject after analyzing DMARC reports.",
317
+ });
318
+ }
319
+ else if (result.policy === "quarantine") {
320
+ findings.push({
321
+ severity: "medium",
322
+ title: "DMARC policy is p=quarantine",
323
+ description: "DMARC p=quarantine instructs receivers to treat failures as suspicious (typically moved to spam). p=reject provides stronger protection.",
324
+ remediation: "Consider upgrading to p=reject after verifying all legitimate senders pass SPF/DKIM.",
325
+ });
326
+ }
327
+ else if (result.policy === "reject") {
328
+ findings.push({
329
+ severity: "info",
330
+ title: "DMARC policy is p=reject",
331
+ description: "DMARC p=reject is the strongest policy, instructing receivers to reject unauthorized emails outright.",
332
+ });
333
+ }
334
+ // Check percentage
335
+ if (result.percentage !== null && result.percentage < 100) {
336
+ findings.push({
337
+ severity: "medium",
338
+ title: `DMARC pct=${result.percentage}% — partial enforcement`,
339
+ description: `Only ${result.percentage}% of messages are subject to the DMARC policy. The remaining ${100 - result.percentage}% are treated as p=none.`,
340
+ remediation: "Increase pct to 100 once confident in SPF/DKIM alignment.",
341
+ });
342
+ }
343
+ // Check reporting
344
+ if (!result.rua) {
345
+ findings.push({
346
+ severity: "medium",
347
+ title: "No DMARC aggregate reporting (rua) configured",
348
+ description: "Without rua, you will not receive aggregate DMARC reports to monitor authentication failures.",
349
+ remediation: "Add rua=mailto:dmarc-reports@yourdomain.com to your DMARC record.",
350
+ });
351
+ }
352
+ // Check alignment
353
+ if (result.aspf === "r") {
354
+ findings.push({
355
+ severity: "low",
356
+ title: "DMARC SPF alignment is relaxed",
357
+ description: "Relaxed SPF alignment (aspf=r) allows subdomains to pass alignment. Strict (aspf=s) is more secure.",
358
+ });
359
+ }
360
+ if (result.adkim === "r") {
361
+ findings.push({
362
+ severity: "low",
363
+ title: "DMARC DKIM alignment is relaxed",
364
+ description: "Relaxed DKIM alignment (adkim=r) allows subdomains to pass alignment. Strict (adkim=s) is more secure.",
365
+ });
366
+ }
367
+ }
368
+ catch (err) {
369
+ findings.push({
370
+ severity: "critical",
371
+ title: "DMARC lookup failed",
372
+ description: `Failed to query TXT records for ${dmarcDomain}: ${err.message}`,
373
+ });
374
+ }
375
+ result.findings = findings;
376
+ return result;
377
+ }
378
+ // ─── BIMI Check Logic ───
379
+ async function checkBimi(domain) {
380
+ const resolver = createResolver();
381
+ const findings = [];
382
+ const bimiDomain = `default._bimi.${domain}`;
383
+ const result = {
384
+ found: false,
385
+ record: null,
386
+ version: null,
387
+ logoUrl: null,
388
+ vmcUrl: null,
389
+ findings: [],
390
+ };
391
+ try {
392
+ const txtRecords = await resolver.resolveTxt(bimiDomain);
393
+ const bimiTxt = txtRecords
394
+ .map((r) => r.join(""))
395
+ .find((r) => r.toLowerCase().startsWith("v=bimi1"));
396
+ if (!bimiTxt) {
397
+ findings.push({
398
+ severity: "info",
399
+ title: "No BIMI record found",
400
+ description: `No BIMI record at ${bimiDomain}. BIMI allows displaying a brand logo next to authenticated emails.`,
401
+ remediation: "Add a BIMI TXT record at default._bimi.yourdomain.com with v=BIMI1; l=<SVG URL>; a=<VMC URL>",
402
+ });
403
+ result.findings = findings;
404
+ return result;
405
+ }
406
+ result.found = true;
407
+ result.record = bimiTxt;
408
+ // Parse BIMI tags
409
+ const tags = new Map();
410
+ for (const part of bimiTxt.split(";")) {
411
+ const trimmed = part.trim();
412
+ const eqIdx = trimmed.indexOf("=");
413
+ if (eqIdx > 0) {
414
+ const key = trimmed.slice(0, eqIdx).trim().toLowerCase();
415
+ const value = trimmed.slice(eqIdx + 1).trim();
416
+ tags.set(key, value);
417
+ }
418
+ }
419
+ result.version = tags.get("v") ?? null;
420
+ result.logoUrl = tags.get("l") ?? null;
421
+ result.vmcUrl = tags.get("a") ?? null;
422
+ if (!result.logoUrl) {
423
+ findings.push({
424
+ severity: "medium",
425
+ title: "BIMI record missing logo URL (l= tag)",
426
+ description: "BIMI record does not contain a logo URL. Without it, no brand indicator will be displayed.",
427
+ remediation: "Add l=https://yourdomain.com/logo.svg to your BIMI record.",
428
+ });
429
+ }
430
+ else {
431
+ findings.push({
432
+ severity: "info",
433
+ title: "BIMI logo URL present",
434
+ description: `Logo URL: ${result.logoUrl}`,
435
+ });
436
+ }
437
+ if (!result.vmcUrl) {
438
+ findings.push({
439
+ severity: "low",
440
+ title: "BIMI record has no VMC certificate (a= tag)",
441
+ description: "Without a Verified Mark Certificate (VMC), some email clients (notably Gmail) will not display the BIMI logo.",
442
+ remediation: "Obtain a VMC from a qualified certificate authority and add a= tag to your BIMI record.",
443
+ });
444
+ }
445
+ else {
446
+ findings.push({
447
+ severity: "info",
448
+ title: "BIMI VMC certificate present",
449
+ description: `VMC URL: ${result.vmcUrl}`,
450
+ });
451
+ }
452
+ }
453
+ catch (err) {
454
+ findings.push({
455
+ severity: "info",
456
+ title: "BIMI lookup failed",
457
+ description: `Failed to query TXT records for ${bimiDomain}: ${err.message}`,
458
+ });
459
+ }
460
+ result.findings = findings;
461
+ return result;
462
+ }
463
+ // ─── MTA-STS Check Logic ───
464
+ async function checkMtaSts(domain) {
465
+ const resolver = createResolver();
466
+ const findings = [];
467
+ const stsDomain = `_mta-sts.${domain}`;
468
+ const result = {
469
+ dnsRecord: { found: false, id: null },
470
+ policy: {
471
+ fetched: false,
472
+ version: null,
473
+ mode: null,
474
+ mxPatterns: [],
475
+ maxAge: null,
476
+ },
477
+ findings: [],
478
+ };
479
+ // Step 1: Check DNS TXT record
480
+ try {
481
+ const txtRecords = await resolver.resolveTxt(stsDomain);
482
+ const stsTxt = txtRecords
483
+ .map((r) => r.join(""))
484
+ .find((r) => r.toLowerCase().startsWith("v=stsv1"));
485
+ if (!stsTxt) {
486
+ findings.push({
487
+ severity: "medium",
488
+ title: "No MTA-STS DNS record found",
489
+ description: `No MTA-STS TXT record at ${stsDomain}. MTA-STS enforces TLS for inbound SMTP connections.`,
490
+ remediation: "Add a TXT record at _mta-sts.yourdomain.com with \"v=STSv1; id=<unique-id>\"",
491
+ });
492
+ result.findings = findings;
493
+ return result;
494
+ }
495
+ result.dnsRecord.found = true;
496
+ // Parse id from the record
497
+ const idMatch = stsTxt.match(/id\s*=\s*(\S+)/i);
498
+ result.dnsRecord.id = idMatch ? idMatch[1] : null;
499
+ }
500
+ catch (err) {
501
+ findings.push({
502
+ severity: "medium",
503
+ title: "MTA-STS DNS lookup failed",
504
+ description: `Failed to query TXT for ${stsDomain}: ${err.message}`,
505
+ });
506
+ result.findings = findings;
507
+ return result;
508
+ }
509
+ // Step 2: Fetch the policy file
510
+ const policyUrl = `https://mta-sts.${domain}/.well-known/mta-sts.txt`;
511
+ try {
512
+ const response = await fetch(policyUrl, {
513
+ signal: AbortSignal.timeout(10000),
514
+ redirect: "follow",
515
+ });
516
+ if (!response.ok) {
517
+ findings.push({
518
+ severity: "high",
519
+ title: "MTA-STS policy file not accessible",
520
+ description: `HTTP ${response.status} when fetching ${policyUrl}. The MTA-STS DNS record exists but the policy file is not served.`,
521
+ remediation: "Ensure the policy file is served at https://mta-sts.yourdomain.com/.well-known/mta-sts.txt",
522
+ });
523
+ result.findings = findings;
524
+ return result;
525
+ }
526
+ const policyText = await response.text();
527
+ result.policy.fetched = true;
528
+ // Parse policy fields
529
+ for (const line of policyText.split("\n")) {
530
+ const trimmed = line.trim();
531
+ if (!trimmed)
532
+ continue;
533
+ const colonIdx = trimmed.indexOf(":");
534
+ if (colonIdx < 0)
535
+ continue;
536
+ const key = trimmed.slice(0, colonIdx).trim().toLowerCase();
537
+ const value = trimmed.slice(colonIdx + 1).trim();
538
+ switch (key) {
539
+ case "version":
540
+ result.policy.version = value;
541
+ break;
542
+ case "mode":
543
+ result.policy.mode = value.toLowerCase();
544
+ break;
545
+ case "mx":
546
+ result.policy.mxPatterns.push(value);
547
+ break;
548
+ case "max_age":
549
+ result.policy.maxAge = parseInt(value, 10);
550
+ break;
551
+ }
552
+ }
553
+ // Evaluate policy
554
+ if (result.policy.mode === "testing") {
555
+ findings.push({
556
+ severity: "medium",
557
+ title: "MTA-STS is in testing mode",
558
+ description: "MTA-STS mode=testing means senders will report TLS failures but not enforce TLS. Mail may still be sent unencrypted.",
559
+ remediation: "Switch to mode=enforce once all MX hosts have valid TLS certificates.",
560
+ });
561
+ }
562
+ else if (result.policy.mode === "none") {
563
+ findings.push({
564
+ severity: "high",
565
+ title: "MTA-STS mode is set to none",
566
+ description: "MTA-STS mode=none effectively disables the policy. No TLS enforcement occurs.",
567
+ remediation: "Set mode to enforce or testing.",
568
+ });
569
+ }
570
+ else if (result.policy.mode === "enforce") {
571
+ findings.push({
572
+ severity: "info",
573
+ title: "MTA-STS is in enforce mode",
574
+ description: "MTA-STS mode=enforce ensures senders must use TLS when delivering to your MX hosts.",
575
+ });
576
+ }
577
+ if (result.policy.maxAge !== null && result.policy.maxAge < 86400) {
578
+ findings.push({
579
+ severity: "medium",
580
+ title: `MTA-STS max_age is short (${result.policy.maxAge}s)`,
581
+ description: `max_age of ${result.policy.maxAge} seconds (${(result.policy.maxAge / 3600).toFixed(1)} hours) is very short. An attacker could wait for the policy to expire and then downgrade the connection.`,
582
+ remediation: "Set max_age to at least 86400 (1 day), recommended 604800-31557600 (1 week to 1 year).",
583
+ });
584
+ }
585
+ }
586
+ catch (err) {
587
+ findings.push({
588
+ severity: "high",
589
+ title: "MTA-STS policy file fetch failed",
590
+ description: `Could not retrieve ${policyUrl}: ${err.message}`,
591
+ remediation: "Ensure mta-sts.yourdomain.com resolves and serves the policy file over HTTPS.",
592
+ });
593
+ }
594
+ result.findings = findings;
595
+ return result;
596
+ }
597
+ // ─── DANE/TLSA Check Logic ───
598
+ async function checkDane(domain) {
599
+ const resolver = createResolver();
600
+ const findings = [];
601
+ const mxHosts = [];
602
+ const tlsaRecords = [];
603
+ // Resolve MX records
604
+ try {
605
+ const mxResults = await resolver.resolveMx(domain);
606
+ for (const mx of mxResults.sort((a, b) => a.priority - b.priority)) {
607
+ mxHosts.push(mx.exchange);
608
+ }
609
+ }
610
+ catch {
611
+ findings.push({
612
+ severity: "medium",
613
+ title: "No MX records found",
614
+ description: `Could not resolve MX records for ${domain}. DANE/TLSA check requires MX hosts.`,
615
+ });
616
+ return { mxHosts, tlsaRecords, findings };
617
+ }
618
+ if (mxHosts.length === 0) {
619
+ findings.push({
620
+ severity: "medium",
621
+ title: "No MX records found",
622
+ description: `Domain ${domain} has no MX records.`,
623
+ });
624
+ return { mxHosts, tlsaRecords, findings };
625
+ }
626
+ let anyTlsaFound = false;
627
+ for (const mxHost of mxHosts) {
628
+ const tlsaDomain = `_25._tcp.${mxHost}`;
629
+ try {
630
+ const rawResult = await queryRaw(tlsaDomain, "TLSA", "8.8.8.8", 53, { dnssec: true });
631
+ for (const answer of rawResult.answers) {
632
+ if (answer.type === "TLSA") {
633
+ const data = answer.data;
634
+ anyTlsaFound = true;
635
+ tlsaRecords.push({
636
+ mxHost,
637
+ usage: data?.usage ?? 0,
638
+ selector: data?.selector ?? 0,
639
+ matchingType: data?.matchingType ?? 0,
640
+ certificate: data?.certificate ? Buffer.from(data.certificate).toString("hex") : (typeof data === "string" ? data : ""),
641
+ });
642
+ }
643
+ }
644
+ // Check for DNSSEC (AD flag)
645
+ if (anyTlsaFound && !rawResult.flags.authenticatedData) {
646
+ findings.push({
647
+ severity: "high",
648
+ title: `DANE without DNSSEC for ${mxHost}`,
649
+ description: `TLSA records found for ${tlsaDomain} but the response is not DNSSEC-authenticated (AD flag not set). DANE requires DNSSEC to be effective.`,
650
+ remediation: "Enable DNSSEC for the zone containing the TLSA records.",
651
+ });
652
+ }
653
+ }
654
+ catch {
655
+ // TLSA query failed — likely no TLSA records
656
+ }
657
+ }
658
+ if (!anyTlsaFound) {
659
+ findings.push({
660
+ severity: "info",
661
+ title: "No DANE/TLSA records found",
662
+ description: `No TLSA records found for any MX host of ${domain}. DANE/TLSA provides certificate pinning for SMTP connections.`,
663
+ remediation: "Publish TLSA records for your MX hosts and enable DNSSEC.",
664
+ });
665
+ }
666
+ else {
667
+ findings.push({
668
+ severity: "info",
669
+ title: `DANE/TLSA records found for ${tlsaRecords.length} MX endpoint(s)`,
670
+ description: `TLSA records provide certificate association for SMTP TLS connections.`,
671
+ });
672
+ }
673
+ return { mxHosts, tlsaRecords, findings };
674
+ }
675
+ // ─── PTR / FCrDNS Check Logic ───
676
+ async function checkPtr(domain) {
677
+ const resolver = createResolver();
678
+ const findings = [];
679
+ const mxHostResults = [];
680
+ // Resolve MX records
681
+ let mxExchanges = [];
682
+ try {
683
+ const mxResults = await resolver.resolveMx(domain);
684
+ mxExchanges = mxResults
685
+ .sort((a, b) => a.priority - b.priority)
686
+ .map((mx) => mx.exchange);
687
+ }
688
+ catch {
689
+ findings.push({
690
+ severity: "medium",
691
+ title: "No MX records found",
692
+ description: `Could not resolve MX records for ${domain}. PTR/FCrDNS check requires MX hosts.`,
693
+ });
694
+ return { mxHosts: mxHostResults, findings };
695
+ }
696
+ for (const mxHost of mxExchanges) {
697
+ const hostResult = {
698
+ host: mxHost,
699
+ ips: [],
700
+ ptrResults: [],
701
+ };
702
+ // Resolve MX host to IPs
703
+ try {
704
+ const ips = await resolver.resolve4(mxHost);
705
+ hostResult.ips = ips;
706
+ for (const ip of ips) {
707
+ const ptrResult = {
708
+ ip,
709
+ ptrHostnames: [],
710
+ fcrDnsValid: false,
711
+ };
712
+ // Reverse DNS lookup
713
+ try {
714
+ const ptrs = await dns.reverse(ip);
715
+ ptrResult.ptrHostnames = ptrs;
716
+ if (ptrs.length === 0) {
717
+ findings.push({
718
+ severity: "medium",
719
+ title: `Missing PTR for ${ip} (MX: ${mxHost})`,
720
+ description: `IP ${ip} has no PTR record. Many mail servers reject mail from IPs without reverse DNS.`,
721
+ remediation: "Configure a PTR record for this IP with your hosting/ISP provider.",
722
+ });
723
+ }
724
+ else {
725
+ // FCrDNS check: PTR hostname should forward-resolve back to the same IP
726
+ let matched = false;
727
+ for (const ptrHostname of ptrs) {
728
+ try {
729
+ const forwardIps = await resolver.resolve4(ptrHostname);
730
+ if (forwardIps.includes(ip)) {
731
+ matched = true;
732
+ }
733
+ }
734
+ catch {
735
+ // Forward lookup failed
736
+ }
737
+ }
738
+ ptrResult.fcrDnsValid = matched;
739
+ if (!matched) {
740
+ findings.push({
741
+ severity: "high",
742
+ title: `FCrDNS mismatch for ${ip} (MX: ${mxHost})`,
743
+ description: `IP ${ip} has PTR record(s) [${ptrs.join(", ")}] but none forward-resolve back to ${ip}. This FCrDNS (Forward-Confirmed reverse DNS) failure can cause mail rejection.`,
744
+ remediation: "Ensure the PTR hostname forward-resolves to the same IP address.",
745
+ });
746
+ }
747
+ else {
748
+ findings.push({
749
+ severity: "info",
750
+ title: `FCrDNS valid for ${ip} (MX: ${mxHost})`,
751
+ description: `PTR record for ${ip} resolves to ${ptrs.join(", ")} which forward-resolves back to the same IP.`,
752
+ });
753
+ }
754
+ }
755
+ hostResult.ptrResults.push(ptrResult);
756
+ }
757
+ catch {
758
+ findings.push({
759
+ severity: "medium",
760
+ title: `PTR lookup failed for ${ip} (MX: ${mxHost})`,
761
+ description: `Could not perform reverse DNS lookup for ${ip}.`,
762
+ remediation: "Ensure a PTR record exists for this IP address.",
763
+ });
764
+ hostResult.ptrResults.push(ptrResult);
765
+ }
766
+ }
767
+ }
768
+ catch {
769
+ findings.push({
770
+ severity: "medium",
771
+ title: `Could not resolve MX host ${mxHost} to IP`,
772
+ description: `A record lookup for ${mxHost} failed.`,
773
+ });
774
+ }
775
+ mxHostResults.push(hostResult);
776
+ }
777
+ return { mxHosts: mxHostResults, findings };
778
+ }
779
+ async function calcSpoofability(domain) {
780
+ const [spfResult, dkimResult, dmarcResult] = await Promise.all([
781
+ checkSpf(domain),
782
+ checkDkim(domain, DKIM_SELECTORS),
783
+ checkDmarc(domain),
784
+ ]);
785
+ const breakdown = [];
786
+ let score = 0;
787
+ // SPF scoring (max 30)
788
+ if (!spfResult.found) {
789
+ score += 30;
790
+ breakdown.push({ category: "SPF", points: 30, maxPoints: 30, detail: "No SPF record found" });
791
+ }
792
+ else if (spfResult.allQualifier === "+" || spfResult.allQualifier === "?") {
793
+ score += 25;
794
+ breakdown.push({ category: "SPF", points: 25, maxPoints: 30, detail: `SPF uses ${spfResult.allQualifier}all (permissive)` });
795
+ }
796
+ else if (spfResult.allQualifier === "~") {
797
+ score += 20;
798
+ breakdown.push({ category: "SPF", points: 20, maxPoints: 30, detail: "SPF uses ~all (softfail — spoofable)" });
799
+ }
800
+ else if (spfResult.allQualifier === "-") {
801
+ score += 0;
802
+ breakdown.push({ category: "SPF", points: 0, maxPoints: 30, detail: "SPF uses -all (hardfail)" });
803
+ }
804
+ else {
805
+ score += 15;
806
+ breakdown.push({ category: "SPF", points: 15, maxPoints: 30, detail: "SPF present but no all mechanism found" });
807
+ }
808
+ // DMARC scoring (max 30)
809
+ if (!dmarcResult.found) {
810
+ score += 30;
811
+ breakdown.push({ category: "DMARC", points: 30, maxPoints: 30, detail: "No DMARC record found" });
812
+ }
813
+ else if (dmarcResult.policy === "none") {
814
+ score += 20;
815
+ breakdown.push({ category: "DMARC", points: 20, maxPoints: 30, detail: "DMARC p=none (monitoring only)" });
816
+ }
817
+ else if (dmarcResult.policy === "quarantine") {
818
+ score += 10;
819
+ breakdown.push({ category: "DMARC", points: 10, maxPoints: 30, detail: "DMARC p=quarantine" });
820
+ }
821
+ else if (dmarcResult.policy === "reject") {
822
+ score += 0;
823
+ breakdown.push({ category: "DMARC", points: 0, maxPoints: 30, detail: "DMARC p=reject" });
824
+ }
825
+ // DKIM scoring (max 15)
826
+ const dkimFound = dkimResult.selectorsFound.length > 0;
827
+ const weakKey = dkimResult.selectorsFound.some((s) => s.keyType?.toLowerCase() === "rsa" && s.estimatedBits !== null && s.estimatedBits < 1024);
828
+ if (!dkimFound) {
829
+ score += 15;
830
+ breakdown.push({ category: "DKIM", points: 15, maxPoints: 15, detail: "No DKIM records found" });
831
+ }
832
+ else if (weakKey) {
833
+ score += 10;
834
+ breakdown.push({ category: "DKIM", points: 10, maxPoints: 15, detail: "DKIM found but uses weak key (<1024 bits)" });
835
+ }
836
+ else {
837
+ score += 0;
838
+ breakdown.push({ category: "DKIM", points: 0, maxPoints: 15, detail: "DKIM configured with adequate key strength" });
839
+ }
840
+ // Ensure score stays in 0–100 range (remaining 25 points are implicit baseline)
841
+ score = Math.min(100, Math.max(0, Math.round((score / 75) * 100)));
842
+ let verdict;
843
+ if (score >= 70) {
844
+ verdict = "easily spoofable";
845
+ }
846
+ else if (score >= 40) {
847
+ verdict = "moderately protected";
848
+ }
849
+ else {
850
+ verdict = "well protected";
851
+ }
852
+ return {
853
+ score,
854
+ verdict,
855
+ breakdown,
856
+ spfSummary: { found: spfResult.found, allQualifier: spfResult.allQualifier },
857
+ dkimSummary: { found: dkimFound, weakKey },
858
+ dmarcSummary: { found: dmarcResult.found, policy: dmarcResult.policy },
859
+ };
860
+ }
861
+ async function runFullAudit(domain) {
862
+ const [spf, dkim, dmarc, bimi, mtaSts, dane, ptr, spoofability] = await Promise.all([
863
+ checkSpf(domain),
864
+ checkDkim(domain, DKIM_SELECTORS),
865
+ checkDmarc(domain),
866
+ checkBimi(domain),
867
+ checkMtaSts(domain),
868
+ checkDane(domain),
869
+ checkPtr(domain),
870
+ calcSpoofability(domain),
871
+ ]);
872
+ // Collect all findings
873
+ const allFindings = [
874
+ ...spf.findings,
875
+ ...dkim.findings,
876
+ ...dmarc.findings,
877
+ ...bimi.findings,
878
+ ...mtaSts.findings,
879
+ ...dane.findings,
880
+ ...ptr.findings,
881
+ ];
882
+ // Group by severity
883
+ const grouped = {
884
+ critical: [],
885
+ high: [],
886
+ medium: [],
887
+ low: [],
888
+ info: [],
889
+ };
890
+ for (const f of allFindings) {
891
+ grouped[f.severity].push(f);
892
+ }
893
+ return {
894
+ domain,
895
+ summary: {
896
+ critical: grouped.critical.length,
897
+ high: grouped.high.length,
898
+ medium: grouped.medium.length,
899
+ low: grouped.low.length,
900
+ info: grouped.info.length,
901
+ total: allFindings.length,
902
+ },
903
+ findings: grouped,
904
+ checks: { spf, dkim, dmarc, bimi, mtaSts, dane, ptr, spoofability },
905
+ };
906
+ }
907
+ // ─── Tool Definitions ───
908
+ const emailCheckSpf = {
909
+ name: "email_check_spf",
910
+ description: "Check SPF (Sender Policy Framework) record for a domain. Parses mechanisms, qualifiers, follows include chains, counts DNS lookups (RFC 7208 max 10), and flags misconfigurations like ~all, +all, ptr, and excessive lookups.",
911
+ schema: {
912
+ domain: z.string().describe("The domain to check SPF records for (e.g. example.com)"),
913
+ },
914
+ execute: async (args) => {
915
+ const domain = args.domain;
916
+ const result = await checkSpf(domain);
917
+ return json(result);
918
+ },
919
+ };
920
+ const emailCheckDkim = {
921
+ name: "email_check_dkim",
922
+ description: "Check DKIM (DomainKeys Identified Mail) records for a domain by probing common selectors. Parses key type, estimates RSA key size, and flags weak keys (<1024 bits), testing mode (t=y), and revoked keys (empty p=).",
923
+ schema: {
924
+ domain: z.string().describe("The domain to check DKIM records for (e.g. example.com)"),
925
+ selectors: z
926
+ .array(z.string())
927
+ .optional()
928
+ .describe("Optional list of DKIM selectors to check. If omitted, a built-in list of ~80 common selectors is used."),
929
+ },
930
+ execute: async (args) => {
931
+ const domain = args.domain;
932
+ const selectors = args.selectors ?? DKIM_SELECTORS;
933
+ const result = await checkDkim(domain, selectors);
934
+ return json(result);
935
+ },
936
+ };
937
+ const emailCheckDmarc = {
938
+ name: "email_check_dmarc",
939
+ description: "Check DMARC (Domain-based Message Authentication, Reporting & Conformance) record for a domain. Parses policy (p=), subdomain policy (sp=), percentage (pct=), reporting URIs (rua/ruf), and alignment modes (aspf/adkim). Flags p=none, missing reporting, and relaxed alignment.",
940
+ schema: {
941
+ domain: z.string().describe("The domain to check DMARC records for (e.g. example.com)"),
942
+ },
943
+ execute: async (args) => {
944
+ const domain = args.domain;
945
+ const result = await checkDmarc(domain);
946
+ return json(result);
947
+ },
948
+ };
949
+ const emailCheckBimi = {
950
+ name: "email_check_bimi",
951
+ description: "Check BIMI (Brand Indicators for Message Identification) record for a domain. Validates the presence of v=BIMI1, logo URL (l=), and VMC certificate URL (a=).",
952
+ schema: {
953
+ domain: z.string().describe("The domain to check BIMI records for (e.g. example.com)"),
954
+ },
955
+ execute: async (args) => {
956
+ const domain = args.domain;
957
+ const result = await checkBimi(domain);
958
+ return json(result);
959
+ },
960
+ };
961
+ const emailCheckMtaSts = {
962
+ name: "email_check_mta_sts",
963
+ description: "Check MTA-STS (Mail Transfer Agent Strict Transport Security) for a domain. Queries the _mta-sts TXT record for the policy ID, then fetches the HTTPS policy file. Parses mode (enforce/testing/none), MX patterns, and max_age. Flags testing mode, short max_age, and missing policy files.",
964
+ schema: {
965
+ domain: z.string().describe("The domain to check MTA-STS for (e.g. example.com)"),
966
+ },
967
+ execute: async (args) => {
968
+ const domain = args.domain;
969
+ const result = await checkMtaSts(domain);
970
+ return json(result);
971
+ },
972
+ };
973
+ const emailCheckDane = {
974
+ name: "email_check_dane",
975
+ description: "Check DANE/TLSA records for a domain's MX hosts. Resolves MX records, then queries TLSA records at _25._tcp.<mx-host> using raw DNS queries. Reports certificate usage, selector, and matching type fields. Flags missing TLSA records and DANE without DNSSEC.",
976
+ schema: {
977
+ domain: z.string().describe("The domain to check DANE/TLSA records for (e.g. example.com)"),
978
+ },
979
+ execute: async (args) => {
980
+ const domain = args.domain;
981
+ const result = await checkDane(domain);
982
+ return json(result);
983
+ },
984
+ };
985
+ const emailCheckPtr = {
986
+ name: "email_check_ptr",
987
+ description: "Check PTR (reverse DNS) and FCrDNS (Forward-Confirmed reverse DNS) for a domain's MX hosts. Resolves MX -> IP -> PTR -> forward A record and verifies the IP matches. Flags missing PTR records and FCrDNS mismatches that can cause mail delivery failures.",
988
+ schema: {
989
+ domain: z.string().describe("The domain to check PTR/FCrDNS for (e.g. example.com)"),
990
+ },
991
+ execute: async (args) => {
992
+ const domain = args.domain;
993
+ const result = await checkPtr(domain);
994
+ return json(result);
995
+ },
996
+ };
997
+ const emailSpoofabilityScore = {
998
+ name: "email_spoofability_score",
999
+ description: "Calculate an email spoofability score (0-100) for a domain based on SPF, DKIM, and DMARC configuration. Returns a score, verdict (easily spoofable / moderately protected / well protected), and per-check breakdown.",
1000
+ schema: {
1001
+ domain: z.string().describe("The domain to calculate spoofability score for (e.g. example.com)"),
1002
+ },
1003
+ execute: async (args) => {
1004
+ const domain = args.domain;
1005
+ const result = await calcSpoofability(domain);
1006
+ return json(result);
1007
+ },
1008
+ };
1009
+ const emailFullAudit = {
1010
+ name: "email_full_audit",
1011
+ description: "Run a comprehensive email security audit for a domain. Checks SPF, DKIM, DMARC, BIMI, MTA-STS, DANE/TLSA, PTR/FCrDNS, and calculates spoofability score. Aggregates all findings into a single report grouped by severity (critical/high/medium/low/info).",
1012
+ schema: {
1013
+ domain: z.string().describe("The domain to run a full email security audit on (e.g. example.com)"),
1014
+ },
1015
+ execute: async (args) => {
1016
+ const domain = args.domain;
1017
+ const result = await runFullAudit(domain);
1018
+ return json(result);
1019
+ },
1020
+ };
1021
+ function cidrSize(cidr) {
1022
+ const slash = cidr.indexOf("/");
1023
+ if (slash === -1)
1024
+ return 1;
1025
+ const prefix = parseInt(cidr.slice(slash + 1), 10);
1026
+ return Math.pow(2, 32 - prefix);
1027
+ }
1028
+ async function enumerateSpfIps(domain, resolver, visited, chain, depth, maxDepth) {
1029
+ if (depth > maxDepth || visited.has(domain.toLowerCase())) {
1030
+ return { ipv4: [], ipv6: [], aHosts: [], mxHosts: [] };
1031
+ }
1032
+ visited.add(domain.toLowerCase());
1033
+ const ipv4 = [];
1034
+ const ipv6 = [];
1035
+ const aHosts = [];
1036
+ const mxHosts = [];
1037
+ let spfRecord = null;
1038
+ try {
1039
+ const txtRecords = await resolver.resolveTxt(domain);
1040
+ const spf = txtRecords
1041
+ .map((r) => r.join(""))
1042
+ .find((r) => r.toLowerCase().startsWith("v=spf1"));
1043
+ if (spf)
1044
+ spfRecord = spf;
1045
+ }
1046
+ catch {
1047
+ return { ipv4, ipv6, aHosts, mxHosts };
1048
+ }
1049
+ if (!spfRecord)
1050
+ return { ipv4, ipv6, aHosts, mxHosts };
1051
+ chain.push({ domain, record: spfRecord, depth });
1052
+ const parts = spfRecord.split(/\s+/).slice(1);
1053
+ for (const part of parts) {
1054
+ const mechanism = part.replace(/^[+\-~?]/, "");
1055
+ const [type, ...rest] = mechanism.split(":");
1056
+ const value = rest.join(":");
1057
+ switch (type?.toLowerCase()) {
1058
+ case "ip4":
1059
+ if (value)
1060
+ ipv4.push(value.includes("/") ? value : `${value}/32`);
1061
+ break;
1062
+ case "ip6":
1063
+ if (value)
1064
+ ipv6.push(value.includes("/") ? value : `${value}/128`);
1065
+ break;
1066
+ case "a": {
1067
+ const target = value || domain;
1068
+ aHosts.push(target);
1069
+ try {
1070
+ const ips = await resolver.resolve4(target);
1071
+ for (const ip of ips)
1072
+ ipv4.push(`${ip}/32`);
1073
+ }
1074
+ catch { /* skip */ }
1075
+ try {
1076
+ const ips = await resolver.resolve6(target);
1077
+ for (const ip of ips)
1078
+ ipv6.push(`${ip}/128`);
1079
+ }
1080
+ catch { /* skip */ }
1081
+ break;
1082
+ }
1083
+ case "mx": {
1084
+ const target = value || domain;
1085
+ mxHosts.push(target);
1086
+ try {
1087
+ const mxRecords = await resolver.resolveMx(target);
1088
+ for (const mx of mxRecords) {
1089
+ try {
1090
+ const ips = await resolver.resolve4(mx.exchange);
1091
+ for (const ip of ips)
1092
+ ipv4.push(`${ip}/32`);
1093
+ }
1094
+ catch { /* skip */ }
1095
+ }
1096
+ }
1097
+ catch { /* skip */ }
1098
+ break;
1099
+ }
1100
+ case "include":
1101
+ case "redirect": {
1102
+ const target = type === "redirect" ? value : value;
1103
+ if (target) {
1104
+ const sub = await enumerateSpfIps(target, resolver, visited, chain, depth + 1, maxDepth);
1105
+ ipv4.push(...sub.ipv4);
1106
+ ipv6.push(...sub.ipv6);
1107
+ aHosts.push(...sub.aHosts);
1108
+ mxHosts.push(...sub.mxHosts);
1109
+ }
1110
+ break;
1111
+ }
1112
+ }
1113
+ }
1114
+ return { ipv4, ipv6, aHosts, mxHosts };
1115
+ }
1116
+ const emailSpfEnumerate = {
1117
+ name: "email_spf_enumerate",
1118
+ description: "Recursively walk the entire SPF include chain for a domain and extract all authorized IP addresses and CIDR ranges. Reveals mail infrastructure: cloud providers, hosting ranges, third-party senders. Useful for attack surface mapping and infrastructure reconnaissance.",
1119
+ schema: {
1120
+ domain: z.string().describe("The domain to enumerate SPF IPs for (e.g. google.com)"),
1121
+ max_depth: z
1122
+ .number()
1123
+ .optional()
1124
+ .describe("Maximum include chain depth to follow (default 10)"),
1125
+ },
1126
+ execute: async (args) => {
1127
+ const domain = args.domain;
1128
+ const maxDepth = args.max_depth ?? 10;
1129
+ const resolver = createResolver();
1130
+ const visited = new Set();
1131
+ const chain = [];
1132
+ const findings = [];
1133
+ const result = await enumerateSpfIps(domain, resolver, visited, chain, 0, maxDepth);
1134
+ const uniqueIpv4 = [...new Set(result.ipv4)].sort();
1135
+ const uniqueIpv6 = [...new Set(result.ipv6)].sort();
1136
+ const totalIpv4 = uniqueIpv4.reduce((sum, cidr) => sum + cidrSize(cidr), 0);
1137
+ if (chain.length === 0) {
1138
+ findings.push({
1139
+ severity: "info",
1140
+ title: "No SPF record found",
1141
+ description: `${domain} has no SPF record. No IP ranges to enumerate.`,
1142
+ });
1143
+ }
1144
+ if (visited.size > 10) {
1145
+ findings.push({
1146
+ severity: "medium",
1147
+ title: "Excessive SPF include chain",
1148
+ description: `SPF chain spans ${visited.size} domains, exceeding the RFC 7208 limit of 10 DNS lookups.`,
1149
+ remediation: "Flatten the SPF record by replacing includes with direct ip4/ip6 mechanisms.",
1150
+ });
1151
+ }
1152
+ if (uniqueIpv4.some((r) => {
1153
+ const slash = r.indexOf("/");
1154
+ return slash !== -1 && parseInt(r.slice(slash + 1), 10) <= 16;
1155
+ })) {
1156
+ findings.push({
1157
+ severity: "info",
1158
+ title: "Large CIDR ranges in SPF",
1159
+ description: "SPF authorizes /16 or larger ranges, potentially allowing many hosts to send email.",
1160
+ });
1161
+ }
1162
+ const output = {
1163
+ domain,
1164
+ ipv4Ranges: uniqueIpv4,
1165
+ ipv6Ranges: uniqueIpv6,
1166
+ totalIpv4Addresses: totalIpv4,
1167
+ chain,
1168
+ aHosts: [...new Set(result.aHosts)],
1169
+ mxHosts: [...new Set(result.mxHosts)],
1170
+ findings,
1171
+ };
1172
+ return json(output);
1173
+ },
1174
+ };
1175
+ // ─── Export ───
1176
+ export const emailTools = [
1177
+ emailCheckSpf,
1178
+ emailCheckDkim,
1179
+ emailCheckDmarc,
1180
+ emailCheckBimi,
1181
+ emailCheckMtaSts,
1182
+ emailCheckDane,
1183
+ emailCheckPtr,
1184
+ emailSpoofabilityScore,
1185
+ emailFullAudit,
1186
+ emailSpfEnumerate,
1187
+ ];
1188
+ //# sourceMappingURL=index.js.map