@slicedearth/whoisleuth-cli 1.36.1 → 1.43.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 (139) hide show
  1. package/README.md +32 -2
  2. package/bin/whoisleuth.mjs +52 -24
  3. package/cli/archive-inspect.mjs +37 -2
  4. package/cli/arguments.mjs +493 -157
  5. package/cli/artifact-verify.mjs +91 -15
  6. package/cli/bulk-checkpoint.mjs +7 -11
  7. package/cli/bulk-command-runner.mjs +19 -2
  8. package/cli/case-pack.mjs +136 -0
  9. package/cli/ci-report.mjs +73 -0
  10. package/cli/collection-preflight.mjs +59 -0
  11. package/cli/command-reference.mjs +395 -0
  12. package/cli/compare.mjs +1 -1
  13. package/cli/completion.mjs +49 -11
  14. package/cli/config-profile.mjs +180 -0
  15. package/cli/ct-event-intake.mjs +159 -0
  16. package/cli/discovery-scan-command-runner.mjs +15 -1
  17. package/cli/doctor.mjs +11 -5
  18. package/cli/domain-control-monitor.mjs +104 -0
  19. package/cli/domain-control-observations.mjs +235 -0
  20. package/cli/evidence-command-arguments.mjs +12 -1
  21. package/cli/evidence-command-runner.mjs +3 -2
  22. package/cli/evidence-signing.mjs +4 -4
  23. package/cli/external-observation-mapping.mjs +162 -0
  24. package/cli/fail-policy.mjs +64 -0
  25. package/cli/formatters/html.mjs +2 -2
  26. package/cli/formatters/json.mjs +13 -1
  27. package/cli/formatters/terminal.mjs +7 -3
  28. package/cli/investigation-manifest.mjs +113 -0
  29. package/cli/investigation-plan.mjs +2 -2
  30. package/cli/investigation-run.mjs +112 -0
  31. package/cli/local-mmdb-review.mjs +76 -0
  32. package/cli/lookalike-calibration.mjs +121 -0
  33. package/cli/lookup-brief.mjs +116 -0
  34. package/cli/lookup-command-runner.mjs +12 -2
  35. package/cli/lookup-diff.mjs +17 -19
  36. package/cli/lookup-plan.mjs +3 -3
  37. package/cli/lookup-reconcile.mjs +141 -0
  38. package/cli/lookup-timeline.mjs +1 -1
  39. package/cli/mail-review.mjs +1 -1
  40. package/cli/network-command-runner.mjs +4 -1
  41. package/cli/offline-evidence-review.mjs +125 -10
  42. package/cli/open-asset-model-bridge.mjs +169 -0
  43. package/cli/registry-cohort.mjs +121 -0
  44. package/cli/registry-doctor.mjs +220 -0
  45. package/cli/registry-fixture-scaffold.mjs +73 -0
  46. package/cli/risk-calibration.mjs +117 -9
  47. package/cli/runner.mjs +561 -280
  48. package/cli/saved-lookup.mjs +2 -2
  49. package/cli/sharing-review.mjs +202 -0
  50. package/cli/source-reliability.mjs +1 -1
  51. package/docs/cli-reference.md +1336 -0
  52. package/docs/cli.md +259 -940
  53. package/frontend/src/lib/analysis/acquisition-decision-packet.js +1 -1
  54. package/frontend/src/lib/analysis/acquisition-due-diligence.js +7 -7
  55. package/frontend/src/lib/analysis/brand-profile-model.js +46 -4
  56. package/frontend/src/lib/analysis/bulk-domain-comparison.js +12 -12
  57. package/frontend/src/lib/analysis/bulk-mail-exposure.js +1 -1
  58. package/frontend/src/lib/analysis/bulk-session-model.js +7 -2
  59. package/frontend/src/lib/analysis/case-evidence-model.js +26 -4
  60. package/frontend/src/lib/analysis/case-migration-model.js +6 -1
  61. package/frontend/src/lib/analysis/case-record-contracts.js +4 -2
  62. package/frontend/src/lib/analysis/case-record-core.js +5 -1
  63. package/frontend/src/lib/analysis/case-record-operations.js +6 -1
  64. package/frontend/src/lib/analysis/case-report.js +573 -0
  65. package/frontend/src/lib/analysis/case-response-model.js +30 -1
  66. package/frontend/src/lib/analysis/case-response-packet.js +7 -7
  67. package/frontend/src/lib/analysis/detection-rule-model.js +36 -0
  68. package/frontend/src/lib/analysis/evidence-coverage-ledger.js +328 -0
  69. package/frontend/src/lib/analysis/external-findings-import.js +320 -0
  70. package/frontend/src/lib/analysis/investigation-capsule.js +105 -0
  71. package/frontend/src/lib/analysis/lookup-asset-graph.js +806 -0
  72. package/frontend/src/lib/analysis/lookup-decision-support.js +487 -0
  73. package/frontend/src/lib/analysis/lookup-investigation-brief.js +153 -0
  74. package/frontend/src/lib/analysis/lookup-presentation.js +64 -0
  75. package/frontend/src/lib/analysis/lookup-response.js +2 -0
  76. package/frontend/src/lib/analysis/lookup-source-refresh.js +304 -0
  77. package/frontend/src/lib/analysis/lookup-summary-model.js +398 -0
  78. package/frontend/src/lib/analysis/page-similarity.js +1 -1
  79. package/frontend/src/lib/analysis/relationship-evidence.js +3 -3
  80. package/frontend/src/lib/analysis/relationship-observation-model.js +4 -4
  81. package/frontend/src/lib/analysis/scoring.js +9 -89
  82. package/frontend/src/lib/analysis/shortlist-model.js +8 -4
  83. package/frontend/src/lib/analysis/visualization-bounds.js +26 -0
  84. package/frontend/src/lib/analysis/visualization-force-graph.js +352 -0
  85. package/frontend/src/lib/analysis/visualization-matrix.js +88 -0
  86. package/frontend/src/lib/analysis/visualization-models.js +369 -0
  87. package/frontend/src/lib/analysis/visualization-time-series.js +144 -0
  88. package/frontend/src/lib/analysis/workspace-archive-crypto.js +1 -1
  89. package/frontend/src/lib/analysis/workspace-archive.js +4 -2
  90. package/lib/abusech-auth.mjs +1 -1
  91. package/lib/analyst-taxonomy.mjs +40 -0
  92. package/lib/availability.mjs +2 -2
  93. package/lib/bounded-contract-normalizers.mjs +33 -0
  94. package/lib/browser-library-profile.mjs +15 -2
  95. package/lib/certificate-extension-profile.mjs +71 -0
  96. package/lib/credential-surface-profile.mjs +1 -1
  97. package/lib/dns-convergence-review.mjs +183 -0
  98. package/lib/dns-delegation-health.mjs +196 -3
  99. package/lib/domain-assurance.mjs +368 -0
  100. package/lib/domain-change-packet.mjs +133 -0
  101. package/lib/domain-change-review.mjs +307 -0
  102. package/lib/domain-control-flight-recorder.mjs +268 -0
  103. package/lib/domain-control-manifest.mjs +50 -7
  104. package/lib/domain-portfolio-review.mjs +151 -0
  105. package/lib/domain-posture.mjs +9 -9
  106. package/lib/encrypted-dns-contract.mjs +1 -1
  107. package/lib/evidence-export.mjs +1 -1
  108. package/lib/evidence-report-markdown.mjs +1 -1
  109. package/lib/evidence-report.mjs +1 -1
  110. package/lib/generated/cisa-kev-catalog.mjs +1668 -0
  111. package/lib/html-signals.mjs +5 -11
  112. package/lib/ip-prefix.mjs +34 -1
  113. package/lib/local-geoip-evidence.mjs +2 -2
  114. package/lib/lookup-response-contract.mjs +346 -0
  115. package/lib/nameserver-preflight-review.mjs +159 -0
  116. package/lib/observed-network-context.mjs +1 -1
  117. package/lib/opportunity-scoring.mjs +107 -0
  118. package/lib/page-fingerprints.mjs +1 -1
  119. package/lib/page-language-signals.mjs +124 -0
  120. package/lib/rdap-capabilities.mjs +6 -6
  121. package/lib/rdap-search-workbench.mjs +96 -6
  122. package/lib/registry-access.mjs +1 -1
  123. package/lib/registry-capability-catalogue.mjs +4 -4
  124. package/lib/registry-insights.mjs +6 -6
  125. package/lib/risk-scoring.mjs +247 -83
  126. package/lib/scoring-evidence-quality.mjs +107 -0
  127. package/lib/security-txt.mjs +3 -3
  128. package/lib/static-html-analysis.mjs +29 -1
  129. package/lib/threat-intelligence-manifests.mjs +1 -1
  130. package/lib/threatfox-intelligence.mjs +1 -1
  131. package/lib/tls-intelligence.mjs +13 -3
  132. package/lib/trust-store-comparison.mjs +146 -0
  133. package/lib/urlhaus-intelligence.mjs +1 -1
  134. package/lib/website-security-posture.mjs +5 -5
  135. package/lib/website-technology.mjs +25 -11
  136. package/lib/whois-chain.mjs +1 -1
  137. package/lib/zone-intent-review.mjs +534 -0
  138. package/package.json +5 -1
  139. package/third-party-notices.txt +911 -19
