@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.
- package/cli/arguments.mjs +21 -5
- package/cli/artifact-validation/case-response.mjs +771 -0
- package/cli/artifact-validation/investigation-capsule.mjs +426 -0
- package/cli/artifact-validation/lookup-evidence.mjs +718 -0
- package/cli/artifact-validation/signed-review.mjs +598 -0
- package/cli/artifact-validation/structure-primitives.mjs +105 -0
- package/cli/artifact-verify.mjs +1 -1
- package/cli/assurance-command-runner.mjs +324 -0
- package/cli/command-reference.mjs +1175 -843
- package/cli/completion.mjs +56 -9
- package/cli/discriminated-command-handlers.mjs +11 -0
- package/cli/doctor.mjs +2 -2
- package/cli/evidence-command-runner.mjs +41 -31
- package/cli/export-evidence.mjs +1 -1
- package/cli/fail-policy.mjs +33 -8
- package/cli/formatters/terminal-command-formats.mjs +437 -0
- package/cli/formatters/terminal-lookup.mjs +758 -0
- package/cli/formatters/terminal-metadata.mjs +58 -0
- package/cli/formatters/terminal-shared.mjs +83 -0
- package/cli/formatters/terminal.mjs +5 -1232
- package/cli/history-command-runner.mjs +132 -0
- package/cli/inline-command-families.mjs +45 -0
- package/cli/inline-command-runner.mjs +55 -0
- package/cli/investigation-plan.mjs +9 -2
- package/cli/investigation-run.mjs +114 -9
- package/cli/lookup-brief.mjs +141 -23
- package/cli/lookup-browser-view.mjs +289 -0
- package/cli/lookup-browser.mjs +3 -288
- package/cli/mail-review.mjs +2 -6
- package/cli/offline-artifact-validation.mjs +19 -0
- package/cli/review-command-runner.mjs +269 -0
- package/cli/runner.mjs +15 -972
- package/cli/saved-lookup.mjs +19 -2
- package/cli/support-command-runner.mjs +229 -0
- package/cli/workflow-command-runner.mjs +129 -0
- package/docs/cli-reference.md +17 -10
- package/docs/cli.md +5 -3
- package/lib/availability.mjs +12 -9
- package/lib/bounded-contract-normalizers.mjs +2 -9
- package/lib/browser-library-profile.mjs +1 -2
- package/lib/client-behavior-profile.mjs +1 -2
- package/lib/credential-surface-profile.mjs +1 -1
- package/lib/domain-posture-analysis.mjs +10 -1
- package/lib/domain-posture.mjs +51 -23
- package/lib/evidence-export.mjs +46 -5
- package/lib/evidence-report-markdown.mjs +4 -0
- package/lib/evidence-report.mjs +50 -0
- package/lib/generated/cisa-kev-catalog.mjs +34 -4
- package/lib/generated/registrar-standing-catalogue.mjs +367 -0
- package/lib/json-record.mjs +12 -0
- package/lib/lookup-child-profile-contract.mjs +858 -0
- package/lib/lookup-contract-primitives.mjs +98 -0
- package/lib/lookup-diagnostics.mjs +96 -0
- package/lib/lookup-network-evidence-bounds.mjs +1 -0
- package/lib/lookup-response.mjs +367 -0
- package/lib/lookup.mjs +28 -443
- package/lib/page-role-profile.mjs +1 -3
- package/lib/project-metadata.mjs +1 -1
- package/lib/registrar-standing-catalogue-contract.mjs +70 -0
- package/lib/registrar-standing-contract.mjs +300 -0
- package/lib/registrar-standing.mjs +269 -0
- package/lib/registry-insights.mjs +2 -8
- package/lib/risk-scoring.mjs +26 -10
- package/lib/sslbl-certificates.generated.mjs +138 -136
- package/lib/structured-data-identity.mjs +1 -3
- package/lib/technology-evidence-role.mjs +51 -0
- package/lib/threat-intelligence-runtime.mjs +3 -3
- package/lib/threat-intelligence-types.mjs +13 -0
- package/lib/threatfox-intelligence.mjs +1 -3
- package/lib/tls-intelligence.mjs +1 -2
- package/lib/urlhaus-intelligence.mjs +1 -3
- package/lib/urlscan-intelligence.mjs +1 -3
- package/lib/website-security-posture.mjs +1 -2
- package/lib/website-technology.mjs +72 -23
- package/lib/whois-common-formats.mjs +276 -0
- package/lib/whois-dialects.mjs +534 -0
- package/lib/whois-parser-support.mjs +305 -0
- package/lib/whois-parser.mjs +135 -0
- package/lib/whois.mjs +6 -1187
- package/package.json +3 -3
- package/packages/cases/case-evidence-model.mjs +9 -0
- package/packages/cases/case-migration-model.mjs +17 -11
- package/packages/cases/case-response-model.mjs +13 -9
- package/packages/cases/case-response-packet.mjs +297 -112
- package/packages/comparison/page-similarity.mjs +23 -0
- package/packages/contracts/case-portability.mjs +227 -63
- package/packages/contracts/cli-lookup.mjs +4 -3
- package/packages/contracts/domain-control-manifest.mjs +1 -1
- package/packages/contracts/investigation-portability.mjs +1 -1
- package/packages/contracts/risk-calibration.mjs +2 -2
- package/packages/contracts/workspace-portability.mjs +34 -6
- package/packages/investigation/evidence-coverage-ledger.mjs +21 -0
- package/packages/investigation/lookup-claim-readiness.mjs +2 -2
- package/packages/workspace/brand-profile-model.mjs +1 -1
- package/packages/workspace/website-snapshot-model.mjs +79 -6
- package/third-party-notices.txt +1 -1
- 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
|
-
|
|
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)
|