@slicedearth/whoisleuth-cli 2.2.0 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (282) hide show
  1. package/NOTICE +6 -0
  2. package/README.md +20 -15
  3. package/bin/whoisleuth.mjs +8 -1
  4. package/cli/archive-content-digest.mjs +14 -0
  5. package/cli/archive-inspect.mjs +20 -53
  6. package/cli/arguments.mjs +454 -1748
  7. package/cli/artifact-validation/case-response.mjs +66 -35
  8. package/cli/artifact-validation/investigation-capsule.mjs +52 -31
  9. package/cli/artifact-validation/lookup-evidence.mjs +12 -6
  10. package/cli/artifact-validation/signed-review.mjs +112 -72
  11. package/cli/artifact-validation/structure-primitives.mjs +1 -105
  12. package/cli/artifact-verify.mjs +90 -24
  13. package/cli/assurance-command-runner.mjs +39 -4
  14. package/cli/bagit-folder.mjs +57 -0
  15. package/cli/bagit-review.mjs +23 -0
  16. package/cli/bounded-json.mjs +1 -1
  17. package/cli/bulk-checkpoint.mjs +34 -3
  18. package/cli/bulk-command-runner.mjs +2 -0
  19. package/cli/bulk-output.mjs +29 -3
  20. package/cli/case-command.mjs +195 -0
  21. package/cli/case-pack.mjs +66 -111
  22. package/cli/command-argument-grammar.mjs +215 -0
  23. package/cli/command-reference.mjs +342 -285
  24. package/cli/completion.mjs +303 -80
  25. package/cli/config-profile.mjs +7 -5
  26. package/cli/ct-event-intake.mjs +12 -12
  27. package/cli/discover.mjs +4 -3
  28. package/cli/discovery-observation-snapshot.mjs +59 -18
  29. package/cli/discovery-scan-command-runner.mjs +21 -0
  30. package/cli/discovery-scan.mjs +13 -4
  31. package/cli/discovery-workflow.mjs +1 -1
  32. package/cli/domain-control-monitor.mjs +50 -20
  33. package/cli/domain-control-observations.mjs +149 -102
  34. package/cli/errors.mjs +33 -1
  35. package/cli/evidence-command-runner.mjs +16 -5
  36. package/cli/evidence-signing.mjs +2 -2
  37. package/cli/external-observation-mapping.mjs +7 -8
  38. package/cli/formatters/terminal-lookup.mjs +18 -46
  39. package/cli/formatters/terminal-shared.mjs +5 -3
  40. package/cli/formatters/terminal-technology.mjs +114 -0
  41. package/cli/inline-command-families.mjs +2 -0
  42. package/cli/input.mjs +116 -0
  43. package/cli/interactive-launcher.mjs +2 -160
  44. package/cli/interchange-report.mjs +18 -8
  45. package/cli/investigation-artifacts.mjs +176 -0
  46. package/cli/investigation-checkpoint.mjs +10 -0
  47. package/cli/investigation-folder.mjs +114 -0
  48. package/cli/investigation-manifest.mjs +1 -112
  49. package/cli/investigation-package-review.mjs +103 -0
  50. package/cli/investigation-plan.mjs +32 -19
  51. package/cli/investigation-run.mjs +188 -33
  52. package/cli/local-document-checkpoint.mjs +170 -0
  53. package/cli/lookalike-calibration.mjs +7 -2
  54. package/cli/lookup-brief.mjs +4 -2
  55. package/cli/lookup-browser-view.mjs +1 -1
  56. package/cli/lookup-browser.mjs +5 -12
  57. package/cli/lookup-command-runner.mjs +17 -6
  58. package/cli/lookup-diff.mjs +37 -3
  59. package/cli/lookup-plan.mjs +11 -5
  60. package/cli/lookup-reconcile.mjs +10 -8
  61. package/cli/mail-header-review.mjs +467 -0
  62. package/cli/mail-review.mjs +66 -14
  63. package/cli/manual.mjs +1 -0
  64. package/cli/network-command-runner.mjs +2 -0
  65. package/cli/output-file.mjs +31 -5
  66. package/cli/registry-doctor.mjs +3 -1
  67. package/cli/registry-support.mjs +1 -1
  68. package/cli/retained-artifact-diff.mjs +2 -2
  69. package/cli/review-command-runner.mjs +70 -0
  70. package/cli/risk-calibration.mjs +19 -17
  71. package/cli/runner.mjs +62 -7
  72. package/cli/sharing-review.mjs +21 -34
  73. package/cli/signer-trust.mjs +104 -0
  74. package/cli/source-reliability.mjs +74 -83
  75. package/cli/terminal-input.mjs +168 -0
  76. package/cli/workflow-command-runner.mjs +68 -13
  77. package/docs/cli-reference.md +77 -24
  78. package/docs/cli.md +300 -25
  79. package/lib/abort.mjs +4 -1
  80. package/lib/availability.mjs +161 -112
  81. package/lib/bounded-file.mjs +3 -1
  82. package/lib/bounded-html-document.mjs +125 -0
  83. package/lib/bounded-json.mjs +51 -8
  84. package/lib/browser-library-profile.mjs +42 -51
  85. package/lib/browser-library-worker.mjs +112 -0
  86. package/lib/client-behavior-profile.mjs +3 -3
  87. package/lib/ct-search.mjs +11 -4
  88. package/lib/dns-convergence-review.mjs +1 -0
  89. package/lib/dns-delegation-health.mjs +24 -14
  90. package/lib/dns-inheritance-review.mjs +217 -0
  91. package/lib/dns-intelligence.mjs +73 -26
  92. package/lib/dns-mx.mjs +1 -1
  93. package/lib/dnssec-chain-validation.mjs +6 -0
  94. package/lib/dnssec-evidence-validation.mjs +11 -3
  95. package/lib/domain-change-packet.mjs +49 -16
  96. package/lib/domain-change-review.mjs +14 -8
  97. package/lib/domain-control-flight-recorder.mjs +221 -117
  98. package/lib/domain-control-manifest.mjs +129 -69
  99. package/lib/domain-posture-analysis.mjs +20 -7
  100. package/lib/domain-posture-parsers.mjs +201 -8
  101. package/lib/domain-posture.mjs +147 -29
  102. package/lib/evidence-export-privacy.mjs +561 -0
  103. package/lib/evidence-export.mjs +11 -543
  104. package/lib/evidence-report.mjs +5 -3
  105. package/lib/favicon.mjs +117 -41
  106. package/lib/generated/cisa-kev-catalog.mjs +12 -4
  107. package/lib/generated/registrar-standing-catalogue.mjs +7 -7
  108. package/lib/generated/retire-browser-catalog.mjs +2335 -940
  109. package/lib/hostname.mjs +2 -38
  110. package/lib/html-signals.mjs +111 -108
  111. package/lib/http-evidence-bounds.mjs +2 -1
  112. package/lib/http-intelligence.mjs +39 -26
  113. package/lib/local-geoip-evidence.mjs +19 -3
  114. package/lib/lookup-cache.mjs +34 -11
  115. package/lib/lookup-child-profile-contract.mjs +24 -15
  116. package/lib/lookup-diagnostics.mjs +24 -1
  117. package/lib/lookup-progress.mjs +30 -13
  118. package/lib/lookup-response-contract.mjs +1048 -0
  119. package/lib/lookup-response.mjs +8 -10
  120. package/lib/lookup-source-progress.mjs +40 -14
  121. package/lib/lookup.mjs +48 -27
  122. package/lib/nameserver-preflight-review.mjs +1 -0
  123. package/lib/observed-network-context.mjs +43 -27
  124. package/lib/outbound-request-bounds.mjs +4 -1
  125. package/lib/page-fingerprints.mjs +69 -133
  126. package/lib/page-role-profile.mjs +12 -8
  127. package/lib/perceptual-hash.mjs +43 -14
  128. package/lib/rdap-bootstrap.mjs +72 -22
  129. package/lib/rdap-capabilities.mjs +1 -1
  130. package/lib/rdap-client.mjs +15 -7
  131. package/lib/rdap-normalization.mjs +33 -18
  132. package/lib/rdap-search-workbench.mjs +1 -1
  133. package/lib/rdap-transport.mjs +47 -20
  134. package/lib/rdap-types.mjs +2 -1
  135. package/lib/rdap.mjs +18 -3
  136. package/lib/registrable-domain.mjs +21 -1
  137. package/lib/registrar-standing-contract.mjs +1 -0
  138. package/lib/registry-capabilities.mjs +5 -1
  139. package/lib/registry-capability-catalogue.mjs +2 -1
  140. package/lib/registry-dates.mjs +2 -190
  141. package/lib/registry-insights.mjs +85 -34
  142. package/lib/response-policy.mjs +86 -48
  143. package/lib/risk-calibration-summary.mjs +18 -6
  144. package/lib/rpki-evidence.mjs +8 -5
  145. package/lib/safe-fetch.mjs +54 -47
  146. package/lib/security-txt.mjs +42 -7
  147. package/lib/service-binding-dns.mjs +8 -0
  148. package/lib/smtp-transport-review.mjs +27 -15
  149. package/lib/sslbl-certificates.generated.mjs +136 -135
  150. package/lib/static-html-analysis.mjs +178 -151
  151. package/lib/structured-data-identity.mjs +1 -0
  152. package/lib/technology-markup.mjs +100 -0
  153. package/lib/threat-intelligence-manifests.mjs +11 -139
  154. package/lib/threat-intelligence-runtime.mjs +24 -52
  155. package/lib/threat-intelligence-targets.mjs +6 -34
  156. package/lib/threat-intelligence-types.mjs +39 -0
  157. package/lib/typosquat-generator.mjs +19 -15
  158. package/lib/urlhaus-intelligence.mjs +8 -3
  159. package/lib/urlscan-intelligence.mjs +1 -1
  160. package/lib/website-security-posture.mjs +30 -6
  161. package/lib/website-technology.mjs +153 -45
  162. package/lib/whois-authority.mjs +14 -2
  163. package/lib/whois-chain.mjs +9 -2
  164. package/lib/whois-common-formats.mjs +5 -5
  165. package/lib/whois-contracts.mjs +2 -1
  166. package/lib/whois-dialects.mjs +2 -2
  167. package/lib/whois-parser-support.mjs +8 -1
  168. package/lib/whois-transport.mjs +23 -7
  169. package/lib/zone-intent-review.mjs +16 -6
  170. package/package.json +2 -1
  171. package/packages/cases/case-attachment-model.mjs +83 -0
  172. package/packages/cases/case-evidence-model.mjs +156 -62
  173. package/packages/cases/case-export-input.mjs +53 -0
  174. package/packages/cases/case-migration-model.mjs +127 -44
  175. package/packages/cases/case-model.mjs +1 -0
  176. package/packages/cases/case-recheck-model.mjs +91 -0
  177. package/packages/cases/case-record-contracts.mjs +5 -20
  178. package/packages/cases/case-record-core.mjs +12 -22
  179. package/packages/cases/case-record-decisions.mjs +72 -0
  180. package/packages/cases/case-record-model.mjs +1 -0
  181. package/packages/cases/case-record-operations.mjs +95 -31
  182. package/packages/cases/case-record-projection.mjs +221 -0
  183. package/packages/cases/case-report.mjs +70 -54
  184. package/packages/cases/case-response-actions.mjs +654 -0
  185. package/packages/cases/case-response-model.mjs +64 -1115
  186. package/packages/cases/case-response-outcomes.mjs +317 -0
  187. package/packages/cases/case-response-packet-render.mjs +153 -0
  188. package/packages/cases/case-response-packet-types.mjs +1 -0
  189. package/packages/cases/case-response-packet-vocabulary.mjs +48 -0
  190. package/packages/cases/case-response-packet.mjs +74 -572
  191. package/packages/cases/case-response-records.mjs +94 -0
  192. package/packages/cases/case-response-review-inputs.mjs +371 -0
  193. package/packages/cases/case-response-values.mjs +85 -0
  194. package/packages/cases/case-review-package.mjs +45 -0
  195. package/packages/cases/case-selection.mjs +46 -0
  196. package/packages/cases/case-storage-model.mjs +31 -2
  197. package/packages/cases/response-route-freshness.mjs +16 -0
  198. package/packages/comparison/capture-context.mjs +61 -0
  199. package/packages/comparison/comparison-ledger-bulk.mjs +13 -11
  200. package/packages/comparison/page-similarity.mjs +5 -2
  201. package/packages/comparison/relationship-evidence.mjs +85 -8
  202. package/packages/comparison/relationship-provenance.mjs +93 -0
  203. package/packages/contracts/analyst-interchange.mjs +216 -0
  204. package/packages/contracts/analyst-review-state-contract.mjs +11 -0
  205. package/packages/contracts/analyst-review-state.mjs +5 -4
  206. package/packages/contracts/case-portability.mjs +366 -83
  207. package/packages/contracts/case-views-contract.mjs +12 -0
  208. package/packages/contracts/case-views.mjs +26 -0
  209. package/packages/contracts/cli-command-semantics.mjs +94 -0
  210. package/packages/contracts/cli-lookup.mjs +0 -1
  211. package/packages/contracts/domain-control-flight-recorder.mjs +80 -42
  212. package/packages/contracts/domain-control-manifest.mjs +127 -73
  213. package/packages/contracts/domain-control-monitor.mjs +48 -45
  214. package/packages/contracts/domain-control-review.mjs +179 -121
  215. package/packages/contracts/domain-name.mjs +21 -0
  216. package/packages/contracts/external-observation-interchange.mjs +2 -1
  217. package/packages/contracts/extracted-domain-lifecycle.mjs +11 -32
  218. package/packages/contracts/http-url.mjs +2 -0
  219. package/packages/contracts/investigation-package-limits.mjs +24 -0
  220. package/packages/contracts/investigation-portability.mjs +56 -27
  221. package/packages/contracts/investigation-run.mjs +16 -0
  222. package/packages/contracts/ip-address.mjs +34 -0
  223. package/packages/contracts/lookup-evidence.mjs +35 -0
  224. package/packages/contracts/offline-comparison.mjs +1 -2
  225. package/packages/contracts/page-fingerprints.mjs +15 -0
  226. package/packages/contracts/risk-calibration.mjs +15 -11
  227. package/packages/contracts/schema-compatibility.mjs +1 -1
  228. package/packages/contracts/schema-lifecycle.mjs +83 -153
  229. package/packages/contracts/selected-file-limits.mjs +6 -0
  230. package/packages/contracts/vulnerability-identifiers.mjs +5 -0
  231. package/packages/contracts/web-capture.mjs +10 -0
  232. package/packages/contracts/workspace-portability.mjs +183 -211
  233. package/packages/evidence/artifact-integrity.mjs +8 -2
  234. package/packages/evidence/artifact-structure.mjs +105 -0
  235. package/packages/evidence/decision-fact.mjs +4 -1
  236. package/packages/evidence/domain-control-runtime.mjs +201 -33
  237. package/packages/evidence/domain-posture-context.mjs +79 -0
  238. package/packages/evidence/image-regions.mjs +56 -0
  239. package/packages/evidence/latest-observations.mjs +28 -0
  240. package/packages/evidence/lookup-target.mjs +104 -0
  241. package/packages/evidence/observation.mjs +16 -1
  242. package/packages/evidence/passphrase-encryption.mjs +44 -0
  243. package/packages/evidence/record-identity.mjs +72 -0
  244. package/packages/evidence/registry-dates.mjs +190 -0
  245. package/packages/evidence/retained-file.mjs +69 -0
  246. package/packages/evidence/review-age.mjs +14 -0
  247. package/packages/interchange/bagit.mjs +342 -0
  248. package/packages/interchange/bounded-zip-extraction.mjs +278 -0
  249. package/packages/interchange/crc32.mjs +17 -0
  250. package/packages/interchange/external-findings-import.mjs +92 -51
  251. package/packages/interchange/web-capture-import.mjs +382 -0
  252. package/packages/investigation/bulk-mail-exposure.mjs +2 -1
  253. package/packages/investigation/investigation-bagit.mjs +14 -0
  254. package/packages/investigation/investigation-capsule.mjs +33 -5
  255. package/packages/investigation/investigation-manifest.mjs +200 -0
  256. package/packages/investigation/investigation-package-crypto.mjs +98 -0
  257. package/packages/investigation/investigation-package.mjs +200 -0
  258. package/packages/investigation/lookup-asset-graph-inputs.mjs +38 -0
  259. package/packages/investigation/lookup-asset-graph.mjs +193 -165
  260. package/packages/investigation/lookup-claim-readiness.mjs +18 -0
  261. package/packages/investigation/lookup-investigation-brief.mjs +3 -3
  262. package/packages/investigation/lookup-task-guidance.mjs +58 -0
  263. package/packages/monitoring/analyst-review-state.mjs +38 -90
  264. package/packages/workspace/brand-profile-model.mjs +276 -22
  265. package/packages/workspace/bulk-columns.mjs +17 -0
  266. package/packages/workspace/bulk-review-model.mjs +23 -11
  267. package/packages/workspace/bulk-session-model.mjs +100 -24
  268. package/packages/workspace/bulk-sort.mjs +1 -0
  269. package/packages/workspace/case-views.mjs +102 -0
  270. package/packages/workspace/detection-rule-model.mjs +70 -22
  271. package/packages/workspace/hostile-input.mjs +29 -8
  272. package/packages/workspace/investigation-template-model.mjs +19 -4
  273. package/packages/workspace/page-baseline.mjs +18 -15
  274. package/packages/workspace/relationship-observation-model.mjs +41 -23
  275. package/packages/workspace/watchlist-history.mjs +2 -2
  276. package/packages/workspace/watchlist-store.mjs +8 -2
  277. package/packages/workspace/website-snapshot-model.mjs +164 -26
  278. package/packages/workspace/workspace-archive-crypto.mjs +41 -61
  279. package/packages/workspace/workspace-archive.mjs +87 -21
  280. package/third-party-notices.txt +29 -2
  281. package/cli/evidence-command-arguments.mjs +0 -156
  282. package/packages/investigation/bulk-review-export.mjs +0 -63
