blackveil-dns 2.2.2 → 2.3.4

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 CHANGED
@@ -9,7 +9,7 @@ Open-source DNS & email security scanner for Claude, Cursor, VS Code, and MCP cl
9
9
  [![GitHub stars](https://img.shields.io/github/stars/MadaBurns/bv-mcp?style=flat&logo=github)](https://github.com/MadaBurns/bv-mcp/stargazers)
10
10
  [![npm version](https://img.shields.io/npm/v/blackveil-dns)](https://www.npmjs.com/package/blackveil-dns)
11
11
  [![npm downloads](https://img.shields.io/npm/dm/blackveil-dns)](https://www.npmjs.com/package/blackveil-dns)
12
- [![Tests](https://img.shields.io/badge/Tests-1932-brightgreen)](https://github.com/MadaBurns/bv-mcp/actions)
12
+ [![Tests](https://img.shields.io/badge/Tests-2283-brightgreen)](https://github.com/MadaBurns/bv-mcp/actions)
13
13
  [![Coverage](https://img.shields.io/badge/Coverage-~90%25-brightgreen)](https://github.com/MadaBurns/bv-mcp/actions)
14
14
  [![BUSL-1.1 License](https://img.shields.io/badge/License-BUSL--1.1-blue.svg)](LICENSE)
15
15
  [![MCP](https://img.shields.io/badge/MCP-2025--03--26-blue)](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 41 tools available instantly. [Verify your download](https://blackveilsecurity.com/extensions/claude-dns#install).
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
- - **57+ 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
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
- 41 MCP tools · 7 prompts · 6 resources
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 explain_finding
90
- check_dkim check_caa check_lookalikes compare_baseline
91
- check_mta_sts check_ssl check_shadow_domains
92
- check_mx check_http_security
93
- check_mx_reputation check_dane Intelligence Remediation
94
- check_dane_https ────────────── ──────────────
95
- DNS Hygiene check_svcb_https get_benchmark generate_fix_plan
96
- ──────────── check_srv get_provider_ generate_spf_record
97
- check_txt_hygiene check_zone_hygiene insights generate_dmarc_record
98
- check_resolver_ assess_spoofability generate_dkim_config
99
- consistency map_supply_chain generate_mta_sts_policy
100
- resolve_spf_chain generate_rollout_plan
101
- discover_subdomains validate_fix
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.2.2";
194
+ declare const SERVER_VERSION = "2.3.4";
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 response = await fetch(url, {
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 fetch(url, {
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
- if (opts?.secondaryDoh?.endpoint) {
203
- const bvDns = await fetchDohResponse(
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.2.2";
574
+ var SERVER_VERSION = "2.3.4";
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
- const baseResult = await checkDNSSEC(
1110
- domain,
1111
- makeQueryDNS5(dnsOptions),
1112
- {
1113
- timeout: dnsOptions?.timeoutMs ?? 5e3,
1114
- rawQueryDNS: async (d, type, dnssecFlag) => {
1115
- const resp = await queryDns(d, type, dnssecFlag ?? false, dnsOptions);
1116
- return { AD: resp.AD, Answer: resp.Answer };
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
- 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");
1121
- if (dnssecAbsent) {
1122
- return baseResult;
1123
- }
1124
- const [dnskeyResult, dsResult] = await Promise.allSettled([
1125
- queryDnsRecords(domain, "DNSKEY", dnsOptions),
1126
- queryDnsRecords(domain, "DS", dnsOptions)
1127
- ]);
1128
- const hasDnskey = dnskeyResult.status === "fulfilled" && dnskeyResult.value.length > 0;
1129
- const hasDs = dsResult.status === "fulfilled" && dsResult.value.length > 0;
1130
- const dnssecSource = hasDnskey && hasDs ? "domain_configured" : "tld_inherited";
1131
- if (dnssecSource === "tld_inherited") {
1132
- const inheritedFinding = createFinding(
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 inherited from TLD",
1229
+ "DNSSEC configured by domain owner",
1135
1230
  "info",
1136
- `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.`,
1137
- { dnssecSource: "tld_inherited" }
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", [...baseResult.findings, inheritedFinding]);
1140
- }
1141
- if (baseResult.findings.length > 0) {
1142
- const [first, ...rest] = baseResult.findings;
1143
- const tagged = { ...first, metadata: { ...first.metadata ?? {}, dnssecSource: "domain_configured" } };
1144
- return buildCheckResult("dnssec", [tagged, ...rest]);
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
- return buildCheckResult("lookalikes", [
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. This check generates many DNS queries \u2014 try again shortly, as partial results are cached."
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: null,
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 findings = [createFinding(category, `${category.toUpperCase()} check error`, "high", `Check failed: ${safeMessage}`)];
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: "error" };
3883
+ return { ...result, score: 0, checkStatus };
3778
3884
  }
3779
3885
  }
3780
3886