blackveil-dns 2.6.2 → 2.9.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -7
- package/dist/index.d.ts +6 -1
- package/dist/index.js +575 -216
- package/dist/index.js.map +1 -1
- package/dist/stdio.js +1914 -686
- package/dist/stdio.js.map +1 -1
- package/package.json +3 -2
package/dist/stdio.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { z, ZodError } from 'zod';
|
|
3
|
-
import { checkSubdomailing as checkSubdomailing$1, checkSVCBHTTPS, checkDANEHTTPS, checkDANE, checkHTTPSecurity, checkTLSRPT, checkBIMI, checkCAA, checkNS, checkMTASTS, checkSSL, checkDNSSEC, checkDKIM, checkDMARC, checkSPF, checkSubdomainTakeover as checkSubdomainTakeover$1, createFinding as createFinding$1, buildCheckResult as buildCheckResult$1,
|
|
3
|
+
import { checkSubdomailing as checkSubdomailing$1, checkSVCBHTTPS, checkDANEHTTPS, checkDANE, checkHTTPSecurity, checkTLSRPT, checkBIMI, checkCAA, checkNS, checkMTASTS, checkSSL, checkDNSSEC, checkDKIM, checkDMARC, checkSPF, checkSubdomainTakeover as checkSubdomainTakeover$1, createFinding as createFinding$1, buildCheckResult as buildCheckResult$1, checkMX, parseDmarcTags } from '@blackveil/dns-checks';
|
|
4
4
|
import { PROFILE_WEIGHTS, createFinding, buildCheckResult, detectDomainContext, getProfileWeights, computeScanScore, IMPORTANCE_WEIGHTS, scoreToGrade } from '@blackveil/dns-checks/scoring';
|
|
5
|
+
import 'drizzle-orm';
|
|
6
|
+
import 'drizzle-orm/durable-sqlite';
|
|
5
7
|
|
|
6
8
|
var __defProp = Object.defineProperty;
|
|
7
9
|
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
@@ -75,7 +77,7 @@ var init_log = __esm({
|
|
|
75
77
|
});
|
|
76
78
|
|
|
77
79
|
// src/lib/config.ts
|
|
78
|
-
var BLOCKED_SUFFIXES, BLOCKED_HOSTS, BLOCKED_IP_PATTERNS, BLOCKED_DNS_REBINDING, MAX_DOMAIN_LENGTH, MAX_LABEL_LENGTH, LABEL_REGEX, HTTPS_TIMEOUT_MS, DNS_TIMEOUT_MS, DNS_RETRIES, DOH_EDGE_CACHE_TTL, INFLIGHT_CLEANUP_MS, DNS_RETRY_BASE_DELAY_MS, DNS_CONFIRM_WITH_SECONDARY_ON_EMPTY, GLOBAL_DAILY_TOOL_LIMIT, TIER_DAILY_LIMITS, TIER_TOOL_DAILY_LIMITS, FREE_TOOL_DAILY_LIMITS, TIER_CONCURRENT_LIMITS;
|
|
80
|
+
var BLOCKED_SUFFIXES, BLOCKED_HOSTS, BLOCKED_IP_PATTERNS, BLOCKED_DNS_REBINDING, MAX_DOMAIN_LENGTH, MAX_LABEL_LENGTH, LABEL_REGEX, HTTPS_TIMEOUT_MS, DNS_TIMEOUT_MS, DNS_RETRIES, DOH_EDGE_CACHE_TTL, INFLIGHT_CLEANUP_MS, DNS_RETRY_BASE_DELAY_MS, DNS_CONFIRM_WITH_SECONDARY_ON_EMPTY, GLOBAL_DAILY_TOOL_LIMIT, TIER_DAILY_LIMITS, TIER_TOOL_DAILY_LIMITS, FREE_TOOL_DAILY_LIMITS, TIER_CONCURRENT_LIMITS, IP_LOCK_TTL_MS, IP_LOCK_RETRY_MS;
|
|
79
81
|
var init_config = __esm({
|
|
80
82
|
"src/lib/config.ts"() {
|
|
81
83
|
BLOCKED_SUFFIXES = [
|
|
@@ -195,7 +197,14 @@ var init_config = __esm({
|
|
|
195
197
|
resolve_spf_chain: 100,
|
|
196
198
|
discover_subdomains: 50,
|
|
197
199
|
map_compliance: 75,
|
|
198
|
-
simulate_attack_paths: 75
|
|
200
|
+
simulate_attack_paths: 75,
|
|
201
|
+
check_dbl: 30,
|
|
202
|
+
check_rbl: 20,
|
|
203
|
+
cymru_asn: 30,
|
|
204
|
+
rdap_lookup: 20,
|
|
205
|
+
check_nsec_walkability: 30,
|
|
206
|
+
check_dnssec_chain: 30,
|
|
207
|
+
check_fast_flux: 10
|
|
199
208
|
};
|
|
200
209
|
TIER_CONCURRENT_LIMITS = {
|
|
201
210
|
free: 3,
|
|
@@ -205,6 +214,8 @@ var init_config = __esm({
|
|
|
205
214
|
partner: 50,
|
|
206
215
|
owner: Infinity
|
|
207
216
|
};
|
|
217
|
+
IP_LOCK_TTL_MS = 500;
|
|
218
|
+
IP_LOCK_RETRY_MS = 200;
|
|
208
219
|
}
|
|
209
220
|
});
|
|
210
221
|
|
|
@@ -213,6 +224,7 @@ var cache_exports = {};
|
|
|
213
224
|
__export(cache_exports, {
|
|
214
225
|
IN_MEMORY_CACHE: () => IN_MEMORY_CACHE,
|
|
215
226
|
TTLCache: () => TTLCache,
|
|
227
|
+
cacheDelete: () => cacheDelete,
|
|
216
228
|
cacheGet: () => cacheGet,
|
|
217
229
|
cacheSet: () => cacheSet,
|
|
218
230
|
cacheSetDeferred: () => cacheSetDeferred,
|
|
@@ -230,6 +242,15 @@ async function cacheGet(key, kv) {
|
|
|
230
242
|
}
|
|
231
243
|
return IN_MEMORY_CACHE.get(key);
|
|
232
244
|
}
|
|
245
|
+
async function cacheDelete(key, kv) {
|
|
246
|
+
IN_MEMORY_CACHE.delete(key);
|
|
247
|
+
if (kv) {
|
|
248
|
+
try {
|
|
249
|
+
await kv.delete(key);
|
|
250
|
+
} catch {
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
233
254
|
function cacheSetDeferred(key, value, ctx, kv, ttlSeconds) {
|
|
234
255
|
ctx.waitUntil(cacheSet(key, value, kv, ttlSeconds));
|
|
235
256
|
}
|
|
@@ -424,6 +445,7 @@ var init_dns_types = __esm({
|
|
|
424
445
|
DNSKEY: 48,
|
|
425
446
|
DS: 43,
|
|
426
447
|
RRSIG: 46,
|
|
448
|
+
NSEC3PARAM: 51,
|
|
427
449
|
PTR: 12,
|
|
428
450
|
SRV: 33,
|
|
429
451
|
HTTPS: 65
|
|
@@ -485,7 +507,7 @@ function hasTypedAnswers(response, type) {
|
|
|
485
507
|
function retryDelay(attempt) {
|
|
486
508
|
return new Promise((r) => setTimeout(r, DNS_RETRY_BASE_DELAY_MS * (attempt + 1) + Math.random() * 50));
|
|
487
509
|
}
|
|
488
|
-
async function
|
|
510
|
+
async function fetchDohOutcome(url, timeoutMs, opts) {
|
|
489
511
|
try {
|
|
490
512
|
const headers = { Accept: "application/dns-json" };
|
|
491
513
|
if (opts?.token) headers["X-BV-Token"] = opts.token;
|
|
@@ -496,11 +518,25 @@ async function fetchDohResponse(url, timeoutMs, opts) {
|
|
|
496
518
|
...opts?.useEdgeCache ? { cf: { cacheTtl: DOH_EDGE_CACHE_TTL, cacheEverything: true } } : {}
|
|
497
519
|
});
|
|
498
520
|
const response = opts?.semaphore ? await opts.semaphore.run(doFetch) : await doFetch();
|
|
499
|
-
if (!response.ok)
|
|
521
|
+
if (!response.ok) {
|
|
522
|
+
logError("DNS fetch non-2xx", {
|
|
523
|
+
severity: "warn",
|
|
524
|
+
category: "dns-transport",
|
|
525
|
+
details: { url: url.replace(/name=[^&]+/, "name=<domain>"), status: response.status }
|
|
526
|
+
});
|
|
527
|
+
return { kind: "error", reason: "http" };
|
|
528
|
+
}
|
|
500
529
|
const data = await response.json();
|
|
501
530
|
const parsed = DohResponseSchema.safeParse(data);
|
|
502
|
-
if (!parsed.success)
|
|
503
|
-
|
|
531
|
+
if (!parsed.success) {
|
|
532
|
+
logError("DNS parse failure", {
|
|
533
|
+
severity: "warn",
|
|
534
|
+
category: "dns-transport",
|
|
535
|
+
details: { url: url.replace(/name=[^&]+/, "name=<domain>") }
|
|
536
|
+
});
|
|
537
|
+
return { kind: "error", reason: "parse" };
|
|
538
|
+
}
|
|
539
|
+
return { kind: "ok", response: parsed.data };
|
|
504
540
|
} catch (err) {
|
|
505
541
|
const isTimeout = err instanceof DOMException && err.name === "TimeoutError";
|
|
506
542
|
logError(isTimeout ? "DNS fetch timeout" : "DNS fetch failed", {
|
|
@@ -508,7 +544,7 @@ async function fetchDohResponse(url, timeoutMs, opts) {
|
|
|
508
544
|
category: "dns-transport",
|
|
509
545
|
details: { url: url.replace(/name=[^&]+/, "name=<domain>"), errorType: isTimeout ? "timeout" : "network" }
|
|
510
546
|
});
|
|
511
|
-
return
|
|
547
|
+
return { kind: "error", reason: isTimeout ? "timeout" : "network" };
|
|
512
548
|
}
|
|
513
549
|
}
|
|
514
550
|
async function queryDns(domain, type, dnssecCheck = false, opts) {
|
|
@@ -570,43 +606,38 @@ async function queryDnsUncached(domain, type, dnssecCheck = false, opts) {
|
|
|
570
606
|
}
|
|
571
607
|
const data = validated.data;
|
|
572
608
|
if (confirmWithSecondaryOnEmpty && !opts?.skipSecondaryConfirmation && !hasTypedAnswers(data, type)) {
|
|
573
|
-
const
|
|
574
|
-
|
|
609
|
+
const secondaryOpts = opts?.secondaryDoh ? { secondaryDoh: { url: opts.secondaryDoh.endpoint, token: opts.secondaryDoh.token } } : void 0;
|
|
610
|
+
const secondaryResult = await confirmWithSecondaryResolvers(domain, type, dnssecCheck, timeoutMs, sem, secondaryOpts);
|
|
611
|
+
if ("kind" in secondaryResult && secondaryResult.kind === "unconfirmed") {
|
|
612
|
+
return data;
|
|
613
|
+
}
|
|
614
|
+
const confirmedResponse = secondaryResult;
|
|
615
|
+
return confirmedResponse;
|
|
575
616
|
}
|
|
576
617
|
return data;
|
|
577
618
|
}
|
|
578
619
|
throw new DnsQueryError("DNS query failed after retries", domain, type);
|
|
579
620
|
}
|
|
580
621
|
async function confirmWithSecondaryResolvers(domain, type, dnssecCheck, timeoutMs, sem, opts) {
|
|
581
|
-
const
|
|
622
|
+
const bvDnsUrl = opts?.secondaryDoh ? buildDohUrl(opts.secondaryDoh.url, domain, type, dnssecCheck) : null;
|
|
582
623
|
const googleUrl = buildDohUrl(GOOGLE_DOH_ENDPOINT, domain, type, dnssecCheck);
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
for (const r of results) {
|
|
591
|
-
if (r.status === "fulfilled" && r.value && hasTypedAnswers(r.value, type)) {
|
|
592
|
-
return r.value;
|
|
593
|
-
}
|
|
594
|
-
}
|
|
595
|
-
const allFailed = results.every((r) => r.status === "rejected" || !r.value);
|
|
596
|
-
if (allFailed) {
|
|
597
|
-
logError("All secondary DNS resolvers failed", {
|
|
598
|
-
severity: "warn",
|
|
599
|
-
category: "dns-transport",
|
|
600
|
-
details: { domain, type }
|
|
601
|
-
});
|
|
602
|
-
}
|
|
603
|
-
return null;
|
|
624
|
+
const candidates = [
|
|
625
|
+
bvDnsUrl ? fetchDohOutcome(bvDnsUrl, timeoutMs, { token: opts.secondaryDoh.token, semaphore: sem }) : Promise.resolve({ kind: "error", reason: "network" }),
|
|
626
|
+
fetchDohOutcome(googleUrl, timeoutMs, { useEdgeCache: true, semaphore: sem })
|
|
627
|
+
];
|
|
628
|
+
const results = await Promise.allSettled(candidates);
|
|
629
|
+
for (const r of results) {
|
|
630
|
+
if (r.status === "fulfilled" && r.value.kind === "ok" && hasTypedAnswers(r.value.response, type)) return r.value.response;
|
|
604
631
|
}
|
|
605
|
-
const
|
|
606
|
-
|
|
607
|
-
return google;
|
|
632
|
+
for (const r of results) {
|
|
633
|
+
if (r.status === "fulfilled" && r.value.kind === "ok") return r.value.response;
|
|
608
634
|
}
|
|
609
|
-
|
|
635
|
+
logError("All secondary resolvers failed", {
|
|
636
|
+
severity: "warn",
|
|
637
|
+
category: "dns-transport",
|
|
638
|
+
details: { type }
|
|
639
|
+
});
|
|
640
|
+
return { kind: "unconfirmed" };
|
|
610
641
|
}
|
|
611
642
|
var DOH_ENDPOINT, GOOGLE_DOH_ENDPOINT, DnsQueryError;
|
|
612
643
|
var init_dns_transport = __esm({
|
|
@@ -701,6 +732,21 @@ var init_dns2 = __esm({
|
|
|
701
732
|
}
|
|
702
733
|
});
|
|
703
734
|
|
|
735
|
+
// src/lib/dns-query-adapter.ts
|
|
736
|
+
function makeQueryDNS(dnsOptions) {
|
|
737
|
+
return async (domain, type) => {
|
|
738
|
+
if (type === "TXT") {
|
|
739
|
+
return queryTxtRecords(domain, dnsOptions);
|
|
740
|
+
}
|
|
741
|
+
return queryDnsRecords(domain, type, dnsOptions);
|
|
742
|
+
};
|
|
743
|
+
}
|
|
744
|
+
var init_dns_query_adapter = __esm({
|
|
745
|
+
"src/lib/dns-query-adapter.ts"() {
|
|
746
|
+
init_dns2();
|
|
747
|
+
}
|
|
748
|
+
});
|
|
749
|
+
|
|
704
750
|
// src/lib/provider-signature-source.ts
|
|
705
751
|
function normalizeSha256(value) {
|
|
706
752
|
return value.trim().toLowerCase().replace(/^sha256:/, "");
|
|
@@ -932,19 +978,11 @@ var check_mx_exports = {};
|
|
|
932
978
|
__export(check_mx_exports, {
|
|
933
979
|
checkMx: () => checkMx
|
|
934
980
|
});
|
|
935
|
-
function makeQueryDNS15(dnsOptions) {
|
|
936
|
-
return async (domain, type) => {
|
|
937
|
-
if (type === "TXT") {
|
|
938
|
-
return queryTxtRecords(domain, dnsOptions);
|
|
939
|
-
}
|
|
940
|
-
return queryDnsRecords(domain, type, dnsOptions);
|
|
941
|
-
};
|
|
942
|
-
}
|
|
943
981
|
async function checkMx(domain, options, dnsOptions) {
|
|
944
982
|
const timeout = dnsOptions?.timeoutMs ?? 5e3;
|
|
945
983
|
const baseResult = await checkMX(
|
|
946
984
|
domain,
|
|
947
|
-
|
|
985
|
+
makeQueryDNS(dnsOptions),
|
|
948
986
|
{ timeout }
|
|
949
987
|
);
|
|
950
988
|
const hasCritical = baseResult.findings.some((f) => f.severity === "critical");
|
|
@@ -953,6 +991,7 @@ async function checkMx(domain, options, dnsOptions) {
|
|
|
953
991
|
return baseResult;
|
|
954
992
|
}
|
|
955
993
|
const findings = [...baseResult.findings];
|
|
994
|
+
let providerDetectionFailed = false;
|
|
956
995
|
try {
|
|
957
996
|
const mxAnswers = await queryDnsRecords(domain, "MX", dnsOptions);
|
|
958
997
|
const mxTargets = mxAnswers.map((answer) => {
|
|
@@ -982,6 +1021,7 @@ async function checkMx(domain, options, dnsOptions) {
|
|
|
982
1021
|
);
|
|
983
1022
|
}
|
|
984
1023
|
if (providerSignatures.degraded) {
|
|
1024
|
+
providerDetectionFailed = true;
|
|
985
1025
|
findings.push(
|
|
986
1026
|
createFinding$1(
|
|
987
1027
|
"mx",
|
|
@@ -1000,6 +1040,7 @@ async function checkMx(domain, options, dnsOptions) {
|
|
|
1000
1040
|
}
|
|
1001
1041
|
}
|
|
1002
1042
|
} catch (err) {
|
|
1043
|
+
providerDetectionFailed = true;
|
|
1003
1044
|
logEvent({
|
|
1004
1045
|
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1005
1046
|
severity: "warn",
|
|
@@ -1010,11 +1051,16 @@ async function checkMx(domain, options, dnsOptions) {
|
|
|
1010
1051
|
details: { phase: "provider_detection" }
|
|
1011
1052
|
});
|
|
1012
1053
|
}
|
|
1013
|
-
return {
|
|
1054
|
+
return {
|
|
1055
|
+
...baseResult,
|
|
1056
|
+
findings,
|
|
1057
|
+
...providerDetectionFailed ? { metadata: { providerDetectionFailed: true } } : {}
|
|
1058
|
+
};
|
|
1014
1059
|
}
|
|
1015
1060
|
var init_check_mx = __esm({
|
|
1016
1061
|
"src/tools/check-mx.ts"() {
|
|
1017
1062
|
init_dns2();
|
|
1063
|
+
init_dns_query_adapter();
|
|
1018
1064
|
init_provider_signatures();
|
|
1019
1065
|
init_log();
|
|
1020
1066
|
}
|
|
@@ -1215,8 +1261,8 @@ function checkToolDailyRateLimitInMemory(principalId, toolName, limit) {
|
|
|
1215
1261
|
const key = `${principalId}:${toolName.trim().toLowerCase()}`;
|
|
1216
1262
|
const entry = getOrCreateToolDailyEntry(key);
|
|
1217
1263
|
entry.timestamps = pruneTimestamps(entry.timestamps, DAY_MS, now);
|
|
1218
|
-
const
|
|
1219
|
-
if (
|
|
1264
|
+
const count2 = entry.timestamps.length;
|
|
1265
|
+
if (count2 >= limit) {
|
|
1220
1266
|
const oldestInWindow = entry.timestamps[0];
|
|
1221
1267
|
const retryAfterMs = oldestInWindow + DAY_MS - now;
|
|
1222
1268
|
return {
|
|
@@ -1360,6 +1406,7 @@ var CircuitBreaker = class {
|
|
|
1360
1406
|
|
|
1361
1407
|
// src/lib/rate-limiter.ts
|
|
1362
1408
|
init_log();
|
|
1409
|
+
init_config();
|
|
1363
1410
|
var MINUTE_LIMIT = 50;
|
|
1364
1411
|
var HOUR_LIMIT = 300;
|
|
1365
1412
|
var CONTROL_PLANE_MINUTE_LIMIT = 60;
|
|
@@ -1368,6 +1415,7 @@ var MINUTE_MS2 = 6e4;
|
|
|
1368
1415
|
var HOUR_MS2 = 36e5;
|
|
1369
1416
|
var DAY_MS2 = 864e5;
|
|
1370
1417
|
var KV_IP_LOCK_TAILS = /* @__PURE__ */ new Map();
|
|
1418
|
+
var KV_IP_LOCK_MAX = 5e3;
|
|
1371
1419
|
var quotaCoordinatorBreaker = new CircuitBreaker({
|
|
1372
1420
|
name: "QuotaCoordinator",
|
|
1373
1421
|
failureThreshold: 3,
|
|
@@ -1428,12 +1476,15 @@ async function checkScopedRateLimitKV(ip, scope, minuteLimit, hourLimit, kv) {
|
|
|
1428
1476
|
});
|
|
1429
1477
|
}
|
|
1430
1478
|
async function checkRateLimitKV(ip, kv) {
|
|
1431
|
-
return
|
|
1479
|
+
return checkScopedRateLimitKVWithAdvisory(ip, "tools", MINUTE_LIMIT, HOUR_LIMIT, kv);
|
|
1432
1480
|
}
|
|
1433
1481
|
async function checkControlPlaneRateLimitKV(ip, kv) {
|
|
1434
1482
|
return checkScopedRateLimitKV(ip, "control", CONTROL_PLANE_MINUTE_LIMIT, CONTROL_PLANE_HOUR_LIMIT, kv);
|
|
1435
1483
|
}
|
|
1436
1484
|
async function withIpKvLock(ip, work) {
|
|
1485
|
+
if (KV_IP_LOCK_TAILS.size >= KV_IP_LOCK_MAX && !KV_IP_LOCK_TAILS.has(ip)) {
|
|
1486
|
+
return work();
|
|
1487
|
+
}
|
|
1437
1488
|
const prevTail = KV_IP_LOCK_TAILS.get(ip) ?? Promise.resolve();
|
|
1438
1489
|
let release;
|
|
1439
1490
|
const current = new Promise((resolve) => {
|
|
@@ -1451,6 +1502,33 @@ async function withIpKvLock(ip, work) {
|
|
|
1451
1502
|
}
|
|
1452
1503
|
}
|
|
1453
1504
|
}
|
|
1505
|
+
async function withIpKvAdvisoryLock(ip, kv, work) {
|
|
1506
|
+
const contended = KV_IP_LOCK_TAILS.has(ip);
|
|
1507
|
+
if (!contended) return work();
|
|
1508
|
+
const advisoryKey = `lk:ip:${ip}`;
|
|
1509
|
+
try {
|
|
1510
|
+
const held = await kv.get(advisoryKey);
|
|
1511
|
+
if (held) await new Promise((r) => setTimeout(r, IP_LOCK_RETRY_MS));
|
|
1512
|
+
await kv.put(advisoryKey, String(Date.now()), { expirationTtl: Math.max(1, Math.ceil(IP_LOCK_TTL_MS / 1e3)) });
|
|
1513
|
+
} catch {
|
|
1514
|
+
}
|
|
1515
|
+
try {
|
|
1516
|
+
return await work();
|
|
1517
|
+
} finally {
|
|
1518
|
+
try {
|
|
1519
|
+
await kv.delete(advisoryKey);
|
|
1520
|
+
} catch {
|
|
1521
|
+
}
|
|
1522
|
+
}
|
|
1523
|
+
}
|
|
1524
|
+
async function checkScopedRateLimitKVWithAdvisory(ip, scope, minuteLimit, hourLimit, kv) {
|
|
1525
|
+
try {
|
|
1526
|
+
return await withIpKvAdvisoryLock(ip, kv, () => checkScopedRateLimitKV(ip, scope, minuteLimit, hourLimit, kv));
|
|
1527
|
+
} catch {
|
|
1528
|
+
logError("[rate-limiter] KV error, falling back to in-memory");
|
|
1529
|
+
return checkScopedRateLimitInMemory(ip, scope, minuteLimit, hourLimit);
|
|
1530
|
+
}
|
|
1531
|
+
}
|
|
1454
1532
|
async function checkToolDailyRateLimitKV(principalId, toolName, limit, kv) {
|
|
1455
1533
|
return withIpKvLock(`tool:${principalId}:${toolName}`, async () => {
|
|
1456
1534
|
const now = Date.now();
|
|
@@ -1682,15 +1760,15 @@ function checkSessionCreateRateLimitInMemory(ip) {
|
|
|
1682
1760
|
remaining: SESSION_CREATE_LIMIT_PER_MINUTE - recent.length
|
|
1683
1761
|
};
|
|
1684
1762
|
}
|
|
1685
|
-
function evictOldestSessionCreateIps(
|
|
1686
|
-
if (
|
|
1763
|
+
function evictOldestSessionCreateIps(count2) {
|
|
1764
|
+
if (count2 <= 0) return;
|
|
1687
1765
|
const entries = [];
|
|
1688
1766
|
for (const [key, timestamps] of SESSION_CREATE_BY_IP.entries()) {
|
|
1689
1767
|
const latest = timestamps.length > 0 ? timestamps[timestamps.length - 1] : 0;
|
|
1690
1768
|
entries.push([key, latest]);
|
|
1691
1769
|
}
|
|
1692
1770
|
entries.sort((a, b) => a[1] - b[1]);
|
|
1693
|
-
for (let i = 0; i <
|
|
1771
|
+
for (let i = 0; i < count2 && i < entries.length; i++) {
|
|
1694
1772
|
SESSION_CREATE_BY_IP.delete(entries[i][0]);
|
|
1695
1773
|
}
|
|
1696
1774
|
}
|
|
@@ -1828,7 +1906,7 @@ function generateSessionId() {
|
|
|
1828
1906
|
crypto.getRandomValues(bytes);
|
|
1829
1907
|
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
1830
1908
|
}
|
|
1831
|
-
async function createSession(kv) {
|
|
1909
|
+
async function createSession(kv, analytics) {
|
|
1832
1910
|
const id = generateSessionId();
|
|
1833
1911
|
const now = Date.now();
|
|
1834
1912
|
const record = { createdAt: now, lastAccessedAt: now };
|
|
@@ -1838,6 +1916,7 @@ async function createSession(kv) {
|
|
|
1838
1916
|
await createSessionKVRecord(id, kv, record);
|
|
1839
1917
|
} catch {
|
|
1840
1918
|
logError("[session] KV create failed, in-memory fallback active", { category: "session" });
|
|
1919
|
+
analytics?.emitDegradationEvent({ degradationType: "kv_fallback", component: "session" });
|
|
1841
1920
|
}
|
|
1842
1921
|
}
|
|
1843
1922
|
return id;
|
|
@@ -2372,6 +2451,11 @@ var GenerateRolloutPlanArgs = z.object({
|
|
|
2372
2451
|
timeline: TimelineSchema.optional().describe("Rollout speed: aggressive, standard, conservative (default: standard)"),
|
|
2373
2452
|
format: FormatSchema.optional().describe("Output verbosity. Auto-detected if omitted.")
|
|
2374
2453
|
}).passthrough();
|
|
2454
|
+
var CheckFastFluxArgs = z.object({
|
|
2455
|
+
domain: DomainSchema.describe("Domain to check (e.g., example.com)"),
|
|
2456
|
+
rounds: z.number().int().min(3).max(5).optional().describe("Number of query rounds (3-5, default 3)."),
|
|
2457
|
+
format: FormatSchema.optional().describe("Output verbosity. Auto-detected if omitted.")
|
|
2458
|
+
}).passthrough();
|
|
2375
2459
|
var TOOL_SCHEMA_MAP = {
|
|
2376
2460
|
check_mx: BaseDomainArgs,
|
|
2377
2461
|
check_spf: BaseDomainArgs,
|
|
@@ -2416,7 +2500,14 @@ var TOOL_SCHEMA_MAP = {
|
|
|
2416
2500
|
resolve_spf_chain: BaseDomainArgs,
|
|
2417
2501
|
discover_subdomains: BaseDomainArgs,
|
|
2418
2502
|
map_compliance: BaseDomainArgs,
|
|
2419
|
-
simulate_attack_paths: BaseDomainArgs
|
|
2503
|
+
simulate_attack_paths: BaseDomainArgs,
|
|
2504
|
+
check_dbl: BaseDomainArgs,
|
|
2505
|
+
check_rbl: BaseDomainArgs,
|
|
2506
|
+
cymru_asn: BaseDomainArgs,
|
|
2507
|
+
rdap_lookup: BaseDomainArgs,
|
|
2508
|
+
check_nsec_walkability: BaseDomainArgs,
|
|
2509
|
+
check_dnssec_chain: BaseDomainArgs,
|
|
2510
|
+
check_fast_flux: CheckFastFluxArgs
|
|
2420
2511
|
};
|
|
2421
2512
|
|
|
2422
2513
|
// src/handlers/tool-args.ts
|
|
@@ -2519,15 +2610,7 @@ function extractExplainFindingArgs(args) {
|
|
|
2519
2610
|
init_cache();
|
|
2520
2611
|
|
|
2521
2612
|
// src/tools/check-spf.ts
|
|
2522
|
-
|
|
2523
|
-
function makeQueryDNS(dnsOptions) {
|
|
2524
|
-
return async (domain, type) => {
|
|
2525
|
-
if (type === "TXT") {
|
|
2526
|
-
return queryTxtRecords(domain, dnsOptions);
|
|
2527
|
-
}
|
|
2528
|
-
return queryDnsRecords(domain, type, dnsOptions);
|
|
2529
|
-
};
|
|
2530
|
-
}
|
|
2613
|
+
init_dns_query_adapter();
|
|
2531
2614
|
async function checkSpf(domain, dnsOptions) {
|
|
2532
2615
|
return checkSPF(
|
|
2533
2616
|
domain,
|
|
@@ -2537,25 +2620,17 @@ async function checkSpf(domain, dnsOptions) {
|
|
|
2537
2620
|
}
|
|
2538
2621
|
|
|
2539
2622
|
// src/tools/check-dmarc.ts
|
|
2540
|
-
|
|
2541
|
-
function makeQueryDNS2(dnsOptions) {
|
|
2542
|
-
return async (domain, type) => {
|
|
2543
|
-
if (type === "TXT") {
|
|
2544
|
-
return queryTxtRecords(domain, dnsOptions);
|
|
2545
|
-
}
|
|
2546
|
-
return queryDnsRecords(domain, type, dnsOptions);
|
|
2547
|
-
};
|
|
2548
|
-
}
|
|
2623
|
+
init_dns_query_adapter();
|
|
2549
2624
|
async function checkDmarc(domain, dnsOptions) {
|
|
2550
2625
|
return checkDMARC(
|
|
2551
2626
|
domain,
|
|
2552
|
-
|
|
2627
|
+
makeQueryDNS(dnsOptions),
|
|
2553
2628
|
{ timeout: dnsOptions?.timeoutMs ?? 5e3 }
|
|
2554
2629
|
);
|
|
2555
2630
|
}
|
|
2556
2631
|
|
|
2557
2632
|
// src/tools/check-dkim.ts
|
|
2558
|
-
|
|
2633
|
+
init_dns_query_adapter();
|
|
2559
2634
|
var HIGH_CONFIDENCE_DKIM_PROVIDERS = /* @__PURE__ */ new Set([
|
|
2560
2635
|
"amazon ses",
|
|
2561
2636
|
"sendgrid",
|
|
@@ -2565,18 +2640,10 @@ var HIGH_CONFIDENCE_DKIM_PROVIDERS = /* @__PURE__ */ new Set([
|
|
|
2565
2640
|
"microsoft 365"
|
|
2566
2641
|
]);
|
|
2567
2642
|
var MEDIUM_CONFIDENCE_DKIM_PROVIDERS = /* @__PURE__ */ new Set(["proofpoint", "mimecast"]);
|
|
2568
|
-
function makeQueryDNS3(dnsOptions) {
|
|
2569
|
-
return async (domain, type) => {
|
|
2570
|
-
if (type === "TXT") {
|
|
2571
|
-
return queryTxtRecords(domain, dnsOptions);
|
|
2572
|
-
}
|
|
2573
|
-
return queryDnsRecords(domain, type, dnsOptions);
|
|
2574
|
-
};
|
|
2575
|
-
}
|
|
2576
2643
|
async function checkDkim(domain, selector, dnsOptions) {
|
|
2577
2644
|
return checkDKIM(
|
|
2578
2645
|
domain,
|
|
2579
|
-
|
|
2646
|
+
makeQueryDNS(dnsOptions),
|
|
2580
2647
|
{ timeout: dnsOptions?.timeoutMs ?? 5e3, selector }
|
|
2581
2648
|
);
|
|
2582
2649
|
}
|
|
@@ -2629,6 +2696,10 @@ function applyProviderDkimContext(dkimResult, provider) {
|
|
|
2629
2696
|
|
|
2630
2697
|
// src/tools/check-dnssec.ts
|
|
2631
2698
|
init_dns2();
|
|
2699
|
+
init_dns_query_adapter();
|
|
2700
|
+
|
|
2701
|
+
// src/lib/adaptive-weights.ts
|
|
2702
|
+
var MATURITY_THRESHOLD = 200;
|
|
2632
2703
|
var SCORING_NOTE_DELTA_THRESHOLD = 3;
|
|
2633
2704
|
var CRITICAL_MAIL_CATEGORIES = /* @__PURE__ */ new Set(["dmarc", "spf", "dkim", "ssl"]);
|
|
2634
2705
|
var CRITICAL_MAIL_PROFILES = /* @__PURE__ */ new Set(["mail_enabled", "enterprise_mail"]);
|
|
@@ -2699,19 +2770,61 @@ function formatNote(category, delta, provider) {
|
|
|
2699
2770
|
function capitalizeWords(s) {
|
|
2700
2771
|
return s.replace(/\b\w/g, (c) => c.toUpperCase());
|
|
2701
2772
|
}
|
|
2702
|
-
|
|
2703
|
-
|
|
2704
|
-
|
|
2705
|
-
|
|
2706
|
-
}
|
|
2707
|
-
|
|
2708
|
-
|
|
2773
|
+
var GOOGLE_DOH_ENDPOINT2 = "https://dns.google/resolve";
|
|
2774
|
+
var AD_CONFIRM_TIMEOUT_MS = 3e3;
|
|
2775
|
+
async function confirmAdWithGoogle(domain, timeoutMs = AD_CONFIRM_TIMEOUT_MS) {
|
|
2776
|
+
try {
|
|
2777
|
+
const url = `${GOOGLE_DOH_ENDPOINT2}?name=${encodeURIComponent(domain)}&type=A&cd=0`;
|
|
2778
|
+
const resp = await fetch(url, {
|
|
2779
|
+
method: "GET",
|
|
2780
|
+
redirect: "manual",
|
|
2781
|
+
headers: { Accept: "application/dns-json" },
|
|
2782
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
2783
|
+
});
|
|
2784
|
+
if (!resp.ok) return false;
|
|
2785
|
+
const data = await resp.json();
|
|
2786
|
+
return data.AD === true;
|
|
2787
|
+
} catch {
|
|
2788
|
+
return false;
|
|
2789
|
+
}
|
|
2790
|
+
}
|
|
2791
|
+
async function augmentWithSource(domain, baseResult, dnsOptions) {
|
|
2792
|
+
const [dnskeyResult, dsResult] = await Promise.allSettled([
|
|
2793
|
+
queryDnsRecords(domain, "DNSKEY", dnsOptions),
|
|
2794
|
+
queryDnsRecords(domain, "DS", dnsOptions)
|
|
2795
|
+
]);
|
|
2796
|
+
const hasDnskey = dnskeyResult.status === "fulfilled" && dnskeyResult.value.length > 0;
|
|
2797
|
+
const hasDs = dsResult.status === "fulfilled" && dsResult.value.length > 0;
|
|
2798
|
+
const dnssecSource = hasDnskey && hasDs ? "domain_configured" : "tld_inherited";
|
|
2799
|
+
if (dnssecSource === "tld_inherited") {
|
|
2800
|
+
const inheritedFinding = createFinding(
|
|
2801
|
+
"dnssec",
|
|
2802
|
+
"DNSSEC inherited from TLD",
|
|
2803
|
+
"info",
|
|
2804
|
+
`DNSSEC validation passes but ${domain} does not have its own DNSKEY or DS records. DNSSEC protection is inherited from the TLD registry, not configured by the domain owner.`,
|
|
2805
|
+
{ dnssecSource: "tld_inherited" }
|
|
2806
|
+
);
|
|
2807
|
+
return buildCheckResult("dnssec", [...baseResult.findings, inheritedFinding]);
|
|
2808
|
+
}
|
|
2809
|
+
if (baseResult.findings.length > 0) {
|
|
2810
|
+
const [first, ...rest] = baseResult.findings;
|
|
2811
|
+
const tagged = { ...first, metadata: { ...first.metadata ?? {}, dnssecSource: "domain_configured" } };
|
|
2812
|
+
return buildCheckResult("dnssec", [tagged, ...rest]);
|
|
2813
|
+
}
|
|
2814
|
+
const configuredFinding = createFinding(
|
|
2815
|
+
"dnssec",
|
|
2816
|
+
"DNSSEC configured by domain owner",
|
|
2817
|
+
"info",
|
|
2818
|
+
`${domain} has DNSKEY and DS records \u2014 DNSSEC is explicitly configured by the domain owner.`,
|
|
2819
|
+
{ dnssecSource: "domain_configured" }
|
|
2820
|
+
);
|
|
2821
|
+
return buildCheckResult("dnssec", [configuredFinding]);
|
|
2709
2822
|
}
|
|
2710
2823
|
async function checkDnssec2(domain, dnsOptions) {
|
|
2711
2824
|
try {
|
|
2712
2825
|
const baseResult = await checkDNSSEC(
|
|
2713
2826
|
domain,
|
|
2714
|
-
|
|
2827
|
+
makeQueryDNS(dnsOptions),
|
|
2715
2828
|
{
|
|
2716
2829
|
timeout: dnsOptions?.timeoutMs ?? 5e3,
|
|
2717
2830
|
rawQueryDNS: async (d, type, dnssecFlag) => {
|
|
@@ -2720,51 +2833,49 @@ async function checkDnssec2(domain, dnsOptions) {
|
|
|
2720
2833
|
}
|
|
2721
2834
|
}
|
|
2722
2835
|
);
|
|
2723
|
-
const
|
|
2836
|
+
const isDnsTransportError = baseResult.findings.some((f) => f.title === "DNSSEC check failed");
|
|
2837
|
+
if (isDnsTransportError) {
|
|
2838
|
+
return { ...baseResult, checkStatus: "error" };
|
|
2839
|
+
}
|
|
2840
|
+
const dnssecAbsent = baseResult.findings.some((f) => f.title === "DNSSEC not enabled") || baseResult.findings.some((f) => f.title === "DNSSEC check failed") || baseResult.findings.some((f) => f.title === "DNSSEC chain of trust incomplete");
|
|
2724
2841
|
if (dnssecAbsent) {
|
|
2725
2842
|
return baseResult;
|
|
2726
2843
|
}
|
|
2727
|
-
const
|
|
2728
|
-
|
|
2729
|
-
|
|
2730
|
-
|
|
2731
|
-
|
|
2732
|
-
|
|
2733
|
-
|
|
2734
|
-
|
|
2735
|
-
|
|
2736
|
-
|
|
2737
|
-
|
|
2738
|
-
|
|
2739
|
-
|
|
2740
|
-
|
|
2741
|
-
|
|
2742
|
-
|
|
2743
|
-
|
|
2744
|
-
|
|
2745
|
-
const [first, ...rest] = baseResult.findings;
|
|
2746
|
-
const tagged = { ...first, metadata: { ...first.metadata ?? {}, dnssecSource: "domain_configured" } };
|
|
2747
|
-
return buildCheckResult("dnssec", [tagged, ...rest]);
|
|
2844
|
+
const validationFailing = baseResult.findings.some((f) => f.title === "DNSSEC validation failing");
|
|
2845
|
+
if (validationFailing) {
|
|
2846
|
+
const googleConfirmsAd = await confirmAdWithGoogle(domain, dnsOptions?.timeoutMs ?? AD_CONFIRM_TIMEOUT_MS);
|
|
2847
|
+
if (googleConfirmsAd) {
|
|
2848
|
+
const correctedResult = await checkDNSSEC(
|
|
2849
|
+
domain,
|
|
2850
|
+
makeQueryDNS(dnsOptions),
|
|
2851
|
+
{
|
|
2852
|
+
timeout: dnsOptions?.timeoutMs ?? 5e3,
|
|
2853
|
+
rawQueryDNS: async (d, type, dnssecFlag) => {
|
|
2854
|
+
const resp = await queryDns(d, type, dnssecFlag ?? false, dnsOptions);
|
|
2855
|
+
return { AD: true, Answer: resp.Answer };
|
|
2856
|
+
}
|
|
2857
|
+
}
|
|
2858
|
+
);
|
|
2859
|
+
return augmentWithSource(domain, correctedResult, dnsOptions);
|
|
2860
|
+
}
|
|
2861
|
+
return baseResult;
|
|
2748
2862
|
}
|
|
2749
|
-
|
|
2750
|
-
"dnssec",
|
|
2751
|
-
"DNSSEC configured by domain owner",
|
|
2752
|
-
"info",
|
|
2753
|
-
`${domain} has DNSKEY and DS records \u2014 DNSSEC is explicitly configured by the domain owner.`,
|
|
2754
|
-
{ dnssecSource: "domain_configured" }
|
|
2755
|
-
);
|
|
2756
|
-
return buildCheckResult("dnssec", [configuredFinding]);
|
|
2863
|
+
return augmentWithSource(domain, baseResult, dnsOptions);
|
|
2757
2864
|
} catch (err) {
|
|
2758
2865
|
if (err instanceof DnsQueryError) {
|
|
2759
|
-
|
|
2760
|
-
|
|
2761
|
-
|
|
2762
|
-
|
|
2763
|
-
|
|
2764
|
-
|
|
2765
|
-
|
|
2766
|
-
|
|
2767
|
-
|
|
2866
|
+
const message = err.message;
|
|
2867
|
+
return {
|
|
2868
|
+
...buildCheckResult("dnssec", [
|
|
2869
|
+
createFinding(
|
|
2870
|
+
"dnssec",
|
|
2871
|
+
"DNSSEC check could not complete",
|
|
2872
|
+
"info",
|
|
2873
|
+
`DNS query failed (${message}). DNSSEC posture unknown.`,
|
|
2874
|
+
{ dnsError: message, checkStatus: "error" }
|
|
2875
|
+
)
|
|
2876
|
+
]),
|
|
2877
|
+
checkStatus: "error"
|
|
2878
|
+
};
|
|
2768
2879
|
}
|
|
2769
2880
|
throw err;
|
|
2770
2881
|
}
|
|
@@ -2777,38 +2888,23 @@ async function checkSsl(domain) {
|
|
|
2777
2888
|
}
|
|
2778
2889
|
|
|
2779
2890
|
// src/tools/check-mta-sts.ts
|
|
2780
|
-
|
|
2891
|
+
init_dns_query_adapter();
|
|
2781
2892
|
init_config();
|
|
2782
|
-
function makeQueryDNS5(dnsOptions) {
|
|
2783
|
-
return async (domain, type) => {
|
|
2784
|
-
if (type === "TXT") {
|
|
2785
|
-
return queryTxtRecords(domain, dnsOptions);
|
|
2786
|
-
}
|
|
2787
|
-
return queryDnsRecords(domain, type, dnsOptions);
|
|
2788
|
-
};
|
|
2789
|
-
}
|
|
2790
2893
|
async function checkMtaSts(domain, dnsOptions) {
|
|
2791
2894
|
return checkMTASTS(
|
|
2792
2895
|
domain,
|
|
2793
|
-
|
|
2896
|
+
makeQueryDNS(dnsOptions),
|
|
2794
2897
|
{ timeout: dnsOptions?.timeoutMs ?? HTTPS_TIMEOUT_MS, fetchFn: fetch }
|
|
2795
2898
|
);
|
|
2796
2899
|
}
|
|
2797
2900
|
|
|
2798
2901
|
// src/tools/check-ns.ts
|
|
2799
2902
|
init_dns2();
|
|
2800
|
-
|
|
2801
|
-
return async (domain, type) => {
|
|
2802
|
-
if (type === "TXT") {
|
|
2803
|
-
return queryTxtRecords(domain, dnsOptions);
|
|
2804
|
-
}
|
|
2805
|
-
return queryDnsRecords(domain, type, dnsOptions);
|
|
2806
|
-
};
|
|
2807
|
-
}
|
|
2903
|
+
init_dns_query_adapter();
|
|
2808
2904
|
async function checkNs(domain, dnsOptions) {
|
|
2809
2905
|
return checkNS(
|
|
2810
2906
|
domain,
|
|
2811
|
-
|
|
2907
|
+
makeQueryDNS(dnsOptions),
|
|
2812
2908
|
{
|
|
2813
2909
|
timeout: dnsOptions?.timeoutMs ?? 5e3,
|
|
2814
2910
|
rawQueryDNS: async (d, type, dnssecFlag) => {
|
|
@@ -2820,55 +2916,31 @@ async function checkNs(domain, dnsOptions) {
|
|
|
2820
2916
|
}
|
|
2821
2917
|
|
|
2822
2918
|
// src/tools/check-caa.ts
|
|
2823
|
-
|
|
2824
|
-
function makeQueryDNS7(dnsOptions) {
|
|
2825
|
-
return async (domain, type) => {
|
|
2826
|
-
if (type === "TXT") {
|
|
2827
|
-
return queryTxtRecords(domain, dnsOptions);
|
|
2828
|
-
}
|
|
2829
|
-
return queryDnsRecords(domain, type, dnsOptions);
|
|
2830
|
-
};
|
|
2831
|
-
}
|
|
2919
|
+
init_dns_query_adapter();
|
|
2832
2920
|
async function checkCaa(domain, dnsOptions) {
|
|
2833
2921
|
return checkCAA(
|
|
2834
2922
|
domain,
|
|
2835
|
-
|
|
2923
|
+
makeQueryDNS(dnsOptions),
|
|
2836
2924
|
{ timeout: dnsOptions?.timeoutMs ?? 5e3 }
|
|
2837
2925
|
);
|
|
2838
2926
|
}
|
|
2839
2927
|
|
|
2840
2928
|
// src/tools/check-bimi.ts
|
|
2841
|
-
|
|
2842
|
-
function makeQueryDNS8(dnsOptions) {
|
|
2843
|
-
return async (domain, type) => {
|
|
2844
|
-
if (type === "TXT") {
|
|
2845
|
-
return queryTxtRecords(domain, dnsOptions);
|
|
2846
|
-
}
|
|
2847
|
-
return queryDnsRecords(domain, type, dnsOptions);
|
|
2848
|
-
};
|
|
2849
|
-
}
|
|
2929
|
+
init_dns_query_adapter();
|
|
2850
2930
|
async function checkBimi(domain, dnsOptions) {
|
|
2851
2931
|
return checkBIMI(
|
|
2852
2932
|
domain,
|
|
2853
|
-
|
|
2933
|
+
makeQueryDNS(dnsOptions),
|
|
2854
2934
|
{ timeout: dnsOptions?.timeoutMs ?? 5e3, fetchFn: fetch }
|
|
2855
2935
|
);
|
|
2856
2936
|
}
|
|
2857
2937
|
|
|
2858
2938
|
// src/tools/check-tlsrpt.ts
|
|
2859
|
-
|
|
2860
|
-
function makeQueryDNS9(dnsOptions) {
|
|
2861
|
-
return async (domain, type) => {
|
|
2862
|
-
if (type === "TXT") {
|
|
2863
|
-
return queryTxtRecords(domain, dnsOptions);
|
|
2864
|
-
}
|
|
2865
|
-
return queryDnsRecords(domain, type, dnsOptions);
|
|
2866
|
-
};
|
|
2867
|
-
}
|
|
2939
|
+
init_dns_query_adapter();
|
|
2868
2940
|
async function checkTlsrpt(domain, dnsOptions) {
|
|
2869
2941
|
return checkTLSRPT(
|
|
2870
2942
|
domain,
|
|
2871
|
-
|
|
2943
|
+
makeQueryDNS(dnsOptions),
|
|
2872
2944
|
{ timeout: dnsOptions?.timeoutMs ?? 5e3 }
|
|
2873
2945
|
);
|
|
2874
2946
|
}
|
|
@@ -3945,9 +4017,9 @@ async function checkTxtHygiene(domain, dnsOptions) {
|
|
|
3945
4017
|
}
|
|
3946
4018
|
}
|
|
3947
4019
|
const duplicates = [];
|
|
3948
|
-
for (const [, { service, count }] of prefixCounts) {
|
|
3949
|
-
if (
|
|
3950
|
-
duplicates.push({ service, count });
|
|
4020
|
+
for (const [, { service, count: count2 }] of prefixCounts) {
|
|
4021
|
+
if (count2 >= 2) {
|
|
4022
|
+
duplicates.push({ service, count: count2 });
|
|
3951
4023
|
}
|
|
3952
4024
|
}
|
|
3953
4025
|
if (duplicates.length > 0) {
|
|
@@ -4042,6 +4114,38 @@ async function checkTxtHygiene(domain, dnsOptions) {
|
|
|
4042
4114
|
return buildCheckResult("txt_hygiene", findings);
|
|
4043
4115
|
}
|
|
4044
4116
|
init_config();
|
|
4117
|
+
var SCANNER_USER_AGENT = "Mozilla/5.0 (compatible; BlackVeilDNSScanner/1.0; +https://blackveilsecurity.com)";
|
|
4118
|
+
var WAF_CHALLENGE_FINGERPRINTS = [
|
|
4119
|
+
{
|
|
4120
|
+
name: "cloudflare",
|
|
4121
|
+
// cf-ray header is conclusive on its own; body title is a belt-and-suspenders signal
|
|
4122
|
+
matchHeaders: (h) => !!(h.get("cf-ray") && (h.get("server") ?? "").toLowerCase().includes("cloudflare")),
|
|
4123
|
+
matchBody: (body) => /just a moment/i.test(body)
|
|
4124
|
+
},
|
|
4125
|
+
{
|
|
4126
|
+
name: "akamai",
|
|
4127
|
+
matchHeaders: (h) => (h.get("server") ?? "").toLowerCase().includes("akamaighost")
|
|
4128
|
+
}
|
|
4129
|
+
];
|
|
4130
|
+
function detectWafChallenge(headers, body) {
|
|
4131
|
+
for (const fp of WAF_CHALLENGE_FINGERPRINTS) {
|
|
4132
|
+
if (!fp.matchHeaders(headers)) continue;
|
|
4133
|
+
if (fp.matchBody && body !== void 0 && !fp.matchBody(body)) continue;
|
|
4134
|
+
return fp.name;
|
|
4135
|
+
}
|
|
4136
|
+
return null;
|
|
4137
|
+
}
|
|
4138
|
+
var MERGE_HEADERS = [
|
|
4139
|
+
"content-security-policy",
|
|
4140
|
+
"x-frame-options",
|
|
4141
|
+
"x-content-type-options",
|
|
4142
|
+
"permissions-policy",
|
|
4143
|
+
"referrer-policy",
|
|
4144
|
+
"cross-origin-resource-policy",
|
|
4145
|
+
"cross-origin-opener-policy",
|
|
4146
|
+
"cross-origin-embedder-policy"
|
|
4147
|
+
];
|
|
4148
|
+
var MAX_REDIRECT_HOPS = 3;
|
|
4045
4149
|
function detectCdnProvider(headers) {
|
|
4046
4150
|
if (headers.get("cf-ray") || headers.get("server")?.toLowerCase().includes("cloudflare")) {
|
|
4047
4151
|
return "Cloudflare";
|
|
@@ -4061,9 +4165,107 @@ function detectCdnProvider(headers) {
|
|
|
4061
4165
|
}
|
|
4062
4166
|
return null;
|
|
4063
4167
|
}
|
|
4168
|
+
async function fetchWithRedirects(url, timeoutMs) {
|
|
4169
|
+
let response = await fetch(url, {
|
|
4170
|
+
method: "HEAD",
|
|
4171
|
+
redirect: "manual",
|
|
4172
|
+
headers: { "User-Agent": SCANNER_USER_AGENT },
|
|
4173
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
4174
|
+
});
|
|
4175
|
+
for (let hop = 0; hop < MAX_REDIRECT_HOPS; hop++) {
|
|
4176
|
+
const status = response.status;
|
|
4177
|
+
const isRedirect = status >= 300 && status < 400 || response.type === "opaqueredirect" || status === 0 && response.headers.get("location");
|
|
4178
|
+
if (!isRedirect) break;
|
|
4179
|
+
const location = response.headers.get("location");
|
|
4180
|
+
if (!location) break;
|
|
4181
|
+
let nextUrl;
|
|
4182
|
+
try {
|
|
4183
|
+
nextUrl = new URL(location, response.url || void 0).href;
|
|
4184
|
+
} catch {
|
|
4185
|
+
break;
|
|
4186
|
+
}
|
|
4187
|
+
if (!nextUrl.startsWith("https://")) break;
|
|
4188
|
+
try {
|
|
4189
|
+
response = await fetch(nextUrl, {
|
|
4190
|
+
method: "HEAD",
|
|
4191
|
+
redirect: "manual",
|
|
4192
|
+
headers: { "User-Agent": SCANNER_USER_AGENT },
|
|
4193
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
4194
|
+
});
|
|
4195
|
+
} catch {
|
|
4196
|
+
break;
|
|
4197
|
+
}
|
|
4198
|
+
}
|
|
4199
|
+
return response;
|
|
4200
|
+
}
|
|
4201
|
+
function mergeSecurityHeaders(a, b) {
|
|
4202
|
+
const merged = new Headers();
|
|
4203
|
+
a.forEach((value, key) => merged.set(key, value));
|
|
4204
|
+
for (const header of MERGE_HEADERS) {
|
|
4205
|
+
if (!merged.has(header) && b.has(header)) {
|
|
4206
|
+
merged.set(header, b.get(header));
|
|
4207
|
+
}
|
|
4208
|
+
}
|
|
4209
|
+
b.forEach((value, key) => {
|
|
4210
|
+
if (!merged.has(key)) merged.set(key, value);
|
|
4211
|
+
});
|
|
4212
|
+
return merged;
|
|
4213
|
+
}
|
|
4214
|
+
async function dualFetchHeaders(domain, timeoutMs) {
|
|
4215
|
+
const url = `https://${domain}`;
|
|
4216
|
+
const results = await Promise.allSettled([fetchWithRedirects(url, timeoutMs), fetchWithRedirects(url, timeoutMs)]);
|
|
4217
|
+
const responses = results.filter((r) => r.status === "fulfilled").map((r) => r.value);
|
|
4218
|
+
if (responses.length === 0) return null;
|
|
4219
|
+
const usable = responses.filter((r) => r.ok || r.status >= 300 && r.status < 400);
|
|
4220
|
+
if (usable.length === 0) return null;
|
|
4221
|
+
if (usable.length === 1) {
|
|
4222
|
+
return { headers: usable[0].headers, ok: usable[0].ok, status: usable[0].status };
|
|
4223
|
+
}
|
|
4224
|
+
const merged = mergeSecurityHeaders(usable[0].headers, usable[1].headers);
|
|
4225
|
+
const primary = usable[0].ok ? usable[0] : usable[1].ok ? usable[1] : usable[0];
|
|
4226
|
+
return { headers: merged, ok: primary.ok, status: primary.status };
|
|
4227
|
+
}
|
|
4228
|
+
async function fetchBodyForWafDetection(url, timeoutMs) {
|
|
4229
|
+
try {
|
|
4230
|
+
const response = await fetch(url, {
|
|
4231
|
+
method: "GET",
|
|
4232
|
+
redirect: "manual",
|
|
4233
|
+
headers: { "User-Agent": SCANNER_USER_AGENT },
|
|
4234
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
4235
|
+
});
|
|
4236
|
+
return await response.text();
|
|
4237
|
+
} catch {
|
|
4238
|
+
return "";
|
|
4239
|
+
}
|
|
4240
|
+
}
|
|
4064
4241
|
async function checkHttpSecurity(domain) {
|
|
4242
|
+
const dualResult = await dualFetchHeaders(domain, HTTPS_TIMEOUT_MS);
|
|
4243
|
+
if (dualResult) {
|
|
4244
|
+
const headersForWaf = dualResult.headers;
|
|
4245
|
+
const needsBody = WAF_CHALLENGE_FINGERPRINTS.some((fp) => fp.matchHeaders(headersForWaf) && fp.matchBody);
|
|
4246
|
+
const body = needsBody ? await fetchBodyForWafDetection(`https://${domain}`, HTTPS_TIMEOUT_MS) : void 0;
|
|
4247
|
+
const wafName = detectWafChallenge(headersForWaf, body);
|
|
4248
|
+
if (wafName) {
|
|
4249
|
+
const finding = createFinding(
|
|
4250
|
+
"http_security",
|
|
4251
|
+
`${wafName.charAt(0).toUpperCase() + wafName.slice(1)} WAF challenge intercepted`,
|
|
4252
|
+
"info",
|
|
4253
|
+
`The fetched response appears to be a WAF/CDN challenge page, not the real site. Header analysis is inconclusive.`,
|
|
4254
|
+
{ wafChallenge: wafName, inconclusive: true }
|
|
4255
|
+
);
|
|
4256
|
+
const base2 = buildCheckResult("http_security", [finding]);
|
|
4257
|
+
return { ...base2, score: 0, passed: false, checkStatus: "error" };
|
|
4258
|
+
}
|
|
4259
|
+
}
|
|
4065
4260
|
let capturedHeaders = null;
|
|
4066
4261
|
const capturingFetch = async (input, init) => {
|
|
4262
|
+
if (dualResult) {
|
|
4263
|
+
capturedHeaders = dualResult.headers;
|
|
4264
|
+
return new Response(null, {
|
|
4265
|
+
status: dualResult.status,
|
|
4266
|
+
headers: dualResult.headers
|
|
4267
|
+
});
|
|
4268
|
+
}
|
|
4067
4269
|
const response = await fetch(input, init);
|
|
4068
4270
|
capturedHeaders = response.headers;
|
|
4069
4271
|
return response;
|
|
@@ -4086,18 +4288,11 @@ async function checkHttpSecurity(domain) {
|
|
|
4086
4288
|
|
|
4087
4289
|
// src/tools/check-dane.ts
|
|
4088
4290
|
init_dns2();
|
|
4089
|
-
|
|
4090
|
-
return async (domain, type) => {
|
|
4091
|
-
if (type === "TXT") {
|
|
4092
|
-
return queryTxtRecords(domain, dnsOptions);
|
|
4093
|
-
}
|
|
4094
|
-
return queryDnsRecords(domain, type, dnsOptions);
|
|
4095
|
-
};
|
|
4096
|
-
}
|
|
4291
|
+
init_dns_query_adapter();
|
|
4097
4292
|
async function checkDane(domain, dnsOptions) {
|
|
4098
4293
|
return checkDANE(
|
|
4099
4294
|
domain,
|
|
4100
|
-
|
|
4295
|
+
makeQueryDNS(dnsOptions),
|
|
4101
4296
|
{
|
|
4102
4297
|
timeout: dnsOptions?.timeoutMs ?? 5e3,
|
|
4103
4298
|
rawQueryDNS: async (d, type, dnssecFlag) => {
|
|
@@ -4110,18 +4305,11 @@ async function checkDane(domain, dnsOptions) {
|
|
|
4110
4305
|
|
|
4111
4306
|
// src/tools/check-dane-https.ts
|
|
4112
4307
|
init_dns2();
|
|
4113
|
-
|
|
4114
|
-
return async (domain, type) => {
|
|
4115
|
-
if (type === "TXT") {
|
|
4116
|
-
return queryTxtRecords(domain, dnsOptions);
|
|
4117
|
-
}
|
|
4118
|
-
return queryDnsRecords(domain, type, dnsOptions);
|
|
4119
|
-
};
|
|
4120
|
-
}
|
|
4308
|
+
init_dns_query_adapter();
|
|
4121
4309
|
async function checkDaneHttps(domain, dnsOptions) {
|
|
4122
4310
|
return checkDANEHTTPS(
|
|
4123
4311
|
domain,
|
|
4124
|
-
|
|
4312
|
+
makeQueryDNS(dnsOptions),
|
|
4125
4313
|
{
|
|
4126
4314
|
timeout: dnsOptions?.timeoutMs ?? 5e3,
|
|
4127
4315
|
rawQueryDNS: async (d, type, dnssecFlag) => {
|
|
@@ -4133,19 +4321,11 @@ async function checkDaneHttps(domain, dnsOptions) {
|
|
|
4133
4321
|
}
|
|
4134
4322
|
|
|
4135
4323
|
// src/tools/check-svcb-https.ts
|
|
4136
|
-
|
|
4137
|
-
function makeQueryDNS12(dnsOptions) {
|
|
4138
|
-
return async (domain, type) => {
|
|
4139
|
-
if (type === "TXT") {
|
|
4140
|
-
return queryTxtRecords(domain, dnsOptions);
|
|
4141
|
-
}
|
|
4142
|
-
return queryDnsRecords(domain, type, dnsOptions);
|
|
4143
|
-
};
|
|
4144
|
-
}
|
|
4324
|
+
init_dns_query_adapter();
|
|
4145
4325
|
async function checkSvcbHttps(domain, dnsOptions) {
|
|
4146
4326
|
return checkSVCBHTTPS(
|
|
4147
4327
|
domain,
|
|
4148
|
-
|
|
4328
|
+
makeQueryDNS(dnsOptions),
|
|
4149
4329
|
{ timeout: dnsOptions?.timeoutMs ?? 5e3 }
|
|
4150
4330
|
);
|
|
4151
4331
|
}
|
|
@@ -4771,19 +4951,11 @@ async function checkZoneHygiene(domain, dnsOptions) {
|
|
|
4771
4951
|
}
|
|
4772
4952
|
|
|
4773
4953
|
// src/tools/check-subdomailing.ts
|
|
4774
|
-
|
|
4775
|
-
function makeQueryDNS13(dnsOptions) {
|
|
4776
|
-
return async (domain, type) => {
|
|
4777
|
-
if (type === "TXT") {
|
|
4778
|
-
return queryTxtRecords(domain, dnsOptions);
|
|
4779
|
-
}
|
|
4780
|
-
return queryDnsRecords(domain, type, dnsOptions);
|
|
4781
|
-
};
|
|
4782
|
-
}
|
|
4954
|
+
init_dns_query_adapter();
|
|
4783
4955
|
async function checkSubdomailing(domain, dnsOptions) {
|
|
4784
4956
|
return checkSubdomailing$1(
|
|
4785
4957
|
domain,
|
|
4786
|
-
|
|
4958
|
+
makeQueryDNS(dnsOptions),
|
|
4787
4959
|
{ timeout: dnsOptions?.timeoutMs ?? 5e3 }
|
|
4788
4960
|
);
|
|
4789
4961
|
}
|
|
@@ -4881,20 +5053,12 @@ function applyInteractionPenalties(score, config) {
|
|
|
4881
5053
|
init_cache();
|
|
4882
5054
|
|
|
4883
5055
|
// src/tools/check-subdomain-takeover.ts
|
|
4884
|
-
|
|
5056
|
+
init_dns_query_adapter();
|
|
4885
5057
|
init_config();
|
|
4886
|
-
function makeQueryDNS14(dnsOptions) {
|
|
4887
|
-
return async (domain, type) => {
|
|
4888
|
-
if (type === "TXT") {
|
|
4889
|
-
return queryTxtRecords(domain, dnsOptions);
|
|
4890
|
-
}
|
|
4891
|
-
return queryDnsRecords(domain, type, dnsOptions);
|
|
4892
|
-
};
|
|
4893
|
-
}
|
|
4894
5058
|
async function checkSubdomainTakeover(domain, dnsOptions) {
|
|
4895
5059
|
return checkSubdomainTakeover$1(
|
|
4896
5060
|
domain,
|
|
4897
|
-
|
|
5061
|
+
makeQueryDNS(dnsOptions),
|
|
4898
5062
|
{ timeout: dnsOptions?.timeoutMs ?? HTTPS_TIMEOUT_MS, fetchFn: fetch }
|
|
4899
5063
|
);
|
|
4900
5064
|
}
|
|
@@ -4964,6 +5128,11 @@ function upsertCheckResult(results, updated) {
|
|
|
4964
5128
|
return results.map((result) => result.category === updated.category ? updated : result);
|
|
4965
5129
|
}
|
|
4966
5130
|
async function addOutboundProviderInference(results, runtimeOptions) {
|
|
5131
|
+
const mxResult = results.find((result) => result.category === "mx");
|
|
5132
|
+
const providerDetectionFailed = Boolean(
|
|
5133
|
+
mxResult?.metadata?.providerDetectionFailed
|
|
5134
|
+
);
|
|
5135
|
+
if (providerDetectionFailed) return results;
|
|
4967
5136
|
const spfResult = results.find((result) => result.category === "spf");
|
|
4968
5137
|
const dkimResult = results.find((result) => result.category === "dkim");
|
|
4969
5138
|
const signalDomains = extractSpfSignalDomains(spfResult);
|
|
@@ -5103,6 +5272,34 @@ function adjustForNoSendDomain(results) {
|
|
|
5103
5272
|
// src/tools/scan-domain.ts
|
|
5104
5273
|
init_log();
|
|
5105
5274
|
|
|
5275
|
+
// src/lib/profile-accumulator.ts
|
|
5276
|
+
init_log();
|
|
5277
|
+
var ADAPTIVE_WEIGHT_KV_TTL_SECONDS = 60;
|
|
5278
|
+
function awKey(profile, provider) {
|
|
5279
|
+
return `aw:${profile}:${provider}`;
|
|
5280
|
+
}
|
|
5281
|
+
async function publishAdaptiveWeightSummary(profile, provider, weights, kv) {
|
|
5282
|
+
try {
|
|
5283
|
+
await kv.put(awKey(profile, provider), JSON.stringify(weights), {
|
|
5284
|
+
expirationTtl: ADAPTIVE_WEIGHT_KV_TTL_SECONDS
|
|
5285
|
+
});
|
|
5286
|
+
} catch {
|
|
5287
|
+
logError("[profile-accumulator] KV publish failed", {
|
|
5288
|
+
severity: "warn",
|
|
5289
|
+
category: "profile-accumulator"
|
|
5290
|
+
});
|
|
5291
|
+
}
|
|
5292
|
+
}
|
|
5293
|
+
async function getAdaptiveWeights(profile, provider, kv) {
|
|
5294
|
+
try {
|
|
5295
|
+
const raw = await kv.get(awKey(profile, provider));
|
|
5296
|
+
if (!raw) return null;
|
|
5297
|
+
return JSON.parse(raw);
|
|
5298
|
+
} catch {
|
|
5299
|
+
return null;
|
|
5300
|
+
}
|
|
5301
|
+
}
|
|
5302
|
+
|
|
5106
5303
|
// src/tools/scan/maturity-staging.ts
|
|
5107
5304
|
function capMaturityStage(maturity, score) {
|
|
5108
5305
|
if (score < 50 && maturity.stage > 2) {
|
|
@@ -5635,6 +5832,59 @@ var EXPLANATIONS = {
|
|
|
5635
5832
|
explanation: "An HTTPS/SVCB record is configured, advertising modern connection capabilities for this domain.",
|
|
5636
5833
|
recommendation: "No action required. Consider adding ECH for enhanced privacy.",
|
|
5637
5834
|
references: ["https://datatracker.ietf.org/doc/html/rfc9460"]
|
|
5835
|
+
},
|
|
5836
|
+
// --- Intelligence tools ---
|
|
5837
|
+
DBL_LISTED: {
|
|
5838
|
+
title: "Domain Listed on Blocklist",
|
|
5839
|
+
severity: "high",
|
|
5840
|
+
explanation: "The domain appears on one or more DNS-based Domain Block Lists (DBLs), indicating it has been flagged for spam, phishing, or malware distribution.",
|
|
5841
|
+
impact: "Listed domains may have email deliverability issues and are often blocked by recipient mail servers.",
|
|
5842
|
+
adverseConsequences: "Legitimate email from this domain may be silently dropped or quarantined.",
|
|
5843
|
+
recommendation: "Investigate the listing reason. For Spamhaus DBL, check https://check.spamhaus.org/. Request delisting after resolving the underlying issue.",
|
|
5844
|
+
references: ["https://www.spamhaus.org/dbl/", "https://uribl.com/", "https://www.surbl.org/"]
|
|
5845
|
+
},
|
|
5846
|
+
RBL_LISTED: {
|
|
5847
|
+
title: "IP Listed on Real-time Blocklist",
|
|
5848
|
+
severity: "high",
|
|
5849
|
+
explanation: "One or more mail server IPs are listed on DNS-based Real-time Blocklists, indicating the IP has been flagged for sending spam or malicious traffic.",
|
|
5850
|
+
impact: "Email from listed IPs is likely to be rejected or quarantined by recipient servers.",
|
|
5851
|
+
adverseConsequences: "Significant email deliverability degradation. May require IP change or delisting process.",
|
|
5852
|
+
recommendation: "Check Spamhaus at https://check.spamhaus.org/. For shared hosting, contact your provider. For dedicated IPs, resolve the abuse issue and request delisting.",
|
|
5853
|
+
references: ["https://www.spamhaus.org/zen/", "https://www.spamcop.net/"]
|
|
5854
|
+
},
|
|
5855
|
+
ASN_HIGH_RISK: {
|
|
5856
|
+
title: "High-Risk ASN Detected",
|
|
5857
|
+
severity: "medium",
|
|
5858
|
+
explanation: "The domain resolves to IP addresses in an Autonomous System commonly associated with abuse infrastructure (bulletproof hosting, botnets).",
|
|
5859
|
+
recommendation: "Verify the hosting choice is intentional. High-risk ASNs are not inherently malicious but are statistically over-represented in abuse reports.",
|
|
5860
|
+
references: ["https://www.team-cymru.com/ip-asn-mapping"]
|
|
5861
|
+
},
|
|
5862
|
+
FAST_FLUX_DETECTED: {
|
|
5863
|
+
title: "Fast-Flux Behavior Detected",
|
|
5864
|
+
severity: "high",
|
|
5865
|
+
explanation: "The domain shows rapidly rotating IP addresses with very low TTLs, a technique commonly used by botnets and phishing operations to evade takedown.",
|
|
5866
|
+
impact: "Fast-flux domains are a strong indicator of malicious infrastructure.",
|
|
5867
|
+
adverseConsequences: "Associating with fast-flux infrastructure damages domain reputation and may trigger automated blocking.",
|
|
5868
|
+
recommendation: "Investigate immediately. If this is a CDN or legitimate load balancer, TTLs are typically higher and rotation patterns are predictable.",
|
|
5869
|
+
references: ["https://en.wikipedia.org/wiki/Fast_flux"]
|
|
5870
|
+
},
|
|
5871
|
+
DNSSEC_CHAIN_BROKEN: {
|
|
5872
|
+
title: "DNSSEC Chain of Trust Broken",
|
|
5873
|
+
severity: "high",
|
|
5874
|
+
explanation: "The DNSSEC chain of trust has a gap \u2014 a DS record exists at the parent zone but the corresponding DNSKEY is missing or mismatched at the child zone.",
|
|
5875
|
+
impact: "DNSSEC-validating resolvers will return SERVFAIL for this domain, causing complete resolution failure for security-conscious clients.",
|
|
5876
|
+
adverseConsequences: "Domain may be unreachable for users behind DNSSEC-validating resolvers.",
|
|
5877
|
+
recommendation: "Ensure the DS record at the parent matches a DNSKEY at the child zone. Use `delv +vtrace` for detailed chain debugging.",
|
|
5878
|
+
references: ["https://datatracker.ietf.org/doc/html/rfc4033"]
|
|
5879
|
+
},
|
|
5880
|
+
NSEC_WALKABLE: {
|
|
5881
|
+
title: "Zone Walkable (No NSEC3)",
|
|
5882
|
+
severity: "high",
|
|
5883
|
+
explanation: "The zone does not appear to use NSEC3, meaning it likely uses plain NSEC records which allow full zone enumeration (zone walking).",
|
|
5884
|
+
impact: "Attackers can discover all hostnames in the zone without brute-forcing, exposing internal infrastructure.",
|
|
5885
|
+
adverseConsequences: "Complete zone enumeration reveals the attack surface \u2014 internal hosts, staging environments, and service names.",
|
|
5886
|
+
recommendation: "Deploy NSEC3 with at least algorithm 1 (SHA-1) and consider using salt. RFC 9276 recommends 0 iterations with no salt as the minimum.",
|
|
5887
|
+
references: ["https://datatracker.ietf.org/doc/html/rfc5155", "https://datatracker.ietf.org/doc/html/rfc9276"]
|
|
5638
5888
|
}
|
|
5639
5889
|
};
|
|
5640
5890
|
var DEFAULT_EXPLANATION = {
|
|
@@ -5711,6 +5961,26 @@ var CATEGORY_FALLBACK_IMPACT = {
|
|
|
5711
5961
|
SVCB_HTTPS: {
|
|
5712
5962
|
impact: "Modern transport capabilities (ALPN, ECH) cannot be advertised via DNS, reducing connection efficiency and privacy.",
|
|
5713
5963
|
adverseConsequences: "Clients require additional round-trips to negotiate protocols, and ECH-based privacy is unavailable."
|
|
5964
|
+
},
|
|
5965
|
+
RBL: {
|
|
5966
|
+
impact: "Mail server IPs are listed on one or more DNS blocklists, likely degrading email deliverability.",
|
|
5967
|
+
adverseConsequences: "Outbound email may be silently dropped or quarantined by recipient servers."
|
|
5968
|
+
},
|
|
5969
|
+
DBL: {
|
|
5970
|
+
impact: "Domain is flagged on DNS-based domain blocklists, indicating prior spam or abuse association.",
|
|
5971
|
+
adverseConsequences: "Domain reputation is damaged. Email and web traffic may be blocked by security tools."
|
|
5972
|
+
},
|
|
5973
|
+
FAST_FLUX: {
|
|
5974
|
+
impact: "DNS resolution shows fast-flux patterns \u2014 rapidly rotating IPs with low TTLs.",
|
|
5975
|
+
adverseConsequences: "Strong indicator of botnet or phishing infrastructure. Domain reputation will be severely impacted."
|
|
5976
|
+
},
|
|
5977
|
+
DNSSEC_CHAIN: {
|
|
5978
|
+
impact: "DNSSEC chain structure has gaps or uses weak algorithms, reducing trust in DNS responses.",
|
|
5979
|
+
adverseConsequences: "DNSSEC-validating resolvers may fail to resolve the domain or accept spoofed responses."
|
|
5980
|
+
},
|
|
5981
|
+
NSEC_WALKABILITY: {
|
|
5982
|
+
impact: "Zone denial-of-existence parameters allow enumeration of zone contents.",
|
|
5983
|
+
adverseConsequences: "Attackers can map the full attack surface by walking the zone without brute-forcing."
|
|
5714
5984
|
}
|
|
5715
5985
|
};
|
|
5716
5986
|
var SEVERITY_FALLBACK_IMPACT = {
|
|
@@ -6079,11 +6349,86 @@ function formatScanReport(result, format = "full") {
|
|
|
6079
6349
|
var CACHE_PREFIX = "cache:";
|
|
6080
6350
|
var PER_CHECK_TIMEOUT_MS = 8e3;
|
|
6081
6351
|
var SCAN_TIMEOUT_MS = 12e3;
|
|
6352
|
+
var RETRY_BUDGET_MS = 3e3;
|
|
6353
|
+
var MAX_RETRIES_PER_SCAN = 3;
|
|
6354
|
+
var RETRY_TIMEOUT_MS = 2500;
|
|
6082
6355
|
var adaptiveWeightCache = /* @__PURE__ */ new Map();
|
|
6083
6356
|
var ADAPTIVE_CACHE_TTL_MS = 6e4;
|
|
6084
6357
|
var ADAPTIVE_CACHE_MAX_ENTRIES = 100;
|
|
6085
6358
|
var ADAPTIVE_FETCH_TIMEOUT_MS = 200;
|
|
6359
|
+
function shouldRetry(result) {
|
|
6360
|
+
return result.checkStatus === "error" && result.score === 0;
|
|
6361
|
+
}
|
|
6362
|
+
async function runCheckRetry(category, domain, scanDns, runtimeOptions) {
|
|
6363
|
+
const retryDns = { ...scanDns, queryCache: /* @__PURE__ */ new Map() };
|
|
6364
|
+
const timeoutPromise = new Promise(
|
|
6365
|
+
(_, reject) => setTimeout(() => reject(new Error("Retry timed out")), RETRY_TIMEOUT_MS)
|
|
6366
|
+
);
|
|
6367
|
+
let checkPromise;
|
|
6368
|
+
switch (category) {
|
|
6369
|
+
case "spf":
|
|
6370
|
+
checkPromise = checkSpf(domain, retryDns);
|
|
6371
|
+
break;
|
|
6372
|
+
case "dmarc":
|
|
6373
|
+
checkPromise = checkDmarc(domain, retryDns);
|
|
6374
|
+
break;
|
|
6375
|
+
case "dkim":
|
|
6376
|
+
checkPromise = checkDkim(domain, void 0, retryDns);
|
|
6377
|
+
break;
|
|
6378
|
+
case "dnssec":
|
|
6379
|
+
checkPromise = checkDnssec2(domain, retryDns);
|
|
6380
|
+
break;
|
|
6381
|
+
case "ssl":
|
|
6382
|
+
checkPromise = checkSsl(domain);
|
|
6383
|
+
break;
|
|
6384
|
+
case "mta_sts":
|
|
6385
|
+
checkPromise = checkMtaSts(domain, retryDns);
|
|
6386
|
+
break;
|
|
6387
|
+
case "ns":
|
|
6388
|
+
checkPromise = checkNs(domain, retryDns);
|
|
6389
|
+
break;
|
|
6390
|
+
case "caa":
|
|
6391
|
+
checkPromise = checkCaa(domain, retryDns);
|
|
6392
|
+
break;
|
|
6393
|
+
case "bimi":
|
|
6394
|
+
checkPromise = checkBimi(domain, retryDns);
|
|
6395
|
+
break;
|
|
6396
|
+
case "tlsrpt":
|
|
6397
|
+
checkPromise = checkTlsrpt(domain, retryDns);
|
|
6398
|
+
break;
|
|
6399
|
+
case "subdomain_takeover":
|
|
6400
|
+
checkPromise = checkSubdomainTakeover(domain, retryDns);
|
|
6401
|
+
break;
|
|
6402
|
+
case "http_security":
|
|
6403
|
+
checkPromise = checkHttpSecurity(domain);
|
|
6404
|
+
break;
|
|
6405
|
+
case "dane":
|
|
6406
|
+
checkPromise = checkDane(domain, retryDns);
|
|
6407
|
+
break;
|
|
6408
|
+
case "dane_https":
|
|
6409
|
+
checkPromise = checkDaneHttps(domain, retryDns);
|
|
6410
|
+
break;
|
|
6411
|
+
case "svcb_https":
|
|
6412
|
+
checkPromise = checkSvcbHttps(domain, retryDns);
|
|
6413
|
+
break;
|
|
6414
|
+
case "subdomailing":
|
|
6415
|
+
checkPromise = checkSubdomailing(domain, retryDns);
|
|
6416
|
+
break;
|
|
6417
|
+
case "mx":
|
|
6418
|
+
checkPromise = checkMx(domain, {
|
|
6419
|
+
providerSignaturesUrl: runtimeOptions?.providerSignaturesUrl,
|
|
6420
|
+
providerSignaturesAllowedHosts: runtimeOptions?.providerSignaturesAllowedHosts,
|
|
6421
|
+
providerSignaturesSha256: runtimeOptions?.providerSignaturesSha256
|
|
6422
|
+
}, retryDns);
|
|
6423
|
+
break;
|
|
6424
|
+
default:
|
|
6425
|
+
return { ...buildCheckResult(category, []), score: 0, passed: false, checkStatus: "error" };
|
|
6426
|
+
}
|
|
6427
|
+
return Promise.race([checkPromise, timeoutPromise]);
|
|
6428
|
+
}
|
|
6086
6429
|
async function scanDomain(domain, kv, runtimeOptions) {
|
|
6430
|
+
crypto.randomUUID();
|
|
6431
|
+
const scanStartTime = Date.now();
|
|
6087
6432
|
const explicitProfile = runtimeOptions?.profile;
|
|
6088
6433
|
const isExplicit = explicitProfile && explicitProfile !== "auto";
|
|
6089
6434
|
const cacheKey = isExplicit ? `${CACHE_PREFIX}${domain}:profile:${explicitProfile}` : `${CACHE_PREFIX}${domain}`;
|
|
@@ -6173,6 +6518,21 @@ async function scanDomain(domain, kv, runtimeOptions) {
|
|
|
6173
6518
|
degradedStatuses.set(r.category, r.checkStatus);
|
|
6174
6519
|
}
|
|
6175
6520
|
}
|
|
6521
|
+
if (!timedOut && Date.now() - scanStartTime < SCAN_TIMEOUT_MS - RETRY_BUDGET_MS) {
|
|
6522
|
+
const retryable = checkResults.map((r, idx) => ({ r, idx })).filter(({ r }) => shouldRetry(r)).slice(0, MAX_RETRIES_PER_SCAN);
|
|
6523
|
+
if (retryable.length > 0) {
|
|
6524
|
+
const retrySettled = await Promise.allSettled(
|
|
6525
|
+
retryable.map(({ r }) => runCheckRetry(r.category, domain, scanDns, runtimeOptions))
|
|
6526
|
+
);
|
|
6527
|
+
for (let i = 0; i < retryable.length; i++) {
|
|
6528
|
+
const s = retrySettled[i];
|
|
6529
|
+
if (s.status === "fulfilled" && s.value.checkStatus !== "error" && s.value.score > 0) {
|
|
6530
|
+
checkResults[retryable[i].idx] = s.value;
|
|
6531
|
+
degradedStatuses.delete(retryable[i].r.category);
|
|
6532
|
+
}
|
|
6533
|
+
}
|
|
6534
|
+
}
|
|
6535
|
+
}
|
|
6176
6536
|
if (timedOut) {
|
|
6177
6537
|
const completedCategories = new Set(checkResults.map((r) => r.category));
|
|
6178
6538
|
for (const category of ALL_CHECK_CATEGORIES) {
|
|
@@ -6186,7 +6546,7 @@ async function scanDomain(domain, kv, runtimeOptions) {
|
|
|
6186
6546
|
)
|
|
6187
6547
|
];
|
|
6188
6548
|
const result2 = buildCheckResult(category, findings);
|
|
6189
|
-
checkResults.push({ ...result2, score: 0, checkStatus: "timeout" });
|
|
6549
|
+
checkResults.push({ ...result2, score: 0, passed: false, checkStatus: "timeout" });
|
|
6190
6550
|
degradedStatuses.set(category, "timeout");
|
|
6191
6551
|
}
|
|
6192
6552
|
}
|
|
@@ -6197,7 +6557,7 @@ async function scanDomain(domain, kv, runtimeOptions) {
|
|
|
6197
6557
|
if (degradedStatuses.size > 0) {
|
|
6198
6558
|
checkResults = checkResults.map((r) => {
|
|
6199
6559
|
const status = degradedStatuses.get(r.category);
|
|
6200
|
-
return status ? { ...r, score: 0, checkStatus: status } : r;
|
|
6560
|
+
return status ? { ...r, score: 0, passed: false, checkStatus: status } : r;
|
|
6201
6561
|
});
|
|
6202
6562
|
}
|
|
6203
6563
|
let domainContext = detectDomainContext(checkResults);
|
|
@@ -6217,12 +6577,31 @@ async function scanDomain(domain, kv, runtimeOptions) {
|
|
|
6217
6577
|
}
|
|
6218
6578
|
const scoringContext = isExplicit ? domainContext : void 0;
|
|
6219
6579
|
let adaptiveResponse = null;
|
|
6220
|
-
|
|
6580
|
+
const adaptiveProvider = domainContext.detectedProvider ?? "";
|
|
6581
|
+
if (kv && adaptiveProvider) {
|
|
6582
|
+
const kvWeights = await getAdaptiveWeights(domainContext.profile, adaptiveProvider, kv);
|
|
6583
|
+
if (kvWeights) {
|
|
6584
|
+
adaptiveResponse = {
|
|
6585
|
+
profile: domainContext.profile,
|
|
6586
|
+
provider: adaptiveProvider,
|
|
6587
|
+
sampleCount: MATURITY_THRESHOLD,
|
|
6588
|
+
blendFactor: 1,
|
|
6589
|
+
weights: kvWeights,
|
|
6590
|
+
boundHits: []
|
|
6591
|
+
};
|
|
6592
|
+
}
|
|
6593
|
+
}
|
|
6594
|
+
if (!adaptiveResponse && runtimeOptions?.profileAccumulator) {
|
|
6221
6595
|
adaptiveResponse = await fetchAdaptiveWeights(
|
|
6222
6596
|
runtimeOptions.profileAccumulator,
|
|
6223
6597
|
domainContext.profile,
|
|
6224
6598
|
domainContext.detectedProvider
|
|
6225
6599
|
);
|
|
6600
|
+
if (adaptiveResponse && adaptiveProvider && kv && runtimeOptions.waitUntil) {
|
|
6601
|
+
runtimeOptions.waitUntil(
|
|
6602
|
+
publishAdaptiveWeightSummary(domainContext.profile, adaptiveProvider, adaptiveResponse.weights, kv)
|
|
6603
|
+
);
|
|
6604
|
+
}
|
|
6226
6605
|
}
|
|
6227
6606
|
if (adaptiveResponse?.boundHits.length) {
|
|
6228
6607
|
domainContext.signals.push(`adaptive bound hits: ${adaptiveResponse.boundHits.join(", ")}`);
|
|
@@ -9680,164 +10059,1254 @@ All evaluated attack vectors are blocked by the current security configuration.`
|
|
|
9680
10059
|
return lines.join("\n").trimEnd();
|
|
9681
10060
|
}
|
|
9682
10061
|
|
|
9683
|
-
// src/
|
|
9684
|
-
|
|
9685
|
-
|
|
9686
|
-
|
|
9687
|
-
|
|
9688
|
-
|
|
9689
|
-
|
|
9690
|
-
|
|
9691
|
-
|
|
9692
|
-
|
|
9693
|
-
|
|
9694
|
-
|
|
9695
|
-
|
|
9696
|
-
|
|
9697
|
-
|
|
9698
|
-
|
|
9699
|
-
|
|
9700
|
-
|
|
9701
|
-
|
|
9702
|
-
domain: options.domain,
|
|
9703
|
-
result: options.logResult,
|
|
9704
|
-
details: options.logDetails,
|
|
9705
|
-
durationMs: options.durationMs,
|
|
9706
|
-
severity: options.severity ?? (options.status === "pass" ? "info" : "warn")
|
|
9707
|
-
});
|
|
9708
|
-
}
|
|
9709
|
-
function logToolFailure(options) {
|
|
9710
|
-
options.analytics?.emitToolEvent({
|
|
9711
|
-
toolName: options.toolName,
|
|
9712
|
-
status: "error",
|
|
9713
|
-
durationMs: options.durationMs,
|
|
9714
|
-
domain: options.domain,
|
|
9715
|
-
isError: true,
|
|
9716
|
-
score: options.score,
|
|
9717
|
-
cacheStatus: options.cacheStatus,
|
|
9718
|
-
country: options.country,
|
|
9719
|
-
clientType: options.clientType,
|
|
9720
|
-
authTier: options.authTier,
|
|
9721
|
-
keyHash: options.keyHash
|
|
9722
|
-
});
|
|
9723
|
-
logError(options.error instanceof Error ? options.error : String(options.error), {
|
|
9724
|
-
tool: options.toolName,
|
|
9725
|
-
domain: options.domain,
|
|
9726
|
-
details: options.args,
|
|
9727
|
-
severity: options.severity ?? "error"
|
|
9728
|
-
});
|
|
9729
|
-
}
|
|
9730
|
-
|
|
9731
|
-
// src/handlers/tool-formatters.ts
|
|
9732
|
-
function mcpError(message) {
|
|
9733
|
-
return { type: "text", text: `Error: ${message}` };
|
|
9734
|
-
}
|
|
9735
|
-
function mcpText(text) {
|
|
9736
|
-
return { type: "text", text };
|
|
9737
|
-
}
|
|
9738
|
-
function buildToolContent(text, structuredData, format) {
|
|
9739
|
-
const content = [mcpText(text)];
|
|
9740
|
-
if (format === "full") {
|
|
9741
|
-
content.push(mcpText(`<!-- STRUCTURED_RESULT
|
|
9742
|
-
${JSON.stringify(structuredData)}
|
|
9743
|
-
STRUCTURED_RESULT -->`));
|
|
10062
|
+
// src/tools/check-dbl.ts
|
|
10063
|
+
init_dns2();
|
|
10064
|
+
var CATEGORY = "dbl";
|
|
10065
|
+
var SPAMHAUS_CODES = {
|
|
10066
|
+
"127.0.1.2": "Spam domain",
|
|
10067
|
+
"127.0.1.4": "Phishing domain",
|
|
10068
|
+
"127.0.1.5": "Malware domain",
|
|
10069
|
+
"127.0.1.6": "Botnet C&C domain",
|
|
10070
|
+
"127.0.1.102": "Abused legit spam domain",
|
|
10071
|
+
"127.0.1.103": "Abused legit spammed redirector",
|
|
10072
|
+
"127.0.1.104": "Abused legit phishing domain",
|
|
10073
|
+
"127.0.1.105": "Abused legit malware domain",
|
|
10074
|
+
"127.0.1.106": "Abused legit botnet C&C domain"
|
|
10075
|
+
};
|
|
10076
|
+
function decodeSpamhaus(ip) {
|
|
10077
|
+
if (/^127\.255\.255\./.test(ip)) return null;
|
|
10078
|
+
const label = SPAMHAUS_CODES[ip];
|
|
10079
|
+
if (label) {
|
|
10080
|
+
return { label, detail: `Spamhaus DBL return code ${ip}: ${label}` };
|
|
9744
10081
|
}
|
|
9745
|
-
|
|
10082
|
+
if (/^127\.0\.1\./.test(ip)) {
|
|
10083
|
+
return { label: "Listed (unknown code)", detail: `Spamhaus DBL return code ${ip}: unknown listing type` };
|
|
10084
|
+
}
|
|
10085
|
+
return null;
|
|
9746
10086
|
}
|
|
9747
|
-
|
|
9748
|
-
|
|
9749
|
-
|
|
9750
|
-
|
|
9751
|
-
|
|
9752
|
-
|
|
9753
|
-
|
|
9754
|
-
|
|
9755
|
-
|
|
9756
|
-
|
|
9757
|
-
|
|
9758
|
-
|
|
9759
|
-
|
|
10087
|
+
var URIBL_FLAGS = [
|
|
10088
|
+
{ mask: 2, label: "Black" },
|
|
10089
|
+
{ mask: 4, label: "Grey" },
|
|
10090
|
+
{ mask: 8, label: "Red" }
|
|
10091
|
+
];
|
|
10092
|
+
function decodeUribl(ip) {
|
|
10093
|
+
const octet = parseInt(ip.split(".")[3], 10);
|
|
10094
|
+
if (!Number.isFinite(octet) || octet === 0) return null;
|
|
10095
|
+
if ((octet & 1) !== 0 && (octet & -2) === 0) return null;
|
|
10096
|
+
const matched = URIBL_FLAGS.filter((f) => (octet & f.mask) !== 0).map((f) => f.label);
|
|
10097
|
+
if (matched.length === 0) return null;
|
|
10098
|
+
const labels = matched.join(", ");
|
|
10099
|
+
return { label: labels, detail: `URIBL flags: ${labels} (return code ${ip})` };
|
|
10100
|
+
}
|
|
10101
|
+
var SURBL_FLAGS = [
|
|
10102
|
+
{ mask: 2, label: "SC (SpamCop)" },
|
|
10103
|
+
{ mask: 4, label: "WS (sa-blacklist)" },
|
|
10104
|
+
{ mask: 8, label: "PH (Phishing)" },
|
|
10105
|
+
{ mask: 16, label: "MW (Malware)" },
|
|
10106
|
+
{ mask: 32, label: "AB (AbuseButler)" },
|
|
10107
|
+
{ mask: 64, label: "JP" },
|
|
10108
|
+
{ mask: 128, label: "CR (Cracked)" }
|
|
10109
|
+
];
|
|
10110
|
+
function decodeSurbl(ip) {
|
|
10111
|
+
const octet = parseInt(ip.split(".")[3], 10);
|
|
10112
|
+
if (!Number.isFinite(octet) || octet === 0) return null;
|
|
10113
|
+
const matched = SURBL_FLAGS.filter((f) => (octet & f.mask) !== 0).map((f) => f.label);
|
|
10114
|
+
if (matched.length === 0) return null;
|
|
10115
|
+
const labels = matched.join(", ");
|
|
10116
|
+
return { label: labels, detail: `SURBL flags: ${labels} (return code ${ip})` };
|
|
10117
|
+
}
|
|
10118
|
+
var DBL_ZONES = [
|
|
10119
|
+
{ name: "Spamhaus DBL", zone: "dbl.spamhaus.org", decode: decodeSpamhaus, severity: "high" },
|
|
10120
|
+
{ name: "URIBL", zone: "multi.uribl.com", decode: decodeUribl, severity: "medium" },
|
|
10121
|
+
{ name: "SURBL", zone: "multi.surbl.org", decode: decodeSurbl, severity: "medium" }
|
|
10122
|
+
];
|
|
10123
|
+
async function checkDbl(domain, dnsOptions) {
|
|
10124
|
+
const findings = [];
|
|
10125
|
+
const results = await Promise.allSettled(
|
|
10126
|
+
DBL_ZONES.map(async (zone) => {
|
|
10127
|
+
const queryName = `${domain}.${zone.zone}`;
|
|
10128
|
+
const answers = await queryDnsRecords(queryName, "A", dnsOptions);
|
|
10129
|
+
return { zone, answers };
|
|
10130
|
+
})
|
|
10131
|
+
);
|
|
10132
|
+
let listedCount = 0;
|
|
10133
|
+
let checkedCount = 0;
|
|
10134
|
+
for (const result of results) {
|
|
10135
|
+
if (result.status === "rejected") {
|
|
10136
|
+
const zoneIndex = results.indexOf(result);
|
|
10137
|
+
const zone2 = DBL_ZONES[zoneIndex];
|
|
10138
|
+
findings.push(
|
|
10139
|
+
createFinding(
|
|
10140
|
+
CATEGORY,
|
|
10141
|
+
`${zone2.name} lookup error`,
|
|
10142
|
+
"low",
|
|
10143
|
+
`DNS query error for ${domain} on ${zone2.name} (${zone2.zone}). Partial results may be available from other blocklists.`,
|
|
10144
|
+
{ zone: zone2.zone, error: true }
|
|
10145
|
+
)
|
|
10146
|
+
);
|
|
10147
|
+
continue;
|
|
10148
|
+
}
|
|
10149
|
+
checkedCount++;
|
|
10150
|
+
const { zone, answers } = result.value;
|
|
10151
|
+
if (answers.length === 0) {
|
|
10152
|
+
continue;
|
|
10153
|
+
}
|
|
10154
|
+
const ip = answers[0];
|
|
10155
|
+
if (zone.zone === "dbl.spamhaus.org" && /^127\.255\.255\./.test(ip)) {
|
|
10156
|
+
findings.push(
|
|
10157
|
+
createFinding(
|
|
10158
|
+
CATEGORY,
|
|
10159
|
+
`${zone.name} query rate-limited`,
|
|
10160
|
+
"low",
|
|
10161
|
+
`Spamhaus DBL returned ${ip}, indicating a query quota or rate limit. This is not a listing. Results from this zone are unavailable.`,
|
|
10162
|
+
{ zone: zone.zone, returnCode: ip, quotaError: true }
|
|
10163
|
+
)
|
|
10164
|
+
);
|
|
10165
|
+
continue;
|
|
10166
|
+
}
|
|
10167
|
+
if (zone.zone === "multi.uribl.com") {
|
|
10168
|
+
const uriblOctet = parseInt(ip.split(".")[3], 10);
|
|
10169
|
+
if (uriblOctet === 1) {
|
|
10170
|
+
findings.push(
|
|
10171
|
+
createFinding(
|
|
10172
|
+
CATEGORY,
|
|
10173
|
+
`${zone.name} query rate-limited`,
|
|
10174
|
+
"info",
|
|
10175
|
+
`URIBL returned ${ip}, indicating the querier is rate-limited or blocked. This is not a listing. Results from this zone are unavailable.`,
|
|
10176
|
+
{ zone: zone.zone, returnCode: ip, quotaError: true }
|
|
10177
|
+
)
|
|
10178
|
+
);
|
|
9760
10179
|
continue;
|
|
9761
10180
|
}
|
|
9762
|
-
|
|
9763
|
-
|
|
9764
|
-
|
|
9765
|
-
|
|
9766
|
-
|
|
9767
|
-
|
|
9768
|
-
|
|
9769
|
-
|
|
9770
|
-
|
|
9771
|
-
|
|
9772
|
-
|
|
9773
|
-
|
|
9774
|
-
|
|
9775
|
-
category: finding.category,
|
|
9776
|
-
severity: finding.severity,
|
|
9777
|
-
title: finding.title,
|
|
9778
|
-
detail: finding.detail
|
|
9779
|
-
});
|
|
9780
|
-
if (narrative.impact) {
|
|
9781
|
-
lines.push(` Potential Impact: ${narrative.impact}`);
|
|
9782
|
-
}
|
|
9783
|
-
if (narrative.adverseConsequences) {
|
|
9784
|
-
lines.push(` Adverse Consequences: ${narrative.adverseConsequences}`);
|
|
9785
|
-
}
|
|
9786
|
-
}
|
|
10181
|
+
}
|
|
10182
|
+
const decoded = zone.decode(ip);
|
|
10183
|
+
if (decoded) {
|
|
10184
|
+
listedCount++;
|
|
10185
|
+
findings.push(
|
|
10186
|
+
createFinding(
|
|
10187
|
+
CATEGORY,
|
|
10188
|
+
`Listed on ${zone.name}`,
|
|
10189
|
+
zone.severity,
|
|
10190
|
+
`${domain} is listed on ${zone.name}: ${decoded.detail}`,
|
|
10191
|
+
{ zone: zone.zone, returnCode: ip, labels: decoded.label }
|
|
10192
|
+
)
|
|
10193
|
+
);
|
|
9787
10194
|
}
|
|
9788
10195
|
}
|
|
9789
|
-
|
|
10196
|
+
if (listedCount === 0 && findings.length === 0) {
|
|
10197
|
+
findings.push(
|
|
10198
|
+
createFinding(
|
|
10199
|
+
CATEGORY,
|
|
10200
|
+
"Domain not listed on any blocklist",
|
|
10201
|
+
"info",
|
|
10202
|
+
`${domain} is not listed on any of the ${checkedCount} checked DNS-based domain blocklists (Spamhaus DBL, URIBL, SURBL).`,
|
|
10203
|
+
{ zonesChecked: checkedCount }
|
|
10204
|
+
)
|
|
10205
|
+
);
|
|
10206
|
+
} else if (listedCount === 0 && findings.every((f) => f.severity === "low")) {
|
|
10207
|
+
findings.push(
|
|
10208
|
+
createFinding(
|
|
10209
|
+
CATEGORY,
|
|
10210
|
+
"Domain not listed on any blocklist",
|
|
10211
|
+
"info",
|
|
10212
|
+
`${domain} was not found on any of the successfully queried blocklists.`,
|
|
10213
|
+
{ zonesChecked: checkedCount }
|
|
10214
|
+
)
|
|
10215
|
+
);
|
|
10216
|
+
}
|
|
10217
|
+
return buildCheckResult(CATEGORY, findings);
|
|
9790
10218
|
}
|
|
9791
|
-
|
|
9792
|
-
|
|
9793
|
-
|
|
10219
|
+
|
|
10220
|
+
// src/tools/check-rbl.ts
|
|
10221
|
+
init_dns2();
|
|
10222
|
+
|
|
10223
|
+
// src/lib/ip-utils.ts
|
|
10224
|
+
function reverseIPv4(ip) {
|
|
10225
|
+
return ip.split(".").reverse().join(".");
|
|
9794
10226
|
}
|
|
9795
|
-
function
|
|
9796
|
-
|
|
9797
|
-
|
|
9798
|
-
|
|
9799
|
-
|
|
10227
|
+
function isPrivateIP(ip) {
|
|
10228
|
+
if (ip.includes(":")) {
|
|
10229
|
+
if (ip === "::1") return true;
|
|
10230
|
+
const lower = ip.toLowerCase();
|
|
10231
|
+
if (lower.startsWith("fe80:")) return true;
|
|
10232
|
+
if (lower.startsWith("fc") || lower.startsWith("fd")) return true;
|
|
10233
|
+
return false;
|
|
9800
10234
|
}
|
|
9801
|
-
|
|
10235
|
+
const parts = ip.split(".").map(Number);
|
|
10236
|
+
if (parts.length !== 4) return false;
|
|
10237
|
+
const [a, b] = parts;
|
|
10238
|
+
if (a === 10) return true;
|
|
10239
|
+
if (a === 172 && b >= 16 && b <= 31) return true;
|
|
10240
|
+
if (a === 192 && b === 168) return true;
|
|
10241
|
+
if (a === 127) return true;
|
|
10242
|
+
if (a === 169 && b === 254) return true;
|
|
10243
|
+
return false;
|
|
9802
10244
|
}
|
|
9803
|
-
|
|
9804
|
-
|
|
9805
|
-
|
|
9806
|
-
|
|
9807
|
-
|
|
10245
|
+
|
|
10246
|
+
// src/tools/check-rbl.ts
|
|
10247
|
+
var RBL_ZONES = [
|
|
10248
|
+
{ name: "Spamhaus ZEN", zone: "zen.spamhaus.org" },
|
|
10249
|
+
{ name: "SpamCop", zone: "bl.spamcop.net" },
|
|
10250
|
+
{ name: "UCEProtect L1", zone: "dnsbl-1.uceprotect.net" },
|
|
10251
|
+
{ name: "UCEProtect L2", zone: "dnsbl-2.uceprotect.net" },
|
|
10252
|
+
{ name: "Mailspike", zone: "bl.mailspike.net" },
|
|
10253
|
+
{ name: "Barracuda", zone: "b.barracudacentral.org" },
|
|
10254
|
+
{ name: "PSBL", zone: "psbl.surriel.com" },
|
|
10255
|
+
{ name: "SORBS", zone: "dnsbl.sorbs.net" }
|
|
10256
|
+
];
|
|
10257
|
+
var CATEGORY2 = "rbl";
|
|
10258
|
+
var MAX_MX_IPS = 4;
|
|
10259
|
+
var SPAMHAUS_CODES2 = {
|
|
10260
|
+
"0.2": "SBL \u2014 direct spam source",
|
|
10261
|
+
"0.3": "SBL CSS \u2014 spam support service",
|
|
10262
|
+
"0.4": "XBL CBL \u2014 exploited host",
|
|
10263
|
+
"0.5": "XBL \u2014 exploited host (NJABL)",
|
|
10264
|
+
"0.9": "SBL DROP \u2014 hijacked netblock",
|
|
10265
|
+
"0.10": "PBL ISP \u2014 end-user IP",
|
|
10266
|
+
"0.11": "PBL ISP \u2014 end-user IP"
|
|
10267
|
+
};
|
|
10268
|
+
function decodeSpamhausCode(ip) {
|
|
10269
|
+
if (ip.startsWith("127.255.255.")) return null;
|
|
10270
|
+
const lastTwo = ip.split(".").slice(2).join(".");
|
|
10271
|
+
return SPAMHAUS_CODES2[lastTwo] ?? `Listed (${ip})`;
|
|
10272
|
+
}
|
|
10273
|
+
function isMailspikePositive(ip) {
|
|
10274
|
+
const last = parseInt(ip.split(".").pop() ?? "0", 10);
|
|
10275
|
+
return last >= 10 && last <= 14;
|
|
10276
|
+
}
|
|
10277
|
+
async function checkRbl(domain, dnsOptions) {
|
|
10278
|
+
const findings = [];
|
|
10279
|
+
let ips = [];
|
|
10280
|
+
let usedFallback = false;
|
|
10281
|
+
try {
|
|
10282
|
+
const mxRecords = await queryMxRecords(domain, dnsOptions);
|
|
10283
|
+
if (mxRecords.length > 0) {
|
|
10284
|
+
const mxHosts = mxRecords.map((r) => r.exchange).filter(Boolean);
|
|
10285
|
+
const ipResults = await Promise.allSettled(
|
|
10286
|
+
mxHosts.slice(0, MAX_MX_IPS).map((host) => queryDnsRecords(host, "A", dnsOptions))
|
|
10287
|
+
);
|
|
10288
|
+
for (const r of ipResults) {
|
|
10289
|
+
if (r.status === "fulfilled") ips.push(...r.value);
|
|
10290
|
+
}
|
|
10291
|
+
}
|
|
10292
|
+
} catch {
|
|
10293
|
+
}
|
|
10294
|
+
if (ips.length === 0) {
|
|
10295
|
+
try {
|
|
10296
|
+
ips = await queryDnsRecords(domain, "A", dnsOptions);
|
|
10297
|
+
if (ips.length > 0) usedFallback = true;
|
|
10298
|
+
} catch {
|
|
10299
|
+
}
|
|
10300
|
+
}
|
|
10301
|
+
if (ips.length === 0) {
|
|
10302
|
+
findings.push(
|
|
10303
|
+
createFinding(CATEGORY2, "No IP addresses found", "info", `Could not resolve any IP addresses for ${domain} (no MX or A records).`, {
|
|
10304
|
+
domain
|
|
10305
|
+
})
|
|
10306
|
+
);
|
|
10307
|
+
return buildCheckResult(CATEGORY2, findings);
|
|
10308
|
+
}
|
|
10309
|
+
if (usedFallback) {
|
|
10310
|
+
findings.push(
|
|
10311
|
+
createFinding(CATEGORY2, "No MX records \u2014 using A record fallback", "info", `${domain} has no MX records. Using A record IP(s) for RBL checks.`, {
|
|
10312
|
+
domain,
|
|
10313
|
+
fallback: true
|
|
10314
|
+
})
|
|
10315
|
+
);
|
|
10316
|
+
}
|
|
10317
|
+
ips = [...new Set(ips)].slice(0, MAX_MX_IPS);
|
|
10318
|
+
let totalListings = 0;
|
|
10319
|
+
for (const ip of ips) {
|
|
10320
|
+
if (!/^(\d{1,3}\.){3}\d{1,3}$/.test(ip)) continue;
|
|
10321
|
+
if (isPrivateIP(ip)) {
|
|
10322
|
+
findings.push(
|
|
10323
|
+
createFinding(CATEGORY2, `Private IP detected: ${ip}`, "info", `MX resolves to private IP ${ip} \u2014 RBL checks skipped for this address.`, {
|
|
10324
|
+
ip,
|
|
10325
|
+
private: true
|
|
10326
|
+
})
|
|
10327
|
+
);
|
|
10328
|
+
continue;
|
|
10329
|
+
}
|
|
10330
|
+
const reversed = reverseIPv4(ip);
|
|
10331
|
+
let ipListingCount = 0;
|
|
10332
|
+
let hasSpamhausListing = false;
|
|
10333
|
+
const ipListingIndices = [];
|
|
10334
|
+
const rblResults = await Promise.allSettled(
|
|
10335
|
+
RBL_ZONES.map(async (rbl) => {
|
|
10336
|
+
const queryName = `${reversed}.${rbl.zone}`;
|
|
10337
|
+
try {
|
|
10338
|
+
const answers = await queryDnsRecords(queryName, "A", dnsOptions);
|
|
10339
|
+
if (answers.length === 0) return { rbl, listed: false };
|
|
10340
|
+
const returnIp = answers[0];
|
|
10341
|
+
if (rbl.zone === "zen.spamhaus.org" && returnIp.startsWith("127.255.255.")) {
|
|
10342
|
+
return { rbl, listed: false, quota: true };
|
|
10343
|
+
}
|
|
10344
|
+
if (rbl.zone === "bl.mailspike.net" && isMailspikePositive(returnIp)) {
|
|
10345
|
+
return { rbl, listed: false, positiveRep: true };
|
|
10346
|
+
}
|
|
10347
|
+
return { rbl, listed: true, returnIp };
|
|
10348
|
+
} catch {
|
|
10349
|
+
return { rbl, listed: false, error: true };
|
|
10350
|
+
}
|
|
10351
|
+
})
|
|
10352
|
+
);
|
|
10353
|
+
for (const settled of rblResults) {
|
|
10354
|
+
if (settled.status === "rejected") continue;
|
|
10355
|
+
const result = settled.value;
|
|
10356
|
+
if (result.quota) {
|
|
10357
|
+
findings.push(
|
|
10358
|
+
createFinding(CATEGORY2, `${result.rbl.name} quota exceeded`, "info", `Spamhaus returned a quota/rate-limit response for ${ip}. Use a DQS key for reliable results.`, {
|
|
10359
|
+
ip,
|
|
10360
|
+
zone: result.rbl.zone,
|
|
10361
|
+
quota: true
|
|
10362
|
+
})
|
|
10363
|
+
);
|
|
10364
|
+
continue;
|
|
10365
|
+
}
|
|
10366
|
+
if (result.positiveRep) {
|
|
10367
|
+
findings.push(
|
|
10368
|
+
createFinding(CATEGORY2, `Positive Mailspike reputation for ${ip}`, "info", `${ip} has positive reputation on Mailspike.`, {
|
|
10369
|
+
ip,
|
|
10370
|
+
zone: result.rbl.zone,
|
|
10371
|
+
positiveReputation: true
|
|
10372
|
+
})
|
|
10373
|
+
);
|
|
10374
|
+
continue;
|
|
10375
|
+
}
|
|
10376
|
+
if (result.error || !result.listed) continue;
|
|
10377
|
+
ipListingCount++;
|
|
10378
|
+
totalListings++;
|
|
10379
|
+
if (result.rbl.zone === "zen.spamhaus.org") hasSpamhausListing = true;
|
|
10380
|
+
const severity = result.rbl.zone === "zen.spamhaus.org" ? "high" : "low";
|
|
10381
|
+
const detail = result.rbl.zone === "zen.spamhaus.org" ? `${ip} is listed on ${result.rbl.name}: ${decodeSpamhausCode(result.returnIp) ?? result.returnIp}.` : `${ip} is listed on ${result.rbl.name}.`;
|
|
10382
|
+
ipListingIndices.push(findings.length);
|
|
10383
|
+
findings.push(
|
|
10384
|
+
createFinding(CATEGORY2, `Listed on ${result.rbl.name}`, severity, detail, {
|
|
10385
|
+
ip,
|
|
10386
|
+
zone: result.rbl.zone,
|
|
10387
|
+
returnCode: result.returnIp
|
|
10388
|
+
})
|
|
10389
|
+
);
|
|
10390
|
+
}
|
|
10391
|
+
if (!hasSpamhausListing && ipListingCount >= 2) {
|
|
10392
|
+
const firstLowIdx = ipListingIndices.find((idx) => findings[idx]?.severity === "low");
|
|
10393
|
+
if (firstLowIdx !== void 0) {
|
|
10394
|
+
const f = findings[firstLowIdx];
|
|
10395
|
+
findings[firstLowIdx] = createFinding(
|
|
10396
|
+
CATEGORY2,
|
|
10397
|
+
f.title,
|
|
10398
|
+
"medium",
|
|
10399
|
+
f.detail + ` (elevated: listed on ${ipListingCount} RBLs)`,
|
|
10400
|
+
f.metadata
|
|
10401
|
+
);
|
|
10402
|
+
}
|
|
10403
|
+
}
|
|
10404
|
+
}
|
|
10405
|
+
if (totalListings === 0 && !findings.some((f) => f.title.includes("Listed"))) {
|
|
10406
|
+
findings.push(
|
|
10407
|
+
createFinding(CATEGORY2, "IP reputation clean \u2014 not listed on any RBL", "info", `All checked IPs for ${domain} are clean on ${RBL_ZONES.length} RBLs.`, {
|
|
10408
|
+
ips,
|
|
10409
|
+
zones: RBL_ZONES.map((z8) => z8.zone)
|
|
10410
|
+
})
|
|
10411
|
+
);
|
|
10412
|
+
}
|
|
10413
|
+
return buildCheckResult(CATEGORY2, findings);
|
|
10414
|
+
}
|
|
10415
|
+
|
|
10416
|
+
// src/tools/check-cymru-asn.ts
|
|
10417
|
+
init_dns2();
|
|
10418
|
+
var CATEGORY3 = "asn";
|
|
10419
|
+
var HIGH_RISK_ASNS = /* @__PURE__ */ new Set([
|
|
10420
|
+
9009,
|
|
10421
|
+
// M247
|
|
10422
|
+
53667,
|
|
10423
|
+
// Frantech/BuyVM
|
|
10424
|
+
36352,
|
|
10425
|
+
// ColoCrossing
|
|
10426
|
+
20473,
|
|
10427
|
+
// Vultr
|
|
10428
|
+
14061,
|
|
10429
|
+
// DigitalOcean
|
|
10430
|
+
63949
|
|
10431
|
+
// Linode/Akamai
|
|
10432
|
+
]);
|
|
10433
|
+
function parseOriginTxt(txt) {
|
|
10434
|
+
const parts = txt.split("|").map((p) => p.trim());
|
|
10435
|
+
if (parts.length < 5) return null;
|
|
10436
|
+
const asn = parseInt(parts[0], 10);
|
|
10437
|
+
if (!Number.isFinite(asn) || asn <= 0) return null;
|
|
10438
|
+
return {
|
|
10439
|
+
asn,
|
|
10440
|
+
prefix: parts[1],
|
|
10441
|
+
cc: parts[2],
|
|
10442
|
+
registry: parts[3],
|
|
10443
|
+
allocated: parts[4]
|
|
10444
|
+
};
|
|
10445
|
+
}
|
|
10446
|
+
function parseOrgTxt(txt) {
|
|
10447
|
+
const parts = txt.split("|").map((p) => p.trim());
|
|
10448
|
+
if (parts.length < 5) return null;
|
|
10449
|
+
return parts[4] || null;
|
|
10450
|
+
}
|
|
10451
|
+
async function checkCymruAsn(domain, dnsOptions) {
|
|
10452
|
+
const findings = [];
|
|
10453
|
+
let ips = [];
|
|
10454
|
+
try {
|
|
10455
|
+
ips = await queryDnsRecords(domain, "A", dnsOptions);
|
|
10456
|
+
} catch {
|
|
10457
|
+
}
|
|
10458
|
+
if (ips.length === 0) {
|
|
10459
|
+
findings.push(
|
|
10460
|
+
createFinding(CATEGORY3, "No A records found", "info", `Could not resolve any A records for ${domain}. ASN lookup requires IPv4 addresses.`, {
|
|
10461
|
+
domain
|
|
10462
|
+
})
|
|
10463
|
+
);
|
|
10464
|
+
return buildCheckResult(CATEGORY3, findings);
|
|
10465
|
+
}
|
|
10466
|
+
ips = [...new Set(ips)];
|
|
10467
|
+
const seenAsns = /* @__PURE__ */ new Set();
|
|
10468
|
+
for (const ip of ips) {
|
|
10469
|
+
if (!/^(\d{1,3}\.){3}\d{1,3}$/.test(ip)) continue;
|
|
10470
|
+
const reversed = reverseIPv4(ip);
|
|
10471
|
+
const originName = `${reversed}.origin.asn.cymru.com`;
|
|
10472
|
+
let originTxts = [];
|
|
10473
|
+
try {
|
|
10474
|
+
originTxts = await queryTxtRecords(originName, dnsOptions);
|
|
10475
|
+
} catch {
|
|
10476
|
+
}
|
|
10477
|
+
if (originTxts.length === 0) {
|
|
10478
|
+
findings.push(
|
|
10479
|
+
createFinding(CATEGORY3, `No ASN data for ${ip}`, "info", `No ASN data returned from Team Cymru for ${ip}.`, {
|
|
10480
|
+
ip
|
|
10481
|
+
})
|
|
10482
|
+
);
|
|
10483
|
+
continue;
|
|
10484
|
+
}
|
|
10485
|
+
const origin = parseOriginTxt(originTxts[0]);
|
|
10486
|
+
if (!origin) {
|
|
10487
|
+
findings.push(
|
|
10488
|
+
createFinding(CATEGORY3, `Unparseable ASN response for ${ip}`, "info", `Team Cymru returned an unparseable response for ${ip}.`, {
|
|
10489
|
+
ip,
|
|
10490
|
+
raw: originTxts[0]
|
|
10491
|
+
})
|
|
10492
|
+
);
|
|
10493
|
+
continue;
|
|
10494
|
+
}
|
|
10495
|
+
let orgName = null;
|
|
10496
|
+
if (!seenAsns.has(origin.asn)) {
|
|
10497
|
+
try {
|
|
10498
|
+
const orgTxts = await queryTxtRecords(`AS${origin.asn}.asn.cymru.com`, dnsOptions);
|
|
10499
|
+
if (orgTxts.length > 0) {
|
|
10500
|
+
orgName = parseOrgTxt(orgTxts[0]);
|
|
10501
|
+
}
|
|
10502
|
+
} catch {
|
|
10503
|
+
}
|
|
10504
|
+
}
|
|
10505
|
+
seenAsns.add(origin.asn);
|
|
10506
|
+
const isHighRisk = HIGH_RISK_ASNS.has(origin.asn);
|
|
10507
|
+
const orgLabel = orgName ? ` (${orgName})` : "";
|
|
10508
|
+
if (isHighRisk) {
|
|
10509
|
+
findings.push(
|
|
10510
|
+
createFinding(
|
|
10511
|
+
CATEGORY3,
|
|
10512
|
+
`High-risk ASN ${origin.asn} detected`,
|
|
10513
|
+
"medium",
|
|
10514
|
+
`${ip} is announced by high-risk ASN ${origin.asn}${orgLabel} in prefix ${origin.prefix} (${origin.cc}, ${origin.registry}).`,
|
|
10515
|
+
{ ip, asn: origin.asn, prefix: origin.prefix, cc: origin.cc, registry: origin.registry, orgName, highRisk: true }
|
|
10516
|
+
)
|
|
10517
|
+
);
|
|
10518
|
+
}
|
|
10519
|
+
findings.push(
|
|
10520
|
+
createFinding(
|
|
10521
|
+
CATEGORY3,
|
|
10522
|
+
`ASN ${origin.asn} for ${ip}`,
|
|
10523
|
+
"info",
|
|
10524
|
+
`${ip} \u2192 AS${origin.asn}${orgLabel}, prefix ${origin.prefix}, country ${origin.cc}, registry ${origin.registry}, allocated ${origin.allocated}.`,
|
|
10525
|
+
{ ip, asn: origin.asn, prefix: origin.prefix, cc: origin.cc, registry: origin.registry, allocated: origin.allocated, orgName }
|
|
10526
|
+
)
|
|
10527
|
+
);
|
|
10528
|
+
}
|
|
10529
|
+
return buildCheckResult(CATEGORY3, findings);
|
|
10530
|
+
}
|
|
10531
|
+
|
|
10532
|
+
// src/tools/check-rdap-lookup.ts
|
|
10533
|
+
var CATEGORY4 = "rdap";
|
|
10534
|
+
var IANA_BOOTSTRAP_URL = "https://data.iana.org/rdap/dns.json";
|
|
10535
|
+
var FALLBACK_RDAP_SERVERS = {
|
|
10536
|
+
com: "https://rdap.verisign.com/com/v1/",
|
|
10537
|
+
net: "https://rdap.verisign.com/net/v1/",
|
|
10538
|
+
org: "https://rdap.org/",
|
|
10539
|
+
info: "https://rdap.afilias.net/rdap/info/",
|
|
10540
|
+
io: "https://rdap.nic.io/"
|
|
10541
|
+
};
|
|
10542
|
+
var bootstrapCache = null;
|
|
10543
|
+
var RDAP_TIMEOUT_MS = 1e4;
|
|
10544
|
+
async function fetchBootstrap() {
|
|
10545
|
+
if (bootstrapCache) return bootstrapCache;
|
|
10546
|
+
try {
|
|
10547
|
+
const resp = await fetch(IANA_BOOTSTRAP_URL, {
|
|
10548
|
+
redirect: "manual",
|
|
10549
|
+
signal: AbortSignal.timeout(RDAP_TIMEOUT_MS),
|
|
10550
|
+
headers: { Accept: "application/json" }
|
|
10551
|
+
});
|
|
10552
|
+
if (!resp.ok) return {};
|
|
10553
|
+
const data = await resp.json();
|
|
10554
|
+
const map2 = {};
|
|
10555
|
+
if (Array.isArray(data.services)) {
|
|
10556
|
+
for (const [tlds, urls] of data.services) {
|
|
10557
|
+
if (!Array.isArray(tlds) || !Array.isArray(urls) || urls.length === 0) continue;
|
|
10558
|
+
const serverUrl = urls[0];
|
|
10559
|
+
for (const tld of tlds) {
|
|
10560
|
+
if (typeof tld === "string" && typeof serverUrl === "string") {
|
|
10561
|
+
map2[tld.toLowerCase()] = serverUrl;
|
|
10562
|
+
}
|
|
10563
|
+
}
|
|
10564
|
+
}
|
|
10565
|
+
}
|
|
10566
|
+
bootstrapCache = map2;
|
|
10567
|
+
return map2;
|
|
10568
|
+
} catch {
|
|
10569
|
+
return {};
|
|
10570
|
+
}
|
|
10571
|
+
}
|
|
10572
|
+
async function resolveRdapServer(tld) {
|
|
10573
|
+
const normalizedTld = tld.toLowerCase();
|
|
10574
|
+
const bootstrap = await fetchBootstrap();
|
|
10575
|
+
if (bootstrap[normalizedTld]) return bootstrap[normalizedTld];
|
|
10576
|
+
return FALLBACK_RDAP_SERVERS[normalizedTld] ?? null;
|
|
10577
|
+
}
|
|
10578
|
+
function extractVcardName(entity) {
|
|
10579
|
+
if (!entity.vcardArray || entity.vcardArray[0] !== "vcard") return null;
|
|
10580
|
+
const properties = entity.vcardArray[1];
|
|
10581
|
+
if (!Array.isArray(properties)) return null;
|
|
10582
|
+
for (const prop of properties) {
|
|
10583
|
+
if (Array.isArray(prop) && prop[0] === "fn" && typeof prop[3] === "string") {
|
|
10584
|
+
return prop[3];
|
|
10585
|
+
}
|
|
10586
|
+
}
|
|
10587
|
+
return null;
|
|
10588
|
+
}
|
|
10589
|
+
function extractVcardCountry(entity) {
|
|
10590
|
+
if (!entity.vcardArray || entity.vcardArray[0] !== "vcard") return null;
|
|
10591
|
+
const properties = entity.vcardArray[1];
|
|
10592
|
+
if (!Array.isArray(properties)) return null;
|
|
10593
|
+
for (const prop of properties) {
|
|
10594
|
+
if (Array.isArray(prop) && prop[0] === "adr") {
|
|
10595
|
+
const value = prop[3];
|
|
10596
|
+
if (Array.isArray(value) && value.length > 0) {
|
|
10597
|
+
const country = value[value.length - 1];
|
|
10598
|
+
return typeof country === "string" && country.length > 0 ? country : null;
|
|
10599
|
+
}
|
|
10600
|
+
}
|
|
10601
|
+
}
|
|
10602
|
+
return null;
|
|
10603
|
+
}
|
|
10604
|
+
function findEntityByRole(entities, role) {
|
|
10605
|
+
if (!Array.isArray(entities)) return null;
|
|
10606
|
+
for (const entity of entities) {
|
|
10607
|
+
if (Array.isArray(entity.roles) && entity.roles.includes(role)) {
|
|
10608
|
+
return entity;
|
|
10609
|
+
}
|
|
10610
|
+
if (Array.isArray(entity.entities)) {
|
|
10611
|
+
const nested = findEntityByRole(entity.entities, role);
|
|
10612
|
+
if (nested) return nested;
|
|
10613
|
+
}
|
|
10614
|
+
}
|
|
10615
|
+
return null;
|
|
10616
|
+
}
|
|
10617
|
+
function findEvent(events, action) {
|
|
10618
|
+
if (!Array.isArray(events)) return null;
|
|
10619
|
+
return events.find((e) => e.eventAction === action) ?? null;
|
|
10620
|
+
}
|
|
10621
|
+
async function checkRdapLookup(domain) {
|
|
10622
|
+
const findings = [];
|
|
10623
|
+
const labels = domain.split(".");
|
|
10624
|
+
const tld = labels[labels.length - 1];
|
|
10625
|
+
const rdapServerUrl = await resolveRdapServer(tld);
|
|
10626
|
+
if (!rdapServerUrl) {
|
|
10627
|
+
findings.push(
|
|
10628
|
+
createFinding(CATEGORY4, "No RDAP server found", "info", `No RDAP server found for TLD ".${tld}". RDAP data unavailable for this domain.`, {
|
|
10629
|
+
domain,
|
|
10630
|
+
tld
|
|
10631
|
+
})
|
|
10632
|
+
);
|
|
10633
|
+
return buildCheckResult(CATEGORY4, findings);
|
|
10634
|
+
}
|
|
10635
|
+
let rdapData;
|
|
10636
|
+
try {
|
|
10637
|
+
const baseUrl = rdapServerUrl.endsWith("/") ? rdapServerUrl : `${rdapServerUrl}/`;
|
|
10638
|
+
const rdapUrl = `${baseUrl}domain/${domain}`;
|
|
10639
|
+
const resp = await fetch(rdapUrl, {
|
|
10640
|
+
redirect: "manual",
|
|
10641
|
+
signal: AbortSignal.timeout(RDAP_TIMEOUT_MS),
|
|
10642
|
+
headers: { Accept: "application/rdap+json, application/json" }
|
|
10643
|
+
});
|
|
10644
|
+
if (!resp.ok) {
|
|
10645
|
+
findings.push(
|
|
10646
|
+
createFinding(CATEGORY4, "RDAP lookup failed", "info", `RDAP server returned HTTP ${resp.status} for ${domain}. Registration data unavailable.`, {
|
|
10647
|
+
domain,
|
|
10648
|
+
httpStatus: resp.status
|
|
10649
|
+
})
|
|
10650
|
+
);
|
|
10651
|
+
return buildCheckResult(CATEGORY4, findings);
|
|
10652
|
+
}
|
|
10653
|
+
rdapData = await resp.json();
|
|
10654
|
+
} catch (err) {
|
|
10655
|
+
const message = err instanceof Error ? err.message : "Unknown error";
|
|
10656
|
+
findings.push(
|
|
10657
|
+
createFinding(CATEGORY4, "RDAP lookup failed", "info", `RDAP lookup failed for ${domain}: ${message}`, {
|
|
10658
|
+
domain,
|
|
10659
|
+
error: message
|
|
10660
|
+
})
|
|
10661
|
+
);
|
|
10662
|
+
return buildCheckResult(CATEGORY4, findings);
|
|
10663
|
+
}
|
|
10664
|
+
const registrarEntity = findEntityByRole(rdapData.entities, "registrar");
|
|
10665
|
+
const registrarName = registrarEntity ? extractVcardName(registrarEntity) : null;
|
|
10666
|
+
const registrantEntity = findEntityByRole(rdapData.entities, "registrant");
|
|
10667
|
+
const registrantName = registrantEntity ? extractVcardName(registrantEntity) : null;
|
|
10668
|
+
const registrantCountry = registrantEntity ? extractVcardCountry(registrantEntity) : null;
|
|
10669
|
+
const registrationEvent = findEvent(rdapData.events, "registration");
|
|
10670
|
+
const expirationEvent = findEvent(rdapData.events, "expiration");
|
|
10671
|
+
const lastChangedEvent = findEvent(rdapData.events, "last changed");
|
|
10672
|
+
let domainAgeDays = null;
|
|
10673
|
+
if (registrationEvent?.eventDate) {
|
|
10674
|
+
const creationTime = new Date(registrationEvent.eventDate).getTime();
|
|
10675
|
+
if (Number.isFinite(creationTime)) {
|
|
10676
|
+
domainAgeDays = Math.floor((Date.now() - creationTime) / (1e3 * 60 * 60 * 24));
|
|
10677
|
+
}
|
|
10678
|
+
}
|
|
10679
|
+
let daysUntilExpiration = null;
|
|
10680
|
+
if (expirationEvent?.eventDate) {
|
|
10681
|
+
const expirationTime = new Date(expirationEvent.eventDate).getTime();
|
|
10682
|
+
if (Number.isFinite(expirationTime)) {
|
|
10683
|
+
daysUntilExpiration = Math.floor((expirationTime - Date.now()) / (1e3 * 60 * 60 * 24));
|
|
10684
|
+
}
|
|
10685
|
+
}
|
|
10686
|
+
const eppStatus = Array.isArray(rdapData.status) ? rdapData.status : [];
|
|
10687
|
+
const metadata = {
|
|
10688
|
+
domain,
|
|
10689
|
+
registrar: registrarName,
|
|
10690
|
+
registrant: registrantName,
|
|
10691
|
+
registrantCountry,
|
|
10692
|
+
creationDate: registrationEvent?.eventDate ?? null,
|
|
10693
|
+
expirationDate: expirationEvent?.eventDate ?? null,
|
|
10694
|
+
lastChanged: lastChangedEvent?.eventDate ?? null,
|
|
10695
|
+
eppStatus,
|
|
10696
|
+
rdapServer: rdapServerUrl
|
|
10697
|
+
};
|
|
10698
|
+
if (domainAgeDays !== null) {
|
|
10699
|
+
metadata.domainAgeDays = domainAgeDays;
|
|
10700
|
+
}
|
|
10701
|
+
if (daysUntilExpiration !== null) {
|
|
10702
|
+
metadata.daysUntilExpiration = daysUntilExpiration;
|
|
10703
|
+
}
|
|
10704
|
+
if (domainAgeDays !== null && domainAgeDays < 30) {
|
|
10705
|
+
findings.push(
|
|
10706
|
+
createFinding(
|
|
10707
|
+
CATEGORY4,
|
|
10708
|
+
"Newly registered domain",
|
|
10709
|
+
"medium",
|
|
10710
|
+
`${domain} was registered ${domainAgeDays} day${domainAgeDays !== 1 ? "s" : ""} ago (${registrationEvent.eventDate}). Newly registered domains are commonly used for phishing and spam.`,
|
|
10711
|
+
metadata
|
|
10712
|
+
)
|
|
10713
|
+
);
|
|
10714
|
+
}
|
|
10715
|
+
if (daysUntilExpiration !== null && daysUntilExpiration <= 30 && daysUntilExpiration >= 0) {
|
|
10716
|
+
findings.push(
|
|
10717
|
+
createFinding(
|
|
10718
|
+
CATEGORY4,
|
|
10719
|
+
"Domain expiring soon",
|
|
10720
|
+
"low",
|
|
10721
|
+
`${domain} expires in ${daysUntilExpiration} day${daysUntilExpiration !== 1 ? "s" : ""} (${expirationEvent.eventDate}). Expired domains can be re-registered by attackers.`,
|
|
10722
|
+
metadata
|
|
10723
|
+
)
|
|
10724
|
+
);
|
|
10725
|
+
}
|
|
10726
|
+
const parts = [];
|
|
10727
|
+
if (registrarName) parts.push(`Registrar: ${registrarName}`);
|
|
10728
|
+
if (registrantName) parts.push(`Registrant: ${registrantName}`);
|
|
10729
|
+
if (registrantCountry) parts.push(`Country: ${registrantCountry}`);
|
|
10730
|
+
if (registrationEvent?.eventDate) parts.push(`Created: ${registrationEvent.eventDate.split("T")[0]}`);
|
|
10731
|
+
if (expirationEvent?.eventDate) parts.push(`Expires: ${expirationEvent.eventDate.split("T")[0]}`);
|
|
10732
|
+
if (lastChangedEvent?.eventDate) parts.push(`Updated: ${lastChangedEvent.eventDate.split("T")[0]}`);
|
|
10733
|
+
if (eppStatus.length > 0) parts.push(`Status: ${eppStatus.join(", ")}`);
|
|
10734
|
+
if (domainAgeDays !== null) parts.push(`Age: ${domainAgeDays} days`);
|
|
10735
|
+
findings.push(
|
|
10736
|
+
createFinding(
|
|
10737
|
+
CATEGORY4,
|
|
10738
|
+
"Registration details",
|
|
10739
|
+
"info",
|
|
10740
|
+
parts.length > 0 ? parts.join(". ") + "." : `RDAP data retrieved for ${domain} but no structured fields found.`,
|
|
10741
|
+
metadata
|
|
10742
|
+
)
|
|
10743
|
+
);
|
|
10744
|
+
return buildCheckResult(CATEGORY4, findings);
|
|
10745
|
+
}
|
|
10746
|
+
|
|
10747
|
+
// src/tools/check-nsec-walkability.ts
|
|
10748
|
+
init_dns2();
|
|
10749
|
+
var CATEGORY5 = "nsec_walkability";
|
|
10750
|
+
var NSEC3_HASH_ALGORITHMS = {
|
|
10751
|
+
1: "SHA-1"
|
|
10752
|
+
};
|
|
10753
|
+
function parseNsec3Param(data) {
|
|
10754
|
+
const parts = data.trim().split(/\s+/);
|
|
10755
|
+
if (parts.length < 4) return null;
|
|
10756
|
+
const algorithm = parseInt(parts[0], 10);
|
|
10757
|
+
const flags = parseInt(parts[1], 10);
|
|
10758
|
+
const iterations = parseInt(parts[2], 10);
|
|
10759
|
+
const salt = parts[3];
|
|
10760
|
+
if (!Number.isFinite(algorithm) || !Number.isFinite(flags) || !Number.isFinite(iterations)) {
|
|
10761
|
+
return null;
|
|
10762
|
+
}
|
|
10763
|
+
return {
|
|
10764
|
+
algorithm,
|
|
10765
|
+
algorithmName: NSEC3_HASH_ALGORITHMS[algorithm] ?? `Unknown (${algorithm})`,
|
|
10766
|
+
flags,
|
|
10767
|
+
iterations,
|
|
10768
|
+
salt
|
|
10769
|
+
};
|
|
10770
|
+
}
|
|
10771
|
+
async function checkNsecWalkability(domain, dnsOptions) {
|
|
10772
|
+
const findings = [];
|
|
10773
|
+
let nsec3Records = [];
|
|
10774
|
+
try {
|
|
10775
|
+
nsec3Records = await queryDnsRecords(domain, "NSEC3PARAM", dnsOptions);
|
|
10776
|
+
} catch {
|
|
10777
|
+
findings.push(
|
|
10778
|
+
createFinding(
|
|
10779
|
+
CATEGORY5,
|
|
10780
|
+
"NSEC3PARAM query failed",
|
|
10781
|
+
"info",
|
|
10782
|
+
`DNS query for NSEC3PARAM records at ${domain} failed. Unable to assess zone walkability. Note: this analysis cannot probe for actual NSEC/NSEC3 denial records via DoH and analyzes configuration parameters only.`,
|
|
10783
|
+
{ domain }
|
|
10784
|
+
)
|
|
10785
|
+
);
|
|
10786
|
+
return buildCheckResult(CATEGORY5, findings);
|
|
10787
|
+
}
|
|
10788
|
+
if (nsec3Records.length === 0) {
|
|
10789
|
+
findings.push(
|
|
10790
|
+
createFinding(
|
|
10791
|
+
CATEGORY5,
|
|
10792
|
+
"No NSEC3PARAM record found",
|
|
10793
|
+
"high",
|
|
10794
|
+
`No NSEC3PARAM record was found for ${domain}. The zone likely uses plain NSEC, which makes it fully walkable \u2014 an attacker can enumerate all zone contents by following NSEC chain links. Limitation: DoH cannot probe for actual NSEC/NSEC3 denial-of-existence records, so this assessment is based on the absence of NSEC3PARAM configuration only.`,
|
|
10795
|
+
{ domain, walkable: true }
|
|
10796
|
+
)
|
|
10797
|
+
);
|
|
10798
|
+
return buildCheckResult(CATEGORY5, findings);
|
|
10799
|
+
}
|
|
10800
|
+
const params = parseNsec3Param(nsec3Records[0]);
|
|
10801
|
+
if (!params) {
|
|
10802
|
+
findings.push(
|
|
10803
|
+
createFinding(
|
|
10804
|
+
CATEGORY5,
|
|
10805
|
+
"Unparseable NSEC3PARAM",
|
|
10806
|
+
"info",
|
|
10807
|
+
`NSEC3PARAM record for ${domain} could not be parsed: ${nsec3Records[0]}`,
|
|
10808
|
+
{ domain, raw: nsec3Records[0] }
|
|
10809
|
+
)
|
|
10810
|
+
);
|
|
10811
|
+
return buildCheckResult(CATEGORY5, findings);
|
|
10812
|
+
}
|
|
10813
|
+
const hasSalt = params.salt !== "-";
|
|
10814
|
+
const hasIterations = params.iterations > 0;
|
|
10815
|
+
if (params.flags & 1) {
|
|
10816
|
+
findings.push(
|
|
10817
|
+
createFinding(
|
|
10818
|
+
CATEGORY5,
|
|
10819
|
+
"NSEC3 opt-out enabled",
|
|
10820
|
+
"low",
|
|
10821
|
+
`NSEC3PARAM for ${domain} has the opt-out flag set (flags=${params.flags}). Opt-out allows unsigned delegations to be omitted from the NSEC3 chain, which may leave some subdomains without denial-of-existence protection.`,
|
|
10822
|
+
{ domain, flags: params.flags, optOut: true }
|
|
10823
|
+
)
|
|
10824
|
+
);
|
|
10825
|
+
}
|
|
10826
|
+
if (!hasIterations && !hasSalt) {
|
|
10827
|
+
findings.push(
|
|
10828
|
+
createFinding(
|
|
10829
|
+
CATEGORY5,
|
|
10830
|
+
"NSEC3 with minimal parameters",
|
|
10831
|
+
"medium",
|
|
10832
|
+
`NSEC3PARAM for ${domain} uses 0 iterations and no salt (RFC 9276 recommended defaults). While NSEC3 prevents trivial zone walking, the low enumeration cost means offline dictionary attacks against the hashed names are feasible. Algorithm: ${params.algorithmName}. Note: this tool analyzes NSEC3PARAM configuration only and cannot probe for actual NSEC3 denial records via DoH.`,
|
|
10833
|
+
{
|
|
10834
|
+
domain,
|
|
10835
|
+
algorithm: params.algorithmName,
|
|
10836
|
+
algorithmId: params.algorithm,
|
|
10837
|
+
iterations: params.iterations,
|
|
10838
|
+
salt: params.salt,
|
|
10839
|
+
hasSalt: false
|
|
10840
|
+
}
|
|
10841
|
+
)
|
|
10842
|
+
);
|
|
10843
|
+
} else {
|
|
10844
|
+
findings.push(
|
|
10845
|
+
createFinding(
|
|
10846
|
+
CATEGORY5,
|
|
10847
|
+
"NSEC3 parameters configured",
|
|
10848
|
+
"info",
|
|
10849
|
+
`NSEC3PARAM for ${domain}: algorithm ${params.algorithmName}, ${params.iterations} iteration${params.iterations !== 1 ? "s" : ""}, salt ${hasSalt ? params.salt : "none"}. Zone uses NSEC3 hashed denial-of-existence, which mitigates trivial zone walking. Note: this tool analyzes configuration parameters only.`,
|
|
10850
|
+
{
|
|
10851
|
+
domain,
|
|
10852
|
+
algorithm: params.algorithmName,
|
|
10853
|
+
algorithmId: params.algorithm,
|
|
10854
|
+
iterations: params.iterations,
|
|
10855
|
+
salt: params.salt,
|
|
10856
|
+
hasSalt
|
|
10857
|
+
}
|
|
10858
|
+
)
|
|
10859
|
+
);
|
|
10860
|
+
}
|
|
10861
|
+
return buildCheckResult(CATEGORY5, findings);
|
|
10862
|
+
}
|
|
10863
|
+
|
|
10864
|
+
// src/tools/check-dnssec-chain.ts
|
|
10865
|
+
init_dns2();
|
|
10866
|
+
var CATEGORY6 = "dnssec_chain";
|
|
10867
|
+
var DNSSEC_ALGORITHMS = {
|
|
10868
|
+
1: "RSA-MD5",
|
|
10869
|
+
3: "DSA-SHA1",
|
|
10870
|
+
5: "RSA-SHA1",
|
|
10871
|
+
6: "DSA-NSEC3-SHA1",
|
|
10872
|
+
7: "RSA-SHA1-NSEC3",
|
|
10873
|
+
8: "RSA-SHA256",
|
|
10874
|
+
10: "RSA-SHA512",
|
|
10875
|
+
12: "ECC-GOST",
|
|
10876
|
+
13: "ECDSAP256SHA256",
|
|
10877
|
+
14: "ECDSAP384SHA384",
|
|
10878
|
+
15: "Ed25519",
|
|
10879
|
+
16: "Ed448"
|
|
10880
|
+
};
|
|
10881
|
+
var WEAK_ALGORITHMS = /* @__PURE__ */ new Set([1, 3, 5, 6, 7]);
|
|
10882
|
+
var DIGEST_TYPES = { 1: "SHA-1", 2: "SHA-256", 4: "SHA-384" };
|
|
10883
|
+
function parseDsRecord2(data) {
|
|
10884
|
+
const parts = data.trim().split(/\s+/);
|
|
10885
|
+
if (parts.length < 4) return null;
|
|
10886
|
+
const keyTag = parseInt(parts[0], 10);
|
|
10887
|
+
const algorithm = parseInt(parts[1], 10);
|
|
10888
|
+
const digestType = parseInt(parts[2], 10);
|
|
10889
|
+
const digest = parts.slice(3).join("");
|
|
10890
|
+
if (!Number.isFinite(keyTag) || !Number.isFinite(algorithm) || !Number.isFinite(digestType)) return null;
|
|
10891
|
+
return { keyTag, algorithm, digestType, digest };
|
|
10892
|
+
}
|
|
10893
|
+
function parseDnskeyRecord(data) {
|
|
10894
|
+
const parts = data.trim().split(/\s+/);
|
|
10895
|
+
if (parts.length < 4) return null;
|
|
10896
|
+
const flags = parseInt(parts[0], 10);
|
|
10897
|
+
const protocol = parseInt(parts[1], 10);
|
|
10898
|
+
const algorithm = parseInt(parts[2], 10);
|
|
10899
|
+
const pubkey = parts.slice(3).join("");
|
|
10900
|
+
if (!Number.isFinite(flags) || !Number.isFinite(protocol) || !Number.isFinite(algorithm)) return null;
|
|
10901
|
+
return { flags, protocol, algorithm, pubkey, isKsk: flags === 257 };
|
|
10902
|
+
}
|
|
10903
|
+
function determineLinkage(dsRecords, dnskeyRecords) {
|
|
10904
|
+
if (dsRecords.length === 0) return "no_ds";
|
|
10905
|
+
if (dnskeyRecords.length === 0) return "no_dnskey";
|
|
10906
|
+
const dsAlgs = new Set(dsRecords.map((ds) => ds.algorithm));
|
|
10907
|
+
const keyAlgs = new Set(dnskeyRecords.map((k) => k.algorithm));
|
|
10908
|
+
for (const alg of dsAlgs) {
|
|
10909
|
+
if (keyAlgs.has(alg)) return "linked";
|
|
10910
|
+
}
|
|
10911
|
+
return "broken";
|
|
10912
|
+
}
|
|
10913
|
+
function buildZoneHierarchy(domain) {
|
|
10914
|
+
const labels = domain.split(".");
|
|
10915
|
+
const zones = ["."];
|
|
10916
|
+
for (let i = labels.length - 1; i >= 0; i--) {
|
|
10917
|
+
zones.push(labels.slice(i).join("."));
|
|
10918
|
+
}
|
|
10919
|
+
return zones;
|
|
10920
|
+
}
|
|
10921
|
+
async function checkDnssecChain(domain, dnsOptions) {
|
|
10922
|
+
const findings = [];
|
|
10923
|
+
const zones = buildZoneHierarchy(domain);
|
|
10924
|
+
const zoneResults = [];
|
|
10925
|
+
let chainBroken = false;
|
|
10926
|
+
const weakAlgsFound = [];
|
|
10927
|
+
for (const zone of zones) {
|
|
10928
|
+
let dsRecords = [];
|
|
10929
|
+
if (zone !== ".") {
|
|
10930
|
+
try {
|
|
10931
|
+
const rawDs = await queryDnsRecords(zone, "DS", dnsOptions);
|
|
10932
|
+
dsRecords = rawDs.map(parseDsRecord2).filter((r) => r !== null);
|
|
10933
|
+
} catch {
|
|
10934
|
+
}
|
|
10935
|
+
}
|
|
10936
|
+
let dnskeyRecords = [];
|
|
10937
|
+
try {
|
|
10938
|
+
const rawDnskey = await queryDnsRecords(zone, "DNSKEY", dnsOptions);
|
|
10939
|
+
dnskeyRecords = rawDnskey.map(parseDnskeyRecord).filter((r) => r !== null);
|
|
10940
|
+
} catch {
|
|
10941
|
+
}
|
|
10942
|
+
const linkage = zone === "." ? dnskeyRecords.length > 0 ? "linked" : "no_dnskey" : determineLinkage(dsRecords, dnskeyRecords);
|
|
10943
|
+
const allAlgs = /* @__PURE__ */ new Set();
|
|
10944
|
+
for (const ds of dsRecords) allAlgs.add(ds.algorithm);
|
|
10945
|
+
for (const key of dnskeyRecords) allAlgs.add(key.algorithm);
|
|
10946
|
+
const algorithms = [...allAlgs].map((a) => DNSSEC_ALGORITHMS[a] ?? `Unknown(${a})`);
|
|
10947
|
+
const weakAlgorithms = [...allAlgs].filter((a) => WEAK_ALGORITHMS.has(a)).map((a) => DNSSEC_ALGORITHMS[a] ?? `Unknown(${a})`);
|
|
10948
|
+
weakAlgsFound.push(...weakAlgorithms);
|
|
10949
|
+
zoneResults.push({
|
|
10950
|
+
zone,
|
|
10951
|
+
dsRecords,
|
|
10952
|
+
dnskeyRecords,
|
|
10953
|
+
linkage,
|
|
10954
|
+
algorithms,
|
|
10955
|
+
weakAlgorithms
|
|
10956
|
+
});
|
|
10957
|
+
if (linkage === "no_dnskey" || linkage === "broken") {
|
|
10958
|
+
chainBroken = true;
|
|
10959
|
+
}
|
|
10960
|
+
if (zone !== "." && dsRecords.length === 0 && dnskeyRecords.length === 0) {
|
|
10961
|
+
break;
|
|
10962
|
+
}
|
|
10963
|
+
}
|
|
10964
|
+
let adFlag = false;
|
|
10965
|
+
try {
|
|
10966
|
+
const adResp = await queryDns(domain, "A", true, dnsOptions);
|
|
10967
|
+
adFlag = adResp.AD === true;
|
|
10968
|
+
} catch {
|
|
10969
|
+
}
|
|
10970
|
+
const lastZone = zoneResults[zoneResults.length - 1];
|
|
10971
|
+
const reachedTarget = lastZone?.zone === domain;
|
|
10972
|
+
const targetSigned = reachedTarget && (lastZone.dsRecords.length > 0 || lastZone.dnskeyRecords.length > 0);
|
|
10973
|
+
const chainComplete = reachedTarget && !chainBroken && targetSigned;
|
|
10974
|
+
if (chainBroken) {
|
|
10975
|
+
const brokenZones = zoneResults.filter((z8) => z8.linkage === "no_dnskey" || z8.linkage === "broken");
|
|
10976
|
+
for (const bz of brokenZones) {
|
|
10977
|
+
const reason = bz.linkage === "no_dnskey" ? "DS record exists but no DNSKEY found" : "DS and DNSKEY algorithm mismatch";
|
|
10978
|
+
findings.push(
|
|
10979
|
+
createFinding(
|
|
10980
|
+
CATEGORY6,
|
|
10981
|
+
`Broken DNSSEC chain at ${bz.zone}`,
|
|
10982
|
+
"high",
|
|
10983
|
+
`DNSSEC chain is broken at ${bz.zone}: ${reason}. Resolvers that validate DNSSEC will return SERVFAIL for this zone.`,
|
|
10984
|
+
{ zone: bz.zone, linkage: bz.linkage }
|
|
10985
|
+
)
|
|
10986
|
+
);
|
|
10987
|
+
}
|
|
10988
|
+
}
|
|
10989
|
+
if (weakAlgsFound.length > 0) {
|
|
10990
|
+
const uniqueWeak = [...new Set(weakAlgsFound)];
|
|
10991
|
+
findings.push(
|
|
10992
|
+
createFinding(
|
|
10993
|
+
CATEGORY6,
|
|
10994
|
+
"Weak DNSSEC algorithm in chain",
|
|
10995
|
+
"medium",
|
|
10996
|
+
`DNSSEC chain uses deprecated/weak algorithm(s): ${uniqueWeak.join(", ")}. These are considered cryptographically weak and should be migrated to RSA-SHA256 (algorithm 8) or ECDSA (algorithm 13/14).`,
|
|
10997
|
+
{ weakAlgorithms: uniqueWeak }
|
|
10998
|
+
)
|
|
10999
|
+
);
|
|
11000
|
+
}
|
|
11001
|
+
const zonesSummary = zoneResults.map((z8) => ({
|
|
11002
|
+
zone: z8.zone,
|
|
11003
|
+
dsCount: z8.dsRecords.length,
|
|
11004
|
+
dnskeyCount: z8.dnskeyRecords.length,
|
|
11005
|
+
kskCount: z8.dnskeyRecords.filter((k) => k.isKsk).length,
|
|
11006
|
+
zskCount: z8.dnskeyRecords.filter((k) => !k.isKsk).length,
|
|
11007
|
+
linkage: z8.linkage,
|
|
11008
|
+
algorithms: z8.algorithms,
|
|
11009
|
+
dsDigestTypes: [...new Set(z8.dsRecords.map((ds) => DIGEST_TYPES[ds.digestType] ?? `Unknown(${ds.digestType})`))]
|
|
11010
|
+
}));
|
|
11011
|
+
const stoppedEarly = !reachedTarget;
|
|
11012
|
+
let summaryStatus;
|
|
11013
|
+
if (stoppedEarly) {
|
|
11014
|
+
summaryStatus = `stopped at ${lastZone?.zone ?? "."} \u2014 zone has no DS and no DNSKEY (not signed)`;
|
|
11015
|
+
} else if (!targetSigned) {
|
|
11016
|
+
summaryStatus = `${domain} has no DS and no DNSKEY \u2014 domain is not signed`;
|
|
11017
|
+
} else if (chainComplete) {
|
|
11018
|
+
summaryStatus = "complete chain from root to target";
|
|
11019
|
+
} else {
|
|
11020
|
+
summaryStatus = "chain broken";
|
|
11021
|
+
}
|
|
11022
|
+
const summaryDetail = `DNSSEC chain walk for ${domain}: ${summaryStatus}. Zones walked: ${zoneResults.map((z8) => z8.zone).join(" \u2192 ")}. AD flag: ${adFlag}. Limitation: no cryptographic RRSIG verification; reports structure and linkage only.`;
|
|
11023
|
+
findings.push(
|
|
11024
|
+
createFinding(CATEGORY6, "DNSSEC chain summary", "info", summaryDetail, {
|
|
11025
|
+
chainComplete,
|
|
11026
|
+
adFlag,
|
|
11027
|
+
zonesWalked: zoneResults.length,
|
|
11028
|
+
zones: zonesSummary
|
|
11029
|
+
})
|
|
11030
|
+
);
|
|
11031
|
+
return buildCheckResult(CATEGORY6, findings);
|
|
11032
|
+
}
|
|
11033
|
+
|
|
11034
|
+
// src/tools/check-fast-flux.ts
|
|
11035
|
+
init_dns2();
|
|
11036
|
+
var CATEGORY7 = "fast_flux";
|
|
11037
|
+
var TYPE_A = 1;
|
|
11038
|
+
var TYPE_AAAA = 28;
|
|
11039
|
+
async function checkFastFlux(domain, rounds, dnsOptions, delayMs = 2e3) {
|
|
11040
|
+
const effectiveRounds = Math.max(3, Math.min(5, rounds ?? 3));
|
|
11041
|
+
const roundResults = [];
|
|
11042
|
+
for (let i = 0; i < effectiveRounds; i++) {
|
|
11043
|
+
if (i > 0 && delayMs > 0) {
|
|
11044
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
11045
|
+
}
|
|
11046
|
+
try {
|
|
11047
|
+
const [aResult, aaaaResult] = await Promise.allSettled([
|
|
11048
|
+
queryDns(domain, "A", false, dnsOptions),
|
|
11049
|
+
queryDns(domain, "AAAA", false, dnsOptions)
|
|
11050
|
+
]);
|
|
11051
|
+
const answers = [];
|
|
11052
|
+
if (aResult.status === "fulfilled" && aResult.value.Answer) {
|
|
11053
|
+
answers.push(...aResult.value.Answer.filter((a) => a.type === TYPE_A));
|
|
11054
|
+
}
|
|
11055
|
+
if (aaaaResult.status === "fulfilled" && aaaaResult.value.Answer) {
|
|
11056
|
+
answers.push(...aaaaResult.value.Answer.filter((a) => a.type === TYPE_AAAA));
|
|
11057
|
+
}
|
|
11058
|
+
const ips = answers.map((a) => a.data).sort();
|
|
11059
|
+
const minTtl = answers.length > 0 ? Math.min(...answers.map((a) => a.TTL)) : Infinity;
|
|
11060
|
+
roundResults.push({ ips, minTtl });
|
|
11061
|
+
} catch {
|
|
11062
|
+
roundResults.push({ ips: [], minTtl: Infinity });
|
|
11063
|
+
}
|
|
11064
|
+
}
|
|
11065
|
+
const successfulRounds = roundResults.filter((r) => r.ips.length > 0);
|
|
11066
|
+
if (successfulRounds.length === 0) {
|
|
11067
|
+
const findings2 = [
|
|
11068
|
+
createFinding(
|
|
11069
|
+
CATEGORY7,
|
|
11070
|
+
"DNS queries failed",
|
|
11071
|
+
"medium",
|
|
11072
|
+
`All ${effectiveRounds} query rounds failed for ${domain}. Unable to assess fast-flux behavior.`
|
|
11073
|
+
)
|
|
11074
|
+
];
|
|
11075
|
+
return buildCheckResult(CATEGORY7, findings2);
|
|
11076
|
+
}
|
|
11077
|
+
const allUniqueIps = /* @__PURE__ */ new Set();
|
|
11078
|
+
for (const round of roundResults) {
|
|
11079
|
+
for (const ip of round.ips) {
|
|
11080
|
+
allUniqueIps.add(ip);
|
|
11081
|
+
}
|
|
11082
|
+
}
|
|
11083
|
+
let ipSetChanges = 0;
|
|
11084
|
+
for (let i = 1; i < roundResults.length; i++) {
|
|
11085
|
+
const prev = roundResults[i - 1].ips.join(",");
|
|
11086
|
+
const curr = roundResults[i].ips.join(",");
|
|
11087
|
+
if (prev !== curr && roundResults[i - 1].ips.length > 0 && roundResults[i].ips.length > 0) {
|
|
11088
|
+
ipSetChanges++;
|
|
11089
|
+
}
|
|
11090
|
+
}
|
|
11091
|
+
const overallMinTtl = Math.min(...successfulRounds.map((r) => r.minTtl));
|
|
11092
|
+
const fluxDetected = overallMinTtl < 300 && ipSetChanges > 0;
|
|
11093
|
+
const findings = [];
|
|
11094
|
+
if (fluxDetected) {
|
|
11095
|
+
findings.push(
|
|
11096
|
+
createFinding(
|
|
11097
|
+
CATEGORY7,
|
|
11098
|
+
"Fast-flux behavior detected",
|
|
11099
|
+
"high",
|
|
11100
|
+
`${domain} shows fast-flux indicators: ${allUniqueIps.size} unique IPs observed across ${effectiveRounds} rounds with ${ipSetChanges} IP set change(s) and minimum TTL of ${overallMinTtl}s. Rotating IPs with low TTLs are characteristic of fast-flux networks used to evade takedowns.`,
|
|
11101
|
+
{
|
|
11102
|
+
domain,
|
|
11103
|
+
flux_detected: true,
|
|
11104
|
+
unique_ips: allUniqueIps.size,
|
|
11105
|
+
ip_set_changes: ipSetChanges,
|
|
11106
|
+
min_ttl: overallMinTtl,
|
|
11107
|
+
rounds: effectiveRounds
|
|
11108
|
+
}
|
|
11109
|
+
)
|
|
11110
|
+
);
|
|
11111
|
+
} else {
|
|
11112
|
+
findings.push(
|
|
11113
|
+
createFinding(
|
|
11114
|
+
CATEGORY7,
|
|
11115
|
+
"Stable resolution \u2014 no fast-flux indicators",
|
|
11116
|
+
"info",
|
|
11117
|
+
`${domain} resolved consistently across ${effectiveRounds} rounds: ${allUniqueIps.size} unique IP(s), ${ipSetChanges} IP set change(s), minimum TTL ${overallMinTtl === Infinity ? "N/A" : `${overallMinTtl}s`}. No fast-flux behavior detected.`,
|
|
11118
|
+
{
|
|
11119
|
+
domain,
|
|
11120
|
+
flux_detected: false,
|
|
11121
|
+
unique_ips: allUniqueIps.size,
|
|
11122
|
+
ip_set_changes: ipSetChanges,
|
|
11123
|
+
min_ttl: overallMinTtl === Infinity ? null : overallMinTtl,
|
|
11124
|
+
rounds: effectiveRounds
|
|
11125
|
+
}
|
|
11126
|
+
)
|
|
11127
|
+
);
|
|
11128
|
+
}
|
|
11129
|
+
findings.push(
|
|
11130
|
+
createFinding(
|
|
11131
|
+
CATEGORY7,
|
|
11132
|
+
"Detection limitations",
|
|
11133
|
+
"info",
|
|
11134
|
+
"DoH resolver caching may mask IP rotation. This check has lower fidelity than direct UDP probing against authoritative nameservers."
|
|
11135
|
+
)
|
|
11136
|
+
);
|
|
11137
|
+
return buildCheckResult(CATEGORY7, findings);
|
|
11138
|
+
}
|
|
11139
|
+
|
|
11140
|
+
// src/handlers/tool-execution.ts
|
|
11141
|
+
init_log();
|
|
11142
|
+
function buildLogContext(toolName, startTime, domain, runtimeOptions) {
|
|
11143
|
+
return {
|
|
11144
|
+
toolName,
|
|
11145
|
+
durationMs: Date.now() - startTime,
|
|
11146
|
+
domain,
|
|
11147
|
+
analytics: runtimeOptions?.analytics,
|
|
11148
|
+
country: runtimeOptions?.country,
|
|
11149
|
+
clientType: runtimeOptions?.clientType,
|
|
11150
|
+
authTier: runtimeOptions?.authTier,
|
|
11151
|
+
keyHash: runtimeOptions?.keyHash
|
|
11152
|
+
};
|
|
11153
|
+
}
|
|
11154
|
+
function logToolSuccess(options) {
|
|
11155
|
+
options.analytics?.emitToolEvent({
|
|
11156
|
+
toolName: options.toolName,
|
|
11157
|
+
status: options.status,
|
|
11158
|
+
durationMs: options.durationMs,
|
|
11159
|
+
domain: options.domain,
|
|
11160
|
+
isError: false,
|
|
11161
|
+
score: options.score,
|
|
11162
|
+
cacheStatus: options.cacheStatus,
|
|
11163
|
+
country: options.country,
|
|
11164
|
+
clientType: options.clientType,
|
|
11165
|
+
authTier: options.authTier,
|
|
11166
|
+
keyHash: options.keyHash
|
|
11167
|
+
});
|
|
11168
|
+
logEvent({
|
|
11169
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
11170
|
+
tool: options.toolName,
|
|
11171
|
+
domain: options.domain,
|
|
11172
|
+
result: options.logResult,
|
|
11173
|
+
details: options.logDetails,
|
|
11174
|
+
durationMs: options.durationMs,
|
|
11175
|
+
severity: options.severity ?? (options.status === "pass" ? "info" : "warn")
|
|
11176
|
+
});
|
|
11177
|
+
}
|
|
11178
|
+
function logToolFailure(options) {
|
|
11179
|
+
options.analytics?.emitToolEvent({
|
|
11180
|
+
toolName: options.toolName,
|
|
11181
|
+
status: "error",
|
|
11182
|
+
durationMs: options.durationMs,
|
|
11183
|
+
domain: options.domain,
|
|
11184
|
+
isError: true,
|
|
11185
|
+
score: options.score,
|
|
11186
|
+
cacheStatus: options.cacheStatus,
|
|
11187
|
+
country: options.country,
|
|
11188
|
+
clientType: options.clientType,
|
|
11189
|
+
authTier: options.authTier,
|
|
11190
|
+
keyHash: options.keyHash
|
|
11191
|
+
});
|
|
11192
|
+
logError(options.error instanceof Error ? options.error : String(options.error), {
|
|
11193
|
+
tool: options.toolName,
|
|
11194
|
+
domain: options.domain,
|
|
11195
|
+
details: options.args,
|
|
11196
|
+
severity: options.severity ?? "error"
|
|
11197
|
+
});
|
|
11198
|
+
}
|
|
11199
|
+
|
|
11200
|
+
// src/handlers/tool-formatters.ts
|
|
11201
|
+
function mcpError(message) {
|
|
11202
|
+
return { type: "text", text: `Error: ${message}` };
|
|
11203
|
+
}
|
|
11204
|
+
function mcpText(text) {
|
|
11205
|
+
return { type: "text", text };
|
|
11206
|
+
}
|
|
11207
|
+
function buildToolContent(text, structuredData, format) {
|
|
11208
|
+
const content = [mcpText(text)];
|
|
11209
|
+
if (format === "full") {
|
|
11210
|
+
content.push(mcpText(`<!-- STRUCTURED_RESULT
|
|
11211
|
+
${JSON.stringify(structuredData)}
|
|
11212
|
+
STRUCTURED_RESULT -->`));
|
|
11213
|
+
}
|
|
11214
|
+
return content;
|
|
11215
|
+
}
|
|
11216
|
+
function formatCheckResult(result, format = "full") {
|
|
11217
|
+
const lines = [];
|
|
11218
|
+
lines.push(`## ${result.category.toUpperCase()} Check`);
|
|
11219
|
+
lines.push(`**Status:** ${result.passed ? "\u2705 Passed" : "\u274C Failed"}`);
|
|
11220
|
+
lines.push(`**Score:** ${result.score}/100`);
|
|
11221
|
+
lines.push("");
|
|
11222
|
+
if (result.findings.length > 0) {
|
|
11223
|
+
lines.push("### Findings");
|
|
11224
|
+
for (const finding of result.findings) {
|
|
11225
|
+
if (format === "compact") {
|
|
11226
|
+
const isHighPriority = finding.severity === "critical" || finding.severity === "high";
|
|
11227
|
+
const detailLimit = isHighPriority ? 4e3 : 300;
|
|
11228
|
+
lines.push(`- [${finding.severity.toUpperCase()}] ${sanitizeOutputText(finding.title, 120)} \u2014 ${sanitizeOutputText(finding.detail, detailLimit)}`);
|
|
11229
|
+
continue;
|
|
11230
|
+
}
|
|
11231
|
+
const icon = finding.severity === "info" ? "\u2139\uFE0F" : finding.severity === "low" ? "\u26A0\uFE0F" : finding.severity === "medium" ? "\u{1F536}" : finding.severity === "high" ? "\u{1F534}" : "\u{1F6A8}";
|
|
11232
|
+
lines.push(`- ${icon} **[${finding.severity.toUpperCase()}]** ${sanitizeOutputText(finding.title, 120)}`);
|
|
11233
|
+
lines.push(` ${sanitizeOutputText(finding.detail)}`);
|
|
11234
|
+
const verificationStatus = finding.category === "subdomain_takeover" && finding.metadata?.verificationStatus ? String(finding.metadata.verificationStatus) : void 0;
|
|
11235
|
+
if (verificationStatus) {
|
|
11236
|
+
lines.push(` Takeover Verification: ${sanitizeOutputText(verificationStatus, 80)}`);
|
|
11237
|
+
}
|
|
11238
|
+
const confidence = finding.metadata?.confidence ? String(finding.metadata.confidence) : void 0;
|
|
11239
|
+
if (confidence) {
|
|
11240
|
+
lines.push(` Confidence: ${sanitizeOutputText(confidence, 80)}`);
|
|
11241
|
+
}
|
|
11242
|
+
if (finding.severity !== "info") {
|
|
11243
|
+
const narrative = resolveImpactNarrative({
|
|
11244
|
+
category: finding.category,
|
|
11245
|
+
severity: finding.severity,
|
|
11246
|
+
title: finding.title,
|
|
11247
|
+
detail: finding.detail
|
|
11248
|
+
});
|
|
11249
|
+
if (narrative.impact) {
|
|
11250
|
+
lines.push(` Potential Impact: ${narrative.impact}`);
|
|
11251
|
+
}
|
|
11252
|
+
if (narrative.adverseConsequences) {
|
|
11253
|
+
lines.push(` Adverse Consequences: ${narrative.adverseConsequences}`);
|
|
11254
|
+
}
|
|
11255
|
+
}
|
|
11256
|
+
}
|
|
11257
|
+
}
|
|
11258
|
+
return lines.join("\n");
|
|
11259
|
+
}
|
|
11260
|
+
var KNOWN_ACRONYMS = /* @__PURE__ */ new Set(["mx", "spf", "dmarc", "dkim", "dnssec", "ssl", "mta", "sts", "ns", "caa", "bimi", "tlsrpt", "http", "https", "dane", "svcb", "srv", "txt", "doh", "rpm", "dbl", "rdap", "nsec"]);
|
|
11261
|
+
function toolNameToTitle(name) {
|
|
11262
|
+
return name.split("_").map((word) => KNOWN_ACRONYMS.has(word) ? word.toUpperCase() : word.charAt(0).toUpperCase() + word.slice(1)).join(" ");
|
|
11263
|
+
}
|
|
11264
|
+
function toInputSchema(schema) {
|
|
11265
|
+
const jsonSchema = z.toJSONSchema(schema);
|
|
11266
|
+
delete jsonSchema.$schema;
|
|
11267
|
+
if (jsonSchema.additionalProperties !== void 0 && typeof jsonSchema.additionalProperties === "object" && jsonSchema.additionalProperties !== null && Object.keys(jsonSchema.additionalProperties).length === 0) {
|
|
11268
|
+
delete jsonSchema.additionalProperties;
|
|
11269
|
+
}
|
|
11270
|
+
return jsonSchema;
|
|
11271
|
+
}
|
|
11272
|
+
var TOOL_DEFS = {
|
|
11273
|
+
check_mx: {
|
|
11274
|
+
description: "Look up MX records for a domain. Shows mail servers, email provider detection, and validates configuration.",
|
|
11275
|
+
schema: BaseDomainArgs,
|
|
11276
|
+
group: "email_auth",
|
|
9808
11277
|
tier: "protective",
|
|
9809
11278
|
scanIncluded: true
|
|
9810
11279
|
},
|
|
9811
11280
|
check_spf: {
|
|
9812
|
-
description: "
|
|
11281
|
+
description: "Look up and validate SPF record for a domain. Shows authorized senders, syntax issues, and trust surface.",
|
|
9813
11282
|
schema: BaseDomainArgs,
|
|
9814
11283
|
group: "email_auth",
|
|
9815
11284
|
tier: "core",
|
|
9816
11285
|
scanIncluded: true
|
|
9817
11286
|
},
|
|
9818
11287
|
check_dmarc: {
|
|
9819
|
-
description: "
|
|
11288
|
+
description: "Look up and validate DMARC record for a domain. Shows policy enforcement, alignment mode, and reporting config.",
|
|
9820
11289
|
schema: BaseDomainArgs,
|
|
9821
11290
|
group: "email_auth",
|
|
9822
11291
|
tier: "core",
|
|
9823
11292
|
scanIncluded: true
|
|
9824
11293
|
},
|
|
9825
11294
|
check_dkim: {
|
|
9826
|
-
description: "
|
|
11295
|
+
description: "Look up DKIM records for a domain. Probes common selectors and validates key strength and algorithm.",
|
|
9827
11296
|
schema: CheckDkimArgs,
|
|
9828
11297
|
group: "email_auth",
|
|
9829
11298
|
tier: "core",
|
|
9830
11299
|
scanIncluded: true
|
|
9831
11300
|
},
|
|
9832
11301
|
check_dnssec: {
|
|
9833
|
-
description: "
|
|
11302
|
+
description: "Check DNSSEC status for a domain. Verifies DNSKEY/DS records and validation chain.",
|
|
9834
11303
|
schema: BaseDomainArgs,
|
|
9835
11304
|
group: "infrastructure",
|
|
9836
11305
|
tier: "core",
|
|
9837
11306
|
scanIncluded: true
|
|
9838
11307
|
},
|
|
9839
11308
|
check_ssl: {
|
|
9840
|
-
description: "
|
|
11309
|
+
description: "Check SSL/TLS certificate for a domain. Shows issuer, expiry, protocol versions, and HTTPS configuration.",
|
|
9841
11310
|
schema: BaseDomainArgs,
|
|
9842
11311
|
group: "infrastructure",
|
|
9843
11312
|
tier: "core",
|
|
@@ -9851,14 +11320,14 @@ var TOOL_DEFS = {
|
|
|
9851
11320
|
scanIncluded: true
|
|
9852
11321
|
},
|
|
9853
11322
|
check_ns: {
|
|
9854
|
-
description: "
|
|
11323
|
+
description: "Look up NS (nameserver) records for a domain. Shows DNS provider, delegation, and redundancy.",
|
|
9855
11324
|
schema: BaseDomainArgs,
|
|
9856
11325
|
group: "infrastructure",
|
|
9857
11326
|
tier: "protective",
|
|
9858
11327
|
scanIncluded: true
|
|
9859
11328
|
},
|
|
9860
11329
|
check_caa: {
|
|
9861
|
-
description: "
|
|
11330
|
+
description: "Look up CAA records for a domain. Shows which Certificate Authorities are authorized to issue certificates.",
|
|
9862
11331
|
schema: BaseDomainArgs,
|
|
9863
11332
|
group: "infrastructure",
|
|
9864
11333
|
tier: "protective",
|
|
@@ -9921,7 +11390,7 @@ var TOOL_DEFS = {
|
|
|
9921
11390
|
scanIncluded: true
|
|
9922
11391
|
},
|
|
9923
11392
|
scan_domain: {
|
|
9924
|
-
description: "
|
|
11393
|
+
description: "Look up any domain to get a full DNS and email security audit. Use this whenever a user mentions a domain name, asks to check/scan/lookup/analyze a domain, or wants to know about a domain's security posture. Returns score, grade, maturity stage, and prioritized findings. Start here for any domain-related question.",
|
|
9925
11394
|
schema: ScanDomainArgs,
|
|
9926
11395
|
group: "meta",
|
|
9927
11396
|
scanIncluded: false
|
|
@@ -10064,13 +11533,13 @@ var TOOL_DEFS = {
|
|
|
10064
11533
|
scanIncluded: false
|
|
10065
11534
|
},
|
|
10066
11535
|
resolve_spf_chain: {
|
|
10067
|
-
description: "
|
|
11536
|
+
description: "Trace the full SPF include chain for a domain. Recursively resolves all includes, shows lookup count, tree depth, and flags circular includes or exceeding the 10-lookup limit.",
|
|
10068
11537
|
schema: BaseDomainArgs,
|
|
10069
11538
|
group: "intelligence",
|
|
10070
11539
|
scanIncluded: false
|
|
10071
11540
|
},
|
|
10072
11541
|
discover_subdomains: {
|
|
10073
|
-
description: "
|
|
11542
|
+
description: "Find subdomains of a domain using Certificate Transparency logs. Reveals shadow IT, forgotten services, and unauthorized certificate issuance.",
|
|
10074
11543
|
schema: BaseDomainArgs,
|
|
10075
11544
|
group: "intelligence",
|
|
10076
11545
|
scanIncluded: false
|
|
@@ -10086,6 +11555,48 @@ var TOOL_DEFS = {
|
|
|
10086
11555
|
schema: BaseDomainArgs,
|
|
10087
11556
|
group: "intelligence",
|
|
10088
11557
|
scanIncluded: false
|
|
11558
|
+
},
|
|
11559
|
+
check_dbl: {
|
|
11560
|
+
description: "Check domain reputation against DNS-based Domain Block Lists (Spamhaus DBL, URIBL, SURBL). Returns listing status with decoded return codes.",
|
|
11561
|
+
schema: BaseDomainArgs,
|
|
11562
|
+
group: "intelligence",
|
|
11563
|
+
scanIncluded: false
|
|
11564
|
+
},
|
|
11565
|
+
check_rbl: {
|
|
11566
|
+
description: "Check MX server IP reputation against 8 DNS-based Real-time Blocklists (Spamhaus ZEN, SpamCop, UCEProtect, Mailspike, Barracuda, PSBL, SORBS). Resolves MX hosts to IPs first.",
|
|
11567
|
+
schema: BaseDomainArgs,
|
|
11568
|
+
group: "intelligence",
|
|
11569
|
+
scanIncluded: false
|
|
11570
|
+
},
|
|
11571
|
+
cymru_asn: {
|
|
11572
|
+
description: "Map domain IPs to Autonomous System Numbers via Team Cymru DNS. Returns ASN, prefix, country, registry, and organization for each IP. Flags high-risk hosting ASNs.",
|
|
11573
|
+
schema: BaseDomainArgs,
|
|
11574
|
+
group: "intelligence",
|
|
11575
|
+
scanIncluded: false
|
|
11576
|
+
},
|
|
11577
|
+
rdap_lookup: {
|
|
11578
|
+
description: "Fetch domain registration data via RDAP (modern WHOIS replacement). Returns registrar, creation/expiration dates, EPP status, registrant info, and domain age.",
|
|
11579
|
+
schema: BaseDomainArgs,
|
|
11580
|
+
group: "intelligence",
|
|
11581
|
+
scanIncluded: false
|
|
11582
|
+
},
|
|
11583
|
+
check_nsec_walkability: {
|
|
11584
|
+
description: "Assess zone walkability risk by analyzing NSEC3PARAM configuration. Detects plain NSEC zones, weak NSEC3 parameters, and opt-out flags.",
|
|
11585
|
+
schema: BaseDomainArgs,
|
|
11586
|
+
group: "intelligence",
|
|
11587
|
+
scanIncluded: false
|
|
11588
|
+
},
|
|
11589
|
+
check_dnssec_chain: {
|
|
11590
|
+
description: "Walk the DNSSEC chain of trust from root to target domain. Reports DS/DNSKEY records, algorithm usage, and linkage status at each zone level.",
|
|
11591
|
+
schema: BaseDomainArgs,
|
|
11592
|
+
group: "intelligence",
|
|
11593
|
+
scanIncluded: false
|
|
11594
|
+
},
|
|
11595
|
+
check_fast_flux: {
|
|
11596
|
+
description: "Detect fast-flux DNS behavior by performing multiple rounds of A/AAAA queries with delays. Compares IP answer sets and TTLs across rounds to identify rotating infrastructure.",
|
|
11597
|
+
schema: CheckFastFluxArgs,
|
|
11598
|
+
group: "intelligence",
|
|
11599
|
+
scanIncluded: false
|
|
10089
11600
|
}
|
|
10090
11601
|
};
|
|
10091
11602
|
var TOOLS = Object.entries(TOOL_DEFS).map(([name, def]) => ({
|
|
@@ -10148,9 +11659,16 @@ var TOOL_REGISTRY = {
|
|
|
10148
11659
|
check_mx_reputation: { cacheKey: () => "mx_reputation", execute: (d, _args, ro) => checkMxReputation(d, buildDnsOptions(ro)), cacheTtlSeconds: 3600 },
|
|
10149
11660
|
check_srv: { cacheKey: () => "srv", execute: (d, _args, ro) => checkSrv(d, buildDnsOptions(ro)) },
|
|
10150
11661
|
check_zone_hygiene: { cacheKey: () => "zone_hygiene", execute: (d, _args, ro) => checkZoneHygiene(d, buildDnsOptions(ro)) },
|
|
10151
|
-
check_subdomailing: { cacheKey: () => "subdomailing", execute: (d, _args, ro) => checkSubdomailing(d, buildDnsOptions(ro)) }
|
|
11662
|
+
check_subdomailing: { cacheKey: () => "subdomailing", execute: (d, _args, ro) => checkSubdomailing(d, buildDnsOptions(ro)) },
|
|
11663
|
+
check_dbl: { cacheKey: () => "dbl", execute: (d, _args, ro) => checkDbl(d, buildDnsOptions(ro)), cacheTtlSeconds: 3600 },
|
|
11664
|
+
check_rbl: { cacheKey: () => "rbl", execute: (d, _args, ro) => checkRbl(d, buildDnsOptions(ro)), cacheTtlSeconds: 3600 },
|
|
11665
|
+
cymru_asn: { cacheKey: () => "asn", execute: (d, _args, ro) => checkCymruAsn(d, buildDnsOptions(ro)), cacheTtlSeconds: 3600 },
|
|
11666
|
+
rdap_lookup: { cacheKey: () => "rdap", execute: (d) => checkRdapLookup(d), cacheTtlSeconds: 3600 },
|
|
11667
|
+
check_nsec_walkability: { cacheKey: () => "nsec_walkability", execute: (d, _args, ro) => checkNsecWalkability(d, buildDnsOptions(ro)), cacheTtlSeconds: 3600 },
|
|
11668
|
+
check_dnssec_chain: { cacheKey: () => "dnssec_chain", execute: (d, _args, ro) => checkDnssecChain(d, buildDnsOptions(ro)) },
|
|
11669
|
+
check_fast_flux: { cacheKey: () => "fast_flux", execute: (d, args, ro) => checkFastFlux(d, args.rounds ?? 3, buildDnsOptions(ro)) }
|
|
10152
11670
|
};
|
|
10153
|
-
var INTERACTIVE_CLIENTS = /* @__PURE__ */ new Set(["claude_code", "cursor", "vscode", "claude_desktop", "windsurf"]);
|
|
11671
|
+
var INTERACTIVE_CLIENTS = /* @__PURE__ */ new Set(["claude_mobile", "claude_code", "cursor", "vscode", "claude_desktop", "windsurf"]);
|
|
10154
11672
|
function isInteractiveClient(clientType) {
|
|
10155
11673
|
return INTERACTIVE_CLIENTS.has(clientType ?? "");
|
|
10156
11674
|
}
|
|
@@ -10162,19 +11680,13 @@ function resolveFormat(args, clientType) {
|
|
|
10162
11680
|
function buildToolErrorResult(message) {
|
|
10163
11681
|
return { content: [mcpError(message)], isError: true };
|
|
10164
11682
|
}
|
|
10165
|
-
function handleExplainFindingValidationError(args,
|
|
11683
|
+
function handleExplainFindingValidationError(args, startTime, runtimeOptions) {
|
|
10166
11684
|
const error2 = new Error("Missing required parameters: checkType and status");
|
|
10167
11685
|
logToolFailure({
|
|
10168
|
-
|
|
10169
|
-
durationMs,
|
|
10170
|
-
analytics: runtimeOptions?.analytics,
|
|
11686
|
+
...buildLogContext("explain_finding", startTime, void 0, runtimeOptions),
|
|
10171
11687
|
error: error2,
|
|
10172
11688
|
args,
|
|
10173
|
-
severity: "warn"
|
|
10174
|
-
country: runtimeOptions?.country,
|
|
10175
|
-
clientType: runtimeOptions?.clientType,
|
|
10176
|
-
authTier: runtimeOptions?.authTier,
|
|
10177
|
-
keyHash: runtimeOptions?.keyHash
|
|
11689
|
+
severity: "warn"
|
|
10178
11690
|
});
|
|
10179
11691
|
return buildToolErrorResult(error2.message);
|
|
10180
11692
|
}
|
|
@@ -10183,6 +11695,7 @@ async function handleToolsCall(params, scanCacheKV, runtimeOptions) {
|
|
|
10183
11695
|
const name = normalizeToolName(params.name);
|
|
10184
11696
|
const args = params.arguments ?? {};
|
|
10185
11697
|
const startTime = Date.now();
|
|
11698
|
+
const ctx = () => buildLogContext(name, startTime, domain, runtimeOptions);
|
|
10186
11699
|
let domain;
|
|
10187
11700
|
let logResult = "unknown";
|
|
10188
11701
|
let logDetails;
|
|
@@ -10201,27 +11714,17 @@ async function handleToolsCall(params, scanCacheKV, runtimeOptions) {
|
|
|
10201
11714
|
const checkName = registeredTool.cacheKey(validatedArgs);
|
|
10202
11715
|
const cacheKey = `cache:${validDomain}:check:${checkName}`;
|
|
10203
11716
|
const { data: result, cacheStatus } = await runWithCacheTracked(cacheKey, () => registeredTool.execute(validDomain, validatedArgs, runtimeOptions), scanCacheKV, registeredTool.cacheTtlSeconds);
|
|
10204
|
-
if (result.partial
|
|
10205
|
-
|
|
10206
|
-
await scanCacheKV.delete(cacheKey);
|
|
10207
|
-
} catch {
|
|
10208
|
-
}
|
|
11717
|
+
if (result.partial) {
|
|
11718
|
+
await cacheDelete(cacheKey, scanCacheKV);
|
|
10209
11719
|
}
|
|
10210
11720
|
runtimeOptions?.resultCapture?.(result);
|
|
10211
11721
|
logResult = result.passed ? "pass" : "fail";
|
|
10212
11722
|
logDetails = result;
|
|
10213
11723
|
logToolSuccess({
|
|
10214
|
-
|
|
10215
|
-
durationMs: Date.now() - startTime,
|
|
10216
|
-
domain,
|
|
10217
|
-
analytics: runtimeOptions?.analytics,
|
|
11724
|
+
...ctx(),
|
|
10218
11725
|
status: result.passed ? "pass" : "fail",
|
|
10219
11726
|
logResult,
|
|
10220
11727
|
logDetails,
|
|
10221
|
-
country: runtimeOptions?.country,
|
|
10222
|
-
clientType: runtimeOptions?.clientType,
|
|
10223
|
-
authTier: runtimeOptions?.authTier,
|
|
10224
|
-
keyHash: runtimeOptions?.keyHash,
|
|
10225
11728
|
cacheStatus
|
|
10226
11729
|
});
|
|
10227
11730
|
return { content: buildToolContent(formatCheckResult(result, effectiveFormat), result, effectiveFormat) };
|
|
@@ -10234,20 +11737,7 @@ async function handleToolsCall(params, scanCacheKV, runtimeOptions) {
|
|
|
10234
11737
|
const result = await scanDomain(validDomain, scanCacheKV, scanOptions);
|
|
10235
11738
|
logResult = result.score.grade;
|
|
10236
11739
|
logDetails = result;
|
|
10237
|
-
logToolSuccess({
|
|
10238
|
-
toolName: name,
|
|
10239
|
-
durationMs: Date.now() - startTime,
|
|
10240
|
-
domain,
|
|
10241
|
-
analytics: runtimeOptions?.analytics,
|
|
10242
|
-
status: result.score.overall >= 50 ? "pass" : "fail",
|
|
10243
|
-
logResult,
|
|
10244
|
-
logDetails,
|
|
10245
|
-
severity: "info",
|
|
10246
|
-
country: runtimeOptions?.country,
|
|
10247
|
-
clientType: runtimeOptions?.clientType,
|
|
10248
|
-
authTier: runtimeOptions?.authTier,
|
|
10249
|
-
keyHash: runtimeOptions?.keyHash
|
|
10250
|
-
});
|
|
11740
|
+
logToolSuccess({ ...ctx(), status: result.score.overall >= 50 ? "pass" : "fail", logResult, logDetails, severity: "info" });
|
|
10251
11741
|
const structured = buildStructuredScanResult(result);
|
|
10252
11742
|
return { content: buildToolContent(formatScanReport(result, effectiveFormat), structured, effectiveFormat) };
|
|
10253
11743
|
}
|
|
@@ -10268,19 +11758,7 @@ async function handleToolsCall(params, scanCacheKV, runtimeOptions) {
|
|
|
10268
11758
|
}
|
|
10269
11759
|
});
|
|
10270
11760
|
const batchText = formatBatchScan(batchResults, effectiveFormat);
|
|
10271
|
-
logToolSuccess({
|
|
10272
|
-
toolName: "batch_scan",
|
|
10273
|
-
durationMs: Date.now() - startTime,
|
|
10274
|
-
analytics: runtimeOptions?.analytics,
|
|
10275
|
-
status: "pass",
|
|
10276
|
-
logResult: `${batchResults.filter((r) => !r.error).length}/${batchResults.length} domains`,
|
|
10277
|
-
logDetails: { totalDomains: batchResults.length },
|
|
10278
|
-
severity: "info",
|
|
10279
|
-
country: runtimeOptions?.country,
|
|
10280
|
-
clientType: runtimeOptions?.clientType,
|
|
10281
|
-
authTier: runtimeOptions?.authTier,
|
|
10282
|
-
keyHash: runtimeOptions?.keyHash
|
|
10283
|
-
});
|
|
11761
|
+
logToolSuccess({ ...ctx(), status: "pass", logResult: `${batchResults.filter((r) => !r.error).length}/${batchResults.length} domains`, logDetails: { totalDomains: batchResults.length }, severity: "info" });
|
|
10284
11762
|
return { content: buildToolContent(batchText, batchResults, effectiveFormat) };
|
|
10285
11763
|
}
|
|
10286
11764
|
case "compare_domains": {
|
|
@@ -10298,19 +11776,7 @@ async function handleToolsCall(params, scanCacheKV, runtimeOptions) {
|
|
|
10298
11776
|
}
|
|
10299
11777
|
});
|
|
10300
11778
|
const compareText = formatDomainComparison(compareResults, effectiveFormat);
|
|
10301
|
-
logToolSuccess({
|
|
10302
|
-
toolName: "compare_domains",
|
|
10303
|
-
durationMs: Date.now() - startTime,
|
|
10304
|
-
analytics: runtimeOptions?.analytics,
|
|
10305
|
-
status: "pass",
|
|
10306
|
-
logResult: `${Object.keys(compareResults.scores).length}/${compareResults.domains.length} domains compared`,
|
|
10307
|
-
logDetails: { totalDomains: compareResults.domains.length, winner: compareResults.winner },
|
|
10308
|
-
severity: "info",
|
|
10309
|
-
country: runtimeOptions?.country,
|
|
10310
|
-
clientType: runtimeOptions?.clientType,
|
|
10311
|
-
authTier: runtimeOptions?.authTier,
|
|
10312
|
-
keyHash: runtimeOptions?.keyHash
|
|
10313
|
-
});
|
|
11779
|
+
logToolSuccess({ ...ctx(), status: "pass", logResult: `${Object.keys(compareResults.scores).length}/${compareResults.domains.length} domains compared`, logDetails: { totalDomains: compareResults.domains.length, winner: compareResults.winner }, severity: "info" });
|
|
10314
11780
|
return { content: buildToolContent(compareText, compareResults, effectiveFormat) };
|
|
10315
11781
|
}
|
|
10316
11782
|
case "compare_baseline": {
|
|
@@ -10319,135 +11785,45 @@ async function handleToolsCall(params, scanCacheKV, runtimeOptions) {
|
|
|
10319
11785
|
const result = compareBaseline(scan, baseline);
|
|
10320
11786
|
logResult = result.passed ? "pass" : "fail";
|
|
10321
11787
|
logDetails = result;
|
|
10322
|
-
logToolSuccess({
|
|
10323
|
-
toolName: name,
|
|
10324
|
-
durationMs: Date.now() - startTime,
|
|
10325
|
-
domain,
|
|
10326
|
-
analytics: runtimeOptions?.analytics,
|
|
10327
|
-
status: result.passed ? "pass" : "fail",
|
|
10328
|
-
logResult,
|
|
10329
|
-
logDetails,
|
|
10330
|
-
severity: "info",
|
|
10331
|
-
country: runtimeOptions?.country,
|
|
10332
|
-
clientType: runtimeOptions?.clientType,
|
|
10333
|
-
authTier: runtimeOptions?.authTier,
|
|
10334
|
-
keyHash: runtimeOptions?.keyHash
|
|
10335
|
-
});
|
|
11788
|
+
logToolSuccess({ ...ctx(), status: result.passed ? "pass" : "fail", logResult, logDetails, severity: "info" });
|
|
10336
11789
|
return { content: buildToolContent(formatBaselineResult(result, effectiveFormat), result, effectiveFormat) };
|
|
10337
11790
|
}
|
|
10338
11791
|
case "generate_fix_plan": {
|
|
10339
11792
|
const plan = await generateFixPlan(validDomain, scanCacheKV, runtimeOptions);
|
|
10340
11793
|
logResult = plan.grade;
|
|
10341
11794
|
logDetails = plan;
|
|
10342
|
-
logToolSuccess({
|
|
10343
|
-
toolName: name,
|
|
10344
|
-
durationMs: Date.now() - startTime,
|
|
10345
|
-
domain,
|
|
10346
|
-
analytics: runtimeOptions?.analytics,
|
|
10347
|
-
status: "pass",
|
|
10348
|
-
logResult,
|
|
10349
|
-
logDetails,
|
|
10350
|
-
severity: "info",
|
|
10351
|
-
country: runtimeOptions?.country,
|
|
10352
|
-
clientType: runtimeOptions?.clientType,
|
|
10353
|
-
authTier: runtimeOptions?.authTier,
|
|
10354
|
-
keyHash: runtimeOptions?.keyHash
|
|
10355
|
-
});
|
|
11795
|
+
logToolSuccess({ ...ctx(), status: "pass", logResult, logDetails, severity: "info" });
|
|
10356
11796
|
return { content: buildToolContent(formatFixPlan(plan, effectiveFormat), plan, effectiveFormat) };
|
|
10357
11797
|
}
|
|
10358
11798
|
case "generate_spf_record": {
|
|
10359
11799
|
const includeProviders = extractIncludeProviders(validatedArgs);
|
|
10360
11800
|
const record = await generateSpfRecord(validDomain, includeProviders, buildDnsOptions(runtimeOptions));
|
|
10361
|
-
logToolSuccess({
|
|
10362
|
-
toolName: name,
|
|
10363
|
-
durationMs: Date.now() - startTime,
|
|
10364
|
-
domain,
|
|
10365
|
-
analytics: runtimeOptions?.analytics,
|
|
10366
|
-
status: "pass",
|
|
10367
|
-
logResult: "generated",
|
|
10368
|
-
logDetails: record,
|
|
10369
|
-
severity: "info",
|
|
10370
|
-
country: runtimeOptions?.country,
|
|
10371
|
-
clientType: runtimeOptions?.clientType,
|
|
10372
|
-
authTier: runtimeOptions?.authTier,
|
|
10373
|
-
keyHash: runtimeOptions?.keyHash
|
|
10374
|
-
});
|
|
11801
|
+
logToolSuccess({ ...ctx(), status: "pass", logResult: "generated", logDetails: record, severity: "info" });
|
|
10375
11802
|
return { content: buildToolContent(formatGeneratedRecord(record, effectiveFormat), record, effectiveFormat) };
|
|
10376
11803
|
}
|
|
10377
11804
|
case "generate_dmarc_record": {
|
|
10378
11805
|
const policy = typeof validatedArgs.policy === "string" ? validatedArgs.policy : void 0;
|
|
10379
11806
|
const ruaEmail = typeof validatedArgs.rua_email === "string" ? validatedArgs.rua_email : void 0;
|
|
10380
11807
|
const record = await generateDmarcRecord(validDomain, policy, ruaEmail, buildDnsOptions(runtimeOptions));
|
|
10381
|
-
logToolSuccess({
|
|
10382
|
-
toolName: name,
|
|
10383
|
-
durationMs: Date.now() - startTime,
|
|
10384
|
-
domain,
|
|
10385
|
-
analytics: runtimeOptions?.analytics,
|
|
10386
|
-
status: "pass",
|
|
10387
|
-
logResult: "generated",
|
|
10388
|
-
logDetails: record,
|
|
10389
|
-
severity: "info",
|
|
10390
|
-
country: runtimeOptions?.country,
|
|
10391
|
-
clientType: runtimeOptions?.clientType,
|
|
10392
|
-
authTier: runtimeOptions?.authTier,
|
|
10393
|
-
keyHash: runtimeOptions?.keyHash
|
|
10394
|
-
});
|
|
11808
|
+
logToolSuccess({ ...ctx(), status: "pass", logResult: "generated", logDetails: record, severity: "info" });
|
|
10395
11809
|
return { content: buildToolContent(formatGeneratedRecord(record, effectiveFormat), record, effectiveFormat) };
|
|
10396
11810
|
}
|
|
10397
11811
|
case "generate_dkim_config": {
|
|
10398
11812
|
const provider = typeof validatedArgs.provider === "string" ? validatedArgs.provider : void 0;
|
|
10399
11813
|
const record = await generateDkimConfig(validDomain, provider);
|
|
10400
|
-
logToolSuccess({
|
|
10401
|
-
toolName: name,
|
|
10402
|
-
durationMs: Date.now() - startTime,
|
|
10403
|
-
domain,
|
|
10404
|
-
analytics: runtimeOptions?.analytics,
|
|
10405
|
-
status: "pass",
|
|
10406
|
-
logResult: "generated",
|
|
10407
|
-
logDetails: record,
|
|
10408
|
-
severity: "info",
|
|
10409
|
-
country: runtimeOptions?.country,
|
|
10410
|
-
clientType: runtimeOptions?.clientType,
|
|
10411
|
-
authTier: runtimeOptions?.authTier,
|
|
10412
|
-
keyHash: runtimeOptions?.keyHash
|
|
10413
|
-
});
|
|
11814
|
+
logToolSuccess({ ...ctx(), status: "pass", logResult: "generated", logDetails: record, severity: "info" });
|
|
10414
11815
|
return { content: buildToolContent(formatGeneratedRecord(record, effectiveFormat), record, effectiveFormat) };
|
|
10415
11816
|
}
|
|
10416
11817
|
case "generate_mta_sts_policy": {
|
|
10417
11818
|
const mxHosts = extractMxHosts(validatedArgs);
|
|
10418
11819
|
const record = await generateMtaStsPolicy(validDomain, mxHosts, buildDnsOptions(runtimeOptions));
|
|
10419
|
-
logToolSuccess({
|
|
10420
|
-
toolName: name,
|
|
10421
|
-
durationMs: Date.now() - startTime,
|
|
10422
|
-
domain,
|
|
10423
|
-
analytics: runtimeOptions?.analytics,
|
|
10424
|
-
status: "pass",
|
|
10425
|
-
logResult: "generated",
|
|
10426
|
-
logDetails: record,
|
|
10427
|
-
severity: "info",
|
|
10428
|
-
country: runtimeOptions?.country,
|
|
10429
|
-
clientType: runtimeOptions?.clientType,
|
|
10430
|
-
authTier: runtimeOptions?.authTier,
|
|
10431
|
-
keyHash: runtimeOptions?.keyHash
|
|
10432
|
-
});
|
|
11820
|
+
logToolSuccess({ ...ctx(), status: "pass", logResult: "generated", logDetails: record, severity: "info" });
|
|
10433
11821
|
return { content: buildToolContent(formatGeneratedRecord(record, effectiveFormat), record, effectiveFormat) };
|
|
10434
11822
|
}
|
|
10435
11823
|
case "get_benchmark": {
|
|
10436
11824
|
const profile = typeof validatedArgs.profile === "string" ? validatedArgs.profile : "mail_enabled";
|
|
10437
11825
|
const result = await getBenchmark(runtimeOptions?.profileAccumulator, profile);
|
|
10438
|
-
logToolSuccess({
|
|
10439
|
-
toolName: name,
|
|
10440
|
-
durationMs: Date.now() - startTime,
|
|
10441
|
-
analytics: runtimeOptions?.analytics,
|
|
10442
|
-
status: "pass",
|
|
10443
|
-
logResult: result.status,
|
|
10444
|
-
logDetails: result,
|
|
10445
|
-
severity: "info",
|
|
10446
|
-
country: runtimeOptions?.country,
|
|
10447
|
-
clientType: runtimeOptions?.clientType,
|
|
10448
|
-
authTier: runtimeOptions?.authTier,
|
|
10449
|
-
keyHash: runtimeOptions?.keyHash
|
|
10450
|
-
});
|
|
11826
|
+
logToolSuccess({ ...ctx(), status: "pass", logResult: result.status, logDetails: result, severity: "info" });
|
|
10451
11827
|
return { content: buildToolContent(formatBenchmark(result, effectiveFormat), result, effectiveFormat) };
|
|
10452
11828
|
}
|
|
10453
11829
|
case "get_provider_insights": {
|
|
@@ -10457,39 +11833,14 @@ async function handleToolsCall(params, scanCacheKV, runtimeOptions) {
|
|
|
10457
11833
|
}
|
|
10458
11834
|
const profile = typeof validatedArgs.profile === "string" ? validatedArgs.profile : "mail_enabled";
|
|
10459
11835
|
const result = await getProviderInsights(runtimeOptions?.profileAccumulator, provider, profile);
|
|
10460
|
-
logToolSuccess({
|
|
10461
|
-
toolName: name,
|
|
10462
|
-
durationMs: Date.now() - startTime,
|
|
10463
|
-
analytics: runtimeOptions?.analytics,
|
|
10464
|
-
status: "pass",
|
|
10465
|
-
logResult: result.status,
|
|
10466
|
-
logDetails: result,
|
|
10467
|
-
severity: "info",
|
|
10468
|
-
country: runtimeOptions?.country,
|
|
10469
|
-
clientType: runtimeOptions?.clientType,
|
|
10470
|
-
authTier: runtimeOptions?.authTier,
|
|
10471
|
-
keyHash: runtimeOptions?.keyHash
|
|
10472
|
-
});
|
|
11836
|
+
logToolSuccess({ ...ctx(), status: "pass", logResult: result.status, logDetails: result, severity: "info" });
|
|
10473
11837
|
return { content: buildToolContent(formatProviderInsights(result, effectiveFormat), result, effectiveFormat) };
|
|
10474
11838
|
}
|
|
10475
11839
|
case "assess_spoofability": {
|
|
10476
11840
|
const result = await assessSpoofability(validDomain, buildDnsOptions(runtimeOptions));
|
|
10477
11841
|
logResult = result.riskLevel;
|
|
10478
11842
|
logDetails = result;
|
|
10479
|
-
logToolSuccess({
|
|
10480
|
-
toolName: name,
|
|
10481
|
-
durationMs: Date.now() - startTime,
|
|
10482
|
-
domain,
|
|
10483
|
-
analytics: runtimeOptions?.analytics,
|
|
10484
|
-
status: result.spoofabilityScore <= 30 ? "pass" : "fail",
|
|
10485
|
-
logResult,
|
|
10486
|
-
logDetails,
|
|
10487
|
-
severity: "info",
|
|
10488
|
-
country: runtimeOptions?.country,
|
|
10489
|
-
clientType: runtimeOptions?.clientType,
|
|
10490
|
-
authTier: runtimeOptions?.authTier,
|
|
10491
|
-
keyHash: runtimeOptions?.keyHash
|
|
10492
|
-
});
|
|
11843
|
+
logToolSuccess({ ...ctx(), status: result.spoofabilityScore <= 30 ? "pass" : "fail", logResult, logDetails, severity: "info" });
|
|
10493
11844
|
return { content: buildToolContent(formatSpoofability(result, effectiveFormat), result, effectiveFormat) };
|
|
10494
11845
|
}
|
|
10495
11846
|
case "check_resolver_consistency": {
|
|
@@ -10498,20 +11849,7 @@ async function handleToolsCall(params, scanCacheKV, runtimeOptions) {
|
|
|
10498
11849
|
runtimeOptions?.resultCapture?.(result);
|
|
10499
11850
|
logResult = result.passed ? "pass" : "fail";
|
|
10500
11851
|
logDetails = result;
|
|
10501
|
-
logToolSuccess({
|
|
10502
|
-
toolName: name,
|
|
10503
|
-
durationMs: Date.now() - startTime,
|
|
10504
|
-
domain,
|
|
10505
|
-
analytics: runtimeOptions?.analytics,
|
|
10506
|
-
status: result.passed ? "pass" : "fail",
|
|
10507
|
-
logResult,
|
|
10508
|
-
logDetails,
|
|
10509
|
-
severity: "info",
|
|
10510
|
-
country: runtimeOptions?.country,
|
|
10511
|
-
clientType: runtimeOptions?.clientType,
|
|
10512
|
-
authTier: runtimeOptions?.authTier,
|
|
10513
|
-
keyHash: runtimeOptions?.keyHash
|
|
10514
|
-
});
|
|
11852
|
+
logToolSuccess({ ...ctx(), status: result.passed ? "pass" : "fail", logResult, logDetails, severity: "info" });
|
|
10515
11853
|
return { content: buildToolContent(formatResolverConsistency(result, effectiveFormat), result, effectiveFormat) };
|
|
10516
11854
|
}
|
|
10517
11855
|
case "explain_finding": {
|
|
@@ -10519,23 +11857,11 @@ async function handleToolsCall(params, scanCacheKV, runtimeOptions) {
|
|
|
10519
11857
|
try {
|
|
10520
11858
|
explainArgs = extractExplainFindingArgs(validatedArgs);
|
|
10521
11859
|
} catch {
|
|
10522
|
-
return handleExplainFindingValidationError(args,
|
|
11860
|
+
return handleExplainFindingValidationError(args, startTime, runtimeOptions);
|
|
10523
11861
|
}
|
|
10524
11862
|
const { checkType, status, details } = explainArgs;
|
|
10525
11863
|
const result = explainFinding(checkType, status, details);
|
|
10526
|
-
logToolSuccess({
|
|
10527
|
-
toolName: name,
|
|
10528
|
-
durationMs: Date.now() - startTime,
|
|
10529
|
-
analytics: runtimeOptions?.analytics,
|
|
10530
|
-
status: "pass",
|
|
10531
|
-
logResult: status,
|
|
10532
|
-
logDetails: { checkType, details },
|
|
10533
|
-
severity: "info",
|
|
10534
|
-
country: runtimeOptions?.country,
|
|
10535
|
-
clientType: runtimeOptions?.clientType,
|
|
10536
|
-
authTier: runtimeOptions?.authTier,
|
|
10537
|
-
keyHash: runtimeOptions?.keyHash
|
|
10538
|
-
});
|
|
11864
|
+
logToolSuccess({ ...ctx(), status: "pass", logResult: status, logDetails: { checkType, details }, severity: "info" });
|
|
10539
11865
|
return { content: buildToolContent(formatExplanation(result, effectiveFormat), result, effectiveFormat) };
|
|
10540
11866
|
}
|
|
10541
11867
|
case "validate_fix": {
|
|
@@ -10544,40 +11870,14 @@ async function handleToolsCall(params, scanCacheKV, runtimeOptions) {
|
|
|
10544
11870
|
const result = await validateFix(validDomain, check, expected, buildDnsOptions(runtimeOptions));
|
|
10545
11871
|
logResult = result.verdict;
|
|
10546
11872
|
logDetails = result;
|
|
10547
|
-
logToolSuccess({
|
|
10548
|
-
toolName: name,
|
|
10549
|
-
durationMs: Date.now() - startTime,
|
|
10550
|
-
domain,
|
|
10551
|
-
analytics: runtimeOptions?.analytics,
|
|
10552
|
-
status: result.verdict === "fixed" ? "pass" : "fail",
|
|
10553
|
-
logResult,
|
|
10554
|
-
logDetails,
|
|
10555
|
-
severity: "info",
|
|
10556
|
-
country: runtimeOptions?.country,
|
|
10557
|
-
clientType: runtimeOptions?.clientType,
|
|
10558
|
-
authTier: runtimeOptions?.authTier,
|
|
10559
|
-
keyHash: runtimeOptions?.keyHash
|
|
10560
|
-
});
|
|
11873
|
+
logToolSuccess({ ...ctx(), status: result.verdict === "fixed" ? "pass" : "fail", logResult, logDetails, severity: "info" });
|
|
10561
11874
|
return { content: buildToolContent(formatValidateFix(result, effectiveFormat), result, effectiveFormat) };
|
|
10562
11875
|
}
|
|
10563
11876
|
case "map_supply_chain": {
|
|
10564
11877
|
const result = await mapSupplyChain(validDomain, buildDnsOptions(runtimeOptions));
|
|
10565
11878
|
logResult = `${result.summary.totalProviders} providers`;
|
|
10566
11879
|
logDetails = result;
|
|
10567
|
-
logToolSuccess({
|
|
10568
|
-
toolName: name,
|
|
10569
|
-
durationMs: Date.now() - startTime,
|
|
10570
|
-
domain,
|
|
10571
|
-
analytics: runtimeOptions?.analytics,
|
|
10572
|
-
status: "pass",
|
|
10573
|
-
logResult,
|
|
10574
|
-
logDetails,
|
|
10575
|
-
severity: "info",
|
|
10576
|
-
country: runtimeOptions?.country,
|
|
10577
|
-
clientType: runtimeOptions?.clientType,
|
|
10578
|
-
authTier: runtimeOptions?.authTier,
|
|
10579
|
-
keyHash: runtimeOptions?.keyHash
|
|
10580
|
-
});
|
|
11880
|
+
logToolSuccess({ ...ctx(), status: "pass", logResult, logDetails, severity: "info" });
|
|
10581
11881
|
return { content: buildToolContent(formatSupplyChain(result, effectiveFormat), result, effectiveFormat) };
|
|
10582
11882
|
}
|
|
10583
11883
|
case "generate_rollout_plan": {
|
|
@@ -10586,20 +11886,7 @@ async function handleToolsCall(params, scanCacheKV, runtimeOptions) {
|
|
|
10586
11886
|
const result = await generateRolloutPlan(validDomain, targetPolicy, timeline, buildDnsOptions(runtimeOptions));
|
|
10587
11887
|
logResult = result.atTarget ? "at_target" : `${result.phases.length} phases`;
|
|
10588
11888
|
logDetails = result;
|
|
10589
|
-
logToolSuccess({
|
|
10590
|
-
toolName: name,
|
|
10591
|
-
durationMs: Date.now() - startTime,
|
|
10592
|
-
domain,
|
|
10593
|
-
analytics: runtimeOptions?.analytics,
|
|
10594
|
-
status: "pass",
|
|
10595
|
-
logResult,
|
|
10596
|
-
logDetails,
|
|
10597
|
-
severity: "info",
|
|
10598
|
-
country: runtimeOptions?.country,
|
|
10599
|
-
clientType: runtimeOptions?.clientType,
|
|
10600
|
-
authTier: runtimeOptions?.authTier,
|
|
10601
|
-
keyHash: runtimeOptions?.keyHash
|
|
10602
|
-
});
|
|
11889
|
+
logToolSuccess({ ...ctx(), status: "pass", logResult, logDetails, severity: "info" });
|
|
10603
11890
|
return { content: buildToolContent(formatRolloutPlan(result, effectiveFormat), result, effectiveFormat) };
|
|
10604
11891
|
}
|
|
10605
11892
|
case "analyze_drift": {
|
|
@@ -10627,77 +11914,40 @@ async function handleToolsCall(params, scanCacheKV, runtimeOptions) {
|
|
|
10627
11914
|
const drift = computeDrift(validDomain, baselineScore, scanResult.score);
|
|
10628
11915
|
logResult = drift.classification;
|
|
10629
11916
|
logDetails = drift;
|
|
10630
|
-
logToolSuccess({
|
|
10631
|
-
toolName: name,
|
|
10632
|
-
durationMs: Date.now() - startTime,
|
|
10633
|
-
domain,
|
|
10634
|
-
analytics: runtimeOptions?.analytics,
|
|
10635
|
-
status: "pass",
|
|
10636
|
-
logResult,
|
|
10637
|
-
logDetails,
|
|
10638
|
-
severity: "info",
|
|
10639
|
-
country: runtimeOptions?.country,
|
|
10640
|
-
clientType: runtimeOptions?.clientType,
|
|
10641
|
-
authTier: runtimeOptions?.authTier,
|
|
10642
|
-
keyHash: runtimeOptions?.keyHash
|
|
10643
|
-
});
|
|
11917
|
+
logToolSuccess({ ...ctx(), status: "pass", logResult, logDetails, severity: "info" });
|
|
10644
11918
|
return { content: buildToolContent(formatDriftReport(drift, effectiveFormat), drift, effectiveFormat) };
|
|
10645
11919
|
}
|
|
10646
11920
|
case "resolve_spf_chain": {
|
|
10647
11921
|
const result = await resolveSpfChain(validDomain, buildDnsOptions(runtimeOptions));
|
|
10648
11922
|
logResult = result.overLimit ? "over_limit" : "ok";
|
|
10649
11923
|
logDetails = { totalLookups: result.totalLookups, maxDepth: result.maxDepth, issues: result.issues.length };
|
|
10650
|
-
logToolSuccess({
|
|
10651
|
-
toolName: name,
|
|
10652
|
-
durationMs: Date.now() - startTime,
|
|
10653
|
-
domain,
|
|
10654
|
-
analytics: runtimeOptions?.analytics,
|
|
10655
|
-
status: result.overLimit ? "fail" : "pass",
|
|
10656
|
-
logResult,
|
|
10657
|
-
logDetails,
|
|
10658
|
-
severity: "info",
|
|
10659
|
-
country: runtimeOptions?.country,
|
|
10660
|
-
clientType: runtimeOptions?.clientType,
|
|
10661
|
-
authTier: runtimeOptions?.authTier,
|
|
10662
|
-
keyHash: runtimeOptions?.keyHash
|
|
10663
|
-
});
|
|
11924
|
+
logToolSuccess({ ...ctx(), status: result.overLimit ? "fail" : "pass", logResult, logDetails, severity: "info" });
|
|
10664
11925
|
return { content: buildToolContent(formatSpfChain(result, effectiveFormat), result, effectiveFormat) };
|
|
10665
11926
|
}
|
|
10666
11927
|
case "discover_subdomains": {
|
|
10667
11928
|
const result = await discoverSubdomains(validDomain, runtimeOptions?.certstream);
|
|
10668
11929
|
logResult = `${result.totalSubdomains} subdomains`;
|
|
10669
11930
|
logDetails = { totalSubdomains: result.totalSubdomains, issues: result.issues.length };
|
|
10670
|
-
logToolSuccess({
|
|
11931
|
+
logToolSuccess({ ...ctx(), status: "pass", logResult, logDetails, severity: "info" });
|
|
10671
11932
|
return { content: buildToolContent(formatSubdomainDiscovery(result, effectiveFormat), result, effectiveFormat) };
|
|
10672
11933
|
}
|
|
10673
11934
|
case "map_compliance": {
|
|
10674
11935
|
const result = await mapCompliance(validDomain, scanCacheKV, runtimeOptions);
|
|
10675
11936
|
logResult = "mapped";
|
|
10676
11937
|
logDetails = result;
|
|
10677
|
-
logToolSuccess({
|
|
11938
|
+
logToolSuccess({ ...ctx(), status: "pass", logResult, logDetails, severity: "info" });
|
|
10678
11939
|
return { content: buildToolContent(formatCompliance(result, effectiveFormat), result, effectiveFormat) };
|
|
10679
11940
|
}
|
|
10680
11941
|
case "simulate_attack_paths": {
|
|
10681
11942
|
const result = await simulateAttackPaths(validDomain, buildDnsOptions(runtimeOptions));
|
|
10682
11943
|
logResult = `${result.totalPaths} paths, risk: ${result.overallRisk}`;
|
|
10683
11944
|
logDetails = { totalPaths: result.totalPaths, overallRisk: result.overallRisk };
|
|
10684
|
-
logToolSuccess({
|
|
11945
|
+
logToolSuccess({ ...ctx(), status: result.overallRisk === "low" ? "pass" : "fail", logResult, logDetails, severity: "info" });
|
|
10685
11946
|
return { content: buildToolContent(formatAttackPaths(result, effectiveFormat), result, effectiveFormat) };
|
|
10686
11947
|
}
|
|
10687
11948
|
default:
|
|
10688
|
-
logToolFailure({
|
|
10689
|
-
|
|
10690
|
-
durationMs: Date.now() - startTime,
|
|
10691
|
-
domain,
|
|
10692
|
-
analytics: runtimeOptions?.analytics,
|
|
10693
|
-
error: `Unknown tool: ${name}`,
|
|
10694
|
-
args,
|
|
10695
|
-
country: runtimeOptions?.country,
|
|
10696
|
-
clientType: runtimeOptions?.clientType,
|
|
10697
|
-
authTier: runtimeOptions?.authTier,
|
|
10698
|
-
keyHash: runtimeOptions?.keyHash
|
|
10699
|
-
});
|
|
10700
|
-
return buildToolErrorResult(`Unknown tool: ${name}. Call tools/list to see all 44 available tools.`);
|
|
11949
|
+
logToolFailure({ ...ctx(), error: `Unknown tool: ${name}`, args });
|
|
11950
|
+
return buildToolErrorResult(`Unknown tool: ${name}. Call tools/list to see all 51 available tools.`);
|
|
10701
11951
|
}
|
|
10702
11952
|
};
|
|
10703
11953
|
return await Promise.race([
|
|
@@ -10706,36 +11956,14 @@ async function handleToolsCall(params, scanCacheKV, runtimeOptions) {
|
|
|
10706
11956
|
]);
|
|
10707
11957
|
} catch (err) {
|
|
10708
11958
|
if (err instanceof Error && err.message === "__tool_timeout__") {
|
|
10709
|
-
logToolFailure({
|
|
10710
|
-
toolName: name,
|
|
10711
|
-
durationMs: Date.now() - startTime,
|
|
10712
|
-
domain,
|
|
10713
|
-
analytics: runtimeOptions?.analytics,
|
|
10714
|
-
error: "Tool call timed out",
|
|
10715
|
-
args,
|
|
10716
|
-
country: runtimeOptions?.country,
|
|
10717
|
-
clientType: runtimeOptions?.clientType,
|
|
10718
|
-
authTier: runtimeOptions?.authTier,
|
|
10719
|
-
keyHash: runtimeOptions?.keyHash
|
|
10720
|
-
});
|
|
11959
|
+
logToolFailure({ ...ctx(), error: "Tool call timed out", args });
|
|
10721
11960
|
return {
|
|
10722
11961
|
content: [mcpError(`${name} timed out after ${TOOL_CALL_TIMEOUT_MS / 1e3}s. Try a simpler check or retry \u2014 cached partial results make retries faster.`)],
|
|
10723
11962
|
isError: true
|
|
10724
11963
|
};
|
|
10725
11964
|
}
|
|
10726
11965
|
const message = sanitizeErrorMessage(err, `An unexpected error occurred while running ${name}. Retry the request \u2014 transient DNS failures are common.`);
|
|
10727
|
-
logToolFailure({
|
|
10728
|
-
toolName: name,
|
|
10729
|
-
durationMs: Date.now() - startTime,
|
|
10730
|
-
domain,
|
|
10731
|
-
analytics: runtimeOptions?.analytics,
|
|
10732
|
-
error: err,
|
|
10733
|
-
args,
|
|
10734
|
-
country: runtimeOptions?.country,
|
|
10735
|
-
clientType: runtimeOptions?.clientType,
|
|
10736
|
-
authTier: runtimeOptions?.authTier,
|
|
10737
|
-
keyHash: runtimeOptions?.keyHash
|
|
10738
|
-
});
|
|
11966
|
+
logToolFailure({ ...ctx(), error: err, args });
|
|
10739
11967
|
return buildToolErrorResult(message);
|
|
10740
11968
|
}
|
|
10741
11969
|
}
|
|
@@ -11239,7 +12467,7 @@ async function dispatchMcpMethod(options) {
|
|
|
11239
12467
|
};
|
|
11240
12468
|
}
|
|
11241
12469
|
}
|
|
11242
|
-
const sessionId = createSessionOnInitialize ? await createSession(options.sessionStore) : options.existingSessionId;
|
|
12470
|
+
const sessionId = createSessionOnInitialize ? await createSession(options.sessionStore, options.analytics) : options.existingSessionId;
|
|
11243
12471
|
if (createSessionOnInitialize && sessionId) {
|
|
11244
12472
|
auditSessionCreated(options.ip, sessionId);
|
|
11245
12473
|
options.analytics?.emitSessionEvent({
|
|
@@ -11852,7 +13080,7 @@ async function executeMcpRequest(options) {
|
|
|
11852
13080
|
}
|
|
11853
13081
|
|
|
11854
13082
|
// src/lib/server-version.ts
|
|
11855
|
-
var SERVER_VERSION = "2.
|
|
13083
|
+
var SERVER_VERSION = "2.9.2";
|
|
11856
13084
|
|
|
11857
13085
|
// src/stdio.ts
|
|
11858
13086
|
function buildNotInitializedError(id) {
|