@slicedearth/whoisleuth-cli 1.32.2 → 1.36.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 +43 -5
- package/SECURITY.md +35 -0
- package/cli/arguments.mjs +180 -8
- package/cli/artifact-verify.mjs +32 -0
- package/cli/bulk-command-runner.mjs +3 -1
- package/cli/bulk-output.mjs +6 -1
- package/cli/bulk.mjs +1 -2
- package/cli/command-catalogue.mjs +33 -0
- package/cli/completion.mjs +70 -7
- package/cli/doctor.mjs +78 -43
- package/cli/export-evidence.mjs +9 -2
- package/cli/formatters/html.mjs +1 -1
- package/cli/formatters/terminal.mjs +13 -7
- package/cli/investigation-plan.mjs +112 -0
- package/cli/lookup-command-runner.mjs +8 -0
- package/cli/lookup-diff.mjs +16 -5
- package/cli/lookup-plan.mjs +99 -0
- package/cli/lookup-timeline.mjs +123 -0
- package/cli/mail-review.mjs +47 -2
- package/cli/manual.mjs +2 -1
- package/cli/offline-evidence-review.mjs +141 -0
- package/cli/registry-support.mjs +1 -4
- package/cli/runner.mjs +214 -11
- package/docs/cli.md +200 -45
- package/frontend/src/lib/analysis/bulk-domain-comparison.js +12 -6
- package/frontend/src/lib/analysis/bulk-retry-plan.js +3 -10
- package/frontend/src/lib/analysis/bulk-source-coverage.js +76 -0
- package/frontend/src/lib/analysis/http-summary.js +2 -1
- package/frontend/src/lib/analysis/utils.js +7 -40
- package/frontend/src/lib/analysis/website-snapshot-model.js +57 -3
- package/lib/availability.mjs +30 -10
- package/lib/bulk-limits.mjs +13 -0
- package/lib/csv.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-control-manifest.mjs +339 -0
- package/lib/domain-posture.mjs +2 -1
- package/lib/encrypted-dns-contract.mjs +81 -0
- package/lib/evidence-export.mjs +17 -3
- package/lib/evidence-report-markdown.mjs +3 -3
- package/lib/evidence-report.mjs +3 -0
- package/lib/favicon.mjs +3 -8
- 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 +54 -0
- package/lib/local-geoip-evidence.mjs +89 -0
- package/lib/outbound-identity.mjs +9 -0
- package/lib/outbound-request-bounds.mjs +9 -0
- 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-search-workbench.mjs +157 -0
- package/lib/rdap.mjs +7 -2
- package/lib/registry-capabilities.mjs +31 -1
- package/lib/registry-capability-catalogue.mjs +11 -10
- package/lib/response-policy.mjs +81 -6
- package/lib/rpki-evidence.mjs +105 -0
- package/lib/safe-fetch.mjs +2 -1
- package/lib/semantic-version.mjs +54 -0
- package/lib/static-html-analysis.mjs +27 -0
- package/lib/threatfox-intelligence.mjs +10 -5
- package/lib/tlsa-evidence.mjs +194 -0
- package/lib/urlhaus-intelligence.mjs +10 -5
- package/lib/urlscan-intelligence.mjs +10 -5
- package/lib/website-security-posture.mjs +11 -4
- package/lib/whois-chain.mjs +14 -152
- package/lib/whois.mjs +2 -2
- package/package.json +7 -5
- package/third-party-notices.txt +2 -2
package/lib/evidence-export.mjs
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { compareRdapPublications, compareRegistrySources } from "./registry-comparison.mjs";
|
|
2
2
|
import { buildRegistryInsights } from "./registry-insights.mjs";
|
|
3
|
+
import { WHOISLEUTH_SOURCE_REPOSITORY_URL } from "./project-metadata.mjs";
|
|
4
|
+
import { normalizeBoundedSemanticVersion } from "./semantic-version.mjs";
|
|
3
5
|
export const LOOKUP_EVIDENCE_SCHEMA = 'whoisleuth.lookup-evidence';
|
|
4
|
-
export const LOOKUP_EVIDENCE_SCHEMA_VERSION =
|
|
6
|
+
export const LOOKUP_EVIDENCE_SCHEMA_VERSION = 24;
|
|
5
7
|
const REGISTRAR_RDAP_STATUSES = new Set([
|
|
6
8
|
'success', 'partial', 'error', 'unsupported', 'not_found', 'skipped', 'disabled',
|
|
7
9
|
]);
|
|
@@ -37,6 +39,14 @@ function boundedTimestamp(value) {
|
|
|
37
39
|
const time = Date.parse(text);
|
|
38
40
|
return Number.isFinite(time) ? new Date(time).toISOString() : null;
|
|
39
41
|
}
|
|
42
|
+
function boundedSemanticVersion(value) {
|
|
43
|
+
try {
|
|
44
|
+
return normalizeBoundedSemanticVersion(value, 'Evidence application');
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
40
50
|
function boundedEndpoint(value) {
|
|
41
51
|
const text = boundedString(value, 2048);
|
|
42
52
|
if (!text)
|
|
@@ -359,7 +369,7 @@ function registrarPublicationComparison(body, registryParsed) {
|
|
|
359
369
|
});
|
|
360
370
|
}
|
|
361
371
|
export function buildLookupEvidence(response, options = {}) {
|
|
362
|
-
const { generatedAt = new Date().toISOString(), idnAnalysis = null } = options;
|
|
372
|
+
const { generatedAt = new Date().toISOString(), idnAnalysis = null, applicationVersion = null } = options;
|
|
363
373
|
const body = recordOrNull(response) || {};
|
|
364
374
|
const rdap = recordOrNull(body.rdap);
|
|
365
375
|
const whois = recordOrNull(body.whois);
|
|
@@ -389,7 +399,11 @@ export function buildLookupEvidence(response, options = {}) {
|
|
|
389
399
|
schema: LOOKUP_EVIDENCE_SCHEMA,
|
|
390
400
|
schemaVersion: LOOKUP_EVIDENCE_SCHEMA_VERSION,
|
|
391
401
|
generatedAt,
|
|
392
|
-
application: {
|
|
402
|
+
application: {
|
|
403
|
+
name: 'WHOISleuth',
|
|
404
|
+
version: boundedSemanticVersion(applicationVersion),
|
|
405
|
+
projectUrl: WHOISLEUTH_SOURCE_REPOSITORY_URL,
|
|
406
|
+
},
|
|
393
407
|
query: {
|
|
394
408
|
submitted: body.query || null,
|
|
395
409
|
type: body.type || null,
|
|
@@ -21,7 +21,7 @@ function appendGroups(lines, groups) {
|
|
|
21
21
|
function formatLookupEvidenceMarkdown(document, options = {}) {
|
|
22
22
|
const report = buildLookupEvidenceReport(document, options);
|
|
23
23
|
const lines = [
|
|
24
|
-
`# Lookup evidence report
|
|
24
|
+
`# Lookup evidence report: ${escapeMarkdownValue(report.title)}`,
|
|
25
25
|
'', `> ${report.notice}`, '',
|
|
26
26
|
];
|
|
27
27
|
appendFields(lines, report.metadata);
|
|
@@ -42,7 +42,7 @@ function formatLookupEvidenceMarkdown(document, options = {}) {
|
|
|
42
42
|
}
|
|
43
43
|
else {
|
|
44
44
|
for (const field of report.comparison.fields) {
|
|
45
|
-
lines.push(`- **${escapeMarkdownValue(field.label)}
|
|
45
|
+
lines.push(`- **${escapeMarkdownValue(field.label)} (${escapeMarkdownValue(field.status)}):** RDAP ${escapeMarkdownValue(field.rdap)}; WHOIS ${escapeMarkdownValue(field.whois)}`);
|
|
46
46
|
}
|
|
47
47
|
if (report.comparison.omitted)
|
|
48
48
|
lines.push(`- ${report.comparison.omitted} additional comparison fields omitted.`);
|
|
@@ -51,7 +51,7 @@ function formatLookupEvidenceMarkdown(document, options = {}) {
|
|
|
51
51
|
lines.push('', '## Registry / registrar RDAP comparison');
|
|
52
52
|
appendFields(lines, report.registrarComparison.health);
|
|
53
53
|
for (const field of report.registrarComparison.fields) {
|
|
54
|
-
lines.push(`- **${escapeMarkdownValue(field.label)}
|
|
54
|
+
lines.push(`- **${escapeMarkdownValue(field.label)} (${escapeMarkdownValue(field.status)}):** Registry RDAP ${escapeMarkdownValue(field.registry)}; registrar RDAP ${escapeMarkdownValue(field.registrar)}`);
|
|
55
55
|
}
|
|
56
56
|
if (report.registrarComparison.omitted)
|
|
57
57
|
lines.push(`- ${report.registrarComparison.omitted} additional comparison fields omitted.`);
|
package/lib/evidence-report.mjs
CHANGED
|
@@ -89,6 +89,7 @@ function reportField(label, value, fallback = 'Not reported') {
|
|
|
89
89
|
}
|
|
90
90
|
function buildLookupEvidenceReport(document, options = {}) {
|
|
91
91
|
const report = objectOrEmpty(document);
|
|
92
|
+
const application = objectOrEmpty(report.application);
|
|
92
93
|
const query = objectOrEmpty(report.query);
|
|
93
94
|
const diagnostics = objectOrEmpty(report.diagnostics);
|
|
94
95
|
const rdapDiagnostics = objectOrEmpty(diagnostics.rdap);
|
|
@@ -300,6 +301,8 @@ function buildLookupEvidenceReport(document, options = {}) {
|
|
|
300
301
|
notice: 'Human-readable summary. The versioned JSON export remains the full-fidelity evidence package; raw RDAP JSON and WHOIS response bodies are deliberately omitted here.',
|
|
301
302
|
metadata: [
|
|
302
303
|
reportField('Generated', report.generatedAt),
|
|
304
|
+
reportField('Generator', application.version ? `${application.name || 'WHOISleuth'} ${application.version}` : application.name || 'WHOISleuth'),
|
|
305
|
+
reportField('Project', application.projectUrl),
|
|
303
306
|
reportField('Report contract', `${cleanReportText(report.schema)} v${cleanReportText(report.schemaVersion)}`),
|
|
304
307
|
],
|
|
305
308
|
query: [
|
package/lib/favicon.mjs
CHANGED
|
@@ -14,14 +14,9 @@
|
|
|
14
14
|
// adds coverage without weakening the exact tier.
|
|
15
15
|
import * as crypto from 'node:crypto';
|
|
16
16
|
import { safeFetch, readBytesCapped } from "./safe-fetch.mjs";
|
|
17
|
+
import { whoisleuthRequestHeaders } from "./outbound-identity.mjs";
|
|
17
18
|
import { faviconPerceptualHash } from "./perceptual-hash.mjs";
|
|
18
|
-
|
|
19
|
-
const FAVICON_FETCH_TIMEOUT_MS = 5000;
|
|
20
|
-
// Bounds worst-case work when a (possibly hostile) page declares many icon
|
|
21
|
-
// links - we try candidates in priority order and stop at the first that
|
|
22
|
-
// yields hashable bytes, so this only bites on pages where every earlier
|
|
23
|
-
// candidate fails.
|
|
24
|
-
const MAX_FAVICON_CANDIDATES = 4;
|
|
19
|
+
import { FAVICON_FETCH_TIMEOUT_MS, MAX_FAVICON_BYTES, MAX_FAVICON_CANDIDATES, } from "./outbound-request-bounds.mjs";
|
|
25
20
|
// Extracts favicon URLs declared in the page's own <link rel="...icon..."> tags
|
|
26
21
|
// (resolved to absolute URLs against the page origin), in preference order:
|
|
27
22
|
// standard "icon"/"shortcut icon" first, then "apple-touch-icon". Many modern
|
|
@@ -129,7 +124,7 @@ function buildFaviconCandidates(domain, html = '') {
|
|
|
129
124
|
return candidates.slice(0, MAX_FAVICON_CANDIDATES);
|
|
130
125
|
}
|
|
131
126
|
async function fetchFaviconHash(domain, { html = '' } = {}) {
|
|
132
|
-
const headers =
|
|
127
|
+
const headers = whoisleuthRequestHeaders();
|
|
133
128
|
const candidates = buildFaviconCandidates(domain, html);
|
|
134
129
|
for (const url of candidates) {
|
|
135
130
|
// eslint-disable-next-line no-await-in-loop
|
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,54 @@
|
|
|
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
|
+
export { parseIpPrefix, prefixContains };
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { isIP } from 'node:net';
|
|
2
|
+
import { 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: `${parsedNetwork.address}/${parsedNetwork.length}`,
|
|
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,9 @@
|
|
|
1
|
+
import packageDocument from '../package.json' with { type: 'json' };
|
|
2
|
+
import { WHOISLEUTH_PROJECT_URL, WHOISLEUTH_REQUEST_POLICY_URL, } from "./project-metadata.mjs";
|
|
3
|
+
import { normalizeBoundedSemanticVersion } from "./semantic-version.mjs";
|
|
4
|
+
export { WHOISLEUTH_PROJECT_URL, WHOISLEUTH_REQUEST_POLICY_URL };
|
|
5
|
+
export const WHOISLEUTH_USER_AGENT_VERSION = normalizeBoundedSemanticVersion(packageDocument.version, 'Application manifest');
|
|
6
|
+
export const WHOISLEUTH_USER_AGENT = `WHOISleuth/${WHOISLEUTH_USER_AGENT_VERSION} (+${WHOISLEUTH_REQUEST_POLICY_URL})`;
|
|
7
|
+
export function whoisleuthRequestHeaders(headers = {}) {
|
|
8
|
+
return { ...headers, 'User-Agent': WHOISLEUTH_USER_AGENT };
|
|
9
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// Shared declarative limits for direct website observation. Runtime collectors
|
|
2
|
+
// and the public request-policy page import these values so operator-facing
|
|
3
|
+
// disclosures cannot drift from the enforced request boundaries.
|
|
4
|
+
export const MAX_OUTBOUND_REDIRECTS = 5;
|
|
5
|
+
export const HOMEPAGE_FETCH_TIMEOUT_MS = 6_000;
|
|
6
|
+
export const MAX_HOMEPAGE_BYTES = 300_000;
|
|
7
|
+
export const FAVICON_FETCH_TIMEOUT_MS = 5_000;
|
|
8
|
+
export const MAX_FAVICON_BYTES = 200_000;
|
|
9
|
+
export const MAX_FAVICON_CANDIDATES = 4;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Browser-safe comparison contract for the 64-bit perceptual hashes produced
|
|
2
|
+
// by the bounded image decoder. Keeping comparison and informativeness here
|
|
3
|
+
// prevents Node and browser consumers from drifting into different matches.
|
|
4
|
+
export const PERCEPTUAL_HASH_HEX_RE = /^[0-9a-f]{16}$/u;
|
|
5
|
+
export const MIN_INFORMATIVE_HASH_BITS = 10;
|
|
6
|
+
const POPCOUNT = new Uint8Array(16);
|
|
7
|
+
for (let index = 0; index < POPCOUNT.length; index += 1) {
|
|
8
|
+
POPCOUNT[index] = (index & 1) + ((index >> 1) & 1) + ((index >> 2) & 1) + ((index >> 3) & 1);
|
|
9
|
+
}
|
|
10
|
+
export function isPerceptualHash(value) {
|
|
11
|
+
return typeof value === 'string' && PERCEPTUAL_HASH_HEX_RE.test(value);
|
|
12
|
+
}
|
|
13
|
+
function popcountHex(hex) {
|
|
14
|
+
let bits = 0;
|
|
15
|
+
for (let index = 0; index < hex.length; index += 1) {
|
|
16
|
+
bits += POPCOUNT[parseInt(hex.charAt(index), 16)] ?? 0;
|
|
17
|
+
}
|
|
18
|
+
return bits;
|
|
19
|
+
}
|
|
20
|
+
export function isInformativePerceptualHash(value) {
|
|
21
|
+
if (!isPerceptualHash(value))
|
|
22
|
+
return false;
|
|
23
|
+
const bits = popcountHex(value);
|
|
24
|
+
return bits >= MIN_INFORMATIVE_HASH_BITS && bits <= 64 - MIN_INFORMATIVE_HASH_BITS;
|
|
25
|
+
}
|
|
26
|
+
export function hammingDistanceHex(left, right) {
|
|
27
|
+
if (!isPerceptualHash(left) || !isPerceptualHash(right))
|
|
28
|
+
return null;
|
|
29
|
+
let distance = 0;
|
|
30
|
+
for (let index = 0; index < 16; index += 1) {
|
|
31
|
+
distance += POPCOUNT[parseInt(left.charAt(index), 16) ^ parseInt(right.charAt(index), 16)] ?? 0;
|
|
32
|
+
}
|
|
33
|
+
return distance;
|
|
34
|
+
}
|
package/lib/perceptual-hash.mjs
CHANGED
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
// case, while staying cheap (no DCT). Output is 16 lowercase hex chars;
|
|
22
22
|
// similarity is the Hamming distance between two such strings.
|
|
23
23
|
import * as zlib from 'node:zlib';
|
|
24
|
+
import { hammingDistanceHex, isInformativePerceptualHash, } from "./perceptual-hash-comparison.mjs";
|
|
24
25
|
// Bound the decode work on attacker-controlled image bytes. Real favicons top
|
|
25
26
|
// out around 256x256; anything claiming to be larger is refused rather than
|
|
26
27
|
// decoded (it would also never be a legitimate favicon to compare against).
|
|
@@ -371,21 +372,11 @@ function grayscaleGrid(image, cols, rows) {
|
|
|
371
372
|
// (callers fall back to the exact hash) rather than a collision-prone one.
|
|
372
373
|
// isInformativeHash re-checks this at comparison time too, since a Brand
|
|
373
374
|
// Profile saved before this guard existed may already hold a degenerate hash.
|
|
374
|
-
const MIN_INFORMATIVE_BITS = 10;
|
|
375
|
-
function popcountHex(hex) {
|
|
376
|
-
let bits = 0;
|
|
377
|
-
for (let i = 0; i < hex.length; i += 1)
|
|
378
|
-
bits += POPCOUNT[parseInt(hex.charAt(i), 16)] ?? 0;
|
|
379
|
-
return bits;
|
|
380
|
-
}
|
|
381
375
|
function isInformativeHash(hex) {
|
|
382
|
-
|
|
383
|
-
return false;
|
|
384
|
-
const bits = popcountHex(hex);
|
|
385
|
-
return bits >= MIN_INFORMATIVE_BITS && bits <= 64 - MIN_INFORMATIVE_BITS;
|
|
376
|
+
return isInformativePerceptualHash(hex);
|
|
386
377
|
}
|
|
387
378
|
// 64-bit dHash over a 9x8 grid -> 16 hex chars, or null when the resulting
|
|
388
|
-
// hash is degenerate (see
|
|
379
|
+
// hash is degenerate (see the shared comparison contract). Each row contributes 8 bits:
|
|
389
380
|
// bit set when a cell is brighter than its right-hand neighbour.
|
|
390
381
|
function dHash(image) {
|
|
391
382
|
const cols = 9;
|
|
@@ -429,23 +420,4 @@ function faviconPerceptualHash(buf) {
|
|
|
429
420
|
// produced by the optional local capture package. Keep the generic name as an
|
|
430
421
|
// alias so the evidence contract does not imply that a screenshot is a favicon.
|
|
431
422
|
const imagePerceptualHash = faviconPerceptualHash;
|
|
432
|
-
const HEX_HASH_RE = /^[0-9a-f]{16}$/;
|
|
433
|
-
const POPCOUNT = new Uint8Array(16);
|
|
434
|
-
for (let i = 0; i < 16; i += 1)
|
|
435
|
-
POPCOUNT[i] = (i & 1) + ((i >> 1) & 1) + ((i >> 2) & 1) + ((i >> 3) & 1);
|
|
436
|
-
/**
|
|
437
|
-
* Hamming distance (0-64) between two 16-hex dHash strings, or null if either
|
|
438
|
-
* isn't a well-formed hash. Smaller = more visually similar.
|
|
439
|
-
*/
|
|
440
|
-
function hammingDistanceHex(a, b) {
|
|
441
|
-
if (typeof a !== 'string' || typeof b !== 'string')
|
|
442
|
-
return null;
|
|
443
|
-
if (!HEX_HASH_RE.test(a) || !HEX_HASH_RE.test(b))
|
|
444
|
-
return null;
|
|
445
|
-
let distance = 0;
|
|
446
|
-
for (let i = 0; i < 16; i += 1) {
|
|
447
|
-
distance += POPCOUNT[parseInt(a.charAt(i), 16) ^ parseInt(b.charAt(i), 16)] ?? 0;
|
|
448
|
-
}
|
|
449
|
-
return distance;
|
|
450
|
-
}
|
|
451
423
|
export { faviconPerceptualHash, hammingDistanceHex, imagePerceptualHash };
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// Browser-safe public project metadata shared by request identity, package
|
|
2
|
+
// verification, canonical URLs, structured data, and public policy pages.
|
|
3
|
+
export const WHOISLEUTH_SITE_ORIGIN = 'https://whoisleuth.com';
|
|
4
|
+
export const WHOISLEUTH_PROJECT_URL = `${WHOISLEUTH_SITE_ORIGIN}/`;
|
|
5
|
+
export const WHOISLEUTH_REQUEST_POLICY_URL = `${WHOISLEUTH_SITE_ORIGIN}/request-policy`;
|
|
6
|
+
export const WHOISLEUTH_SOURCE_REPOSITORY_URL = 'https://github.com/slicedearth/whoisleuth';
|
|
7
|
+
export const WHOISLEUTH_SOURCE_REPOSITORY_GIT_URL = `git+${WHOISLEUTH_SOURCE_REPOSITORY_URL}.git`;
|
|
8
|
+
export const WHOISLEUTH_SOURCE_ISSUES_URL = `${WHOISLEUTH_SOURCE_REPOSITORY_URL}/issues`;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
export const PROVIDER_POLICY_MAX_REVIEW_AGE_DAYS = 180;
|
|
2
|
+
export const DEPLOYMENT_PURPOSES = ['personal', 'internal', 'commercial'];
|
|
3
|
+
function deploymentPurpose(value) {
|
|
4
|
+
const normalized = String(value ?? '').trim().toLowerCase();
|
|
5
|
+
return DEPLOYMENT_PURPOSES.includes(normalized)
|
|
6
|
+
? normalized
|
|
7
|
+
: null;
|
|
8
|
+
}
|
|
9
|
+
export function providerPolicyAdmission(terms, env = process.env, now = Date.now()) {
|
|
10
|
+
const source = env && typeof env === 'object' ? env : {};
|
|
11
|
+
const purpose = deploymentPurpose(source.WHOISLEUTH_DEPLOYMENT_PURPOSE);
|
|
12
|
+
const reviewedAt = Date.parse(terms.reviewedAt);
|
|
13
|
+
const reviewAgeDays = Number.isFinite(reviewedAt)
|
|
14
|
+
? Math.max(0, Math.floor((now - reviewedAt) / 86_400_000))
|
|
15
|
+
: null;
|
|
16
|
+
if (!purpose) {
|
|
17
|
+
return {
|
|
18
|
+
allowed: false,
|
|
19
|
+
purpose: null,
|
|
20
|
+
reviewAgeDays,
|
|
21
|
+
reason: 'Optional intelligence requires WHOISLEUTH_DEPLOYMENT_PURPOSE=personal, internal, or commercial.',
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
if (reviewAgeDays === null || reviewAgeDays > PROVIDER_POLICY_MAX_REVIEW_AGE_DAYS) {
|
|
25
|
+
return {
|
|
26
|
+
allowed: false,
|
|
27
|
+
purpose,
|
|
28
|
+
reviewAgeDays,
|
|
29
|
+
reason: `Optional intelligence provider policy review is older than ${PROVIDER_POLICY_MAX_REVIEW_AGE_DAYS} days or invalid.`,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
if (terms.commercialUse === 'unknown') {
|
|
33
|
+
return {
|
|
34
|
+
allowed: false,
|
|
35
|
+
purpose,
|
|
36
|
+
reviewAgeDays,
|
|
37
|
+
reason: 'Optional intelligence commercial-use permission is unknown.',
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
if (terms.commercialUse === 'restricted' && purpose !== 'personal') {
|
|
41
|
+
return {
|
|
42
|
+
allowed: false,
|
|
43
|
+
purpose,
|
|
44
|
+
reviewAgeDays,
|
|
45
|
+
reason: `Optional intelligence is classified as restricted for the declared ${purpose} deployment purpose.`,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
return { allowed: true, purpose, reviewAgeDays, reason: null };
|
|
49
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
const RDAP_SEARCH_WORKBENCH_SCHEMA = 'whoisleuth.rdap-search-workbench';
|
|
2
|
+
const RDAP_SEARCH_WORKBENCH_VERSION = 1;
|
|
3
|
+
const MAX_HELP_ENTRIES = 64;
|
|
4
|
+
const MAX_QUERY_VALUE_LENGTH = 254;
|
|
5
|
+
const SEARCHABLE_RESOURCE_TYPES = new Set(['domains', 'nameservers', 'entities', 'ips', 'autnums']);
|
|
6
|
+
const RELATED_RESOURCE_TYPES = new Set(['domains', 'nameservers', 'entities', 'ips', 'autnums']);
|
|
7
|
+
const RECOGNIZED_PROPERTIES = new Set(['handle', 'fn', 'email', 'role']);
|
|
8
|
+
const SENSITIVE_PROPERTIES = new Set(['fn', 'email']);
|
|
9
|
+
function record(value) {
|
|
10
|
+
return value && typeof value === 'object' && !Array.isArray(value)
|
|
11
|
+
? value
|
|
12
|
+
: null;
|
|
13
|
+
}
|
|
14
|
+
function boundedToken(value) {
|
|
15
|
+
if (typeof value !== 'string')
|
|
16
|
+
return null;
|
|
17
|
+
const normalized = value.trim().toLowerCase();
|
|
18
|
+
if (!normalized || normalized.length > 48 || !/^[a-z][a-z0-9_-]*$/u.test(normalized))
|
|
19
|
+
return null;
|
|
20
|
+
return normalized;
|
|
21
|
+
}
|
|
22
|
+
function declarationKey(value) {
|
|
23
|
+
return `${value.searchableResourceType}:${value.relatedResourceType}:${value.property}`;
|
|
24
|
+
}
|
|
25
|
+
function emptySummary(state, limitation, rejectedCount = 0) {
|
|
26
|
+
return Object.freeze({
|
|
27
|
+
schema: RDAP_SEARCH_WORKBENCH_SCHEMA,
|
|
28
|
+
version: RDAP_SEARCH_WORKBENCH_VERSION,
|
|
29
|
+
state,
|
|
30
|
+
capabilities: Object.freeze([]),
|
|
31
|
+
rejectedCount,
|
|
32
|
+
truncated: false,
|
|
33
|
+
limitations: Object.freeze([limitation]),
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
function normalizeRdapSearchHelp(value) {
|
|
37
|
+
const document = record(value);
|
|
38
|
+
if (!document)
|
|
39
|
+
return emptySummary('invalid', 'The RDAP help response was not an object.');
|
|
40
|
+
const raw = document.reverse_search_properties;
|
|
41
|
+
if (raw === undefined) {
|
|
42
|
+
return emptySummary('unsupported', 'The help response did not advertise reverse-search properties.');
|
|
43
|
+
}
|
|
44
|
+
if (!Array.isArray(raw)) {
|
|
45
|
+
return emptySummary('invalid', 'The advertised reverse-search properties were malformed.', 1);
|
|
46
|
+
}
|
|
47
|
+
const capabilities = [];
|
|
48
|
+
const seen = new Set();
|
|
49
|
+
let rejectedCount = 0;
|
|
50
|
+
const truncated = raw.length > MAX_HELP_ENTRIES;
|
|
51
|
+
for (const entry of raw.slice(0, MAX_HELP_ENTRIES)) {
|
|
52
|
+
const candidate = record(entry);
|
|
53
|
+
const searchableResourceType = boundedToken(candidate?.searchableResourceType);
|
|
54
|
+
const relatedResourceType = boundedToken(candidate?.relatedResourceType);
|
|
55
|
+
const property = boundedToken(candidate?.property);
|
|
56
|
+
if (!searchableResourceType || !relatedResourceType || !property) {
|
|
57
|
+
rejectedCount += 1;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
const declaration = { searchableResourceType, relatedResourceType, property };
|
|
61
|
+
const key = declarationKey(declaration);
|
|
62
|
+
if (seen.has(key))
|
|
63
|
+
continue;
|
|
64
|
+
seen.add(key);
|
|
65
|
+
const supported = SEARCHABLE_RESOURCE_TYPES.has(searchableResourceType)
|
|
66
|
+
&& RELATED_RESOURCE_TYPES.has(relatedResourceType)
|
|
67
|
+
&& RECOGNIZED_PROPERTIES.has(property);
|
|
68
|
+
capabilities.push(Object.freeze({
|
|
69
|
+
...declaration,
|
|
70
|
+
state: supported ? 'supported' : 'unsupported',
|
|
71
|
+
disclosure: SENSITIVE_PROPERTIES.has(property) ? 'contact' : 'identifier',
|
|
72
|
+
limitation: supported ? null : 'The server advertised a tuple that this release does not construct.',
|
|
73
|
+
}));
|
|
74
|
+
}
|
|
75
|
+
if (truncated)
|
|
76
|
+
rejectedCount += raw.length - MAX_HELP_ENTRIES;
|
|
77
|
+
const supportedCount = capabilities.filter((entry) => entry.state === 'supported').length;
|
|
78
|
+
const limitations = [];
|
|
79
|
+
if (truncated)
|
|
80
|
+
limitations.push(`Only the first ${MAX_HELP_ENTRIES} advertised properties were reviewed.`);
|
|
81
|
+
if (rejectedCount > 0)
|
|
82
|
+
limitations.push(`${rejectedCount} malformed or excess properties were not used.`);
|
|
83
|
+
if (capabilities.some((entry) => entry.state === 'unsupported')) {
|
|
84
|
+
limitations.push('Unrecognized advertised tuples remain visible but cannot be planned by this release.');
|
|
85
|
+
}
|
|
86
|
+
if (supportedCount === 0)
|
|
87
|
+
limitations.push('No supported reverse-search tuple was advertised.');
|
|
88
|
+
return Object.freeze({
|
|
89
|
+
schema: RDAP_SEARCH_WORKBENCH_SCHEMA,
|
|
90
|
+
version: RDAP_SEARCH_WORKBENCH_VERSION,
|
|
91
|
+
state: supportedCount === 0 ? 'unsupported' : limitations.length > 0 ? 'partial' : 'supported',
|
|
92
|
+
capabilities: Object.freeze(capabilities),
|
|
93
|
+
rejectedCount,
|
|
94
|
+
truncated,
|
|
95
|
+
limitations: Object.freeze(limitations),
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
function normalizeQueryValue(value) {
|
|
99
|
+
if (typeof value !== 'string')
|
|
100
|
+
return null;
|
|
101
|
+
const normalized = value.trim();
|
|
102
|
+
if (!normalized || normalized.length > MAX_QUERY_VALUE_LENGTH)
|
|
103
|
+
return null;
|
|
104
|
+
if (/[\x00-\x1f\x7f]/u.test(normalized))
|
|
105
|
+
return null;
|
|
106
|
+
return normalized;
|
|
107
|
+
}
|
|
108
|
+
function planRdapReverseSearch(help, requested) {
|
|
109
|
+
const searchableResourceType = boundedToken(requested.searchableResourceType);
|
|
110
|
+
const relatedResourceType = boundedToken(requested.relatedResourceType);
|
|
111
|
+
const property = boundedToken(requested.property);
|
|
112
|
+
const value = normalizeQueryValue(requested.value);
|
|
113
|
+
if (!searchableResourceType || !relatedResourceType || !property || !value) {
|
|
114
|
+
return Object.freeze({
|
|
115
|
+
schema: RDAP_SEARCH_WORKBENCH_SCHEMA,
|
|
116
|
+
version: RDAP_SEARCH_WORKBENCH_VERSION,
|
|
117
|
+
state: 'invalid',
|
|
118
|
+
requestPath: null,
|
|
119
|
+
query: null,
|
|
120
|
+
disclosure: null,
|
|
121
|
+
limitation: 'The requested resource types, property, or value were invalid or outside the supported bounds.',
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
const capability = help.capabilities.find((entry) => (entry.state === 'supported'
|
|
125
|
+
&& entry.searchableResourceType === searchableResourceType
|
|
126
|
+
&& entry.relatedResourceType === relatedResourceType
|
|
127
|
+
&& entry.property === property));
|
|
128
|
+
if (!capability) {
|
|
129
|
+
return Object.freeze({
|
|
130
|
+
schema: RDAP_SEARCH_WORKBENCH_SCHEMA,
|
|
131
|
+
version: RDAP_SEARCH_WORKBENCH_VERSION,
|
|
132
|
+
state: 'unsupported',
|
|
133
|
+
requestPath: null,
|
|
134
|
+
query: null,
|
|
135
|
+
disclosure: null,
|
|
136
|
+
limitation: 'The supplied help response did not advertise this exact reverse-search tuple.',
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
const parameter = `${relatedResourceType}_${property}`;
|
|
140
|
+
const contactDisclosure = capability.disclosure === 'contact';
|
|
141
|
+
return Object.freeze({
|
|
142
|
+
schema: RDAP_SEARCH_WORKBENCH_SCHEMA,
|
|
143
|
+
version: RDAP_SEARCH_WORKBENCH_VERSION,
|
|
144
|
+
state: 'ready',
|
|
145
|
+
requestPath: `/${searchableResourceType}`,
|
|
146
|
+
query: Object.freeze({ [parameter]: value }),
|
|
147
|
+
disclosure: Object.freeze({
|
|
148
|
+
class: capability.disclosure,
|
|
149
|
+
summary: contactDisclosure
|
|
150
|
+
? `Would disclose the supplied ${property} contact value to the selected RDAP server.`
|
|
151
|
+
: `Would disclose the supplied ${property} identifier to the selected RDAP server.`,
|
|
152
|
+
requiresApproval: true,
|
|
153
|
+
}),
|
|
154
|
+
limitation: 'This is an offline request plan. It neither sends the query nor establishes result authority, completeness, or currentness.',
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
export { MAX_HELP_ENTRIES, MAX_QUERY_VALUE_LENGTH, RDAP_SEARCH_WORKBENCH_SCHEMA, RDAP_SEARCH_WORKBENCH_VERSION, normalizeRdapSearchHelp, planRdapReverseSearch, };
|
package/lib/rdap.mjs
CHANGED
|
@@ -4,11 +4,16 @@ import { fetchRdapFromBasesWithParser, fetchRdapRecordWithParser, } from "./rdap
|
|
|
4
4
|
import { normalizeRdapEvents, parseRdap, summarizeLifecycle, } from "./rdap-normalization.mjs";
|
|
5
5
|
import { fetchRegistrarRdapRecordWithParser, selectRegistrarRdapLink, } from "./rdap-registrar.mjs";
|
|
6
6
|
import { fetchRdapWithTimeout, } from "./rdap-transport.mjs";
|
|
7
|
+
import { registryServiceAdmissionFor } from "./registry-capabilities.mjs";
|
|
7
8
|
async function fetchRdapFromBases(type, value, bases, fetchUpstream = fetchRdapWithTimeout) {
|
|
8
9
|
return fetchRdapFromBasesWithParser(type, value, bases, parseRdap, fetchUpstream);
|
|
9
10
|
}
|
|
10
|
-
async function fetchRdapRecord(type, value) {
|
|
11
|
-
|
|
11
|
+
async function fetchRdapRecord(type, value, options = {}) {
|
|
12
|
+
if (type === 'domain' && registryServiceAdmissionFor(value, 'rdap')?.allowed === false) {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
const fetchRecord = options.fetchRecord ?? fetchRdapRecordWithParser;
|
|
16
|
+
return fetchRecord(type, value, parseRdap);
|
|
12
17
|
}
|
|
13
18
|
async function fetchRegistrarRdapRecord(domain, registryRecord, options = {}) {
|
|
14
19
|
return fetchRegistrarRdapRecordWithParser(domain, registryRecord, parseRdap, options);
|