@@ -8,6 +8,7 @@
8
8
  //
9
9
  // Shared by the Express server and the Netlify Functions.
10
10
  import { promises as dns } from 'node:dns';
11
+ import { abortable } from "./abort.mjs";
11
12
  import { fetchRdapRecord } from "./rdap.mjs";
12
13
  import { buildWhoisChain, parseWhoisChain } from "./whois.mjs";
13
14
  import { safeFetchDetailed, readTextCapped } from "./safe-fetch.mjs";
@@ -15,6 +16,7 @@ import { whoisleuthRequestHeaders } from "./outbound-identity.mjs";
15
16
  import { collectDnsIntelligence, skippedDnsIntelligence } from "./dns-intelligence.mjs";
16
17
  import { fetchFaviconHash } from "./favicon.mjs";
17
18
  import { extractHtmlSignals } from "./html-signals.mjs";
19
+ import { analyzeStaticHtml } from "./static-html-analysis.mjs";
18
20
  import { featureDecision, networkFeaturePolicy } from "./feature-policy.mjs";
19
21
  import { buildHttpObservation, failedHttpObservation, skippedHttpObservation } from "./http-intelligence.mjs";
20
22
  import { collectTlsIntelligence, skippedTlsObservation } from "./tls-intelligence.mjs";
