blackveil-dns 2.2.2 → 2.3.5
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 +27 -19
- package/dist/index.d.ts +32 -1
- package/dist/index.js +225 -119
- package/dist/index.js.map +1 -1
- package/dist/stdio.js +389 -98
- package/dist/stdio.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -9,7 +9,7 @@ Open-source DNS & email security scanner for Claude, Cursor, VS Code, and MCP cl
|
|
|
9
9
|
[](https://github.com/MadaBurns/bv-mcp/stargazers)
|
|
10
10
|
[](https://www.npmjs.com/package/blackveil-dns)
|
|
11
11
|
[](https://www.npmjs.com/package/blackveil-dns)
|
|
12
|
-
[](https://github.com/MadaBurns/bv-mcp/actions)
|
|
13
13
|
[](https://github.com/MadaBurns/bv-mcp/actions)
|
|
14
14
|
[](LICENSE)
|
|
15
15
|
[](https://modelcontextprotocol.io/)
|
|
@@ -26,7 +26,7 @@ Open-source DNS & email security scanner for Claude, Cursor, VS Code, and MCP cl
|
|
|
26
26
|
|
|
27
27
|
**Claude Desktop** (one-click install):
|
|
28
28
|
|
|
29
|
-
Download the [Blackveil DNS extension](https://github.com/MadaBurns/bv-claude-dns/releases/latest/download/bv-claude-dns.mcpb) and open it — all
|
|
29
|
+
Download the [Blackveil DNS extension](https://github.com/MadaBurns/bv-claude-dns/releases/latest/download/bv-claude-dns.mcpb) and open it — all 44 tools available instantly. [Verify your download](https://blackveilsecurity.com/extensions/claude-dns#install).
|
|
30
30
|
|
|
31
31
|
**Claude Code** (one command):
|
|
32
32
|
|
|
@@ -66,7 +66,7 @@ Transport support:
|
|
|
66
66
|
|
|
67
67
|
## What you get
|
|
68
68
|
|
|
69
|
-
- **
|
|
69
|
+
- **80+ checks across 20 categories** — SPF, DMARC, DKIM, DNSSEC, SSL/TLS, MTA-STS, NS, CAA, MX, BIMI, TLS-RPT, subdomain takeover, lookalike domains, HTTP security headers, DANE, shadow domains, TXT hygiene, MX reputation, SRV, zone hygiene
|
|
70
70
|
- **Maturity staging** — Stage 0-4 classification (Unprotected to Hardened) with score-based capping to prevent inflated labels
|
|
71
71
|
- **Trust surface analysis** — detects shared SaaS platforms (Google, M365, SendGrid) and cross-references DMARC enforcement to determine real exposure
|
|
72
72
|
- **Guided remediation** — `generate_fix_plan` produces provider-aware prioritized actions; record generators output ready-to-publish records; `validate_fix` confirms whether a fix was applied successfully
|
|
@@ -81,25 +81,25 @@ Transport support:
|
|
|
81
81
|
## Tools
|
|
82
82
|
|
|
83
83
|
```
|
|
84
|
-
|
|
84
|
+
44 MCP tools · 7 prompts · 6 resources
|
|
85
85
|
|
|
86
86
|
Email Auth Infrastructure Brand & Threats Meta
|
|
87
|
-
──────────── ──────────────── ─────────────────
|
|
87
|
+
──────────── ──────────────── ───────────────── ──────────────────────
|
|
88
88
|
check_spf check_dnssec check_bimi scan_domain
|
|
89
|
-
check_dmarc check_ns check_tlsrpt
|
|
90
|
-
check_dkim check_caa check_lookalikes
|
|
91
|
-
check_mta_sts check_ssl check_shadow_domains
|
|
92
|
-
check_mx check_http_security
|
|
93
|
-
check_mx_reputation check_dane Intelligence
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
consistency map_supply_chain
|
|
100
|
-
resolve_spf_chain
|
|
101
|
-
discover_subdomains
|
|
102
|
-
map_compliance
|
|
89
|
+
check_dmarc check_ns check_tlsrpt batch_scan
|
|
90
|
+
check_dkim check_caa check_lookalikes compare_domains
|
|
91
|
+
check_mta_sts check_ssl check_shadow_domains compare_baseline
|
|
92
|
+
check_mx check_http_security explain_finding
|
|
93
|
+
check_mx_reputation check_dane Intelligence
|
|
94
|
+
check_subdomailing check_dane_https ────────────── Remediation
|
|
95
|
+
check_svcb_https get_benchmark ──────────────
|
|
96
|
+
DNS Hygiene check_srv get_provider_ generate_fix_plan
|
|
97
|
+
──────────── check_zone_hygiene insights generate_spf_record
|
|
98
|
+
check_txt_hygiene check_resolver_ assess_spoofability generate_dmarc_record
|
|
99
|
+
consistency map_supply_chain generate_dkim_config
|
|
100
|
+
resolve_spf_chain generate_mta_sts_policy
|
|
101
|
+
discover_subdomains generate_rollout_plan
|
|
102
|
+
map_compliance validate_fix
|
|
103
103
|
simulate_attack_paths
|
|
104
104
|
analyze_drift
|
|
105
105
|
|
|
@@ -184,6 +184,14 @@ For full hosted setup examples, stdio usage, and legacy fallback endpoints, see
|
|
|
184
184
|
|
|
185
185
|
---
|
|
186
186
|
|
|
187
|
+
## Responsible use
|
|
188
|
+
|
|
189
|
+
This tool is intended for **authorized security assessments** of domains you own or have explicit permission to test. Do not use it for unauthorized reconnaissance, harassment, or any activity that violates applicable laws. Findings from attack simulation, spoofability, and subdomain discovery tools should be used to **improve your own security posture**, not to exploit others.
|
|
190
|
+
|
|
191
|
+
If you discover a vulnerability in a third-party domain, please follow [coordinated disclosure](https://www.cisa.gov/coordinated-vulnerability-disclosure-process) practices.
|
|
192
|
+
|
|
193
|
+
---
|
|
194
|
+
|
|
187
195
|
<div align="center">
|
|
188
196
|
|
|
189
197
|
Built and maintained by [**BLACKVEIL**](https://blackveilsecurity.com) — NZ-owned cybersecurity consultancy.
|
package/dist/index.d.ts
CHANGED
|
@@ -3,6 +3,35 @@ export { CATEGORY_DISPLAY_WEIGHTS, CheckCategory, CheckResult, DomainContext, Do
|
|
|
3
3
|
import { z } from 'zod';
|
|
4
4
|
export { parseDmarcTags } from '@blackveil/dns-checks';
|
|
5
5
|
|
|
6
|
+
/**
|
|
7
|
+
* Promise-based counting semaphore for concurrency control.
|
|
8
|
+
*
|
|
9
|
+
* Used to cap concurrent outbound DoH fetches per isolate,
|
|
10
|
+
* preventing DNS/KV resource exhaustion during batch scans.
|
|
11
|
+
*
|
|
12
|
+
* Workers-compatible: uses only Promises and setTimeout, no Node.js APIs.
|
|
13
|
+
*/
|
|
14
|
+
interface SemaphoreOptions {
|
|
15
|
+
/** Maximum milliseconds a caller waits in the queue before rejection. */
|
|
16
|
+
maxWaitMs?: number;
|
|
17
|
+
}
|
|
18
|
+
declare class Semaphore {
|
|
19
|
+
private _active;
|
|
20
|
+
private readonly _queue;
|
|
21
|
+
private readonly maxConcurrent;
|
|
22
|
+
private readonly maxWaitMs?;
|
|
23
|
+
constructor(maxConcurrent: number, options?: SemaphoreOptions);
|
|
24
|
+
get active(): number;
|
|
25
|
+
get waiting(): number;
|
|
26
|
+
/** Acquire a semaphore slot. Returns a release function. */
|
|
27
|
+
acquire(): Promise<() => void>;
|
|
28
|
+
/** Run an async function within a semaphore-controlled slot. */
|
|
29
|
+
run<T>(fn: () => Promise<T>): Promise<T>;
|
|
30
|
+
/** Wait for all active and queued tasks to finish. */
|
|
31
|
+
drain(): Promise<void>;
|
|
32
|
+
private release;
|
|
33
|
+
}
|
|
34
|
+
|
|
6
35
|
/** Standard DNS record type codes */
|
|
7
36
|
declare const RecordType: {
|
|
8
37
|
readonly A: 1;
|
|
@@ -68,6 +97,8 @@ interface QueryDnsOptions {
|
|
|
68
97
|
queryCache?: Map<string, Promise<DohResponse>>;
|
|
69
98
|
/** Custom secondary DoH resolver. When set, used instead of Google DoH for empty-result confirmation. Falls back to Google if this resolver fails. */
|
|
70
99
|
secondaryDoh?: SecondaryDohConfig;
|
|
100
|
+
/** Semaphore for capping concurrent outbound DoH fetches per isolate. */
|
|
101
|
+
dnsSemaphore?: Semaphore;
|
|
71
102
|
}
|
|
72
103
|
|
|
73
104
|
/** Error thrown when a DNS query fails */
|
|
@@ -160,7 +191,7 @@ declare function sanitizeDomain(input: string): string;
|
|
|
160
191
|
declare function sanitizeInput(input: string, maxLength?: number): string;
|
|
161
192
|
|
|
162
193
|
/** Server version — keep in sync with package.json */
|
|
163
|
-
declare const SERVER_VERSION = "2.
|
|
194
|
+
declare const SERVER_VERSION = "2.3.5";
|
|
164
195
|
|
|
165
196
|
/**
|
|
166
197
|
* Map of every tool name to its Zod argument schema.
|
package/dist/index.js
CHANGED
|
@@ -97,6 +97,62 @@ z.object({
|
|
|
97
97
|
certData: z.string()
|
|
98
98
|
});
|
|
99
99
|
|
|
100
|
+
// src/lib/log.ts
|
|
101
|
+
var REDACTED = "[redacted]";
|
|
102
|
+
var MAX_LOG_STRING_LENGTH = 256;
|
|
103
|
+
var MAX_ERROR_STRING_LENGTH = 1024;
|
|
104
|
+
var SENSITIVE_KEY_PATTERN = /(^ip$|authorization|mcp-session-id|session|token|api[-_]?key|secret|password|cookie|rawbody)/i;
|
|
105
|
+
function isSensitiveKey(key) {
|
|
106
|
+
return !/^has[A-Z]/.test(key) && SENSITIVE_KEY_PATTERN.test(key);
|
|
107
|
+
}
|
|
108
|
+
function isPlainObject(value) {
|
|
109
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
110
|
+
}
|
|
111
|
+
function sanitizeString(value, maxLength = MAX_LOG_STRING_LENGTH) {
|
|
112
|
+
const stripped = value.replace(/[\x00-\x08\x0a-\x1f\x7f]/g, " ");
|
|
113
|
+
if (stripped.length <= maxLength) return stripped;
|
|
114
|
+
const half = Math.floor((maxLength - 5) / 2);
|
|
115
|
+
return `${stripped.slice(0, half)} ... ${stripped.slice(-half)}`;
|
|
116
|
+
}
|
|
117
|
+
function sanitizeLogValue(value, key, maxLength) {
|
|
118
|
+
if (key && isSensitiveKey(key)) {
|
|
119
|
+
return REDACTED;
|
|
120
|
+
}
|
|
121
|
+
if (typeof value === "string") {
|
|
122
|
+
return sanitizeString(value, maxLength);
|
|
123
|
+
}
|
|
124
|
+
if (Array.isArray(value)) {
|
|
125
|
+
return value.map((item) => sanitizeLogValue(item, void 0, maxLength));
|
|
126
|
+
}
|
|
127
|
+
if (isPlainObject(value)) {
|
|
128
|
+
const sanitized = {};
|
|
129
|
+
for (const [entryKey, entryValue] of Object.entries(value)) {
|
|
130
|
+
sanitized[entryKey] = sanitizeLogValue(entryValue, entryKey, maxLength);
|
|
131
|
+
}
|
|
132
|
+
return sanitized;
|
|
133
|
+
}
|
|
134
|
+
return value;
|
|
135
|
+
}
|
|
136
|
+
function logEvent(event) {
|
|
137
|
+
const isError = event.severity === "error";
|
|
138
|
+
const maxLen = isError ? MAX_ERROR_STRING_LENGTH : MAX_LOG_STRING_LENGTH;
|
|
139
|
+
const log = {
|
|
140
|
+
...event,
|
|
141
|
+
timestamp: event.timestamp || (/* @__PURE__ */ new Date()).toISOString(),
|
|
142
|
+
details: sanitizeLogValue(event.details, void 0, maxLen),
|
|
143
|
+
error: typeof event.error === "string" ? sanitizeString(event.error, maxLen) : event.error
|
|
144
|
+
};
|
|
145
|
+
console.log(JSON.stringify(log));
|
|
146
|
+
}
|
|
147
|
+
function logError(error, context) {
|
|
148
|
+
logEvent({
|
|
149
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
150
|
+
severity: "error",
|
|
151
|
+
error: typeof error === "string" ? error : error.message,
|
|
152
|
+
...context
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
100
156
|
// src/lib/dns-transport.ts
|
|
101
157
|
var DOH_ENDPOINT = "https://cloudflare-dns.com/dns-query";
|
|
102
158
|
var GOOGLE_DOH_ENDPOINT = "https://dns.google/resolve";
|
|
@@ -118,18 +174,25 @@ async function fetchDohResponse(url, timeoutMs, opts) {
|
|
|
118
174
|
try {
|
|
119
175
|
const headers = { Accept: "application/dns-json" };
|
|
120
176
|
if (opts?.token) headers["X-BV-Token"] = opts.token;
|
|
121
|
-
const
|
|
177
|
+
const doFetch = () => fetch(url, {
|
|
122
178
|
method: "GET",
|
|
123
179
|
headers,
|
|
124
180
|
signal: AbortSignal.timeout(timeoutMs),
|
|
125
181
|
...opts?.useEdgeCache ? { cf: { cacheTtl: DOH_EDGE_CACHE_TTL, cacheEverything: true } } : {}
|
|
126
182
|
});
|
|
183
|
+
const response = opts?.semaphore ? await opts.semaphore.run(doFetch) : await doFetch();
|
|
127
184
|
if (!response.ok) return null;
|
|
128
185
|
const data = await response.json();
|
|
129
186
|
const parsed = DohResponseSchema.safeParse(data);
|
|
130
187
|
if (!parsed.success) return null;
|
|
131
188
|
return parsed.data;
|
|
132
|
-
} catch {
|
|
189
|
+
} catch (err) {
|
|
190
|
+
const isTimeout = err instanceof DOMException && err.name === "TimeoutError";
|
|
191
|
+
logError(isTimeout ? "DNS fetch timeout" : "DNS fetch failed", {
|
|
192
|
+
severity: "warn",
|
|
193
|
+
category: "dns-transport",
|
|
194
|
+
details: { url: url.replace(/name=[^&]+/, "name=<domain>"), errorType: isTimeout ? "timeout" : "network" }
|
|
195
|
+
});
|
|
133
196
|
return null;
|
|
134
197
|
}
|
|
135
198
|
}
|
|
@@ -161,11 +224,13 @@ async function queryDnsUncached(domain, type, dnssecCheck = false, opts) {
|
|
|
161
224
|
const timeoutMs = opts?.timeoutMs ?? DNS_TIMEOUT_MS;
|
|
162
225
|
const retries = opts?.retries ?? DNS_RETRIES;
|
|
163
226
|
const confirmWithSecondaryOnEmpty = opts?.confirmWithSecondaryOnEmpty ?? DNS_CONFIRM_WITH_SECONDARY_ON_EMPTY;
|
|
227
|
+
const sem = opts?.dnsSemaphore;
|
|
164
228
|
const url = buildDohUrl(DOH_ENDPOINT, domain, type, dnssecCheck);
|
|
229
|
+
const guardedFetch = (input, init) => sem ? sem.run(() => fetch(input, init)) : fetch(input, init);
|
|
165
230
|
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
166
231
|
let response;
|
|
167
232
|
try {
|
|
168
|
-
response = await
|
|
233
|
+
response = await guardedFetch(url, {
|
|
169
234
|
method: "GET",
|
|
170
235
|
headers: { Accept: "application/dns-json" },
|
|
171
236
|
signal: AbortSignal.timeout(timeoutMs),
|
|
@@ -199,29 +264,44 @@ async function queryDnsUncached(domain, type, dnssecCheck = false, opts) {
|
|
|
199
264
|
}
|
|
200
265
|
const data = validated.data;
|
|
201
266
|
if (confirmWithSecondaryOnEmpty && !opts?.skipSecondaryConfirmation && !hasTypedAnswers(data, type)) {
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
buildDohUrl(opts.secondaryDoh.endpoint, domain, type, dnssecCheck),
|
|
205
|
-
timeoutMs,
|
|
206
|
-
{ token: opts.secondaryDoh.token }
|
|
207
|
-
);
|
|
208
|
-
if (bvDns && hasTypedAnswers(bvDns, type)) {
|
|
209
|
-
return bvDns;
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
const google = await fetchDohResponse(
|
|
213
|
-
buildDohUrl(GOOGLE_DOH_ENDPOINT, domain, type, dnssecCheck),
|
|
214
|
-
timeoutMs,
|
|
215
|
-
{ useEdgeCache: true }
|
|
216
|
-
);
|
|
217
|
-
if (google && hasTypedAnswers(google, type)) {
|
|
218
|
-
return google;
|
|
219
|
-
}
|
|
267
|
+
const secondaryResult = await confirmWithSecondaryResolvers(domain, type, dnssecCheck, timeoutMs, sem, opts);
|
|
268
|
+
if (secondaryResult) return secondaryResult;
|
|
220
269
|
}
|
|
221
270
|
return data;
|
|
222
271
|
}
|
|
223
272
|
throw new DnsQueryError("DNS query failed after retries", domain, type);
|
|
224
273
|
}
|
|
274
|
+
async function confirmWithSecondaryResolvers(domain, type, dnssecCheck, timeoutMs, sem, opts) {
|
|
275
|
+
const hasBvDns = !!opts?.secondaryDoh?.endpoint;
|
|
276
|
+
const googleUrl = buildDohUrl(GOOGLE_DOH_ENDPOINT, domain, type, dnssecCheck);
|
|
277
|
+
if (hasBvDns) {
|
|
278
|
+
const bvDnsUrl = buildDohUrl(opts.secondaryDoh.endpoint, domain, type, dnssecCheck);
|
|
279
|
+
const candidates = [
|
|
280
|
+
fetchDohResponse(bvDnsUrl, timeoutMs, { token: opts.secondaryDoh.token, semaphore: sem }),
|
|
281
|
+
fetchDohResponse(googleUrl, timeoutMs, { useEdgeCache: true, semaphore: sem })
|
|
282
|
+
];
|
|
283
|
+
const results = await Promise.allSettled(candidates);
|
|
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;
|
|
298
|
+
}
|
|
299
|
+
const google = await fetchDohResponse(googleUrl, timeoutMs, { useEdgeCache: true, semaphore: sem });
|
|
300
|
+
if (google && hasTypedAnswers(google, type)) {
|
|
301
|
+
return google;
|
|
302
|
+
}
|
|
303
|
+
return null;
|
|
304
|
+
}
|
|
225
305
|
|
|
226
306
|
// src/lib/dns-records.ts
|
|
227
307
|
async function queryDnsRecords(domain, type, opts) {
|
|
@@ -491,7 +571,7 @@ function sanitizeInput(input, maxLength = 500) {
|
|
|
491
571
|
}
|
|
492
572
|
|
|
493
573
|
// src/lib/server-version.ts
|
|
494
|
-
var SERVER_VERSION = "2.
|
|
574
|
+
var SERVER_VERSION = "2.3.5";
|
|
495
575
|
var DomainSchema = z.string().min(1).max(253);
|
|
496
576
|
z.string().regex(/^[0-9a-f]{64}$/);
|
|
497
577
|
var DkimSelectorSchema = z.string().transform((s) => s.trim().toLowerCase()).pipe(z.string().max(63).regex(/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/));
|
|
@@ -1106,51 +1186,66 @@ function makeQueryDNS5(dnsOptions) {
|
|
|
1106
1186
|
};
|
|
1107
1187
|
}
|
|
1108
1188
|
async function checkDnssec2(domain, dnsOptions) {
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1189
|
+
try {
|
|
1190
|
+
const baseResult = await checkDNSSEC(
|
|
1191
|
+
domain,
|
|
1192
|
+
makeQueryDNS5(dnsOptions),
|
|
1193
|
+
{
|
|
1194
|
+
timeout: dnsOptions?.timeoutMs ?? 5e3,
|
|
1195
|
+
rawQueryDNS: async (d, type, dnssecFlag) => {
|
|
1196
|
+
const resp = await queryDns(d, type, dnssecFlag ?? false, dnsOptions);
|
|
1197
|
+
return { AD: resp.AD, Answer: resp.Answer };
|
|
1198
|
+
}
|
|
1117
1199
|
}
|
|
1200
|
+
);
|
|
1201
|
+
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") || baseResult.findings.some((f) => f.title === "DNSSEC validation failing");
|
|
1202
|
+
if (dnssecAbsent) {
|
|
1203
|
+
return baseResult;
|
|
1118
1204
|
}
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1205
|
+
const [dnskeyResult, dsResult] = await Promise.allSettled([
|
|
1206
|
+
queryDnsRecords(domain, "DNSKEY", dnsOptions),
|
|
1207
|
+
queryDnsRecords(domain, "DS", dnsOptions)
|
|
1208
|
+
]);
|
|
1209
|
+
const hasDnskey = dnskeyResult.status === "fulfilled" && dnskeyResult.value.length > 0;
|
|
1210
|
+
const hasDs = dsResult.status === "fulfilled" && dsResult.value.length > 0;
|
|
1211
|
+
const dnssecSource = hasDnskey && hasDs ? "domain_configured" : "tld_inherited";
|
|
1212
|
+
if (dnssecSource === "tld_inherited") {
|
|
1213
|
+
const inheritedFinding = createFinding(
|
|
1214
|
+
"dnssec",
|
|
1215
|
+
"DNSSEC inherited from TLD",
|
|
1216
|
+
"info",
|
|
1217
|
+
`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.`,
|
|
1218
|
+
{ dnssecSource: "tld_inherited" }
|
|
1219
|
+
);
|
|
1220
|
+
return buildCheckResult("dnssec", [...baseResult.findings, inheritedFinding]);
|
|
1221
|
+
}
|
|
1222
|
+
if (baseResult.findings.length > 0) {
|
|
1223
|
+
const [first, ...rest] = baseResult.findings;
|
|
1224
|
+
const tagged = { ...first, metadata: { ...first.metadata ?? {}, dnssecSource: "domain_configured" } };
|
|
1225
|
+
return buildCheckResult("dnssec", [tagged, ...rest]);
|
|
1226
|
+
}
|
|
1227
|
+
const configuredFinding = createFinding(
|
|
1133
1228
|
"dnssec",
|
|
1134
|
-
"DNSSEC
|
|
1229
|
+
"DNSSEC configured by domain owner",
|
|
1135
1230
|
"info",
|
|
1136
|
-
|
|
1137
|
-
{ dnssecSource: "
|
|
1231
|
+
`${domain} has DNSKEY and DS records \u2014 DNSSEC is explicitly configured by the domain owner.`,
|
|
1232
|
+
{ dnssecSource: "domain_configured" }
|
|
1138
1233
|
);
|
|
1139
|
-
return buildCheckResult("dnssec", [
|
|
1140
|
-
}
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1234
|
+
return buildCheckResult("dnssec", [configuredFinding]);
|
|
1235
|
+
} catch (err) {
|
|
1236
|
+
if (err instanceof DnsQueryError) {
|
|
1237
|
+
return buildCheckResult("dnssec", [
|
|
1238
|
+
createFinding(
|
|
1239
|
+
"dnssec",
|
|
1240
|
+
"DNSSEC check could not complete",
|
|
1241
|
+
"info",
|
|
1242
|
+
`Unable to verify DNSSEC for ${domain} \u2014 DNS query failed: ${err.message}`,
|
|
1243
|
+
{ checkStatus: "error" }
|
|
1244
|
+
)
|
|
1245
|
+
]);
|
|
1246
|
+
}
|
|
1247
|
+
throw err;
|
|
1145
1248
|
}
|
|
1146
|
-
const configuredFinding = createFinding(
|
|
1147
|
-
"dnssec",
|
|
1148
|
-
"DNSSEC configured by domain owner",
|
|
1149
|
-
"info",
|
|
1150
|
-
`${domain} has DNSKEY and DS records \u2014 DNSSEC is explicitly configured by the domain owner.`,
|
|
1151
|
-
{ dnssecSource: "domain_configured" }
|
|
1152
|
-
);
|
|
1153
|
-
return buildCheckResult("dnssec", [configuredFinding]);
|
|
1154
1249
|
}
|
|
1155
1250
|
|
|
1156
1251
|
// src/tools/lookalike-analysis.ts
|
|
@@ -1393,14 +1488,16 @@ async function checkLookalikes(domain) {
|
|
|
1393
1488
|
checkLookalikesCore(domain),
|
|
1394
1489
|
new Promise((_, reject) => setTimeout(() => reject(new Error("Lookalike check timed out")), LOOKALIKE_TIMEOUT_MS))
|
|
1395
1490
|
]).catch(() => {
|
|
1396
|
-
|
|
1491
|
+
const result = buildCheckResult("lookalikes", [
|
|
1397
1492
|
createFinding(
|
|
1398
1493
|
"lookalikes",
|
|
1399
1494
|
"Lookalike check incomplete",
|
|
1400
1495
|
"info",
|
|
1401
|
-
"Lookalike check did not complete
|
|
1496
|
+
"Lookalike check did not complete within the time limit. Results may be incomplete \u2014 try again shortly."
|
|
1402
1497
|
)
|
|
1403
1498
|
]);
|
|
1499
|
+
result.partial = true;
|
|
1500
|
+
return result;
|
|
1404
1501
|
});
|
|
1405
1502
|
}
|
|
1406
1503
|
async function checkLookalikesCore(domain) {
|
|
@@ -1823,7 +1920,16 @@ async function checkMx(domain, options, dnsOptions) {
|
|
|
1823
1920
|
);
|
|
1824
1921
|
}
|
|
1825
1922
|
}
|
|
1826
|
-
} catch {
|
|
1923
|
+
} catch (err) {
|
|
1924
|
+
logEvent({
|
|
1925
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1926
|
+
severity: "warn",
|
|
1927
|
+
category: "provider-detection",
|
|
1928
|
+
domain,
|
|
1929
|
+
tool: "check_mx",
|
|
1930
|
+
error: err instanceof Error ? err.message : String(err),
|
|
1931
|
+
details: { phase: "provider_detection" }
|
|
1932
|
+
});
|
|
1827
1933
|
}
|
|
1828
1934
|
return { ...baseResult, findings };
|
|
1829
1935
|
}
|
|
@@ -2683,57 +2789,6 @@ function applyInteractionPenalties(score, config) {
|
|
|
2683
2789
|
};
|
|
2684
2790
|
}
|
|
2685
2791
|
|
|
2686
|
-
// src/lib/log.ts
|
|
2687
|
-
var REDACTED = "[redacted]";
|
|
2688
|
-
var MAX_LOG_STRING_LENGTH = 256;
|
|
2689
|
-
var SENSITIVE_KEY_PATTERN = /(^ip$|authorization|mcp-session-id|session|token|api[-_]?key|secret|password|cookie|rawbody)/i;
|
|
2690
|
-
function isSensitiveKey(key) {
|
|
2691
|
-
return !/^has[A-Z]/.test(key) && SENSITIVE_KEY_PATTERN.test(key);
|
|
2692
|
-
}
|
|
2693
|
-
function isPlainObject(value) {
|
|
2694
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2695
|
-
}
|
|
2696
|
-
function sanitizeString(value) {
|
|
2697
|
-
const stripped = value.replace(/[\x00-\x08\x0a-\x1f\x7f]/g, " ");
|
|
2698
|
-
return stripped.length > MAX_LOG_STRING_LENGTH ? `${stripped.slice(0, MAX_LOG_STRING_LENGTH)}...` : stripped;
|
|
2699
|
-
}
|
|
2700
|
-
function sanitizeLogValue(value, key) {
|
|
2701
|
-
if (key && isSensitiveKey(key)) {
|
|
2702
|
-
return REDACTED;
|
|
2703
|
-
}
|
|
2704
|
-
if (typeof value === "string") {
|
|
2705
|
-
return sanitizeString(value);
|
|
2706
|
-
}
|
|
2707
|
-
if (Array.isArray(value)) {
|
|
2708
|
-
return value.map((item) => sanitizeLogValue(item));
|
|
2709
|
-
}
|
|
2710
|
-
if (isPlainObject(value)) {
|
|
2711
|
-
const sanitized = {};
|
|
2712
|
-
for (const [entryKey, entryValue] of Object.entries(value)) {
|
|
2713
|
-
sanitized[entryKey] = sanitizeLogValue(entryValue, entryKey);
|
|
2714
|
-
}
|
|
2715
|
-
return sanitized;
|
|
2716
|
-
}
|
|
2717
|
-
return value;
|
|
2718
|
-
}
|
|
2719
|
-
function logEvent(event) {
|
|
2720
|
-
const log = {
|
|
2721
|
-
...event,
|
|
2722
|
-
timestamp: event.timestamp || (/* @__PURE__ */ new Date()).toISOString(),
|
|
2723
|
-
details: sanitizeLogValue(event.details),
|
|
2724
|
-
error: typeof event.error === "string" ? sanitizeString(event.error) : event.error
|
|
2725
|
-
};
|
|
2726
|
-
console.log(JSON.stringify(log));
|
|
2727
|
-
}
|
|
2728
|
-
function logError(error, context) {
|
|
2729
|
-
logEvent({
|
|
2730
|
-
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2731
|
-
severity: "error",
|
|
2732
|
-
error: typeof error === "string" ? error : error.message,
|
|
2733
|
-
...context
|
|
2734
|
-
});
|
|
2735
|
-
}
|
|
2736
|
-
|
|
2737
2792
|
// src/lib/cache.ts
|
|
2738
2793
|
var DEFAULT_TTL_MS = 5 * 60 * 1e3;
|
|
2739
2794
|
var DEFAULT_TTL_SECONDS = 300;
|
|
@@ -2852,10 +2907,37 @@ async function runWithCache(key, run, kv, ttlSeconds, skipCache) {
|
|
|
2852
2907
|
}
|
|
2853
2908
|
const existing = INFLIGHT.get(key);
|
|
2854
2909
|
if (existing) return existing;
|
|
2910
|
+
if (kv && !skipCache) {
|
|
2911
|
+
const sentinelKey = `${key}:computing`;
|
|
2912
|
+
try {
|
|
2913
|
+
const sentinel = await kv.get(sentinelKey);
|
|
2914
|
+
if (sentinel) {
|
|
2915
|
+
const polled = await pollForResult(key, kv);
|
|
2916
|
+
if (polled !== void 0) return polled;
|
|
2917
|
+
} else {
|
|
2918
|
+
await kv.put(sentinelKey, String(Date.now()), { expirationTtl: SENTINEL_TTL_SECONDS });
|
|
2919
|
+
}
|
|
2920
|
+
} catch {
|
|
2921
|
+
}
|
|
2922
|
+
}
|
|
2855
2923
|
const cleanup = setTimeout(() => INFLIGHT.delete(key), INFLIGHT_CLEANUP_MS);
|
|
2856
2924
|
const promise = run().then(async (result) => {
|
|
2857
2925
|
await cacheSet(key, result, kv, ttlSeconds);
|
|
2926
|
+
if (kv) {
|
|
2927
|
+
try {
|
|
2928
|
+
await kv.delete(`${key}:computing`);
|
|
2929
|
+
} catch {
|
|
2930
|
+
}
|
|
2931
|
+
}
|
|
2858
2932
|
return result;
|
|
2933
|
+
}).catch(async (err) => {
|
|
2934
|
+
if (kv) {
|
|
2935
|
+
try {
|
|
2936
|
+
await kv.delete(`${key}:computing`);
|
|
2937
|
+
} catch {
|
|
2938
|
+
}
|
|
2939
|
+
}
|
|
2940
|
+
throw err;
|
|
2859
2941
|
}).finally(() => {
|
|
2860
2942
|
clearTimeout(cleanup);
|
|
2861
2943
|
INFLIGHT.delete(key);
|
|
@@ -2863,6 +2945,20 @@ async function runWithCache(key, run, kv, ttlSeconds, skipCache) {
|
|
|
2863
2945
|
INFLIGHT.set(key, promise);
|
|
2864
2946
|
return promise;
|
|
2865
2947
|
}
|
|
2948
|
+
var SENTINEL_TTL_SECONDS = 10;
|
|
2949
|
+
var POLL_DELAYS_MS = [250, 500, 750];
|
|
2950
|
+
async function pollForResult(key, kv) {
|
|
2951
|
+
for (const delay of POLL_DELAYS_MS) {
|
|
2952
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
2953
|
+
try {
|
|
2954
|
+
const val = await kv.get(key, "json");
|
|
2955
|
+
if (val !== null) return val;
|
|
2956
|
+
} catch {
|
|
2957
|
+
return void 0;
|
|
2958
|
+
}
|
|
2959
|
+
}
|
|
2960
|
+
return void 0;
|
|
2961
|
+
}
|
|
2866
2962
|
function detectCdnProvider(headers) {
|
|
2867
2963
|
if (headers.get("cf-ray") || headers.get("server")?.toLowerCase().includes("cloudflare")) {
|
|
2868
2964
|
return "Cloudflare";
|
|
@@ -3669,7 +3765,12 @@ async function scanDomain(domain, kv, runtimeOptions) {
|
|
|
3669
3765
|
})();
|
|
3670
3766
|
if (runtimeOptions.waitUntil) runtimeOptions.waitUntil(telemetryPromise);
|
|
3671
3767
|
}
|
|
3672
|
-
} catch {
|
|
3768
|
+
} catch (postProcessError) {
|
|
3769
|
+
logError(postProcessError instanceof Error ? postProcessError : String(postProcessError), {
|
|
3770
|
+
category: "scan-domain",
|
|
3771
|
+
domain,
|
|
3772
|
+
details: { phase: "post-processing", checksCompleted: checkResults.length }
|
|
3773
|
+
});
|
|
3673
3774
|
if (degradedStatuses.size > 0) {
|
|
3674
3775
|
checkResults = checkResults.map((r) => {
|
|
3675
3776
|
const status = degradedStatuses.get(r.category);
|
|
@@ -3697,7 +3798,7 @@ async function scanDomain(domain, kv, runtimeOptions) {
|
|
|
3697
3798
|
context: fallbackContext,
|
|
3698
3799
|
cached: false,
|
|
3699
3800
|
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3700
|
-
scoringNote:
|
|
3801
|
+
scoringNote: "Post-processing encountered an error; results may be approximate",
|
|
3701
3802
|
adaptiveWeightDeltas: null,
|
|
3702
3803
|
interactionEffects: []
|
|
3703
3804
|
};
|
|
@@ -3770,11 +3871,16 @@ async function safeCheck(category, fn) {
|
|
|
3770
3871
|
return result;
|
|
3771
3872
|
} catch (err) {
|
|
3772
3873
|
const rawMessage = err instanceof Error ? err.message : "Check failed";
|
|
3874
|
+
const isTimeout = rawMessage === "Check timed out";
|
|
3773
3875
|
const SAFE_PREFIXES = ["DNS query", "Check timed out", "Check failed", "Connection", "timeout"];
|
|
3774
3876
|
const safeMessage = SAFE_PREFIXES.some((p) => rawMessage.startsWith(p)) ? rawMessage : "Check failed";
|
|
3775
|
-
const
|
|
3877
|
+
const severity = isTimeout ? "low" : "high";
|
|
3878
|
+
const title = isTimeout ? `${category.toUpperCase()} check timed out` : `${category.toUpperCase()} check error`;
|
|
3879
|
+
const detail = isTimeout ? `Check did not complete within the ${PER_CHECK_TIMEOUT_MS / 1e3}s per-check time limit. Try running this check individually.` : `Check failed: ${safeMessage}`;
|
|
3880
|
+
const checkStatus = isTimeout ? "timeout" : "error";
|
|
3881
|
+
const findings = [createFinding(category, title, severity, detail)];
|
|
3776
3882
|
const result = buildCheckResult(category, findings);
|
|
3777
|
-
return { ...result, score: 0, checkStatus
|
|
3883
|
+
return { ...result, score: 0, checkStatus };
|
|
3778
3884
|
}
|
|
3779
3885
|
}
|
|
3780
3886
|
|