@slicedearth/whoisleuth-cli 1.34.0 → 1.39.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/README.md +19 -2
- package/bin/whoisleuth.mjs +41 -24
- package/cli/arguments.mjs +326 -3
- package/cli/artifact-verify.mjs +60 -14
- package/cli/bulk.mjs +1 -2
- package/cli/compare.mjs +1 -1
- package/cli/completion.mjs +36 -2
- package/cli/doctor.mjs +11 -5
- package/cli/evidence-command-arguments.mjs +1 -1
- package/cli/evidence-command-runner.mjs +2 -2
- package/cli/evidence-signing.mjs +4 -4
- package/cli/export-evidence.mjs +9 -2
- package/cli/formatters/html.mjs +1 -1
- package/cli/formatters/json.mjs +13 -1
- package/cli/formatters/terminal.mjs +14 -4
- package/cli/investigation-plan.mjs +112 -0
- package/cli/local-mmdb-review.mjs +76 -0
- package/cli/lookalike-calibration.mjs +121 -0
- package/cli/lookup-command-runner.mjs +4 -1
- package/cli/lookup-plan.mjs +3 -3
- package/cli/lookup-reconcile.mjs +141 -0
- package/cli/lookup-timeline.mjs +1 -1
- package/cli/mail-review.mjs +48 -3
- package/cli/offline-evidence-review.mjs +159 -0
- package/cli/registry-doctor.mjs +125 -0
- package/cli/registry-support.mjs +1 -4
- package/cli/risk-calibration.mjs +117 -9
- package/cli/runner.mjs +342 -15
- package/cli/saved-lookup.mjs +2 -2
- package/cli/sharing-review.mjs +202 -0
- package/cli/source-reliability.mjs +1 -1
- package/docs/cli-reference.md +1110 -0
- package/docs/cli.md +224 -847
- package/frontend/src/lib/analysis/acquisition-decision-packet.js +1 -1
- package/frontend/src/lib/analysis/acquisition-due-diligence.js +6 -6
- package/frontend/src/lib/analysis/bulk-domain-comparison.js +12 -12
- package/frontend/src/lib/analysis/bulk-mail-exposure.js +1 -1
- package/frontend/src/lib/analysis/bulk-retry-plan.js +3 -10
- package/frontend/src/lib/analysis/bulk-session-model.js +7 -2
- package/frontend/src/lib/analysis/bulk-source-coverage.js +76 -0
- package/frontend/src/lib/analysis/case-evidence-model.js +26 -4
- package/frontend/src/lib/analysis/case-migration-model.js +6 -1
- package/frontend/src/lib/analysis/case-record-contracts.js +4 -2
- package/frontend/src/lib/analysis/case-record-core.js +5 -1
- package/frontend/src/lib/analysis/case-record-operations.js +6 -1
- package/frontend/src/lib/analysis/case-response-packet.js +7 -7
- package/frontend/src/lib/analysis/evidence-coverage-ledger.js +328 -0
- package/frontend/src/lib/analysis/http-summary.js +2 -1
- package/frontend/src/lib/analysis/investigation-capsule.js +105 -0
- package/frontend/src/lib/analysis/lookup-asset-graph.js +806 -0
- package/frontend/src/lib/analysis/lookup-decision-support.js +480 -0
- package/frontend/src/lib/analysis/lookup-investigation-brief.js +153 -0
- package/frontend/src/lib/analysis/lookup-presentation.js +74 -0
- package/frontend/src/lib/analysis/lookup-response.js +2 -0
- package/frontend/src/lib/analysis/lookup-source-refresh.js +304 -0
- package/frontend/src/lib/analysis/lookup-summary-model.js +398 -0
- package/frontend/src/lib/analysis/page-similarity.js +1 -1
- package/frontend/src/lib/analysis/relationship-evidence.js +3 -3
- package/frontend/src/lib/analysis/relationship-observation-model.js +4 -4
- package/frontend/src/lib/analysis/scoring.js +8 -88
- package/frontend/src/lib/analysis/shortlist-model.js +8 -4
- package/frontend/src/lib/analysis/utils.js +7 -40
- package/frontend/src/lib/analysis/visualization-bounds.js +26 -0
- package/frontend/src/lib/analysis/visualization-force-graph.js +352 -0
- package/frontend/src/lib/analysis/visualization-matrix.js +88 -0
- package/frontend/src/lib/analysis/visualization-models.js +370 -0
- package/frontend/src/lib/analysis/visualization-time-series.js +144 -0
- package/frontend/src/lib/analysis/website-snapshot-model.js +57 -3
- package/frontend/src/lib/analysis/workspace-archive-crypto.js +1 -1
- package/frontend/src/lib/analysis/workspace-archive.js +2 -2
- package/lib/analyst-taxonomy.mjs +40 -0
- package/lib/availability.mjs +30 -10
- package/lib/browser-library-profile.mjs +15 -2
- package/lib/bulk-limits.mjs +13 -0
- package/lib/certificate-extension-profile.mjs +71 -0
- package/lib/credential-surface-profile.mjs +1 -1
- package/lib/ct-response-bounds.mjs +9 -0
- package/lib/ct-search.mjs +66 -12
- package/lib/dns-delegation-health.mjs +56 -5
- package/lib/dnssec-evidence-validation.mjs +235 -0
- package/lib/domain-assurance.mjs +368 -0
- package/lib/domain-control-manifest.mjs +382 -0
- package/lib/domain-posture.mjs +11 -10
- package/lib/encrypted-dns-contract.mjs +81 -0
- package/lib/evidence-export.mjs +17 -3
- package/lib/evidence-report-markdown.mjs +1 -1
- package/lib/evidence-report.mjs +4 -1
- package/lib/favicon.mjs +3 -8
- package/lib/generated/cisa-kev-catalog.mjs +1668 -0
- package/lib/html-signals.mjs +4 -0
- package/lib/http-evidence-bounds.mjs +4 -0
- package/lib/http-intelligence.mjs +2 -1
- package/lib/ip-prefix.mjs +87 -0
- package/lib/local-geoip-evidence.mjs +89 -0
- package/lib/lookup-response-contract.mjs +346 -0
- package/lib/observed-network-context.mjs +1 -1
- package/lib/opportunity-scoring.mjs +107 -0
- package/lib/outbound-identity.mjs +9 -0
- package/lib/outbound-request-bounds.mjs +9 -0
- package/lib/page-fingerprints.mjs +1 -1
- package/lib/perceptual-hash-comparison.mjs +34 -0
- package/lib/perceptual-hash.mjs +3 -31
- package/lib/project-metadata.mjs +8 -0
- package/lib/provider-policy-admission.mjs +49 -0
- package/lib/rdap-capabilities.mjs +6 -6
- package/lib/rdap-search-workbench.mjs +247 -0
- package/lib/rdap.mjs +7 -2
- package/lib/registry-access.mjs +1 -1
- package/lib/registry-capabilities.mjs +31 -1
- package/lib/registry-capability-catalogue.mjs +15 -14
- package/lib/registry-insights.mjs +6 -6
- package/lib/response-policy.mjs +81 -6
- package/lib/risk-scoring.mjs +206 -83
- package/lib/rpki-evidence.mjs +105 -0
- package/lib/safe-fetch.mjs +2 -1
- package/lib/scoring-evidence-quality.mjs +107 -0
- package/lib/security-txt.mjs +3 -3
- package/lib/semantic-version.mjs +54 -0
- package/lib/static-html-analysis.mjs +27 -0
- package/lib/threat-intelligence-manifests.mjs +1 -1
- package/lib/threatfox-intelligence.mjs +11 -6
- package/lib/tls-intelligence.mjs +13 -3
- package/lib/tlsa-evidence.mjs +194 -0
- package/lib/urlhaus-intelligence.mjs +11 -6
- package/lib/urlscan-intelligence.mjs +10 -5
- package/lib/website-security-posture.mjs +16 -9
- package/lib/website-technology.mjs +1 -1
- package/lib/whois-chain.mjs +14 -152
- package/lib/whois.mjs +2 -2
- package/package.json +6 -2
- package/third-party-notices.txt +911 -19
package/lib/html-signals.mjs
CHANGED
|
@@ -10,6 +10,7 @@ import { analyzeClientBehavior } from "./client-behavior-profile.mjs";
|
|
|
10
10
|
import { createObservation } from "./observation.mjs";
|
|
11
11
|
import { createPageFingerprints } from "./page-fingerprints.mjs";
|
|
12
12
|
import { analyzePageRole } from "./page-role-profile.mjs";
|
|
13
|
+
import { analyzeCspMetaPolicies } from "./response-policy.mjs";
|
|
13
14
|
import { analyzeStaticHtml } from "./static-html-analysis.mjs";
|
|
14
15
|
import { analyzeStructuredDataIdentity } from "./structured-data-identity.mjs";
|
|
15
16
|
import { analyzeWebsiteTechnology } from "./website-technology.mjs";
|
|
@@ -642,6 +643,9 @@ function extractHtmlSignals(html, domain, options = {}) {
|
|
|
642
643
|
? pageIdentity.forms.externalActionOrigins.length > 0
|
|
643
644
|
: null,
|
|
644
645
|
externalAssetHosts: extractExternalAssetHosts(html, domain),
|
|
646
|
+
cspMetaPolicy: htmlAnalysis
|
|
647
|
+
? analyzeCspMetaPolicies(htmlAnalysis.cspMetaPolicies, htmlAnalysis.cspMetaLimitReached)
|
|
648
|
+
: null,
|
|
645
649
|
pageIdentity,
|
|
646
650
|
credentialSurfaceProfile: includeCredentialSurfaceProfile && htmlAnalysis ? analyzeCredentialSurfaceProfile({
|
|
647
651
|
htmlAnalysis,
|
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
// converts the shared safe-fetch trace and selected response metadata into an
|
|
4
4
|
// additive evidence object suitable for API responses and exports.
|
|
5
5
|
import { createObservation } from "./observation.mjs";
|
|
6
|
+
import { MAX_HTTP_EVIDENCE_REDIRECTS } from "./http-evidence-bounds.mjs";
|
|
6
7
|
const MAX_HTTP_PROVENANCE_URL = 2048;
|
|
7
|
-
const MAX_HTTP_REDIRECTS =
|
|
8
|
+
const MAX_HTTP_REDIRECTS = MAX_HTTP_EVIDENCE_REDIRECTS;
|
|
8
9
|
const MAX_HTTP_ATTEMPTS = 2;
|
|
9
10
|
const MAX_HTTP_ERROR_LENGTH = 180;
|
|
10
11
|
const MAX_HTTP_HEADER_LENGTH = 1024;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { isIP } from 'node:net';
|
|
2
|
+
function ipv4Value(value) {
|
|
3
|
+
return value.split('.').reduce((total, part) => (total << 8n) | BigInt(Number(part)), 0n);
|
|
4
|
+
}
|
|
5
|
+
function ipv6Parts(value) {
|
|
6
|
+
if (value.includes('.'))
|
|
7
|
+
return null;
|
|
8
|
+
const halves = value.toLowerCase().split('::');
|
|
9
|
+
if (halves.length > 2)
|
|
10
|
+
return null;
|
|
11
|
+
const left = halves[0] ? halves[0].split(':') : [];
|
|
12
|
+
const right = halves.length === 2 && halves[1] ? halves[1].split(':') : [];
|
|
13
|
+
if (left.some((part) => !/^[0-9a-f]{1,4}$/u.test(part))
|
|
14
|
+
|| right.some((part) => !/^[0-9a-f]{1,4}$/u.test(part)))
|
|
15
|
+
return null;
|
|
16
|
+
const missing = 8 - left.length - right.length;
|
|
17
|
+
if (halves.length === 1 && missing !== 0 || halves.length === 2 && missing < 1)
|
|
18
|
+
return null;
|
|
19
|
+
return [...left, ...Array(missing).fill('0'), ...right].map((part) => Number.parseInt(part, 16));
|
|
20
|
+
}
|
|
21
|
+
function ipValue(value, family) {
|
|
22
|
+
if (family === 4)
|
|
23
|
+
return ipv4Value(value);
|
|
24
|
+
const parts = ipv6Parts(value);
|
|
25
|
+
return parts?.reduce((total, part) => (total << 16n) | BigInt(part), 0n) ?? null;
|
|
26
|
+
}
|
|
27
|
+
function parseIpPrefix(value) {
|
|
28
|
+
if (typeof value !== 'string' || value.length > 96 || /[\s%\u0000-\u001f\u007f]/u.test(value))
|
|
29
|
+
return null;
|
|
30
|
+
const [addressValue, lengthValue, ...rest] = value.toLowerCase().split('/');
|
|
31
|
+
if (!addressValue || rest.length > 0)
|
|
32
|
+
return null;
|
|
33
|
+
const family = isIP(addressValue);
|
|
34
|
+
if (family !== 4 && family !== 6)
|
|
35
|
+
return null;
|
|
36
|
+
const maximum = family === 4 ? 32 : 128;
|
|
37
|
+
if (lengthValue !== undefined && !/^\d{1,3}$/u.test(lengthValue))
|
|
38
|
+
return null;
|
|
39
|
+
const length = lengthValue === undefined ? maximum : Number(lengthValue);
|
|
40
|
+
if (!Number.isInteger(length) || length < 0 || length > maximum)
|
|
41
|
+
return null;
|
|
42
|
+
const numeric = ipValue(addressValue, family);
|
|
43
|
+
if (numeric === null)
|
|
44
|
+
return null;
|
|
45
|
+
const full = (1n << BigInt(maximum)) - 1n;
|
|
46
|
+
const mask = length === 0 ? 0n : (full << BigInt(maximum - length)) & full;
|
|
47
|
+
return Object.freeze({ family, address: addressValue, length, value: numeric & mask, mask });
|
|
48
|
+
}
|
|
49
|
+
function prefixContains(container, candidate) {
|
|
50
|
+
return container.family === candidate.family
|
|
51
|
+
&& container.length <= candidate.length
|
|
52
|
+
&& (candidate.value & container.mask) === container.value;
|
|
53
|
+
}
|
|
54
|
+
function formatIpPrefix(prefix) {
|
|
55
|
+
if (prefix.family === 4) {
|
|
56
|
+
const parts = [24n, 16n, 8n, 0n].map((shift) => Number((prefix.value >> shift) & 255n));
|
|
57
|
+
return `${parts.join('.')}/${prefix.length}`;
|
|
58
|
+
}
|
|
59
|
+
const parts = Array.from({ length: 8 }, (_, index) => {
|
|
60
|
+
const shift = BigInt((7 - index) * 16);
|
|
61
|
+
return ((prefix.value >> shift) & 0xffffn).toString(16);
|
|
62
|
+
});
|
|
63
|
+
let bestStart = -1;
|
|
64
|
+
let bestLength = 0;
|
|
65
|
+
for (let start = 0; start < parts.length;) {
|
|
66
|
+
if (parts[start] !== '0') {
|
|
67
|
+
start += 1;
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
let end = start + 1;
|
|
71
|
+
while (end < parts.length && parts[end] === '0')
|
|
72
|
+
end += 1;
|
|
73
|
+
const length = end - start;
|
|
74
|
+
if (length > bestLength && length >= 2) {
|
|
75
|
+
bestStart = start;
|
|
76
|
+
bestLength = length;
|
|
77
|
+
}
|
|
78
|
+
start = end;
|
|
79
|
+
}
|
|
80
|
+
if (bestStart === -1)
|
|
81
|
+
return `${parts.join(':')}/${prefix.length}`;
|
|
82
|
+
const left = parts.slice(0, bestStart).join(':');
|
|
83
|
+
const right = parts.slice(bestStart + bestLength).join(':');
|
|
84
|
+
const address = left && right ? `${left}::${right}` : left ? `${left}::` : right ? `::${right}` : '::';
|
|
85
|
+
return `${address}/${prefix.length}`;
|
|
86
|
+
}
|
|
87
|
+
export { parseIpPrefix, prefixContains, formatIpPrefix };
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { isIP } from 'node:net';
|
|
2
|
+
import { formatIpPrefix, parseIpPrefix, prefixContains } from "./ip-prefix.mjs";
|
|
3
|
+
const LOCAL_GEOIP_SCHEMA = 'whoisleuth.local-geoip-evidence';
|
|
4
|
+
const LOCAL_GEOIP_VERSION = 1;
|
|
5
|
+
const MAX_GEOIP_RECORDS = 100_000;
|
|
6
|
+
function record(value) {
|
|
7
|
+
return value && typeof value === 'object' && !Array.isArray(value)
|
|
8
|
+
? value
|
|
9
|
+
: null;
|
|
10
|
+
}
|
|
11
|
+
function boundedText(value, maximum) {
|
|
12
|
+
if (typeof value !== 'string' || value.length > maximum || /[\u0000-\u001f\u007f]/u.test(value))
|
|
13
|
+
return null;
|
|
14
|
+
return value.replace(/\s+/gu, ' ').trim() || null;
|
|
15
|
+
}
|
|
16
|
+
function normalizeGeoIpEntry(value) {
|
|
17
|
+
const source = record(value);
|
|
18
|
+
const parsedNetwork = parseIpPrefix(source?.network ?? source?.cidr);
|
|
19
|
+
if (!source || !parsedNetwork)
|
|
20
|
+
return null;
|
|
21
|
+
const country = boundedText(source.countryCode ?? source.country, 2)?.toUpperCase() ?? null;
|
|
22
|
+
if (country !== null && !/^[A-Z]{2}$/u.test(country))
|
|
23
|
+
return null;
|
|
24
|
+
const asnValue = source.asn === null || source.asn === undefined ? null : Number(String(source.asn).replace(/^AS/iu, ''));
|
|
25
|
+
if (asnValue !== null && (!Number.isInteger(asnValue) || asnValue < 0 || asnValue > 4_294_967_295))
|
|
26
|
+
return null;
|
|
27
|
+
return Object.freeze({
|
|
28
|
+
network: formatIpPrefix(parsedNetwork),
|
|
29
|
+
parsedNetwork,
|
|
30
|
+
countryCode: country,
|
|
31
|
+
region: boundedText(source.region, 120),
|
|
32
|
+
city: boundedText(source.city, 120),
|
|
33
|
+
asn: asnValue,
|
|
34
|
+
asName: boundedText(source.asName ?? source.organization, 240),
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
function buildLocalGeoIpDatabase(value) {
|
|
38
|
+
const source = record(value);
|
|
39
|
+
const sourceLabel = boundedText(source?.sourceLabel, 120);
|
|
40
|
+
const databaseVersion = boundedText(source?.databaseVersion, 80);
|
|
41
|
+
const license = boundedText(source?.license, 240);
|
|
42
|
+
const rows = Array.isArray(source?.records) ? source.records : null;
|
|
43
|
+
if (!sourceLabel || !databaseVersion || !license || !rows) {
|
|
44
|
+
throw new TypeError('A local GeoIP database requires bounded source, version, licence, and record fields.');
|
|
45
|
+
}
|
|
46
|
+
const truncated = rows.length > MAX_GEOIP_RECORDS;
|
|
47
|
+
const normalized = rows.slice(0, MAX_GEOIP_RECORDS).map(normalizeGeoIpEntry);
|
|
48
|
+
const records = normalized.filter((item) => item !== null)
|
|
49
|
+
.sort((left, right) => right.parsedNetwork.length - left.parsedNetwork.length);
|
|
50
|
+
return Object.freeze({
|
|
51
|
+
sourceLabel,
|
|
52
|
+
databaseVersion,
|
|
53
|
+
license,
|
|
54
|
+
records: Object.freeze(records),
|
|
55
|
+
rejectedCount: normalized.length - records.length + Math.max(0, rows.length - MAX_GEOIP_RECORDS),
|
|
56
|
+
truncated,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
function lookupLocalGeoIp(database, address) {
|
|
60
|
+
const normalized = typeof address === 'string' ? address.trim().toLowerCase() : '';
|
|
61
|
+
const family = isIP(normalized);
|
|
62
|
+
const target = family === 4 || family === 6 ? parseIpPrefix(normalized) : null;
|
|
63
|
+
if (!target) {
|
|
64
|
+
return Object.freeze({ state: 'invalid', address: null, match: null, source: null, limitations: Object.freeze([]) });
|
|
65
|
+
}
|
|
66
|
+
const match = database.records.find((item) => prefixContains(item.parsedNetwork, target)) ?? null;
|
|
67
|
+
return Object.freeze({
|
|
68
|
+
state: match ? database.truncated || database.rejectedCount > 0 ? 'partial' : 'matched' : 'not_found',
|
|
69
|
+
address: normalized,
|
|
70
|
+
match: match ? Object.freeze({
|
|
71
|
+
network: match.network,
|
|
72
|
+
countryCode: match.countryCode,
|
|
73
|
+
region: match.region,
|
|
74
|
+
city: match.city,
|
|
75
|
+
asn: match.asn,
|
|
76
|
+
asName: match.asName,
|
|
77
|
+
}) : null,
|
|
78
|
+
source: Object.freeze({
|
|
79
|
+
label: database.sourceLabel,
|
|
80
|
+
version: database.databaseVersion,
|
|
81
|
+
license: database.license,
|
|
82
|
+
}),
|
|
83
|
+
limitations: Object.freeze([
|
|
84
|
+
'Geolocation and network attribution are provider estimates and may be stale, coarse, shared, or incorrect.',
|
|
85
|
+
'A location or ASN association does not establish the operator, owner, user, intent, safety, or maliciousness of an address.',
|
|
86
|
+
]),
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
export { LOCAL_GEOIP_SCHEMA, LOCAL_GEOIP_VERSION, MAX_GEOIP_RECORDS, buildLocalGeoIpDatabase, lookupLocalGeoIp, };
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
// Shared HTTP response boundary for the unified Lookup API.
|
|
2
|
+
//
|
|
3
|
+
// The backend owns the evidence collectors, but an HTTP response is still an
|
|
4
|
+
// untrusted value when it reaches the browser. This module keeps the response
|
|
5
|
+
// envelope typed and validates the small set of fields the Lookup controller
|
|
6
|
+
// relies on before any evidence is derived or rendered. Nested source records
|
|
7
|
+
// stay separately attributed and additive; their own normalizers remain the
|
|
8
|
+
// authority for source-specific fields.
|
|
9
|
+
const INVALID_LOOKUP_RESPONSE = 'INVALID_LOOKUP_RESPONSE';
|
|
10
|
+
const INVALID_LOOKUP_RESPONSE_MESSAGE = 'Lookup returned an invalid response.';
|
|
11
|
+
const INVALID_COMPACT_LOOKUP_RESPONSE = 'INVALID_COMPACT_LOOKUP_RESPONSE';
|
|
12
|
+
const INVALID_COMPACT_LOOKUP_RESPONSE_MESSAGE = 'Bulk lookup returned an invalid response.';
|
|
13
|
+
const MAX_LOOKUP_RESPONSE_QUERY_LENGTH = 4096;
|
|
14
|
+
const MAX_LOOKUP_RESPONSE_HOST_LENGTH = 253;
|
|
15
|
+
const MAX_LOOKUP_RESPONSE_TOP_LEVEL_KEYS = 32;
|
|
16
|
+
const MAX_LOOKUP_RESPONSE_ERROR_LENGTH = 240;
|
|
17
|
+
const MAX_COMPACT_LOOKUP_RESPONSE_TOP_LEVEL_KEYS = 8;
|
|
18
|
+
const MAX_COMPACT_LOOKUP_AVAILABILITY_KEYS = 128;
|
|
19
|
+
const MAX_COMPACT_LOOKUP_DIAGNOSTIC_KEYS = 16;
|
|
20
|
+
const MAX_COMPACT_BULK_TECHNOLOGY_IDS = 12;
|
|
21
|
+
const MAX_THREAT_INTELLIGENCE_PROVIDERS = 10;
|
|
22
|
+
const MAX_LOOKUP_TIMING_MS = 120_000;
|
|
23
|
+
const MAX_LOOKUP_TIMING_SOURCES = 10;
|
|
24
|
+
const CONTROL_CHAR_RE = /[\u0000-\u001f\u007f]/u;
|
|
25
|
+
const QUERY_TYPES = new Set(['domain', 'ipv4', 'ipv6', 'asn']);
|
|
26
|
+
const COMPACT_AVAILABILITY_STATES = new Set([
|
|
27
|
+
'available',
|
|
28
|
+
'expiring',
|
|
29
|
+
'for_sale',
|
|
30
|
+
'registered',
|
|
31
|
+
'unknown',
|
|
32
|
+
]);
|
|
33
|
+
const COMPACT_CONFIDENCE_LEVELS = new Set(['high', 'low', 'medium']);
|
|
34
|
+
const COMPACT_AVAILABILITY_DIAGNOSTIC_STATES = new Set(['complete', 'disabled', 'error']);
|
|
35
|
+
const COMPACT_BULK_COMPARISON_STATES = new Set([
|
|
36
|
+
'error',
|
|
37
|
+
'not_found',
|
|
38
|
+
'partial',
|
|
39
|
+
'success',
|
|
40
|
+
'unavailable',
|
|
41
|
+
]);
|
|
42
|
+
const COMPACT_BULK_TECHNOLOGY_ID_RE = /^[a-z0-9][a-z0-9_-]{0,79}$/u;
|
|
43
|
+
const SHA256_RE = /^[a-f0-9]{64}$/u;
|
|
44
|
+
const LOOKUP_TIMING_SOURCES = new Set([
|
|
45
|
+
'rdap',
|
|
46
|
+
'whois',
|
|
47
|
+
'domain_evidence',
|
|
48
|
+
'reverse_dns',
|
|
49
|
+
'registrar_rdap',
|
|
50
|
+
'network_context',
|
|
51
|
+
'security_txt',
|
|
52
|
+
'external_intelligence',
|
|
53
|
+
'malware_host_intelligence',
|
|
54
|
+
'malware_ioc_intelligence',
|
|
55
|
+
]);
|
|
56
|
+
const LOOKUP_TIMING_OUTCOMES = new Set(['fulfilled', 'rejected']);
|
|
57
|
+
const EMPTY_RECORD = Object.freeze({});
|
|
58
|
+
function isJsonObject(value) {
|
|
59
|
+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
60
|
+
}
|
|
61
|
+
function record(value) {
|
|
62
|
+
return isJsonObject(value) ? value : EMPTY_RECORD;
|
|
63
|
+
}
|
|
64
|
+
function optionalBoundedText(value, maxLength) {
|
|
65
|
+
return value === undefined || (typeof value === 'string'
|
|
66
|
+
&& value.length <= maxLength
|
|
67
|
+
&& !CONTROL_CHAR_RE.test(value));
|
|
68
|
+
}
|
|
69
|
+
function normalizedDomain(value) {
|
|
70
|
+
if (typeof value !== 'string'
|
|
71
|
+
|| !value.trim()
|
|
72
|
+
|| value.length > MAX_LOOKUP_RESPONSE_HOST_LENGTH
|
|
73
|
+
|| CONTROL_CHAR_RE.test(value)
|
|
74
|
+
|| /[\s/?#@\\:]/u.test(value)) {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
try {
|
|
78
|
+
const parsed = new URL(`https://${value.trim().replace(/\.$/u, '')}/`);
|
|
79
|
+
const hostname = parsed.hostname.toLowerCase().replace(/\.$/u, '');
|
|
80
|
+
const labels = hostname.split('.');
|
|
81
|
+
return labels.length >= 2
|
|
82
|
+
&& hostname.length <= MAX_LOOKUP_RESPONSE_HOST_LENGTH
|
|
83
|
+
&& labels.every((label) => (label.length <= 63
|
|
84
|
+
&& /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/u.test(label)))
|
|
85
|
+
? hostname
|
|
86
|
+
: null;
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
function compactDomainMatches(value, expectedDomain) {
|
|
93
|
+
const domain = normalizedDomain(value);
|
|
94
|
+
const expected = normalizedDomain(expectedDomain);
|
|
95
|
+
return Boolean(domain && expected && (domain === expected || expected.endsWith(`.${domain}`)));
|
|
96
|
+
}
|
|
97
|
+
function validCompactBulkComparison(value) {
|
|
98
|
+
if (!isJsonObject(value)
|
|
99
|
+
|| Object.keys(value).length > 3
|
|
100
|
+
|| value.version !== 1
|
|
101
|
+
|| !isJsonObject(value.technology)
|
|
102
|
+
|| Object.keys(value.technology).length > 3
|
|
103
|
+
|| typeof value.technology.state !== 'string'
|
|
104
|
+
|| !COMPACT_BULK_COMPARISON_STATES.has(value.technology.state)
|
|
105
|
+
|| !Array.isArray(value.technology.ids)
|
|
106
|
+
|| value.technology.ids.length > MAX_COMPACT_BULK_TECHNOLOGY_IDS
|
|
107
|
+
|| typeof value.technology.truncated !== 'boolean'
|
|
108
|
+
|| !isJsonObject(value.tls)
|
|
109
|
+
|| Object.keys(value.tls).length > 3
|
|
110
|
+
|| typeof value.tls.state !== 'string'
|
|
111
|
+
|| !COMPACT_BULK_COMPARISON_STATES.has(value.tls.state)
|
|
112
|
+
|| (value.tls.issuerLabel !== null && !optionalBoundedText(value.tls.issuerLabel, 240))
|
|
113
|
+
|| (value.tls.spkiSha256 !== null
|
|
114
|
+
&& (typeof value.tls.spkiSha256 !== 'string' || !SHA256_RE.test(value.tls.spkiSha256)))) {
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
const ids = value.technology.ids;
|
|
118
|
+
return ids.every((id) => typeof id === 'string' && COMPACT_BULK_TECHNOLOGY_ID_RE.test(id))
|
|
119
|
+
&& new Set(ids).size === ids.length;
|
|
120
|
+
}
|
|
121
|
+
function invalidLookupResponse() {
|
|
122
|
+
return {
|
|
123
|
+
ok: false,
|
|
124
|
+
errorCode: INVALID_LOOKUP_RESPONSE,
|
|
125
|
+
error: INVALID_LOOKUP_RESPONSE_MESSAGE,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
function invalidCompactLookupResponse() {
|
|
129
|
+
return {
|
|
130
|
+
ok: false,
|
|
131
|
+
errorCode: INVALID_COMPACT_LOOKUP_RESPONSE,
|
|
132
|
+
error: INVALID_COMPACT_LOOKUP_RESPONSE_MESSAGE,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
function parseLookupHttpResponse(value) {
|
|
136
|
+
if (!isJsonObject(value) || Object.keys(value).length > MAX_LOOKUP_RESPONSE_TOP_LEVEL_KEYS) {
|
|
137
|
+
return invalidLookupResponse();
|
|
138
|
+
}
|
|
139
|
+
if (typeof value.query !== 'string'
|
|
140
|
+
|| !value.query.trim()
|
|
141
|
+
|| value.query.length > MAX_LOOKUP_RESPONSE_QUERY_LENGTH
|
|
142
|
+
|| CONTROL_CHAR_RE.test(value.query)
|
|
143
|
+
|| typeof value.type !== 'string'
|
|
144
|
+
|| !QUERY_TYPES.has(value.type)
|
|
145
|
+
|| !isJsonObject(value.rdap)
|
|
146
|
+
|| !isJsonObject(value.whois)
|
|
147
|
+
|| !isJsonObject(value.availability)
|
|
148
|
+
|| !isJsonObject(value.diagnostics)
|
|
149
|
+
|| !optionalBoundedText(value.inputHostname, MAX_LOOKUP_RESPONSE_HOST_LENGTH)
|
|
150
|
+
|| !optionalBoundedText(value.registrableDomain, MAX_LOOKUP_RESPONSE_HOST_LENGTH)
|
|
151
|
+
|| (value.isSubdomain !== undefined && typeof value.isSubdomain !== 'boolean')) {
|
|
152
|
+
return invalidLookupResponse();
|
|
153
|
+
}
|
|
154
|
+
for (const key of ['reverseDns', 'networkContext', 'securityTxt', 'sslbl', 'threatIntelligence', 'registryInsights']) {
|
|
155
|
+
const section = value[key];
|
|
156
|
+
if (section !== undefined && !isJsonObject(section))
|
|
157
|
+
return invalidLookupResponse();
|
|
158
|
+
}
|
|
159
|
+
return { ok: true, value: value };
|
|
160
|
+
}
|
|
161
|
+
function parseCompactLookupHttpResponse(value, expectedDomain) {
|
|
162
|
+
if (!isJsonObject(value) || Object.keys(value).length > MAX_COMPACT_LOOKUP_RESPONSE_TOP_LEVEL_KEYS) {
|
|
163
|
+
return invalidCompactLookupResponse();
|
|
164
|
+
}
|
|
165
|
+
const availability = value.availability;
|
|
166
|
+
const diagnostics = value.diagnostics;
|
|
167
|
+
if (!isJsonObject(availability)
|
|
168
|
+
|| Object.keys(availability).length > MAX_COMPACT_LOOKUP_AVAILABILITY_KEYS
|
|
169
|
+
|| availability.applicable !== true
|
|
170
|
+
|| !compactDomainMatches(availability.domain, expectedDomain)
|
|
171
|
+
|| typeof availability.state !== 'string'
|
|
172
|
+
|| !COMPACT_AVAILABILITY_STATES.has(availability.state)
|
|
173
|
+
|| typeof availability.confidence !== 'string'
|
|
174
|
+
|| !COMPACT_CONFIDENCE_LEVELS.has(availability.confidence)
|
|
175
|
+
|| (availability.deepScanComplete !== undefined && typeof availability.deepScanComplete !== 'boolean')
|
|
176
|
+
|| (availability.bulkComparison !== undefined
|
|
177
|
+
&& !validCompactBulkComparison(availability.bulkComparison))
|
|
178
|
+
|| (value.query !== undefined && !compactDomainMatches(value.query, expectedDomain))
|
|
179
|
+
|| (value.type !== undefined && value.type !== 'domain')
|
|
180
|
+
|| (value.inputHostname !== undefined && !compactDomainMatches(value.inputHostname, expectedDomain))
|
|
181
|
+
|| (value.registrableDomain !== undefined
|
|
182
|
+
&& normalizedDomain(value.registrableDomain) !== normalizedDomain(availability.domain))
|
|
183
|
+
|| (value.isSubdomain !== undefined && typeof value.isSubdomain !== 'boolean')
|
|
184
|
+
|| !isJsonObject(diagnostics)
|
|
185
|
+
|| Object.keys(diagnostics).length > MAX_COMPACT_LOOKUP_DIAGNOSTIC_KEYS
|
|
186
|
+
|| diagnostics.version !== 7
|
|
187
|
+
|| !isJsonObject(diagnostics.rdap)
|
|
188
|
+
|| !isJsonObject(diagnostics.whois)
|
|
189
|
+
|| !isJsonObject(diagnostics.availability)
|
|
190
|
+
|| typeof diagnostics.availability.status !== 'string'
|
|
191
|
+
|| !COMPACT_AVAILABILITY_DIAGNOSTIC_STATES.has(diagnostics.availability.status)) {
|
|
192
|
+
return invalidCompactLookupResponse();
|
|
193
|
+
}
|
|
194
|
+
return { ok: true, value: value };
|
|
195
|
+
}
|
|
196
|
+
function lookupHttpErrorMessage(value, status) {
|
|
197
|
+
const source = record(value);
|
|
198
|
+
const message = typeof source.error === 'string'
|
|
199
|
+
? source.error
|
|
200
|
+
.replace(/[\u0000-\u001f\u007f]+/gu, ' ')
|
|
201
|
+
.replace(/\s+/gu, ' ')
|
|
202
|
+
.trim()
|
|
203
|
+
.slice(0, MAX_LOOKUP_RESPONSE_ERROR_LENGTH)
|
|
204
|
+
: '';
|
|
205
|
+
return message || `Lookup failed (${status})`;
|
|
206
|
+
}
|
|
207
|
+
function isBoundedTimingMs(value, totalMs) {
|
|
208
|
+
return Number.isInteger(value)
|
|
209
|
+
&& Number(value) >= 0
|
|
210
|
+
&& Number(value) <= totalMs
|
|
211
|
+
&& Number(value) <= MAX_LOOKUP_TIMING_MS;
|
|
212
|
+
}
|
|
213
|
+
function normalizeLookupTiming(value) {
|
|
214
|
+
if (!isJsonObject(value)
|
|
215
|
+
|| value.version !== 1
|
|
216
|
+
|| !Number.isInteger(value.totalMs)
|
|
217
|
+
|| Number(value.totalMs) < 0
|
|
218
|
+
|| Number(value.totalMs) > MAX_LOOKUP_TIMING_MS
|
|
219
|
+
|| !Array.isArray(value.sources)
|
|
220
|
+
|| value.sources.length > MAX_LOOKUP_TIMING_SOURCES) {
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
const totalMs = Number(value.totalMs);
|
|
224
|
+
const seen = new Set();
|
|
225
|
+
const sources = [];
|
|
226
|
+
for (const candidate of value.sources) {
|
|
227
|
+
if (!isJsonObject(candidate)
|
|
228
|
+
|| typeof candidate.source !== 'string'
|
|
229
|
+
|| !LOOKUP_TIMING_SOURCES.has(candidate.source)
|
|
230
|
+
|| seen.has(candidate.source)
|
|
231
|
+
|| typeof candidate.outcome !== 'string'
|
|
232
|
+
|| !LOOKUP_TIMING_OUTCOMES.has(candidate.outcome)
|
|
233
|
+
|| !isBoundedTimingMs(candidate.durationMs, totalMs)
|
|
234
|
+
|| !isBoundedTimingMs(candidate.completedAfterMs, totalMs)
|
|
235
|
+
|| Number(candidate.durationMs) > Number(candidate.completedAfterMs)) {
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
const source = candidate.source;
|
|
239
|
+
seen.add(source);
|
|
240
|
+
sources.push({
|
|
241
|
+
source,
|
|
242
|
+
outcome: candidate.outcome,
|
|
243
|
+
durationMs: Number(candidate.durationMs),
|
|
244
|
+
completedAfterMs: Number(candidate.completedAfterMs),
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
return {
|
|
248
|
+
version: 1,
|
|
249
|
+
totalMs,
|
|
250
|
+
sources,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
function createLookupHttpResponse(query, classified, result) {
|
|
254
|
+
return {
|
|
255
|
+
...result,
|
|
256
|
+
query,
|
|
257
|
+
type: classified.type,
|
|
258
|
+
inputHostname: classified.inputHostname,
|
|
259
|
+
registrableDomain: classified.registrableDomain,
|
|
260
|
+
isSubdomain: classified.isSubdomain,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
function createLookupViewModel(response) {
|
|
264
|
+
const availability = record(response?.availability);
|
|
265
|
+
const rdap = record(response?.rdap);
|
|
266
|
+
const registrarRdap = record(rdap.registrarRdap);
|
|
267
|
+
const whois = record(response?.whois);
|
|
268
|
+
const diagnostics = record(response?.diagnostics);
|
|
269
|
+
const reverseDns = record(response?.reverseDns);
|
|
270
|
+
const observedNetworkContext = record(response?.networkContext);
|
|
271
|
+
const registryInsights = record(response?.registryInsights);
|
|
272
|
+
const securityTxt = record(response?.securityTxt);
|
|
273
|
+
const sslbl = record(response?.sslbl);
|
|
274
|
+
const threatIntelligence = record(response?.threatIntelligence);
|
|
275
|
+
const providers = Array.isArray(threatIntelligence.providers)
|
|
276
|
+
? threatIntelligence.providers
|
|
277
|
+
.filter(isJsonObject)
|
|
278
|
+
.slice(0, MAX_THREAT_INTELLIGENCE_PROVIDERS)
|
|
279
|
+
: [];
|
|
280
|
+
const dnsEvidence = record(availability.dns);
|
|
281
|
+
const httpEvidence = record(availability.http);
|
|
282
|
+
const httpResponse = record(httpEvidence.response);
|
|
283
|
+
const tlsEvidence = record(availability.tls);
|
|
284
|
+
const tlsCertificate = record(tlsEvidence.certificate);
|
|
285
|
+
const pageIdentity = record(availability.pageIdentity);
|
|
286
|
+
const pageOpenGraph = record(pageIdentity.openGraph);
|
|
287
|
+
const pageResources = record(pageIdentity.resources);
|
|
288
|
+
const securityPosture = record(availability.securityPosture);
|
|
289
|
+
return {
|
|
290
|
+
availability,
|
|
291
|
+
rdap,
|
|
292
|
+
registrarRdap,
|
|
293
|
+
registrarRdapParsed: record(registrarRdap.parsed),
|
|
294
|
+
whois,
|
|
295
|
+
rdapParsed: record(rdap.parsed),
|
|
296
|
+
whoisParsed: record(whois.parsed),
|
|
297
|
+
diagnostics,
|
|
298
|
+
timing: normalizeLookupTiming(diagnostics.timing),
|
|
299
|
+
registryAccess: record(diagnostics.registryAccess),
|
|
300
|
+
registryInsights,
|
|
301
|
+
reverseDns,
|
|
302
|
+
reverseDnsRecords: record(reverseDns.records),
|
|
303
|
+
observedNetworkContext,
|
|
304
|
+
observedNetworkEndpoint: record(observedNetworkContext.endpoint),
|
|
305
|
+
observedNetworkRdap: record(observedNetworkContext.rdap),
|
|
306
|
+
observedNetwork: record(observedNetworkContext.network),
|
|
307
|
+
securityTxt,
|
|
308
|
+
sslbl,
|
|
309
|
+
threatIntelligence,
|
|
310
|
+
threatIntelligenceProviders: providers,
|
|
311
|
+
dnsEvidence,
|
|
312
|
+
dnsRecords: record(dnsEvidence.records),
|
|
313
|
+
httpEvidence,
|
|
314
|
+
httpResponse,
|
|
315
|
+
httpSecurityHeaders: record(httpResponse.securityHeaders),
|
|
316
|
+
tlsEvidence,
|
|
317
|
+
tlsCertificate,
|
|
318
|
+
tlsSubject: record(tlsCertificate.subject),
|
|
319
|
+
tlsIssuer: record(tlsCertificate.issuer),
|
|
320
|
+
tlsAltNames: record(tlsCertificate.subjectAltNames),
|
|
321
|
+
tlsPublicKey: record(tlsCertificate.publicKey),
|
|
322
|
+
tlsCipher: record(tlsEvidence.cipher),
|
|
323
|
+
tlsAuthorization: record(tlsEvidence.authorization),
|
|
324
|
+
tlsHostname: record(tlsEvidence.hostname),
|
|
325
|
+
tlsValidity: record(tlsEvidence.validity),
|
|
326
|
+
tlsDiagnostics: record(tlsEvidence.diagnostics),
|
|
327
|
+
pageIdentity,
|
|
328
|
+
pageCanonical: record(pageIdentity.canonical),
|
|
329
|
+
pageMetaRefresh: record(pageIdentity.metaRefresh),
|
|
330
|
+
pageOpenGraph,
|
|
331
|
+
pageOpenGraphUrl: record(pageOpenGraph.url),
|
|
332
|
+
pageForms: record(pageIdentity.forms),
|
|
333
|
+
pageResources,
|
|
334
|
+
pageResourceTypes: record(pageResources.byType),
|
|
335
|
+
pageDownloads: record(pageIdentity.downloads),
|
|
336
|
+
pageFingerprints: record(pageIdentity.fingerprints),
|
|
337
|
+
credentialSurfaceProfile: record(availability.credentialSurfaceProfile),
|
|
338
|
+
structuredDataIdentity: record(availability.structuredDataIdentity),
|
|
339
|
+
technologyProfile: record(availability.technologyProfile),
|
|
340
|
+
pageRoleProfile: record(availability.pageRoleProfile),
|
|
341
|
+
clientBehaviorProfile: record(availability.clientBehaviorProfile),
|
|
342
|
+
securityPosture,
|
|
343
|
+
securityPostureSummary: record(securityPosture.summary),
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
export { INVALID_COMPACT_LOOKUP_RESPONSE, INVALID_COMPACT_LOOKUP_RESPONSE_MESSAGE, INVALID_LOOKUP_RESPONSE, INVALID_LOOKUP_RESPONSE_MESSAGE, MAX_COMPACT_LOOKUP_AVAILABILITY_KEYS, MAX_COMPACT_LOOKUP_DIAGNOSTIC_KEYS, MAX_COMPACT_LOOKUP_RESPONSE_TOP_LEVEL_KEYS, MAX_LOOKUP_RESPONSE_ERROR_LENGTH, MAX_LOOKUP_RESPONSE_HOST_LENGTH, MAX_LOOKUP_RESPONSE_QUERY_LENGTH, MAX_LOOKUP_RESPONSE_TOP_LEVEL_KEYS, MAX_LOOKUP_TIMING_MS, MAX_LOOKUP_TIMING_SOURCES, MAX_THREAT_INTELLIGENCE_PROVIDERS, createLookupHttpResponse, createLookupViewModel, isJsonObject, lookupHttpErrorMessage, normalizeLookupTiming, parseCompactLookupHttpResponse, parseLookupHttpResponse, record as lookupRecord, };
|
|
@@ -297,7 +297,7 @@ async function collectObservedNetworkContext(availabilityValue, options = {}) {
|
|
|
297
297
|
});
|
|
298
298
|
const parsed = record(rdapRecord.parsed);
|
|
299
299
|
if (rdap.httpStatus !== 200 || !Object.keys(parsed).length)
|
|
300
|
-
throw new Error('IP RDAP returned no usable
|
|
300
|
+
throw new Error('IP RDAP returned no usable normalised object');
|
|
301
301
|
const summary = networkSummary(parsed, selection.family);
|
|
302
302
|
const partial = summary.truncated;
|
|
303
303
|
const sourceObservedAt = rdap.fetchedAt || observedAt();
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { buildScoreEvidenceQuality, } from "./scoring-evidence-quality.mjs";
|
|
2
|
+
export const OPPORTUNITY_MODEL_VERSION = 2;
|
|
3
|
+
export const OPPORTUNITY_REVIEW_THRESHOLD = 70;
|
|
4
|
+
const STATE_BASE = Object.freeze({
|
|
5
|
+
available: 82,
|
|
6
|
+
for_sale: 70,
|
|
7
|
+
expiring: 52,
|
|
8
|
+
registered: 22,
|
|
9
|
+
});
|
|
10
|
+
const STATE_LABELS = Object.freeze({
|
|
11
|
+
available: 'available',
|
|
12
|
+
for_sale: 'registered with a sale signal',
|
|
13
|
+
expiring: 'registered in a lifecycle transition',
|
|
14
|
+
registered: 'registered',
|
|
15
|
+
});
|
|
16
|
+
function finite(value) {
|
|
17
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
|
18
|
+
}
|
|
19
|
+
function formatAge(days) {
|
|
20
|
+
if (days < 60)
|
|
21
|
+
return `${Math.max(0, Math.round(days))}d old`;
|
|
22
|
+
const years = days / 365.25;
|
|
23
|
+
return years < 1 ? `${Math.round(days / 30)}mo old` : `${years.toFixed(1)}y old`;
|
|
24
|
+
}
|
|
25
|
+
function addFactor(factors, dimensions, family, label, delta) {
|
|
26
|
+
factors.push({ family, label, delta });
|
|
27
|
+
dimensions.set(family, (dimensions.get(family) ?? 0) + delta);
|
|
28
|
+
}
|
|
29
|
+
export function normalizeOpportunityModelVersion(value) {
|
|
30
|
+
return typeof value === 'number' && Number.isSafeInteger(value) && value > 0 && value <= 1000 ? value : null;
|
|
31
|
+
}
|
|
32
|
+
// Opportunity is acquisition readiness, not price, value, eligibility, or a
|
|
33
|
+
// prediction that a registration will lapse. It uses only bounded evidence
|
|
34
|
+
// already collected for the current lookup.
|
|
35
|
+
export function explainOpportunityScore(value) {
|
|
36
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
37
|
+
return null;
|
|
38
|
+
const input = value;
|
|
39
|
+
const state = input.availability ?? input.state;
|
|
40
|
+
if (typeof state !== 'string' || !Object.hasOwn(STATE_BASE, state))
|
|
41
|
+
return null;
|
|
42
|
+
const factors = [];
|
|
43
|
+
const dimensions = new Map();
|
|
44
|
+
addFactor(factors, dimensions, 'registration', `Base readiness for “${STATE_LABELS[state] ?? state}”`, STATE_BASE[state] ?? 0);
|
|
45
|
+
if (input.confidence === 'high')
|
|
46
|
+
addFactor(factors, dimensions, 'registration', 'High-confidence registration decision', 8);
|
|
47
|
+
else if (input.confidence === 'medium')
|
|
48
|
+
addFactor(factors, dimensions, 'registration', 'Medium-confidence registration decision', 3);
|
|
49
|
+
else if (input.confidence === 'low')
|
|
50
|
+
addFactor(factors, dimensions, 'registration', 'Low-confidence registration decision', -8);
|
|
51
|
+
if (state === 'for_sale')
|
|
52
|
+
addFactor(factors, dimensions, 'listing', 'Bounded sale signal observed; listing remains unverified', 6);
|
|
53
|
+
else if (input.activityStatus === 'parked')
|
|
54
|
+
addFactor(factors, dimensions, 'listing', 'Parked page observed without a verified sale offer', 3);
|
|
55
|
+
else if (state === 'registered' && input.activityStatus === 'active') {
|
|
56
|
+
addFactor(factors, dimensions, 'listing', 'Active website suggests an in-use registration', -12);
|
|
57
|
+
}
|
|
58
|
+
if (input.hasPublicRegistrantContact === true) {
|
|
59
|
+
addFactor(factors, dimensions, 'contactability', 'Public registrant contact route observed; acquisition suitability is not established', 10);
|
|
60
|
+
}
|
|
61
|
+
const expiresInDays = finite(input.expiresInDays);
|
|
62
|
+
if (state === 'registered' && expiresInDays !== null && expiresInDays >= 0 && expiresInDays < 30) {
|
|
63
|
+
addFactor(factors, dimensions, 'lifecycle', 'Published expiry is within 30 days; release is not implied', 4);
|
|
64
|
+
}
|
|
65
|
+
if (typeof input.privacyProtected === 'boolean') {
|
|
66
|
+
addFactor(factors, dimensions, 'contactability', 'Registration privacy is neutral; it does not establish contactability', 0);
|
|
67
|
+
}
|
|
68
|
+
const ageDays = finite(input.domainAgeDays);
|
|
69
|
+
if (ageDays !== null && ageDays >= 0) {
|
|
70
|
+
addFactor(factors, dimensions, 'registration', `Domain age (${formatAge(ageDays)}) is context only`, 0);
|
|
71
|
+
}
|
|
72
|
+
const rawScore = Math.round(factors.reduce((total, factor) => total + factor.delta, 0));
|
|
73
|
+
const score = Math.max(0, Math.min(100, rawScore));
|
|
74
|
+
const dimensionLabels = {
|
|
75
|
+
registration: 'Registration certainty',
|
|
76
|
+
listing: 'Listing readiness',
|
|
77
|
+
contactability: 'Contactability',
|
|
78
|
+
lifecycle: 'Lifecycle timing',
|
|
79
|
+
};
|
|
80
|
+
const observedFamilies = [...dimensions.keys()];
|
|
81
|
+
const evidenceQuality = buildScoreEvidenceQuality(input, {
|
|
82
|
+
expectedDepth: 'fast',
|
|
83
|
+
observedFamilies,
|
|
84
|
+
expectedFamilies: ['registration'],
|
|
85
|
+
});
|
|
86
|
+
return {
|
|
87
|
+
modelVersion: OPPORTUNITY_MODEL_VERSION,
|
|
88
|
+
score,
|
|
89
|
+
rawScore,
|
|
90
|
+
capped: score !== rawScore,
|
|
91
|
+
factors,
|
|
92
|
+
dimensions: [...dimensions.entries()].map(([id, contribution]) => ({ id, label: dimensionLabels[id], contribution })),
|
|
93
|
+
evidenceQuality,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
export function computeOpportunityScore(value) {
|
|
97
|
+
return explainOpportunityScore(value)?.score ?? null;
|
|
98
|
+
}
|
|
99
|
+
export function opportunityTone(score) {
|
|
100
|
+
if (score === null)
|
|
101
|
+
return 'neutral';
|
|
102
|
+
if (score >= OPPORTUNITY_REVIEW_THRESHOLD)
|
|
103
|
+
return 'good';
|
|
104
|
+
if (score >= 40)
|
|
105
|
+
return 'neutral';
|
|
106
|
+
return 'warn';
|
|
107
|
+
}
|