@slicedearth/whoisleuth-cli 1.36.1 → 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 (97) hide show
  1. package/README.md +17 -2
  2. package/bin/whoisleuth.mjs +41 -24
  3. package/cli/arguments.mjs +236 -5
  4. package/cli/artifact-verify.mjs +56 -14
  5. package/cli/compare.mjs +1 -1
  6. package/cli/completion.mjs +18 -3
  7. package/cli/doctor.mjs +11 -5
  8. package/cli/evidence-command-arguments.mjs +1 -1
  9. package/cli/evidence-command-runner.mjs +2 -2
  10. package/cli/evidence-signing.mjs +4 -4
  11. package/cli/formatters/html.mjs +1 -1
  12. package/cli/formatters/json.mjs +13 -1
  13. package/cli/formatters/terminal.mjs +7 -3
  14. package/cli/investigation-plan.mjs +2 -2
  15. package/cli/local-mmdb-review.mjs +76 -0
  16. package/cli/lookalike-calibration.mjs +121 -0
  17. package/cli/lookup-command-runner.mjs +4 -1
  18. package/cli/lookup-plan.mjs +3 -3
  19. package/cli/lookup-reconcile.mjs +141 -0
  20. package/cli/lookup-timeline.mjs +1 -1
  21. package/cli/mail-review.mjs +1 -1
  22. package/cli/offline-evidence-review.mjs +20 -2
  23. package/cli/registry-doctor.mjs +125 -0
  24. package/cli/risk-calibration.mjs +117 -9
  25. package/cli/runner.mjs +236 -17
  26. package/cli/saved-lookup.mjs +2 -2
  27. package/cli/sharing-review.mjs +202 -0
  28. package/cli/source-reliability.mjs +1 -1
  29. package/docs/cli-reference.md +1110 -0
  30. package/docs/cli.md +224 -940
  31. package/frontend/src/lib/analysis/acquisition-decision-packet.js +1 -1
  32. package/frontend/src/lib/analysis/acquisition-due-diligence.js +6 -6
  33. package/frontend/src/lib/analysis/bulk-domain-comparison.js +12 -12
  34. package/frontend/src/lib/analysis/bulk-mail-exposure.js +1 -1
  35. package/frontend/src/lib/analysis/bulk-session-model.js +7 -2
  36. package/frontend/src/lib/analysis/case-evidence-model.js +26 -4
  37. package/frontend/src/lib/analysis/case-migration-model.js +6 -1
  38. package/frontend/src/lib/analysis/case-record-contracts.js +4 -2
  39. package/frontend/src/lib/analysis/case-record-core.js +5 -1
  40. package/frontend/src/lib/analysis/case-record-operations.js +6 -1
  41. package/frontend/src/lib/analysis/case-response-packet.js +7 -7
  42. package/frontend/src/lib/analysis/evidence-coverage-ledger.js +328 -0
  43. package/frontend/src/lib/analysis/investigation-capsule.js +105 -0
  44. package/frontend/src/lib/analysis/lookup-asset-graph.js +806 -0
  45. package/frontend/src/lib/analysis/lookup-decision-support.js +480 -0
  46. package/frontend/src/lib/analysis/lookup-investigation-brief.js +153 -0
  47. package/frontend/src/lib/analysis/lookup-presentation.js +74 -0
  48. package/frontend/src/lib/analysis/lookup-response.js +2 -0
  49. package/frontend/src/lib/analysis/lookup-source-refresh.js +304 -0
  50. package/frontend/src/lib/analysis/lookup-summary-model.js +398 -0
  51. package/frontend/src/lib/analysis/page-similarity.js +1 -1
  52. package/frontend/src/lib/analysis/relationship-evidence.js +3 -3
  53. package/frontend/src/lib/analysis/relationship-observation-model.js +4 -4
  54. package/frontend/src/lib/analysis/scoring.js +8 -88
  55. package/frontend/src/lib/analysis/shortlist-model.js +8 -4
  56. package/frontend/src/lib/analysis/visualization-bounds.js +26 -0
  57. package/frontend/src/lib/analysis/visualization-force-graph.js +352 -0
  58. package/frontend/src/lib/analysis/visualization-matrix.js +88 -0
  59. package/frontend/src/lib/analysis/visualization-models.js +370 -0
  60. package/frontend/src/lib/analysis/visualization-time-series.js +144 -0
  61. package/frontend/src/lib/analysis/workspace-archive-crypto.js +1 -1
  62. package/frontend/src/lib/analysis/workspace-archive.js +2 -2
  63. package/lib/analyst-taxonomy.mjs +40 -0
  64. package/lib/availability.mjs +1 -1
  65. package/lib/browser-library-profile.mjs +15 -2
  66. package/lib/certificate-extension-profile.mjs +71 -0
  67. package/lib/credential-surface-profile.mjs +1 -1
  68. package/lib/domain-assurance.mjs +368 -0
  69. package/lib/domain-control-manifest.mjs +50 -7
  70. package/lib/domain-posture.mjs +9 -9
  71. package/lib/encrypted-dns-contract.mjs +1 -1
  72. package/lib/evidence-report-markdown.mjs +1 -1
  73. package/lib/evidence-report.mjs +1 -1
  74. package/lib/generated/cisa-kev-catalog.mjs +1668 -0
  75. package/lib/ip-prefix.mjs +34 -1
  76. package/lib/local-geoip-evidence.mjs +2 -2
  77. package/lib/lookup-response-contract.mjs +346 -0
  78. package/lib/observed-network-context.mjs +1 -1
  79. package/lib/opportunity-scoring.mjs +107 -0
  80. package/lib/page-fingerprints.mjs +1 -1
  81. package/lib/rdap-capabilities.mjs +6 -6
  82. package/lib/rdap-search-workbench.mjs +96 -6
  83. package/lib/registry-access.mjs +1 -1
  84. package/lib/registry-capability-catalogue.mjs +4 -4
  85. package/lib/registry-insights.mjs +6 -6
  86. package/lib/risk-scoring.mjs +206 -83
  87. package/lib/scoring-evidence-quality.mjs +107 -0
  88. package/lib/security-txt.mjs +3 -3
  89. package/lib/threat-intelligence-manifests.mjs +1 -1
  90. package/lib/threatfox-intelligence.mjs +1 -1
  91. package/lib/tls-intelligence.mjs +13 -3
  92. package/lib/urlhaus-intelligence.mjs +1 -1
  93. package/lib/website-security-posture.mjs +5 -5
  94. package/lib/website-technology.mjs +1 -1
  95. package/lib/whois-chain.mjs +1 -1
  96. package/package.json +5 -1
  97. package/third-party-notices.txt +911 -19
