@slicedearth/whoisleuth-cli 2.3.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.
- package/NOTICE +6 -0
- package/README.md +19 -15
- package/bin/whoisleuth.mjs +8 -1
- package/cli/archive-content-digest.mjs +14 -0
- package/cli/archive-inspect.mjs +20 -53
- package/cli/arguments.mjs +68 -6
- package/cli/artifact-validation/case-response.mjs +47 -41
- package/cli/artifact-validation/investigation-capsule.mjs +52 -31
- package/cli/artifact-validation/lookup-evidence.mjs +12 -6
- package/cli/artifact-validation/signed-review.mjs +83 -61
- package/cli/artifact-validation/structure-primitives.mjs +1 -105
- package/cli/artifact-verify.mjs +90 -24
- package/cli/assurance-command-runner.mjs +39 -4
- package/cli/bagit-folder.mjs +57 -0
- package/cli/bagit-review.mjs +23 -0
- package/cli/bounded-json.mjs +1 -1
- package/cli/bulk-command-runner.mjs +2 -0
- package/cli/bulk-output.mjs +29 -3
- package/cli/case-command.mjs +195 -0
- package/cli/case-pack.mjs +39 -31
- package/cli/command-argument-grammar.mjs +4 -0
- package/cli/command-reference.mjs +145 -75
- package/cli/completion.mjs +298 -79
- package/cli/config-profile.mjs +7 -5
- package/cli/ct-event-intake.mjs +12 -12
- package/cli/discover.mjs +4 -3
- package/cli/discovery-observation-snapshot.mjs +44 -16
- package/cli/discovery-scan-command-runner.mjs +2 -0
- package/cli/discovery-scan.mjs +7 -4
- package/cli/discovery-workflow.mjs +1 -1
- package/cli/domain-control-monitor.mjs +50 -20
- package/cli/domain-control-observations.mjs +149 -102
- package/cli/errors.mjs +33 -1
- package/cli/evidence-command-runner.mjs +16 -5
- package/cli/evidence-signing.mjs +2 -2
- package/cli/external-observation-mapping.mjs +7 -8
- package/cli/formatters/terminal-lookup.mjs +18 -46
- package/cli/formatters/terminal-shared.mjs +5 -3
- package/cli/formatters/terminal-technology.mjs +114 -0
- package/cli/inline-command-families.mjs +1 -0
- package/cli/interactive-launcher.mjs +2 -160
- package/cli/interchange-report.mjs +4 -8
- package/cli/investigation-artifacts.mjs +176 -0
- package/cli/investigation-checkpoint.mjs +10 -0
- package/cli/investigation-folder.mjs +114 -0
- package/cli/investigation-manifest.mjs +1 -112
- package/cli/investigation-package-review.mjs +103 -0
- package/cli/investigation-plan.mjs +32 -19
- package/cli/investigation-run.mjs +188 -33
- package/cli/local-document-checkpoint.mjs +170 -0
- package/cli/lookup-browser.mjs +5 -12
- package/cli/lookup-command-runner.mjs +17 -6
- package/cli/lookup-diff.mjs +37 -3
- package/cli/lookup-plan.mjs +11 -5
- package/cli/lookup-reconcile.mjs +10 -8
- package/cli/mail-review.mjs +66 -14
- package/cli/manual.mjs +1 -0
- package/cli/network-command-runner.mjs +2 -0
- package/cli/output-file.mjs +31 -5
- package/cli/registry-doctor.mjs +3 -1
- package/cli/registry-support.mjs +1 -1
- package/cli/retained-artifact-diff.mjs +2 -2
- package/cli/review-command-runner.mjs +45 -0
- package/cli/runner.mjs +55 -6
- package/cli/sharing-review.mjs +21 -34
- package/cli/signer-trust.mjs +104 -0
- package/cli/source-reliability.mjs +74 -83
- package/cli/terminal-input.mjs +168 -0
- package/cli/workflow-command-runner.mjs +68 -13
- package/docs/cli-reference.md +76 -30
- package/docs/cli.md +289 -25
- package/lib/abort.mjs +4 -1
- package/lib/availability.mjs +161 -112
- package/lib/bounded-file.mjs +3 -1
- package/lib/bounded-html-document.mjs +125 -0
- package/lib/bounded-json.mjs +43 -3
- package/lib/browser-library-profile.mjs +42 -51
- package/lib/browser-library-worker.mjs +112 -0
- package/lib/client-behavior-profile.mjs +3 -3
- package/lib/ct-search.mjs +11 -4
- package/lib/dns-delegation-health.mjs +24 -14
- package/lib/dns-inheritance-review.mjs +217 -0
- package/lib/dns-intelligence.mjs +36 -23
- package/lib/dns-mx.mjs +1 -1
- package/lib/dnssec-evidence-validation.mjs +11 -3
- package/lib/domain-change-packet.mjs +7 -2
- package/lib/domain-change-review.mjs +14 -8
- package/lib/domain-control-flight-recorder.mjs +219 -118
- package/lib/domain-control-manifest.mjs +117 -57
- package/lib/domain-posture-analysis.mjs +20 -7
- package/lib/domain-posture-parsers.mjs +88 -6
- package/lib/domain-posture.mjs +113 -26
- package/lib/evidence-export-privacy.mjs +561 -0
- package/lib/evidence-export.mjs +11 -592
- package/lib/evidence-report.mjs +3 -1
- package/lib/favicon.mjs +117 -41
- package/lib/generated/cisa-kev-catalog.mjs +12 -4
- package/lib/generated/registrar-standing-catalogue.mjs +7 -7
- package/lib/generated/retire-browser-catalog.mjs +2335 -940
- package/lib/hostname.mjs +2 -38
- package/lib/html-signals.mjs +82 -85
- package/lib/http-evidence-bounds.mjs +2 -1
- package/lib/http-intelligence.mjs +39 -26
- package/lib/lookup-cache.mjs +34 -11
- package/lib/lookup-child-profile-contract.mjs +24 -15
- package/lib/lookup-diagnostics.mjs +24 -1
- package/lib/lookup-progress.mjs +30 -13
- package/lib/lookup-response-contract.mjs +1048 -0
- package/lib/lookup-response.mjs +7 -10
- package/lib/lookup-source-progress.mjs +17 -14
- package/lib/lookup.mjs +48 -27
- package/lib/observed-network-context.mjs +43 -27
- package/lib/outbound-request-bounds.mjs +4 -1
- package/lib/page-fingerprints.mjs +69 -133
- package/lib/page-role-profile.mjs +12 -8
- package/lib/perceptual-hash.mjs +43 -14
- package/lib/rdap-bootstrap.mjs +72 -22
- package/lib/rdap-client.mjs +15 -7
- package/lib/rdap-normalization.mjs +33 -18
- package/lib/rdap-search-workbench.mjs +1 -1
- package/lib/rdap-transport.mjs +47 -20
- package/lib/rdap-types.mjs +2 -1
- package/lib/rdap.mjs +18 -3
- package/lib/registrable-domain.mjs +21 -1
- package/lib/registrar-standing-contract.mjs +1 -0
- package/lib/registry-capabilities.mjs +5 -1
- package/lib/registry-capability-catalogue.mjs +2 -1
- package/lib/registry-dates.mjs +2 -190
- package/lib/registry-insights.mjs +56 -18
- package/lib/response-policy.mjs +86 -48
- package/lib/risk-calibration-summary.mjs +17 -3
- package/lib/rpki-evidence.mjs +8 -5
- package/lib/safe-fetch.mjs +54 -47
- package/lib/security-txt.mjs +10 -7
- package/lib/service-binding-dns.mjs +8 -0
- package/lib/smtp-transport-review.mjs +27 -15
- package/lib/sslbl-certificates.generated.mjs +136 -135
- package/lib/static-html-analysis.mjs +117 -146
- package/lib/structured-data-identity.mjs +1 -0
- package/lib/technology-markup.mjs +100 -0
- package/lib/threat-intelligence-manifests.mjs +11 -139
- package/lib/threat-intelligence-runtime.mjs +24 -52
- package/lib/threat-intelligence-targets.mjs +6 -34
- package/lib/threat-intelligence-types.mjs +39 -0
- package/lib/typosquat-generator.mjs +19 -15
- package/lib/urlhaus-intelligence.mjs +8 -3
- package/lib/urlscan-intelligence.mjs +1 -1
- package/lib/website-security-posture.mjs +30 -6
- package/lib/website-technology.mjs +111 -71
- package/lib/whois-authority.mjs +14 -2
- package/lib/whois-chain.mjs +9 -2
- package/lib/whois-common-formats.mjs +5 -5
- package/lib/whois-contracts.mjs +2 -1
- package/lib/whois-dialects.mjs +2 -2
- package/lib/whois-parser-support.mjs +8 -1
- package/lib/whois-transport.mjs +23 -7
- package/package.json +2 -1
- package/packages/cases/case-attachment-model.mjs +83 -0
- package/packages/cases/case-evidence-model.mjs +156 -62
- package/packages/cases/case-export-input.mjs +53 -0
- package/packages/cases/case-migration-model.mjs +59 -25
- package/packages/cases/case-model.mjs +1 -0
- package/packages/cases/case-recheck-model.mjs +91 -0
- package/packages/cases/case-record-core.mjs +3 -2
- package/packages/cases/case-record-operations.mjs +89 -27
- package/packages/cases/case-record-projection.mjs +23 -20
- package/packages/cases/case-report.mjs +63 -52
- package/packages/cases/case-response-actions.mjs +654 -0
- package/packages/cases/case-response-model.mjs +41 -1129
- package/packages/cases/case-response-outcomes.mjs +317 -0
- package/packages/cases/case-response-packet-render.mjs +153 -0
- package/packages/cases/case-response-packet-types.mjs +1 -0
- package/packages/cases/case-response-packet-vocabulary.mjs +48 -0
- package/packages/cases/case-response-packet.mjs +38 -587
- package/packages/cases/case-response-records.mjs +94 -0
- package/packages/cases/case-response-review-inputs.mjs +371 -0
- package/packages/cases/case-response-values.mjs +85 -0
- package/packages/cases/case-review-package.mjs +45 -0
- package/packages/cases/case-selection.mjs +46 -0
- package/packages/cases/case-storage-model.mjs +25 -0
- package/packages/cases/response-route-freshness.mjs +16 -0
- package/packages/comparison/capture-context.mjs +61 -0
- package/packages/comparison/comparison-ledger-bulk.mjs +4 -1
- package/packages/comparison/page-similarity.mjs +2 -1
- package/packages/comparison/relationship-evidence.mjs +85 -8
- package/packages/comparison/relationship-provenance.mjs +93 -0
- package/packages/contracts/analyst-interchange.mjs +216 -0
- package/packages/contracts/analyst-review-state-contract.mjs +11 -0
- package/packages/contracts/analyst-review-state.mjs +5 -4
- package/packages/contracts/case-portability.mjs +279 -116
- package/packages/contracts/case-views-contract.mjs +12 -0
- package/packages/contracts/case-views.mjs +26 -0
- package/packages/contracts/cli-command-semantics.mjs +1 -0
- package/packages/contracts/cli-lookup.mjs +0 -1
- package/packages/contracts/domain-control-flight-recorder.mjs +80 -42
- package/packages/contracts/domain-control-manifest.mjs +127 -73
- package/packages/contracts/domain-control-monitor.mjs +48 -45
- package/packages/contracts/domain-control-review.mjs +179 -121
- package/packages/contracts/domain-name.mjs +21 -0
- package/packages/contracts/external-observation-interchange.mjs +2 -1
- package/packages/contracts/extracted-domain-lifecycle.mjs +11 -32
- package/packages/contracts/http-url.mjs +2 -0
- package/packages/contracts/investigation-package-limits.mjs +24 -0
- package/packages/contracts/investigation-portability.mjs +56 -27
- package/packages/contracts/investigation-run.mjs +16 -0
- package/packages/contracts/ip-address.mjs +34 -0
- package/packages/contracts/lookup-evidence.mjs +35 -0
- package/packages/contracts/offline-comparison.mjs +1 -2
- package/packages/contracts/page-fingerprints.mjs +15 -0
- package/packages/contracts/risk-calibration.mjs +0 -1
- package/packages/contracts/schema-compatibility.mjs +1 -1
- package/packages/contracts/schema-lifecycle.mjs +83 -153
- package/packages/contracts/selected-file-limits.mjs +6 -0
- package/packages/contracts/vulnerability-identifiers.mjs +5 -0
- package/packages/contracts/web-capture.mjs +10 -0
- package/packages/contracts/workspace-portability.mjs +160 -225
- package/packages/evidence/artifact-integrity.mjs +8 -2
- package/packages/evidence/artifact-structure.mjs +105 -0
- package/packages/evidence/decision-fact.mjs +4 -1
- package/packages/evidence/domain-control-runtime.mjs +155 -27
- package/packages/evidence/domain-posture-context.mjs +79 -0
- package/packages/evidence/image-regions.mjs +56 -0
- package/packages/evidence/latest-observations.mjs +28 -0
- package/packages/evidence/lookup-target.mjs +104 -0
- package/packages/evidence/observation.mjs +16 -1
- package/packages/evidence/passphrase-encryption.mjs +44 -0
- package/packages/evidence/record-identity.mjs +72 -0
- package/packages/evidence/registry-dates.mjs +190 -0
- package/packages/evidence/retained-file.mjs +69 -0
- package/packages/evidence/review-age.mjs +14 -0
- package/packages/interchange/bagit.mjs +342 -0
- package/packages/interchange/bounded-zip-extraction.mjs +278 -0
- package/packages/interchange/crc32.mjs +17 -0
- package/packages/interchange/external-findings-import.mjs +89 -49
- package/packages/interchange/web-capture-import.mjs +382 -0
- package/packages/investigation/bulk-mail-exposure.mjs +2 -1
- package/packages/investigation/investigation-bagit.mjs +14 -0
- package/packages/investigation/investigation-capsule.mjs +33 -5
- package/packages/investigation/investigation-manifest.mjs +200 -0
- package/packages/investigation/investigation-package-crypto.mjs +98 -0
- package/packages/investigation/investigation-package.mjs +200 -0
- package/packages/investigation/lookup-asset-graph-inputs.mjs +38 -0
- package/packages/investigation/lookup-asset-graph.mjs +161 -141
- package/packages/investigation/lookup-claim-readiness.mjs +18 -0
- package/packages/investigation/lookup-investigation-brief.mjs +3 -3
- package/packages/investigation/lookup-task-guidance.mjs +58 -0
- package/packages/monitoring/analyst-review-state.mjs +38 -90
- package/packages/workspace/brand-profile-model.mjs +158 -20
- package/packages/workspace/bulk-columns.mjs +17 -0
- package/packages/workspace/bulk-review-model.mjs +11 -5
- package/packages/workspace/bulk-session-model.mjs +95 -20
- package/packages/workspace/bulk-sort.mjs +1 -0
- package/packages/workspace/case-views.mjs +102 -0
- package/packages/workspace/hostile-input.mjs +29 -8
- package/packages/workspace/investigation-template-model.mjs +19 -4
- package/packages/workspace/page-baseline.mjs +18 -15
- package/packages/workspace/relationship-observation-model.mjs +41 -26
- package/packages/workspace/watchlist-history.mjs +2 -2
- package/packages/workspace/watchlist-store.mjs +8 -2
- package/packages/workspace/website-snapshot-model.mjs +70 -30
- package/packages/workspace/workspace-archive-crypto.mjs +41 -61
- package/packages/workspace/workspace-archive.mjs +85 -21
- package/third-party-notices.txt +29 -2
- package/packages/investigation/bulk-review-export.mjs +0 -63
package/lib/availability.mjs
CHANGED
|
@@ -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,
|
|
171
|
-
|
|
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
|
|
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
|
|
222
|
-
|
|
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
|
-
|
|
242
|
-
|
|
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:
|
|
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
|
-
:
|
|
289
|
-
|
|
290
|
-
|
|
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
|
|
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
|
|
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, {
|
|
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
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
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(
|
|
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:
|
|
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://${
|
|
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(
|
|
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(
|
|
664
|
+
? collectTls(observationHostname)
|
|
617
665
|
: Promise.resolve(skippedTlsObservation()),
|
|
618
666
|
]);
|
|
667
|
+
options.signal?.throwIfAborted();
|
|
619
668
|
const page = homepage.text;
|
|
620
|
-
const
|
|
621
|
-
|
|
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,
|
|
651
|
-
...(
|
|
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
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
? '
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
package/lib/bounded-file.mjs
CHANGED
|
@@ -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
|
-
|
|
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)
|