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,1167 @@
1
+ import { z } from "zod";
2
+ import { text, json } from "../types/index.js";
3
+ import { resolveAll, createResolver, queryRaw } from "../utils/dns-client.js";
4
+ // ─── Constants ───
5
+ const FETCH_TIMEOUT = 10_000;
6
+ // ─── Helpers ───
7
+ function reverseIp(ip) {
8
+ return ip.split(".").reverse().join(".");
9
+ }
10
+ async function lookupDnsbl(query, dnsblDomain) {
11
+ const resolver = createResolver();
12
+ try {
13
+ const results = await resolver.resolve4(`${query}.${dnsblDomain}`);
14
+ return results.length > 0;
15
+ }
16
+ catch {
17
+ return false;
18
+ }
19
+ }
20
+ async function runCoreAuditChecks(domain) {
21
+ const findings = [];
22
+ const resolver = createResolver();
23
+ const scores = {};
24
+ // ── DNS Lookup ──
25
+ const allRecords = await resolveAll(domain, ["A", "AAAA", "NS", "MX", "TXT", "CAA", "SOA"]);
26
+ const aRecords = allRecords.filter((r) => r.type === "A").map((r) => r.data);
27
+ const aaaaRecords = allRecords.filter((r) => r.type === "AAAA").map((r) => r.data);
28
+ const nsRecords = allRecords.filter((r) => r.type === "NS").map((r) => r.data);
29
+ const mxRecords = allRecords.filter((r) => r.type === "MX").map((r) => r.data);
30
+ const txtRecords = allRecords.filter((r) => r.type === "TXT").map((r) => r.data);
31
+ const caaRecords = allRecords.filter((r) => r.type === "CAA").map((r) => r.data);
32
+ const soaRecord = allRecords.find((r) => r.type === "SOA")?.data ?? null;
33
+ const records = {
34
+ a: aRecords,
35
+ aaaa: aaaaRecords,
36
+ ns: nsRecords,
37
+ mx: mxRecords,
38
+ txt: txtRecords,
39
+ caa: caaRecords,
40
+ soa: soaRecord,
41
+ };
42
+ // ── DNS Resolution ──
43
+ scores.dns = { passed: 0, total: 2 };
44
+ if (aRecords.length > 0 || aaaaRecords.length > 0) {
45
+ scores.dns.passed++;
46
+ }
47
+ else {
48
+ findings.push({
49
+ severity: "critical",
50
+ title: "No A/AAAA records",
51
+ description: `Domain ${domain} does not resolve to any IP address.`,
52
+ remediation: "Ensure A and/or AAAA records are configured.",
53
+ category: "dns",
54
+ });
55
+ }
56
+ if (nsRecords.length > 0) {
57
+ scores.dns.passed++;
58
+ }
59
+ else {
60
+ findings.push({
61
+ severity: "high",
62
+ title: "No NS records",
63
+ description: `No nameserver records found for ${domain}.`,
64
+ remediation: "Configure NS records at the domain registrar.",
65
+ category: "dns",
66
+ });
67
+ }
68
+ // ── DNSSEC Presence ──
69
+ scores.dnssec = { passed: 0, total: 2 };
70
+ // Check for DS record (delegation signer)
71
+ let hasDsRecord = false;
72
+ try {
73
+ const dsResult = await queryRaw(domain, "DS", "8.8.8.8", 53, { rd: true, dnssec: true });
74
+ hasDsRecord = dsResult.answers.some((a) => a.type === "DS");
75
+ }
76
+ catch {
77
+ // DS query failed
78
+ }
79
+ if (hasDsRecord) {
80
+ scores.dnssec.passed++;
81
+ }
82
+ else {
83
+ findings.push({
84
+ severity: "high",
85
+ title: "No DNSSEC DS record",
86
+ description: `No DS (Delegation Signer) record found for ${domain}. DNSSEC is not enabled.`,
87
+ remediation: "Enable DNSSEC by publishing DS records at the parent zone/registrar.",
88
+ category: "dnssec",
89
+ });
90
+ }
91
+ // Check for DNSKEY record
92
+ let hasDnskeyRecord = false;
93
+ try {
94
+ const dnskeyResult = await queryRaw(domain, "DNSKEY", "8.8.8.8", 53, { rd: true, dnssec: true });
95
+ hasDnskeyRecord = dnskeyResult.answers.some((a) => a.type === "DNSKEY");
96
+ }
97
+ catch {
98
+ // DNSKEY query failed
99
+ }
100
+ if (hasDnskeyRecord) {
101
+ scores.dnssec.passed++;
102
+ }
103
+ else {
104
+ findings.push({
105
+ severity: "medium",
106
+ title: "No DNSKEY record",
107
+ description: `No DNSKEY record found for ${domain}.`,
108
+ remediation: "Configure DNSKEY records as part of DNSSEC deployment.",
109
+ category: "dnssec",
110
+ });
111
+ }
112
+ // ── Email Security (SPF, DMARC) ──
113
+ scores.email = { passed: 0, total: 3 };
114
+ // SPF
115
+ const spfRecords = txtRecords.filter((t) => t.startsWith("v=spf1"));
116
+ if (spfRecords.length === 1) {
117
+ scores.email.passed++;
118
+ // Check for overly permissive SPF
119
+ if (spfRecords[0].includes("+all")) {
120
+ findings.push({
121
+ severity: "high",
122
+ title: "SPF permits all senders (+all)",
123
+ description: `SPF record uses +all which allows any server to send email for ${domain}.`,
124
+ remediation: "Use -all (hard fail) or ~all (soft fail) instead of +all.",
125
+ category: "email",
126
+ });
127
+ }
128
+ }
129
+ else if (spfRecords.length > 1) {
130
+ findings.push({
131
+ severity: "medium",
132
+ title: "Multiple SPF records",
133
+ description: `Found ${spfRecords.length} SPF records. RFC 7208 requires exactly one SPF record.`,
134
+ remediation: "Consolidate into a single SPF TXT record.",
135
+ category: "email",
136
+ });
137
+ }
138
+ else {
139
+ findings.push({
140
+ severity: "high",
141
+ title: "No SPF record",
142
+ description: `No SPF TXT record found for ${domain}.`,
143
+ remediation: "Publish a v=spf1 TXT record to authorize mail senders.",
144
+ category: "email",
145
+ });
146
+ }
147
+ // DMARC
148
+ let hasDmarc = false;
149
+ try {
150
+ const dmarcTxt = await resolver.resolveTxt(`_dmarc.${domain}`);
151
+ const dmarcRecords = dmarcTxt.map((chunks) => chunks.join("")).filter((t) => t.startsWith("v=DMARC1"));
152
+ if (dmarcRecords.length > 0) {
153
+ hasDmarc = true;
154
+ scores.email.passed++;
155
+ // Check DMARC policy
156
+ const policy = dmarcRecords[0].match(/;\s*p=(\w+)/);
157
+ if (policy && policy[1] === "none") {
158
+ findings.push({
159
+ severity: "medium",
160
+ title: "DMARC policy set to none",
161
+ description: `DMARC policy is p=none — monitoring only, no enforcement.`,
162
+ remediation: "Progress to p=quarantine or p=reject after monitoring.",
163
+ category: "email",
164
+ });
165
+ }
166
+ }
167
+ }
168
+ catch {
169
+ // No DMARC record
170
+ }
171
+ if (!hasDmarc) {
172
+ findings.push({
173
+ severity: "high",
174
+ title: "No DMARC record",
175
+ description: `No DMARC record found at _dmarc.${domain}.`,
176
+ remediation: "Publish a DMARC TXT record at _dmarc.${domain} (e.g., v=DMARC1; p=reject; rua=mailto:dmarc@${domain}).",
177
+ category: "email",
178
+ });
179
+ }
180
+ // DKIM (check common selectors)
181
+ let hasDkim = false;
182
+ const dkimSelectors = ["default", "google", "selector1", "selector2", "dkim", "k1", "s1", "s2"];
183
+ for (const sel of dkimSelectors) {
184
+ try {
185
+ const dkimTxt = await resolver.resolveTxt(`${sel}._domainkey.${domain}`);
186
+ if (dkimTxt.length > 0) {
187
+ hasDkim = true;
188
+ break;
189
+ }
190
+ }
191
+ catch {
192
+ // Selector not found
193
+ }
194
+ }
195
+ if (hasDkim) {
196
+ scores.email.passed++;
197
+ }
198
+ else {
199
+ findings.push({
200
+ severity: "medium",
201
+ title: "No DKIM records found",
202
+ description: `No DKIM records found for common selectors at ${domain}.`,
203
+ remediation: "Configure DKIM signing and publish corresponding DNS records.",
204
+ category: "email",
205
+ });
206
+ }
207
+ // ── NS Diversity ──
208
+ scores.ns_diversity = { passed: 0, total: 2 };
209
+ if (nsRecords.length >= 2) {
210
+ scores.ns_diversity.passed++;
211
+ }
212
+ else {
213
+ findings.push({
214
+ severity: "high",
215
+ title: "Insufficient NS diversity",
216
+ description: `Only ${nsRecords.length} nameserver(s) found. At least 2 are recommended.`,
217
+ remediation: "Add additional nameservers on different networks for redundancy.",
218
+ category: "infrastructure",
219
+ });
220
+ }
221
+ // Check NS on different /24 subnets
222
+ const nsSubnets = new Set();
223
+ for (const ns of nsRecords.slice(0, 10)) {
224
+ try {
225
+ const nsIps = await resolver.resolve4(ns);
226
+ for (const ip of nsIps) {
227
+ nsSubnets.add(ip.split(".").slice(0, 3).join("."));
228
+ }
229
+ }
230
+ catch {
231
+ // NS resolution failed
232
+ }
233
+ }
234
+ if (nsSubnets.size >= 2) {
235
+ scores.ns_diversity.passed++;
236
+ }
237
+ else if (nsRecords.length >= 2) {
238
+ findings.push({
239
+ severity: "medium",
240
+ title: "Nameservers on same subnet",
241
+ description: `All nameservers resolve to the same /24 subnet (${[...nsSubnets].join(", ")}). A network outage could affect all NS.`,
242
+ remediation: "Use nameservers on different /24 networks for resilience.",
243
+ category: "infrastructure",
244
+ });
245
+ }
246
+ // ── CAA Records ──
247
+ scores.caa = { passed: 0, total: 1 };
248
+ if (caaRecords.length > 0) {
249
+ scores.caa.passed++;
250
+ }
251
+ else {
252
+ findings.push({
253
+ severity: "medium",
254
+ title: "No CAA records",
255
+ description: `No CAA records found for ${domain}. Any CA can issue certificates.`,
256
+ remediation: "Publish CAA records to restrict which CAs can issue certificates for this domain.",
257
+ category: "ct",
258
+ });
259
+ }
260
+ // ── Blocklist Check (Spamhaus DBL) ──
261
+ scores.blocklist = { passed: 0, total: 1 };
262
+ const isBlocklisted = await lookupDnsbl(domain, "dbl.spamhaus.org");
263
+ if (!isBlocklisted) {
264
+ scores.blocklist.passed++;
265
+ }
266
+ else {
267
+ findings.push({
268
+ severity: "critical",
269
+ title: "Domain listed on Spamhaus DBL",
270
+ description: `${domain} is listed on the Spamhaus Domain Block List (DBL).`,
271
+ remediation: "Investigate the listing cause and request removal at https://www.spamhaus.org/dbl/removal/.",
272
+ category: "blocklist",
273
+ });
274
+ }
275
+ // ── Dangling CNAME check on common subdomains ──
276
+ scores.hijack = { passed: 0, total: 1 };
277
+ const danglingSubdomains = [];
278
+ const commonSubs = ["www", "mail", "ftp", "webmail", "blog", "shop", "dev", "staging"];
279
+ await Promise.all(commonSubs.map(async (sub) => {
280
+ const fqdn = `${sub}.${domain}`;
281
+ try {
282
+ const cnames = await resolver.resolveCname(fqdn);
283
+ if (cnames.length > 0) {
284
+ // Check if the CNAME target resolves
285
+ for (const cname of cnames) {
286
+ try {
287
+ await resolver.resolve4(cname);
288
+ }
289
+ catch {
290
+ // CNAME target does not resolve — dangling
291
+ danglingSubdomains.push(`${fqdn} -> ${cname}`);
292
+ }
293
+ }
294
+ }
295
+ }
296
+ catch {
297
+ // No CNAME or doesn't exist
298
+ }
299
+ }));
300
+ if (danglingSubdomains.length === 0) {
301
+ scores.hijack.passed++;
302
+ }
303
+ else {
304
+ for (const dangling of danglingSubdomains) {
305
+ findings.push({
306
+ severity: "high",
307
+ title: "Dangling CNAME detected",
308
+ description: `${dangling} — CNAME target does not resolve. Vulnerable to subdomain takeover.`,
309
+ remediation: "Remove the dangling CNAME record or point it to a valid target.",
310
+ category: "hijack",
311
+ });
312
+ }
313
+ }
314
+ return { findings, records, scores };
315
+ }
316
+ // ─── Tool 1: report_rfc_compliance ───
317
+ const reportRfcCompliance = {
318
+ name: "report_rfc_compliance",
319
+ description: "Tests domain DNS compliance with key RFCs: RFC 1035 (valid labels, length), RFC 4034 (DNSSEC — DS/DNSKEY), " +
320
+ "RFC 6891 (EDNS — NS responds to EDNS), RFC 7208 (SPF — TXT has SPF), RFC 7489 (DMARC — _dmarc TXT). " +
321
+ "Returns per-RFC pass/fail status.",
322
+ schema: {
323
+ domain: z.string().describe("The domain to test for RFC compliance (e.g. 'example.com')"),
324
+ },
325
+ async execute(args) {
326
+ const domain = args.domain;
327
+ try {
328
+ const rfcResults = [];
329
+ const resolver = createResolver();
330
+ // ── RFC 1035: Basic DNS Compliance ──
331
+ // Check label validity
332
+ const labels = domain.split(".");
333
+ let rfc1035Pass = true;
334
+ const rfc1035Details = [];
335
+ // Total domain length <= 253
336
+ if (domain.length > 253) {
337
+ rfc1035Pass = false;
338
+ rfc1035Details.push(`Domain length ${domain.length} exceeds 253 character limit`);
339
+ }
340
+ // Each label <= 63 characters, valid characters
341
+ for (const label of labels) {
342
+ if (label.length > 63) {
343
+ rfc1035Pass = false;
344
+ rfc1035Details.push(`Label "${label}" exceeds 63 character limit (${label.length})`);
345
+ }
346
+ if (!/^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?$/.test(label)) {
347
+ rfc1035Pass = false;
348
+ rfc1035Details.push(`Label "${label}" contains invalid characters`);
349
+ }
350
+ }
351
+ rfcResults.push({
352
+ rfc: "RFC 1035",
353
+ title: "Domain Names — Implementation and Specification",
354
+ status: rfc1035Pass ? "pass" : "fail",
355
+ detail: rfc1035Pass
356
+ ? "All labels valid, length within limits."
357
+ : rfc1035Details.join("; "),
358
+ });
359
+ // ── RFC 4034: DNSSEC ──
360
+ let hasDsOrDnskey = false;
361
+ const rfc4034Details = [];
362
+ try {
363
+ const dsResult = await queryRaw(domain, "DS", "8.8.8.8", 53, { rd: true, dnssec: true });
364
+ const dsFound = dsResult.answers.some((a) => a.type === "DS");
365
+ if (dsFound) {
366
+ hasDsOrDnskey = true;
367
+ rfc4034Details.push("DS record found");
368
+ }
369
+ }
370
+ catch {
371
+ rfc4034Details.push("DS query failed");
372
+ }
373
+ try {
374
+ const dnskeyResult = await queryRaw(domain, "DNSKEY", "8.8.8.8", 53, { rd: true, dnssec: true });
375
+ const dnskeyFound = dnskeyResult.answers.some((a) => a.type === "DNSKEY");
376
+ if (dnskeyFound) {
377
+ hasDsOrDnskey = true;
378
+ rfc4034Details.push("DNSKEY record found");
379
+ }
380
+ }
381
+ catch {
382
+ rfc4034Details.push("DNSKEY query failed");
383
+ }
384
+ rfcResults.push({
385
+ rfc: "RFC 4034",
386
+ title: "Resource Records for DNS Security Extensions (DNSSEC)",
387
+ status: hasDsOrDnskey ? "pass" : "fail",
388
+ detail: hasDsOrDnskey
389
+ ? rfc4034Details.join("; ")
390
+ : "No DS or DNSKEY records found — DNSSEC not enabled.",
391
+ });
392
+ // ── RFC 6891: EDNS ──
393
+ let ednsSupported = false;
394
+ let rfc6891Detail = "";
395
+ try {
396
+ // Query with EDNS OPT record
397
+ const ednsResult = await queryRaw(domain, "A", "8.8.8.8", 53, { rd: true, dnssec: true });
398
+ // Check for OPT in additionals
399
+ const hasOpt = ednsResult.additionals.some((a) => a.type === "OPT");
400
+ if (hasOpt) {
401
+ ednsSupported = true;
402
+ rfc6891Detail = "Resolver responds with EDNS OPT record.";
403
+ }
404
+ else {
405
+ // Even without OPT in response, if we get answers, EDNS is at least tolerated
406
+ if (ednsResult.answers.length > 0) {
407
+ ednsSupported = true;
408
+ rfc6891Detail = "Server processes EDNS queries (no OPT in response but query succeeded).";
409
+ }
410
+ else {
411
+ rfc6891Detail = "No EDNS support detected.";
412
+ }
413
+ }
414
+ }
415
+ catch {
416
+ rfc6891Detail = "EDNS query failed — server may not support extension mechanisms.";
417
+ }
418
+ rfcResults.push({
419
+ rfc: "RFC 6891",
420
+ title: "Extension Mechanisms for DNS (EDNS(0))",
421
+ status: ednsSupported ? "pass" : "warn",
422
+ detail: rfc6891Detail,
423
+ });
424
+ // ── RFC 7208: SPF ──
425
+ let hasSpf = false;
426
+ let rfc7208Detail = "";
427
+ try {
428
+ const txtResults = await resolver.resolveTxt(domain);
429
+ const spfRecords = txtResults
430
+ .map((chunks) => chunks.join(""))
431
+ .filter((t) => t.startsWith("v=spf1"));
432
+ if (spfRecords.length === 1) {
433
+ hasSpf = true;
434
+ rfc7208Detail = `SPF record found: ${spfRecords[0].substring(0, 100)}`;
435
+ }
436
+ else if (spfRecords.length > 1) {
437
+ rfc7208Detail = `${spfRecords.length} SPF records found — RFC 7208 requires exactly one.`;
438
+ }
439
+ else {
440
+ rfc7208Detail = "No SPF record found.";
441
+ }
442
+ }
443
+ catch {
444
+ rfc7208Detail = "Failed to resolve TXT records.";
445
+ }
446
+ rfcResults.push({
447
+ rfc: "RFC 7208",
448
+ title: "Sender Policy Framework (SPF)",
449
+ status: hasSpf ? "pass" : "fail",
450
+ detail: rfc7208Detail,
451
+ });
452
+ // ── RFC 7489: DMARC ──
453
+ let hasDmarc = false;
454
+ let rfc7489Detail = "";
455
+ try {
456
+ const dmarcTxt = await resolver.resolveTxt(`_dmarc.${domain}`);
457
+ const dmarcRecords = dmarcTxt
458
+ .map((chunks) => chunks.join(""))
459
+ .filter((t) => t.startsWith("v=DMARC1"));
460
+ if (dmarcRecords.length > 0) {
461
+ hasDmarc = true;
462
+ rfc7489Detail = `DMARC record found: ${dmarcRecords[0].substring(0, 100)}`;
463
+ }
464
+ else {
465
+ rfc7489Detail = "No DMARC record found at _dmarc." + domain;
466
+ }
467
+ }
468
+ catch {
469
+ rfc7489Detail = "No DMARC record found at _dmarc." + domain;
470
+ }
471
+ rfcResults.push({
472
+ rfc: "RFC 7489",
473
+ title: "Domain-based Message Authentication, Reporting and Conformance (DMARC)",
474
+ status: hasDmarc ? "pass" : "fail",
475
+ detail: rfc7489Detail,
476
+ });
477
+ // Summary
478
+ const passed = rfcResults.filter((r) => r.status === "pass").length;
479
+ const failed = rfcResults.filter((r) => r.status === "fail").length;
480
+ const warned = rfcResults.filter((r) => r.status === "warn").length;
481
+ return json({
482
+ domain,
483
+ total_checks: rfcResults.length,
484
+ passed,
485
+ failed,
486
+ warnings: warned,
487
+ compliance_score: Math.round((passed / rfcResults.length) * 100),
488
+ rfc_results: rfcResults,
489
+ });
490
+ }
491
+ catch (err) {
492
+ return text(`Error checking RFC compliance for ${domain}: ${err.message}`);
493
+ }
494
+ },
495
+ };
496
+ // ─── Tool 2: report_best_practice ───
497
+ const reportBestPractice = {
498
+ name: "report_best_practice",
499
+ description: "CIS DNS Benchmark-style best practice checks: DNSSEC signed, email auth (SPF+DKIM+DMARC), " +
500
+ "NS diversity (>1 NS, different /24), zone transfer restricted, version.bind hidden, recursive disabled on authoritative. " +
501
+ "Returns pass/fail per check and overall score 0-100.",
502
+ schema: {
503
+ domain: z.string().describe("The domain to run best practice checks against (e.g. 'example.com')"),
504
+ },
505
+ async execute(args) {
506
+ const domain = args.domain;
507
+ try {
508
+ const checks = [];
509
+ const resolver = createResolver();
510
+ const allRecords = await resolveAll(domain, ["A", "NS", "MX", "TXT", "CAA"]);
511
+ const nsRecords = allRecords.filter((r) => r.type === "NS").map((r) => r.data);
512
+ const txtRecords = allRecords.filter((r) => r.type === "TXT").map((r) => r.data);
513
+ const caaRecords = allRecords.filter((r) => r.type === "CAA").map((r) => r.data);
514
+ // ── Check 1: DNSSEC ──
515
+ let dnssecSigned = false;
516
+ try {
517
+ const dsResult = await queryRaw(domain, "DS", "8.8.8.8", 53, { rd: true, dnssec: true });
518
+ dnssecSigned = dsResult.answers.some((a) => a.type === "DS");
519
+ }
520
+ catch {
521
+ // DS query failed
522
+ }
523
+ checks.push({
524
+ check: "DNSSEC Signed",
525
+ category: "security",
526
+ status: dnssecSigned ? "pass" : "fail",
527
+ detail: dnssecSigned ? "DS record found — zone is DNSSEC signed." : "No DS record — DNSSEC not enabled.",
528
+ weight: 20,
529
+ });
530
+ // ── Check 2: SPF ──
531
+ const spfRecords = txtRecords.filter((t) => t.startsWith("v=spf1"));
532
+ const hasValidSpf = spfRecords.length === 1;
533
+ checks.push({
534
+ check: "SPF Record",
535
+ category: "email",
536
+ status: hasValidSpf ? "pass" : "fail",
537
+ detail: hasValidSpf
538
+ ? `Valid SPF: ${spfRecords[0].substring(0, 80)}`
539
+ : spfRecords.length > 1
540
+ ? `Multiple SPF records found (${spfRecords.length}) — invalid`
541
+ : "No SPF record found.",
542
+ weight: 15,
543
+ });
544
+ // ── Check 3: DKIM ──
545
+ let hasDkim = false;
546
+ const dkimSelectors = ["default", "google", "selector1", "selector2", "dkim", "k1", "s1"];
547
+ for (const sel of dkimSelectors) {
548
+ try {
549
+ const dkimTxt = await resolver.resolveTxt(`${sel}._domainkey.${domain}`);
550
+ if (dkimTxt.length > 0) {
551
+ hasDkim = true;
552
+ break;
553
+ }
554
+ }
555
+ catch {
556
+ // Selector not found
557
+ }
558
+ }
559
+ checks.push({
560
+ check: "DKIM Record",
561
+ category: "email",
562
+ status: hasDkim ? "pass" : "warn",
563
+ detail: hasDkim
564
+ ? "DKIM record found for at least one common selector."
565
+ : "No DKIM records found for common selectors.",
566
+ weight: 10,
567
+ });
568
+ // ── Check 4: DMARC ──
569
+ let hasDmarc = false;
570
+ let dmarcPolicy = "";
571
+ try {
572
+ const dmarcTxt = await resolver.resolveTxt(`_dmarc.${domain}`);
573
+ const dmarcRecords = dmarcTxt.map((c) => c.join("")).filter((t) => t.startsWith("v=DMARC1"));
574
+ if (dmarcRecords.length > 0) {
575
+ hasDmarc = true;
576
+ const policyMatch = dmarcRecords[0].match(/;\s*p=(\w+)/);
577
+ dmarcPolicy = policyMatch ? policyMatch[1] : "none";
578
+ }
579
+ }
580
+ catch {
581
+ // No DMARC
582
+ }
583
+ checks.push({
584
+ check: "DMARC Record",
585
+ category: "email",
586
+ status: hasDmarc
587
+ ? dmarcPolicy === "reject" || dmarcPolicy === "quarantine"
588
+ ? "pass"
589
+ : "warn"
590
+ : "fail",
591
+ detail: hasDmarc
592
+ ? `DMARC found with p=${dmarcPolicy}`
593
+ : "No DMARC record found.",
594
+ weight: 15,
595
+ });
596
+ // ── Check 5: NS Diversity (>1 NS) ──
597
+ checks.push({
598
+ check: "NS Count (>1)",
599
+ category: "infrastructure",
600
+ status: nsRecords.length >= 2 ? "pass" : "fail",
601
+ detail: `${nsRecords.length} nameserver(s) found.`,
602
+ weight: 10,
603
+ });
604
+ // ── Check 6: NS on different /24 subnets ──
605
+ const nsSubnets = new Set();
606
+ for (const ns of nsRecords.slice(0, 10)) {
607
+ try {
608
+ const nsIps = await resolver.resolve4(ns);
609
+ for (const ip of nsIps) {
610
+ nsSubnets.add(ip.split(".").slice(0, 3).join("."));
611
+ }
612
+ }
613
+ catch {
614
+ // NS resolution failed
615
+ }
616
+ }
617
+ checks.push({
618
+ check: "NS Network Diversity",
619
+ category: "infrastructure",
620
+ status: nsSubnets.size >= 2 ? "pass" : nsRecords.length >= 2 ? "warn" : "fail",
621
+ detail: nsSubnets.size >= 2
622
+ ? `Nameservers span ${nsSubnets.size} different /24 subnets.`
623
+ : `All nameservers on ${nsSubnets.size} /24 subnet(s).`,
624
+ weight: 10,
625
+ });
626
+ // ── Check 7: Zone Transfer Restricted (AXFR) ──
627
+ let zoneTransferOpen = false;
628
+ for (const ns of nsRecords.slice(0, 3)) {
629
+ try {
630
+ const nsIps = await resolver.resolve4(ns);
631
+ if (nsIps.length > 0) {
632
+ // Try to import queryTcp for AXFR test
633
+ try {
634
+ const { queryTcp } = await import("../utils/dns-client.js");
635
+ const axfrResult = await queryTcp(domain, "AXFR", nsIps[0]);
636
+ if (axfrResult.length > 5) {
637
+ zoneTransferOpen = true;
638
+ }
639
+ }
640
+ catch {
641
+ // AXFR denied or timeout — good
642
+ }
643
+ }
644
+ }
645
+ catch {
646
+ // NS resolution failed
647
+ }
648
+ }
649
+ checks.push({
650
+ check: "Zone Transfer Restricted",
651
+ category: "security",
652
+ status: zoneTransferOpen ? "fail" : "pass",
653
+ detail: zoneTransferOpen
654
+ ? "AXFR zone transfer is OPEN — anyone can download the full zone."
655
+ : "Zone transfer appears restricted (AXFR denied or failed).",
656
+ weight: 10,
657
+ });
658
+ // ── Check 8: version.bind Hidden ──
659
+ let versionExposed = false;
660
+ let versionValue = "";
661
+ if (nsRecords.length > 0) {
662
+ try {
663
+ const nsIps = await resolver.resolve4(nsRecords[0]);
664
+ if (nsIps.length > 0) {
665
+ const vResult = await queryRaw("version.bind", "TXT", nsIps[0], 53, { rd: false });
666
+ const txtAnswers = vResult.answers.filter((a) => a.type === "TXT");
667
+ if (txtAnswers.length > 0) {
668
+ versionExposed = true;
669
+ const txtData = txtAnswers[0].data;
670
+ if (typeof txtData === "string") {
671
+ versionValue = txtData;
672
+ }
673
+ else if (Buffer.isBuffer(txtData)) {
674
+ versionValue = txtData.toString();
675
+ }
676
+ else if (Array.isArray(txtData)) {
677
+ versionValue = txtData.map((d) => (Buffer.isBuffer(d) ? d.toString() : String(d))).join("");
678
+ }
679
+ }
680
+ }
681
+ }
682
+ catch {
683
+ // version.bind query failed — likely hidden (good)
684
+ }
685
+ }
686
+ checks.push({
687
+ check: "version.bind Hidden",
688
+ category: "security",
689
+ status: versionExposed ? "warn" : "pass",
690
+ detail: versionExposed
691
+ ? `version.bind exposed: "${versionValue}" — reveals DNS software version.`
692
+ : "version.bind not exposed or query denied.",
693
+ weight: 5,
694
+ });
695
+ // ── Check 9: CAA Records ──
696
+ checks.push({
697
+ check: "CAA Records Present",
698
+ category: "security",
699
+ status: caaRecords.length > 0 ? "pass" : "warn",
700
+ detail: caaRecords.length > 0
701
+ ? `${caaRecords.length} CAA record(s) found — certificate issuance restricted.`
702
+ : "No CAA records — any CA can issue certificates.",
703
+ weight: 5,
704
+ });
705
+ // ── Calculate Overall Score ──
706
+ let totalWeight = 0;
707
+ let earnedWeight = 0;
708
+ for (const check of checks) {
709
+ totalWeight += check.weight;
710
+ if (check.status === "pass") {
711
+ earnedWeight += check.weight;
712
+ }
713
+ else if (check.status === "warn") {
714
+ earnedWeight += check.weight * 0.5;
715
+ }
716
+ }
717
+ const overallScore = totalWeight > 0 ? Math.round((earnedWeight / totalWeight) * 100) : 0;
718
+ return json({
719
+ domain,
720
+ overall_score: overallScore,
721
+ grade: overallScore >= 90
722
+ ? "A"
723
+ : overallScore >= 80
724
+ ? "B"
725
+ : overallScore >= 70
726
+ ? "C"
727
+ : overallScore >= 60
728
+ ? "D"
729
+ : "F",
730
+ total_checks: checks.length,
731
+ passed: checks.filter((c) => c.status === "pass").length,
732
+ warnings: checks.filter((c) => c.status === "warn").length,
733
+ failed: checks.filter((c) => c.status === "fail").length,
734
+ checks,
735
+ });
736
+ }
737
+ catch (err) {
738
+ return text(`Error running best practice checks for ${domain}: ${err.message}`);
739
+ }
740
+ },
741
+ };
742
+ // ─── Tool 3: report_full_audit ───
743
+ const reportFullAudit = {
744
+ name: "report_full_audit",
745
+ description: "Runs a comprehensive DNS security audit across all categories: DNS resolution, DNSSEC validation, " +
746
+ "email security (SPF/DKIM/DMARC), subdomain hijack (dangling CNAME/NS), Certificate Transparency, " +
747
+ "blocklist checks, and infrastructure analysis. Returns executive summary and per-category findings.",
748
+ schema: {
749
+ domain: z.string().describe("The domain to perform a full security audit on (e.g. 'example.com')"),
750
+ },
751
+ async execute(args) {
752
+ const domain = args.domain;
753
+ try {
754
+ const { findings, records, scores } = await runCoreAuditChecks(domain);
755
+ // ── Executive Summary ──
756
+ const critical = findings.filter((f) => f.severity === "critical");
757
+ const high = findings.filter((f) => f.severity === "high");
758
+ const medium = findings.filter((f) => f.severity === "medium");
759
+ const low = findings.filter((f) => f.severity === "low");
760
+ const info = findings.filter((f) => f.severity === "info");
761
+ // Calculate overall score
762
+ let totalChecks = 0;
763
+ let passedChecks = 0;
764
+ for (const [, score] of Object.entries(scores)) {
765
+ totalChecks += score.total;
766
+ passedChecks += score.passed;
767
+ }
768
+ const overallScore = totalChecks > 0 ? Math.round((passedChecks / totalChecks) * 100) : 0;
769
+ // Group findings by category
770
+ const categories = new Map();
771
+ for (const finding of findings) {
772
+ const group = categories.get(finding.category) ?? [];
773
+ group.push(finding);
774
+ categories.set(finding.category, group);
775
+ }
776
+ const categoryResults = Array.from(categories.entries()).map(([category, catFindings]) => ({
777
+ category,
778
+ finding_count: catFindings.length,
779
+ worst_severity: catFindings.reduce((worst, f) => {
780
+ const order = { critical: 0, high: 1, medium: 2, low: 3, info: 4 };
781
+ return order[f.severity] < order[worst] ? f.severity : worst;
782
+ }, "info"),
783
+ findings: catFindings,
784
+ }));
785
+ const categoryScores = Object.fromEntries(Object.entries(scores).map(([k, v]) => [
786
+ k,
787
+ { passed: v.passed, total: v.total, score: v.total > 0 ? Math.round((v.passed / v.total) * 100) : 100 },
788
+ ]));
789
+ return json({
790
+ domain,
791
+ audit_timestamp: new Date().toISOString(),
792
+ executive_summary: {
793
+ overall_score: overallScore,
794
+ grade: overallScore >= 90
795
+ ? "A"
796
+ : overallScore >= 80
797
+ ? "B"
798
+ : overallScore >= 70
799
+ ? "C"
800
+ : overallScore >= 60
801
+ ? "D"
802
+ : "F",
803
+ total_findings: findings.length,
804
+ by_severity: {
805
+ critical: critical.length,
806
+ high: high.length,
807
+ medium: medium.length,
808
+ low: low.length,
809
+ info: info.length,
810
+ },
811
+ top_risks: critical.concat(high).slice(0, 5).map((f) => ({
812
+ severity: f.severity,
813
+ title: f.title,
814
+ category: f.category,
815
+ })),
816
+ },
817
+ dns_records: records,
818
+ category_scores: categoryScores,
819
+ category_results: categoryResults,
820
+ all_findings: findings,
821
+ });
822
+ }
823
+ catch (err) {
824
+ return text(`Error running full audit for ${domain}: ${err.message}`);
825
+ }
826
+ },
827
+ };
828
+ // ─── Tool 4: report_generate ───
829
+ const reportGenerate = {
830
+ name: "report_generate",
831
+ description: "Runs a full DNS security audit and outputs in the specified format: json (raw findings), " +
832
+ "markdown (formatted report with severity headers), or sarif (SARIF 2.1.0 structure for integration " +
833
+ "with code analysis tools). Default format is json.",
834
+ schema: {
835
+ domain: z.string().describe("The domain to generate a security report for (e.g. 'example.com')"),
836
+ format: z
837
+ .enum(["json", "markdown", "sarif"])
838
+ .optional()
839
+ .describe("Output format: 'json' (raw), 'markdown' (formatted report), or 'sarif' (SARIF 2.1.0). Default 'json'."),
840
+ },
841
+ async execute(args) {
842
+ const domain = args.domain;
843
+ const format = args.format ?? "json";
844
+ try {
845
+ const { findings, records, scores } = await runCoreAuditChecks(domain);
846
+ // Calculate overall score
847
+ let totalChecks = 0;
848
+ let passedChecks = 0;
849
+ for (const [, score] of Object.entries(scores)) {
850
+ totalChecks += score.total;
851
+ passedChecks += score.passed;
852
+ }
853
+ const overallScore = totalChecks > 0 ? Math.round((passedChecks / totalChecks) * 100) : 0;
854
+ if (format === "markdown") {
855
+ // ── Markdown Format ──
856
+ const lines = [];
857
+ lines.push(`# DNS Security Audit Report: ${domain}`);
858
+ lines.push("");
859
+ lines.push(`**Date:** ${new Date().toISOString()}`);
860
+ lines.push(`**Overall Score:** ${overallScore}/100`);
861
+ lines.push("");
862
+ // Summary
863
+ const critical = findings.filter((f) => f.severity === "critical");
864
+ const high = findings.filter((f) => f.severity === "high");
865
+ const medium = findings.filter((f) => f.severity === "medium");
866
+ const low = findings.filter((f) => f.severity === "low");
867
+ lines.push("## Executive Summary");
868
+ lines.push("");
869
+ lines.push(`| Severity | Count |`);
870
+ lines.push(`|----------|-------|`);
871
+ lines.push(`| Critical | ${critical.length} |`);
872
+ lines.push(`| High | ${high.length} |`);
873
+ lines.push(`| Medium | ${medium.length} |`);
874
+ lines.push(`| Low | ${low.length} |`);
875
+ lines.push("");
876
+ // DNS Records
877
+ lines.push("## DNS Records");
878
+ lines.push("");
879
+ lines.push(`- **A:** ${records.a.join(", ") || "none"}`);
880
+ lines.push(`- **AAAA:** ${records.aaaa.join(", ") || "none"}`);
881
+ lines.push(`- **NS:** ${records.ns.join(", ") || "none"}`);
882
+ lines.push(`- **MX:** ${records.mx.join(", ") || "none"}`);
883
+ lines.push(`- **CAA:** ${records.caa.join(", ") || "none"}`);
884
+ lines.push("");
885
+ // Findings by severity
886
+ if (critical.length > 0) {
887
+ lines.push("## Critical Findings");
888
+ lines.push("");
889
+ for (const f of critical) {
890
+ lines.push(`### ${f.title}`);
891
+ lines.push(`- **Category:** ${f.category}`);
892
+ lines.push(`- **Description:** ${f.description}`);
893
+ if (f.remediation)
894
+ lines.push(`- **Remediation:** ${f.remediation}`);
895
+ lines.push("");
896
+ }
897
+ }
898
+ if (high.length > 0) {
899
+ lines.push("## High Findings");
900
+ lines.push("");
901
+ for (const f of high) {
902
+ lines.push(`### ${f.title}`);
903
+ lines.push(`- **Category:** ${f.category}`);
904
+ lines.push(`- **Description:** ${f.description}`);
905
+ if (f.remediation)
906
+ lines.push(`- **Remediation:** ${f.remediation}`);
907
+ lines.push("");
908
+ }
909
+ }
910
+ if (medium.length > 0) {
911
+ lines.push("## Medium Findings");
912
+ lines.push("");
913
+ for (const f of medium) {
914
+ lines.push(`### ${f.title}`);
915
+ lines.push(`- **Category:** ${f.category}`);
916
+ lines.push(`- **Description:** ${f.description}`);
917
+ if (f.remediation)
918
+ lines.push(`- **Remediation:** ${f.remediation}`);
919
+ lines.push("");
920
+ }
921
+ }
922
+ if (low.length > 0) {
923
+ lines.push("## Low Findings");
924
+ lines.push("");
925
+ for (const f of low) {
926
+ lines.push(`### ${f.title}`);
927
+ lines.push(`- **Category:** ${f.category}`);
928
+ lines.push(`- **Description:** ${f.description}`);
929
+ if (f.remediation)
930
+ lines.push(`- **Remediation:** ${f.remediation}`);
931
+ lines.push("");
932
+ }
933
+ }
934
+ // Category Scores
935
+ lines.push("## Category Scores");
936
+ lines.push("");
937
+ lines.push(`| Category | Score |`);
938
+ lines.push(`|----------|-------|`);
939
+ for (const [cat, score] of Object.entries(scores)) {
940
+ const pct = score.total > 0 ? Math.round((score.passed / score.total) * 100) : 100;
941
+ lines.push(`| ${cat} | ${pct}% (${score.passed}/${score.total}) |`);
942
+ }
943
+ lines.push("");
944
+ return text(lines.join("\n"));
945
+ }
946
+ if (format === "sarif") {
947
+ // ── SARIF 2.1.0 Format ──
948
+ const severityToLevel = {
949
+ critical: "error",
950
+ high: "error",
951
+ medium: "warning",
952
+ low: "note",
953
+ info: "note",
954
+ };
955
+ const rules = findings.map((f, i) => ({
956
+ id: `dns-${f.category}-${i}`,
957
+ name: f.title.replace(/[^a-zA-Z0-9]/g, ""),
958
+ shortDescription: { text: f.title },
959
+ fullDescription: { text: f.description },
960
+ help: { text: f.remediation ?? "No remediation provided." },
961
+ defaultConfiguration: {
962
+ level: severityToLevel[f.severity] ?? "note",
963
+ },
964
+ properties: {
965
+ severity: f.severity,
966
+ category: f.category,
967
+ },
968
+ }));
969
+ const results = findings.map((f, i) => ({
970
+ ruleId: `dns-${f.category}-${i}`,
971
+ level: severityToLevel[f.severity] ?? "note",
972
+ message: { text: f.description },
973
+ locations: [
974
+ {
975
+ physicalLocation: {
976
+ artifactLocation: { uri: domain },
977
+ },
978
+ },
979
+ ],
980
+ properties: {
981
+ severity: f.severity,
982
+ category: f.category,
983
+ remediation: f.remediation ?? undefined,
984
+ },
985
+ }));
986
+ const sarif = {
987
+ $schema: "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json",
988
+ version: "2.1.0",
989
+ runs: [
990
+ {
991
+ tool: {
992
+ driver: {
993
+ name: "dns-security-mcp",
994
+ version: "0.1.0",
995
+ informationUri: "https://github.com/dns-security-mcp",
996
+ rules,
997
+ },
998
+ },
999
+ results,
1000
+ invocations: [
1001
+ {
1002
+ executionSuccessful: true,
1003
+ endTimeUtc: new Date().toISOString(),
1004
+ properties: {
1005
+ domain,
1006
+ overallScore,
1007
+ },
1008
+ },
1009
+ ],
1010
+ },
1011
+ ],
1012
+ };
1013
+ return json(sarif);
1014
+ }
1015
+ // ── Default JSON Format ──
1016
+ return json({
1017
+ domain,
1018
+ format: "json",
1019
+ audit_timestamp: new Date().toISOString(),
1020
+ overall_score: overallScore,
1021
+ dns_records: records,
1022
+ category_scores: Object.fromEntries(Object.entries(scores).map(([k, v]) => [
1023
+ k,
1024
+ { passed: v.passed, total: v.total, score: v.total > 0 ? Math.round((v.passed / v.total) * 100) : 100 },
1025
+ ])),
1026
+ findings,
1027
+ });
1028
+ }
1029
+ catch (err) {
1030
+ return text(`Error generating report for ${domain}: ${err.message}`);
1031
+ }
1032
+ },
1033
+ };
1034
+ // ─── Tool 5: report_compare ───
1035
+ const reportCompare = {
1036
+ name: "report_compare",
1037
+ description: "Runs a current DNS security audit and compares it against a baseline (JSON string from a previous run). " +
1038
+ "Returns new findings, resolved findings, and unchanged findings. Useful for tracking DNS security posture over time.",
1039
+ schema: {
1040
+ domain: z.string().describe("The domain to audit and compare against baseline (e.g. 'example.com')"),
1041
+ baseline: z.string().describe("JSON string from a previous audit run (the 'findings' array from report_full_audit or report_generate output)"),
1042
+ },
1043
+ async execute(args) {
1044
+ const domain = args.domain;
1045
+ const baselineStr = args.baseline;
1046
+ try {
1047
+ // Parse baseline
1048
+ let baselineFindings;
1049
+ try {
1050
+ const parsed = JSON.parse(baselineStr);
1051
+ // Support both raw findings array and wrapped audit output
1052
+ if (Array.isArray(parsed)) {
1053
+ baselineFindings = parsed;
1054
+ }
1055
+ else if (parsed.findings && Array.isArray(parsed.findings)) {
1056
+ baselineFindings = parsed.findings;
1057
+ }
1058
+ else if (parsed.all_findings && Array.isArray(parsed.all_findings)) {
1059
+ baselineFindings = parsed.all_findings;
1060
+ }
1061
+ else {
1062
+ return text("Error: Baseline JSON must be an array of findings or an object with 'findings' or 'all_findings' key.");
1063
+ }
1064
+ }
1065
+ catch {
1066
+ return text("Error: Invalid baseline JSON string. Provide the JSON output from a previous audit run.");
1067
+ }
1068
+ // Run current audit
1069
+ const { findings: currentFindings, records, scores } = await runCoreAuditChecks(domain);
1070
+ // Create fingerprints for comparison (title + category is unique enough)
1071
+ const fingerprint = (f) => `${f.category}::${f.title}`;
1072
+ const baselineSet = new Set(baselineFindings.map(fingerprint));
1073
+ const currentSet = new Set(currentFindings.map(fingerprint));
1074
+ // Categorize
1075
+ const newFindings = [];
1076
+ const resolvedFindings = [];
1077
+ const unchangedFindings = [];
1078
+ for (const f of currentFindings) {
1079
+ const fp = fingerprint(f);
1080
+ if (baselineSet.has(fp)) {
1081
+ unchangedFindings.push(f);
1082
+ }
1083
+ else {
1084
+ newFindings.push(f);
1085
+ }
1086
+ }
1087
+ for (const f of baselineFindings) {
1088
+ const fp = fingerprint(f);
1089
+ if (!currentSet.has(fp)) {
1090
+ resolvedFindings.push(f);
1091
+ }
1092
+ }
1093
+ // Calculate scores
1094
+ let totalChecks = 0;
1095
+ let passedChecks = 0;
1096
+ for (const [, score] of Object.entries(scores)) {
1097
+ totalChecks += score.total;
1098
+ passedChecks += score.passed;
1099
+ }
1100
+ const currentScore = totalChecks > 0 ? Math.round((passedChecks / totalChecks) * 100) : 0;
1101
+ // Determine trend
1102
+ let trend;
1103
+ if (newFindings.length === 0 && resolvedFindings.length > 0) {
1104
+ trend = "improved";
1105
+ }
1106
+ else if (newFindings.length > 0 && resolvedFindings.length === 0) {
1107
+ trend = "degraded";
1108
+ }
1109
+ else if (newFindings.length > resolvedFindings.length) {
1110
+ trend = "degraded";
1111
+ }
1112
+ else if (resolvedFindings.length > newFindings.length) {
1113
+ trend = "improved";
1114
+ }
1115
+ else {
1116
+ trend = "unchanged";
1117
+ }
1118
+ return json({
1119
+ domain,
1120
+ comparison_timestamp: new Date().toISOString(),
1121
+ current_score: currentScore,
1122
+ trend,
1123
+ summary: {
1124
+ baseline_findings: baselineFindings.length,
1125
+ current_findings: currentFindings.length,
1126
+ new_findings: newFindings.length,
1127
+ resolved_findings: resolvedFindings.length,
1128
+ unchanged_findings: unchangedFindings.length,
1129
+ },
1130
+ new_findings: newFindings.map((f) => ({
1131
+ severity: f.severity,
1132
+ title: f.title,
1133
+ description: f.description,
1134
+ category: f.category,
1135
+ remediation: f.remediation,
1136
+ status: "NEW",
1137
+ })),
1138
+ resolved_findings: resolvedFindings.map((f) => ({
1139
+ severity: f.severity,
1140
+ title: f.title,
1141
+ description: f.description,
1142
+ category: f.category,
1143
+ status: "RESOLVED",
1144
+ })),
1145
+ unchanged_findings: unchangedFindings.map((f) => ({
1146
+ severity: f.severity,
1147
+ title: f.title,
1148
+ category: f.category,
1149
+ status: "UNCHANGED",
1150
+ })),
1151
+ current_dns_records: records,
1152
+ });
1153
+ }
1154
+ catch (err) {
1155
+ return text(`Error comparing audit for ${domain}: ${err.message}`);
1156
+ }
1157
+ },
1158
+ };
1159
+ // ─── Export All Report Tools ───
1160
+ export const reportTools = [
1161
+ reportRfcCompliance,
1162
+ reportBestPractice,
1163
+ reportFullAudit,
1164
+ reportGenerate,
1165
+ reportCompare,
1166
+ ];
1167
+ //# sourceMappingURL=index.js.map