@@ -0,0 +1,71 @@
1
+ // Narrow X.509 extension parsing for fields unavailable through Node's native
2
+ // X509Certificate API. Raw certificates and extension locations are never
3
+ // retained; malformed extensions fail soft and leave an explicit partial flag.
4
+ import 'reflect-metadata';
5
+ import { CertificatePolicyExtension, CRLDistributionPointsExtension, X509Certificate, } from '@peculiar/x509';
6
+ export const MAX_CERTIFICATE_POLICIES = 16;
7
+ export const MAX_CRL_DISTRIBUTION_POINTS = 32;
8
+ function emptyProfile(partial = false) {
9
+ return Object.freeze({
10
+ certificatePolicies: Object.freeze({ oids: Object.freeze([]), truncated: false }),
11
+ crlDistributionPoints: Object.freeze({ total: 0, http: 0, https: 0, ldap: 0, other: 0, truncated: false }),
12
+ parsed: false,
13
+ partial,
14
+ });
15
+ }
16
+ function distributionUris(extension) {
17
+ const uris = [];
18
+ for (const point of extension.distributionPoints.slice(0, MAX_CRL_DISTRIBUTION_POINTS + 1)) {
19
+ for (const name of point.distributionPoint?.fullName ?? []) {
20
+ if (typeof name.uniformResourceIdentifier === 'string')
21
+ uris.push(name.uniformResourceIdentifier);
22
+ }
23
+ }
24
+ return uris;
25
+ }
26
+ export function parseCertificateExtensionProfile(raw) {
27
+ if (!Buffer.isBuffer(raw) || raw.length === 0 || raw.length > 256 * 1024)
28
+ return emptyProfile();
29
+ try {
30
+ const encoded = Uint8Array.from(raw).buffer;
31
+ const certificate = new X509Certificate(encoded);
32
+ const policies = certificate.getExtension(CertificatePolicyExtension)?.policies ?? [];
33
+ const crlExtension = certificate.getExtension(CRLDistributionPointsExtension);
34
+ const uris = crlExtension ? distributionUris(crlExtension) : [];
35
+ const retainedPolicies = [...new Set(policies.filter((oid) => /^\d+(?:\.\d+)+$/u.test(oid)))]
36
+ .slice(0, MAX_CERTIFICATE_POLICIES);
37
+ const retainedUris = uris.slice(0, MAX_CRL_DISTRIBUTION_POINTS);
38
+ const schemes = { http: 0, https: 0, ldap: 0, other: 0 };
39
+ for (const uri of retainedUris) {
40
+ let protocol = '';
41
+ try {
42
+ protocol = new URL(uri).protocol.toLowerCase();
43
+ }
44
+ catch { /* counted as other */ }
45
+ if (protocol === 'http:')
46
+ schemes.http += 1;
47
+ else if (protocol === 'https:')
48
+ schemes.https += 1;
49
+ else if (protocol === 'ldap:' || protocol === 'ldaps:')
50
+ schemes.ldap += 1;
51
+ else
52
+ schemes.other += 1;
53
+ }
54
+ return Object.freeze({
55
+ certificatePolicies: Object.freeze({
56
+ oids: Object.freeze(retainedPolicies),
57
+ truncated: policies.length > retainedPolicies.length,
58
+ }),
59
+ crlDistributionPoints: Object.freeze({
60
+ total: retainedUris.length,
61
+ ...schemes,
62
+ truncated: uris.length > retainedUris.length,
63
+ }),
64
+ parsed: true,
65
+ partial: policies.length > retainedPolicies.length || uris.length > retainedUris.length,
66
+ });
67
+ }
68
+ catch {
69
+ return emptyProfile(true);
70
+ }
71
+ }
@@ -18,7 +18,7 @@ function analyzeCredentialSurfaceProfile(input = {}) {
18
18
  const partial = truncated || unclassifiedActions > 0;
19
19
  const limitations = [
20
20
  'Static input categories use only bounded type and autocomplete declarations; JavaScript-rendered, non-semantic, disabled, and hidden controls are not classified.',
21
- 'Category counts can overlap when one input declares more than one recognized purpose.',
21
+ 'Category counts can overlap when one input declares more than one recognised purpose.',
22
22
  'External submission can be legitimate and does not establish phishing, unsafe handling, ownership, intent, or maliciousness.',
23
23
  'No field names, values, labels, placeholders, complete action URLs, paths, queries, fragments, or arbitrary attributes are retained.',
24
24
  ];
@@ -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, };
@@ -1,6 +1,7 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { domainToASCII } from 'node:url';
3
3
  import { canonicalArtifactJson } from "../frontend/src/lib/analysis/artifact-integrity.js";
