@slicedearth/whoisleuth-cli 2.4.0 → 2.5.1
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 +2 -2
- package/cli/argument-values.mjs +55 -0
- package/cli/arguments.mjs +24 -470
- package/cli/artifact-validation/case-response.mjs +21 -6
- package/cli/artifact-validation/lookup-evidence.mjs +3 -3
- package/cli/artifact-validation/signed-review.mjs +59 -13
- package/cli/artifact-verify.mjs +2 -0
- package/cli/assurance-arguments.mjs +56 -0
- package/cli/assurance-command-definitions.mjs +179 -0
- package/cli/assurance-command-runner.mjs +1 -1
- package/cli/bounded-json.mjs +1 -1
- package/cli/bulk-checkpoint.mjs +1 -1
- package/cli/bulk-command-runner.mjs +2 -2
- package/cli/bulk.mjs +2 -3
- package/cli/case-command.mjs +20 -3
- package/cli/case-pack.mjs +12 -2
- package/cli/ci-report.mjs +6 -3
- package/cli/collection-arguments.mjs +123 -0
- package/cli/collection-command-definitions.mjs +330 -0
- package/cli/collection-preflight.mjs +8 -1
- package/cli/command-argument-grammar.mjs +8 -210
- package/cli/command-definition.mjs +329 -0
- package/cli/command-reference.mjs +55 -1388
- package/cli/command-seeds.mjs +18 -0
- package/cli/compare.mjs +1 -1
- package/cli/config-profile.mjs +1 -1
- package/cli/ct-event-intake.mjs +4 -4
- package/cli/discover.mjs +2 -2
- package/cli/discovery-observation-snapshot.mjs +2 -2
- package/cli/discovery-scan-command-runner.mjs +3 -3
- package/cli/discovery-scan.mjs +1 -1
- package/cli/discovery-snapshot.mjs +2 -2
- package/cli/discovery-workflow.mjs +4 -4
- package/cli/domain-control-monitor.mjs +6 -6
- package/cli/domain-control-observations.mjs +2 -1
- package/cli/errors.mjs +5 -6
- package/cli/evidence-arguments.mjs +46 -0
- package/cli/evidence-command-definitions.mjs +69 -0
- package/cli/evidence-command-runner.mjs +2 -3
- package/cli/evidence-signing.mjs +3 -2
- package/cli/external-observation-mapping.mjs +2 -2
- package/cli/formatters/terminal-command-formats.mjs +6 -0
- package/cli/formatters/terminal-lookup.mjs +6 -0
- package/cli/history-arguments.mjs +39 -0
- package/cli/history-command-definitions.mjs +88 -0
- package/cli/indicator-set-command.mjs +33 -0
- package/cli/inline-command-families.mjs +6 -47
- package/cli/inline-command-runner.mjs +4 -31
- package/cli/input.mjs +2 -2
- package/cli/intake-command.mjs +107 -0
- package/cli/interchange-report.mjs +4 -2
- package/cli/investigation-artifacts.mjs +1 -1
- package/cli/investigation-plan.mjs +32 -154
- package/cli/investigation-recipes.mjs +177 -0
- package/cli/investigation-run.mjs +78 -12
- package/cli/local-mmdb-review.mjs +197 -17
- package/cli/lookalike-calibration.mjs +1 -1
- package/cli/lookup-brief.mjs +2 -2
- package/cli/lookup-command-runner.mjs +18 -35
- package/cli/lookup-diff.mjs +10 -9
- package/cli/lookup-plan.mjs +10 -14
- package/cli/lookup-reconcile.mjs +6 -5
- package/cli/lookup-timeline.mjs +7 -6
- package/cli/mail-header-review.mjs +24 -247
- package/cli/mail-review.mjs +9 -7
- package/cli/network-arguments.mjs +51 -0
- package/cli/network-command-definitions.mjs +130 -0
- package/cli/network-command-runner.mjs +43 -31
- package/cli/offline-evidence-review.mjs +39 -6
- package/cli/posture.mjs +1 -1
- package/cli/registry-fixture-scaffold.mjs +1 -1
- package/cli/retained-artifact-diff.mjs +8 -7
- package/cli/review-arguments.mjs +131 -0
- package/cli/review-command-definitions.mjs +273 -0
- package/cli/review-command-runner.mjs +68 -158
- package/cli/risk-calibration.mjs +8 -8
- package/cli/runner.mjs +20 -32
- package/cli/saved-lookup.mjs +2 -2
- package/cli/selected-input-worker.mjs +63 -0
- package/cli/sharing-review.mjs +5 -64
- package/cli/source-reliability.mjs +2 -2
- package/cli/support-arguments.mjs +49 -0
- package/cli/support-command-definitions.mjs +209 -0
- package/cli/support-command-runner.mjs +2 -2
- package/cli/workflow-arguments.mjs +86 -0
- package/cli/workflow-command-definitions.mjs +78 -0
- package/cli/workflow-command-runner.mjs +8 -1
- package/docs/cli-reference.md +27 -0
- package/docs/cli.md +223 -2
- package/docs/contextual-reviews.md +155 -0
- package/lib/analyst-taxonomy.mjs +2 -40
- package/lib/application-version.mjs +1 -1
- package/lib/availability.mjs +13 -789
- package/lib/bounded-contract-normalizers.mjs +4 -0
- package/lib/bounded-json.mjs +2 -330
- package/lib/browser-library-profile.mjs +62 -84
- package/lib/candidate-provenance-bounds.mjs +2 -0
- package/lib/classify.mjs +1 -1
- package/lib/ct-query.mjs +2 -27
- package/lib/ct-response-bounds.mjs +2 -9
- package/lib/ct-search.mjs +83 -47
- package/lib/dmarc-discovery.mjs +62 -0
- package/lib/dns-convergence-review.mjs +21 -8
- package/lib/dns-delegation-health.mjs +12 -8
- package/lib/dns-inheritance-review.mjs +53 -63
- package/lib/dns-intelligence.mjs +9 -7
- package/lib/dns-query-coverage.mjs +40 -0
- package/lib/domain-assurance.mjs +1 -1
- package/lib/domain-change-packet.mjs +12 -13
- package/lib/domain-change-review.mjs +32 -14
- package/lib/domain-control-flight-recorder.mjs +103 -27
- package/lib/domain-control-manifest.mjs +25 -17
- package/lib/domain-posture-analysis.mjs +22 -5
- package/lib/domain-posture.mjs +16 -15
- package/lib/domain-registration.mjs +392 -0
- package/lib/evidence-export-privacy.mjs +22 -15
- package/lib/evidence-export.mjs +3 -3
- package/lib/evidence-report.mjs +4 -1
- package/lib/external-intelligence-risk.mjs +2 -124
- package/lib/generated/cisa-kev-catalog.mjs +41 -3
- package/lib/generated/registrar-standing-catalogue.mjs +18 -10
- package/lib/generated/retire-browser-catalog.mjs +332 -19
- package/lib/generated/{unicode-confusables-17.mjs → unicode-confusables.mjs} +33 -33
- package/lib/html-signals.mjs +7 -2
- package/lib/http-evidence-bounds.mjs +2 -8
- package/lib/http-intelligence.mjs +13 -1
- package/lib/idn-confusable-policy.mjs +6 -4
- package/lib/idn-confusables.mjs +1 -1
- package/lib/interchange-fidelity-registry.mjs +10 -0
- package/lib/lookup-contract-primitives.mjs +1 -1
- package/lib/lookup-diagnostics.mjs +1 -23
- package/lib/lookup-network-evidence-bounds.mjs +1 -10
- package/lib/lookup-progress.mjs +50 -1
- package/lib/lookup-response-contract.mjs +69 -40
- package/lib/lookup-response.mjs +5 -15
- package/lib/lookup-source-progress.mjs +8 -22
- package/lib/lookup.mjs +1 -1
- package/lib/opportunity-scoring.mjs +2 -107
- package/lib/outbound-identity.mjs +1 -1
- package/lib/page-fingerprints.mjs +18 -1
- package/lib/perceptual-hash-comparison.mjs +2 -34
- package/lib/perceptual-hash.mjs +36 -10
- package/lib/portable-generator.mjs +2 -29
- package/lib/project-metadata.mjs +2 -8
- package/lib/rdap-client.mjs +11 -7
- package/lib/registrable-domain.mjs +2 -56
- package/lib/registrar-standing-contract.mjs +1 -1
- package/lib/registrar-standing.mjs +9 -13
- package/lib/registry-capabilities.mjs +2 -2
- package/lib/registry-capability-catalogue.mjs +27 -19
- package/lib/risk-calibration-summary.mjs +2 -352
- package/lib/risk-scoring.mjs +2 -345
- package/lib/scoring-evidence-quality.mjs +2 -107
- package/lib/semantic-version.mjs +2 -54
- package/lib/service-binding-dns.mjs +27 -0
- package/lib/smtp-transport-review.mjs +48 -19
- package/lib/sslbl-certificates.generated.mjs +141 -139
- package/lib/static-html-analysis.mjs +1 -1
- package/lib/structured-data-identity.mjs +1 -1
- package/lib/threat-intelligence-manifests.mjs +1 -1
- package/lib/threat-intelligence-runtime.mjs +1 -1
- package/lib/threat-intelligence-targets.mjs +1 -1
- package/lib/threat-intelligence-types.mjs +2 -75
- package/lib/typosquat-generator.mjs +11 -2
- package/lib/website-enrichment.mjs +388 -0
- package/lib/whois-authority.mjs +43 -5
- package/lib/whois-normalization.mjs +3 -1
- package/lib/whois-parser.mjs +5 -3
- package/package.json +13 -14
- package/packages/analysis/analyst-taxonomy.mjs +40 -0
- package/packages/analysis/bounded-json.mjs +330 -0
- package/packages/analysis/candidate-provenance-bounds.mjs +4 -0
- package/packages/analysis/cli-argument-grammar.mjs +216 -0
- package/packages/analysis/ct-query.mjs +27 -0
- package/packages/analysis/ct-response-bounds.mjs +9 -0
- package/packages/analysis/external-intelligence-risk.mjs +124 -0
- package/packages/analysis/http-evidence-bounds.mjs +8 -0
- package/packages/analysis/lookup-network-evidence-bounds.mjs +10 -0
- package/packages/analysis/opportunity-scoring.mjs +107 -0
- package/packages/analysis/perceptual-hash-comparison.mjs +34 -0
- package/packages/analysis/portable-generator.mjs +29 -0
- package/packages/analysis/project-metadata.mjs +8 -0
- package/packages/analysis/registrable-domain.mjs +56 -0
- package/packages/analysis/risk-calibration-summary.mjs +352 -0
- package/packages/analysis/risk-scoring.mjs +345 -0
- package/packages/analysis/scoring-evidence-quality.mjs +107 -0
- package/packages/analysis/semantic-version.mjs +54 -0
- package/packages/analysis/threat-intelligence-types.mjs +75 -0
- package/packages/cases/case-evidence-links.mjs +135 -0
- package/packages/cases/case-evidence-model.mjs +78 -12
- package/packages/cases/case-evidence-quality.mjs +5 -0
- package/packages/cases/case-export-input.mjs +7 -5
- package/packages/cases/case-incident-context.mjs +52 -0
- package/packages/cases/case-markdown.mjs +11 -0
- package/packages/cases/case-migration-model.mjs +40 -24
- package/packages/cases/case-recheck-model.mjs +90 -32
- package/packages/cases/case-record-contracts.mjs +1 -1
- package/packages/cases/case-record-core.mjs +1 -1
- package/packages/cases/case-record-operations.mjs +40 -112
- package/packages/cases/case-record-projection.mjs +8 -1
- package/packages/cases/case-record-version-input.mjs +19 -0
- package/packages/cases/case-report.mjs +63 -27
- package/packages/cases/case-requested-evidence.mjs +146 -0
- package/packages/cases/case-response-actions.mjs +103 -1
- package/packages/cases/case-response-model.mjs +9 -1
- package/packages/cases/case-response-outcomes.mjs +29 -8
- package/packages/cases/case-response-packet-render.mjs +13 -9
- package/packages/cases/case-response-packet.mjs +25 -11
- package/packages/cases/case-response-records.mjs +0 -1
- package/packages/cases/case-response-review-inputs.mjs +14 -3
- package/packages/cases/case-storage-model.mjs +1 -1
- package/packages/cases/case-workflow-metadata.mjs +225 -0
- package/packages/cases/case-workflow-migration.mjs +43 -0
- package/packages/cases/http-summary.mjs +6 -1
- package/packages/comparison/favicon-similarity.mjs +1 -1
- package/packages/comparison/page-similarity.mjs +1 -1
- package/packages/comparison/relationship-evidence.mjs +21 -9
- package/packages/comparison/relationship-provenance.mjs +7 -1
- package/packages/contracts/analyst-interchange.mjs +28 -10
- package/packages/contracts/case-portability.mjs +118 -25
- package/packages/contracts/cli-command-semantics.mjs +2 -0
- package/packages/contracts/cli-grammar.mjs +6 -0
- package/packages/contracts/cli-lookup-brief.mjs +2 -0
- package/packages/contracts/context-review.mjs +24 -0
- package/packages/contracts/document-intake.mjs +11 -0
- package/packages/contracts/domain-control-flight-recorder.mjs +30 -14
- package/packages/contracts/domain-control-manifest.mjs +1 -1
- package/packages/contracts/domain-control-monitor.mjs +13 -2
- package/packages/contracts/domain-control-review.mjs +36 -15
- package/packages/contracts/har-review.mjs +2 -0
- package/packages/contracts/identity-events.mjs +4 -0
- package/packages/contracts/investigation-run.mjs +2 -0
- package/packages/contracts/ip-address.mjs +10 -0
- package/packages/contracts/mail-authentication.mjs +5 -0
- package/packages/contracts/message-intake.mjs +31 -0
- package/packages/contracts/offline-comparison.mjs +4 -1
- package/packages/contracts/passive-mail.mjs +10 -0
- package/packages/contracts/web-capture.mjs +2 -2
- package/packages/contracts/workspace-portability.mjs +63 -17
- package/packages/evidence/collection-quality.mjs +67 -0
- package/packages/evidence/dns-query-outcome.mjs +72 -0
- package/packages/evidence/email-recipient.mjs +18 -0
- package/packages/evidence/lookup-target.mjs +5 -1
- package/packages/evidence/observation.mjs +2 -0
- package/packages/evidence/png-pixels.mjs +117 -0
- package/packages/evidence/public-address-policy.mjs +83 -0
- package/packages/interchange/defensive-indicator-export.mjs +264 -0
- package/packages/interchange/external-findings-import.mjs +4 -12
- package/packages/interchange/managed-indicator-export.mjs +35 -0
- package/packages/interchange/managed-indicator-set.mjs +210 -0
- package/packages/interchange/misp-indicator-export.mjs +93 -0
- package/packages/interchange/sharing-policy.mjs +56 -0
- package/packages/interchange/stix-indicator-export.mjs +129 -0
- package/packages/interchange/web-capture-import.mjs +22 -15
- package/packages/investigation/acquisition-decision-packet.mjs +1 -1
- package/packages/investigation/bulk-mail-exposure.mjs +7 -13
- package/packages/investigation/capture-coverage.mjs +57 -0
- package/packages/investigation/connector-provenance-review.mjs +100 -0
- package/packages/investigation/context-review.mjs +35 -0
- package/packages/investigation/document-intake.mjs +81 -0
- package/packages/investigation/docx-intake.mjs +133 -0
- package/packages/investigation/domain-history-review.mjs +100 -0
- package/packages/investigation/domain-transition-review.mjs +47 -0
- package/packages/investigation/evidence-coverage-ledger.mjs +9 -1
- package/packages/investigation/har-intake.mjs +68 -0
- package/packages/investigation/identity-event-intake.mjs +131 -0
- package/packages/investigation/identity-incident-review.mjs +39 -0
- package/packages/investigation/incident-sequence-review.mjs +58 -0
- package/packages/investigation/intake-report.mjs +19 -0
- package/packages/investigation/investigation-manifest.mjs +2 -2
- package/packages/investigation/link-intake.mjs +179 -0
- package/packages/investigation/lookup-asset-graph.mjs +1 -1
- package/packages/investigation/mail-authentication-review.mjs +222 -0
- package/packages/investigation/mail-header-identity.mjs +209 -0
- package/packages/investigation/message-intake.mjs +195 -0
- package/packages/investigation/page-behaviour.mjs +113 -0
- package/packages/investigation/pdf-intake.mjs +155 -0
- package/packages/investigation/platform-continuity-review.mjs +60 -0
- package/packages/investigation/qr-intake.mjs +56 -0
- package/packages/investigation/requested-action-hints.mjs +11 -0
- package/packages/investigation/selected-input-review.mjs +17 -0
- package/packages/investigation/storefront-review.mjs +70 -0
- package/packages/workspace/brand-profile-model.mjs +2 -2
- package/packages/workspace/bulk-review-model.mjs +28 -26
- package/packages/workspace/bulk-session-model.mjs +23 -13
- package/packages/workspace/campaign-model.mjs +1 -1
- package/packages/workspace/detection-rule-model.mjs +1 -1
- package/packages/workspace/hostile-input.mjs +1 -1
- package/packages/workspace/investigation-guide.mjs +2 -2
- package/packages/workspace/investigation-template-model.mjs +26 -2
- package/packages/workspace/page-baseline.mjs +2 -2
- package/packages/workspace/relationship-observation-model.mjs +17 -13
- package/packages/workspace/shortlist-model.mjs +14 -5
- package/packages/workspace/watchlist-history.mjs +28 -12
- package/packages/workspace/watchlist-store.mjs +17 -5
- package/packages/workspace/website-snapshot-model.mjs +28 -3
- package/packages/workspace/workspace-archive-crypto.mjs +1 -1
- package/packages/workspace/workspace-archive.mjs +5 -4
- package/third-party-notices.txt +549 -16
- package/lib/hostname.mjs +0 -2
package/lib/availability.mjs
CHANGED
|
@@ -1,599 +1,11 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
// Determines registered vs. available vs. expiring via RDAP/WHOIS, and flags
|
|
4
|
-
// likely for-sale listings by checking for known parking/marketplace
|
|
5
|
-
// nameservers and "for sale" listing language on the domain's own homepage.
|
|
6
|
-
// This does not attempt to price the domain - registries don't publish sale
|
|
7
|
-
// prices, and that's proprietary marketplace data.
|
|
8
|
-
//
|
|
9
|
-
// Shared by the Express server and the Netlify Functions.
|
|
10
|
-
import { promises as dns } from 'node:dns';
|
|
11
|
-
import { abortable } from "./abort.mjs";
|
|
12
|
-
import { fetchRdapRecord } from "./rdap.mjs";
|
|
13
|
-
import { buildWhoisChain, parseWhoisChain } from "./whois.mjs";
|
|
14
|
-
import { safeFetchDetailed, readTextCapped } from "./safe-fetch.mjs";
|
|
15
|
-
import { whoisleuthRequestHeaders } from "./outbound-identity.mjs";
|
|
16
|
-
import { collectDnsIntelligence, skippedDnsIntelligence } from "./dns-intelligence.mjs";
|
|
17
|
-
import { fetchFaviconHash } from "./favicon.mjs";
|
|
18
|
-
import { extractHtmlSignals } from "./html-signals.mjs";
|
|
19
|
-
import { analyzeStaticHtml } from "./static-html-analysis.mjs";
|
|
1
|
+
// Composition boundary shared by the server and serverless handlers.
|
|
2
|
+
// Registration authority and optional website enrichment retain separate owners.
|
|
20
3
|
import { featureDecision, networkFeaturePolicy } from "./feature-policy.mjs";
|
|
21
|
-
import {
|
|
22
|
-
import {
|
|
23
|
-
import { parseRegistryDate, registryDateIso } from "./registry-dates.mjs";
|
|
24
|
-
import { analyzeWebsiteSecurityPosture } from "./website-security-posture.mjs";
|
|
25
|
-
import { analyzeWebsiteTechnology, captureTechnologyResponseHeaders, } from "./website-technology.mjs";
|
|
26
|
-
import { analyzeResponsePolicyHeaders, qualifyResponsePolicyWithCspMeta, } from "./response-policy.mjs";
|
|
27
|
-
import { nonEmptyErrorMessage } from "./error-detail.mjs";
|
|
28
|
-
import { registryServiceAdmissionFor } from "./registry-capabilities.mjs";
|
|
29
|
-
import { canonicalRegistrableDomain } from "./registrable-domain.mjs";
|
|
30
|
-
import { isValidAsciiHostname } from "./hostname.mjs";
|
|
4
|
+
import { canonicalRegistrableDomain } from "../packages/analysis/registrable-domain.mjs";
|
|
5
|
+
import { isValidAsciiHostname } from "../packages/contracts/domain-name.mjs";
|
|
31
6
|
import { prepareSelectedLookupUrl } from "../packages/evidence/lookup-target.mjs";
|
|
32
|
-
import {
|
|
33
|
-
|
|
34
|
-
const MAX_DELEGATION_NAMESERVERS = 50;
|
|
35
|
-
const MISSING_DNS_CODES = new Set(['ENODATA', 'ENOTFOUND', 'ENONAME', 'NXDOMAIN']);
|
|
36
|
-
function errorRecord(value) {
|
|
37
|
-
return value && typeof value === 'object' && !Array.isArray(value)
|
|
38
|
-
? value
|
|
39
|
-
: {};
|
|
40
|
-
}
|
|
41
|
-
function registryStatusToken(value) {
|
|
42
|
-
return value.toLowerCase().replace(/[^a-z0-9]+/gu, '');
|
|
43
|
-
}
|
|
44
|
-
function rdapEventDate(events, action) {
|
|
45
|
-
const date = events.find((event) => event.action === action)?.date;
|
|
46
|
-
return typeof date === 'string' ? date : null;
|
|
47
|
-
}
|
|
48
|
-
function withoutHttpDeliveryMetadata(value) {
|
|
49
|
-
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
50
|
-
return value;
|
|
51
|
-
const output = { ...value };
|
|
52
|
-
if (output.response && typeof output.response === 'object' && !Array.isArray(output.response)) {
|
|
53
|
-
const response = { ...output.response };
|
|
54
|
-
delete response.deliveryMetadata;
|
|
55
|
-
output.response = response;
|
|
56
|
-
}
|
|
57
|
-
return output;
|
|
58
|
-
}
|
|
59
|
-
// No marketplace (Afternic/Sedo/Dan.com/GoDaddy Auctions/etc.) offers a
|
|
60
|
-
// free, no-auth API to check "is this specific domain listed for sale" -
|
|
61
|
-
// what's checkable without credentials is the parking/landing-page
|
|
62
|
-
// nameservers and homepage copy these services actually use, which is
|
|
63
|
-
// broader coverage of the same signal, not a live cross-marketplace lookup.
|
|
64
|
-
const PARKING_NS_PATTERNS = [
|
|
65
|
-
/sedoparking\.com$/i,
|
|
66
|
-
/sedo\.com$/i,
|
|
67
|
-
/above\.com$/i,
|
|
68
|
-
/bodis\.com$/i,
|
|
69
|
-
/parkingcrew\.net$/i,
|
|
70
|
-
/dan\.com$/i,
|
|
71
|
-
/hugedomains\.com$/i,
|
|
72
|
-
/uniregistry/i,
|
|
73
|
-
/squadhelp/i,
|
|
74
|
-
/afternic/i,
|
|
75
|
-
/voodoo\.com$/i,
|
|
76
|
-
/fabulous\.com$/i,
|
|
77
|
-
/namedrive/i,
|
|
78
|
-
/smartname\.com$/i,
|
|
79
|
-
/domainsponsor\.com$/i,
|
|
80
|
-
/undeveloped\.com$/i,
|
|
81
|
-
/trafficz\.com$/i,
|
|
82
|
-
/dsredirection\.com$/i,
|
|
83
|
-
];
|
|
84
|
-
const FOR_SALE_PATH_RE = /\/(?:premium-)?domains?-for-sale(?:\/|$)/i;
|
|
85
|
-
function forSaleRedirectSignal(httpObservation) {
|
|
86
|
-
if (!httpObservation || typeof httpObservation !== 'object')
|
|
87
|
-
return null;
|
|
88
|
-
const observation = httpObservation;
|
|
89
|
-
const urls = [
|
|
90
|
-
observation.finalUrl,
|
|
91
|
-
...(Array.isArray(observation.redirects)
|
|
92
|
-
? observation.redirects.map((redirect) => redirect && typeof redirect === 'object' ? redirect.to : null)
|
|
93
|
-
: []),
|
|
94
|
-
];
|
|
95
|
-
for (const value of urls) {
|
|
96
|
-
if (typeof value !== 'string' || value.length > 2048)
|
|
97
|
-
continue;
|
|
98
|
-
try {
|
|
99
|
-
const url = new URL(value);
|
|
100
|
-
if (['http:', 'https:'].includes(url.protocol) && FOR_SALE_PATH_RE.test(url.pathname)) {
|
|
101
|
-
return `for-sale landing-page redirect (${url.origin}${url.pathname})`;
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
catch {
|
|
105
|
-
// Malformed retained provenance cannot establish a sale signal.
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
return null;
|
|
109
|
-
}
|
|
110
|
-
// ---------------------------------------------------------------------------
|
|
111
|
-
// Acquisition/sourcing signals: domain age, privacy-redaction, expiry
|
|
112
|
-
// proximity, and site-activity status. These read data already fetched by
|
|
113
|
-
// checkDomainAvailability (RDAP/WHOIS dates and registrant info) - none of
|
|
114
|
-
// this triggers extra registry queries.
|
|
115
|
-
// ---------------------------------------------------------------------------
|
|
116
|
-
function computeAgeDays(dateStr) {
|
|
117
|
-
const d = parseRegistryDate(dateStr);
|
|
118
|
-
return d ? Math.floor((Date.now() - d.getTime()) / 86400000) : null;
|
|
119
|
-
}
|
|
120
|
-
function computeDaysUntil(dateStr) {
|
|
121
|
-
const d = parseRegistryDate(dateStr);
|
|
122
|
-
return d ? Math.ceil((d.getTime() - Date.now()) / 86400000) : null;
|
|
123
|
-
}
|
|
124
|
-
const PRIVACY_MARKERS = [
|
|
125
|
-
/redacted for privacy/i,
|
|
126
|
-
/data protected/i,
|
|
127
|
-
/privacy\s*protect/i,
|
|
128
|
-
/whoisguard/i,
|
|
129
|
-
/domains by proxy/i,
|
|
130
|
-
/perfect privacy/i,
|
|
131
|
-
/contact privacy/i,
|
|
132
|
-
/private registration/i,
|
|
133
|
-
/identity protect/i,
|
|
134
|
-
/not disclosed/i,
|
|
135
|
-
/withheld for privacy/i,
|
|
136
|
-
];
|
|
137
|
-
// Explicit privacy/redaction markers establish a positive privacy signal.
|
|
138
|
-
// Usable public contact data establishes a negative one. A missing or blank
|
|
139
|
-
// contact can instead reflect registry policy, access tier, parser coverage,
|
|
140
|
-
// or ordinary omission, so it remains inconclusive rather than being promoted
|
|
141
|
-
// to an affirmative privacy claim.
|
|
142
|
-
function isPrivacyProtected(registrant) {
|
|
143
|
-
if (!registrant)
|
|
144
|
-
return null;
|
|
145
|
-
const blob = [registrant.name, registrant.org, registrant.email].filter(Boolean).join(' ');
|
|
146
|
-
if (!blob)
|
|
147
|
-
return null;
|
|
148
|
-
return PRIVACY_MARKERS.some((re) => re.test(blob));
|
|
149
|
-
}
|
|
150
|
-
// RDAP Lookup retains a bounded multi-value contact inventory, but Bulk and
|
|
151
|
-
// watchlist availability records deliberately keep the historical compact
|
|
152
|
-
// shape. This prevents repeated contact arrays and registry links from
|
|
153
|
-
// expanding browser-local stores or flowing into case evidence implicitly.
|
|
154
|
-
function compactContact(contact) {
|
|
155
|
-
if (!contact || typeof contact !== 'object' || Array.isArray(contact))
|
|
156
|
-
return null;
|
|
157
|
-
const value = contact;
|
|
158
|
-
const compact = {
|
|
159
|
-
handle: value.handle || null,
|
|
160
|
-
name: value.name || null,
|
|
161
|
-
org: value.org || null,
|
|
162
|
-
email: value.email || null,
|
|
163
|
-
phone: value.phone || null,
|
|
164
|
-
address: value.address || null,
|
|
165
|
-
};
|
|
166
|
-
return Object.values(compact).some(Boolean) ? compact : null;
|
|
167
|
-
}
|
|
168
|
-
// A number of ccTLD registries publish neither an RDAP bootstrap entry nor a
|
|
169
|
-
// generally reachable port-43 WHOIS service. A positive NS answer cannot
|
|
170
|
-
// replace registry registration data, but it does prove that the registrable
|
|
171
|
-
// domain has an active DNS delegation. Use that as a bounded, positive-only
|
|
172
|
-
// fallback: no answer is never interpreted as availability because registered
|
|
173
|
-
// domains can legitimately be undelegated.
|
|
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);
|
|
184
|
-
try {
|
|
185
|
-
const records = await abortable(() => resolve(domain), signal);
|
|
186
|
-
const validNameservers = [...new Set((Array.isArray(records) ? records : [])
|
|
187
|
-
.filter((value) => typeof value === 'string')
|
|
188
|
-
.map((value) => value.trim().replace(/\.+$/, '').toLowerCase())
|
|
189
|
-
.filter((value) => value.length > 0 && value.length <= 253)
|
|
190
|
-
.filter((value) => value.split('.').every((label) => /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/i.test(label))))]
|
|
191
|
-
.sort();
|
|
192
|
-
const normalized = validNameservers.slice(0, MAX_DELEGATION_NAMESERVERS);
|
|
193
|
-
return {
|
|
194
|
-
delegated: normalized.length > 0,
|
|
195
|
-
nameservers: normalized,
|
|
196
|
-
nameserversTruncated: validNameservers.length > MAX_DELEGATION_NAMESERVERS,
|
|
197
|
-
error: null,
|
|
198
|
-
};
|
|
199
|
-
}
|
|
200
|
-
catch (err) {
|
|
201
|
-
options.signal?.throwIfAborted();
|
|
202
|
-
const error = errorRecord(err);
|
|
203
|
-
if (typeof error.code === 'string' && MISSING_DNS_CODES.has(error.code)) {
|
|
204
|
-
return { delegated: false, nameservers: [], nameserversTruncated: false, error: null };
|
|
205
|
-
}
|
|
206
|
-
return {
|
|
207
|
-
delegated: false,
|
|
208
|
-
nameservers: [],
|
|
209
|
-
nameserversTruncated: false,
|
|
210
|
-
error: nonEmptyErrorMessage(err, String(err)).slice(0, 180),
|
|
211
|
-
};
|
|
212
|
-
}
|
|
213
|
-
finally {
|
|
214
|
-
clearTimeout(timer);
|
|
215
|
-
signal.removeEventListener('abort', cancel);
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
// Fetches enough of the homepage for the lightweight HTML signals below and
|
|
219
|
-
// preserves why the probe failed. A failed request is not evidence that a
|
|
220
|
-
// domain has "no site": transient DNS/TLS/network failures, a slow origin, or
|
|
221
|
-
// an HTTP error can all produce the same null body. Keeping that distinction
|
|
222
|
-
// avoids turning an inconclusive probe into a false inactivity claim.
|
|
223
|
-
async function fetchHomepage(domain, { fetcher = safeFetchDetailed, timeoutMs = HOMEPAGE_FETCH_TIMEOUT_MS, selectedUrl, signal } = {}) {
|
|
224
|
-
const selected = selectedUrl === undefined ? null : prepareSelectedLookupUrl(selectedUrl, domain);
|
|
225
|
-
const requestTimeoutMs = Number.isInteger(timeoutMs) && timeoutMs >= 10 && timeoutMs <= HOMEPAGE_FETCH_TIMEOUT_MS
|
|
226
|
-
? timeoutMs
|
|
227
|
-
: HOMEPAGE_FETCH_TIMEOUT_MS;
|
|
228
|
-
const headers = whoisleuthRequestHeaders();
|
|
229
|
-
const failures = [];
|
|
230
|
-
const probeStartedAt = Date.now();
|
|
231
|
-
for (const requestUrl of selected ? [selected] : [`https://${domain}`, `http://${domain}`]) {
|
|
232
|
-
signal?.throwIfAborted();
|
|
233
|
-
const scheme = new URL(requestUrl).protocol.slice(0, -1);
|
|
234
|
-
const attemptStartedAt = Date.now();
|
|
235
|
-
const controller = new AbortController();
|
|
236
|
-
const timeout = setTimeout(() => controller.abort(), requestTimeoutMs);
|
|
237
|
-
try {
|
|
238
|
-
const fetched = await fetcher(requestUrl, { signal: signal ? AbortSignal.any([signal, controller.signal]) : controller.signal, headers });
|
|
239
|
-
const fetchedResponse = fetched instanceof Response ? fetched : fetched.response;
|
|
240
|
-
const detail = fetched && typeof fetched === 'object' && 'response' in fetched
|
|
241
|
-
? fetched
|
|
242
|
-
: {
|
|
243
|
-
response: fetchedResponse,
|
|
244
|
-
requestedUrl: requestUrl,
|
|
245
|
-
finalUrl: requestUrl,
|
|
246
|
-
redirectCount: 0,
|
|
247
|
-
redirectLimitReached: false,
|
|
248
|
-
hops: [{ url: requestUrl, status: fetchedResponse.status, location: null, durationMs: Date.now() - attemptStartedAt }],
|
|
249
|
-
durationMs: Date.now() - attemptStartedAt,
|
|
250
|
-
};
|
|
251
|
-
const res = detail.response;
|
|
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
|
|
257
|
-
// timeout stays armed through this read (cleared in `finally` below,
|
|
258
|
-
// not here) - a malicious site could otherwise send headers
|
|
259
|
-
// immediately and then trickle or stall the body forever, hanging
|
|
260
|
-
// this worker with no deadline once the timer above is disarmed.
|
|
261
|
-
if (res.ok) {
|
|
262
|
-
const body = await readTextCapped(res, MAX_HOMEPAGE_BYTES, { includeSha256: true });
|
|
263
|
-
return {
|
|
264
|
-
text: body.text,
|
|
265
|
-
status: 'fetched',
|
|
266
|
-
detail: `${selected ? 'Selected URL' : 'Homepage'} responded over ${scheme.toUpperCase()} (HTTP ${res.status}).`,
|
|
267
|
-
responsePolicy: analyzeResponsePolicyHeaders(res.headers),
|
|
268
|
-
technologyHeaders: captureTechnologyResponseHeaders(res.headers),
|
|
269
|
-
...(selected ? { analysisBaseUrl: analysisBase.toString() } : {}),
|
|
270
|
-
http: buildHttpObservation({ ...detail, durationMs: Date.now() - attemptStartedAt }, {
|
|
271
|
-
previousAttempts: failures,
|
|
272
|
-
capturedBodyBytes: body.bytesRead,
|
|
273
|
-
bodyInspected: true,
|
|
274
|
-
bodyTruncated: body.truncated,
|
|
275
|
-
bodySha256: body.sha256,
|
|
276
|
-
}),
|
|
277
|
-
};
|
|
278
|
-
}
|
|
279
|
-
// A non-2xx HTTP response still conclusively proves that a web service
|
|
280
|
-
// answered on this domain. It may block this probe (403), require auth
|
|
281
|
-
// (401), have a broken homepage route (404), or be unhealthy (5xx), but
|
|
282
|
-
// none of those are equivalent to "no website". We cannot inspect its
|
|
283
|
-
// HTML signals, so preserve a separate responded status and release the
|
|
284
|
-
// unused body.
|
|
285
|
-
await res.body?.cancel().catch(() => { });
|
|
286
|
-
return {
|
|
287
|
-
text: null,
|
|
288
|
-
status: 'responded',
|
|
289
|
-
detail: `Web server responded over ${scheme.toUpperCase()} (HTTP ${res.status}); ${selected ? 'selected page' : 'homepage'} content was not available for inspection.`,
|
|
290
|
-
responsePolicy: analyzeResponsePolicyHeaders(res.headers),
|
|
291
|
-
technologyHeaders: captureTechnologyResponseHeaders(res.headers),
|
|
292
|
-
http: buildHttpObservation({ ...detail, durationMs: Date.now() - attemptStartedAt }, {
|
|
293
|
-
previousAttempts: failures,
|
|
294
|
-
capturedBodyBytes: 0,
|
|
295
|
-
bodyInspected: false,
|
|
296
|
-
}),
|
|
297
|
-
};
|
|
298
|
-
}
|
|
299
|
-
catch (err) {
|
|
300
|
-
signal?.throwIfAborted();
|
|
301
|
-
const error = errorRecord(err);
|
|
302
|
-
const reason = error.name === 'AbortError'
|
|
303
|
-
? `timed out after ${requestTimeoutMs} milliseconds`
|
|
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);
|
|
308
|
-
failures.push({ url: requestUrl, error: `${scheme.toUpperCase()} ${reason}` });
|
|
309
|
-
}
|
|
310
|
-
finally {
|
|
311
|
-
clearTimeout(timeout);
|
|
312
|
-
}
|
|
313
|
-
}
|
|
314
|
-
return {
|
|
315
|
-
text: null,
|
|
316
|
-
status: 'inconclusive',
|
|
317
|
-
detail: failures.length
|
|
318
|
-
? `Could not confirm ${selected ? 'selected URL' : 'homepage'} activity: ${failures.map((attempt) => attempt.error).join('; ')}.`
|
|
319
|
-
: 'Could not confirm homepage activity.',
|
|
320
|
-
http: failedHttpObservation(failures, { durationMs: Date.now() - probeStartedAt }),
|
|
321
|
-
};
|
|
322
|
-
}
|
|
323
|
-
function deriveWebsiteActivity(homepageStatus, hasFavicon, alreadyParked = false) {
|
|
324
|
-
if (alreadyParked)
|
|
325
|
-
return 'parked';
|
|
326
|
-
if (homepageStatus === 'fetched' || homepageStatus === 'responded' || hasFavicon)
|
|
327
|
-
return 'active';
|
|
328
|
-
return 'unreachable';
|
|
329
|
-
}
|
|
330
|
-
function registryPolicyDetail(domain, fast) {
|
|
331
|
-
const details = [];
|
|
332
|
-
const rdapAdmission = registryServiceAdmissionFor(domain, 'rdap');
|
|
333
|
-
if (rdapAdmission && !rdapAdmission.allowed) {
|
|
334
|
-
details.push('RDAP was not queried because the registry access policy does not permit collection.');
|
|
335
|
-
}
|
|
336
|
-
const whoisAdmission = fast ? null : registryServiceAdmissionFor(domain, 'whois');
|
|
337
|
-
if (whoisAdmission && !whoisAdmission.allowed) {
|
|
338
|
-
details.push(whoisAdmission.state === 'permission_required'
|
|
339
|
-
? 'WHOIS was not queried because registry permission or source authorisation is required.'
|
|
340
|
-
: 'WHOIS was not queried because IANA publishes no domain WHOIS service for this suffix.');
|
|
341
|
-
}
|
|
342
|
-
return details.length ? ` ${details.join(' ')}` : '';
|
|
343
|
-
}
|
|
344
|
-
// fast: true skips the WHOIS fallback (no TCP:43 chain) and the homepage
|
|
345
|
-
// fetch (no for-sale/parking detection) - just RDAP plus the signals
|
|
346
|
-
// derivable from it (age, expiry proximity, privacy). Meant for scanning
|
|
347
|
-
// large sourcing candidate lists quickly and gently on registry rate
|
|
348
|
-
// limits; anything it can't resolve (state "unknown") is meant to get a
|
|
349
|
-
// follow-up deep check (fast: false, the default) on the shortlist only.
|
|
350
|
-
async function resolveDomainRegistration(domain, options, featurePolicy) {
|
|
351
|
-
options.signal?.throwIfAborted();
|
|
352
|
-
const fast = options.fast === true;
|
|
353
|
-
const rdapEnabled = featureDecision('rdap', featurePolicy).enabled;
|
|
354
|
-
const whoisEnabled = featureDecision('whois', featurePolicy).enabled;
|
|
355
|
-
const dnsIntelligenceEnabled = featureDecision('dns_intelligence', featurePolicy).enabled;
|
|
356
|
-
const hasPreloadedRdap = Object.prototype.hasOwnProperty.call(options, 'rdapRecord');
|
|
357
|
-
const hasPreloadedWhois = Object.prototype.hasOwnProperty.call(options, 'whoisChain');
|
|
358
|
-
const hasPreloadedRdapPromise = Object.prototype.hasOwnProperty.call(options, 'rdapRecordPromise');
|
|
359
|
-
const hasPreloadedWhoisPromise = Object.prototype.hasOwnProperty.call(options, 'whoisChainPromise');
|
|
360
|
-
const hasPreloadedDnsDelegation = Object.prototype.hasOwnProperty.call(options, 'dnsDelegation');
|
|
361
|
-
let nameservers = [];
|
|
362
|
-
let statuses = [];
|
|
363
|
-
let rdapServer = null;
|
|
364
|
-
let rdapFound = false;
|
|
365
|
-
let registrar = null;
|
|
366
|
-
let registrant = null;
|
|
367
|
-
let abuse = null;
|
|
368
|
-
let createdDate = null;
|
|
369
|
-
let expiryDate = null;
|
|
370
|
-
let createdDateIso = null;
|
|
371
|
-
let expiryDateIso = null;
|
|
372
|
-
let registrationSource = null;
|
|
373
|
-
let registrationConfidence = 'high';
|
|
374
|
-
let dnssec = null;
|
|
375
|
-
let registryDnsEvidence = null;
|
|
376
|
-
const assessment = (registration) => ({
|
|
377
|
-
registration, registryDnsEvidence, nameservers, dnssec,
|
|
378
|
-
});
|
|
379
|
-
if (rdapEnabled) {
|
|
380
|
-
try {
|
|
381
|
-
// Shared with /api/rdap (lib/rdap.mts's fetchRdapRecord) rather than a
|
|
382
|
-
// separate fetch+parse here - same registry data either way, and this
|
|
383
|
-
// also picks up that function's short-TTL cache (lib/lookup-cache.mts)
|
|
384
|
-
// and upstream timeout for free.
|
|
385
|
-
const recordValue = hasPreloadedRdapPromise
|
|
386
|
-
? await abortable(() => options.rdapRecordPromise, options.signal)
|
|
387
|
-
: hasPreloadedRdap
|
|
388
|
-
? options.rdapRecord
|
|
389
|
-
: await fetchRdapRecord('domain', domain, options.signal ? { signal: options.signal } : {});
|
|
390
|
-
options.signal?.throwIfAborted();
|
|
391
|
-
const record = errorRecord(recordValue);
|
|
392
|
-
const recordRdapServer = typeof record.rdapServer === 'string' ? record.rdapServer : null;
|
|
393
|
-
const upstreamStatus = typeof record.upstreamStatus === 'number' ? record.upstreamStatus : null;
|
|
394
|
-
if (Object.keys(record).length) {
|
|
395
|
-
rdapServer = recordRdapServer;
|
|
396
|
-
if (upstreamStatus === 404) {
|
|
397
|
-
return assessment({
|
|
398
|
-
state: 'available',
|
|
399
|
-
confidence: 'high',
|
|
400
|
-
detail: 'The registry\'s RDAP service has no record for this domain.',
|
|
401
|
-
source: 'rdap',
|
|
402
|
-
rdapServer: recordRdapServer,
|
|
403
|
-
});
|
|
404
|
-
}
|
|
405
|
-
const parsed = errorRecord(record.parsed);
|
|
406
|
-
if (Object.keys(parsed).length) {
|
|
407
|
-
registryDnsEvidence = parsed;
|
|
408
|
-
statuses = Array.isArray(parsed.statuses)
|
|
409
|
-
? parsed.statuses.filter((status) => typeof status === 'string').map((status) => status.toLowerCase())
|
|
410
|
-
: [];
|
|
411
|
-
nameservers = Array.isArray(parsed.nameservers)
|
|
412
|
-
? parsed.nameservers.filter((nameserver) => typeof nameserver === 'string')
|
|
413
|
-
: [];
|
|
414
|
-
registrar = compactContact(parsed.registrar);
|
|
415
|
-
registrant = compactContact(parsed.registrant);
|
|
416
|
-
abuse = compactContact(parsed.abuse);
|
|
417
|
-
const events = Array.isArray(parsed.events)
|
|
418
|
-
? parsed.events.map(errorRecord)
|
|
419
|
-
: [];
|
|
420
|
-
const lifecycle = errorRecord(parsed.lifecycle);
|
|
421
|
-
createdDate = typeof lifecycle.createdDate === 'string'
|
|
422
|
-
? lifecycle.createdDate
|
|
423
|
-
: rdapEventDate(events, 'registration');
|
|
424
|
-
expiryDate = typeof lifecycle.expiryDate === 'string'
|
|
425
|
-
? lifecycle.expiryDate
|
|
426
|
-
: rdapEventDate(events, 'expiration');
|
|
427
|
-
createdDateIso = typeof lifecycle.createdDateIso === 'string'
|
|
428
|
-
? lifecycle.createdDateIso
|
|
429
|
-
: registryDateIso(createdDate);
|
|
430
|
-
expiryDateIso = typeof lifecycle.expiryDateIso === 'string'
|
|
431
|
-
? lifecycle.expiryDateIso
|
|
432
|
-
: registryDateIso(expiryDate);
|
|
433
|
-
dnssec = typeof parsed.dnssec === 'string' ? parsed.dnssec : null;
|
|
434
|
-
rdapFound = true;
|
|
435
|
-
registrationSource = 'rdap';
|
|
436
|
-
}
|
|
437
|
-
}
|
|
438
|
-
}
|
|
439
|
-
catch {
|
|
440
|
-
options.signal?.throwIfAborted();
|
|
441
|
-
/* fall through to WHOIS-based detection (deep mode only) */
|
|
442
|
-
}
|
|
443
|
-
}
|
|
444
|
-
const dnsDelegationPromise = !rdapFound && dnsIntelligenceEnabled
|
|
445
|
-
? hasPreloadedDnsDelegation
|
|
446
|
-
? Promise.resolve(options.dnsDelegation)
|
|
447
|
-
: checkDnsDelegation(domain, {
|
|
448
|
-
...(options.resolveNs ? { resolver: options.resolveNs } : {}),
|
|
449
|
-
...(options.signal ? { signal: options.signal } : {}),
|
|
450
|
-
})
|
|
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(() => { });
|
|
455
|
-
let whoisChain = null;
|
|
456
|
-
let whoisParsed = null;
|
|
457
|
-
if (!rdapFound && !fast && whoisEnabled) {
|
|
458
|
-
try {
|
|
459
|
-
whoisChain = (hasPreloadedWhoisPromise
|
|
460
|
-
? await abortable(() => options.whoisChainPromise, options.signal)
|
|
461
|
-
: hasPreloadedWhois
|
|
462
|
-
? options.whoisChain
|
|
463
|
-
: await buildWhoisChain(domain, options.signal ? { signal: options.signal } : {})) ?? null;
|
|
464
|
-
options.signal?.throwIfAborted();
|
|
465
|
-
if (!Array.isArray(whoisChain))
|
|
466
|
-
throw new Error('WHOIS chain unavailable');
|
|
467
|
-
const parsed = parseWhoisChain(whoisChain);
|
|
468
|
-
whoisParsed = parsed;
|
|
469
|
-
if (parsed.notFound) {
|
|
470
|
-
return assessment({
|
|
471
|
-
state: 'available',
|
|
472
|
-
confidence: 'medium',
|
|
473
|
-
detail: `WHOIS reports no matching record for this domain${parsed.notFoundSource ? ` (per ${parsed.notFoundSource})` : ''}.`,
|
|
474
|
-
source: 'whois',
|
|
475
|
-
});
|
|
476
|
-
}
|
|
477
|
-
if (parsed.registrationStatus === 'registered') {
|
|
478
|
-
if (parsed.nameservers.length)
|
|
479
|
-
nameservers = parsed.nameservers;
|
|
480
|
-
if (parsed.statuses.length)
|
|
481
|
-
statuses = parsed.statuses.map((status) => status.toLowerCase());
|
|
482
|
-
if (parsed.registrar) {
|
|
483
|
-
registrar = {
|
|
484
|
-
handle: null,
|
|
485
|
-
ianaId: parsed.registrarIanaId || null,
|
|
486
|
-
name: parsed.registrar,
|
|
487
|
-
org: null,
|
|
488
|
-
email: parsed.abuseEmail || null,
|
|
489
|
-
phone: parsed.abusePhone || null,
|
|
490
|
-
};
|
|
491
|
-
}
|
|
492
|
-
if (parsed.registrantName || parsed.registrantOrg || parsed.registrantEmail || parsed.registrantPhone) {
|
|
493
|
-
registrant = {
|
|
494
|
-
handle: null,
|
|
495
|
-
name: parsed.registrantName || null,
|
|
496
|
-
org: parsed.registrantOrg || null,
|
|
497
|
-
email: parsed.registrantEmail || null,
|
|
498
|
-
phone: parsed.registrantPhone || null,
|
|
499
|
-
};
|
|
500
|
-
}
|
|
501
|
-
if (parsed.abuseEmail || parsed.abusePhone) {
|
|
502
|
-
abuse = { handle: null, name: null, org: parsed.registrar || null, email: parsed.abuseEmail || null, phone: parsed.abusePhone || null };
|
|
503
|
-
}
|
|
504
|
-
createdDate = parsed.createdDate || null;
|
|
505
|
-
expiryDate = parsed.expiryDate || null;
|
|
506
|
-
createdDateIso = parsed.createdDateIso || parsed.lifecycle?.createdDateIso || registryDateIso(createdDate);
|
|
507
|
-
expiryDateIso = parsed.expiryDateIso || parsed.lifecycle?.expiryDateIso || registryDateIso(expiryDate);
|
|
508
|
-
if (!dnssec)
|
|
509
|
-
dnssec = parsed.dnssec || null;
|
|
510
|
-
registrationSource = 'whois';
|
|
511
|
-
}
|
|
512
|
-
}
|
|
513
|
-
catch {
|
|
514
|
-
options.signal?.throwIfAborted();
|
|
515
|
-
/* if both RDAP and WHOIS fail, we simply can't determine availability */
|
|
516
|
-
}
|
|
517
|
-
}
|
|
518
|
-
// WHOIS ran and was not a confirmed not-found, but produced no positive
|
|
519
|
-
// registration evidence either - e.g. the registry answered inconclusively
|
|
520
|
-
// or every referral hop failed/rate-limited. Report "unknown" rather than
|
|
521
|
-
// fabricating "registered" from an empty record.
|
|
522
|
-
const hasWhoisRegistrationData = whoisParsed?.registrationStatus === 'registered';
|
|
523
|
-
let dnsDelegated = false;
|
|
524
|
-
if (!rdapFound && !hasWhoisRegistrationData && dnsDelegationPromise) {
|
|
525
|
-
const delegation = await dnsDelegationPromise;
|
|
526
|
-
if (delegation && delegation.delegated === true && Array.isArray(delegation.nameservers) && delegation.nameservers.length) {
|
|
527
|
-
dnsDelegated = true;
|
|
528
|
-
nameservers = delegation.nameservers;
|
|
529
|
-
registrationSource = 'dns';
|
|
530
|
-
registrationConfidence = 'medium';
|
|
531
|
-
}
|
|
532
|
-
}
|
|
533
|
-
options.signal?.throwIfAborted();
|
|
534
|
-
if (!rdapFound && !hasWhoisRegistrationData && !dnsDelegated) {
|
|
535
|
-
const disabledSources = [
|
|
536
|
-
!rdapEnabled ? 'RDAP' : null,
|
|
537
|
-
!fast && !whoisEnabled ? 'WHOIS' : null,
|
|
538
|
-
!dnsIntelligenceEnabled ? 'DNS intelligence' : null,
|
|
539
|
-
].filter(Boolean);
|
|
540
|
-
const disabledDetail = disabledSources.length
|
|
541
|
-
? ` ${disabledSources.join(', ')} ${disabledSources.length === 1 ? 'is' : 'are'} disabled by deployment policy.`
|
|
542
|
-
: '';
|
|
543
|
-
const policyDetail = registryPolicyDetail(domain, fast);
|
|
544
|
-
return assessment({
|
|
545
|
-
state: 'unknown',
|
|
546
|
-
confidence: 'low',
|
|
547
|
-
detail: fast
|
|
548
|
-
? `No enabled registration source produced a record or authoritative delegation. A fast scan cannot determine registration status.${disabledDetail}${policyDetail}`
|
|
549
|
-
: whoisParsed && whoisParsed.failedHop
|
|
550
|
-
? `WHOIS was inconclusive - a referral hop did not answer conclusively (${whoisParsed.failedHop}).${policyDetail}`
|
|
551
|
-
: `No enabled registration source returned conclusive data or an authoritative DNS delegation.${disabledDetail}${policyDetail}`,
|
|
552
|
-
...(!fast && whoisEnabled ? { source: 'whois' } : {}),
|
|
553
|
-
});
|
|
554
|
-
}
|
|
555
|
-
const domainAgeDays = computeAgeDays(createdDateIso || createdDate);
|
|
556
|
-
const expiresInDays = computeDaysUntil(expiryDateIso || expiryDate);
|
|
557
|
-
// DNS proves delegation only; it says nothing about whether registry
|
|
558
|
-
// contact data is privacy-protected or merely unavailable.
|
|
559
|
-
const privacyProtected = registrationSource === 'dns' ? null : isPrivacyProtected(registrant);
|
|
560
|
-
const baseInfo = {
|
|
561
|
-
nameservers,
|
|
562
|
-
statuses,
|
|
563
|
-
registrar,
|
|
564
|
-
registrant,
|
|
565
|
-
abuse,
|
|
566
|
-
createdDate,
|
|
567
|
-
expiryDate,
|
|
568
|
-
createdDateIso,
|
|
569
|
-
expiryDateIso,
|
|
570
|
-
rdapServer,
|
|
571
|
-
domainAgeDays,
|
|
572
|
-
expiresInDays,
|
|
573
|
-
privacyProtected,
|
|
574
|
-
dnssec,
|
|
575
|
-
source: registrationSource,
|
|
576
|
-
};
|
|
577
|
-
if (statuses.some((status) => {
|
|
578
|
-
const normalizedStatus = registryStatusToken(status);
|
|
579
|
-
return normalizedStatus.includes('pendingdelete') || normalizedStatus.includes('redemptionperiod');
|
|
580
|
-
})) {
|
|
581
|
-
return assessment({
|
|
582
|
-
state: 'expiring',
|
|
583
|
-
confidence: 'medium',
|
|
584
|
-
detail: 'Domain is in redemption/pending-delete status and may become available soon.',
|
|
585
|
-
...baseInfo,
|
|
586
|
-
});
|
|
587
|
-
}
|
|
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
|
-
}
|
|
7
|
+
import { resolveDomainRegistration } from "./domain-registration.mjs";
|
|
8
|
+
import { enrichWebsite } from "./website-enrichment.mjs";
|
|
597
9
|
async function checkDomainAvailability(domain, options = {}) {
|
|
598
10
|
options.signal?.throwIfAborted();
|
|
599
11
|
const fast = options.fast === true;
|
|
@@ -608,201 +20,13 @@ async function checkDomainAvailability(domain, options = {}) {
|
|
|
608
20
|
const websiteProbeEnabled = featureDecision('website_probe', featurePolicy).enabled;
|
|
609
21
|
if (selectedUrl && (fast || !websiteProbeEnabled))
|
|
610
22
|
throw new TypeError('Selected URL collection requires an enabled Deep website observation.');
|
|
611
|
-
const
|
|
23
|
+
const assessment = await resolveDomainRegistration(domain, options, featurePolicy);
|
|
24
|
+
const { registration } = assessment;
|
|
612
25
|
if (fast || registration.state !== 'registered' && !selectedUrl)
|
|
613
26
|
return registration;
|
|
614
|
-
|
|
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);
|
|
622
|
-
// Registered - look for for-sale/parked/website signals (homepage fetch,
|
|
623
|
-
// deep mode only), and check for a configured mail exchanger as a
|
|
624
|
-
// phishing-risk signal (a lookalike domain that can receive/send mail is
|
|
625
|
-
// capable of running credential-harvesting or BEC campaigns).
|
|
626
|
-
const nsSignal = registration.state === 'registered' ? nameservers.find((ns) => PARKING_NS_PATTERNS.some((re) => re.test(ns))) : undefined;
|
|
627
|
-
let forSaleSignal = nsSignal ? `parking nameserver (${nsSignal})` : null;
|
|
628
|
-
let activityStatus = nsSignal && observationHostname === domain ? 'parked' : 'unknown';
|
|
629
|
-
// Homepage + bounded DNS evidence resolve in parallel; the favicon fetch is sequenced after
|
|
630
|
-
// the homepage so it can use any <link rel="icon"> the page declares (many
|
|
631
|
-
// sites serve no /favicon.ico and only point to a CDN PNG this way). One
|
|
632
|
-
// extra round-trip on the already-slow deep path, in exchange for finding
|
|
633
|
-
// favicons the bare /favicon.ico probe would miss.
|
|
634
|
-
const [homepage, dnsIntelligence, tlsIntelligence] = await Promise.all([
|
|
635
|
-
websiteProbeEnabled ? fetchHomepageForDomain(observationHostname, {
|
|
636
|
-
...(selectedUrl ? { selectedUrl } : {}), ...(options.signal ? { signal: options.signal } : {}),
|
|
637
|
-
}).catch((err) => ({
|
|
638
|
-
text: null,
|
|
639
|
-
status: 'inconclusive',
|
|
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)}.`,
|
|
642
|
-
http: failedHttpObservation([
|
|
643
|
-
{ url: selectedUrl ?? `https://${observationHostname}`, error: selectedUrl ? 'request failed' : String(err && err.message ? err.message : 'request failed') },
|
|
644
|
-
]),
|
|
645
|
-
})) : Promise.resolve({
|
|
646
|
-
text: null,
|
|
647
|
-
status: 'skipped',
|
|
648
|
-
detail: 'Website probing is disabled by deployment policy.',
|
|
649
|
-
http: skippedHttpObservation(),
|
|
650
|
-
}),
|
|
651
|
-
dnsIntelligenceEnabled
|
|
652
|
-
? collectDns(observationHostname, {
|
|
653
|
-
...(options.dnsResolvers ? { resolvers: options.dnsResolvers } : {}),
|
|
654
|
-
includeExtendedContext: options.includeExtendedDnsContext === true,
|
|
655
|
-
includeInheritedCaa: options.includeInheritedCaa === true,
|
|
656
|
-
registryEvidence: registryDnsEvidence,
|
|
657
|
-
...(observationHostname !== domain ? { registrationDomain: domain } : {}),
|
|
658
|
-
})
|
|
659
|
-
: Promise.resolve(skippedDnsIntelligence('DNS intelligence is disabled by deployment policy.', {
|
|
660
|
-
includeExtendedContext: options.includeExtendedDnsContext === true,
|
|
661
|
-
includeInheritedCaa: options.includeInheritedCaa === true,
|
|
662
|
-
})),
|
|
663
|
-
tlsIntelligenceEnabled
|
|
664
|
-
? collectTls(observationHostname)
|
|
665
|
-
: Promise.resolve(skippedTlsObservation()),
|
|
666
|
-
]);
|
|
667
|
-
options.signal?.throwIfAborted();
|
|
668
|
-
const page = homepage.text;
|
|
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;
|
|
671
|
-
let htmlSignals = {
|
|
672
|
-
domainSaleSignal: null,
|
|
673
|
-
pageTitle: null,
|
|
674
|
-
hasPasswordField: false,
|
|
675
|
-
phishingLanguageMatch: null,
|
|
676
|
-
hasExternalFormAction: null,
|
|
677
|
-
externalAssetHosts: [],
|
|
678
|
-
cspMetaPolicy: null,
|
|
679
|
-
pageIdentity: null,
|
|
680
|
-
credentialSurfaceProfile: null,
|
|
681
|
-
structuredDataIdentity: null,
|
|
682
|
-
technologyProfile: null,
|
|
683
|
-
pageRoleProfile: null,
|
|
684
|
-
clientBehaviorProfile: null,
|
|
685
|
-
};
|
|
686
|
-
if (homepage.status === 'fetched') {
|
|
687
|
-
if (page) {
|
|
688
|
-
const responseContentType = homepage.http?.response?.contentType;
|
|
689
|
-
const pageIdentityEligible = typeof responseContentType !== 'string'
|
|
690
|
-
|| responseContentType.trim() === ''
|
|
691
|
-
|| /^(?:text\/html|application\/xhtml\+xml)(?:\s*;|$)/i.test(responseContentType.trim());
|
|
692
|
-
htmlSignals = await extractHtmlSignals(page, observationHostname, {
|
|
693
|
-
...(options.signal ? { signal: options.signal } : {}),
|
|
694
|
-
...(pageAnalysis ? { htmlAnalysis: pageAnalysis } : {}),
|
|
695
|
-
baseUrl: pageBaseUrl,
|
|
696
|
-
...(typeof homepage.http?.observedAt === 'string' ? { observedAt: homepage.http.observedAt } : {}),
|
|
697
|
-
sourceTruncated: homepage.http?.response?.bodyTruncated === true,
|
|
698
|
-
exactBodyHash: homepage.http?.response?.bodyHash,
|
|
699
|
-
httpServer: homepage.http?.response?.server,
|
|
700
|
-
responseHeaders: homepage.technologyHeaders,
|
|
701
|
-
activityStatus,
|
|
702
|
-
includePageIdentity: pageIdentityEligible,
|
|
703
|
-
includePublicationMetadata: options.includePublicationMetadata !== false,
|
|
704
|
-
...(options.includeCredentialSurfaceProfile !== undefined
|
|
705
|
-
? { includeCredentialSurfaceProfile: options.includeCredentialSurfaceProfile }
|
|
706
|
-
: {}),
|
|
707
|
-
...(options.includeStructuredDataIdentity !== undefined
|
|
708
|
-
? { includeStructuredDataIdentity: options.includeStructuredDataIdentity }
|
|
709
|
-
: {}),
|
|
710
|
-
...(options.includeTechnologyProfile !== undefined
|
|
711
|
-
? { includeTechnologyProfile: options.includeTechnologyProfile }
|
|
712
|
-
: {}),
|
|
713
|
-
});
|
|
714
|
-
if (pageIdentityEligible && htmlSignals.domainSaleSignal) {
|
|
715
|
-
if (!selectedUrl && observationHostname === domain)
|
|
716
|
-
forSaleSignal = forSaleSignal || htmlSignals.domainSaleSignal;
|
|
717
|
-
activityStatus = 'parked';
|
|
718
|
-
}
|
|
719
|
-
}
|
|
720
|
-
}
|
|
721
|
-
if (options.includeTechnologyProfile !== false && htmlSignals.technologyProfile === null
|
|
722
|
-
&& ['fetched', 'responded'].includes(homepage.status)) {
|
|
723
|
-
htmlSignals.technologyProfile = await analyzeWebsiteTechnology({
|
|
724
|
-
...(options.signal ? { signal: options.signal } : {}),
|
|
725
|
-
htmlAvailable: false,
|
|
726
|
-
httpServer: homepage.http?.response?.server,
|
|
727
|
-
responseHeaders: homepage.technologyHeaders,
|
|
728
|
-
observedAt: homepage.http?.observedAt,
|
|
729
|
-
});
|
|
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;
|
|
744
|
-
const responsePolicy = qualifyResponsePolicyWithCspMeta(homepage.responsePolicy, htmlSignals.cspMetaPolicy);
|
|
745
|
-
const retainedHttp = options.includeDeliveryMetadata === false
|
|
746
|
-
? withoutHttpDeliveryMetadata(homepage.http)
|
|
747
|
-
: homepage.http;
|
|
748
|
-
const { cspMetaPolicy: _cspMetaPolicy, domainSaleSignal: _domainSaleSignal, ...retainedHtmlSignals } = htmlSignals;
|
|
749
|
-
const securityPosture = options.includeSecurityPosture === false ? null : analyzeWebsiteSecurityPosture({
|
|
750
|
-
http: homepage.http,
|
|
751
|
-
responsePolicy,
|
|
752
|
-
pageIdentity: htmlSignals.pageIdentity,
|
|
753
|
-
tls: tlsIntelligence,
|
|
754
|
-
dns: dnsIntelligence,
|
|
755
|
-
dnssec,
|
|
756
|
-
observedAt: homepage.http?.observedAt,
|
|
757
|
-
});
|
|
758
|
-
const redirectSaleSignal = forSaleRedirectSignal(homepage.http);
|
|
759
|
-
if (!selectedUrl && observationHostname === domain && !forSaleSignal && redirectSaleSignal) {
|
|
760
|
-
forSaleSignal = redirectSaleSignal;
|
|
761
|
-
activityStatus = 'parked';
|
|
762
|
-
}
|
|
763
|
-
const faviconProvedActive = !selectedUrl && homepage.status === 'inconclusive' && Boolean(favicon);
|
|
764
|
-
const websiteProbeStatus = faviconProvedActive ? 'responded' : homepage.status;
|
|
765
|
-
const websiteProbeDetail = faviconProvedActive
|
|
766
|
-
? `${homepage.detail} A favicon responded successfully, confirming an active web service.`
|
|
767
|
-
: homepage.detail;
|
|
768
|
-
if (websiteProbeEnabled) {
|
|
769
|
-
activityStatus = deriveWebsiteActivity(homepage.status, !selectedUrl && Boolean(favicon), activityStatus === 'parked');
|
|
770
|
-
}
|
|
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}).`,
|
|
784
|
-
};
|
|
785
|
-
return {
|
|
786
|
-
...registrationResult,
|
|
787
|
-
activityStatus,
|
|
788
|
-
websiteProbeStatus,
|
|
789
|
-
websiteProbeDetail,
|
|
790
|
-
http: retainedHttp,
|
|
791
|
-
...(options.observationHostname !== undefined || selectedUrl ? { observationHostname } : {}),
|
|
792
|
-
...(selectedUrl ? { webObservationMode: 'selected_url' } : {}),
|
|
793
|
-
deepScanComplete,
|
|
794
|
-
faviconHash,
|
|
795
|
-
faviconPHash,
|
|
796
|
-
...retainedHtmlSignals,
|
|
797
|
-
securityPosture,
|
|
798
|
-
nameservers: nameservers.length || observationHostname !== domain ? nameservers : dnsIntelligence.records.ns,
|
|
799
|
-
dns: dnsIntelligence,
|
|
800
|
-
tls: tlsIntelligence,
|
|
801
|
-
hasMx: dnsIntelligence.hasMx,
|
|
802
|
-
hasNullMx: dnsIntelligence.hasNullMx,
|
|
803
|
-
mxHosts: dnsIntelligence.mxHosts,
|
|
804
|
-
hasSpf: dnsIntelligence.hasSpf,
|
|
805
|
-
hasDmarc: dnsIntelligence.hasDmarc,
|
|
806
|
-
};
|
|
27
|
+
return enrichWebsite({ domain, observationHostname, selectedUrl, ...assessment }, options, featurePolicy);
|
|
807
28
|
}
|
|
808
|
-
export { checkDomainAvailability
|
|
29
|
+
export { checkDomainAvailability };
|
|
30
|
+
export { checkDnsDelegation, isPrivacyProtected } from "./domain-registration.mjs";
|
|
31
|
+
export { fetchHomepage, deriveWebsiteActivity, forSaleRedirectSignal } from "./website-enrichment.mjs";
|
|
32
|
+
export { parseRegistryDate as parseWhoisDate } from "./registry-dates.mjs";
|