@@ -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, };
@@ -0,0 +1,133 @@
1
+ import { exactKeys, requireBoundedString, requireDomainName, requireIsoTimestamp, requireRecord, } from "./bounded-contract-normalizers.mjs";
2
+ import { DOMAIN_ASSURANCE_INPUT_SCHEMA, buildDomainAssurance, } from "./domain-assurance.mjs";
3
+ import { DOMAIN_CHANGE_INPUT_SCHEMA, reviewDomainChange, } from "./domain-change-review.mjs";
4
+ import { sha256ArtifactDigest } from "../frontend/src/lib/analysis/artifact-integrity.js";
5
+ export const DOMAIN_CHANGE_PACKET_INPUT_SCHEMA = 'whoisleuth.domain-change-packet.input';
6
+ export const DOMAIN_CHANGE_PACKET_SCHEMA = 'whoisleuth.domain-change-packet';
7
+ export const DOMAIN_CHANGE_PACKET_VERSION = 1;
8
+ export const MAX_DOMAIN_CHANGE_PACKET_INPUT_BYTES = 6 * 1024 * 1024;
9
+ const ROOT_KEYS = new Set([
10
+ 'schema',
11
+ 'version',
12
+ 'domain',
13
+ 'reference',
14
+ 'preChange',
15
+ 'postChange',
16
+ 'assurance',
17
+ ]);
18
+ function record(value, label) {
19
+ return requireRecord(value, label);
20
+ }
21
+ function domainFromInput(value, label) {
22
+ const input = record(value, label);
23
+ if (input.schema !== DOMAIN_CHANGE_INPUT_SCHEMA) {
24
+ throw new TypeError(`${label} must use ${DOMAIN_CHANGE_INPUT_SCHEMA}.`);
25
+ }
26
+ return requireDomainName(input.domain, `${label}.domain`);
27
+ }
28
+ function assuranceDomain(value) {
29
+ const input = record(value, 'assurance');
30
+ if (input.schema !== DOMAIN_ASSURANCE_INPUT_SCHEMA || input.kind !== 'planned-change') {
31
+ throw new TypeError('assurance must contain a planned-change domain assurance input.');
32
+ }
33
+ return requireDomainName(input.domain, 'assurance.domain');
34
+ }
35
+ function changeSummary(before, after) {
36
+ const rows = (review) => new Map(review.authoritativeRecordMatrix.map((row) => [`${row.owner}\u0000${row.type}`, row]));
37
+ const beforeRows = rows(before);
38
+ const afterRows = rows(after);
39
+ const keys = [...new Set([...beforeRows.keys(), ...afterRows.keys()])].sort();
40
+ return Object.freeze(keys.slice(0, 500).flatMap((key) => {
41
+ const left = beforeRows.get(key);
42
+ const right = afterRows.get(key);
43
+ const leftValues = left?.observations.flatMap((item) => item.values) ?? [];
44
+ const rightValues = right?.observations.flatMap((item) => item.values) ?? [];
45
+ const beforeValues = [...new Set(leftValues)].sort();
46
+ const afterValues = [...new Set(rightValues)].sort();
47
+ if (JSON.stringify(beforeValues) === JSON.stringify(afterValues))
48
+ return [];
49
+ const [owner, type] = key.split('\u0000');
50
+ return [Object.freeze({
51
+ owner: owner ?? '',
52
+ type: type ?? '',
53
+ beforeValues: Object.freeze(beforeValues),
54
+ afterValues: Object.freeze(afterValues),
55
+ })];
56
+ }));
57
+ }
58
+ export async function buildDomainChangePacket(inputRaw, generatedAtValue = new Date().toISOString()) {
59
+ const input = record(inputRaw, 'Domain change packet input');
60
+ if (input.schema !== DOMAIN_CHANGE_PACKET_INPUT_SCHEMA || input.version !== 1) {
61
+ throw new TypeError(`Domain change packet input must use ${DOMAIN_CHANGE_PACKET_INPUT_SCHEMA} version 1.`);
62
+ }
63
+ exactKeys(input, ROOT_KEYS, 'Domain change packet input');
64
+ const domain = requireDomainName(input.domain, 'domain');
65
+ const domains = [
66
+ domainFromInput(input.preChange, 'preChange'),
67
+ domainFromInput(input.postChange, 'postChange'),
68
+ assuranceDomain(input.assurance),
69
+ ];
70
+ if (domains.some((candidate) => candidate !== domain)) {
71
+ throw new TypeError('The packet, change reviews, and assurance plan must refer to the same domain.');
72
+ }
73
+ const generatedAt = requireIsoTimestamp(generatedAtValue, 'generatedAt');
74
+ const preChange = reviewDomainChange(input.preChange, generatedAt);
75
+ const postChange = reviewDomainChange(input.postChange, generatedAt);
76
+ const assurance = buildDomainAssurance(input.assurance, generatedAt);
77
+ const assuranceReasons = assurance.result.review.reasons;
78
+ const gateReasons = Object.freeze([
79
+ ...preChange.gate.reasons.map((reason) => `Pre-change evidence: ${reason}`),
80
+ ...postChange.gate.reasons.map((reason) => `Post-change evidence: ${reason}`),
81
+ ...assuranceReasons.map((reason) => `Change plan: ${reason}`),
82
+ ].slice(0, 100));
83
+ const unsigned = Object.freeze({
84
+ schema: DOMAIN_CHANGE_PACKET_SCHEMA,
85
+ version: DOMAIN_CHANGE_PACKET_VERSION,
86
+ generatedAt,
87
+ domain,
88
+ reference: requireBoundedString(input.reference, 'reference', 200),
89
+ state: gateReasons.length ? 'review' : 'ready',
90
+ gate: Object.freeze({ pass: gateReasons.length === 0, reasons: gateReasons }),
91
+ summary: Object.freeze({
92
+ changedAuthoritativeRecordSets: changeSummary(preChange, postChange),
93
+ preChangeState: preChange.state,
94
+ postChangeState: postChange.state,
95
+ assuranceState: assurance.result.review.state,
96
+ }),
97
+ evidence: Object.freeze({ preChange, postChange, assurance }),
98
+ limitations: Object.freeze([
99
+ 'This packet is assembled only from analyst-supplied observations and planning metadata and makes no network request or configuration change.',
100
+ 'A ready result means the supplied bounded checks passed; it does not prove control, propagation completion, successful recovery, or absence of unobserved dependencies.',
101
+ 'The integrity digest detects later changes to this packet but does not authenticate its author. Use a separately managed signing key when signer authentication is required.',
102
+ ]),
103
+ });
104
+ return Object.freeze({
105
+ ...unsigned,
106
+ integrity: Object.freeze({
107
+ algorithm: 'SHA-256',
108
+ digestSha256: await sha256ArtifactDigest(unsigned),
109
+ }),
110
+ });
111
+ }
112
+ export function formatDomainChangePacket(packet) {
113
+ const lines = [
114
+ 'Domain change assurance packet',
115
+ `Domain ${packet.domain}`,
116
+ `Reference ${packet.reference}`,
117
+ `State ${packet.state}`,
118
+ `Changed record sets ${packet.summary.changedAuthoritativeRecordSets.length}`,
119
+ `Pre-change review ${packet.summary.preChangeState}`,
120
+ `Post-change review ${packet.summary.postChangeState}`,
121
+ `Change plan ${packet.summary.assuranceState}`,
122
+ `Integrity ${packet.integrity.digestSha256}`,
123
+ ];
124
+ if (packet.gate.reasons.length) {
125
+ lines.push('', 'Review reasons:');
126
+ for (const reason of packet.gate.reasons)
127
+ lines.push(` - ${reason}`);
128
+ }
129
+ lines.push('', 'Limitations:');
130
+ for (const limitation of packet.limitations)
131
+ lines.push(` - ${limitation}`);
132
+ return `${lines.join('\n')}\n`;
133
+ }