@slicedearth/whoisleuth-cli 1.34.0 → 1.39.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. package/README.md +19 -2
  2. package/bin/whoisleuth.mjs +41 -24
  3. package/cli/arguments.mjs +326 -3
  4. package/cli/artifact-verify.mjs +60 -14
  5. package/cli/bulk.mjs +1 -2
  6. package/cli/compare.mjs +1 -1
  7. package/cli/completion.mjs +36 -2
  8. package/cli/doctor.mjs +11 -5
  9. package/cli/evidence-command-arguments.mjs +1 -1
  10. package/cli/evidence-command-runner.mjs +2 -2
  11. package/cli/evidence-signing.mjs +4 -4
  12. package/cli/export-evidence.mjs +9 -2
  13. package/cli/formatters/html.mjs +1 -1
  14. package/cli/formatters/json.mjs +13 -1
  15. package/cli/formatters/terminal.mjs +14 -4
  16. package/cli/investigation-plan.mjs +112 -0
  17. package/cli/local-mmdb-review.mjs +76 -0
  18. package/cli/lookalike-calibration.mjs +121 -0
  19. package/cli/lookup-command-runner.mjs +4 -1
  20. package/cli/lookup-plan.mjs +3 -3
  21. package/cli/lookup-reconcile.mjs +141 -0
  22. package/cli/lookup-timeline.mjs +1 -1
  23. package/cli/mail-review.mjs +48 -3
  24. package/cli/offline-evidence-review.mjs +159 -0
  25. package/cli/registry-doctor.mjs +125 -0
  26. package/cli/registry-support.mjs +1 -4
  27. package/cli/risk-calibration.mjs +117 -9
  28. package/cli/runner.mjs +342 -15
  29. package/cli/saved-lookup.mjs +2 -2
  30. package/cli/sharing-review.mjs +202 -0
  31. package/cli/source-reliability.mjs +1 -1
  32. package/docs/cli-reference.md +1110 -0
  33. package/docs/cli.md +224 -847
  34. package/frontend/src/lib/analysis/acquisition-decision-packet.js +1 -1
  35. package/frontend/src/lib/analysis/acquisition-due-diligence.js +6 -6
  36. package/frontend/src/lib/analysis/bulk-domain-comparison.js +12 -12
  37. package/frontend/src/lib/analysis/bulk-mail-exposure.js +1 -1
  38. package/frontend/src/lib/analysis/bulk-retry-plan.js +3 -10
  39. package/frontend/src/lib/analysis/bulk-session-model.js +7 -2
  40. package/frontend/src/lib/analysis/bulk-source-coverage.js +76 -0
  41. package/frontend/src/lib/analysis/case-evidence-model.js +26 -4
  42. package/frontend/src/lib/analysis/case-migration-model.js +6 -1
  43. package/frontend/src/lib/analysis/case-record-contracts.js +4 -2
  44. package/frontend/src/lib/analysis/case-record-core.js +5 -1
  45. package/frontend/src/lib/analysis/case-record-operations.js +6 -1
  46. package/frontend/src/lib/analysis/case-response-packet.js +7 -7
  47. package/frontend/src/lib/analysis/evidence-coverage-ledger.js +328 -0
  48. package/frontend/src/lib/analysis/http-summary.js +2 -1
  49. package/frontend/src/lib/analysis/investigation-capsule.js +105 -0
  50. package/frontend/src/lib/analysis/lookup-asset-graph.js +806 -0
  51. package/frontend/src/lib/analysis/lookup-decision-support.js +480 -0
  52. package/frontend/src/lib/analysis/lookup-investigation-brief.js +153 -0
  53. package/frontend/src/lib/analysis/lookup-presentation.js +74 -0
  54. package/frontend/src/lib/analysis/lookup-response.js +2 -0
  55. package/frontend/src/lib/analysis/lookup-source-refresh.js +304 -0
  56. package/frontend/src/lib/analysis/lookup-summary-model.js +398 -0
  57. package/frontend/src/lib/analysis/page-similarity.js +1 -1
  58. package/frontend/src/lib/analysis/relationship-evidence.js +3 -3
  59. package/frontend/src/lib/analysis/relationship-observation-model.js +4 -4
  60. package/frontend/src/lib/analysis/scoring.js +8 -88
  61. package/frontend/src/lib/analysis/shortlist-model.js +8 -4
  62. package/frontend/src/lib/analysis/utils.js +7 -40
  63. package/frontend/src/lib/analysis/visualization-bounds.js +26 -0
  64. package/frontend/src/lib/analysis/visualization-force-graph.js +352 -0
  65. package/frontend/src/lib/analysis/visualization-matrix.js +88 -0
  66. package/frontend/src/lib/analysis/visualization-models.js +370 -0
  67. package/frontend/src/lib/analysis/visualization-time-series.js +144 -0
  68. package/frontend/src/lib/analysis/website-snapshot-model.js +57 -3
  69. package/frontend/src/lib/analysis/workspace-archive-crypto.js +1 -1
  70. package/frontend/src/lib/analysis/workspace-archive.js +2 -2
  71. package/lib/analyst-taxonomy.mjs +40 -0
  72. package/lib/availability.mjs +30 -10
  73. package/lib/browser-library-profile.mjs +15 -2
  74. package/lib/bulk-limits.mjs +13 -0
  75. package/lib/certificate-extension-profile.mjs +71 -0
  76. package/lib/credential-surface-profile.mjs +1 -1
  77. package/lib/ct-response-bounds.mjs +9 -0
  78. package/lib/ct-search.mjs +66 -12
  79. package/lib/dns-delegation-health.mjs +56 -5
  80. package/lib/dnssec-evidence-validation.mjs +235 -0
  81. package/lib/domain-assurance.mjs +368 -0
  82. package/lib/domain-control-manifest.mjs +382 -0
  83. package/lib/domain-posture.mjs +11 -10
  84. package/lib/encrypted-dns-contract.mjs +81 -0
  85. package/lib/evidence-export.mjs +17 -3
  86. package/lib/evidence-report-markdown.mjs +1 -1
  87. package/lib/evidence-report.mjs +4 -1
  88. package/lib/favicon.mjs +3 -8
  89. package/lib/generated/cisa-kev-catalog.mjs +1668 -0
  90. package/lib/html-signals.mjs +4 -0
  91. package/lib/http-evidence-bounds.mjs +4 -0
  92. package/lib/http-intelligence.mjs +2 -1
  93. package/lib/ip-prefix.mjs +87 -0
  94. package/lib/local-geoip-evidence.mjs +89 -0
  95. package/lib/lookup-response-contract.mjs +346 -0
  96. package/lib/observed-network-context.mjs +1 -1
  97. package/lib/opportunity-scoring.mjs +107 -0
  98. package/lib/outbound-identity.mjs +9 -0
  99. package/lib/outbound-request-bounds.mjs +9 -0
  100. package/lib/page-fingerprints.mjs +1 -1
  101. package/lib/perceptual-hash-comparison.mjs +34 -0
  102. package/lib/perceptual-hash.mjs +3 -31
  103. package/lib/project-metadata.mjs +8 -0
  104. package/lib/provider-policy-admission.mjs +49 -0
  105. package/lib/rdap-capabilities.mjs +6 -6
  106. package/lib/rdap-search-workbench.mjs +247 -0
  107. package/lib/rdap.mjs +7 -2
  108. package/lib/registry-access.mjs +1 -1
  109. package/lib/registry-capabilities.mjs +31 -1
  110. package/lib/registry-capability-catalogue.mjs +15 -14
  111. package/lib/registry-insights.mjs +6 -6
  112. package/lib/response-policy.mjs +81 -6
  113. package/lib/risk-scoring.mjs +206 -83
  114. package/lib/rpki-evidence.mjs +105 -0
  115. package/lib/safe-fetch.mjs +2 -1
  116. package/lib/scoring-evidence-quality.mjs +107 -0
  117. package/lib/security-txt.mjs +3 -3
  118. package/lib/semantic-version.mjs +54 -0
  119. package/lib/static-html-analysis.mjs +27 -0
  120. package/lib/threat-intelligence-manifests.mjs +1 -1
  121. package/lib/threatfox-intelligence.mjs +11 -6
  122. package/lib/tls-intelligence.mjs +13 -3
  123. package/lib/tlsa-evidence.mjs +194 -0
  124. package/lib/urlhaus-intelligence.mjs +11 -6
  125. package/lib/urlscan-intelligence.mjs +10 -5
  126. package/lib/website-security-posture.mjs +16 -9
  127. package/lib/website-technology.mjs +1 -1
  128. package/lib/whois-chain.mjs +14 -152
  129. package/lib/whois.mjs +2 -2
  130. package/package.json +6 -2
  131. package/third-party-notices.txt +911 -19
