@slicedearth/whoisleuth-cli 2.0.0 → 2.1.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 (97) hide show
  1. package/cli/arguments.mjs +21 -5
  2. package/cli/artifact-validation/case-response.mjs +771 -0
  3. package/cli/artifact-validation/investigation-capsule.mjs +426 -0
  4. package/cli/artifact-validation/lookup-evidence.mjs +718 -0
  5. package/cli/artifact-validation/signed-review.mjs +598 -0
  6. package/cli/artifact-validation/structure-primitives.mjs +105 -0
  7. package/cli/artifact-verify.mjs +1 -1
  8. package/cli/assurance-command-runner.mjs +324 -0
  9. package/cli/command-reference.mjs +1175 -843
  10. package/cli/completion.mjs +56 -9
  11. package/cli/discriminated-command-handlers.mjs +11 -0
  12. package/cli/doctor.mjs +2 -2
  13. package/cli/evidence-command-runner.mjs +41 -31
  14. package/cli/export-evidence.mjs +1 -1
  15. package/cli/fail-policy.mjs +33 -8
  16. package/cli/formatters/terminal-command-formats.mjs +437 -0
  17. package/cli/formatters/terminal-lookup.mjs +758 -0
  18. package/cli/formatters/terminal-metadata.mjs +58 -0
  19. package/cli/formatters/terminal-shared.mjs +83 -0
  20. package/cli/formatters/terminal.mjs +5 -1232
  21. package/cli/history-command-runner.mjs +132 -0
  22. package/cli/inline-command-families.mjs +45 -0
  23. package/cli/inline-command-runner.mjs +55 -0
  24. package/cli/investigation-plan.mjs +9 -2
  25. package/cli/investigation-run.mjs +114 -9
  26. package/cli/lookup-brief.mjs +141 -23
  27. package/cli/lookup-browser-view.mjs +289 -0
  28. package/cli/lookup-browser.mjs +3 -288
  29. package/cli/mail-review.mjs +2 -6
  30. package/cli/offline-artifact-validation.mjs +19 -0
  31. package/cli/review-command-runner.mjs +269 -0
  32. package/cli/runner.mjs +15 -972
  33. package/cli/saved-lookup.mjs +19 -2
  34. package/cli/support-command-runner.mjs +229 -0
  35. package/cli/workflow-command-runner.mjs +129 -0
  36. package/docs/cli-reference.md +17 -10
  37. package/docs/cli.md +5 -3
  38. package/lib/availability.mjs +12 -9
  39. package/lib/bounded-contract-normalizers.mjs +2 -9
  40. package/lib/browser-library-profile.mjs +1 -2
  41. package/lib/client-behavior-profile.mjs +1 -2
  42. package/lib/credential-surface-profile.mjs +1 -1
  43. package/lib/domain-posture-analysis.mjs +10 -1
  44. package/lib/domain-posture.mjs +51 -23
  45. package/lib/evidence-export.mjs +46 -5
  46. package/lib/evidence-report-markdown.mjs +4 -0
  47. package/lib/evidence-report.mjs +50 -0
  48. package/lib/generated/cisa-kev-catalog.mjs +34 -4
  49. package/lib/generated/registrar-standing-catalogue.mjs +367 -0
  50. package/lib/json-record.mjs +12 -0
  51. package/lib/lookup-child-profile-contract.mjs +858 -0
  52. package/lib/lookup-contract-primitives.mjs +98 -0
  53. package/lib/lookup-diagnostics.mjs +96 -0
  54. package/lib/lookup-network-evidence-bounds.mjs +1 -0
  55. package/lib/lookup-response.mjs +367 -0
  56. package/lib/lookup.mjs +28 -443
  57. package/lib/page-role-profile.mjs +1 -3
  58. package/lib/project-metadata.mjs +1 -1
  59. package/lib/registrar-standing-catalogue-contract.mjs +70 -0
  60. package/lib/registrar-standing-contract.mjs +300 -0
  61. package/lib/registrar-standing.mjs +269 -0
  62. package/lib/registry-insights.mjs +2 -8
  63. package/lib/risk-scoring.mjs +26 -10
  64. package/lib/sslbl-certificates.generated.mjs +138 -136
  65. package/lib/structured-data-identity.mjs +1 -3
  66. package/lib/technology-evidence-role.mjs +51 -0
  67. package/lib/threat-intelligence-runtime.mjs +3 -3
  68. package/lib/threat-intelligence-types.mjs +13 -0
  69. package/lib/threatfox-intelligence.mjs +1 -3
  70. package/lib/tls-intelligence.mjs +1 -2
  71. package/lib/urlhaus-intelligence.mjs +1 -3
  72. package/lib/urlscan-intelligence.mjs +1 -3
  73. package/lib/website-security-posture.mjs +1 -2
  74. package/lib/website-technology.mjs +72 -23
  75. package/lib/whois-common-formats.mjs +276 -0
  76. package/lib/whois-dialects.mjs +534 -0
  77. package/lib/whois-parser-support.mjs +305 -0
  78. package/lib/whois-parser.mjs +135 -0
  79. package/lib/whois.mjs +6 -1187
  80. package/package.json +3 -3
  81. package/packages/cases/case-evidence-model.mjs +9 -0
  82. package/packages/cases/case-migration-model.mjs +17 -11
  83. package/packages/cases/case-response-model.mjs +13 -9
  84. package/packages/cases/case-response-packet.mjs +297 -112
  85. package/packages/comparison/page-similarity.mjs +23 -0
  86. package/packages/contracts/case-portability.mjs +227 -63
  87. package/packages/contracts/cli-lookup.mjs +4 -3
  88. package/packages/contracts/domain-control-manifest.mjs +1 -1
  89. package/packages/contracts/investigation-portability.mjs +1 -1
  90. package/packages/contracts/risk-calibration.mjs +2 -2
  91. package/packages/contracts/workspace-portability.mjs +34 -6
  92. package/packages/investigation/evidence-coverage-ledger.mjs +21 -0
  93. package/packages/investigation/lookup-claim-readiness.mjs +2 -2
  94. package/packages/workspace/brand-profile-model.mjs +1 -1
  95. package/packages/workspace/website-snapshot-model.mjs +79 -6
  96. package/third-party-notices.txt +1 -1
  97. package/cli/artifact-structure.mjs +0 -2507