@@ -24,6 +26,9 @@ import { analyzeWebsiteTechnology, captureTechnologyResponseHeaders, } from "./w
24
26
  import { analyzeResponsePolicyHeaders, qualifyResponsePolicyWithCspMeta, } from "./response-policy.mjs";
25
27
  import { nonEmptyErrorMessage } from "./error-detail.mjs";
26
28
  import { registryServiceAdmissionFor } from "./registry-capabilities.mjs";
29
+ import { canonicalRegistrableDomain } from "./registrable-domain.mjs";
30
+ import { isValidAsciiHostname } from "./hostname.mjs";
31
+ import { prepareSelectedLookupUrl } from "../packages/evidence/lookup-target.mjs";
27
32
  import { HOMEPAGE_FETCH_TIMEOUT_MS, MAX_HOMEPAGE_BYTES, } from "./outbound-request-bounds.mjs";
28
33
  const DNS_DELEGATION_TIMEOUT_MS = 4000;
29
34
  const MAX_DELEGATION_NAMESERVERS = 50;
@@ -76,7 +81,6 @@ const PARKING_NS_PATTERNS = [
76
81
  /trafficz\.com$/i,
77
82
  /dsredirection\.com$/i,
78
83
  ];
79
- const FOR_SALE_TEXT_RE = /(this domain (?:name )?(?:may be|is) for sale|buy this domain|domain(?: name)? for sale|make (?:an|your) offer|inquire about (?:this|the) domain|purchase this domain|this domain is available for purchase|backorder this domain|bid on this domain|premium domain for sale|own this domain|click here to buy this domain|would you like to buy this domain|this domain is (?:available for lease|listed for sale))/i;
80
84
  const FOR_SALE_PATH_RE = /\/(?:premium-)?domains?-for-sale(?:\/|$)/i;
