blackveil-dns 2.6.4 → 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 +562 -203
- package/dist/index.js.map +1 -1
- package/dist/stdio.js +1902 -674
- package/dist/stdio.js.map +1 -1
- package/package.json +4 -3
package/dist/index.js
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import { PROFILE_WEIGHTS,
|
|
2
|
+
import { PROFILE_WEIGHTS, buildCheckResult, createFinding, detectDomainContext, getProfileWeights, computeScanScore, scoreToGrade } from '@blackveil/dns-checks/scoring';
|
|
3
3
|
export { CATEGORY_DISPLAY_WEIGHTS, SEVERITY_PENALTIES, buildCheckResult, computeCategoryScore, computeScanScore, createFinding, detectDomainContext, getProfileWeights, inferFindingConfidence, scoreToGrade } from '@blackveil/dns-checks/scoring';
|
|
4
4
|
import * as punycode from 'punycode/';
|
|
5
|
-
import { checkBIMI, checkCAA, checkDKIM, checkDMARC, checkDNSSEC, checkMTASTS, checkMX, createFinding as createFinding$1, checkNS, checkSPF, checkSSL, checkSubdomainTakeover as checkSubdomainTakeover$1, checkTLSRPT, buildCheckResult as buildCheckResult$1,
|
|
5
|
+
import { checkBIMI, checkCAA, checkDKIM, checkDMARC, checkDNSSEC, checkMTASTS, checkMX, createFinding as createFinding$1, checkNS, checkSPF, checkSSL, checkSubdomainTakeover as checkSubdomainTakeover$1, checkTLSRPT, buildCheckResult as buildCheckResult$1, checkSubdomailing as checkSubdomailing$1, checkSVCBHTTPS, checkDANEHTTPS, checkDANE, checkHTTPSecurity, parseDmarcTags } from '@blackveil/dns-checks';
|
|
6
6
|
export { parseDmarcTags } from '@blackveil/dns-checks';
|
|
7
|
+
import 'cloudflare:workers';
|
|
8
|
+
import 'drizzle-orm';
|
|
9
|
+
import 'drizzle-orm/durable-sqlite';
|
|
7
10
|
|
|
8
11
|
// src/lib/dns-types.ts
|
|
9
12
|
var RecordType = {
|
|
@@ -19,6 +22,7 @@ var RecordType = {
|
|
|
19
22
|
DNSKEY: 48,
|
|
20
23
|
DS: 43,
|
|
21
24
|
RRSIG: 46,
|
|
25
|
+
NSEC3PARAM: 51,
|
|
22
26
|
PTR: 12,
|
|
23
27
|
SRV: 33,
|
|
24
28
|
HTTPS: 65
|
|
@@ -170,7 +174,7 @@ function hasTypedAnswers(response, type) {
|
|
|
170
174
|
function retryDelay(attempt) {
|
|
171
175
|
return new Promise((r) => setTimeout(r, DNS_RETRY_BASE_DELAY_MS * (attempt + 1) + Math.random() * 50));
|
|
172
176
|
}
|
|
173
|
-
async function
|
|
177
|
+
async function fetchDohOutcome(url, timeoutMs, opts) {
|
|
174
178
|
try {
|
|
175
179
|
const headers = { Accept: "application/dns-json" };
|
|
176
180
|
if (opts?.token) headers["X-BV-Token"] = opts.token;
|
|
@@ -181,11 +185,25 @@ async function fetchDohResponse(url, timeoutMs, opts) {
|
|
|
181
185
|
...opts?.useEdgeCache ? { cf: { cacheTtl: DOH_EDGE_CACHE_TTL, cacheEverything: true } } : {}
|
|
182
186
|
});
|
|
183
187
|
const response = opts?.semaphore ? await opts.semaphore.run(doFetch) : await doFetch();
|
|
184
|
-
if (!response.ok)
|
|
188
|
+
if (!response.ok) {
|
|
189
|
+
logError("DNS fetch non-2xx", {
|
|
190
|
+
severity: "warn",
|
|
191
|
+
category: "dns-transport",
|
|
192
|
+
details: { url: url.replace(/name=[^&]+/, "name=<domain>"), status: response.status }
|
|
193
|
+
});
|
|
194
|
+
return { kind: "error", reason: "http" };
|
|
195
|
+
}
|
|
185
196
|
const data = await response.json();
|
|
186
197
|
const parsed = DohResponseSchema.safeParse(data);
|
|
187
|
-
if (!parsed.success)
|
|
188
|
-
|
|
198
|
+
if (!parsed.success) {
|
|
199
|
+
logError("DNS parse failure", {
|
|
200
|
+
severity: "warn",
|
|
201
|
+
category: "dns-transport",
|
|
202
|
+
details: { url: url.replace(/name=[^&]+/, "name=<domain>") }
|
|
203
|
+
});
|
|
204
|
+
return { kind: "error", reason: "parse" };
|
|
205
|
+
}
|
|
206
|
+
return { kind: "ok", response: parsed.data };
|
|
189
207
|
} catch (err) {
|
|
190
208
|
const isTimeout = err instanceof DOMException && err.name === "TimeoutError";
|
|
191
209
|
logError(isTimeout ? "DNS fetch timeout" : "DNS fetch failed", {
|
|
@@ -193,7 +211,7 @@ async function fetchDohResponse(url, timeoutMs, opts) {
|
|
|
193
211
|
category: "dns-transport",
|
|
194
212
|
details: { url: url.replace(/name=[^&]+/, "name=<domain>"), errorType: isTimeout ? "timeout" : "network" }
|
|
195
213
|
});
|
|
196
|
-
return
|
|
214
|
+
return { kind: "error", reason: isTimeout ? "timeout" : "network" };
|
|
197
215
|
}
|
|
198
216
|
}
|
|
199
217
|
var DnsQueryError = class extends Error {
|
|
@@ -264,43 +282,38 @@ async function queryDnsUncached(domain, type, dnssecCheck = false, opts) {
|
|
|
264
282
|
}
|
|
265
283
|
const data = validated.data;
|
|
266
284
|
if (confirmWithSecondaryOnEmpty && !opts?.skipSecondaryConfirmation && !hasTypedAnswers(data, type)) {
|
|
267
|
-
const
|
|
268
|
-
|
|
285
|
+
const secondaryOpts = opts?.secondaryDoh ? { secondaryDoh: { url: opts.secondaryDoh.endpoint, token: opts.secondaryDoh.token } } : void 0;
|
|
286
|
+
const secondaryResult = await confirmWithSecondaryResolvers(domain, type, dnssecCheck, timeoutMs, sem, secondaryOpts);
|
|
287
|
+
if ("kind" in secondaryResult && secondaryResult.kind === "unconfirmed") {
|
|
288
|
+
return data;
|
|
289
|
+
}
|
|
290
|
+
const confirmedResponse = secondaryResult;
|
|
291
|
+
return confirmedResponse;
|
|
269
292
|
}
|
|
270
293
|
return data;
|
|
271
294
|
}
|
|
272
295
|
throw new DnsQueryError("DNS query failed after retries", domain, type);
|
|
273
296
|
}
|
|
274
297
|
async function confirmWithSecondaryResolvers(domain, type, dnssecCheck, timeoutMs, sem, opts) {
|
|
275
|
-
const
|
|
298
|
+
const bvDnsUrl = opts?.secondaryDoh ? buildDohUrl(opts.secondaryDoh.url, domain, type, dnssecCheck) : null;
|
|
276
299
|
const googleUrl = buildDohUrl(GOOGLE_DOH_ENDPOINT, domain, type, dnssecCheck);
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
for (const r of results) {
|
|
285
|
-
if (r.status === "fulfilled" && r.value && hasTypedAnswers(r.value, type)) {
|
|
286
|
-
return r.value;
|
|
287
|
-
}
|
|
288
|
-
}
|
|
289
|
-
const allFailed = results.every((r) => r.status === "rejected" || !r.value);
|
|
290
|
-
if (allFailed) {
|
|
291
|
-
logError("All secondary DNS resolvers failed", {
|
|
292
|
-
severity: "warn",
|
|
293
|
-
category: "dns-transport",
|
|
294
|
-
details: { domain, type }
|
|
295
|
-
});
|
|
296
|
-
}
|
|
297
|
-
return null;
|
|
300
|
+
const candidates = [
|
|
301
|
+
bvDnsUrl ? fetchDohOutcome(bvDnsUrl, timeoutMs, { token: opts.secondaryDoh.token, semaphore: sem }) : Promise.resolve({ kind: "error", reason: "network" }),
|
|
302
|
+
fetchDohOutcome(googleUrl, timeoutMs, { useEdgeCache: true, semaphore: sem })
|
|
303
|
+
];
|
|
304
|
+
const results = await Promise.allSettled(candidates);
|
|
305
|
+
for (const r of results) {
|
|
306
|
+
if (r.status === "fulfilled" && r.value.kind === "ok" && hasTypedAnswers(r.value.response, type)) return r.value.response;
|
|
298
307
|
}
|
|
299
|
-
const
|
|
300
|
-
|
|
301
|
-
return google;
|
|
308
|
+
for (const r of results) {
|
|
309
|
+
if (r.status === "fulfilled" && r.value.kind === "ok") return r.value.response;
|
|
302
310
|
}
|
|
303
|
-
|
|
311
|
+
logError("All secondary resolvers failed", {
|
|
312
|
+
severity: "warn",
|
|
313
|
+
category: "dns-transport",
|
|
314
|
+
details: { type }
|
|
315
|
+
});
|
|
316
|
+
return { kind: "unconfirmed" };
|
|
304
317
|
}
|
|
305
318
|
|
|
306
319
|
// src/lib/dns-records.ts
|
|
@@ -371,6 +384,9 @@ async function queryMxRecords(domain, opts) {
|
|
|
371
384
|
};
|
|
372
385
|
});
|
|
373
386
|
}
|
|
387
|
+
|
|
388
|
+
// src/lib/adaptive-weights.ts
|
|
389
|
+
var MATURITY_THRESHOLD = 200;
|
|
374
390
|
var SCORING_NOTE_DELTA_THRESHOLD = 3;
|
|
375
391
|
var CRITICAL_MAIL_CATEGORIES = /* @__PURE__ */ new Set(["dmarc", "spf", "dkim", "ssl"]);
|
|
376
392
|
var CRITICAL_MAIL_PROFILES = /* @__PURE__ */ new Set(["mail_enabled", "enterprise_mail"]);
|
|
@@ -571,7 +587,7 @@ function sanitizeInput(input, maxLength = 500) {
|
|
|
571
587
|
}
|
|
572
588
|
|
|
573
589
|
// src/lib/server-version.ts
|
|
574
|
-
var SERVER_VERSION = "2.
|
|
590
|
+
var SERVER_VERSION = "2.9.2";
|
|
575
591
|
var DomainSchema = z.string().min(1).max(253);
|
|
576
592
|
z.string().regex(/^[0-9a-f]{64}$/);
|
|
577
593
|
var DkimSelectorSchema = z.string().transform((s) => s.trim().toLowerCase()).pipe(z.string().max(63).regex(/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/));
|
|
@@ -699,6 +715,11 @@ var GenerateRolloutPlanArgs = z.object({
|
|
|
699
715
|
timeline: TimelineSchema.optional().describe("Rollout speed: aggressive, standard, conservative (default: standard)"),
|
|
700
716
|
format: FormatSchema.optional().describe("Output verbosity. Auto-detected if omitted.")
|
|
701
717
|
}).passthrough();
|
|
718
|
+
var CheckFastFluxArgs = z.object({
|
|
719
|
+
domain: DomainSchema.describe("Domain to check (e.g., example.com)"),
|
|
720
|
+
rounds: z.number().int().min(3).max(5).optional().describe("Number of query rounds (3-5, default 3)."),
|
|
721
|
+
format: FormatSchema.optional().describe("Output verbosity. Auto-detected if omitted.")
|
|
722
|
+
}).passthrough();
|
|
702
723
|
var TOOL_SCHEMA_MAP = {
|
|
703
724
|
check_mx: BaseDomainArgs,
|
|
704
725
|
check_spf: BaseDomainArgs,
|
|
@@ -743,11 +764,18 @@ var TOOL_SCHEMA_MAP = {
|
|
|
743
764
|
resolve_spf_chain: BaseDomainArgs,
|
|
744
765
|
discover_subdomains: BaseDomainArgs,
|
|
745
766
|
map_compliance: BaseDomainArgs,
|
|
746
|
-
simulate_attack_paths: BaseDomainArgs
|
|
767
|
+
simulate_attack_paths: BaseDomainArgs,
|
|
768
|
+
check_dbl: BaseDomainArgs,
|
|
769
|
+
check_rbl: BaseDomainArgs,
|
|
770
|
+
cymru_asn: BaseDomainArgs,
|
|
771
|
+
rdap_lookup: BaseDomainArgs,
|
|
772
|
+
check_nsec_walkability: BaseDomainArgs,
|
|
773
|
+
check_dnssec_chain: BaseDomainArgs,
|
|
774
|
+
check_fast_flux: CheckFastFluxArgs
|
|
747
775
|
};
|
|
748
776
|
|
|
749
777
|
// src/schemas/tool-definitions.ts
|
|
750
|
-
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"]);
|
|
778
|
+
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"]);
|
|
751
779
|
function toolNameToTitle(name) {
|
|
752
780
|
return name.split("_").map((word) => KNOWN_ACRONYMS.has(word) ? word.toUpperCase() : word.charAt(0).toUpperCase() + word.slice(1)).join(" ");
|
|
753
781
|
}
|
|
@@ -1045,6 +1073,48 @@ var TOOL_DEFS = {
|
|
|
1045
1073
|
schema: BaseDomainArgs,
|
|
1046
1074
|
group: "intelligence",
|
|
1047
1075
|
scanIncluded: false
|
|
1076
|
+
},
|
|
1077
|
+
check_dbl: {
|
|
1078
|
+
description: "Check domain reputation against DNS-based Domain Block Lists (Spamhaus DBL, URIBL, SURBL). Returns listing status with decoded return codes.",
|
|
1079
|
+
schema: BaseDomainArgs,
|
|
1080
|
+
group: "intelligence",
|
|
1081
|
+
scanIncluded: false
|
|
1082
|
+
},
|
|
1083
|
+
check_rbl: {
|
|
1084
|
+
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.",
|
|
1085
|
+
schema: BaseDomainArgs,
|
|
1086
|
+
group: "intelligence",
|
|
1087
|
+
scanIncluded: false
|
|
1088
|
+
},
|
|
1089
|
+
cymru_asn: {
|
|
1090
|
+
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.",
|
|
1091
|
+
schema: BaseDomainArgs,
|
|
1092
|
+
group: "intelligence",
|
|
1093
|
+
scanIncluded: false
|
|
1094
|
+
},
|
|
1095
|
+
rdap_lookup: {
|
|
1096
|
+
description: "Fetch domain registration data via RDAP (modern WHOIS replacement). Returns registrar, creation/expiration dates, EPP status, registrant info, and domain age.",
|
|
1097
|
+
schema: BaseDomainArgs,
|
|
1098
|
+
group: "intelligence",
|
|
1099
|
+
scanIncluded: false
|
|
1100
|
+
},
|
|
1101
|
+
check_nsec_walkability: {
|
|
1102
|
+
description: "Assess zone walkability risk by analyzing NSEC3PARAM configuration. Detects plain NSEC zones, weak NSEC3 parameters, and opt-out flags.",
|
|
1103
|
+
schema: BaseDomainArgs,
|
|
1104
|
+
group: "intelligence",
|
|
1105
|
+
scanIncluded: false
|
|
1106
|
+
},
|
|
1107
|
+
check_dnssec_chain: {
|
|
1108
|
+
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.",
|
|
1109
|
+
schema: BaseDomainArgs,
|
|
1110
|
+
group: "intelligence",
|
|
1111
|
+
scanIncluded: false
|
|
1112
|
+
},
|
|
1113
|
+
check_fast_flux: {
|
|
1114
|
+
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.",
|
|
1115
|
+
schema: CheckFastFluxArgs,
|
|
1116
|
+
group: "intelligence",
|
|
1117
|
+
scanIncluded: false
|
|
1048
1118
|
}
|
|
1049
1119
|
};
|
|
1050
1120
|
var TOOLS = Object.entries(TOOL_DEFS).map(([name, def]) => ({
|
|
@@ -1062,6 +1132,8 @@ var TOOLS = Object.entries(TOOL_DEFS).map(([name, def]) => ({
|
|
|
1062
1132
|
...def.tier !== void 0 && { tier: def.tier },
|
|
1063
1133
|
scanIncluded: def.scanIncluded
|
|
1064
1134
|
}));
|
|
1135
|
+
|
|
1136
|
+
// src/lib/dns-query-adapter.ts
|
|
1065
1137
|
function makeQueryDNS(dnsOptions) {
|
|
1066
1138
|
return async (domain, type) => {
|
|
1067
1139
|
if (type === "TXT") {
|
|
@@ -1070,6 +1142,8 @@ function makeQueryDNS(dnsOptions) {
|
|
|
1070
1142
|
return queryDnsRecords(domain, type, dnsOptions);
|
|
1071
1143
|
};
|
|
1072
1144
|
}
|
|
1145
|
+
|
|
1146
|
+
// src/tools/check-bimi.ts
|
|
1073
1147
|
async function checkBimi(domain, dnsOptions) {
|
|
1074
1148
|
return checkBIMI(
|
|
1075
1149
|
domain,
|
|
@@ -1077,18 +1151,10 @@ async function checkBimi(domain, dnsOptions) {
|
|
|
1077
1151
|
{ timeout: dnsOptions?.timeoutMs ?? 5e3, fetchFn: fetch }
|
|
1078
1152
|
);
|
|
1079
1153
|
}
|
|
1080
|
-
function makeQueryDNS2(dnsOptions) {
|
|
1081
|
-
return async (domain, type) => {
|
|
1082
|
-
if (type === "TXT") {
|
|
1083
|
-
return queryTxtRecords(domain, dnsOptions);
|
|
1084
|
-
}
|
|
1085
|
-
return queryDnsRecords(domain, type, dnsOptions);
|
|
1086
|
-
};
|
|
1087
|
-
}
|
|
1088
1154
|
async function checkCaa(domain, dnsOptions) {
|
|
1089
1155
|
return checkCAA(
|
|
1090
1156
|
domain,
|
|
1091
|
-
|
|
1157
|
+
makeQueryDNS(dnsOptions),
|
|
1092
1158
|
{ timeout: dnsOptions?.timeoutMs ?? 5e3 }
|
|
1093
1159
|
);
|
|
1094
1160
|
}
|
|
@@ -1101,18 +1167,10 @@ var HIGH_CONFIDENCE_DKIM_PROVIDERS = /* @__PURE__ */ new Set([
|
|
|
1101
1167
|
"microsoft 365"
|
|
1102
1168
|
]);
|
|
1103
1169
|
var MEDIUM_CONFIDENCE_DKIM_PROVIDERS = /* @__PURE__ */ new Set(["proofpoint", "mimecast"]);
|
|
1104
|
-
function makeQueryDNS3(dnsOptions) {
|
|
1105
|
-
return async (domain, type) => {
|
|
1106
|
-
if (type === "TXT") {
|
|
1107
|
-
return queryTxtRecords(domain, dnsOptions);
|
|
1108
|
-
}
|
|
1109
|
-
return queryDnsRecords(domain, type, dnsOptions);
|
|
1110
|
-
};
|
|
1111
|
-
}
|
|
1112
1170
|
async function checkDkim(domain, selector, dnsOptions) {
|
|
1113
1171
|
return checkDKIM(
|
|
1114
1172
|
domain,
|
|
1115
|
-
|
|
1173
|
+
makeQueryDNS(dnsOptions),
|
|
1116
1174
|
{ timeout: dnsOptions?.timeoutMs ?? 5e3, selector }
|
|
1117
1175
|
);
|
|
1118
1176
|
}
|
|
@@ -1162,34 +1220,68 @@ function applyProviderDkimContext(dkimResult, provider) {
|
|
|
1162
1220
|
}
|
|
1163
1221
|
return buildCheckResult$1("dkim", newFindings);
|
|
1164
1222
|
}
|
|
1165
|
-
function makeQueryDNS4(dnsOptions) {
|
|
1166
|
-
return async (domain, type) => {
|
|
1167
|
-
if (type === "TXT") {
|
|
1168
|
-
return queryTxtRecords(domain, dnsOptions);
|
|
1169
|
-
}
|
|
1170
|
-
return queryDnsRecords(domain, type, dnsOptions);
|
|
1171
|
-
};
|
|
1172
|
-
}
|
|
1173
1223
|
async function checkDmarc(domain, dnsOptions) {
|
|
1174
1224
|
return checkDMARC(
|
|
1175
1225
|
domain,
|
|
1176
|
-
|
|
1226
|
+
makeQueryDNS(dnsOptions),
|
|
1177
1227
|
{ timeout: dnsOptions?.timeoutMs ?? 5e3 }
|
|
1178
1228
|
);
|
|
1179
1229
|
}
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
}
|
|
1185
|
-
|
|
1186
|
-
|
|
1230
|
+
var GOOGLE_DOH_ENDPOINT2 = "https://dns.google/resolve";
|
|
1231
|
+
var AD_CONFIRM_TIMEOUT_MS = 3e3;
|
|
1232
|
+
async function confirmAdWithGoogle(domain, timeoutMs = AD_CONFIRM_TIMEOUT_MS) {
|
|
1233
|
+
try {
|
|
1234
|
+
const url = `${GOOGLE_DOH_ENDPOINT2}?name=${encodeURIComponent(domain)}&type=A&cd=0`;
|
|
1235
|
+
const resp = await fetch(url, {
|
|
1236
|
+
method: "GET",
|
|
1237
|
+
redirect: "manual",
|
|
1238
|
+
headers: { Accept: "application/dns-json" },
|
|
1239
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
1240
|
+
});
|
|
1241
|
+
if (!resp.ok) return false;
|
|
1242
|
+
const data = await resp.json();
|
|
1243
|
+
return data.AD === true;
|
|
1244
|
+
} catch {
|
|
1245
|
+
return false;
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
async function augmentWithSource(domain, baseResult, dnsOptions) {
|
|
1249
|
+
const [dnskeyResult, dsResult] = await Promise.allSettled([
|
|
1250
|
+
queryDnsRecords(domain, "DNSKEY", dnsOptions),
|
|
1251
|
+
queryDnsRecords(domain, "DS", dnsOptions)
|
|
1252
|
+
]);
|
|
1253
|
+
const hasDnskey = dnskeyResult.status === "fulfilled" && dnskeyResult.value.length > 0;
|
|
1254
|
+
const hasDs = dsResult.status === "fulfilled" && dsResult.value.length > 0;
|
|
1255
|
+
const dnssecSource = hasDnskey && hasDs ? "domain_configured" : "tld_inherited";
|
|
1256
|
+
if (dnssecSource === "tld_inherited") {
|
|
1257
|
+
const inheritedFinding = createFinding(
|
|
1258
|
+
"dnssec",
|
|
1259
|
+
"DNSSEC inherited from TLD",
|
|
1260
|
+
"info",
|
|
1261
|
+
`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.`,
|
|
1262
|
+
{ dnssecSource: "tld_inherited" }
|
|
1263
|
+
);
|
|
1264
|
+
return buildCheckResult("dnssec", [...baseResult.findings, inheritedFinding]);
|
|
1265
|
+
}
|
|
1266
|
+
if (baseResult.findings.length > 0) {
|
|
1267
|
+
const [first, ...rest] = baseResult.findings;
|
|
1268
|
+
const tagged = { ...first, metadata: { ...first.metadata ?? {}, dnssecSource: "domain_configured" } };
|
|
1269
|
+
return buildCheckResult("dnssec", [tagged, ...rest]);
|
|
1270
|
+
}
|
|
1271
|
+
const configuredFinding = createFinding(
|
|
1272
|
+
"dnssec",
|
|
1273
|
+
"DNSSEC configured by domain owner",
|
|
1274
|
+
"info",
|
|
1275
|
+
`${domain} has DNSKEY and DS records \u2014 DNSSEC is explicitly configured by the domain owner.`,
|
|
1276
|
+
{ dnssecSource: "domain_configured" }
|
|
1277
|
+
);
|
|
1278
|
+
return buildCheckResult("dnssec", [configuredFinding]);
|
|
1187
1279
|
}
|
|
1188
1280
|
async function checkDnssec2(domain, dnsOptions) {
|
|
1189
1281
|
try {
|
|
1190
1282
|
const baseResult = await checkDNSSEC(
|
|
1191
1283
|
domain,
|
|
1192
|
-
|
|
1284
|
+
makeQueryDNS(dnsOptions),
|
|
1193
1285
|
{
|
|
1194
1286
|
timeout: dnsOptions?.timeoutMs ?? 5e3,
|
|
1195
1287
|
rawQueryDNS: async (d, type, dnssecFlag) => {
|
|
@@ -1198,51 +1290,49 @@ async function checkDnssec2(domain, dnsOptions) {
|
|
|
1198
1290
|
}
|
|
1199
1291
|
}
|
|
1200
1292
|
);
|
|
1201
|
-
const
|
|
1293
|
+
const isDnsTransportError = baseResult.findings.some((f) => f.title === "DNSSEC check failed");
|
|
1294
|
+
if (isDnsTransportError) {
|
|
1295
|
+
return { ...baseResult, checkStatus: "error" };
|
|
1296
|
+
}
|
|
1297
|
+
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");
|
|
1202
1298
|
if (dnssecAbsent) {
|
|
1203
1299
|
return baseResult;
|
|
1204
1300
|
}
|
|
1205
|
-
const
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
const [first, ...rest] = baseResult.findings;
|
|
1224
|
-
const tagged = { ...first, metadata: { ...first.metadata ?? {}, dnssecSource: "domain_configured" } };
|
|
1225
|
-
return buildCheckResult("dnssec", [tagged, ...rest]);
|
|
1301
|
+
const validationFailing = baseResult.findings.some((f) => f.title === "DNSSEC validation failing");
|
|
1302
|
+
if (validationFailing) {
|
|
1303
|
+
const googleConfirmsAd = await confirmAdWithGoogle(domain, dnsOptions?.timeoutMs ?? AD_CONFIRM_TIMEOUT_MS);
|
|
1304
|
+
if (googleConfirmsAd) {
|
|
1305
|
+
const correctedResult = await checkDNSSEC(
|
|
1306
|
+
domain,
|
|
1307
|
+
makeQueryDNS(dnsOptions),
|
|
1308
|
+
{
|
|
1309
|
+
timeout: dnsOptions?.timeoutMs ?? 5e3,
|
|
1310
|
+
rawQueryDNS: async (d, type, dnssecFlag) => {
|
|
1311
|
+
const resp = await queryDns(d, type, dnssecFlag ?? false, dnsOptions);
|
|
1312
|
+
return { AD: true, Answer: resp.Answer };
|
|
1313
|
+
}
|
|
1314
|
+
}
|
|
1315
|
+
);
|
|
1316
|
+
return augmentWithSource(domain, correctedResult, dnsOptions);
|
|
1317
|
+
}
|
|
1318
|
+
return baseResult;
|
|
1226
1319
|
}
|
|
1227
|
-
|
|
1228
|
-
"dnssec",
|
|
1229
|
-
"DNSSEC configured by domain owner",
|
|
1230
|
-
"info",
|
|
1231
|
-
`${domain} has DNSKEY and DS records \u2014 DNSSEC is explicitly configured by the domain owner.`,
|
|
1232
|
-
{ dnssecSource: "domain_configured" }
|
|
1233
|
-
);
|
|
1234
|
-
return buildCheckResult("dnssec", [configuredFinding]);
|
|
1320
|
+
return augmentWithSource(domain, baseResult, dnsOptions);
|
|
1235
1321
|
} catch (err) {
|
|
1236
1322
|
if (err instanceof DnsQueryError) {
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1323
|
+
const message = err.message;
|
|
1324
|
+
return {
|
|
1325
|
+
...buildCheckResult("dnssec", [
|
|
1326
|
+
createFinding(
|
|
1327
|
+
"dnssec",
|
|
1328
|
+
"DNSSEC check could not complete",
|
|
1329
|
+
"info",
|
|
1330
|
+
`DNS query failed (${message}). DNSSEC posture unknown.`,
|
|
1331
|
+
{ dnsError: message, checkStatus: "error" }
|
|
1332
|
+
)
|
|
1333
|
+
]),
|
|
1334
|
+
checkStatus: "error"
|
|
1335
|
+
};
|
|
1246
1336
|
}
|
|
1247
1337
|
throw err;
|
|
1248
1338
|
}
|
|
@@ -1621,18 +1711,10 @@ async function checkLookalikesCore(domain) {
|
|
|
1621
1711
|
}
|
|
1622
1712
|
return buildCheckResult("lookalikes", findings);
|
|
1623
1713
|
}
|
|
1624
|
-
function makeQueryDNS6(dnsOptions) {
|
|
1625
|
-
return async (domain, type) => {
|
|
1626
|
-
if (type === "TXT") {
|
|
1627
|
-
return queryTxtRecords(domain, dnsOptions);
|
|
1628
|
-
}
|
|
1629
|
-
return queryDnsRecords(domain, type, dnsOptions);
|
|
1630
|
-
};
|
|
1631
|
-
}
|
|
1632
1714
|
async function checkMtaSts(domain, dnsOptions) {
|
|
1633
1715
|
return checkMTASTS(
|
|
1634
1716
|
domain,
|
|
1635
|
-
|
|
1717
|
+
makeQueryDNS(dnsOptions),
|
|
1636
1718
|
{ timeout: dnsOptions?.timeoutMs ?? HTTPS_TIMEOUT_MS, fetchFn: fetch }
|
|
1637
1719
|
);
|
|
1638
1720
|
}
|
|
@@ -1853,19 +1935,11 @@ function detectProviderMatchesBySelectors(selectors, signatures) {
|
|
|
1853
1935
|
}
|
|
1854
1936
|
|
|
1855
1937
|
// src/tools/check-mx.ts
|
|
1856
|
-
function makeQueryDNS7(dnsOptions) {
|
|
1857
|
-
return async (domain, type) => {
|
|
1858
|
-
if (type === "TXT") {
|
|
1859
|
-
return queryTxtRecords(domain, dnsOptions);
|
|
1860
|
-
}
|
|
1861
|
-
return queryDnsRecords(domain, type, dnsOptions);
|
|
1862
|
-
};
|
|
1863
|
-
}
|
|
1864
1938
|
async function checkMx(domain, options, dnsOptions) {
|
|
1865
1939
|
const timeout = dnsOptions?.timeoutMs ?? 5e3;
|
|
1866
1940
|
const baseResult = await checkMX(
|
|
1867
1941
|
domain,
|
|
1868
|
-
|
|
1942
|
+
makeQueryDNS(dnsOptions),
|
|
1869
1943
|
{ timeout }
|
|
1870
1944
|
);
|
|
1871
1945
|
const hasCritical = baseResult.findings.some((f) => f.severity === "critical");
|
|
@@ -1874,6 +1948,7 @@ async function checkMx(domain, options, dnsOptions) {
|
|
|
1874
1948
|
return baseResult;
|
|
1875
1949
|
}
|
|
1876
1950
|
const findings = [...baseResult.findings];
|
|
1951
|
+
let providerDetectionFailed = false;
|
|
1877
1952
|
try {
|
|
1878
1953
|
const mxAnswers = await queryDnsRecords(domain, "MX", dnsOptions);
|
|
1879
1954
|
const mxTargets = mxAnswers.map((answer) => {
|
|
@@ -1903,6 +1978,7 @@ async function checkMx(domain, options, dnsOptions) {
|
|
|
1903
1978
|
);
|
|
1904
1979
|
}
|
|
1905
1980
|
if (providerSignatures.degraded) {
|
|
1981
|
+
providerDetectionFailed = true;
|
|
1906
1982
|
findings.push(
|
|
1907
1983
|
createFinding$1(
|
|
1908
1984
|
"mx",
|
|
@@ -1921,6 +1997,7 @@ async function checkMx(domain, options, dnsOptions) {
|
|
|
1921
1997
|
}
|
|
1922
1998
|
}
|
|
1923
1999
|
} catch (err) {
|
|
2000
|
+
providerDetectionFailed = true;
|
|
1924
2001
|
logEvent({
|
|
1925
2002
|
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1926
2003
|
severity: "warn",
|
|
@@ -1931,20 +2008,16 @@ async function checkMx(domain, options, dnsOptions) {
|
|
|
1931
2008
|
details: { phase: "provider_detection" }
|
|
1932
2009
|
});
|
|
1933
2010
|
}
|
|
1934
|
-
return {
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
if (type === "TXT") {
|
|
1939
|
-
return queryTxtRecords(domain, dnsOptions);
|
|
1940
|
-
}
|
|
1941
|
-
return queryDnsRecords(domain, type, dnsOptions);
|
|
2011
|
+
return {
|
|
2012
|
+
...baseResult,
|
|
2013
|
+
findings,
|
|
2014
|
+
...providerDetectionFailed ? { metadata: { providerDetectionFailed: true } } : {}
|
|
1942
2015
|
};
|
|
1943
2016
|
}
|
|
1944
2017
|
async function checkNs(domain, dnsOptions) {
|
|
1945
2018
|
return checkNS(
|
|
1946
2019
|
domain,
|
|
1947
|
-
|
|
2020
|
+
makeQueryDNS(dnsOptions),
|
|
1948
2021
|
{
|
|
1949
2022
|
timeout: dnsOptions?.timeoutMs ?? 5e3,
|
|
1950
2023
|
rawQueryDNS: async (d, type, dnssecFlag) => {
|
|
@@ -1954,51 +2027,27 @@ async function checkNs(domain, dnsOptions) {
|
|
|
1954
2027
|
}
|
|
1955
2028
|
);
|
|
1956
2029
|
}
|
|
1957
|
-
function makeQueryDNS9(dnsOptions) {
|
|
1958
|
-
return async (domain, type) => {
|
|
1959
|
-
if (type === "TXT") {
|
|
1960
|
-
return queryTxtRecords(domain, dnsOptions);
|
|
1961
|
-
}
|
|
1962
|
-
return queryDnsRecords(domain, type, dnsOptions);
|
|
1963
|
-
};
|
|
1964
|
-
}
|
|
1965
2030
|
async function checkSpf(domain, dnsOptions) {
|
|
1966
2031
|
return checkSPF(
|
|
1967
2032
|
domain,
|
|
1968
|
-
|
|
2033
|
+
makeQueryDNS(dnsOptions),
|
|
1969
2034
|
{ timeout: dnsOptions?.timeoutMs ?? 5e3 }
|
|
1970
2035
|
);
|
|
1971
2036
|
}
|
|
1972
2037
|
async function checkSsl(domain) {
|
|
1973
2038
|
return checkSSL(domain, fetch, { timeout: HTTPS_TIMEOUT_MS });
|
|
1974
2039
|
}
|
|
1975
|
-
function makeQueryDNS10(dnsOptions) {
|
|
1976
|
-
return async (domain, type) => {
|
|
1977
|
-
if (type === "TXT") {
|
|
1978
|
-
return queryTxtRecords(domain, dnsOptions);
|
|
1979
|
-
}
|
|
1980
|
-
return queryDnsRecords(domain, type, dnsOptions);
|
|
1981
|
-
};
|
|
1982
|
-
}
|
|
1983
2040
|
async function checkSubdomainTakeover(domain, dnsOptions) {
|
|
1984
2041
|
return checkSubdomainTakeover$1(
|
|
1985
2042
|
domain,
|
|
1986
|
-
|
|
2043
|
+
makeQueryDNS(dnsOptions),
|
|
1987
2044
|
{ timeout: dnsOptions?.timeoutMs ?? HTTPS_TIMEOUT_MS, fetchFn: fetch }
|
|
1988
2045
|
);
|
|
1989
2046
|
}
|
|
1990
|
-
function makeQueryDNS11(dnsOptions) {
|
|
1991
|
-
return async (domain, type) => {
|
|
1992
|
-
if (type === "TXT") {
|
|
1993
|
-
return queryTxtRecords(domain, dnsOptions);
|
|
1994
|
-
}
|
|
1995
|
-
return queryDnsRecords(domain, type, dnsOptions);
|
|
1996
|
-
};
|
|
1997
|
-
}
|
|
1998
2047
|
async function checkTlsrpt(domain, dnsOptions) {
|
|
1999
2048
|
return checkTLSRPT(
|
|
2000
2049
|
domain,
|
|
2001
|
-
|
|
2050
|
+
makeQueryDNS(dnsOptions),
|
|
2002
2051
|
{ timeout: dnsOptions?.timeoutMs ?? 5e3 }
|
|
2003
2052
|
);
|
|
2004
2053
|
}
|
|
@@ -2440,6 +2489,59 @@ var EXPLANATIONS = {
|
|
|
2440
2489
|
explanation: "An HTTPS/SVCB record is configured, advertising modern connection capabilities for this domain.",
|
|
2441
2490
|
recommendation: "No action required. Consider adding ECH for enhanced privacy.",
|
|
2442
2491
|
references: ["https://datatracker.ietf.org/doc/html/rfc9460"]
|
|
2492
|
+
},
|
|
2493
|
+
// --- Intelligence tools ---
|
|
2494
|
+
DBL_LISTED: {
|
|
2495
|
+
title: "Domain Listed on Blocklist",
|
|
2496
|
+
severity: "high",
|
|
2497
|
+
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.",
|
|
2498
|
+
impact: "Listed domains may have email deliverability issues and are often blocked by recipient mail servers.",
|
|
2499
|
+
adverseConsequences: "Legitimate email from this domain may be silently dropped or quarantined.",
|
|
2500
|
+
recommendation: "Investigate the listing reason. For Spamhaus DBL, check https://check.spamhaus.org/. Request delisting after resolving the underlying issue.",
|
|
2501
|
+
references: ["https://www.spamhaus.org/dbl/", "https://uribl.com/", "https://www.surbl.org/"]
|
|
2502
|
+
},
|
|
2503
|
+
RBL_LISTED: {
|
|
2504
|
+
title: "IP Listed on Real-time Blocklist",
|
|
2505
|
+
severity: "high",
|
|
2506
|
+
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.",
|
|
2507
|
+
impact: "Email from listed IPs is likely to be rejected or quarantined by recipient servers.",
|
|
2508
|
+
adverseConsequences: "Significant email deliverability degradation. May require IP change or delisting process.",
|
|
2509
|
+
recommendation: "Check Spamhaus at https://check.spamhaus.org/. For shared hosting, contact your provider. For dedicated IPs, resolve the abuse issue and request delisting.",
|
|
2510
|
+
references: ["https://www.spamhaus.org/zen/", "https://www.spamcop.net/"]
|
|
2511
|
+
},
|
|
2512
|
+
ASN_HIGH_RISK: {
|
|
2513
|
+
title: "High-Risk ASN Detected",
|
|
2514
|
+
severity: "medium",
|
|
2515
|
+
explanation: "The domain resolves to IP addresses in an Autonomous System commonly associated with abuse infrastructure (bulletproof hosting, botnets).",
|
|
2516
|
+
recommendation: "Verify the hosting choice is intentional. High-risk ASNs are not inherently malicious but are statistically over-represented in abuse reports.",
|
|
2517
|
+
references: ["https://www.team-cymru.com/ip-asn-mapping"]
|
|
2518
|
+
},
|
|
2519
|
+
FAST_FLUX_DETECTED: {
|
|
2520
|
+
title: "Fast-Flux Behavior Detected",
|
|
2521
|
+
severity: "high",
|
|
2522
|
+
explanation: "The domain shows rapidly rotating IP addresses with very low TTLs, a technique commonly used by botnets and phishing operations to evade takedown.",
|
|
2523
|
+
impact: "Fast-flux domains are a strong indicator of malicious infrastructure.",
|
|
2524
|
+
adverseConsequences: "Associating with fast-flux infrastructure damages domain reputation and may trigger automated blocking.",
|
|
2525
|
+
recommendation: "Investigate immediately. If this is a CDN or legitimate load balancer, TTLs are typically higher and rotation patterns are predictable.",
|
|
2526
|
+
references: ["https://en.wikipedia.org/wiki/Fast_flux"]
|
|
2527
|
+
},
|
|
2528
|
+
DNSSEC_CHAIN_BROKEN: {
|
|
2529
|
+
title: "DNSSEC Chain of Trust Broken",
|
|
2530
|
+
severity: "high",
|
|
2531
|
+
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.",
|
|
2532
|
+
impact: "DNSSEC-validating resolvers will return SERVFAIL for this domain, causing complete resolution failure for security-conscious clients.",
|
|
2533
|
+
adverseConsequences: "Domain may be unreachable for users behind DNSSEC-validating resolvers.",
|
|
2534
|
+
recommendation: "Ensure the DS record at the parent matches a DNSKEY at the child zone. Use `delv +vtrace` for detailed chain debugging.",
|
|
2535
|
+
references: ["https://datatracker.ietf.org/doc/html/rfc4033"]
|
|
2536
|
+
},
|
|
2537
|
+
NSEC_WALKABLE: {
|
|
2538
|
+
title: "Zone Walkable (No NSEC3)",
|
|
2539
|
+
severity: "high",
|
|
2540
|
+
explanation: "The zone does not appear to use NSEC3, meaning it likely uses plain NSEC records which allow full zone enumeration (zone walking).",
|
|
2541
|
+
impact: "Attackers can discover all hostnames in the zone without brute-forcing, exposing internal infrastructure.",
|
|
2542
|
+
adverseConsequences: "Complete zone enumeration reveals the attack surface \u2014 internal hosts, staging environments, and service names.",
|
|
2543
|
+
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.",
|
|
2544
|
+
references: ["https://datatracker.ietf.org/doc/html/rfc5155", "https://datatracker.ietf.org/doc/html/rfc9276"]
|
|
2443
2545
|
}
|
|
2444
2546
|
};
|
|
2445
2547
|
var DEFAULT_EXPLANATION = {
|
|
@@ -2516,6 +2618,26 @@ var CATEGORY_FALLBACK_IMPACT = {
|
|
|
2516
2618
|
SVCB_HTTPS: {
|
|
2517
2619
|
impact: "Modern transport capabilities (ALPN, ECH) cannot be advertised via DNS, reducing connection efficiency and privacy.",
|
|
2518
2620
|
adverseConsequences: "Clients require additional round-trips to negotiate protocols, and ECH-based privacy is unavailable."
|
|
2621
|
+
},
|
|
2622
|
+
RBL: {
|
|
2623
|
+
impact: "Mail server IPs are listed on one or more DNS blocklists, likely degrading email deliverability.",
|
|
2624
|
+
adverseConsequences: "Outbound email may be silently dropped or quarantined by recipient servers."
|
|
2625
|
+
},
|
|
2626
|
+
DBL: {
|
|
2627
|
+
impact: "Domain is flagged on DNS-based domain blocklists, indicating prior spam or abuse association.",
|
|
2628
|
+
adverseConsequences: "Domain reputation is damaged. Email and web traffic may be blocked by security tools."
|
|
2629
|
+
},
|
|
2630
|
+
FAST_FLUX: {
|
|
2631
|
+
impact: "DNS resolution shows fast-flux patterns \u2014 rapidly rotating IPs with low TTLs.",
|
|
2632
|
+
adverseConsequences: "Strong indicator of botnet or phishing infrastructure. Domain reputation will be severely impacted."
|
|
2633
|
+
},
|
|
2634
|
+
DNSSEC_CHAIN: {
|
|
2635
|
+
impact: "DNSSEC chain structure has gaps or uses weak algorithms, reducing trust in DNS responses.",
|
|
2636
|
+
adverseConsequences: "DNSSEC-validating resolvers may fail to resolve the domain or accept spoofed responses."
|
|
2637
|
+
},
|
|
2638
|
+
NSEC_WALKABILITY: {
|
|
2639
|
+
impact: "Zone denial-of-existence parameters allow enumeration of zone contents.",
|
|
2640
|
+
adverseConsequences: "Attackers can map the full attack surface by walking the zone without brute-forcing."
|
|
2519
2641
|
}
|
|
2520
2642
|
};
|
|
2521
2643
|
var SEVERITY_FALLBACK_IMPACT = {
|
|
@@ -2959,6 +3081,38 @@ async function pollForResult(key, kv) {
|
|
|
2959
3081
|
}
|
|
2960
3082
|
return void 0;
|
|
2961
3083
|
}
|
|
3084
|
+
var SCANNER_USER_AGENT = "Mozilla/5.0 (compatible; BlackVeilDNSScanner/1.0; +https://blackveilsecurity.com)";
|
|
3085
|
+
var WAF_CHALLENGE_FINGERPRINTS = [
|
|
3086
|
+
{
|
|
3087
|
+
name: "cloudflare",
|
|
3088
|
+
// cf-ray header is conclusive on its own; body title is a belt-and-suspenders signal
|
|
3089
|
+
matchHeaders: (h) => !!(h.get("cf-ray") && (h.get("server") ?? "").toLowerCase().includes("cloudflare")),
|
|
3090
|
+
matchBody: (body) => /just a moment/i.test(body)
|
|
3091
|
+
},
|
|
3092
|
+
{
|
|
3093
|
+
name: "akamai",
|
|
3094
|
+
matchHeaders: (h) => (h.get("server") ?? "").toLowerCase().includes("akamaighost")
|
|
3095
|
+
}
|
|
3096
|
+
];
|
|
3097
|
+
function detectWafChallenge(headers, body) {
|
|
3098
|
+
for (const fp of WAF_CHALLENGE_FINGERPRINTS) {
|
|
3099
|
+
if (!fp.matchHeaders(headers)) continue;
|
|
3100
|
+
if (fp.matchBody && body !== void 0 && !fp.matchBody(body)) continue;
|
|
3101
|
+
return fp.name;
|
|
3102
|
+
}
|
|
3103
|
+
return null;
|
|
3104
|
+
}
|
|
3105
|
+
var MERGE_HEADERS = [
|
|
3106
|
+
"content-security-policy",
|
|
3107
|
+
"x-frame-options",
|
|
3108
|
+
"x-content-type-options",
|
|
3109
|
+
"permissions-policy",
|
|
3110
|
+
"referrer-policy",
|
|
3111
|
+
"cross-origin-resource-policy",
|
|
3112
|
+
"cross-origin-opener-policy",
|
|
3113
|
+
"cross-origin-embedder-policy"
|
|
3114
|
+
];
|
|
3115
|
+
var MAX_REDIRECT_HOPS = 3;
|
|
2962
3116
|
function detectCdnProvider(headers) {
|
|
2963
3117
|
if (headers.get("cf-ray") || headers.get("server")?.toLowerCase().includes("cloudflare")) {
|
|
2964
3118
|
return "Cloudflare";
|
|
@@ -2978,9 +3132,107 @@ function detectCdnProvider(headers) {
|
|
|
2978
3132
|
}
|
|
2979
3133
|
return null;
|
|
2980
3134
|
}
|
|
3135
|
+
async function fetchWithRedirects(url, timeoutMs) {
|
|
3136
|
+
let response = await fetch(url, {
|
|
3137
|
+
method: "HEAD",
|
|
3138
|
+
redirect: "manual",
|
|
3139
|
+
headers: { "User-Agent": SCANNER_USER_AGENT },
|
|
3140
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
3141
|
+
});
|
|
3142
|
+
for (let hop = 0; hop < MAX_REDIRECT_HOPS; hop++) {
|
|
3143
|
+
const status = response.status;
|
|
3144
|
+
const isRedirect = status >= 300 && status < 400 || response.type === "opaqueredirect" || status === 0 && response.headers.get("location");
|
|
3145
|
+
if (!isRedirect) break;
|
|
3146
|
+
const location = response.headers.get("location");
|
|
3147
|
+
if (!location) break;
|
|
3148
|
+
let nextUrl;
|
|
3149
|
+
try {
|
|
3150
|
+
nextUrl = new URL(location, response.url || void 0).href;
|
|
3151
|
+
} catch {
|
|
3152
|
+
break;
|
|
3153
|
+
}
|
|
3154
|
+
if (!nextUrl.startsWith("https://")) break;
|
|
3155
|
+
try {
|
|
3156
|
+
response = await fetch(nextUrl, {
|
|
3157
|
+
method: "HEAD",
|
|
3158
|
+
redirect: "manual",
|
|
3159
|
+
headers: { "User-Agent": SCANNER_USER_AGENT },
|
|
3160
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
3161
|
+
});
|
|
3162
|
+
} catch {
|
|
3163
|
+
break;
|
|
3164
|
+
}
|
|
3165
|
+
}
|
|
3166
|
+
return response;
|
|
3167
|
+
}
|
|
3168
|
+
function mergeSecurityHeaders(a, b) {
|
|
3169
|
+
const merged = new Headers();
|
|
3170
|
+
a.forEach((value, key) => merged.set(key, value));
|
|
3171
|
+
for (const header of MERGE_HEADERS) {
|
|
3172
|
+
if (!merged.has(header) && b.has(header)) {
|
|
3173
|
+
merged.set(header, b.get(header));
|
|
3174
|
+
}
|
|
3175
|
+
}
|
|
3176
|
+
b.forEach((value, key) => {
|
|
3177
|
+
if (!merged.has(key)) merged.set(key, value);
|
|
3178
|
+
});
|
|
3179
|
+
return merged;
|
|
3180
|
+
}
|
|
3181
|
+
async function dualFetchHeaders(domain, timeoutMs) {
|
|
3182
|
+
const url = `https://${domain}`;
|
|
3183
|
+
const results = await Promise.allSettled([fetchWithRedirects(url, timeoutMs), fetchWithRedirects(url, timeoutMs)]);
|
|
3184
|
+
const responses = results.filter((r) => r.status === "fulfilled").map((r) => r.value);
|
|
3185
|
+
if (responses.length === 0) return null;
|
|
3186
|
+
const usable = responses.filter((r) => r.ok || r.status >= 300 && r.status < 400);
|
|
3187
|
+
if (usable.length === 0) return null;
|
|
3188
|
+
if (usable.length === 1) {
|
|
3189
|
+
return { headers: usable[0].headers, ok: usable[0].ok, status: usable[0].status };
|
|
3190
|
+
}
|
|
3191
|
+
const merged = mergeSecurityHeaders(usable[0].headers, usable[1].headers);
|
|
3192
|
+
const primary = usable[0].ok ? usable[0] : usable[1].ok ? usable[1] : usable[0];
|
|
3193
|
+
return { headers: merged, ok: primary.ok, status: primary.status };
|
|
3194
|
+
}
|
|
3195
|
+
async function fetchBodyForWafDetection(url, timeoutMs) {
|
|
3196
|
+
try {
|
|
3197
|
+
const response = await fetch(url, {
|
|
3198
|
+
method: "GET",
|
|
3199
|
+
redirect: "manual",
|
|
3200
|
+
headers: { "User-Agent": SCANNER_USER_AGENT },
|
|
3201
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
3202
|
+
});
|
|
3203
|
+
return await response.text();
|
|
3204
|
+
} catch {
|
|
3205
|
+
return "";
|
|
3206
|
+
}
|
|
3207
|
+
}
|
|
2981
3208
|
async function checkHttpSecurity(domain) {
|
|
3209
|
+
const dualResult = await dualFetchHeaders(domain, HTTPS_TIMEOUT_MS);
|
|
3210
|
+
if (dualResult) {
|
|
3211
|
+
const headersForWaf = dualResult.headers;
|
|
3212
|
+
const needsBody = WAF_CHALLENGE_FINGERPRINTS.some((fp) => fp.matchHeaders(headersForWaf) && fp.matchBody);
|
|
3213
|
+
const body = needsBody ? await fetchBodyForWafDetection(`https://${domain}`, HTTPS_TIMEOUT_MS) : void 0;
|
|
3214
|
+
const wafName = detectWafChallenge(headersForWaf, body);
|
|
3215
|
+
if (wafName) {
|
|
3216
|
+
const finding = createFinding(
|
|
3217
|
+
"http_security",
|
|
3218
|
+
`${wafName.charAt(0).toUpperCase() + wafName.slice(1)} WAF challenge intercepted`,
|
|
3219
|
+
"info",
|
|
3220
|
+
`The fetched response appears to be a WAF/CDN challenge page, not the real site. Header analysis is inconclusive.`,
|
|
3221
|
+
{ wafChallenge: wafName, inconclusive: true }
|
|
3222
|
+
);
|
|
3223
|
+
const base = buildCheckResult("http_security", [finding]);
|
|
3224
|
+
return { ...base, score: 0, passed: false, checkStatus: "error" };
|
|
3225
|
+
}
|
|
3226
|
+
}
|
|
2982
3227
|
let capturedHeaders = null;
|
|
2983
3228
|
const capturingFetch = async (input, init) => {
|
|
3229
|
+
if (dualResult) {
|
|
3230
|
+
capturedHeaders = dualResult.headers;
|
|
3231
|
+
return new Response(null, {
|
|
3232
|
+
status: dualResult.status,
|
|
3233
|
+
headers: dualResult.headers
|
|
3234
|
+
});
|
|
3235
|
+
}
|
|
2984
3236
|
const response = await fetch(input, init);
|
|
2985
3237
|
capturedHeaders = response.headers;
|
|
2986
3238
|
return response;
|
|
@@ -3000,18 +3252,10 @@ async function checkHttpSecurity(domain) {
|
|
|
3000
3252
|
);
|
|
3001
3253
|
return { ...result, findings: [...result.findings, cdnFinding] };
|
|
3002
3254
|
}
|
|
3003
|
-
function makeQueryDNS12(dnsOptions) {
|
|
3004
|
-
return async (domain, type) => {
|
|
3005
|
-
if (type === "TXT") {
|
|
3006
|
-
return queryTxtRecords(domain, dnsOptions);
|
|
3007
|
-
}
|
|
3008
|
-
return queryDnsRecords(domain, type, dnsOptions);
|
|
3009
|
-
};
|
|
3010
|
-
}
|
|
3011
3255
|
async function checkDane(domain, dnsOptions) {
|
|
3012
3256
|
return checkDANE(
|
|
3013
3257
|
domain,
|
|
3014
|
-
|
|
3258
|
+
makeQueryDNS(dnsOptions),
|
|
3015
3259
|
{
|
|
3016
3260
|
timeout: dnsOptions?.timeoutMs ?? 5e3,
|
|
3017
3261
|
rawQueryDNS: async (d, type, dnssecFlag) => {
|
|
@@ -3021,18 +3265,10 @@ async function checkDane(domain, dnsOptions) {
|
|
|
3021
3265
|
}
|
|
3022
3266
|
);
|
|
3023
3267
|
}
|
|
3024
|
-
function makeQueryDNS13(dnsOptions) {
|
|
3025
|
-
return async (domain, type) => {
|
|
3026
|
-
if (type === "TXT") {
|
|
3027
|
-
return queryTxtRecords(domain, dnsOptions);
|
|
3028
|
-
}
|
|
3029
|
-
return queryDnsRecords(domain, type, dnsOptions);
|
|
3030
|
-
};
|
|
3031
|
-
}
|
|
3032
3268
|
async function checkDaneHttps(domain, dnsOptions) {
|
|
3033
3269
|
return checkDANEHTTPS(
|
|
3034
3270
|
domain,
|
|
3035
|
-
|
|
3271
|
+
makeQueryDNS(dnsOptions),
|
|
3036
3272
|
{
|
|
3037
3273
|
timeout: dnsOptions?.timeoutMs ?? 5e3,
|
|
3038
3274
|
rawQueryDNS: async (d, type, dnssecFlag) => {
|
|
@@ -3042,33 +3278,17 @@ async function checkDaneHttps(domain, dnsOptions) {
|
|
|
3042
3278
|
}
|
|
3043
3279
|
);
|
|
3044
3280
|
}
|
|
3045
|
-
function makeQueryDNS14(dnsOptions) {
|
|
3046
|
-
return async (domain, type) => {
|
|
3047
|
-
if (type === "TXT") {
|
|
3048
|
-
return queryTxtRecords(domain, dnsOptions);
|
|
3049
|
-
}
|
|
3050
|
-
return queryDnsRecords(domain, type, dnsOptions);
|
|
3051
|
-
};
|
|
3052
|
-
}
|
|
3053
3281
|
async function checkSvcbHttps(domain, dnsOptions) {
|
|
3054
3282
|
return checkSVCBHTTPS(
|
|
3055
3283
|
domain,
|
|
3056
|
-
|
|
3284
|
+
makeQueryDNS(dnsOptions),
|
|
3057
3285
|
{ timeout: dnsOptions?.timeoutMs ?? 5e3 }
|
|
3058
3286
|
);
|
|
3059
3287
|
}
|
|
3060
|
-
function makeQueryDNS15(dnsOptions) {
|
|
3061
|
-
return async (domain, type) => {
|
|
3062
|
-
if (type === "TXT") {
|
|
3063
|
-
return queryTxtRecords(domain, dnsOptions);
|
|
3064
|
-
}
|
|
3065
|
-
return queryDnsRecords(domain, type, dnsOptions);
|
|
3066
|
-
};
|
|
3067
|
-
}
|
|
3068
3288
|
async function checkSubdomailing(domain, dnsOptions) {
|
|
3069
3289
|
return checkSubdomailing$1(
|
|
3070
3290
|
domain,
|
|
3071
|
-
|
|
3291
|
+
makeQueryDNS(dnsOptions),
|
|
3072
3292
|
{ timeout: dnsOptions?.timeoutMs ?? 5e3 }
|
|
3073
3293
|
);
|
|
3074
3294
|
}
|
|
@@ -3133,6 +3353,11 @@ function upsertCheckResult(results, updated) {
|
|
|
3133
3353
|
return results.map((result) => result.category === updated.category ? updated : result);
|
|
3134
3354
|
}
|
|
3135
3355
|
async function addOutboundProviderInference(results, runtimeOptions) {
|
|
3356
|
+
const mxResult = results.find((result) => result.category === "mx");
|
|
3357
|
+
const providerDetectionFailed = Boolean(
|
|
3358
|
+
mxResult?.metadata?.providerDetectionFailed
|
|
3359
|
+
);
|
|
3360
|
+
if (providerDetectionFailed) return results;
|
|
3136
3361
|
const spfResult = results.find((result) => result.category === "spf");
|
|
3137
3362
|
const dkimResult = results.find((result) => result.category === "dkim");
|
|
3138
3363
|
const signalDomains = extractSpfSignalDomains(spfResult);
|
|
@@ -3268,6 +3493,31 @@ function adjustForNoSendDomain(results) {
|
|
|
3268
3493
|
return buildCheckResult(result.category, adjusted);
|
|
3269
3494
|
});
|
|
3270
3495
|
}
|
|
3496
|
+
var ADAPTIVE_WEIGHT_KV_TTL_SECONDS = 60;
|
|
3497
|
+
function awKey(profile, provider) {
|
|
3498
|
+
return `aw:${profile}:${provider}`;
|
|
3499
|
+
}
|
|
3500
|
+
async function publishAdaptiveWeightSummary(profile, provider, weights, kv) {
|
|
3501
|
+
try {
|
|
3502
|
+
await kv.put(awKey(profile, provider), JSON.stringify(weights), {
|
|
3503
|
+
expirationTtl: ADAPTIVE_WEIGHT_KV_TTL_SECONDS
|
|
3504
|
+
});
|
|
3505
|
+
} catch {
|
|
3506
|
+
logError("[profile-accumulator] KV publish failed", {
|
|
3507
|
+
severity: "warn",
|
|
3508
|
+
category: "profile-accumulator"
|
|
3509
|
+
});
|
|
3510
|
+
}
|
|
3511
|
+
}
|
|
3512
|
+
async function getAdaptiveWeights(profile, provider, kv) {
|
|
3513
|
+
try {
|
|
3514
|
+
const raw = await kv.get(awKey(profile, provider));
|
|
3515
|
+
if (!raw) return null;
|
|
3516
|
+
return JSON.parse(raw);
|
|
3517
|
+
} catch {
|
|
3518
|
+
return null;
|
|
3519
|
+
}
|
|
3520
|
+
}
|
|
3271
3521
|
|
|
3272
3522
|
// src/tools/scan/maturity-staging.ts
|
|
3273
3523
|
function capMaturityStage(maturity, score) {
|
|
@@ -3548,11 +3798,86 @@ function formatScanReport(result, format = "full") {
|
|
|
3548
3798
|
var CACHE_PREFIX = "cache:";
|
|
3549
3799
|
var PER_CHECK_TIMEOUT_MS = 8e3;
|
|
3550
3800
|
var SCAN_TIMEOUT_MS = 12e3;
|
|
3801
|
+
var RETRY_BUDGET_MS = 3e3;
|
|
3802
|
+
var MAX_RETRIES_PER_SCAN = 3;
|
|
3803
|
+
var RETRY_TIMEOUT_MS = 2500;
|
|
3551
3804
|
var adaptiveWeightCache = /* @__PURE__ */ new Map();
|
|
3552
3805
|
var ADAPTIVE_CACHE_TTL_MS = 6e4;
|
|
3553
3806
|
var ADAPTIVE_CACHE_MAX_ENTRIES = 100;
|
|
3554
3807
|
var ADAPTIVE_FETCH_TIMEOUT_MS = 200;
|
|
3808
|
+
function shouldRetry(result) {
|
|
3809
|
+
return result.checkStatus === "error" && result.score === 0;
|
|
3810
|
+
}
|
|
3811
|
+
async function runCheckRetry(category, domain, scanDns, runtimeOptions) {
|
|
3812
|
+
const retryDns = { ...scanDns, queryCache: /* @__PURE__ */ new Map() };
|
|
3813
|
+
const timeoutPromise = new Promise(
|
|
3814
|
+
(_, reject) => setTimeout(() => reject(new Error("Retry timed out")), RETRY_TIMEOUT_MS)
|
|
3815
|
+
);
|
|
3816
|
+
let checkPromise;
|
|
3817
|
+
switch (category) {
|
|
3818
|
+
case "spf":
|
|
3819
|
+
checkPromise = checkSpf(domain, retryDns);
|
|
3820
|
+
break;
|
|
3821
|
+
case "dmarc":
|
|
3822
|
+
checkPromise = checkDmarc(domain, retryDns);
|
|
3823
|
+
break;
|
|
3824
|
+
case "dkim":
|
|
3825
|
+
checkPromise = checkDkim(domain, void 0, retryDns);
|
|
3826
|
+
break;
|
|
3827
|
+
case "dnssec":
|
|
3828
|
+
checkPromise = checkDnssec2(domain, retryDns);
|
|
3829
|
+
break;
|
|
3830
|
+
case "ssl":
|
|
3831
|
+
checkPromise = checkSsl(domain);
|
|
3832
|
+
break;
|
|
3833
|
+
case "mta_sts":
|
|
3834
|
+
checkPromise = checkMtaSts(domain, retryDns);
|
|
3835
|
+
break;
|
|
3836
|
+
case "ns":
|
|
3837
|
+
checkPromise = checkNs(domain, retryDns);
|
|
3838
|
+
break;
|
|
3839
|
+
case "caa":
|
|
3840
|
+
checkPromise = checkCaa(domain, retryDns);
|
|
3841
|
+
break;
|
|
3842
|
+
case "bimi":
|
|
3843
|
+
checkPromise = checkBimi(domain, retryDns);
|
|
3844
|
+
break;
|
|
3845
|
+
case "tlsrpt":
|
|
3846
|
+
checkPromise = checkTlsrpt(domain, retryDns);
|
|
3847
|
+
break;
|
|
3848
|
+
case "subdomain_takeover":
|
|
3849
|
+
checkPromise = checkSubdomainTakeover(domain, retryDns);
|
|
3850
|
+
break;
|
|
3851
|
+
case "http_security":
|
|
3852
|
+
checkPromise = checkHttpSecurity(domain);
|
|
3853
|
+
break;
|
|
3854
|
+
case "dane":
|
|
3855
|
+
checkPromise = checkDane(domain, retryDns);
|
|
3856
|
+
break;
|
|
3857
|
+
case "dane_https":
|
|
3858
|
+
checkPromise = checkDaneHttps(domain, retryDns);
|
|
3859
|
+
break;
|
|
3860
|
+
case "svcb_https":
|
|
3861
|
+
checkPromise = checkSvcbHttps(domain, retryDns);
|
|
3862
|
+
break;
|
|
3863
|
+
case "subdomailing":
|
|
3864
|
+
checkPromise = checkSubdomailing(domain, retryDns);
|
|
3865
|
+
break;
|
|
3866
|
+
case "mx":
|
|
3867
|
+
checkPromise = checkMx(domain, {
|
|
3868
|
+
providerSignaturesUrl: runtimeOptions?.providerSignaturesUrl,
|
|
3869
|
+
providerSignaturesAllowedHosts: runtimeOptions?.providerSignaturesAllowedHosts,
|
|
3870
|
+
providerSignaturesSha256: runtimeOptions?.providerSignaturesSha256
|
|
3871
|
+
}, retryDns);
|
|
3872
|
+
break;
|
|
3873
|
+
default:
|
|
3874
|
+
return { ...buildCheckResult(category, []), score: 0, passed: false, checkStatus: "error" };
|
|
3875
|
+
}
|
|
3876
|
+
return Promise.race([checkPromise, timeoutPromise]);
|
|
3877
|
+
}
|
|
3555
3878
|
async function scanDomain(domain, kv, runtimeOptions) {
|
|
3879
|
+
crypto.randomUUID();
|
|
3880
|
+
const scanStartTime = Date.now();
|
|
3556
3881
|
const explicitProfile = runtimeOptions?.profile;
|
|
3557
3882
|
const isExplicit = explicitProfile && explicitProfile !== "auto";
|
|
3558
3883
|
const cacheKey = isExplicit ? `${CACHE_PREFIX}${domain}:profile:${explicitProfile}` : `${CACHE_PREFIX}${domain}`;
|
|
@@ -3642,6 +3967,21 @@ async function scanDomain(domain, kv, runtimeOptions) {
|
|
|
3642
3967
|
degradedStatuses.set(r.category, r.checkStatus);
|
|
3643
3968
|
}
|
|
3644
3969
|
}
|
|
3970
|
+
if (!timedOut && Date.now() - scanStartTime < SCAN_TIMEOUT_MS - RETRY_BUDGET_MS) {
|
|
3971
|
+
const retryable = checkResults.map((r, idx) => ({ r, idx })).filter(({ r }) => shouldRetry(r)).slice(0, MAX_RETRIES_PER_SCAN);
|
|
3972
|
+
if (retryable.length > 0) {
|
|
3973
|
+
const retrySettled = await Promise.allSettled(
|
|
3974
|
+
retryable.map(({ r }) => runCheckRetry(r.category, domain, scanDns, runtimeOptions))
|
|
3975
|
+
);
|
|
3976
|
+
for (let i = 0; i < retryable.length; i++) {
|
|
3977
|
+
const s = retrySettled[i];
|
|
3978
|
+
if (s.status === "fulfilled" && s.value.checkStatus !== "error" && s.value.score > 0) {
|
|
3979
|
+
checkResults[retryable[i].idx] = s.value;
|
|
3980
|
+
degradedStatuses.delete(retryable[i].r.category);
|
|
3981
|
+
}
|
|
3982
|
+
}
|
|
3983
|
+
}
|
|
3984
|
+
}
|
|
3645
3985
|
if (timedOut) {
|
|
3646
3986
|
const completedCategories = new Set(checkResults.map((r) => r.category));
|
|
3647
3987
|
for (const category of ALL_CHECK_CATEGORIES) {
|
|
@@ -3686,12 +4026,31 @@ async function scanDomain(domain, kv, runtimeOptions) {
|
|
|
3686
4026
|
}
|
|
3687
4027
|
const scoringContext = isExplicit ? domainContext : void 0;
|
|
3688
4028
|
let adaptiveResponse = null;
|
|
3689
|
-
|
|
4029
|
+
const adaptiveProvider = domainContext.detectedProvider ?? "";
|
|
4030
|
+
if (kv && adaptiveProvider) {
|
|
4031
|
+
const kvWeights = await getAdaptiveWeights(domainContext.profile, adaptiveProvider, kv);
|
|
4032
|
+
if (kvWeights) {
|
|
4033
|
+
adaptiveResponse = {
|
|
4034
|
+
profile: domainContext.profile,
|
|
4035
|
+
provider: adaptiveProvider,
|
|
4036
|
+
sampleCount: MATURITY_THRESHOLD,
|
|
4037
|
+
blendFactor: 1,
|
|
4038
|
+
weights: kvWeights,
|
|
4039
|
+
boundHits: []
|
|
4040
|
+
};
|
|
4041
|
+
}
|
|
4042
|
+
}
|
|
4043
|
+
if (!adaptiveResponse && runtimeOptions?.profileAccumulator) {
|
|
3690
4044
|
adaptiveResponse = await fetchAdaptiveWeights(
|
|
3691
4045
|
runtimeOptions.profileAccumulator,
|
|
3692
4046
|
domainContext.profile,
|
|
3693
4047
|
domainContext.detectedProvider
|
|
3694
4048
|
);
|
|
4049
|
+
if (adaptiveResponse && adaptiveProvider && kv && runtimeOptions.waitUntil) {
|
|
4050
|
+
runtimeOptions.waitUntil(
|
|
4051
|
+
publishAdaptiveWeightSummary(domainContext.profile, adaptiveProvider, adaptiveResponse.weights, kv)
|
|
4052
|
+
);
|
|
4053
|
+
}
|
|
3695
4054
|
}
|
|
3696
4055
|
if (adaptiveResponse?.boundHits.length) {
|
|
3697
4056
|
domainContext.signals.push(`adaptive bound hits: ${adaptiveResponse.boundHits.join(", ")}`);
|