4
+ import { exactKeys } from "./bounded-contract-normalizers.mjs";
4
5
  export const DOMAIN_CONTROL_MANIFEST_INPUT_SCHEMA = 'whoisleuth.domain-control-manifest-input';
5
6
  export const DOMAIN_CONTROL_MANIFEST_SCHEMA = 'whoisleuth.domain-control-manifest';
6
7
  export const DOMAIN_CONTROL_MANIFEST_VERSION = 1;
@@ -9,6 +10,18 @@ export const DOMAIN_CONTROL_REVIEW_SCHEMA = 'whoisleuth.domain-control-review';
9
10
  export const DOMAIN_CONTROL_REVIEW_VERSION = 1;
10
11
  export const MAX_DOMAIN_CONTROL_ENTRIES = 100;
11
12
  export const MAX_DOMAIN_CONTROL_RECORDS = 32;
13
+ const MANIFEST_KEYS = new Set(['schema', 'version', 'generatedAt', 'expiresAt', 'entries', 'limitations', 'integrity']);
14
+ const MANIFEST_ENTRY_KEYS = new Set(['domain', 'nameservers', 'ds', 'mx', 'caa', 'tlsIssuer', 'tlsSpkiSha256', 'registrarLock', 'renewalReviewAt', 'note']);
15
+ const MANIFEST_INTEGRITY_KEYS = new Set(['algorithm', 'canonicalization', 'digestSha256']);
16
+ const MANIFEST_INPUT_KEYS = new Set(['schema', 'version', 'expiresAt', 'entries']);
17
+ const REVIEW_INPUT_KEYS = new Set(['schema', 'version', 'manifest', 'observations']);
18
+ const OBSERVATION_KEYS = new Set(['domain', 'fields']);
19
+ const OBSERVATION_FIELDS = new Set(['nameservers', 'ds', 'mx', 'caa', 'tlsIssuer', 'tlsSpkiSha256', 'registrarLock']);
20
+ const OBSERVATION_FIELD_KEYS = new Set(['state', 'values', 'source', 'observedAt']);
21
+ const DOMAIN_CONTROL_LIMITATIONS = Object.freeze([
22
+ 'This analyst-authored manifest records intended domain-control state. It does not collect evidence or change registrar, DNS, mail, or certificate configuration.',
23
+ 'Empty desired fields are unconfigured rather than claims that a record should be absent.',
24
+ ]);
12
25
  function record(value) {
13
26
  return value && typeof value === 'object' && !Array.isArray(value)
14
27
  ? value
@@ -89,6 +102,7 @@ export function buildDomainControlManifest(input, generatedAtValue = new Date().
89
102
  if (!source || source.schema !== DOMAIN_CONTROL_MANIFEST_INPUT_SCHEMA || source.version !== 1) {
90
103
  throw new TypeError(`Domain control manifest input must use ${DOMAIN_CONTROL_MANIFEST_INPUT_SCHEMA} version 1.`);
91
104
  }
105
+ exactKeys(source, MANIFEST_INPUT_KEYS, 'Domain control manifest input');
92
106
  const generatedAt = timestamp(generatedAtValue);
93
107
  const expiresAt = timestamp(source.expiresAt);
94
108
  if (!generatedAt || !expiresAt || Date.parse(expiresAt) <= Date.parse(generatedAt)) {
@@ -97,7 +111,10 @@ export function buildDomainControlManifest(input, generatedAtValue = new Date().
97
111
  if (!Array.isArray(source.entries) || source.entries.length < 1 || source.entries.length > MAX_DOMAIN_CONTROL_ENTRIES) {
98
112
  throw new TypeError(`Domain control manifest input must contain between 1 and ${MAX_DOMAIN_CONTROL_ENTRIES} entries.`);
99
113
  }
100
- const entries = source.entries.map(normalizeEntry);
114
+ const entries = source.entries.map((entry, index) => {
115
+ exactKeys(entry, MANIFEST_ENTRY_KEYS, `Domain control manifest input entry ${index + 1}`);
116
+ return normalizeEntry(entry);
117
+ });
101
118
  if (entries.some((entry) => entry === null))
102
119
  throw new TypeError('Domain control manifest contains an invalid entry.');
103
120
  const normalizedEntries = entries;
@@ -110,10 +127,7 @@ export function buildDomainControlManifest(input, generatedAtValue = new Date().
110
127
  generatedAt,
111
128
  expiresAt,
112
129
  entries: Object.freeze([...normalizedEntries].sort((left, right) => left.domain.localeCompare(right.domain))),
113
- limitations: Object.freeze([
114
- 'This analyst-authored manifest records intended domain-control state. It does not collect evidence or change registrar, DNS, mail, or certificate configuration.',
115
- 'Empty desired fields are unconfigured rather than claims that a record should be absent.',
116
- ]),
130
+ limitations: DOMAIN_CONTROL_LIMITATIONS,
117
131
  });
118
132
  return Object.freeze({
119
133
  ...base,
@@ -134,6 +148,8 @@ export function verifyDomainControlManifest(value) {
134
148
  || source.entries.length < 1
135
149
  || source.entries.length > MAX_DOMAIN_CONTROL_ENTRIES
136
150
  || !Array.isArray(source.limitations)
151
+ || source.limitations.length !== DOMAIN_CONTROL_LIMITATIONS.length
152
+ || source.limitations.some((limitation, index) => limitation !== DOMAIN_CONTROL_LIMITATIONS[index])
137
153
  || !integrity
138
154
  || integrity.algorithm !== 'SHA-256'
139
155
  || integrity.canonicalization !== 'sorted-json-v1'
@@ -141,18 +157,41 @@ export function verifyDomainControlManifest(value) {
141
157
  || !/^sha256:[a-f0-9]{64}$/u.test(integrity.digestSha256)) {
142
158
  throw new TypeError('Domain control manifest has an unsupported or malformed structure.');
143
159
  }
160
+ exactKeys(source, MANIFEST_KEYS, 'Domain control manifest');
161
+ exactKeys(integrity, MANIFEST_INTEGRITY_KEYS, 'Domain control manifest integrity');
162
+ for (const [index, entry] of source.entries.entries()) {
163
+ exactKeys(entry, MANIFEST_ENTRY_KEYS, `Domain control manifest entry ${index + 1}`);
164
+ }
144
165
  const generatedAt = timestamp(source.generatedAt);
145
166
  const expiresAt = timestamp(source.expiresAt);
146
167
  const entries = source.entries.map(normalizeEntry);
147
168
  if (!generatedAt || !expiresAt || Date.parse(expiresAt) <= Date.parse(generatedAt) || entries.some((entry) => entry === null)) {
148
- throw new TypeError('Domain control manifest has invalid normalized content.');
169
+ throw new TypeError('Domain control manifest has invalid normalised content.');
149
170
  }
150
171
  const normalizedEntries = entries;
151
172
  if (new Set(normalizedEntries.map((entry) => entry.domain)).size !== normalizedEntries.length) {
152
173
  throw new TypeError('Domain control manifest entries must use unique domains.');
153
174
  }
154
175
  const { integrity: _integrity, ...unsigned } = source;
155
- if (integrityDigest(unsigned) !== integrity.digestSha256) {
176
+ const canonical = Object.freeze({
177
+ schema: DOMAIN_CONTROL_MANIFEST_SCHEMA,
178
+ version: DOMAIN_CONTROL_MANIFEST_VERSION,
179
+ generatedAt,
180
+ expiresAt,
181
+ entries: Object.freeze([...normalizedEntries].sort((left, right) => left.domain.localeCompare(right.domain))),
182
+ limitations: DOMAIN_CONTROL_LIMITATIONS,
183
+ });
184
+ let suppliedCanonical;
185
+ try {
186
+ suppliedCanonical = canonicalArtifactJson(unsigned);
187
+ }
188
+ catch {
189
+ throw new TypeError('Domain control manifest has invalid normalised content.');
190
+ }
191
+ if (suppliedCanonical !== canonicalArtifactJson(canonical)) {
192
+ throw new TypeError('Domain control manifest must use its canonical normalised content.');
193
+ }
194
+ if (`sha256:${createHash('sha256').update(suppliedCanonical).digest('hex')}` !== integrity.digestSha256) {
156
195
  throw new TypeError('Domain control manifest failed its SHA-256 integrity check.');
157
196
  }
158
197
  return source;
@@ -176,11 +215,14 @@ function normalizeObservation(value) {
176
215
  const rawFields = record(source?.fields);
177
216
  if (!source || !normalizedDomain || !rawFields)
178
217
  return null;
218
+ exactKeys(source, OBSERVATION_KEYS, 'Domain control observation');
219
+ exactKeys(rawFields, OBSERVATION_FIELDS, 'Domain control observation fields');
179
220
  const fields = {};
180
221
  for (const field of ['nameservers', 'ds', 'mx', 'caa', 'tlsIssuer', 'tlsSpkiSha256', 'registrarLock']) {
181
222
  const raw = record(rawFields[field]);
182
223
  if (!raw)
183
224
  continue;
225
+ exactKeys(raw, OBSERVATION_FIELD_KEYS, `Domain control observation field ${field}`);
184
226
  const state = raw.state;
185
227
  if (state !== 'observed' && state !== 'partial' && state !== 'unavailable' && state !== 'unsupported')
186
228
  return null;
@@ -260,6 +302,7 @@ export function reviewDomainControlManifest(input, generatedAtValue = new Date()
260
302
  if (!source || source.schema !== DOMAIN_CONTROL_REVIEW_INPUT_SCHEMA || source.version !== 1) {
261
303
  throw new TypeError(`Domain control review input must use ${DOMAIN_CONTROL_REVIEW_INPUT_SCHEMA} version 1.`);
262
304
  }
305
+ exactKeys(source, REVIEW_INPUT_KEYS, 'Domain control review input');
263
306
  const generatedAt = timestamp(generatedAtValue);
264
307
  if (!generatedAt)
265
308
  throw new TypeError('Domain control review time is invalid.');