@@ -0,0 +1,300 @@
1
+ // Browser-safe contract for the registrar accreditation and compliance context
2
+ // attached to a Lookup response. The source catalogue and maintenance tooling
3
+ // remain server-side; this module validates only the small, attributed
4
+ // projection that crosses the HTTP and evidence-export boundaries.
5
+ import { isJsonObject, validBoundedString, } from "./lookup-contract-primitives.mjs";
6
+ import { ICANN_COMPLIANCE_SOURCE_URL, IANA_REGISTRAR_SOURCE_URL, registrarStandingOfficialSourceUrl, } from "./registrar-standing-catalogue-contract.mjs";
7
+ import { defineSchemaCompatibility } from "../packages/contracts/schema-compatibility.mjs";
8
+ export const REGISTRAR_STANDING_SCHEMA = 'whoisleuth.registrar-standing';
9
+ export const REGISTRAR_STANDING_VERSION = 1;
10
+ export const MAX_REGISTRAR_STANDING_BYTES = 32 * 1024;
11
+ export const MAX_REGISTRAR_COMPLIANCE_ACTIONS = 5;
12
+ export const MAX_REGISTRAR_STANDING_LIMITATIONS = 4;
13
+ export const MAX_REGISTRAR_STANDING_NEXT_ACTIONS = 4;
14
+ export const MAX_REGISTRAR_STANDING_TEXT_LENGTH = 360;
15
+ export const REGISTRAR_STANDING_COMPATIBILITY = defineSchemaCompatibility({
16
+ id: 'derived.registrar-standing',
17
+ kind: 'derived',
18
+ schema: REGISTRAR_STANDING_SCHEMA,
19
+ currentVersion: REGISTRAR_STANDING_VERSION,
20
+ supportedVersions: [REGISTRAR_STANDING_VERSION],
21
+ acceptsUnversionedLegacy: false,
22
+ futureVersionBehavior: 'reject',
23
+ migration: 'exact_current_only',
24
+ writeSemantics: 'read_only',
25
+ byteBudget: MAX_REGISTRAR_STANDING_BYTES,
26
+ owner: 'lib/registrar-standing-contract.mts',
27
+ note: 'Bounded zero-request projection keeping official registrar accreditation, compliance actions, source health and limitations separately attributed.',
28
+ });
29
+ export const REGISTRAR_ACCREDITATION_STATES = Object.freeze([
30
+ 'accredited',
31
+ 'terminated',
32
+ 'reserved',
33
+ 'unknown',
34
+ ]);
35
+ export const REGISTRAR_COMPLIANCE_ACTION_TYPES = Object.freeze([
36
+ 'breach',
37
+ 'suspension',
38
+ 'termination',
39
+ 'non_renewal',
40
+ ]);
41
+ export const REGISTRAR_COMPLIANCE_STATES = Object.freeze([
42
+ 'matching_actions',
43
+ 'reviewed_no_match',
44
+ 'stale',
45
+ 'unavailable',
46
+ 'not_applicable',
47
+ ]);
48
+ export const REGISTRAR_STANDING_ASSESSMENT_STATES = Object.freeze([
49
+ 'accredited',
50
+ 'notice_present',
51
+ 'terminated',
52
+ 'reserved',
53
+ 'unknown',
54
+ ]);
55
+ export const REGISTRAR_STANDING_SOURCE_HEALTH_STATES = Object.freeze([
56
+ 'current',
57
+ 'stale',
58
+ 'unavailable',
59
+ ]);
60
+ const ACCREDITATION_STATES = new Set(REGISTRAR_ACCREDITATION_STATES);
61
+ const COMPLIANCE_ACTION_TYPES = new Set(REGISTRAR_COMPLIANCE_ACTION_TYPES);
62
+ const COMPLIANCE_STATES = new Set(REGISTRAR_COMPLIANCE_STATES);
63
+ const SOURCE_HEALTH_STATES = new Set(REGISTRAR_STANDING_SOURCE_HEALTH_STATES);
64
+ const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/u;
65
+ const NOTICE_ID_RE = /^notice-[1-9]\d{0,7}$/u;
66
+ const IANA_ID_RE = /^[1-9]\d{0,7}$/u;
67
+ function normalizedRegistrarIanaId(value) {
68
+ if (typeof value === 'number' && Number.isSafeInteger(value))
69
+ value = String(value);
70
+ if (typeof value !== 'string')
71
+ return null;
72
+ const trimmed = value.trim();
73
+ return /^\d{1,8}$/u.test(trimmed) && Number(trimmed) > 0 ? String(Number(trimmed)) : null;
74
+ }
75
+ export function registrarIanaIds(...publications) {
76
+ const identifiers = new Set();
77
+ for (const publication of publications) {
78
+ if (!isJsonObject(publication))
79
+ continue;
80
+ const identifier = normalizedRegistrarIanaId(publication.registrarIanaId);
81
+ if (identifier)
82
+ identifiers.add(identifier);
83
+ }
84
+ return Object.freeze([...identifiers].sort((left, right) => Number(left) - Number(right)));
85
+ }
86
+ export function resolveRegistrarIanaId(...publications) {
87
+ const identifiers = registrarIanaIds(...publications);
88
+ return identifiers.length === 1 ? identifiers[0] ?? null : null;
89
+ }
90
+ function exactKeys(value, keys) {
91
+ const actual = Object.keys(value).sort();
92
+ const expected = [...keys].sort();
93
+ return actual.length === expected.length && actual.every((key, index) => key === expected[index]);
94
+ }
95
+ function validNullableTimestamp(value) {
96
+ return value === null || (typeof value === 'string'
97
+ && value.length <= 64
98
+ && Number.isFinite(Date.parse(value))
99
+ && new Date(value).toISOString() === value);
100
+ }
101
+ function validNullableDate(value) {
102
+ return value === null || (typeof value === 'string'
103
+ && ISO_DATE_RE.test(value)
104
+ && new Date(`${value}T00:00:00.000Z`).toISOString().startsWith(value));
105
+ }
106
+ function validNullableText(value, maximum = MAX_REGISTRAR_STANDING_TEXT_LENGTH) {
107
+ return value === null || validBoundedString(value, maximum);
108
+ }
109
+ function validStringList(value, maximum) {
110
+ return Array.isArray(value)
111
+ && value.length <= maximum
112
+ && value.every((item) => validBoundedString(item, MAX_REGISTRAR_STANDING_TEXT_LENGTH))
113
+ && new Set(value).size === value.length;
114
+ }
115
+ function validRegistrarComplianceAction(value) {
116
+ if (!isJsonObject(value) || !exactKeys(value, [
117
+ 'noticeId',
118
+ 'type',
119
+ 'issuedOn',
120
+ 'sourceUrl',
121
+ 'indexOutcome',
122
+ ]))
123
+ return false;
124
+ if (typeof value.noticeId !== 'string')
125
+ return false;
126
+ return NOTICE_ID_RE.test(value.noticeId)
127
+ && typeof value.type === 'string'
128
+ && COMPLIANCE_ACTION_TYPES.has(value.type)
129
+ && validNullableDate(value.issuedOn)
130
+ && value.issuedOn !== null
131
+ && registrarStandingOfficialSourceUrl(value.sourceUrl, 'icann_notice', value.noticeId) === value.sourceUrl
132
+ && validNullableText(value.indexOutcome);
133
+ }
134
+ function sourceTimestampMatchesHealth(value, health) {
135
+ return health === 'unavailable' ? value === null : value !== null;
136
+ }
137
+ export function deriveRegistrarStandingAssessment(accreditationState, actions, health) {
138
+ const termination = actions.find((action) => action.type === 'termination');
139
+ if (health !== 'current')
140
+ return Object.freeze({
141
+ state: 'unknown',
142
+ label: 'Current standing unavailable',
143
+ detail: health === 'stale'
144
+ ? 'The checked-in official-source catalogue is beyond its review age, so a current standing conclusion is unavailable.'
145
+ : 'The checked-in official-source catalogue could not be validated, so a current standing conclusion is unavailable.',
146
+ });
147
+ if (accreditationState === 'reserved')
148
+ return Object.freeze({
149
+ state: 'reserved',
150
+ label: 'Reserved IANA identifier',
151
+ detail: 'IANA records this identifier as reserved rather than as an accredited registrar.',
152
+ });
153
+ if (accreditationState === 'terminated')
154
+ return Object.freeze({
155
+ state: 'terminated',
156
+ label: 'Accreditation terminated',
157
+ detail: termination
158
+ ? 'IANA records the registrar as terminated and the current-year ICANN catalogue contains a termination notice.'
159
+ : 'IANA records the registrar as terminated; the reviewed current-year notice catalogue may not contain the historical action.',
160
+ });
161
+ if (actions.length)
162
+ return Object.freeze({
163
+ state: 'notice_present',
164
+ label: termination ? 'Official termination notice found' : 'Official compliance notice found',
165
+ detail: termination && accreditationState === 'accredited'
166
+ ? 'The reviewed IANA catalogue records the registrar as accredited while the current-year ICANN index contains a termination notice. Review the notice dates, scope and current outcome.'
167
+ : `The reviewed ${new Set(actions.map((action) => action.type)).size === 1 ? actions[0]?.type.replaceAll('_', '-') : 'compliance'} notice catalogue contains ${actions.length} matching current-year action${actions.length === 1 ? '' : 's'}.`,
168
+ });
169
+ if (accreditationState === 'accredited')
170
+ return Object.freeze({
171
+ state: 'accredited',
172
+ label: 'Accredited in reviewed catalogue',
173
+ detail: 'IANA records the registrar as accredited, with no matching action in the reviewed current-year ICANN notice catalogue.',
174
+ });
175
+ return Object.freeze({
176
+ state: 'unknown',
177
+ label: 'Current standing unavailable',
178
+ detail: 'The published registrar identifier could not be matched to the checked-in official-source catalogue.',
179
+ });
180
+ }
181
+ export function validRegistrarStanding(value) {
182
+ if (!isJsonObject(value) || !exactKeys(value, [
183
+ 'schema',
184
+ 'version',
185
+ 'ianaId',
186
+ 'accreditation',
187
+ 'compliance',
188
+ 'assessment',
189
+ 'limitations',
190
+ 'nextActions',
191
+ ]))
192
+ return false;
193
+ if (value.schema !== REGISTRAR_STANDING_SCHEMA
194
+ || value.version !== REGISTRAR_STANDING_VERSION
195
+ || (value.ianaId !== null && (typeof value.ianaId !== 'string' || !IANA_ID_RE.test(value.ianaId)))
196
+ || !isJsonObject(value.accreditation)
197
+ || !isJsonObject(value.compliance)
198
+ || !isJsonObject(value.assessment))
199
+ return false;
200
+ const accreditation = value.accreditation;
201
+ if (!exactKeys(accreditation, ['state', 'sourceUrl', 'observedAt', 'sourceHealth'])
202
+ || typeof accreditation.state !== 'string'
203
+ || !ACCREDITATION_STATES.has(accreditation.state)
204
+ || accreditation.sourceUrl !== IANA_REGISTRAR_SOURCE_URL
205
+ || !validNullableTimestamp(accreditation.observedAt)
206
+ || typeof accreditation.sourceHealth !== 'string'
207
+ || !SOURCE_HEALTH_STATES.has(accreditation.sourceHealth))
208
+ return false;
209
+ const compliance = value.compliance;
210
+ if (!exactKeys(compliance, [
211
+ 'state',
212
+ 'sourceUrl',
213
+ 'reviewedAt',
214
+ 'catalogueYear',
215
+ 'sourceHealth',
216
+ 'actions',
217
+ 'truncated',
218
+ ])
219
+ || typeof compliance.state !== 'string'
220
+ || !COMPLIANCE_STATES.has(compliance.state)
221
+ || compliance.sourceUrl !== ICANN_COMPLIANCE_SOURCE_URL
222
+ || !validNullableTimestamp(compliance.reviewedAt)
223
+ || (compliance.catalogueYear !== null
224
+ && (!Number.isInteger(compliance.catalogueYear) || Number(compliance.catalogueYear) < 2000 || Number(compliance.catalogueYear) > 9999))
225
+ || typeof compliance.sourceHealth !== 'string'
226
+ || !SOURCE_HEALTH_STATES.has(compliance.sourceHealth)
227
+ || !Array.isArray(compliance.actions)
228
+ || compliance.actions.length > MAX_REGISTRAR_COMPLIANCE_ACTIONS
229
+ || !compliance.actions.every(validRegistrarComplianceAction)
230
+ || typeof compliance.truncated !== 'boolean')
231
+ return false;
232
+ const actions = compliance.actions;
233
+ const noticeIds = actions.map((action) => action.noticeId);
234
+ const catalogueYear = compliance.catalogueYear;
235
+ const actionsInOrder = actions.every((action, index) => {
236
+ const previous = actions[index - 1];
237
+ return !previous
238
+ || previous.issuedOn > action.issuedOn
239
+ || (previous.issuedOn === action.issuedOn && previous.noticeId > action.noticeId);
240
+ });
241
+ if (!sourceTimestampMatchesHealth(accreditation.observedAt, String(accreditation.sourceHealth))
242
+ || !sourceTimestampMatchesHealth(compliance.reviewedAt, String(compliance.sourceHealth))
243
+ || (compliance.sourceHealth === 'unavailable') !== (catalogueYear === null)
244
+ || (accreditation.sourceHealth === 'unavailable' && accreditation.state !== 'unknown')
245
+ || new Set(noticeIds).size !== noticeIds.length
246
+ || !actionsInOrder
247
+ || actions.some((action) => catalogueYear === null || Number(action.issuedOn.slice(0, 4)) !== catalogueYear)
248
+ || actions.some((action) => compliance.reviewedAt !== null
249
+ && Date.parse(`${action.issuedOn}T00:00:00.000Z`) > Date.parse(compliance.reviewedAt))
250
+ || (compliance.truncated && actions.length !== MAX_REGISTRAR_COMPLIANCE_ACTIONS))
251
+ return false;
252
+ if (value.ianaId === null) {
253
+ if (accreditation.state !== 'unknown'
254
+ || compliance.state !== 'not_applicable'
255
+ || actions.length !== 0
256
+ || compliance.truncated)
257
+ return false;
258
+ }
259
+ else if (compliance.sourceHealth === 'unavailable') {
260
+ if (compliance.state !== 'unavailable' || actions.length !== 0 || compliance.truncated)
261
+ return false;
262
+ }
263
+ else if (actions.length > 0) {
264
+ if (compliance.state !== 'matching_actions')
265
+ return false;
266
+ }
267
+ else if (compliance.sourceHealth === 'stale') {
268
+ if (compliance.state !== 'stale')
269
+ return false;
270
+ }
271
+ else if (compliance.state !== 'reviewed_no_match')
272
+ return false;
273
+ const assessment = value.assessment;
274
+ if (!validStringList(value.limitations, MAX_REGISTRAR_STANDING_LIMITATIONS)
275
+ || !validStringList(value.nextActions, MAX_REGISTRAR_STANDING_NEXT_ACTIONS)
276
+ || (actions.length === 0) !== (value.nextActions.length === 0))
277
+ return false;
278
+ const combinedHealth = accreditation.sourceHealth === 'unavailable'
279
+ || compliance.sourceHealth === 'unavailable'
280
+ ? 'unavailable'
281
+ : accreditation.sourceHealth === 'stale' || compliance.sourceHealth === 'stale'
282
+ ? 'stale'
283
+ : 'current';
284
+ const expectedAssessment = deriveRegistrarStandingAssessment(accreditation.state, actions, combinedHealth);
285
+ return exactKeys(assessment, ['state', 'label', 'detail'])
286
+ && assessment.state === expectedAssessment.state
287
+ && assessment.label === expectedAssessment.label
288
+ && assessment.detail === expectedAssessment.detail;
289
+ }
290
+ export function registrarStandingObservedBy(value, boundary) {
291
+ if (!validRegistrarStanding(value)
292
+ || typeof boundary !== 'string'
293
+ || boundary.length > 64
294
+ || !Number.isFinite(Date.parse(boundary))
295
+ || new Date(boundary).toISOString() !== boundary)
296
+ return false;
297
+ const maximum = Date.parse(boundary);
298
+ return [value.accreditation.observedAt, value.compliance.reviewedAt]
299
+ .every((timestamp) => timestamp === null || Date.parse(timestamp) <= maximum);
300
+ }
@@ -0,0 +1,269 @@
1
+ // Offline registrar standing context keyed by the numeric IANA ID already
2
+ // published in registration evidence. This module never performs collection.
3
+ // Accreditation and compliance remain separate official-source observations;
4
+ // neither is treated as evidence that the looked-up domain is malicious.
5
+ import { createHash } from 'node:crypto';
6
+ import { REGISTRAR_STANDING_CATALOGUE } from "./generated/registrar-standing-catalogue.mjs";
7
+ import { ICANN_COMPLIANCE_SOURCE_URL, IANA_REGISTRAR_SOURCE_URL, REGISTRAR_STANDING_CATALOGUE_SCHEMA, REGISTRAR_STANDING_CATALOGUE_VERSION, REGISTRAR_STANDING_MAX_AGE_DAYS, REGISTRAR_STANDING_MAX_FUTURE_SKEW_MS, registrarStandingOfficialSourceUrl, } from "./registrar-standing-catalogue-contract.mjs";
8
+ import { deriveRegistrarStandingAssessment, MAX_REGISTRAR_COMPLIANCE_ACTIONS, REGISTRAR_STANDING_SCHEMA, REGISTRAR_STANDING_VERSION, } from "./registrar-standing-contract.mjs";
9
+ const STATUS_BY_CODE = Object.freeze({
10
+ A: 'accredited',
11
+ R: 'reserved',
12
+ T: 'terminated',
13
+ });
14
+ const SHA256_RE = /^[a-f0-9]{64}$/u;
15
+ const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/u;
16
+ const NOTICE_ID_RE = /^notice-[1-9]\d{0,7}$/u;
17
+ const CONTROL_CHAR_RE = /[\u0000-\u001f\u007f-\u009f]|\p{Default_Ignorable_Code_Point}/u;
18
+ function sha256(value) {
19
+ return createHash('sha256').update(value).digest('hex');
20
+ }
21
+ function validTimestamp(value) {
22
+ return typeof value === 'string'
23
+ && value.length <= 64
24
+ && Number.isFinite(Date.parse(value))
25
+ && new Date(value).toISOString() === value;
26
+ }
27
+ function validDate(value) {
28
+ return typeof value === 'string'
29
+ && ISO_DATE_RE.test(value)
30
+ && new Date(`${value}T00:00:00.000Z`).toISOString().startsWith(value);
31
+ }
32
+ function exactKeys(value, keys) {
33
+ const actual = Object.keys(value).sort();
34
+ const expected = [...keys].sort();
35
+ return actual.length === expected.length && actual.every((key, index) => key === expected[index]);
36
+ }
37
+ function ageDays(value, now) {
38
+ return Math.max(0, Math.floor((now.getTime() - Date.parse(value)) / 86_400_000));
39
+ }
40
+ function sourceHealth(value, now) {
41
+ return ageDays(value, now) <= REGISTRAR_STANDING_MAX_AGE_DAYS ? 'current' : 'stale';
42
+ }
43
+ function validatedCatalogue(value, now) {
44
+ if (!value || typeof value !== 'object' || Array.isArray(value))
45
+ return null;
46
+ const root = value;
47
+ if (!exactKeys(root, ['schema', 'version', 'generatedAt', 'iana', 'icann'])
48
+ || root.schema !== REGISTRAR_STANDING_CATALOGUE_SCHEMA
49
+ || root.version !== REGISTRAR_STANDING_CATALOGUE_VERSION
50
+ || !validTimestamp(root.generatedAt)
51
+ || !root.iana
52
+ || typeof root.iana !== 'object'
53
+ || Array.isArray(root.iana)
54
+ || !root.icann
55
+ || typeof root.icann !== 'object'
56
+ || Array.isArray(root.icann))
57
+ return null;
58
+ const iana = root.iana;
59
+ const icann = root.icann;
60
+ if (!exactKeys(iana, ['sourceUrl', 'observedAt', 'normalizedSha256', 'rows', 'counts', 'encodedStatuses'])
61
+ || !exactKeys(icann, ['sourceUrl', 'reviewedAt', 'catalogueYear', 'normalizedSha256', 'notices'])
62
+ || iana.sourceUrl !== IANA_REGISTRAR_SOURCE_URL
63
+ || !validTimestamp(iana.observedAt)
64
+ || typeof iana.normalizedSha256 !== 'string'
65
+ || !SHA256_RE.test(iana.normalizedSha256)
66
+ || !Number.isInteger(iana.rows)
67
+ || Number(iana.rows) < 1
68
+ || Number(iana.rows) > 10_000
69
+ || typeof iana.encodedStatuses !== 'string'
70
+ || iana.encodedStatuses.length > 100_000
71
+ || sha256(iana.encodedStatuses) !== iana.normalizedSha256
72
+ || !iana.counts
73
+ || typeof iana.counts !== 'object'
74
+ || Array.isArray(iana.counts)
75
+ || icann.sourceUrl !== ICANN_COMPLIANCE_SOURCE_URL
76
+ || !validTimestamp(icann.reviewedAt)
77
+ || icann.reviewedAt !== root.generatedAt
78
+ || Date.parse(String(iana.observedAt)) > Date.parse(String(root.generatedAt))
79
+ || Date.parse(String(root.generatedAt)) > now.getTime() + REGISTRAR_STANDING_MAX_FUTURE_SKEW_MS
80
+ || !Number.isInteger(icann.catalogueYear)
81
+ || Number(icann.catalogueYear) < 2000
82
+ || Number(icann.catalogueYear) > 9999
83
+ || typeof icann.normalizedSha256 !== 'string'
84
+ || !SHA256_RE.test(icann.normalizedSha256)
85
+ || !Array.isArray(icann.notices)
86
+ || icann.notices.length > 500
87
+ || sha256(JSON.stringify(icann.notices)) !== icann.normalizedSha256)
88
+ return null;
89
+ const counts = iana.counts;
90
+ if (!exactKeys(counts, ['Accredited', 'Reserved', 'Terminated'])
91
+ || Object.values(counts).some((count) => !Number.isInteger(count) || Number(count) < 0))
92
+ return null;
93
+ const statusEntries = iana.encodedStatuses.split(',');
94
+ if (statusEntries.length !== iana.rows)
95
+ return null;
96
+ let previousId = 0;
97
+ const statusCounts = { Accredited: 0, Reserved: 0, Terminated: 0 };
98
+ const registrarIds = new Set();
99
+ for (const entry of statusEntries) {
100
+ const match = /^([1-9]\d{0,7}):([ART])$/u.exec(entry);
101
+ if (!match || Number(match[1]) <= previousId)
102
+ return null;
103
+ previousId = Number(match[1]);
104
+ registrarIds.add(previousId);
105
+ if (match[2] === 'A')
106
+ statusCounts.Accredited += 1;
107
+ else if (match[2] === 'R')
108
+ statusCounts.Reserved += 1;
109
+ else
110
+ statusCounts.Terminated += 1;
111
+ }
112
+ if (counts.Accredited !== statusCounts.Accredited
113
+ || counts.Reserved !== statusCounts.Reserved
114
+ || counts.Terminated !== statusCounts.Terminated)
115
+ return null;
116
+ const noticeIds = new Set();
117
+ let previousNotice = null;
118
+ for (const candidate of icann.notices) {
119
+ if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate))
120
+ return null;
121
+ const notice = candidate;
122
+ if (!exactKeys(notice, ['noticeId', 'ianaId', 'type', 'issuedOn', 'sourceUrl', 'indexOutcome'])
123
+ || typeof notice.noticeId !== 'string'
124
+ || !NOTICE_ID_RE.test(notice.noticeId)
125
+ || noticeIds.has(notice.noticeId)
126
+ || !Number.isInteger(notice.ianaId)
127
+ || Number(notice.ianaId) < 1
128
+ || Number(notice.ianaId) > 99_999_999
129
+ || !registrarIds.has(Number(notice.ianaId))
130
+ || !['breach', 'suspension', 'termination', 'non_renewal'].includes(String(notice.type))
131
+ || !validDate(notice.issuedOn)
132
+ || Number(notice.issuedOn.slice(0, 4)) !== Number(icann.catalogueYear)
133
+ || Date.parse(`${String(notice.issuedOn)}T00:00:00.000Z`) > Date.parse(String(root.generatedAt))
134
+ || registrarStandingOfficialSourceUrl(notice.sourceUrl, 'icann_notice', notice.noticeId) !== notice.sourceUrl
135
+ || (notice.indexOutcome !== null
136
+ && (typeof notice.indexOutcome !== 'string'
137
+ || !notice.indexOutcome
138
+ || notice.indexOutcome.length > 240
139
+ || CONTROL_CHAR_RE.test(notice.indexOutcome))))
140
+ return null;
141
+ if (previousNotice
142
+ && (previousNotice.issuedOn < notice.issuedOn
143
+ || (previousNotice.issuedOn === notice.issuedOn && previousNotice.noticeId < notice.noticeId)))
144
+ return null;
145
+ noticeIds.add(notice.noticeId);
146
+ previousNotice = { issuedOn: notice.issuedOn, noticeId: notice.noticeId };
147
+ }
148
+ return value;
149
+ }
150
+ function accreditationMap(catalogue) {
151
+ const result = new Map();
152
+ for (const encoded of catalogue.iana.encodedStatuses.split(',')) {
153
+ const [id, code] = encoded.split(':');
154
+ const status = STATUS_BY_CODE[code];
155
+ if (id && status)
156
+ result.set(id, status);
157
+ }
158
+ return result;
159
+ }
160
+ function normalizedIanaId(value) {
161
+ if (typeof value === 'number' && Number.isSafeInteger(value))
162
+ value = String(value);
163
+ if (typeof value !== 'string')
164
+ return null;
165
+ const trimmed = value.trim();
166
+ return /^\d{1,8}$/u.test(trimmed) && Number(trimmed) > 0 ? String(Number(trimmed)) : null;
167
+ }
168
+ function projectedActions(catalogue, ianaId) {
169
+ const matching = catalogue.icann.notices.filter((notice) => String(notice.ianaId) === ianaId);
170
+ const actions = matching.slice(0, MAX_REGISTRAR_COMPLIANCE_ACTIONS).map((notice) => {
171
+ return Object.freeze({
172
+ noticeId: notice.noticeId,
173
+ type: notice.type,
174
+ issuedOn: notice.issuedOn,
175
+ sourceUrl: notice.sourceUrl,
176
+ indexOutcome: notice.indexOutcome,
177
+ });
178
+ });
179
+ return Object.freeze({ actions: Object.freeze(actions), truncated: matching.length > actions.length });
180
+ }
181
+ export function buildRegistrarStanding(options) {
182
+ const now = options.now ?? new Date();
183
+ if (!(now instanceof Date) || !Number.isFinite(now.getTime()))
184
+ throw new TypeError('Registrar standing time must be valid.');
185
+ const catalogue = validatedCatalogue(options.catalogue ?? REGISTRAR_STANDING_CATALOGUE, now);
186
+ const ianaId = normalizedIanaId(options.registrarIanaId);
187
+ const ianaHealth = catalogue ? sourceHealth(catalogue.iana.observedAt, now) : 'unavailable';
188
+ const icannHealth = catalogue ? sourceHealth(catalogue.icann.reviewedAt, now) : 'unavailable';
189
+ const accreditation = catalogue && ianaId
190
+ ? accreditationMap(catalogue).get(ianaId) ?? 'unknown'
191
+ : 'unknown';
192
+ const projected = catalogue && ianaId
193
+ ? projectedActions(catalogue, ianaId)
194
+ : Object.freeze({ actions: Object.freeze([]), truncated: false });
195
+ const combinedHealth = ianaHealth === 'unavailable' || icannHealth === 'unavailable'
196
+ ? 'unavailable'
197
+ : ianaHealth === 'stale' || icannHealth === 'stale'
198
+ ? 'stale'
199
+ : 'current';
200
+ const assessment = deriveRegistrarStandingAssessment(accreditation, projected.actions, combinedHealth);
201
+ const hasAction = projected.actions.length > 0;
202
+ const limitations = [
203
+ 'Registrar standing describes the provider, not whether this domain is malicious, safe, owned, or controlled.',
204
+ catalogue
205
+ ? `The reviewed ${catalogue.icann.catalogueYear} ICANN index is not a complete enforcement history and excludes third-party allegations.`
206
+ : 'The checked-in official-source catalogue could not be validated, so registrar standing is unavailable.',
207
+ ...(!ianaId ? ['No single numeric registrar IANA ID was available across the registration evidence, so no registrar match was attempted.'] : []),
208
+ ...(combinedHealth === 'stale' ? ['The catalogue is past its review-age threshold; current absence and accreditation conclusions require a refresh.'] : []),
209
+ ].slice(0, 4);
210
+ const nextActions = hasAction ? [
211
+ 'Open the official notice and verify its dates, scope, and current outcome before acting.',
212
+ 'Preserve current registration, transfer-status, delegation, and account-control evidence.',
213
+ 'Review authenticated registrar notifications and account activity through the provider control plane.',
214
+ 'Recheck the registrar IANA ID and domain transfer status during follow-up.',
215
+ ] : [];
216
+ return Object.freeze({
217
+ schema: REGISTRAR_STANDING_SCHEMA,
218
+ version: REGISTRAR_STANDING_VERSION,
219
+ ianaId,
220
+ accreditation: Object.freeze({
221
+ state: accreditation,
222
+ sourceUrl: IANA_REGISTRAR_SOURCE_URL,
223
+ observedAt: catalogue?.iana.observedAt ?? null,
224
+ sourceHealth: ianaHealth,
225
+ }),
226
+ compliance: Object.freeze({
227
+ state: !ianaId
228
+ ? 'not_applicable'
229
+ : !catalogue
230
+ ? 'unavailable'
231
+ : projected.actions.length
232
+ ? 'matching_actions'
233
+ : icannHealth === 'stale'
234
+ ? 'stale'
235
+ : 'reviewed_no_match',
236
+ sourceUrl: ICANN_COMPLIANCE_SOURCE_URL,
237
+ reviewedAt: catalogue?.icann.reviewedAt ?? null,
238
+ catalogueYear: catalogue?.icann.catalogueYear ?? null,
239
+ sourceHealth: icannHealth,
240
+ actions: projected.actions,
241
+ truncated: projected.truncated,
242
+ }),
243
+ assessment,
244
+ limitations: Object.freeze(limitations),
245
+ nextActions: Object.freeze(nextActions),
246
+ });
247
+ }
248
+ export function registrarStandingCatalogueHealth(now = new Date(), value = REGISTRAR_STANDING_CATALOGUE) {
249
+ if (!(now instanceof Date) || !Number.isFinite(now.getTime()))
250
+ throw new TypeError('Registrar standing health time must be valid.');
251
+ const catalogue = validatedCatalogue(value, now);
252
+ if (!catalogue)
253
+ return Object.freeze({
254
+ state: 'unavailable',
255
+ sourceObservedAt: null,
256
+ ageDays: null,
257
+ itemCount: null,
258
+ detail: 'The checked-in registrar standing catalogue failed its schema or digest checks.',
259
+ });
260
+ const age = Math.max(ageDays(catalogue.iana.observedAt, now), ageDays(catalogue.icann.reviewedAt, now));
261
+ return Object.freeze({
262
+ state: age <= REGISTRAR_STANDING_MAX_AGE_DAYS ? 'current' : 'stale',
263
+ sourceObservedAt: [catalogue.iana.observedAt, catalogue.icann.reviewedAt]
264
+ .sort((left, right) => Date.parse(left) - Date.parse(right))[0] ?? null,
265
+ ageDays: age,
266
+ itemCount: catalogue.iana.rows + catalogue.icann.notices.length,
267
+ detail: `${catalogue.iana.rows.toLocaleString('en')} IANA registrar IDs and ${catalogue.icann.notices.length} ${catalogue.icann.catalogueYear} ICANN notices passed local digest and shape checks.`,
268
+ });
269
+ }
@@ -6,6 +6,7 @@
6
6
  // contact data or a registration condition is absent.