@@ -0,0 +1,235 @@
1
+ import { Buffer } from 'node:buffer';
2
+ import { createHash } from 'node:crypto';
3
+ import { domainToASCII } from 'node:url';
4
+ const DNSSEC_EVIDENCE_SCHEMA = 'whoisleuth.dnssec-evidence-validation';
5
+ const DNSSEC_EVIDENCE_VERSION = 1;
6
+ const MAX_DNSSEC_RECORDS = 50;
7
+ const MAX_PUBLIC_KEY_BYTES = 8192;
8
+ function record(value) {
9
+ return value && typeof value === 'object' && !Array.isArray(value)
10
+ ? value
11
+ : null;
12
+ }
13
+ function integer(value, minimum, maximum) {
14
+ return Number.isInteger(value) && Number(value) >= minimum && Number(value) <= maximum
15
+ ? Number(value)
16
+ : null;
17
+ }
18
+ function ownerName(value) {
19
+ if (typeof value !== 'string' || value.length > 253)
20
+ return null;
21
+ const ascii = domainToASCII(value.trim().replace(/\.$/u, '').toLowerCase());
22
+ if (!ascii || ascii.length > 253)
23
+ return null;
24
+ const labels = ascii.split('.');
25
+ if (labels.some((label) => !label || label.length > 63))
26
+ return null;
27
+ return ascii;
28
+ }
29
+ function hexadecimal(value, length) {
30
+ if (typeof value !== 'string')
31
+ return null;
32
+ const normalized = value.replace(/\s+/gu, '').toUpperCase();
33
+ if (!normalized || !/^[0-9A-F]+$/u.test(normalized) || normalized.length % 2 !== 0)
34
+ return null;
35
+ if (length !== undefined && normalized.length !== length)
36
+ return null;
37
+ return normalized;
38
+ }
39
+ function normalizeDs(value) {
40
+ const source = record(value);
41
+ if (!source)
42
+ return null;
43
+ const keyTag = integer(source.keyTag, 0, 65_535);
44
+ const algorithm = integer(source.algorithm, 0, 255);
45
+ const digestType = integer(source.digestType, 0, 255);
46
+ const expectedLength = digestType === 1 ? 40 : digestType === 2 ? 64 : digestType === 4 ? 96 : undefined;
47
+ const digest = hexadecimal(source.digest, expectedLength);
48
+ return keyTag === null || algorithm === null || digestType === null || !digest
49
+ ? null
50
+ : Object.freeze({ keyTag, algorithm, digestType, digest });
51
+ }
52
+ function normalizeDnskey(value) {
53
+ const source = record(value);
54
+ if (!source)
55
+ return null;
56
+ const flags = integer(source.flags, 0, 65_535);
57
+ const protocol = integer(source.protocol, 0, 255);
58
+ const algorithm = integer(source.algorithm, 0, 255);
59
+ if (flags === null || protocol === null || algorithm === null || typeof source.publicKeyBase64 !== 'string')
60
+ return null;
61
+ if (!/^[A-Za-z0-9+/]+={0,2}$/u.test(source.publicKeyBase64) || source.publicKeyBase64.length > MAX_PUBLIC_KEY_BYTES * 2)
62
+ return null;
63
+ const decoded = Buffer.from(source.publicKeyBase64, 'base64');
64
+ if (!decoded.length || decoded.length > MAX_PUBLIC_KEY_BYTES || decoded.toString('base64') !== source.publicKeyBase64)
65
+ return null;
66
+ return Object.freeze({ flags, protocol, algorithm, publicKeyBase64: source.publicKeyBase64 });
67
+ }
68
+ function dnskeyRdata(value) {
69
+ const key = Buffer.from(value.publicKeyBase64, 'base64');
70
+ const rdata = Buffer.allocUnsafe(4 + key.length);
71
+ rdata.writeUInt16BE(value.flags, 0);
72
+ rdata[2] = value.protocol;
73
+ rdata[3] = value.algorithm;
74
+ key.copy(rdata, 4);
75
+ return rdata;
76
+ }
77
+ function dnskeyTag(rdata) {
78
+ let accumulator = 0;
79
+ for (let index = 0; index < rdata.length; index += 1) {
80
+ accumulator += index & 1 ? rdata[index] : rdata[index] << 8;
81
+ }
82
+ accumulator += (accumulator >> 16) & 0xffff;
83
+ return accumulator & 0xffff;
84
+ }
85
+ function ownerWire(value) {
86
+ const output = [];
87
+ for (const label of value.split('.')) {
88
+ const bytes = Buffer.from(label, 'ascii');
89
+ output.push(bytes.length, ...bytes);
90
+ }
91
+ output.push(0);
92
+ return Buffer.from(output);
93
+ }
94
+ function dsDigest(owner, dnskey, digestType) {
95
+ const hash = digestType === 1 ? 'sha1' : digestType === 2 ? 'sha256' : digestType === 4 ? 'sha384' : null;
96
+ if (!hash)
97
+ return null;
98
+ return createHash(hash).update(ownerWire(owner)).update(dnskeyRdata(dnskey)).digest('hex').toUpperCase();
99
+ }
100
+ function normalizeRrsig(value) {
101
+ const source = record(value);
102
+ if (!source || typeof source.inception !== 'string' || typeof source.expiration !== 'string')
103
+ return null;
104
+ const inception = Date.parse(source.inception);
105
+ const expiration = Date.parse(source.expiration);
106
+ if (!Number.isFinite(inception) || !Number.isFinite(expiration) || inception > expiration)
107
+ return null;
108
+ return Object.freeze({ inception, expiration });
109
+ }
110
+ function signatureTimeState(value, observedAt) {
111
+ if (!value.length || typeof observedAt !== 'string')
112
+ return 'unavailable';
113
+ const observed = Date.parse(observedAt);
114
+ if (!Number.isFinite(observed))
115
+ return 'unavailable';
116
+ let withinWindow = 0;
117
+ for (const item of value) {
118
+ if (observed >= item.inception && observed <= item.expiration)
119
+ withinWindow += 1;
120
+ }
121
+ if (withinWindow === value.length)
122
+ return 'within_window';
123
+ return withinWindow === 0 ? 'outside_window' : 'mixed';
124
+ }
125
+ function validateDnssecEvidence(input) {
126
+ const normalizedOwner = ownerName(input.ownerName);
127
+ const rawDs = Array.isArray(input.dsRecords) ? input.dsRecords : [];
128
+ const rawDnskeys = Array.isArray(input.dnskeyRecords) ? input.dnskeyRecords : [];
129
+ const rawRrsigs = Array.isArray(input.rrSigRecords) ? input.rrSigRecords : [];
130
+ const dsRecords = rawDs.slice(0, MAX_DNSSEC_RECORDS).map(normalizeDs).filter((item) => item !== null);
131
+ const dnskeys = rawDnskeys.slice(0, MAX_DNSSEC_RECORDS).map(normalizeDnskey).filter((item) => item !== null);
132
+ const rrsigs = rawRrsigs.slice(0, MAX_DNSSEC_RECORDS).map(normalizeRrsig).filter((item) => item !== null);
133
+ const rejectedCount = Math.min(rawDs.length, MAX_DNSSEC_RECORDS) - dsRecords.length
134
+ + Math.min(rawDnskeys.length, MAX_DNSSEC_RECORDS) - dnskeys.length
135
+ + Math.min(rawRrsigs.length, MAX_DNSSEC_RECORDS) - rrsigs.length;
136
+ const truncated = rawDs.length > MAX_DNSSEC_RECORDS
137
+ || rawDnskeys.length > MAX_DNSSEC_RECORDS
138
+ || rawRrsigs.length > MAX_DNSSEC_RECORDS;
139
+ const signatureState = signatureTimeState(rrsigs, input.observedAt);
140
+ const findings = [];
141
+ const limitations = [
142
+ 'This offline review verifies supplied DS and DNSKEY relationships only. It does not retrieve or authenticate a complete chain to a configured root trust anchor.',
143
+ 'Missing records may reflect incomplete input and are not treated as proof that DNSSEC is absent.',
144
+ ];
145
+ if (!normalizedOwner) {
146
+ return Object.freeze({
147
+ schema: DNSSEC_EVIDENCE_SCHEMA,
148
+ version: DNSSEC_EVIDENCE_VERSION,
149
+ state: 'invalid',
150
+ ownerName: null,
151
+ dsRecordCount: dsRecords.length,
152
+ dnskeyRecordCount: dnskeys.length,
153
+ rrsigRecordCount: rrsigs.length,
154
+ matchedDsCount: 0,
155
+ unsupportedDigestCount: 0,
156
+ rejectedCount,
157
+ truncated,
158
+ signatureTimeState: signatureState,
159
+ findings: Object.freeze(['The supplied owner name was invalid.']),
160
+ limitations: Object.freeze(limitations),
161
+ });
162
+ }
163
+ if (input.delegationSigned === false && dsRecords.length === 0) {
164
+ findings.push('The supplied delegation state was unsigned and no DS records were supplied.');
165
+ return Object.freeze({
166
+ schema: DNSSEC_EVIDENCE_SCHEMA,
167
+ version: DNSSEC_EVIDENCE_VERSION,
168
+ state: 'unsigned',
169
+ ownerName: normalizedOwner,
170
+ dsRecordCount: 0,
171
+ dnskeyRecordCount: dnskeys.length,
172
+ rrsigRecordCount: rrsigs.length,
173
+ matchedDsCount: 0,
174
+ unsupportedDigestCount: 0,
175
+ rejectedCount,
176
+ truncated,
177
+ signatureTimeState: signatureState,
178
+ findings: Object.freeze(findings),
179
+ limitations: Object.freeze(limitations),
180
+ });
181
+ }
182
+ const unsupportedDigestCount = dsRecords.filter((item) => ![1, 2, 4].includes(item.digestType)).length;
183
+ let matchedDsCount = 0;
184
+ for (const ds of dsRecords) {
185
+ const matched = dnskeys.some((dnskey) => {
186
+ if (dnskey.algorithm !== ds.algorithm)
187
+ return false;
188
+ const rdata = dnskeyRdata(dnskey);
189
+ return dnskeyTag(rdata) === ds.keyTag && dsDigest(normalizedOwner, dnskey, ds.digestType) === ds.digest;
190
+ });
191
+ if (matched)
192
+ matchedDsCount += 1;
193
+ }
194
+ const definiteConflict = input.delegationSigned === false && dsRecords.length > 0
195
+ || input.delegationSigned === true && dsRecords.length === 0 && !truncated
196
+ || signatureState === 'outside_window'
197
+ || (dsRecords.length > 0 && dnskeys.length > 0 && matchedDsCount === 0 && unsupportedDigestCount < dsRecords.length);
198
+ if (definiteConflict)
199
+ findings.push('The supplied delegation, signature-time, DS, or DNSKEY evidence conflicts.');
200
+ if (matchedDsCount > 0)
201
+ findings.push(`${matchedDsCount} supplied DS record(s) matched a supplied DNSKEY.`);
202
+ if (unsupportedDigestCount > 0)
203
+ findings.push(`${unsupportedDigestCount} DS digest type(s) were retained as unsupported.`);
204
+ if (signatureState === 'mixed')
205
+ findings.push('The supplied RRSIG validity windows were mixed at the observation time.');
206
+ if (rejectedCount > 0)
207
+ findings.push(`${rejectedCount} malformed record(s) were rejected.`);
208
+ if (truncated)
209
+ findings.push('The supplied record set exceeded the review bound and was truncated.');
210
+ const completeLocalRelationship = dsRecords.length > 0
211
+ && dnskeys.length > 0
212
+ && matchedDsCount > 0
213
+ && !definiteConflict
214
+ && rejectedCount === 0
215
+ && !truncated
216
+ && unsupportedDigestCount === 0
217
+ && signatureState !== 'mixed';
218
+ return Object.freeze({
219
+ schema: DNSSEC_EVIDENCE_SCHEMA,
220
+ version: DNSSEC_EVIDENCE_VERSION,
221
+ state: definiteConflict ? 'conflict' : completeLocalRelationship ? 'consistent' : 'partial',
222
+ ownerName: normalizedOwner,
223
+ dsRecordCount: dsRecords.length,
224
+ dnskeyRecordCount: dnskeys.length,
225
+ rrsigRecordCount: rrsigs.length,
226
+ matchedDsCount,
227
+ unsupportedDigestCount,
228
+ rejectedCount,
229
+ truncated,
230
+ signatureTimeState: signatureState,
231
+ findings: Object.freeze(findings),
232
+ limitations: Object.freeze(limitations),
233
+ });
234
+ }
235
+ export { DNSSEC_EVIDENCE_SCHEMA, DNSSEC_EVIDENCE_VERSION, MAX_DNSSEC_RECORDS, dnskeyTag, validateDnssecEvidence, };
@@ -0,0 +1,368 @@
1
+ import { domainToASCII } from 'node:url';
2
+ import { exactKeys } from "./bounded-contract-normalizers.mjs";
3
+ const DOMAIN_ASSURANCE_INPUT_SCHEMA = 'whoisleuth.domain-assurance.input';
4
+ const DOMAIN_ASSURANCE_SCHEMA = 'whoisleuth.domain-assurance';
5
+ const DOMAIN_ASSURANCE_VERSION = 2;
6
+ const DOMAIN_ASSURANCE_SUPPORTED_INPUT_VERSIONS = Object.freeze([1, 2]);
7
+ const MAX_ASSURANCE_INPUT_BYTES = 2 * 1024 * 1024;
8
+ const ROOT_KEYS = Object.freeze({
9
+ 'planned-change': new Set(['schema', 'version', 'kind', 'domain', 'change']),
10
+ 'recovery-dependencies': new Set(['schema', 'version', 'kind', 'assets']),
11
+ retirement: new Set(['schema', 'version', 'kind', 'domain', 'checks']),
12
+ });
13
+ const RETIREMENT_ROOT_KEYS_V2 = new Set(['schema', 'version', 'kind', 'domain', 'checks', 'customChecks']);
14
+ const CHANGE_KEYS = new Set(['reference', 'startsAt', 'endsAt', 'milestones', 'rollbackCriteria', 'postChangeChecks']);
15
+ const MILESTONE_KEYS = new Set(['id', 'label', 'expectedBy', 'evidenceSource', 'state', 'observedAt', 'evidenceReference']);
16
+ const ROLLBACK_KEYS = new Set(['id', 'condition', 'owner', 'state']);
17
+ const POST_CHANGE_CHECK_KEYS = new Set(['id', 'label', 'expectedState', 'evidenceSource', 'state', 'evidenceReference']);
18
+ const RECOVERY_ASSET_KEYS = new Set(['domain', 'dependencies', 'readiness']);
19
+ const RECOVERY_DEPENDENCY_KEYS = new Set(['registrar', 'dns', 'mail', 'certificate', 'recovery']);
20
+ const RECOVERY_READINESS_KEYS = new Set(['registrarRecoveryTested', 'dnsRecoveryTested', 'recoveryMfaProtected']);
21
+ const CUSTOM_RETIREMENT_CHECK_KEYS = new Set(['id', 'label', 'expected', 'value']);
22
+ const CUSTOM_RETIREMENT_CHECK_ID_RE = /^[a-z][a-z0-9-]{0,63}$/u;
23
+ function record(value) {
24
+ if (!value || typeof value !== 'object' || Array.isArray(value))
25
+ throw new Error('Domain assurance input must be one JSON object.');
26
+ return value;
27
+ }
28
+ function text(value, field, maximum = 160) {
29
+ if (typeof value !== 'string')
30
+ throw new Error(`${field} must be text.`);
31
+ const normalized = value.replace(/[\u0000-\u001f\u007f]+/gu, ' ').replace(/\s+/gu, ' ').trim();
32
+ if (!normalized || normalized.length > maximum)
33
+ throw new Error(`${field} must contain from 1 to ${maximum} characters.`);
34
+ return normalized;
35
+ }
36
+ function optionalText(value, maximum = 160) {
37
+ if (value === null || value === undefined || value === '')
38
+ return null;
39
+ if (typeof value !== 'string')
40
+ throw new Error('Optional domain assurance labels must be text.');
41
+ const normalized = value.replace(/[\u0000-\u001f\u007f]+/gu, ' ').replace(/\s+/gu, ' ').trim();
42
+ if (!normalized || normalized.length > maximum)
43
+ throw new Error(`Optional domain assurance labels are limited to ${maximum} characters.`);
44
+ return normalized;
45
+ }
46
+ function timestamp(value, field) {
47
+ const normalized = text(value, field, 64);
48
+ if (!Number.isFinite(Date.parse(normalized)))
49
+ throw new Error(`${field} must be an ISO-compatible timestamp.`);
50
+ return new Date(normalized).toISOString();
51
+ }
52
+ function optionalTimestamp(value, field) {
53
+ if (value === null || value === undefined || value === '')
54
+ return null;
55
+ return timestamp(value, field);
56
+ }
57
+ function domain(value, field = 'domain') {
58
+ const normalized = text(value, field, 253).toLowerCase().replace(/\.$/u, '');
59
+ let ascii;
60
+ try {
61
+ ascii = domainToASCII(normalized);
62
+ }
63
+ catch {
64
+ throw new Error(`${field} must be a valid domain name.`);
65
+ }
66
+ if (!ascii || !ascii.includes('.') || ascii.length > 253 || !ascii.split('.').every((labelValue) => (labelValue.length >= 1 && labelValue.length <= 63 && /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/u.test(labelValue))))
67
+ throw new Error(`${field} must be a valid domain name.`);
68
+ return ascii;
69
+ }
70
+ function boundedArray(value, field, minimum, maximum) {
71
+ if (!Array.isArray(value) || value.length < minimum || value.length > maximum) {
72
+ throw new Error(`${field} must contain from ${minimum} to ${maximum} items.`);
73
+ }
74
+ return value;
75
+ }
76
+ function nullableBoolean(value, field) {
77
+ if (value === null || value === undefined)
78
+ return null;
79
+ if (typeof value !== 'boolean')
80
+ throw new Error(`${field} must be true, false, or null.`);
81
+ return value;
82
+ }
83
+ function enumValue(value, field, allowed) {
84
+ if (typeof value !== 'string' || !allowed.includes(value)) {
85
+ throw new Error(`${field} must be one of: ${allowed.join(', ')}.`);
86
+ }
87
+ return value;
88
+ }
89
+ function reviewState(reasons, negative) {
90
+ if (negative)
91
+ return 'needs_review';
92
+ return reasons.length ? 'incomplete' : 'ready';
93
+ }
94
+ function buildPlannedChange(input) {
95
+ const target = domain(input.domain);
96
+ const change = record(input.change);
97
+ exactKeys(change, CHANGE_KEYS, 'change');
98
+ const startsAt = timestamp(change.startsAt, 'change.startsAt');
99
+ const endsAt = timestamp(change.endsAt, 'change.endsAt');
100
+ if (Date.parse(endsAt) <= Date.parse(startsAt))
101
+ throw new Error('change.endsAt must be later than change.startsAt.');
102
+ const ids = new Set();
103
+ const uniqueId = (value, field) => {
104
+ const id = text(value, field, 64);
105
+ if (ids.has(id))
106
+ throw new Error(`Domain assurance item id "${id}" is duplicated.`);
107
+ ids.add(id);
108
+ return id;
109
+ };
110
+ const milestones = boundedArray(change.milestones, 'change.milestones', 1, 24).map((raw, index) => {
111
+ const item = record(raw);
112
+ exactKeys(item, MILESTONE_KEYS, `change.milestones[${index}]`);
113
+ const state = enumValue(item.state, `change.milestones[${index}].state`, ['missed', 'not_checked', 'observed', 'planned']);
114
+ const observedAt = optionalTimestamp(item.observedAt, `change.milestones[${index}].observedAt`);
115
+ const evidenceReference = optionalText(item.evidenceReference, 300);
116
+ if ((state === 'observed' || state === 'missed') && (!observedAt || !evidenceReference)) {
117
+ throw new Error('Observed or missed change milestones require observedAt and evidenceReference.');
118
+ }
119
+ if ((state === 'planned' || state === 'not_checked') && (observedAt || evidenceReference)) {
120
+ throw new Error('Planned or unchecked change milestones cannot contain observation evidence.');
121
+ }
122
+ return {
123
+ id: uniqueId(item.id, `change.milestones[${index}].id`),
124
+ label: text(item.label, `change.milestones[${index}].label`, 180),
125
+ expectedBy: timestamp(item.expectedBy, `change.milestones[${index}].expectedBy`),
126
+ evidenceSource: text(item.evidenceSource, `change.milestones[${index}].evidenceSource`, 120),
127
+ state,
128
+ observedAt,
129
+ evidenceReference,
130
+ };
131
+ });
132
+ const rollbackCriteria = boundedArray(change.rollbackCriteria, 'change.rollbackCriteria', 1, 16).map((raw, index) => {
133
+ const item = record(raw);
134
+ exactKeys(item, ROLLBACK_KEYS, `change.rollbackCriteria[${index}]`);
135
+ return {
136
+ id: uniqueId(item.id, `change.rollbackCriteria[${index}].id`),
137
+ condition: text(item.condition, `change.rollbackCriteria[${index}].condition`, 240),
138
+ owner: text(item.owner, `change.rollbackCriteria[${index}].owner`, 120),
139
+ state: enumValue(item.state, `change.rollbackCriteria[${index}].state`, ['met', 'not_checked', 'not_met']),
140
+ };
141
+ });
142
+ const postChangeChecks = boundedArray(change.postChangeChecks, 'change.postChangeChecks', 1, 24).map((raw, index) => {
143
+ const item = record(raw);
144
+ exactKeys(item, POST_CHANGE_CHECK_KEYS, `change.postChangeChecks[${index}]`);
145
+ const state = enumValue(item.state, `change.postChangeChecks[${index}].state`, ['matched', 'not_checked', 'unexpected']);
146
+ const evidenceReference = optionalText(item.evidenceReference, 300);
147
+ if (state !== 'not_checked' && !evidenceReference)
148
+ throw new Error('Completed post-change checks require evidenceReference.');
149
+ if (state === 'not_checked' && evidenceReference)
150
+ throw new Error('Unchecked post-change checks cannot contain an evidenceReference.');
151
+ return {
152
+ id: uniqueId(item.id, `change.postChangeChecks[${index}].id`),
153
+ label: text(item.label, `change.postChangeChecks[${index}].label`, 180),
154
+ expectedState: text(item.expectedState, `change.postChangeChecks[${index}].expectedState`, 240),
155
+ evidenceSource: text(item.evidenceSource, `change.postChangeChecks[${index}].evidenceSource`, 120),
156
+ state,
157
+ evidenceReference,
158
+ };
159
+ });
160
+ const reasons = [
161
+ ...(milestones.some((item) => item.state === 'missed') ? ['One or more change milestones were missed.'] : []),
162
+ ...(milestones.some((item) => item.state === 'planned' || item.state === 'not_checked') ? ['One or more planned milestones have not been observed.'] : []),
163
+ ...(rollbackCriteria.some((item) => item.state === 'met') ? ['One or more rollback criteria were met.'] : []),
164
+ ...(rollbackCriteria.some((item) => item.state === 'not_checked') ? ['One or more rollback criteria have not been evaluated.'] : []),
165
+ ...(postChangeChecks.some((item) => item.state === 'unexpected') ? ['One or more post-change checks produced an unexpected result.'] : []),
166
+ ...(postChangeChecks.some((item) => item.state === 'not_checked') ? ['One or more post-change checks remain open.'] : []),
167
+ ];
168
+ const negative = milestones.some((item) => item.state === 'missed')
169
+ || rollbackCriteria.some((item) => item.state === 'met')
170
+ || postChangeChecks.some((item) => item.state === 'unexpected');
171
+ return {
172
+ kind: 'planned-change',
173
+ domain: target,
174
+ reference: text(change.reference, 'change.reference', 120),
175
+ window: { startsAt, endsAt },
176
+ milestones,
177
+ rollbackCriteria,
178
+ postChangeChecks,
179
+ review: { state: reviewState(reasons, negative), reasons },
180
+ };
181
+ }
182
+ function buildRecoveryDependencies(input) {
183
+ const assets = boundedArray(input.assets, 'assets', 1, 100).map((raw, index) => {
184
+ const item = record(raw);
185
+ exactKeys(item, RECOVERY_ASSET_KEYS, `assets[${index}]`);
186
+ const dependencies = record(item.dependencies);
187
+ const readiness = record(item.readiness);
188
+ exactKeys(dependencies, RECOVERY_DEPENDENCY_KEYS, `assets[${index}].dependencies`);
189
+ exactKeys(readiness, RECOVERY_READINESS_KEYS, `assets[${index}].readiness`);
190
+ return {
191
+ domain: domain(item.domain, `assets[${index}].domain`),
192
+ dependencies: {
193
+ registrar: optionalText(dependencies.registrar, 120),
194
+ dns: optionalText(dependencies.dns, 120),
195
+ mail: optionalText(dependencies.mail, 120),
196
+ certificate: optionalText(dependencies.certificate, 120),
197
+ recovery: optionalText(dependencies.recovery, 120),
198
+ },
199
+ readiness: {
200
+ registrarRecoveryTested: nullableBoolean(readiness.registrarRecoveryTested, `assets[${index}].readiness.registrarRecoveryTested`),
201
+ dnsRecoveryTested: nullableBoolean(readiness.dnsRecoveryTested, `assets[${index}].readiness.dnsRecoveryTested`),
202
+ recoveryMfaProtected: nullableBoolean(readiness.recoveryMfaProtected, `assets[${index}].readiness.recoveryMfaProtected`),
203
+ },
204
+ };
205
+ });
206
+ if (new Set(assets.map((asset) => asset.domain)).size !== assets.length)
207
+ throw new Error('Recovery dependency domains must be unique.');
208
+ const dependencyTypes = ['registrar', 'dns', 'mail', 'certificate', 'recovery'];
209
+ const concentrations = dependencyTypes.flatMap((dependencyType) => {
210
+ const byProvider = new Map();
211
+ for (const asset of assets) {
212
+ const provider = asset.dependencies[dependencyType];
213
+ if (!provider)
214
+ continue;
215
+ const key = provider.toLowerCase();
216
+ const group = byProvider.get(key) ?? { label: provider, domains: [] };
217
+ group.domains.push(asset.domain);
218
+ byProvider.set(key, group);
219
+ }
220
+ return [...byProvider.values()].filter(({ domains }) => domains.length > 1).map(({ label: provider, domains }) => ({
221
+ dependencyType,
222
+ provider,
223
+ domains: domains.sort(),
224
+ share: Number((domains.length / assets.length).toFixed(4)),
225
+ }));
226
+ }).sort((left, right) => right.share - left.share || left.dependencyType.localeCompare(right.dependencyType));
227
+ const unknownDependencies = assets.reduce((total, asset) => (total + dependencyTypes.filter((dependencyType) => asset.dependencies[dependencyType] === null).length), 0);
228
+ const failedReadinessChecks = assets.reduce((total, asset) => (total + Object.values(asset.readiness).filter((value) => value === false).length), 0);
229
+ const reasons = [
230
+ ...(failedReadinessChecks ? [`${failedReadinessChecks} recovery readiness check${failedReadinessChecks === 1 ? ' is' : 's are'} recorded as not ready.`] : []),
231
+ ...(unknownDependencies ? [`${unknownDependencies} dependency fields are not recorded.`] : []),
232
+ ...(assets.some((asset) => Object.values(asset.readiness).some((value) => value === null)) ? ['One or more recovery checks have not been recorded.'] : []),
233
+ ];
234
+ const negative = assets.some((asset) => Object.values(asset.readiness).some((value) => value === false));
235
+ return {
236
+ kind: 'recovery-dependencies',
237
+ assets,
238
+ concentrations,
239
+ unknownDependencies,
240
+ review: { state: reviewState(reasons, negative), reasons },
241
+ };
242
+ }
243
+ const RETIREMENT_CHECKS = Object.freeze([
244
+ ['autoRenewDisabled', 'Auto-renew is intentionally configured', true],
245
+ ['registrarLockMaintained', 'Registrar lock remains enabled', true],
246
+ ['websiteRetired', 'Web service is retired or intentionally redirected', true],
247
+ ['mailRetired', 'Mail service is retired', true],
248
+ ['nullMxPublished', 'Null MX is published when the domain must not receive mail', true],
249
+ ['spfRestrictive', 'SPF policy is intentionally restrictive', true],
250
+ ['dmarcReject', 'DMARC enforcement is intentionally strict', true],
251
+ ['certificatesReviewed', 'Active and historical certificate exposure was reviewed', true],
252
+ ['delegatedDnsMonitored', 'Delegated DNS remains monitored during retirement', true],
253
+ ['reRegistrationPrevented', 'Registration continuity or defensive renewal is planned', true],
254
+ ]);
255
+ function buildRetirement(input, inputVersion) {
256
+ const checksInput = record(input.checks);
257
+ exactKeys(checksInput, new Set(RETIREMENT_CHECKS.map(([id]) => id)), 'checks');
258
+ const fixedChecks = RETIREMENT_CHECKS.map(([id, checkLabel, expected]) => {
259
+ const value = nullableBoolean(checksInput[id], `checks.${id}`);
260
+ return {
261
+ id,
262
+ label: checkLabel,
263
+ state: value === null ? 'not_checked' : value === expected ? 'confirmed' : 'not_confirmed',
264
+ expected,
265
+ };
266
+ });
267
+ const fixedIds = new Set(RETIREMENT_CHECKS.map(([id]) => id));
268
+ const customIds = new Set();
269
+ const customChecks = inputVersion === 2 && input.customChecks !== undefined
270
+ ? boundedArray(input.customChecks, 'customChecks', 0, 20).map((raw, index) => {
271
+ const item = record(raw);
272
+ exactKeys(item, CUSTOM_RETIREMENT_CHECK_KEYS, `customChecks[${index}]`);
273
+ const id = text(item.id, `customChecks[${index}].id`, 64);
274
+ if (!CUSTOM_RETIREMENT_CHECK_ID_RE.test(id)) {
275
+ throw new Error(`customChecks[${index}].id must use lowercase letters, digits, and hyphens and begin with a letter.`);
276
+ }
277
+ if (fixedIds.has(id) || customIds.has(id))
278
+ throw new Error(`Retirement check id "${id}" is duplicated.`);
279
+ customIds.add(id);
280
+ if (typeof item.expected !== 'boolean')
281
+ throw new Error(`customChecks[${index}].expected must be true or false.`);
282
+ const value = nullableBoolean(item.value, `customChecks[${index}].value`);
283
+ return {
284
+ id,
285
+ label: text(item.label, `customChecks[${index}].label`, 180),
286
+ state: value === null ? 'not_checked' : value === item.expected ? 'confirmed' : 'not_confirmed',
287
+ expected: item.expected,
288
+ };
289
+ })
290
+ : [];
291
+ const checks = [...fixedChecks, ...customChecks];
292
+ const reasons = checks.flatMap((check) => check.state === 'not_confirmed'
293
+ ? [`The expected retirement state for "${check.label}" is not confirmed.`]
294
+ : check.state === 'not_checked'
295
+ ? [`${check.label} has not been checked.`]
296
+ : []);
297
+ const negative = checks.some((check) => check.state === 'not_confirmed');
298
+ return {
299
+ kind: 'retirement',
300
+ domain: domain(input.domain),
301
+ checks,
302
+ review: { state: reviewState(reasons, negative), reasons: reasons.slice(0, 20) },
303
+ };
304
+ }
305
+ function buildDomainAssurance(inputRaw, generatedAt = new Date().toISOString()) {
306
+ const input = record(inputRaw);
307
+ if (input.schema !== DOMAIN_ASSURANCE_INPUT_SCHEMA
308
+ || !DOMAIN_ASSURANCE_SUPPORTED_INPUT_VERSIONS.includes(input.version)) {
309
+ throw new Error(`Domain assurance input must use ${DOMAIN_ASSURANCE_INPUT_SCHEMA} version 1 or ${DOMAIN_ASSURANCE_VERSION}.`);
310
+ }
311
+ const inputVersion = input.version;
312
+ const kind = enumValue(input.kind, 'kind', ['planned-change', 'recovery-dependencies', 'retirement']);
313
+ exactKeys(input, kind === 'retirement' && inputVersion === 2 ? RETIREMENT_ROOT_KEYS_V2 : ROOT_KEYS[kind], 'Domain assurance input');
314
+ const result = kind === 'planned-change'
315
+ ? buildPlannedChange(input)
316
+ : kind === 'recovery-dependencies'
317
+ ? buildRecoveryDependencies(input)
318
+ : buildRetirement(input, inputVersion);
319
+ return {
320
+ schema: DOMAIN_ASSURANCE_SCHEMA,
321
+ version: DOMAIN_ASSURANCE_VERSION,
322
+ generatedAt: timestamp(generatedAt, 'generatedAt'),
323
+ result,
324
+ limitations: [
325
+ 'This review uses only analyst-supplied planning and observation metadata and makes no network request or configuration change.',
326
+ 'Provider labels, readiness checks, and evidence references are analyst-authored assertions; they are not independently verified by this report.',
327
+ 'A ready result means the supplied checklist is complete under this schema. It is not a guarantee of control, recovery, successful change, or safe retirement.',
328
+ 'Do not include passwords, recovery codes, private keys, personal contact details, or other secrets in an assurance input.',
329
+ ],
330
+ };
331
+ }
332
+ function formatDomainAssurance(document) {
333
+ const { result } = document;
334
+ const output = [
335
+ 'Domain assurance review',
336
+ `Workflow ${result.kind.replaceAll('-', ' ')}`,
337
+ `State ${result.review.state.replaceAll('_', ' ')}`,
338
+ ];
339
+ if ('domain' in result)
340
+ output.push(`Domain ${result.domain}`);
341
+ if (result.kind === 'recovery-dependencies') {
342
+ output.push(`Assets ${result.assets.length}`);
343
+ output.push(`Concentrations ${result.concentrations.length}`);
344
+ output.push(`Unknown fields ${result.unknownDependencies}`);
345
+ for (const concentration of result.concentrations) {
346
+ output.push(` ${concentration.dependencyType}: ${concentration.provider} (${concentration.domains.length}/${result.assets.length})`);
347
+ }
348
+ }
349
+ else if (result.kind === 'planned-change') {
350
+ output.push(`Change reference ${result.reference}`);
351
+ output.push(`Milestones ${result.milestones.length}`);
352
+ output.push(`Rollback criteria ${result.rollbackCriteria.length}`);
353
+ output.push(`Post-change checks ${result.postChangeChecks.length}`);
354
+ }
355
+ else {
356
+ output.push(`Confirmed ${result.checks.filter((check) => check.state === 'confirmed').length}/${result.checks.length}`);
357
+ }
358
+ if (result.review.reasons.length) {
359
+ output.push('', 'Review reasons:');
360
+ for (const reason of result.review.reasons)
361
+ output.push(` - ${reason}`);
362
+ }
363
+ output.push('', 'Limitations:');
364
+ for (const limitation of document.limitations)
365
+ output.push(` - ${limitation}`);
366
+ return `${output.join('\n')}\n`;
367
+ }
368
+ export { DOMAIN_ASSURANCE_INPUT_SCHEMA, DOMAIN_ASSURANCE_SCHEMA, DOMAIN_ASSURANCE_SUPPORTED_INPUT_VERSIONS, DOMAIN_ASSURANCE_VERSION, MAX_ASSURANCE_INPUT_BYTES, buildDomainAssurance, formatDomainAssurance, };