81
85
  function forSaleRedirectSignal(httpObservation) {
82
86
  if (!httpObservation || typeof httpObservation !== 'object')
@@ -167,15 +171,18 @@ function compactContact(contact) {
167
171
  // domain has an active DNS delegation. Use that as a bounded, positive-only
168
172
  // fallback: no answer is never interpreted as availability because registered
169
173
  // domains can legitimately be undelegated.
170
- async function checkDnsDelegation(domain, { resolver = dns.resolveNs } = {}) {
171
- let timer;
174
+ async function checkDnsDelegation(domain, options = {}) {
175
+ options.signal?.throwIfAborted();
176
+ // Cancel only this lookup's c-ares requests, never the shared DNS resolver.
177
+ const ownedResolver = options.resolver ? null : new dns.Resolver();
178
+ const resolve = options.resolver || ((name) => ownedResolver.resolveNs(name));
179
+ const controller = new AbortController();
180
+ const signal = options.signal ? AbortSignal.any([options.signal, controller.signal]) : controller.signal;
181
+ const cancel = () => ownedResolver?.cancel();
182
+ signal.addEventListener('abort', cancel, { once: true });
183
+ const timer = setTimeout(() => controller.abort(new Error('DNS delegation lookup timed out')), DNS_DELEGATION_TIMEOUT_MS);
172
184
  try {
173
- const records = await Promise.race([
174
- Promise.resolve().then(() => resolver(domain)),
175
- new Promise((_, reject) => {
176
- timer = setTimeout(() => reject(new Error('DNS delegation lookup timed out')), DNS_DELEGATION_TIMEOUT_MS);
177
- }),
178
- ]);
185
+ const records = await abortable(() => resolve(domain), signal);
179
186
  const validNameservers = [...new Set((Array.isArray(records) ? records : [])
180
187
  .filter((value) => typeof value === 'string')
181
188
  .map((value) => value.trim().replace(/\.+$/, '').toLowerCase())
@@ -191,6 +198,7 @@ async function checkDnsDelegation(domain, { resolver = dns.resolveNs } = {}) {
191
198
  };
192
199
  }
193
200
  catch (err) {
201
+ options.signal?.throwIfAborted();
194
202
  const error = errorRecord(err);
195
203
  if (typeof error.code === 'string' && MISSING_DNS_CODES.has(error.code)) {
196
204
  return { delegated: false, nameservers: [], nameserversTruncated: false, error: null };
@@ -204,6 +212,7 @@ async function checkDnsDelegation(domain, { resolver = dns.resolveNs } = {}) {
204
212
  }
205
213
  finally {
206
214
  clearTimeout(timer);
215
+ signal.removeEventListener('abort', cancel);
207
216
  }
208
217
  }
209
218
  // Fetches enough of the homepage for the lightweight HTML signals below and
@@ -211,20 +220,22 @@ async function checkDnsDelegation(domain, { resolver = dns.resolveNs } = {}) {
211
220
  // domain has "no site": transient DNS/TLS/network failures, a slow origin, or
212
221
  // an HTTP error can all produce the same null body. Keeping that distinction
213
222
  // avoids turning an inconclusive probe into a false inactivity claim.
214
- async function fetchHomepage(domain, { fetcher = safeFetchDetailed, timeoutMs = HOMEPAGE_FETCH_TIMEOUT_MS } = {}) {
223
+ async function fetchHomepage(domain, { fetcher = safeFetchDetailed, timeoutMs = HOMEPAGE_FETCH_TIMEOUT_MS, selectedUrl, signal } = {}) {
224
+ const selected = selectedUrl === undefined ? null : prepareSelectedLookupUrl(selectedUrl, domain);
215
225
  const requestTimeoutMs = Number.isInteger(timeoutMs) && timeoutMs >= 10 && timeoutMs <= HOMEPAGE_FETCH_TIMEOUT_MS
216
226
  ? timeoutMs
217
227
  : HOMEPAGE_FETCH_TIMEOUT_MS;
218
228
  const headers = whoisleuthRequestHeaders();
219
229
  const failures = [];
220
230
  const probeStartedAt = Date.now();
221
- for (const scheme of ['https', 'http']) {
222
- const requestUrl = `${scheme}://${domain}`;
231
+ for (const requestUrl of selected ? [selected] : [`https://${domain}`, `http://${domain}`]) {
232
+ signal?.throwIfAborted();
233
+ const scheme = new URL(requestUrl).protocol.slice(0, -1);
223
234
  const attemptStartedAt = Date.now();
224
235
  const controller = new AbortController();
225
236
  const timeout = setTimeout(() => controller.abort(), requestTimeoutMs);
226
237
  try {
227
- const fetched = await fetcher(requestUrl, { signal: controller.signal, headers });
238
+ const fetched = await fetcher(requestUrl, { signal: signal ? AbortSignal.any([signal, controller.signal]) : controller.signal, headers });
228
239
  const fetchedResponse = fetched instanceof Response ? fetched : fetched.response;
229
240
  const detail = fetched && typeof fetched === 'object' && 'response' in fetched
230
241
  ? fetched
@@ -238,8 +249,11 @@ async function fetchHomepage(domain, { fetcher = safeFetchDetailed, timeoutMs =
238
249
  durationMs: Date.now() - attemptStartedAt,
239
250
  };
240
251
  const res = detail.response;
241
- // A truncated homepage is still fine here - only used to scan for a
242
- // for-sale text match, not parsed as well-formed content. The
252
+ const analysisBase = new URL(detail.finalUrl);
253
+ analysisBase.search = '';
254
+ analysisBase.hash = '';
255
+ // Retain an explicitly partial prefix when the body exceeds its bound;
256
+ // downstream analyses must not treat it as a complete page. The
243
257
  // timeout stays armed through this read (cleared in `finally` below,
244
258
  // not here) - a malicious site could otherwise send headers
245
259
  // immediately and then trickle or stall the body forever, hanging
@@ -249,9 +263,10 @@ async function fetchHomepage(domain, { fetcher = safeFetchDetailed, timeoutMs =
249
263
  return {
250
264
  text: body.text,
251
265
  status: 'fetched',
252
- detail: `Homepage responded over ${scheme.toUpperCase()} (HTTP ${res.status}).`,
266
+ detail: `${selected ? 'Selected URL' : 'Homepage'} responded over ${scheme.toUpperCase()} (HTTP ${res.status}).`,
253
267
  responsePolicy: analyzeResponsePolicyHeaders(res.headers),
254
268
  technologyHeaders: captureTechnologyResponseHeaders(res.headers),
269
+ ...(selected ? { analysisBaseUrl: analysisBase.toString() } : {}),
255
270
  http: buildHttpObservation({ ...detail, durationMs: Date.now() - attemptStartedAt }, {
256
271
  previousAttempts: failures,
257
272
  capturedBodyBytes: body.bytesRead,
@@ -271,7 +286,7 @@ async function fetchHomepage(domain, { fetcher = safeFetchDetailed, timeoutMs =
271
286
  return {
272
287
  text: null,
273
288
  status: 'responded',
274
- detail: `Web server responded over ${scheme.toUpperCase()} (HTTP ${res.status}); homepage content was not available for inspection.`,
289
+ detail: `Web server responded over ${scheme.toUpperCase()} (HTTP ${res.status}); ${selected ? 'selected page' : 'homepage'} content was not available for inspection.`,
275
290
  responsePolicy: analyzeResponsePolicyHeaders(res.headers),
276
291
  technologyHeaders: captureTechnologyResponseHeaders(res.headers),
277
292
  http: buildHttpObservation({ ...detail, durationMs: Date.now() - attemptStartedAt }, {
@@ -282,12 +297,14 @@ async function fetchHomepage(domain, { fetcher = safeFetchDetailed, timeoutMs =
282
297
  };
283
298
  }
284
299
  catch (err) {
300
+ signal?.throwIfAborted();
285
301
  const error = errorRecord(err);
286
302
  const reason = error.name === 'AbortError'
287
303
  ? `timed out after ${requestTimeoutMs} milliseconds`
288
- : nonEmptyErrorMessage(err, 'request failed')
289
- .replace(/[\u0000-\u001f\u007f]+/g, ' ')
290
- .slice(0, 180);
304
+ : selected ? 'request failed; the selected URL was not retried at another path or scheme'
305
+ : nonEmptyErrorMessage(err, 'request failed')
306
+ .replace(/[\u0000-\u001f\u007f]+/g, ' ')
307
+ .slice(0, 180);
291
308
  failures.push({ url: requestUrl, error: `${scheme.toUpperCase()} ${reason}` });
292
309
  }
293
310
  finally {
@@ -298,7 +315,7 @@ async function fetchHomepage(domain, { fetcher = safeFetchDetailed, timeoutMs =
298
315
  text: null,
299
316
  status: 'inconclusive',
300
317
  detail: failures.length
301
- ? `Could not confirm homepage activity: ${failures.map((attempt) => attempt.error).join('; ')}.`
318
+ ? `Could not confirm ${selected ? 'selected URL' : 'homepage'} activity: ${failures.map((attempt) => attempt.error).join('; ')}.`
302
319
  : 'Could not confirm homepage activity.',
303
320
  http: failedHttpObservation(failures, { durationMs: Date.now() - probeStartedAt }),
304
321
  };
@@ -314,7 +331,7 @@ function registryPolicyDetail(domain, fast) {
314
331
  const details = [];
315
332
  const rdapAdmission = registryServiceAdmissionFor(domain, 'rdap');
316
333
  if (rdapAdmission && !rdapAdmission.allowed) {
317
- details.push('RDAP was not queried because the registry capability profile records no IANA-published RDAP service for this suffix.');
334
+ details.push('RDAP was not queried because the registry access policy does not permit collection.');
318
335
  }
319
336
  const whoisAdmission = fast ? null : registryServiceAdmissionFor(domain, 'whois');
320
337
  if (whoisAdmission && !whoisAdmission.allowed) {
@@ -330,23 +347,12 @@ function registryPolicyDetail(domain, fast) {
330
347
  // large sourcing candidate lists quickly and gently on registry rate
331
348
  // limits; anything it can't resolve (state "unknown") is meant to get a
332
349
  // follow-up deep check (fast: false, the default) on the shortlist only.
333
- async function checkDomainAvailability(domain, options = {}) {
350
+ async function resolveDomainRegistration(domain, options, featurePolicy) {
351
+ options.signal?.throwIfAborted();
334
352
  const fast = options.fast === true;
335
- const collectDns = options.collectDnsIntelligence || collectDnsIntelligence;
336
- const collectTls = options.collectTlsIntelligence || collectTlsIntelligence;
337
- const fetchHomepageForDomain = options.fetchHomepage || fetchHomepage;
338
- const fetchFaviconForDomain = options.fetchFaviconHash || fetchFaviconHash;
339
- const featurePolicy = options.featurePolicy || networkFeaturePolicy();
340
353
  const rdapEnabled = featureDecision('rdap', featurePolicy).enabled;
341
354
  const whoisEnabled = featureDecision('whois', featurePolicy).enabled;
342
355
  const dnsIntelligenceEnabled = featureDecision('dns_intelligence', featurePolicy).enabled;
343
- const websiteProbeEnabled = featureDecision('website_probe', featurePolicy).enabled;
344
- const tlsIntelligenceEnabled = featureDecision('tls_intelligence', featurePolicy).enabled;
345
- const deepScanComplete = rdapEnabled
346
- && whoisEnabled
347
- && dnsIntelligenceEnabled
348
- && websiteProbeEnabled
349
- && tlsIntelligenceEnabled;
350
356
  const hasPreloadedRdap = Object.prototype.hasOwnProperty.call(options, 'rdapRecord');
351
357
  const hasPreloadedWhois = Object.prototype.hasOwnProperty.call(options, 'whoisChain');
352
358
  const hasPreloadedRdapPromise = Object.prototype.hasOwnProperty.call(options, 'rdapRecordPromise');
@@ -367,6 +373,9 @@ async function checkDomainAvailability(domain, options = {}) {
367
373
  let registrationConfidence = 'high';
368
374
  let dnssec = null;
369
375
  let registryDnsEvidence = null;
376
+ const assessment = (registration) => ({
377
+ registration, registryDnsEvidence, nameservers, dnssec,
378
+ });
370
379
  if (rdapEnabled) {
371
380
  try {
372
381
  // Shared with /api/rdap (lib/rdap.mts's fetchRdapRecord) rather than a
@@ -374,23 +383,24 @@ async function checkDomainAvailability(domain, options = {}) {
374
383
  // also picks up that function's short-TTL cache (lib/lookup-cache.mts)
375
384
  // and upstream timeout for free.
376
385
  const recordValue = hasPreloadedRdapPromise
377
- ? await options.rdapRecordPromise
386
+ ? await abortable(() => options.rdapRecordPromise, options.signal)
378
387
  : hasPreloadedRdap
379
388
  ? options.rdapRecord
380
- : await fetchRdapRecord('domain', domain);
389
+ : await fetchRdapRecord('domain', domain, options.signal ? { signal: options.signal } : {});
390
+ options.signal?.throwIfAborted();
381
391
  const record = errorRecord(recordValue);
382
392
  const recordRdapServer = typeof record.rdapServer === 'string' ? record.rdapServer : null;
383
393
  const upstreamStatus = typeof record.upstreamStatus === 'number' ? record.upstreamStatus : null;
384
394
  if (Object.keys(record).length) {
385
395
  rdapServer = recordRdapServer;
386
396
  if (upstreamStatus === 404) {
387
- return {
397
+ return assessment({
388
398
  state: 'available',
389
399
  confidence: 'high',
390
400
  detail: 'The registry\'s RDAP service has no record for this domain.',
391
401
  source: 'rdap',
392
402
  rdapServer: recordRdapServer,
393
- };
403
+ });
394
404
  }
395
405
  const parsed = errorRecord(record.parsed);
396
406
  if (Object.keys(parsed).length) {
@@ -427,34 +437,42 @@ async function checkDomainAvailability(domain, options = {}) {
427
437
  }
428
438
  }
429
439
  catch {
440
+ options.signal?.throwIfAborted();
430
441
  /* fall through to WHOIS-based detection (deep mode only) */
431
442
  }
432
443
  }
433
444
  const dnsDelegationPromise = !rdapFound && dnsIntelligenceEnabled
434
445
  ? hasPreloadedDnsDelegation
435
446
  ? Promise.resolve(options.dnsDelegation)
436
- : checkDnsDelegation(domain, { resolver: options.resolveNs || dns.resolveNs })
447
+ : checkDnsDelegation(domain, {
448
+ ...(options.resolveNs ? { resolver: options.resolveNs } : {}),
449
+ ...(options.signal ? { signal: options.signal } : {}),
450
+ })
437
451
  : null;
452
+ // DNS may settle while the independent WHOIS source is still pending. Keep
453
+ // its rejection observed even if WHOIS supplies an early conclusive return.
454
+ void dnsDelegationPromise?.catch(() => { });
438
455
  let whoisChain = null;
439
456
  let whoisParsed = null;
440
457
  if (!rdapFound && !fast && whoisEnabled) {
441
458
  try {
442
459
  whoisChain = (hasPreloadedWhoisPromise
443
- ? await options.whoisChainPromise
460
+ ? await abortable(() => options.whoisChainPromise, options.signal)
444
461
  : hasPreloadedWhois
445
462
  ? options.whoisChain
446
- : await buildWhoisChain(domain)) ?? null;
463
+ : await buildWhoisChain(domain, options.signal ? { signal: options.signal } : {})) ?? null;
464
+ options.signal?.throwIfAborted();
447
465
  if (!Array.isArray(whoisChain))
448
466
  throw new Error('WHOIS chain unavailable');
449
467
  const parsed = parseWhoisChain(whoisChain);
450
468
  whoisParsed = parsed;
451
469
  if (parsed.notFound) {
452
- return {
470
+ return assessment({
453
471
  state: 'available',
454
472
  confidence: 'medium',
455
473
  detail: `WHOIS reports no matching record for this domain${parsed.notFoundSource ? ` (per ${parsed.notFoundSource})` : ''}.`,
456
474
  source: 'whois',
457
- };
475
+ });
458
476
  }
459
477
  if (parsed.registrationStatus === 'registered') {
460
478
  if (parsed.nameservers.length)
@@ -493,6 +511,7 @@ async function checkDomainAvailability(domain, options = {}) {
493
511
  }
494
512
  }
495
513
  catch {
514
+ options.signal?.throwIfAborted();
496
515
  /* if both RDAP and WHOIS fail, we simply can't determine availability */
497
516
  }
498
517
  }
@@ -511,6 +530,7 @@ async function checkDomainAvailability(domain, options = {}) {
511
530
  registrationConfidence = 'medium';
512
531
  }
513
532
  }
533
+ options.signal?.throwIfAborted();
514
534
  if (!rdapFound && !hasWhoisRegistrationData && !dnsDelegated) {
515
535
  const disabledSources = [
516
536
  !rdapEnabled ? 'RDAP' : null,
@@ -521,7 +541,7 @@ async function checkDomainAvailability(domain, options = {}) {
521
541
  ? ` ${disabledSources.join(', ')} ${disabledSources.length === 1 ? 'is' : 'are'} disabled by deployment policy.`
522
542
  : '';
523
543
  const policyDetail = registryPolicyDetail(domain, fast);
524
- return {
544
+ return assessment({
525
545
  state: 'unknown',
526
546
  confidence: 'low',
527
547
  detail: fast
@@ -530,7 +550,7 @@ async function checkDomainAvailability(domain, options = {}) {
530
550
  ? `WHOIS was inconclusive - a referral hop did not answer conclusively (${whoisParsed.failedHop}).${policyDetail}`
531
551
  : `No enabled registration source returned conclusive data or an authoritative DNS delegation.${disabledDetail}${policyDetail}`,
532
552
  ...(!fast && whoisEnabled ? { source: 'whois' } : {}),
533
- };
553
+ });
534
554
  }
535
555
  const domainAgeDays = computeAgeDays(createdDateIso || createdDate);
536
556
  const expiresInDays = computeDaysUntil(expiryDateIso || expiryDate);
@@ -558,42 +578,69 @@ async function checkDomainAvailability(domain, options = {}) {
558
578
  const normalizedStatus = registryStatusToken(status);
559
579
  return normalizedStatus.includes('pendingdelete') || normalizedStatus.includes('redemptionperiod');
560
580
  })) {
561
- return {
581
+ return assessment({
562
582
  state: 'expiring',
563
583
  confidence: 'medium',
564
584
  detail: 'Domain is in redemption/pending-delete status and may become available soon.',
565
585
  ...baseInfo,
566
- };
586
+ });
567
587
  }
568
- if (fast) {
569
- return {
570
- state: 'registered',
571
- confidence: registrationConfidence,
572
- detail: registrationSource === 'dns'
573
- ? 'Authoritative DNS delegation confirms the domain is registered, but RDAP/WHOIS registration details were unavailable.'
574
- : 'Domain is registered. Run a deep check for parking/for-sale detection.',
575
- ...baseInfo,
576
- };
588
+ return assessment({
589
+ state: 'registered',
590
+ confidence: registrationConfidence,
591
+ detail: registrationSource === 'dns'
592
+ ? 'Authoritative DNS delegation confirms the domain is registered, but RDAP/WHOIS registration details were unavailable.'
593
+ : 'Domain is registered. Run a deep check for parking/for-sale detection.',
594
+ ...baseInfo,
595
+ });
596
+ }
597
+ async function checkDomainAvailability(domain, options = {}) {
598
+ options.signal?.throwIfAborted();
599
+ const fast = options.fast === true;
600
+ const observationHostname = fast ? domain : options.observationHostname ?? domain;
601
+ if (options.observationHostname !== undefined && (!isValidAsciiHostname(options.observationHostname)
602
+ || options.observationHostname !== options.observationHostname.toLowerCase()
603
+ || canonicalRegistrableDomain(options.observationHostname) !== domain)) {
604
+ throw new TypeError('The observation hostname must belong to the registration target.');
577
605
  }
606
+ const selectedUrl = options.selectedUrl === undefined ? undefined : prepareSelectedLookupUrl(options.selectedUrl, observationHostname);
607
+ const featurePolicy = options.featurePolicy || networkFeaturePolicy();
608
+ const websiteProbeEnabled = featureDecision('website_probe', featurePolicy).enabled;
609
+ if (selectedUrl && (fast || !websiteProbeEnabled))
610
+ throw new TypeError('Selected URL collection requires an enabled Deep website observation.');
611
+ const { registration, registryDnsEvidence, nameservers, dnssec } = await resolveDomainRegistration(domain, options, featurePolicy);
612
+ if (fast || registration.state !== 'registered' && !selectedUrl)
613
+ return registration;
614
+ const collectDns = options.collectDnsIntelligence || collectDnsIntelligence;
615
+ const collectTls = options.collectTlsIntelligence || collectTlsIntelligence;
616
+ const fetchHomepageForDomain = options.fetchHomepage || fetchHomepage;
617
+ const fetchFaviconForDomain = options.fetchFaviconHash || fetchFaviconHash;
618
+ const dnsIntelligenceEnabled = featureDecision('dns_intelligence', featurePolicy).enabled;
619
+ const tlsIntelligenceEnabled = featureDecision('tls_intelligence', featurePolicy).enabled;
620
+ const deepScanComplete = ['rdap', 'whois', 'dns_intelligence', 'website_probe', 'tls_intelligence']
621
+ .every((feature) => featureDecision(feature, featurePolicy).enabled);
578
622
  // Registered - look for for-sale/parked/website signals (homepage fetch,
579
623
  // deep mode only), and check for a configured mail exchanger as a
580
624
  // phishing-risk signal (a lookalike domain that can receive/send mail is
581
625
  // capable of running credential-harvesting or BEC campaigns).
582
- const nsSignal = nameservers.find((ns) => PARKING_NS_PATTERNS.some((re) => re.test(ns)));
626
+ const nsSignal = registration.state === 'registered' ? nameservers.find((ns) => PARKING_NS_PATTERNS.some((re) => re.test(ns))) : undefined;
583
627
  let forSaleSignal = nsSignal ? `parking nameserver (${nsSignal})` : null;
584
- let activityStatus = nsSignal ? 'parked' : 'unknown';
628
+ let activityStatus = nsSignal && observationHostname === domain ? 'parked' : 'unknown';
585
629
  // Homepage + bounded DNS evidence resolve in parallel; the favicon fetch is sequenced after
586
630
  // the homepage so it can use any <link rel="icon"> the page declares (many
587
631
  // sites serve no /favicon.ico and only point to a CDN PNG this way). One
588
632
  // extra round-trip on the already-slow deep path, in exchange for finding
589
633
  // favicons the bare /favicon.ico probe would miss.
590
634
  const [homepage, dnsIntelligence, tlsIntelligence] = await Promise.all([
591
- websiteProbeEnabled ? fetchHomepageForDomain(domain).catch((err) => ({
635
+ websiteProbeEnabled ? fetchHomepageForDomain(observationHostname, {
636
+ ...(selectedUrl ? { selectedUrl } : {}), ...(options.signal ? { signal: options.signal } : {}),
637
+ }).catch((err) => ({
592
638
  text: null,
593
639
  status: 'inconclusive',
594
- detail: `Could not confirm homepage activity: ${String(err && err.message ? err.message : 'request failed').slice(0, 180)}.`,
640
+ detail: selectedUrl ? 'Could not confirm selected URL activity.'
641
+ : `Could not confirm homepage activity: ${String(err && err.message ? err.message : 'request failed').slice(0, 180)}.`,
595
642
  http: failedHttpObservation([
596
- { url: `https://${domain}`, error: String(err && err.message ? err.message : 'request failed') },
643
+ { url: selectedUrl ?? `https://${observationHostname}`, error: selectedUrl ? 'request failed' : String(err && err.message ? err.message : 'request failed') },
597
644
  ]),
598
645
  })) : Promise.resolve({
599
646
  text: null,
@@ -602,27 +649,27 @@ async function checkDomainAvailability(domain, options = {}) {
602
649
  http: skippedHttpObservation(),
603
650
  }),
604
651
  dnsIntelligenceEnabled
605
- ? collectDns(domain, {
652
+ ? collectDns(observationHostname, {
606
653
  ...(options.dnsResolvers ? { resolvers: options.dnsResolvers } : {}),
607
654
  includeExtendedContext: options.includeExtendedDnsContext === true,
608
655
  includeInheritedCaa: options.includeInheritedCaa === true,
609
656
  registryEvidence: registryDnsEvidence,
657
+ ...(observationHostname !== domain ? { registrationDomain: domain } : {}),
610
658
  })
611
659
  : Promise.resolve(skippedDnsIntelligence('DNS intelligence is disabled by deployment policy.', {
612
660
  includeExtendedContext: options.includeExtendedDnsContext === true,
613
661
  includeInheritedCaa: options.includeInheritedCaa === true,
614
662
  })),
615
663
  tlsIntelligenceEnabled
616
- ? collectTls(domain)
664
+ ? collectTls(observationHostname)
617
665
  : Promise.resolve(skippedTlsObservation()),
618
666
  ]);
667
+ options.signal?.throwIfAborted();
619
668
  const page = homepage.text;
620
- const favicon = websiteProbeEnabled
621
- ? await fetchFaviconForDomain(domain, { html: page || '' }).catch(() => null)
622
- : null;
623
- const faviconHash = favicon ? favicon.hash : null;
624
- const faviconPHash = favicon ? favicon.phash : null;
669
+ const pageBaseUrl = homepage.analysisBaseUrl ?? (typeof homepage.http?.finalUrl === 'string' ? homepage.http.finalUrl : `https://${observationHostname}/`);
670
+ let pageAnalysis = page ? analyzeStaticHtml(page, { baseUrl: pageBaseUrl, includeVisibleText: true }) : undefined;
625
671
  let htmlSignals = {
672
+ domainSaleSignal: null,
626
673
  pageTitle: null,
627
674
  hasPasswordField: false,
628
675
  phishingLanguageMatch: null,
@@ -638,17 +685,14 @@ async function checkDomainAvailability(domain, options = {}) {
638
685
  };
639
686
  if (homepage.status === 'fetched') {
640
687
  if (page) {
641
- const saleMatch = page.match(FOR_SALE_TEXT_RE);
642
- if (saleMatch) {
643
- forSaleSignal = forSaleSignal || `homepage text ("${saleMatch[0]}")`;
644
- activityStatus = 'parked';
645
- }
646
688
  const responseContentType = homepage.http?.response?.contentType;
647
689
  const pageIdentityEligible = typeof responseContentType !== 'string'
648
690
  || responseContentType.trim() === ''
649
691
  || /^(?:text\/html|application\/xhtml\+xml)(?:\s*;|$)/i.test(responseContentType.trim());
650
- htmlSignals = extractHtmlSignals(page, domain, {
651
- ...(typeof homepage.http?.finalUrl === 'string' ? { baseUrl: homepage.http.finalUrl } : {}),
692
+ htmlSignals = await extractHtmlSignals(page, observationHostname, {
693
+ ...(options.signal ? { signal: options.signal } : {}),
694
+ ...(pageAnalysis ? { htmlAnalysis: pageAnalysis } : {}),
695
+ baseUrl: pageBaseUrl,
652
696
  ...(typeof homepage.http?.observedAt === 'string' ? { observedAt: homepage.http.observedAt } : {}),
653
697
  sourceTruncated: homepage.http?.response?.bodyTruncated === true,
654
698
  exactBodyHash: homepage.http?.response?.bodyHash,
@@ -667,22 +711,41 @@ async function checkDomainAvailability(domain, options = {}) {
667
711
  ? { includeTechnologyProfile: options.includeTechnologyProfile }
668
712
  : {}),
669
713
  });
714
+ if (pageIdentityEligible && htmlSignals.domainSaleSignal) {
715
+ if (!selectedUrl && observationHostname === domain)
716
+ forSaleSignal = forSaleSignal || htmlSignals.domainSaleSignal;
717
+ activityStatus = 'parked';
718
+ }
670
719
  }
671
720
  }
672
721
  if (options.includeTechnologyProfile !== false && htmlSignals.technologyProfile === null
673
722
  && ['fetched', 'responded'].includes(homepage.status)) {
674
- htmlSignals.technologyProfile = analyzeWebsiteTechnology({
723
+ htmlSignals.technologyProfile = await analyzeWebsiteTechnology({
724
+ ...(options.signal ? { signal: options.signal } : {}),
675
725
  htmlAvailable: false,
676
726
  httpServer: homepage.http?.response?.server,
677
727
  responseHeaders: homepage.technologyHeaders,
678
728
  observedAt: homepage.http?.observedAt,
679
729
  });
680
730
  }
731
+ // Complete document consumers before an optional network wait. Only the
732
+ // small icon projection, not every parsed element/token, crosses that wait.
733
+ const faviconEvidence = pageAnalysis ? {
734
+ iconLinks: pageAnalysis.iconLinks, effectiveBaseUrl: pageAnalysis.effectiveBaseUrl,
735
+ } : undefined;
736
+ pageAnalysis = undefined;
737
+ const favicon = websiteProbeEnabled
738
+ ? await fetchFaviconForDomain(observationHostname, {
739
+ baseUrl: pageBaseUrl, ...(faviconEvidence ? { htmlAnalysis: faviconEvidence } : {}),
740
+ }).catch(() => null)
741
+ : null;
742
+ const faviconHash = favicon ? favicon.hash : null;
743
+ const faviconPHash = favicon ? favicon.phash : null;
681
744
  const responsePolicy = qualifyResponsePolicyWithCspMeta(homepage.responsePolicy, htmlSignals.cspMetaPolicy);
682
745
  const retainedHttp = options.includeDeliveryMetadata === false
683
746
  ? withoutHttpDeliveryMetadata(homepage.http)
684
747
  : homepage.http;
685
- const { cspMetaPolicy: _cspMetaPolicy, ...retainedHtmlSignals } = htmlSignals;
748
+ const { cspMetaPolicy: _cspMetaPolicy, domainSaleSignal: _domainSaleSignal, ...retainedHtmlSignals } = htmlSignals;
686
749
  const securityPosture = options.includeSecurityPosture === false ? null : analyzeWebsiteSecurityPosture({
687
750
  http: homepage.http,
688
751
  responsePolicy,
@@ -693,60 +756,46 @@ async function checkDomainAvailability(domain, options = {}) {
693
756
  observedAt: homepage.http?.observedAt,
694
757
  });
695
758
  const redirectSaleSignal = forSaleRedirectSignal(homepage.http);
696
- if (!forSaleSignal && redirectSaleSignal) {
759
+ if (!selectedUrl && observationHostname === domain && !forSaleSignal && redirectSaleSignal) {
697
760
  forSaleSignal = redirectSaleSignal;
698
761
  activityStatus = 'parked';
699
762
  }
700
- const faviconProvedActive = homepage.status === 'inconclusive' && Boolean(favicon);
763
+ const faviconProvedActive = !selectedUrl && homepage.status === 'inconclusive' && Boolean(favicon);
701
764
  const websiteProbeStatus = faviconProvedActive ? 'responded' : homepage.status;
702
765
  const websiteProbeDetail = faviconProvedActive
703
766
  ? `${homepage.detail} A favicon responded successfully, confirming an active web service.`
704
767
  : homepage.detail;
705
768
  if (websiteProbeEnabled) {
706
- activityStatus = deriveWebsiteActivity(homepage.status, Boolean(favicon), activityStatus === 'parked');
769
+ activityStatus = deriveWebsiteActivity(homepage.status, !selectedUrl && Boolean(favicon), activityStatus === 'parked');
707
770
  }
708
- if (!forSaleSignal) {
709
- return {
710
- state: 'registered',
711
- confidence: registrationConfidence,
712
- detail: registrationSource === 'dns'
713
- ? 'Authoritative DNS delegation confirms the domain is registered, but RDAP/WHOIS registration details were unavailable. No for-sale signals were observed.'
714
- : 'Domain is registered and shows no for-sale signals.',
715
- activityStatus,
716
- websiteProbeStatus,
717
- websiteProbeDetail,
718
- http: retainedHttp,
719
- deepScanComplete,
720
- faviconHash,
721
- faviconPHash,
722
- ...retainedHtmlSignals,
723
- securityPosture,
724
- ...baseInfo,
725
- nameservers: nameservers.length ? nameservers : dnsIntelligence.records.ns,
726
- dns: dnsIntelligence,
727
- tls: tlsIntelligence,
728
- hasMx: dnsIntelligence.hasMx,
729
- hasNullMx: dnsIntelligence.hasNullMx,
730
- mxHosts: dnsIntelligence.mxHosts,
731
- hasSpf: dnsIntelligence.hasSpf,
732
- hasDmarc: dnsIntelligence.hasDmarc,
771
+ const registrationResult = registration.state !== 'registered' ? registration
772
+ : !forSaleSignal ? {
773
+ ...registration,
774
+ detail: 'source' in registration && registration.source === 'dns'
775
+ ? 'Authoritative DNS delegation confirms the domain is registered, but RDAP/WHOIS registration details were unavailable.'
776
+ : selectedUrl ? 'The domain is registered. Website observations concern the explicitly selected URL.'
777
+ : observationHostname === domain ? 'Domain is registered and shows no for-sale signals.'
778
+ : 'The domain is registered. Website observations concern the separately identified hostname.',
779
+ } : {
780
+ ...registration,
781
+ state: 'for_sale',
782
+ confidence: 'medium',
783
+ detail: `Detected a for-sale listing (${forSaleSignal}).`,
733
784
  };
734
- }
735
785
  return {
736
- state: 'for_sale',
737
- confidence: 'medium',
738
- detail: `Detected a for-sale listing (${forSaleSignal}).`,
786
+ ...registrationResult,
739
787
  activityStatus,
740
788
  websiteProbeStatus,
741
789
  websiteProbeDetail,
742
790
  http: retainedHttp,
791
+ ...(options.observationHostname !== undefined || selectedUrl ? { observationHostname } : {}),
792
+ ...(selectedUrl ? { webObservationMode: 'selected_url' } : {}),
743
793
  deepScanComplete,
744
794
  faviconHash,
745
795
  faviconPHash,
746
796
  ...retainedHtmlSignals,
747
797
  securityPosture,
748
- ...baseInfo,
749
- nameservers: nameservers.length ? nameservers : dnsIntelligence.records.ns,
798
+ nameservers: nameservers.length || observationHostname !== domain ? nameservers : dnsIntelligence.records.ns,
750
799
  dns: dnsIntelligence,
751
800
  tls: tlsIntelligence,
752
801
  hasMx: dnsIntelligence.hasMx,
@@ -1,7 +1,9 @@
1
1
  import { constants } from 'node:fs';
2
2
  import { lstat, open, realpath } from 'node:fs/promises';
3
3
  import path from 'node:path';
4
- const MAX_BOUNDED_FILE_BYTES = 64 * 1024 * 1024;
4
+ // Caller-specific payload limits remain authoritative. The emergency ceiling
5
+ // also admits a 64-MiB evidence package plus its bounded manifest/ZIP headers.
6
+ const MAX_BOUNDED_FILE_BYTES = 128 * 1024 * 1024;
5
7
  function boundedSize(value, fallback) {
6
8
  return Number.isSafeInteger(value) && Number(value) >= 0 && Number(value) <= MAX_BOUNDED_FILE_BYTES
7
9
  ? Number(value)