7
7
  import { compareRegistrySources } from "./registry-comparison.mjs";
8
8
  import { inspectRdapCapabilities } from "./rdap-capabilities.mjs";
9
+ import { isRecord, recordOrEmpty } from "./json-record.mjs";
9
10
  export const REGISTRY_INSIGHTS_VERSION = 1;
10
11
  export const MAX_REGISTRY_INSIGHT_STATUSES = 40;
11
12
  export const MAX_REGISTRY_INSIGHT_ISSUES = 12;
@@ -14,14 +15,7 @@ const CONTROL_RE = /[\u0000-\u001f\u007f]/u;
14
15
  const PRIVACY_PROXY_RE = /(?:privacy|proxy|whoisguard|data protected|identity protect|private registration)/iu;
15
16
  const REDACTED_RE = /(?:redacted|masked|not published)/iu;
16
17
  const WITHHELD_RE = /(?:withheld|not disclosed|restricted disclosure)/iu;
17
- function record(value) {
18
- return value && typeof value === 'object' && !Array.isArray(value)
19
- ? value
20
- : {};
21
- }
22
- function isRecord(value) {
23
- return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
24
- }
18
+ const record = recordOrEmpty;
25
19
  function text(value, maximum = 320) {
26
20
  return typeof value === 'string' && !CONTROL_RE.test(value)
27
21
  ? value.trim().slice(0, maximum)