@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.
Files changed (189) hide show
  1. package/LICENSE +661 -0
  2. package/LICENSES/Retire.js-Apache-2.0.txt +199 -0
  3. package/NOTICE +76 -0
  4. package/README.md +84 -0
  5. package/TRADEMARKS.md +41 -0
  6. package/bin/whoisleuth.mjs +35 -0
  7. package/cli/archive-inspect.mjs +198 -0
  8. package/cli/arguments.mjs +1022 -0
  9. package/cli/artifact-verify.mjs +210 -0
  10. package/cli/bulk-checkpoint.mjs +218 -0
  11. package/cli/bulk-command-runner.mjs +109 -0
  12. package/cli/bulk-output.mjs +112 -0
  13. package/cli/bulk.mjs +133 -0
  14. package/cli/compare.mjs +238 -0
  15. package/cli/completion.mjs +175 -0
  16. package/cli/csv.mjs +1 -0
  17. package/cli/discover.mjs +50 -0
  18. package/cli/discovery-command-runner.mjs +36 -0
  19. package/cli/discovery-observation-snapshot.mjs +315 -0
  20. package/cli/discovery-scan-command-runner.mjs +141 -0
  21. package/cli/discovery-scan.mjs +325 -0
  22. package/cli/discovery-snapshot.mjs +133 -0
  23. package/cli/discovery-workflow.mjs +73 -0
  24. package/cli/doctor.mjs +137 -0
  25. package/cli/errors.mjs +21 -0
  26. package/cli/evidence-command-arguments.mjs +145 -0
  27. package/cli/evidence-command-runner.mjs +106 -0
  28. package/cli/evidence-signing.mjs +215 -0
  29. package/cli/exit-codes.mjs +9 -0
  30. package/cli/export-evidence.mjs +28 -0
  31. package/cli/formatters/evidence-report.mjs +3 -0
  32. package/cli/formatters/html.mjs +69 -0
  33. package/cli/formatters/json.mjs +135 -0
  34. package/cli/formatters/markdown.mjs +3 -0
  35. package/cli/formatters/terminal.mjs +658 -0
  36. package/cli/http.mjs +27 -0
  37. package/cli/lookup-command-runner.mjs +87 -0
  38. package/cli/lookup-diff.mjs +171 -0
  39. package/cli/mail-review.mjs +204 -0
  40. package/cli/manual.mjs +34 -0
  41. package/cli/network-command-runner.mjs +88 -0
  42. package/cli/output-file.mjs +97 -0
  43. package/cli/page-compare.mjs +123 -0
  44. package/cli/posture.mjs +20 -0
  45. package/cli/progress-events.mjs +53 -0
  46. package/cli/progress.mjs +56 -0
  47. package/cli/registry-access.mjs +3 -0
  48. package/cli/registry-support.mjs +120 -0
  49. package/cli/risk-calibration.mjs +294 -0
  50. package/cli/runner-types.mjs +1 -0
  51. package/cli/runner.mjs +726 -0
  52. package/cli/saved-lookup.mjs +87 -0
  53. package/cli/source-reliability.mjs +727 -0
  54. package/cli/strict-exit.mjs +55 -0
  55. package/cli/terminal-presentation.mjs +147 -0
  56. package/docs/cli.md +827 -0
  57. package/frontend/src/lib/analysis/acquisition-decision-packet.js +93 -0
  58. package/frontend/src/lib/analysis/acquisition-due-diligence.js +391 -0
  59. package/frontend/src/lib/analysis/artifact-integrity.js +21 -0
  60. package/frontend/src/lib/analysis/brand-profile-model.js +421 -0
  61. package/frontend/src/lib/analysis/bulk-domain-comparison.js +246 -0
  62. package/frontend/src/lib/analysis/bulk-mail-exposure.js +192 -0
  63. package/frontend/src/lib/analysis/bulk-retry-plan.js +86 -0
  64. package/frontend/src/lib/analysis/bulk-review-export.js +62 -0
  65. package/frontend/src/lib/analysis/bulk-review-model.js +266 -0
  66. package/frontend/src/lib/analysis/bulk-session-model.js +515 -0
  67. package/frontend/src/lib/analysis/bulk-sort.js +49 -0
  68. package/frontend/src/lib/analysis/campaign-model.js +265 -0
  69. package/frontend/src/lib/analysis/case-evidence-model.js +642 -0
  70. package/frontend/src/lib/analysis/case-migration-model.js +248 -0
  71. package/frontend/src/lib/analysis/case-model.js +5 -0
  72. package/frontend/src/lib/analysis/case-record-contracts.js +44 -0
  73. package/frontend/src/lib/analysis/case-record-core.js +232 -0
  74. package/frontend/src/lib/analysis/case-record-model.js +7 -0
  75. package/frontend/src/lib/analysis/case-record-operations.js +224 -0
  76. package/frontend/src/lib/analysis/case-response-model.js +640 -0
  77. package/frontend/src/lib/analysis/case-response-packet.js +573 -0
  78. package/frontend/src/lib/analysis/case-storage-model.js +93 -0
  79. package/frontend/src/lib/analysis/detection-rule-model.js +306 -0
  80. package/frontend/src/lib/analysis/dns-record-normalization.js +11 -0
  81. package/frontend/src/lib/analysis/http-summary.js +165 -0
  82. package/frontend/src/lib/analysis/investigation-guide.js +500 -0
  83. package/frontend/src/lib/analysis/investigation-template-model.js +144 -0
  84. package/frontend/src/lib/analysis/page-baseline.js +272 -0
  85. package/frontend/src/lib/analysis/page-similarity.js +179 -0
  86. package/frontend/src/lib/analysis/relationship-evidence.js +265 -0
  87. package/frontend/src/lib/analysis/relationship-observation-model.js +423 -0
  88. package/frontend/src/lib/analysis/scoring.js +135 -0
  89. package/frontend/src/lib/analysis/shortlist-model.js +209 -0
  90. package/frontend/src/lib/analysis/utils.js +265 -0
  91. package/frontend/src/lib/analysis/watchlist-history.js +643 -0
  92. package/frontend/src/lib/analysis/watchlist-store.js +127 -0
  93. package/frontend/src/lib/analysis/website-snapshot-model.js +302 -0
  94. package/frontend/src/lib/analysis/workspace-archive-crypto.js +282 -0
  95. package/frontend/src/lib/analysis/workspace-archive.js +472 -0
  96. package/lib/abort.mjs +26 -0
  97. package/lib/abusech-auth.mjs +19 -0
  98. package/lib/availability.mjs +716 -0
  99. package/lib/bounded-contract-normalizers.mjs +61 -0
  100. package/lib/browser-library-profile.mjs +310 -0
  101. package/lib/bulk-comparison-evidence.mjs +83 -0
  102. package/lib/classify.mjs +93 -0
  103. package/lib/client-behavior-profile.mjs +83 -0
  104. package/lib/credential-surface-profile.mjs +66 -0
  105. package/lib/csv.mjs +23 -0
  106. package/lib/ct-query.mjs +27 -0
  107. package/lib/ct-search.mjs +393 -0
  108. package/lib/distributed-operation-budget.mjs +461 -0
  109. package/lib/dns-delegation-health.mjs +344 -0
  110. package/lib/dns-intelligence.mjs +739 -0
  111. package/lib/dns-mx.mjs +24 -0
  112. package/lib/dns-resolver-selection.mjs +33 -0
  113. package/lib/domain-posture-analysis.mjs +254 -0
  114. package/lib/domain-posture-parsers.mjs +358 -0
  115. package/lib/domain-posture.mjs +745 -0
  116. package/lib/error-detail.mjs +10 -0
  117. package/lib/evidence-export.mjs +431 -0
  118. package/lib/evidence-report-markdown.mjs +71 -0
  119. package/lib/evidence-report.mjs +391 -0
  120. package/lib/external-intelligence-risk.mjs +108 -0
  121. package/lib/favicon.mjs +146 -0
  122. package/lib/feature-policy.mjs +70 -0
  123. package/lib/generated/retire-browser-catalog.mjs +8724 -0
  124. package/lib/generated/unicode-confusables-17.mjs +93 -0
  125. package/lib/hostname.mjs +38 -0
  126. package/lib/html-signals.mjs +680 -0
  127. package/lib/http-intelligence.mjs +232 -0
  128. package/lib/idn-confusable-policy.mjs +86 -0
  129. package/lib/idn-confusables.mjs +465 -0
  130. package/lib/lookup-cache.mjs +130 -0
  131. package/lib/lookup-progress.mjs +291 -0
  132. package/lib/lookup-source-progress.mjs +109 -0
  133. package/lib/lookup-threat-provider-inventory.mjs +13 -0
  134. package/lib/lookup.mjs +592 -0
  135. package/lib/observation.mjs +114 -0
  136. package/lib/observed-network-context.mjs +355 -0
  137. package/lib/operation-budget.mjs +489 -0
  138. package/lib/page-fingerprints.mjs +481 -0
  139. package/lib/page-role-profile.mjs +117 -0
  140. package/lib/perceptual-hash.mjs +451 -0
  141. package/lib/rdap-attempts.mjs +34 -0
  142. package/lib/rdap-bootstrap.mjs +173 -0
  143. package/lib/rdap-capabilities.mjs +292 -0
  144. package/lib/rdap-client.mjs +125 -0
  145. package/lib/rdap-normalization.mjs +691 -0
  146. package/lib/rdap-registrar.mjs +196 -0
  147. package/lib/rdap-transport.mjs +39 -0
  148. package/lib/rdap-types.mjs +1 -0
  149. package/lib/rdap-validation.mjs +94 -0
  150. package/lib/rdap.mjs +16 -0
  151. package/lib/registry-access.mjs +32 -0
  152. package/lib/registry-capabilities.mjs +117 -0
  153. package/lib/registry-capability-catalogue.mjs +1206 -0
  154. package/lib/registry-comparison.mjs +318 -0
  155. package/lib/registry-dates.mjs +190 -0
  156. package/lib/registry-insights.mjs +325 -0
  157. package/lib/response-policy.mjs +256 -0
  158. package/lib/risk-scoring.mjs +164 -0
  159. package/lib/safe-fetch.mjs +446 -0
  160. package/lib/security-txt.mjs +359 -0
  161. package/lib/service-binding-dns.mjs +608 -0
  162. package/lib/sslbl-certificates.generated.mjs +143 -0
  163. package/lib/sslbl-intelligence.mjs +241 -0
  164. package/lib/static-html-analysis.mjs +340 -0
  165. package/lib/structured-data-identity.mjs +323 -0
  166. package/lib/threat-intelligence-contract.mjs +5 -0
  167. package/lib/threat-intelligence-definition-registry.mjs +34 -0
  168. package/lib/threat-intelligence-fixtures.mjs +82 -0
  169. package/lib/threat-intelligence-manifests.mjs +512 -0
  170. package/lib/threat-intelligence-runtime.mjs +662 -0
  171. package/lib/threat-intelligence-targets.mjs +168 -0
  172. package/lib/threat-intelligence-types.mjs +3 -0
  173. package/lib/threatfox-intelligence.mjs +307 -0
  174. package/lib/tls-intelligence.mjs +814 -0
  175. package/lib/typosquat-generator.mjs +884 -0
  176. package/lib/urlhaus-intelligence.mjs +361 -0
  177. package/lib/urlscan-intelligence.mjs +349 -0
  178. package/lib/website-security-posture.mjs +325 -0
  179. package/lib/website-technology.mjs +437 -0
  180. package/lib/whois-authority.mjs +98 -0
  181. package/lib/whois-chain.mjs +261 -0
  182. package/lib/whois-contacts.mjs +163 -0
  183. package/lib/whois-contracts.mjs +3 -0
  184. package/lib/whois-normalization.mjs +27 -0
  185. package/lib/whois-transport.mjs +100 -0
  186. package/lib/whois-values.mjs +92 -0
  187. package/lib/whois.mjs +1187 -0
  188. package/package.json +55 -0
  189. 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 };