@slicedearth/whoisleuth-cli 1.32.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +661 -0
- package/LICENSES/Retire.js-Apache-2.0.txt +199 -0
- package/NOTICE +76 -0
- package/README.md +84 -0
- package/TRADEMARKS.md +41 -0
- package/bin/whoisleuth.mjs +35 -0
- package/cli/archive-inspect.mjs +198 -0
- package/cli/arguments.mjs +1022 -0
- package/cli/artifact-verify.mjs +210 -0
- package/cli/bulk-checkpoint.mjs +218 -0
- package/cli/bulk-command-runner.mjs +109 -0
- package/cli/bulk-output.mjs +112 -0
- package/cli/bulk.mjs +133 -0
- package/cli/compare.mjs +238 -0
- package/cli/completion.mjs +175 -0
- package/cli/csv.mjs +1 -0
- package/cli/discover.mjs +50 -0
- package/cli/discovery-command-runner.mjs +36 -0
- package/cli/discovery-observation-snapshot.mjs +315 -0
- package/cli/discovery-scan-command-runner.mjs +141 -0
- package/cli/discovery-scan.mjs +325 -0
- package/cli/discovery-snapshot.mjs +133 -0
- package/cli/discovery-workflow.mjs +73 -0
- package/cli/doctor.mjs +137 -0
- package/cli/errors.mjs +21 -0
- package/cli/evidence-command-arguments.mjs +145 -0
- package/cli/evidence-command-runner.mjs +106 -0
- package/cli/evidence-signing.mjs +215 -0
- package/cli/exit-codes.mjs +9 -0
- package/cli/export-evidence.mjs +28 -0
- package/cli/formatters/evidence-report.mjs +3 -0
- package/cli/formatters/html.mjs +69 -0
- package/cli/formatters/json.mjs +135 -0
- package/cli/formatters/markdown.mjs +3 -0
- package/cli/formatters/terminal.mjs +658 -0
- package/cli/http.mjs +27 -0
- package/cli/lookup-command-runner.mjs +87 -0
- package/cli/lookup-diff.mjs +171 -0
- package/cli/mail-review.mjs +204 -0
- package/cli/manual.mjs +34 -0
- package/cli/network-command-runner.mjs +88 -0
- package/cli/output-file.mjs +97 -0
- package/cli/page-compare.mjs +123 -0
- package/cli/posture.mjs +20 -0
- package/cli/progress-events.mjs +53 -0
- package/cli/progress.mjs +56 -0
- package/cli/registry-access.mjs +3 -0
- package/cli/registry-support.mjs +120 -0
- package/cli/risk-calibration.mjs +294 -0
- package/cli/runner-types.mjs +1 -0
- package/cli/runner.mjs +726 -0
- package/cli/saved-lookup.mjs +87 -0
- package/cli/source-reliability.mjs +727 -0
- package/cli/strict-exit.mjs +55 -0
- package/cli/terminal-presentation.mjs +147 -0
- package/docs/cli.md +827 -0
- package/frontend/src/lib/analysis/acquisition-decision-packet.js +93 -0
- package/frontend/src/lib/analysis/acquisition-due-diligence.js +391 -0
- package/frontend/src/lib/analysis/artifact-integrity.js +21 -0
- package/frontend/src/lib/analysis/brand-profile-model.js +421 -0
- package/frontend/src/lib/analysis/bulk-domain-comparison.js +246 -0
- package/frontend/src/lib/analysis/bulk-mail-exposure.js +192 -0
- package/frontend/src/lib/analysis/bulk-retry-plan.js +86 -0
- package/frontend/src/lib/analysis/bulk-review-export.js +62 -0
- package/frontend/src/lib/analysis/bulk-review-model.js +266 -0
- package/frontend/src/lib/analysis/bulk-session-model.js +515 -0
- package/frontend/src/lib/analysis/bulk-sort.js +49 -0
- package/frontend/src/lib/analysis/campaign-model.js +265 -0
- package/frontend/src/lib/analysis/case-evidence-model.js +642 -0
- package/frontend/src/lib/analysis/case-migration-model.js +248 -0
- package/frontend/src/lib/analysis/case-model.js +5 -0
- package/frontend/src/lib/analysis/case-record-contracts.js +44 -0
- package/frontend/src/lib/analysis/case-record-core.js +232 -0
- package/frontend/src/lib/analysis/case-record-model.js +7 -0
- package/frontend/src/lib/analysis/case-record-operations.js +224 -0
- package/frontend/src/lib/analysis/case-response-model.js +640 -0
- package/frontend/src/lib/analysis/case-response-packet.js +573 -0
- package/frontend/src/lib/analysis/case-storage-model.js +93 -0
- package/frontend/src/lib/analysis/detection-rule-model.js +306 -0
- package/frontend/src/lib/analysis/dns-record-normalization.js +11 -0
- package/frontend/src/lib/analysis/http-summary.js +165 -0
- package/frontend/src/lib/analysis/investigation-guide.js +500 -0
- package/frontend/src/lib/analysis/investigation-template-model.js +144 -0
- package/frontend/src/lib/analysis/page-baseline.js +272 -0
- package/frontend/src/lib/analysis/page-similarity.js +179 -0
- package/frontend/src/lib/analysis/relationship-evidence.js +265 -0
- package/frontend/src/lib/analysis/relationship-observation-model.js +423 -0
- package/frontend/src/lib/analysis/scoring.js +135 -0
- package/frontend/src/lib/analysis/shortlist-model.js +209 -0
- package/frontend/src/lib/analysis/utils.js +265 -0
- package/frontend/src/lib/analysis/watchlist-history.js +643 -0
- package/frontend/src/lib/analysis/watchlist-store.js +127 -0
- package/frontend/src/lib/analysis/website-snapshot-model.js +302 -0
- package/frontend/src/lib/analysis/workspace-archive-crypto.js +282 -0
- package/frontend/src/lib/analysis/workspace-archive.js +472 -0
- package/lib/abort.mjs +26 -0
- package/lib/abusech-auth.mjs +19 -0
- package/lib/availability.mjs +716 -0
- package/lib/bounded-contract-normalizers.mjs +61 -0
- package/lib/browser-library-profile.mjs +310 -0
- package/lib/bulk-comparison-evidence.mjs +83 -0
- package/lib/classify.mjs +93 -0
- package/lib/client-behavior-profile.mjs +83 -0
- package/lib/credential-surface-profile.mjs +66 -0
- package/lib/csv.mjs +23 -0
- package/lib/ct-query.mjs +27 -0
- package/lib/ct-search.mjs +393 -0
- package/lib/distributed-operation-budget.mjs +461 -0
- package/lib/dns-delegation-health.mjs +344 -0
- package/lib/dns-intelligence.mjs +739 -0
- package/lib/dns-mx.mjs +24 -0
- package/lib/dns-resolver-selection.mjs +33 -0
- package/lib/domain-posture-analysis.mjs +254 -0
- package/lib/domain-posture-parsers.mjs +358 -0
- package/lib/domain-posture.mjs +745 -0
- package/lib/error-detail.mjs +10 -0
- package/lib/evidence-export.mjs +431 -0
- package/lib/evidence-report-markdown.mjs +71 -0
- package/lib/evidence-report.mjs +391 -0
- package/lib/external-intelligence-risk.mjs +108 -0
- package/lib/favicon.mjs +146 -0
- package/lib/feature-policy.mjs +70 -0
- package/lib/generated/retire-browser-catalog.mjs +8724 -0
- package/lib/generated/unicode-confusables-17.mjs +93 -0
- package/lib/hostname.mjs +38 -0
- package/lib/html-signals.mjs +680 -0
- package/lib/http-intelligence.mjs +232 -0
- package/lib/idn-confusable-policy.mjs +86 -0
- package/lib/idn-confusables.mjs +465 -0
- package/lib/lookup-cache.mjs +130 -0
- package/lib/lookup-progress.mjs +291 -0
- package/lib/lookup-source-progress.mjs +109 -0
- package/lib/lookup-threat-provider-inventory.mjs +13 -0
- package/lib/lookup.mjs +592 -0
- package/lib/observation.mjs +114 -0
- package/lib/observed-network-context.mjs +355 -0
- package/lib/operation-budget.mjs +489 -0
- package/lib/page-fingerprints.mjs +481 -0
- package/lib/page-role-profile.mjs +117 -0
- package/lib/perceptual-hash.mjs +451 -0
- package/lib/rdap-attempts.mjs +34 -0
- package/lib/rdap-bootstrap.mjs +173 -0
- package/lib/rdap-capabilities.mjs +292 -0
- package/lib/rdap-client.mjs +125 -0
- package/lib/rdap-normalization.mjs +691 -0
- package/lib/rdap-registrar.mjs +196 -0
- package/lib/rdap-transport.mjs +39 -0
- package/lib/rdap-types.mjs +1 -0
- package/lib/rdap-validation.mjs +94 -0
- package/lib/rdap.mjs +16 -0
- package/lib/registry-access.mjs +32 -0
- package/lib/registry-capabilities.mjs +117 -0
- package/lib/registry-capability-catalogue.mjs +1206 -0
- package/lib/registry-comparison.mjs +318 -0
- package/lib/registry-dates.mjs +190 -0
- package/lib/registry-insights.mjs +325 -0
- package/lib/response-policy.mjs +256 -0
- package/lib/risk-scoring.mjs +164 -0
- package/lib/safe-fetch.mjs +446 -0
- package/lib/security-txt.mjs +359 -0
- package/lib/service-binding-dns.mjs +608 -0
- package/lib/sslbl-certificates.generated.mjs +143 -0
- package/lib/sslbl-intelligence.mjs +241 -0
- package/lib/static-html-analysis.mjs +340 -0
- package/lib/structured-data-identity.mjs +323 -0
- package/lib/threat-intelligence-contract.mjs +5 -0
- package/lib/threat-intelligence-definition-registry.mjs +34 -0
- package/lib/threat-intelligence-fixtures.mjs +82 -0
- package/lib/threat-intelligence-manifests.mjs +512 -0
- package/lib/threat-intelligence-runtime.mjs +662 -0
- package/lib/threat-intelligence-targets.mjs +168 -0
- package/lib/threat-intelligence-types.mjs +3 -0
- package/lib/threatfox-intelligence.mjs +307 -0
- package/lib/tls-intelligence.mjs +814 -0
- package/lib/typosquat-generator.mjs +884 -0
- package/lib/urlhaus-intelligence.mjs +361 -0
- package/lib/urlscan-intelligence.mjs +349 -0
- package/lib/website-security-posture.mjs +325 -0
- package/lib/website-technology.mjs +437 -0
- package/lib/whois-authority.mjs +98 -0
- package/lib/whois-chain.mjs +261 -0
- package/lib/whois-contacts.mjs +163 -0
- package/lib/whois-contracts.mjs +3 -0
- package/lib/whois-normalization.mjs +27 -0
- package/lib/whois-transport.mjs +100 -0
- package/lib/whois-values.mjs +92 -0
- package/lib/whois.mjs +1187 -0
- package/package.json +55 -0
- package/third-party-notices.txt +5571 -0
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
// Normalized comparison of the domain fields published by RDAP and WHOIS.
|
|
2
|
+
// Registries routinely express the same value with different punctuation,
|
|
3
|
+
// casing, ordering, or timestamp precision, so comparisons happen on a
|
|
4
|
+
// canonical form while consumers retain the original source values.
|
|
5
|
+
const REDACTION_MARKERS = [
|
|
6
|
+
/redacted/i,
|
|
7
|
+
/data protected/i,
|
|
8
|
+
/privacy protect/i,
|
|
9
|
+
/not disclosed/i,
|
|
10
|
+
/not published/i,
|
|
11
|
+
/masked/i,
|
|
12
|
+
];
|
|
13
|
+
function isRedacted(value) {
|
|
14
|
+
if (typeof value !== 'string')
|
|
15
|
+
return false;
|
|
16
|
+
return REDACTION_MARKERS.some((re) => re.test(value));
|
|
17
|
+
}
|
|
18
|
+
function publishedState(value) {
|
|
19
|
+
if (value === null || value === undefined || value === '')
|
|
20
|
+
return 'absent';
|
|
21
|
+
if (Array.isArray(value) && value.length === 0)
|
|
22
|
+
return 'absent';
|
|
23
|
+
if (isRedacted(value))
|
|
24
|
+
return 'redacted';
|
|
25
|
+
return 'value';
|
|
26
|
+
}
|
|
27
|
+
function sourceCondition(status) {
|
|
28
|
+
if (status === 'partial')
|
|
29
|
+
return 'incomplete';
|
|
30
|
+
if (typeof status === 'string' && ['error', 'unsupported', 'not_found', 'skipped', 'disabled'].includes(status))
|
|
31
|
+
return 'unavailable';
|
|
32
|
+
return 'complete';
|
|
33
|
+
}
|
|
34
|
+
function fieldState(value, condition) {
|
|
35
|
+
const state = publishedState(value);
|
|
36
|
+
return state === 'absent' && condition !== 'complete' ? condition : state;
|
|
37
|
+
}
|
|
38
|
+
function registrarValue(value) {
|
|
39
|
+
if (!value || typeof value !== 'object')
|
|
40
|
+
return value || null;
|
|
41
|
+
const record = value;
|
|
42
|
+
return record.name || record.org || record.handle || null;
|
|
43
|
+
}
|
|
44
|
+
function record(value) {
|
|
45
|
+
return value && typeof value === 'object' && !Array.isArray(value)
|
|
46
|
+
? value
|
|
47
|
+
: {};
|
|
48
|
+
}
|
|
49
|
+
function normalizeText(value) {
|
|
50
|
+
if (typeof value !== 'string')
|
|
51
|
+
return null;
|
|
52
|
+
const normalized = value
|
|
53
|
+
.normalize('NFKC')
|
|
54
|
+
.trim()
|
|
55
|
+
.toLowerCase()
|
|
56
|
+
.replace(/&/g, ' and ')
|
|
57
|
+
.replace(/[^\p{L}\p{N}]+/gu, ' ')
|
|
58
|
+
.replace(/\s+/g, ' ')
|
|
59
|
+
.trim();
|
|
60
|
+
return normalized || null;
|
|
61
|
+
}
|
|
62
|
+
function normalizeDomain(value) {
|
|
63
|
+
return typeof value === 'string' ? value.trim().toLowerCase().replace(/\.+$/, '') : null;
|
|
64
|
+
}
|
|
65
|
+
// Registry timestamps may be a date, an ISO timestamp, or a timestamp with
|
|
66
|
+
// different precision. Registration lifecycle comparisons only need the UTC
|
|
67
|
+
// calendar date; comparing milliseconds would turn equivalent publication
|
|
68
|
+
// formats into false conflicts.
|
|
69
|
+
function normalizeDate(value) {
|
|
70
|
+
if (typeof value !== 'string' || !value.trim())
|
|
71
|
+
return null;
|
|
72
|
+
const trimmed = value.trim();
|
|
73
|
+
const ymd = trimmed.match(/^(\d{4})[-/.](\d{2})[-/.](\d{2})/);
|
|
74
|
+
if (ymd)
|
|
75
|
+
return `${ymd[1]}-${ymd[2]}-${ymd[3]}`;
|
|
76
|
+
const parsed = new Date(trimmed);
|
|
77
|
+
if (!Number.isNaN(parsed.getTime()))
|
|
78
|
+
return parsed.toISOString().slice(0, 10);
|
|
79
|
+
return normalizeText(trimmed);
|
|
80
|
+
}
|
|
81
|
+
function normalizeDnssec(value) {
|
|
82
|
+
const normalized = normalizeText(value);
|
|
83
|
+
if (!normalized)
|
|
84
|
+
return null;
|
|
85
|
+
if (/unsigned|not signed|insecure/.test(normalized))
|
|
86
|
+
return 'unsigned';
|
|
87
|
+
if (/signed|secure/.test(normalized))
|
|
88
|
+
return 'signed';
|
|
89
|
+
return normalized;
|
|
90
|
+
}
|
|
91
|
+
function normalizeStatus(value) {
|
|
92
|
+
return typeof value === 'string'
|
|
93
|
+
? value.normalize('NFKC').toLowerCase().replace(/[^\p{L}\p{N}]+/gu, '')
|
|
94
|
+
: null;
|
|
95
|
+
}
|
|
96
|
+
// Some registrar RDAP services publish the portable RDAP status vocabulary
|
|
97
|
+
// (for example "transfer prohibited") while the registry publishes the
|
|
98
|
+
// corresponding client-set EPP status ("client transfer prohibited"). Treat
|
|
99
|
+
// those client-set forms as equivalent across RDAP publications, but retain
|
|
100
|
+
// the server prefix because a registry-set lock is materially different.
|
|
101
|
+
function normalizeRdapPublicationStatus(value) {
|
|
102
|
+
const normalized = normalizeStatus(value);
|
|
103
|
+
if (!normalized)
|
|
104
|
+
return null;
|
|
105
|
+
return normalized.replace(/^client(?=(?:delete|renew|transfer|update)prohibited$)/, '');
|
|
106
|
+
}
|
|
107
|
+
function normalizeNameserver(value) {
|
|
108
|
+
return normalizeDomain(value);
|
|
109
|
+
}
|
|
110
|
+
function normalizeSet(values, normalizeItem) {
|
|
111
|
+
if (!Array.isArray(values))
|
|
112
|
+
return [];
|
|
113
|
+
return [...new Set(values.map(normalizeItem).filter((value) => Boolean(value)))].sort();
|
|
114
|
+
}
|
|
115
|
+
function sameNormalizedValue(left, right) {
|
|
116
|
+
if (Array.isArray(left) || Array.isArray(right)) {
|
|
117
|
+
if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length)
|
|
118
|
+
return false;
|
|
119
|
+
return left.every((value, index) => value === right[index]);
|
|
120
|
+
}
|
|
121
|
+
return left === right;
|
|
122
|
+
}
|
|
123
|
+
function displayValue(value, state, status) {
|
|
124
|
+
if (state === 'absent')
|
|
125
|
+
return 'Not published';
|
|
126
|
+
if (state === 'redacted')
|
|
127
|
+
return 'Redacted by source';
|
|
128
|
+
if (state === 'incomplete')
|
|
129
|
+
return 'Not observed (partial source)';
|
|
130
|
+
if (state === 'unavailable') {
|
|
131
|
+
if (status === 'unsupported')
|
|
132
|
+
return 'Unsupported by source';
|
|
133
|
+
if (status === 'not_found')
|
|
134
|
+
return 'No matching registry object';
|
|
135
|
+
if (status === 'skipped')
|
|
136
|
+
return 'Source skipped';
|
|
137
|
+
if (status === 'disabled')
|
|
138
|
+
return 'Disabled by deployment policy';
|
|
139
|
+
return 'Source unavailable';
|
|
140
|
+
}
|
|
141
|
+
if (Array.isArray(value))
|
|
142
|
+
return value.join(', ');
|
|
143
|
+
return String(value);
|
|
144
|
+
}
|
|
145
|
+
function findRdapEvent(rdap, action) {
|
|
146
|
+
if (!Array.isArray(rdap.events))
|
|
147
|
+
return null;
|
|
148
|
+
const event = rdap.events.map(record).find((item) => item.action === action);
|
|
149
|
+
return event?.date || null;
|
|
150
|
+
}
|
|
151
|
+
function rdapLifecycleDate(rdap, field, fallbackAction) {
|
|
152
|
+
const value = record(rdap.lifecycle)[field];
|
|
153
|
+
return typeof value === 'string' && value ? value : findRdapEvent(rdap, fallbackAction);
|
|
154
|
+
}
|
|
155
|
+
// Conflict and equivalence require values from both sources. Missing fields
|
|
156
|
+
// in a failed, unsupported, skipped, not-found, or partial source are not
|
|
157
|
+
// publication differences: preserving that source health prevents a lookup
|
|
158
|
+
// failure from being presented as an RDAP/WHOIS disagreement.
|
|
159
|
+
function classifyFieldStatus(rdapState, whoisState, rdapValue, whoisValue, normalize) {
|
|
160
|
+
if (rdapState === 'value' && whoisState === 'value') {
|
|
161
|
+
return sameNormalizedValue(normalize(rdapValue), normalize(whoisValue)) ? 'equivalent' : 'conflict';
|
|
162
|
+
}
|
|
163
|
+
if (rdapState === 'redacted' && whoisState === 'redacted')
|
|
164
|
+
return 'equivalent';
|
|
165
|
+
if (rdapState === 'unavailable')
|
|
166
|
+
return 'rdap_unavailable';
|
|
167
|
+
if (whoisState === 'unavailable')
|
|
168
|
+
return 'whois_unavailable';
|
|
169
|
+
if (rdapState === 'incomplete')
|
|
170
|
+
return 'rdap_incomplete';
|
|
171
|
+
if (whoisState === 'incomplete')
|
|
172
|
+
return 'whois_incomplete';
|
|
173
|
+
if (rdapState === 'value' && whoisState === 'absent')
|
|
174
|
+
return 'rdap_only';
|
|
175
|
+
if (whoisState === 'value' && rdapState === 'absent')
|
|
176
|
+
return 'whois_only';
|
|
177
|
+
if (rdapState === 'redacted')
|
|
178
|
+
return 'rdap_redacted';
|
|
179
|
+
return 'whois_redacted';
|
|
180
|
+
}
|
|
181
|
+
function compareField(label, rdapValue, whoisValue, normalize, sourceHealth, comparisonValues = {}) {
|
|
182
|
+
const rdapPublishedState = publishedState(rdapValue);
|
|
183
|
+
const whoisPublishedState = publishedState(whoisValue);
|
|
184
|
+
if (rdapPublishedState === 'absent' && whoisPublishedState === 'absent')
|
|
185
|
+
return null;
|
|
186
|
+
const rdapState = fieldState(rdapValue, sourceHealth.rdapCondition);
|
|
187
|
+
const whoisState = fieldState(whoisValue, sourceHealth.whoisCondition);
|
|
188
|
+
return {
|
|
189
|
+
label,
|
|
190
|
+
status: classifyFieldStatus(rdapState, whoisState, comparisonValues.rdap ?? rdapValue, comparisonValues.whois ?? whoisValue, normalize),
|
|
191
|
+
rdapState,
|
|
192
|
+
whoisState,
|
|
193
|
+
rdapDisplay: displayValue(rdapValue, rdapState, sourceHealth.rdapStatus),
|
|
194
|
+
whoisDisplay: displayValue(whoisValue, whoisState, sourceHealth.whoisStatus),
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
export function compareRegistrySources(rdapParsed, whoisParsed, options = {}) {
|
|
198
|
+
const rdap = record(rdapParsed);
|
|
199
|
+
const whois = record(whoisParsed);
|
|
200
|
+
const sourceHealth = {
|
|
201
|
+
rdapStatus: options.rdapStatus || null,
|
|
202
|
+
whoisStatus: options.whoisStatus || null,
|
|
203
|
+
rdapCondition: sourceCondition(options.rdapStatus),
|
|
204
|
+
whoisCondition: sourceCondition(options.whoisStatus),
|
|
205
|
+
};
|
|
206
|
+
const statusNormalizer = options.statusNormalizer || normalizeStatus;
|
|
207
|
+
const dateField = (label, field, fallbackAction) => {
|
|
208
|
+
const rdapLifecycle = record(rdap.lifecycle);
|
|
209
|
+
const whoisLifecycle = record(whois.lifecycle);
|
|
210
|
+
const rdapValue = rdapLifecycleDate(rdap, field, fallbackAction);
|
|
211
|
+
const whoisValue = whois[field] || whoisLifecycle[field] || null;
|
|
212
|
+
return compareField(label, rdapValue, whoisValue, normalizeDate, sourceHealth, {
|
|
213
|
+
rdap: rdapLifecycle[`${field}Iso`] || rdapValue,
|
|
214
|
+
whois: whois[`${field}Iso`] || whoisLifecycle[`${field}Iso`] || whoisValue,
|
|
215
|
+
});
|
|
216
|
+
};
|
|
217
|
+
const fields = [
|
|
218
|
+
compareField('Domain', rdap.domain, whois.domainName, normalizeDomain, sourceHealth),
|
|
219
|
+
compareField('Registry object ID', rdap.handle, whois.registryDomainId, normalizeText, sourceHealth),
|
|
220
|
+
compareField('Registrar', registrarValue(rdap.registrar), whois.registrar, normalizeText, sourceHealth),
|
|
221
|
+
compareField('Registrar IANA ID', rdap.registrarIanaId, whois.registrarIanaId, normalizeText, sourceHealth),
|
|
222
|
+
dateField('Created', 'createdDate', 'registration'),
|
|
223
|
+
dateField('Expires', 'expiryDate', 'expiration'),
|
|
224
|
+
dateField('Last updated', 'updatedDate', 'last changed'),
|
|
225
|
+
compareField('DNSSEC', rdap.dnssec, whois.dnssec, normalizeDnssec, sourceHealth),
|
|
226
|
+
compareField('Statuses', rdap.statuses, whois.statuses, (values) => normalizeSet(values, statusNormalizer), sourceHealth),
|
|
227
|
+
compareField('Name servers', rdap.nameservers, whois.nameservers, (values) => normalizeSet(values, normalizeNameserver), sourceHealth),
|
|
228
|
+
].filter((field) => field !== null);
|
|
229
|
+
const counts = {
|
|
230
|
+
equivalent: 0, conflict: 0, rdap_only: 0, whois_only: 0,
|
|
231
|
+
rdap_redacted: 0, whois_redacted: 0,
|
|
232
|
+
rdap_unavailable: 0, whois_unavailable: 0,
|
|
233
|
+
rdap_incomplete: 0, whois_incomplete: 0,
|
|
234
|
+
};
|
|
235
|
+
for (const field of fields)
|
|
236
|
+
counts[field.status] += 1;
|
|
237
|
+
return {
|
|
238
|
+
fields,
|
|
239
|
+
counts,
|
|
240
|
+
sourceHealth: {
|
|
241
|
+
rdap: { status: sourceHealth.rdapStatus, condition: sourceHealth.rdapCondition },
|
|
242
|
+
whois: { status: sourceHealth.whoisStatus, condition: sourceHealth.whoisCondition },
|
|
243
|
+
},
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
const RDAP_PUBLICATION_STATUS = {
|
|
247
|
+
equivalent: 'equivalent',
|
|
248
|
+
conflict: 'conflict',
|
|
249
|
+
rdap_only: 'registry_only',
|
|
250
|
+
whois_only: 'registrar_only',
|
|
251
|
+
rdap_redacted: 'registry_redacted',
|
|
252
|
+
whois_redacted: 'registrar_redacted',
|
|
253
|
+
rdap_unavailable: 'registry_unavailable',
|
|
254
|
+
whois_unavailable: 'registrar_unavailable',
|
|
255
|
+
rdap_incomplete: 'registry_incomplete',
|
|
256
|
+
whois_incomplete: 'registrar_incomplete',
|
|
257
|
+
};
|
|
258
|
+
// Registrar RDAP is a separately attributed publication, not a replacement
|
|
259
|
+
// for the registry object. Reuse the established pairwise normalization while
|
|
260
|
+
// adapting only fields with portable semantics across both RDAP publishers.
|
|
261
|
+
// Source-specific object handles and contact inventories stay out of conflict
|
|
262
|
+
// classification because different identifiers and disclosure policies are
|
|
263
|
+
// expected at each layer.
|
|
264
|
+
export function compareRdapPublications(registryParsed, registrarParsed, options = {}) {
|
|
265
|
+
const registry = record(registryParsed);
|
|
266
|
+
const registrar = record(registrarParsed);
|
|
267
|
+
const registrarLifecycle = record(registrar.lifecycle);
|
|
268
|
+
const comparison = compareRegistrySources({ ...registry, handle: null }, {
|
|
269
|
+
domainName: registrar.domain,
|
|
270
|
+
registrar: registrarValue(registrar.registrar),
|
|
271
|
+
registrarIanaId: registrar.registrarIanaId,
|
|
272
|
+
createdDate: registrarLifecycle.createdDate,
|
|
273
|
+
createdDateIso: registrarLifecycle.createdDateIso,
|
|
274
|
+
expiryDate: registrarLifecycle.expiryDate,
|
|
275
|
+
expiryDateIso: registrarLifecycle.expiryDateIso,
|
|
276
|
+
updatedDate: registrarLifecycle.updatedDate,
|
|
277
|
+
updatedDateIso: registrarLifecycle.updatedDateIso,
|
|
278
|
+
dnssec: registrar.dnssec,
|
|
279
|
+
statuses: registrar.statuses,
|
|
280
|
+
nameservers: registrar.nameservers,
|
|
281
|
+
}, {
|
|
282
|
+
rdapStatus: options.registryStatus,
|
|
283
|
+
whoisStatus: options.registrarStatus,
|
|
284
|
+
statusNormalizer: normalizeRdapPublicationStatus,
|
|
285
|
+
});
|
|
286
|
+
const counts = {
|
|
287
|
+
equivalent: 0,
|
|
288
|
+
conflict: 0,
|
|
289
|
+
registry_only: 0,
|
|
290
|
+
registrar_only: 0,
|
|
291
|
+
registry_redacted: 0,
|
|
292
|
+
registrar_redacted: 0,
|
|
293
|
+
registry_unavailable: 0,
|
|
294
|
+
registrar_unavailable: 0,
|
|
295
|
+
registry_incomplete: 0,
|
|
296
|
+
registrar_incomplete: 0,
|
|
297
|
+
};
|
|
298
|
+
const fields = comparison.fields.map((field) => {
|
|
299
|
+
const status = RDAP_PUBLICATION_STATUS[field.status];
|
|
300
|
+
counts[status] += 1;
|
|
301
|
+
return {
|
|
302
|
+
label: field.label,
|
|
303
|
+
status,
|
|
304
|
+
registryState: field.rdapState,
|
|
305
|
+
registrarState: field.whoisState,
|
|
306
|
+
registryDisplay: field.rdapDisplay,
|
|
307
|
+
registrarDisplay: field.whoisDisplay,
|
|
308
|
+
};
|
|
309
|
+
});
|
|
310
|
+
return {
|
|
311
|
+
fields,
|
|
312
|
+
counts,
|
|
313
|
+
sourceHealth: {
|
|
314
|
+
registry: comparison.sourceHealth.rdap,
|
|
315
|
+
registrar: comparison.sourceHealth.whois,
|
|
316
|
+
},
|
|
317
|
+
};
|
|
318
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// Deterministic normalization for lifecycle dates published by RDAP and
|
|
2
|
+
// WHOIS services. Raw upstream strings remain the provenance source; these
|
|
3
|
+
// helpers only produce additive Date/ISO companions for comparison, storage,
|
|
4
|
+
// and presentation.
|
|
5
|
+
const REGISTRY_MONTHS = Object.freeze({
|
|
6
|
+
jan: 1, feb: 2, mar: 3, apr: 4, may: 5, jun: 6,
|
|
7
|
+
jul: 7, aug: 8, sep: 9, oct: 10, nov: 11, dec: 12,
|
|
8
|
+
});
|
|
9
|
+
const REGISTRY_MONTH_NAMES = Object.freeze({
|
|
10
|
+
january: 1, february: 2, march: 3, april: 4, may: 5, june: 6,
|
|
11
|
+
july: 7, august: 8, september: 9, october: 10, november: 11, december: 12,
|
|
12
|
+
});
|
|
13
|
+
function utcDateFromParts(year, month, day, hour = 0, minute = 0, second = 0, millisecond = 0) {
|
|
14
|
+
if (![year, month, day, hour, minute, second, millisecond].every(Number.isInteger))
|
|
15
|
+
return null;
|
|
16
|
+
if (year < 1000 || year > 9999 || month < 1 || month > 12 || day < 1
|
|
17
|
+
|| hour < 0 || hour > 23 || minute < 0 || minute > 59
|
|
18
|
+
|| second < 0 || second > 59 || millisecond < 0 || millisecond > 999)
|
|
19
|
+
return null;
|
|
20
|
+
const date = new Date(Date.UTC(year, month - 1, day, hour, minute, second, millisecond));
|
|
21
|
+
if (date.getUTCFullYear() !== year || date.getUTCMonth() !== month - 1 || date.getUTCDate() !== day
|
|
22
|
+
|| date.getUTCHours() !== hour || date.getUTCMinutes() !== minute
|
|
23
|
+
|| date.getUTCSeconds() !== second || date.getUTCMilliseconds() !== millisecond)
|
|
24
|
+
return null;
|
|
25
|
+
return date;
|
|
26
|
+
}
|
|
27
|
+
// Parse the explicit non-ISO formats observed across supported ccTLD WHOIS
|
|
28
|
+
// services before considering ISO-shaped input. Runtime Date parsing treats
|
|
29
|
+
// dotted dates ambiguously and timezone-less timestamps in the host's local
|
|
30
|
+
// timezone, so every accepted shape is validated and normalized through UTC.
|
|
31
|
+
function parseRegistryDate(input) {
|
|
32
|
+
if (typeof input !== 'string')
|
|
33
|
+
return null;
|
|
34
|
+
// The documented .nz WHOIS examples place one space before the RFC3339
|
|
35
|
+
// `T` separator. Canonicalize that registry-specific presentation quirk
|
|
36
|
+
// before applying the existing strict ISO-shaped parser.
|
|
37
|
+
const value = input.trim()
|
|
38
|
+
.replace(/^(\d{4}-\d{2}-\d{2})[ ]+[Tt](?=\d{2}:)/, '$1T')
|
|
39
|
+
// The .ee WHOIS service separates an otherwise ISO-shaped numeric
|
|
40
|
+
// timezone from its clock with one space.
|
|
41
|
+
.replace(/^(\d{4}-\d{2}-\d{2}[Tt ]\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?)[ ]+([+-]\d{2}:?\d{2})$/, '$1$2');
|
|
42
|
+
if (!value)
|
|
43
|
+
return null;
|
|
44
|
+
// DD.MM.YYYY[ HH:MM:SS] - e.g. 14.03.2024 10:46:48
|
|
45
|
+
let match = value.match(/^(\d{1,2})\.(\d{1,2})\.(\d{4})(?:\s+(\d{1,2}):(\d{2}):(\d{2}))?$/);
|
|
46
|
+
if (match) {
|
|
47
|
+
const [, day = '', month = '', year = '', hour = '0', minute = '0', second = '0'] = match;
|
|
48
|
+
return utcDateFromParts(+year, +month, +day, +(hour || 0), +(minute || 0), +(second || 0));
|
|
49
|
+
}
|
|
50
|
+
// YYYY. MM. DD. - e.g. 2006. 09. 18.
|
|
51
|
+
match = value.match(/^(\d{4})\.\s*(\d{1,2})\.\s*(\d{1,2})\.?$/);
|
|
52
|
+
if (match) {
|
|
53
|
+
const [, year = '', month = '', day = ''] = match;
|
|
54
|
+
return utcDateFromParts(+year, +month, +day);
|
|
55
|
+
}
|
|
56
|
+
// YYYY.MM.DD HH:MM:SS - used by NASK's .pl WHOIS service.
|
|
57
|
+
match = value.match(/^(\d{4})\.(\d{1,2})\.(\d{1,2})\s+(\d{1,2}):(\d{2}):(\d{2})$/);
|
|
58
|
+
if (match) {
|
|
59
|
+
const [, year = '', month = '', day = '', hour = '', minute = '', second = ''] = match;
|
|
60
|
+
return utcDateFromParts(+year, +month, +day, +hour, +minute, +second);
|
|
61
|
+
}
|
|
62
|
+
// YYYY/MM/DD - an unambiguous year-first form used by CIRA WHOIS.
|
|
63
|
+
match = value.match(/^(\d{4})\/(\d{1,2})\/(\d{1,2})$/);
|
|
64
|
+
if (match) {
|
|
65
|
+
const [, year = '', month = '', day = ''] = match;
|
|
66
|
+
return utcDateFromParts(+year, +month, +day);
|
|
67
|
+
}
|
|
68
|
+
// YYYYMMDD with an optional time or Registro.br contact-reference suffix.
|
|
69
|
+
// The raw value remains available as provenance while the validated date
|
|
70
|
+
// and optional clock time are projected into the additive ISO companion.
|
|
71
|
+
match = value.match(/^(\d{4})(\d{2})(\d{2})(?:\s+(\d{1,2}):(\d{2}):(\d{2})|\s+#[0-9]{1,20})?$/);
|
|
72
|
+
if (match) {
|
|
73
|
+
const [, year = '', month = '', day = '', hour = '0', minute = '0', second = '0'] = match;
|
|
74
|
+
return utcDateFromParts(+year, +month, +day, +(hour || 0), +(minute || 0), +(second || 0));
|
|
75
|
+
}
|
|
76
|
+
// YYYY-Mon-DD. - e.g. 1999-Feb-16.
|
|
77
|
+
match = value.match(/^(\d{4})-([A-Za-z]{3})-(\d{1,2})\.?$/);
|
|
78
|
+
if (match) {
|
|
79
|
+
const month = REGISTRY_MONTHS[(match[2] ?? '').toLowerCase()];
|
|
80
|
+
return month ? utcDateFromParts(+(match[1] ?? ''), month, +(match[3] ?? '')) : null;
|
|
81
|
+
}
|
|
82
|
+
// DD-Mon-YYYY[ HH:MM:SS] - used by several ICANN-style ccTLD services.
|
|
83
|
+
match = value.match(/^(\d{1,2})-([A-Za-z]{3})-(\d{4})(?:\s+(\d{1,2}):(\d{2}):(\d{2}))?$/);
|
|
84
|
+
if (match) {
|
|
85
|
+
const month = REGISTRY_MONTHS[(match[2] ?? '').toLowerCase()];
|
|
86
|
+
return month
|
|
87
|
+
? utcDateFromParts(+(match[3] ?? ''), month, +(match[1] ?? ''), +(match[4] || 0), +(match[5] || 0), +(match[6] || 0))
|
|
88
|
+
: null;
|
|
89
|
+
}
|
|
90
|
+
// DD-MM-YYYY - the day-first form published by the currently profiled
|
|
91
|
+
// registries that use bare numeric hyphen dates. A future month-first
|
|
92
|
+
// source must receive its own marker-gated parser rather than reusing this
|
|
93
|
+
// global shape. The four-digit final year remains distinct from ISO below.
|
|
94
|
+
match = value.match(/^(\d{1,2})-(\d{1,2})-(\d{4})$/);
|
|
95
|
+
if (match) {
|
|
96
|
+
const [, day = '', month = '', year = ''] = match;
|
|
97
|
+
return utcDateFromParts(+year, +month, +day);
|
|
98
|
+
}
|
|
99
|
+
// YYYY-MM-DD HH:MM:SS (GMT+H:MM) - the explicit offset form published by
|
|
100
|
+
// the Kazakhstan registry. Parentheses and the GMT marker are required so
|
|
101
|
+
// unrelated prose is not accepted as a lifecycle timestamp.
|
|
102
|
+
match = value.match(/^(\d{4})-(\d{2})-(\d{2})\s+(\d{2}):(\d{2}):(\d{2})\s+\(GMT([+-])(\d{1,2}):(\d{2})\)$/i);
|
|
103
|
+
if (match) {
|
|
104
|
+
const [, year = '', month = '', day = '', hour = '', minute = '', second = '', sign = '', offsetHours = '', offsetMinutes = '',] = match;
|
|
105
|
+
const local = utcDateFromParts(+year, +month, +day, +hour, +minute, +second);
|
|
106
|
+
if (!local || +offsetHours > 23 || +offsetMinutes > 59)
|
|
107
|
+
return null;
|
|
108
|
+
const offsetMs = (+offsetHours * 60 + +offsetMinutes) * 60000;
|
|
109
|
+
return new Date(local.getTime() + (sign === '+' ? -offsetMs : offsetMs));
|
|
110
|
+
}
|
|
111
|
+
// DD-MM-YYYY HH:MM:SS GMT+H - the Tunisian registry's bounded local-time
|
|
112
|
+
// presentation. Accept an optional minute component but require the GMT
|
|
113
|
+
// marker and validate the calendar and offset before normalization.
|
|
114
|
+
match = value.match(/^(\d{1,2})-(\d{1,2})-(\d{4})\s+(\d{2}):(\d{2}):(\d{2})\s+GMT([+-])(\d{1,2})(?::(\d{2}))?$/i);
|
|
115
|
+
if (match) {
|
|
116
|
+
const [, day = '', month = '', year = '', hour = '', minute = '', second = '', sign = '', offsetHours = '', offsetMinutes = '0',] = match;
|
|
117
|
+
const local = utcDateFromParts(+year, +month, +day, +hour, +minute, +second);
|
|
118
|
+
if (!local || +offsetHours > 23 || +offsetMinutes > 59)
|
|
119
|
+
return null;
|
|
120
|
+
const offsetMs = (+offsetHours * 60 + +offsetMinutes) * 60000;
|
|
121
|
+
return new Date(local.getTime() + (sign === '+' ? -offsetMs : offsetMs));
|
|
122
|
+
}
|
|
123
|
+
// DD Mon YYYY - the compact English month form published by THNIC.
|
|
124
|
+
match = value.match(/^(\d{1,2})\s+([A-Za-z]{3})\s+(\d{4})$/);
|
|
125
|
+
if (match) {
|
|
126
|
+
const month = REGISTRY_MONTHS[(match[2] ?? '').toLowerCase()];
|
|
127
|
+
return month ? utcDateFromParts(+(match[3] ?? ''), month, +(match[1] ?? '')) : null;
|
|
128
|
+
}
|
|
129
|
+
// Ordinal-day Month YYYY[ at HH:MM:SS.sss] - the English form published
|
|
130
|
+
// by the Channel Islands registry. The suffix and `at` token are required
|
|
131
|
+
// so ordinary prose containing a month name is not accepted as lifecycle
|
|
132
|
+
// evidence.
|
|
133
|
+
match = value.match(/^(\d{1,2})(st|nd|rd|th)\s+([A-Za-z]{3,9})\s+(\d{4})(?:\s+at\s+(\d{1,2}):(\d{2}):(\d{2})(?:\.(\d{1,3}))?)?$/i);
|
|
134
|
+
if (match) {
|
|
135
|
+
const day = +(match[1] ?? '');
|
|
136
|
+
const suffix = (match[2] ?? '').toLowerCase();
|
|
137
|
+
const expectedSuffix = day % 100 >= 11 && day % 100 <= 13
|
|
138
|
+
? 'th'
|
|
139
|
+
: ({ 1: 'st', 2: 'nd', 3: 'rd' }[day % 10] || 'th');
|
|
140
|
+
if (suffix !== expectedSuffix)
|
|
141
|
+
return null;
|
|
142
|
+
const monthText = (match[3] ?? '').toLowerCase();
|
|
143
|
+
const month = REGISTRY_MONTH_NAMES[monthText] || REGISTRY_MONTHS[monthText];
|
|
144
|
+
const millisecond = match[8] ? Number(`${match[8]}00`.slice(0, 3)) : 0;
|
|
145
|
+
return month
|
|
146
|
+
? utcDateFromParts(+(match[4] ?? ''), month, day, +(match[5] || 0), +(match[6] || 0), +(match[7] || 0), millisecond)
|
|
147
|
+
: null;
|
|
148
|
+
}
|
|
149
|
+
// Ddd Mon DD YYYY[ HH:MM:SS] - the English textual form used by the
|
|
150
|
+
// Belgian registry. Weekday text is presentation-only; the validated
|
|
151
|
+
// calendar components determine the canonical UTC companion.
|
|
152
|
+
match = value.match(/^(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun)\s+([A-Za-z]{3})\s+(\d{1,2})\s+(\d{4})(?:\s+(\d{1,2}):(\d{2}):(\d{2}))?$/i);
|
|
153
|
+
if (match) {
|
|
154
|
+
const month = REGISTRY_MONTHS[(match[1] ?? '').toLowerCase()];
|
|
155
|
+
return month
|
|
156
|
+
? utcDateFromParts(+(match[3] ?? ''), month, +(match[2] ?? ''), +(match[4] || 0), +(match[5] || 0), +(match[6] || 0))
|
|
157
|
+
: null;
|
|
158
|
+
}
|
|
159
|
+
// Month DD YYYY - ISNIC publishes English month names with variable
|
|
160
|
+
// spacing and no timezone. Keep the accepted shape narrow and accept only
|
|
161
|
+
// the twelve exact English names from a fixed table.
|
|
162
|
+
match = value.match(/^([A-Za-z]{3,9})\s+(\d{1,2})\s+(\d{4})$/);
|
|
163
|
+
if (match) {
|
|
164
|
+
const month = REGISTRY_MONTH_NAMES[(match[1] ?? '').toLowerCase()];
|
|
165
|
+
return month ? utcDateFromParts(+(match[3] ?? ''), month, +(match[2] ?? '')) : null;
|
|
166
|
+
}
|
|
167
|
+
// ISO 8601-shaped dates and timestamps. A missing timezone is deliberately
|
|
168
|
+
// interpreted as UTC so Express and function deployments agree.
|
|
169
|
+
match = value.match(/^(\d{4})-(\d{2})-(\d{2})(?:[Tt ](\d{2}):(\d{2})(?::(\d{2})(?:\.(\d{1,9}))?)?(?:([Zz])|([+-])(\d{2}):?(\d{2}))?)?$/);
|
|
170
|
+
if (!match)
|
|
171
|
+
return null;
|
|
172
|
+
const [, year = '', month = '', day = '', hour = '0', minute = '0', second = '0', fraction, zulu, offsetSign, offsetHours = '0', offsetMinutes = '0',] = match;
|
|
173
|
+
const millisecond = fraction ? Number(`${fraction}000`.slice(0, 3)) : 0;
|
|
174
|
+
const local = utcDateFromParts(+year, +month, +day, +(hour || 0), +(minute || 0), +(second || 0), millisecond);
|
|
175
|
+
if (!local)
|
|
176
|
+
return null;
|
|
177
|
+
if (!offsetSign || zulu)
|
|
178
|
+
return local;
|
|
179
|
+
const offsetHour = +offsetHours;
|
|
180
|
+
const offsetMinute = +offsetMinutes;
|
|
181
|
+
if (offsetHour > 23 || offsetMinute > 59)
|
|
182
|
+
return null;
|
|
183
|
+
const offsetMs = (offsetHour * 60 + offsetMinute) * 60000;
|
|
184
|
+
return new Date(local.getTime() + (offsetSign === '+' ? -offsetMs : offsetMs));
|
|
185
|
+
}
|
|
186
|
+
function registryDateIso(value) {
|
|
187
|
+
const date = parseRegistryDate(value);
|
|
188
|
+
return date ? date.toISOString() : null;
|
|
189
|
+
}
|
|
190
|
+
export { parseRegistryDate, registryDateIso };
|