@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,382 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { domainToASCII } from 'node:url';
3
+ import { canonicalArtifactJson } from "../frontend/src/lib/analysis/artifact-integrity.js";
4
+ import { exactKeys } from "./bounded-contract-normalizers.mjs";
5
+ export const DOMAIN_CONTROL_MANIFEST_INPUT_SCHEMA = 'whoisleuth.domain-control-manifest-input';
6
+ export const DOMAIN_CONTROL_MANIFEST_SCHEMA = 'whoisleuth.domain-control-manifest';
7
+ export const DOMAIN_CONTROL_MANIFEST_VERSION = 1;
8
+ export const DOMAIN_CONTROL_REVIEW_INPUT_SCHEMA = 'whoisleuth.domain-control-review-input';
9
+ export const DOMAIN_CONTROL_REVIEW_SCHEMA = 'whoisleuth.domain-control-review';
10
+ export const DOMAIN_CONTROL_REVIEW_VERSION = 1;
11
+ export const MAX_DOMAIN_CONTROL_ENTRIES = 100;
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
+ ]);
25
+ function record(value) {
26
+ return value && typeof value === 'object' && !Array.isArray(value)
27
+ ? value
28
+ : null;
29
+ }
30
+ function boundedText(value, maximum = 300) {
31
+ if (typeof value !== 'string' || value.length > maximum * 4 || /[\u0000-\u001f\u007f]/u.test(value))
32
+ return null;
33
+ return value.replace(/\s+/gu, ' ').trim().slice(0, maximum) || null;
34
+ }
35
+ function timestamp(value) {
36
+ const text = boundedText(value, 64);
37
+ if (!text)
38
+ return null;
39
+ const parsed = Date.parse(text);
40
+ return Number.isFinite(parsed) ? new Date(parsed).toISOString() : null;
41
+ }
42
+ function domain(value) {
43
+ const text = boundedText(value, 253)?.toLowerCase().replace(/\.$/u, '');
44
+ if (!text)
45
+ return null;
46
+ const ascii = domainToASCII(text);
47
+ if (!ascii || ascii.length > 253 || ascii.split('.').some((label) => !/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/u.test(label)))
48
+ return null;
49
+ return ascii;
50
+ }
51
+ function hostnameList(value) {
52
+ if (!Array.isArray(value))
53
+ return [];
54
+ return [...new Set(value.slice(0, MAX_DOMAIN_CONTROL_RECORDS * 4)
55
+ .map(domain)
56
+ .filter((item) => item !== null))]
57
+ .sort()
58
+ .slice(0, MAX_DOMAIN_CONTROL_RECORDS);
59
+ }
60
+ function textList(value, maximum = 300) {
61
+ if (!Array.isArray(value))
62
+ return [];
63
+ return [...new Set(value.slice(0, MAX_DOMAIN_CONTROL_RECORDS * 4)
64
+ .map((item) => boundedText(item, maximum)?.toLowerCase() ?? null)
65
+ .filter((item) => item !== null))]
66
+ .sort()
67
+ .slice(0, MAX_DOMAIN_CONTROL_RECORDS);
68
+ }
69
+ function spkiFingerprint(value) {
70
+ const text = boundedText(value, 64)?.toLowerCase();
71
+ return text && /^[a-f0-9]{64}$/u.test(text) ? text : null;
72
+ }
73
+ function normalizeEntry(value) {
74
+ const source = record(value);
75
+ const normalizedDomain = domain(source?.domain);
76
+ if (!source || !normalizedDomain)
77
+ return null;
78
+ const registrarLock = source.registrarLock === 'required' || source.registrarLock === 'not_required'
79
+ ? source.registrarLock
80
+ : null;
81
+ return Object.freeze({
82
+ domain: normalizedDomain,
83
+ nameservers: Object.freeze(hostnameList(source.nameservers)),
84
+ ds: Object.freeze(textList(source.ds, 500)),
85
+ mx: Object.freeze(hostnameList(source.mx)),
86
+ caa: Object.freeze(textList(source.caa, 500)),
87
+ tlsIssuer: boundedText(source.tlsIssuer, 300)?.toLowerCase() ?? null,
88
+ tlsSpkiSha256: spkiFingerprint(source.tlsSpkiSha256),
89
+ registrarLock,
90
+ renewalReviewAt: timestamp(source.renewalReviewAt),
91
+ note: boundedText(source.note, 500),
92
+ });
93
+ }
94
+ function unsignedManifest(value) {
95
+ return value;
96
+ }
97
+ function integrityDigest(value) {
98
+ return `sha256:${createHash('sha256').update(canonicalArtifactJson(value)).digest('hex')}`;
99
+ }
100
+ export function buildDomainControlManifest(input, generatedAtValue = new Date().toISOString()) {
101
+ const source = record(input);
102
+ if (!source || source.schema !== DOMAIN_CONTROL_MANIFEST_INPUT_SCHEMA || source.version !== 1) {
103
+ throw new TypeError(`Domain control manifest input must use ${DOMAIN_CONTROL_MANIFEST_INPUT_SCHEMA} version 1.`);
104
+ }
105
+ exactKeys(source, MANIFEST_INPUT_KEYS, 'Domain control manifest input');
106
+ const generatedAt = timestamp(generatedAtValue);
107
+ const expiresAt = timestamp(source.expiresAt);
108
+ if (!generatedAt || !expiresAt || Date.parse(expiresAt) <= Date.parse(generatedAt)) {
109
+ throw new TypeError('Domain control manifest expiry must be a valid time after generation.');
110
+ }
111
+ if (!Array.isArray(source.entries) || source.entries.length < 1 || source.entries.length > MAX_DOMAIN_CONTROL_ENTRIES) {
112
+ throw new TypeError(`Domain control manifest input must contain between 1 and ${MAX_DOMAIN_CONTROL_ENTRIES} entries.`);
113
+ }
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
+ });
118
+ if (entries.some((entry) => entry === null))
119
+ throw new TypeError('Domain control manifest contains an invalid entry.');
120
+ const normalizedEntries = entries;
121
+ const domains = new Set(normalizedEntries.map((entry) => entry.domain));
122
+ if (domains.size !== normalizedEntries.length)
123
+ throw new TypeError('Domain control manifest entries must use unique domains.');
124
+ const base = Object.freeze({
125
+ schema: DOMAIN_CONTROL_MANIFEST_SCHEMA,
126
+ version: DOMAIN_CONTROL_MANIFEST_VERSION,
127
+ generatedAt,
128
+ expiresAt,
129
+ entries: Object.freeze([...normalizedEntries].sort((left, right) => left.domain.localeCompare(right.domain))),
130
+ limitations: DOMAIN_CONTROL_LIMITATIONS,
131
+ });
132
+ return Object.freeze({
133
+ ...base,
134
+ integrity: Object.freeze({
135
+ algorithm: 'SHA-256',
136
+ canonicalization: 'sorted-json-v1',
137
+ digestSha256: integrityDigest(unsignedManifest(base)),
138
+ }),
139
+ });
140
+ }
141
+ export function verifyDomainControlManifest(value) {
142
+ const source = record(value);
143
+ const integrity = record(source?.integrity);
144
+ if (!source
145
+ || source.schema !== DOMAIN_CONTROL_MANIFEST_SCHEMA
146
+ || source.version !== DOMAIN_CONTROL_MANIFEST_VERSION
147
+ || !Array.isArray(source.entries)
148
+ || source.entries.length < 1
149
+ || source.entries.length > MAX_DOMAIN_CONTROL_ENTRIES
150
+ || !Array.isArray(source.limitations)
151
+ || source.limitations.length !== DOMAIN_CONTROL_LIMITATIONS.length
152
+ || source.limitations.some((limitation, index) => limitation !== DOMAIN_CONTROL_LIMITATIONS[index])
153
+ || !integrity
154
+ || integrity.algorithm !== 'SHA-256'
155
+ || integrity.canonicalization !== 'sorted-json-v1'
156
+ || typeof integrity.digestSha256 !== 'string'
157
+ || !/^sha256:[a-f0-9]{64}$/u.test(integrity.digestSha256)) {
158
+ throw new TypeError('Domain control manifest has an unsupported or malformed structure.');
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
+ }
165
+ const generatedAt = timestamp(source.generatedAt);
166
+ const expiresAt = timestamp(source.expiresAt);
167
+ const entries = source.entries.map(normalizeEntry);
168
+ if (!generatedAt || !expiresAt || Date.parse(expiresAt) <= Date.parse(generatedAt) || entries.some((entry) => entry === null)) {
169
+ throw new TypeError('Domain control manifest has invalid normalised content.');
170
+ }
171
+ const normalizedEntries = entries;
172
+ if (new Set(normalizedEntries.map((entry) => entry.domain)).size !== normalizedEntries.length) {
173
+ throw new TypeError('Domain control manifest entries must use unique domains.');
174
+ }
175
+ const { integrity: _integrity, ...unsigned } = source;
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) {
195
+ throw new TypeError('Domain control manifest failed its SHA-256 integrity check.');
196
+ }
197
+ return source;
198
+ }
199
+ function fieldValues(field, value) {
200
+ if (field === 'nameservers' || field === 'mx')
201
+ return hostnameList(value);
202
+ if (field === 'tlsSpkiSha256') {
203
+ const normalized = Array.isArray(value) ? value.map(spkiFingerprint).filter(Boolean) : [spkiFingerprint(value)].filter(Boolean);
204
+ return [...new Set(normalized)].sort().slice(0, MAX_DOMAIN_CONTROL_RECORDS);
205
+ }
206
+ if (field === 'registrarLock') {
207
+ const values = Array.isArray(value) ? value : [value];
208
+ return [...new Set(values.filter((item) => item === 'required' || item === 'not_required'))];
209
+ }
210
+ return textList(Array.isArray(value) ? value : [value], 500);
211
+ }
212
+ function normalizeObservation(value) {
213
+ const source = record(value);
214
+ const normalizedDomain = domain(source?.domain);
215
+ const rawFields = record(source?.fields);
216
+ if (!source || !normalizedDomain || !rawFields)
217
+ return null;
218
+ exactKeys(source, OBSERVATION_KEYS, 'Domain control observation');
219
+ exactKeys(rawFields, OBSERVATION_FIELDS, 'Domain control observation fields');
220
+ const fields = {};
221
+ for (const field of ['nameservers', 'ds', 'mx', 'caa', 'tlsIssuer', 'tlsSpkiSha256', 'registrarLock']) {
222
+ const raw = record(rawFields[field]);
223
+ if (!raw)
224
+ continue;
225
+ exactKeys(raw, OBSERVATION_FIELD_KEYS, `Domain control observation field ${field}`);
226
+ const state = raw.state;
227
+ if (state !== 'observed' && state !== 'partial' && state !== 'unavailable' && state !== 'unsupported')
228
+ return null;
229
+ const sourceLabel = boundedText(raw.source, 120);
230
+ if (!sourceLabel)
231
+ return null;
232
+ fields[field] = Object.freeze({
233
+ state,
234
+ values: Object.freeze(fieldValues(field, raw.values)),
235
+ source: sourceLabel,
236
+ observedAt: timestamp(raw.observedAt),
237
+ });
238
+ }
239
+ return Object.freeze({ domain: normalizedDomain, fields: Object.freeze(fields) });
240
+ }
241
+ function desiredValues(entry, field) {
242
+ if (field === 'tlsIssuer')
243
+ return entry.tlsIssuer ? [entry.tlsIssuer] : [];
244
+ if (field === 'tlsSpkiSha256')
245
+ return entry.tlsSpkiSha256 ? [entry.tlsSpkiSha256] : [];
246
+ if (field === 'registrarLock')
247
+ return entry.registrarLock ? [entry.registrarLock] : [];
248
+ return [...entry[field]];
249
+ }
250
+ function compareField(entry, observation, field) {
251
+ const desired = desiredValues(entry, field);
252
+ if (!desired.length)
253
+ return Object.freeze({
254
+ field, state: 'not_configured', desired: Object.freeze([]), observed: Object.freeze([]), source: null, observedAt: null,
255
+ explanation: 'No desired value was configured for this field.',
256
+ });
257
+ const evidence = observation?.fields[field];
258
+ if (!evidence)
259
+ return Object.freeze({
260
+ field, state: 'unavailable', desired: Object.freeze(desired), observed: Object.freeze([]), source: null, observedAt: null,
261
+ explanation: 'No separately attributed observation was supplied for this field.',
262
+ });
263
+ if (evidence.state !== 'observed')
264
+ return Object.freeze({
265
+ field,
266
+ state: evidence.state,
267
+ desired: Object.freeze(desired),
268
+ observed: evidence.values,
269
+ source: evidence.source,
270
+ observedAt: evidence.observedAt,
271
+ explanation: evidence.state === 'partial'
272
+ ? 'The supplied observation was partial, so a difference is inconclusive.'
273
+ : `The supplied source reported this field as ${evidence.state}.`,
274
+ });
275
+ const aligned = desired.length === evidence.values.length && desired.every((item, index) => item === evidence.values[index]);
276
+ return Object.freeze({
277
+ field,
278
+ state: aligned ? 'aligned' : 'drift',
279
+ desired: Object.freeze(desired),
280
+ observed: evidence.values,
281
+ source: evidence.source,
282
+ observedAt: evidence.observedAt,
283
+ explanation: aligned
284
+ ? 'The complete supplied observation matches the desired value.'
285
+ : 'The complete supplied observation differs from the desired value.',
286
+ });
287
+ }
288
+ function renewalComparison(entry, now) {
289
+ if (!entry.renewalReviewAt)
290
+ return Object.freeze({
291
+ field: 'renewalReviewAt', state: 'not_configured', desired: Object.freeze([]), observed: Object.freeze([]), source: null, observedAt: null,
292
+ explanation: 'No renewal review date was configured.',
293
+ });
294
+ const due = Date.parse(entry.renewalReviewAt) <= Date.parse(now);
295
+ return Object.freeze({
296
+ field: 'renewalReviewAt', state: due ? 'due' : 'aligned', desired: Object.freeze([entry.renewalReviewAt]), observed: Object.freeze([]), source: null, observedAt: null,
297
+ explanation: due ? 'The configured renewal review date is due.' : 'The configured renewal review date is still in the future.',
298
+ });
299
+ }
300
+ export function reviewDomainControlManifest(input, generatedAtValue = new Date().toISOString()) {
301
+ const source = record(input);
302
+ if (!source || source.schema !== DOMAIN_CONTROL_REVIEW_INPUT_SCHEMA || source.version !== 1) {
303
+ throw new TypeError(`Domain control review input must use ${DOMAIN_CONTROL_REVIEW_INPUT_SCHEMA} version 1.`);
304
+ }
305
+ exactKeys(source, REVIEW_INPUT_KEYS, 'Domain control review input');
306
+ const generatedAt = timestamp(generatedAtValue);
307
+ if (!generatedAt)
308
+ throw new TypeError('Domain control review time is invalid.');
309
+ const manifest = verifyDomainControlManifest(source.manifest);
310
+ if (!Array.isArray(source.observations) || source.observations.length > MAX_DOMAIN_CONTROL_ENTRIES * 2) {
311
+ throw new TypeError('Domain control observations must be a bounded array.');
312
+ }
313
+ const observations = source.observations.map(normalizeObservation);
314
+ if (observations.some((item) => item === null))
315
+ throw new TypeError('Domain control review contains an invalid observation.');
316
+ const normalizedObservations = observations;
317
+ if (new Set(normalizedObservations.map((item) => item.domain)).size !== normalizedObservations.length) {
318
+ throw new TypeError('Domain control observations must use unique domains.');
319
+ }
320
+ const observationMap = new Map(normalizedObservations.map((item) => [item.domain, item]));
321
+ const fields = ['nameservers', 'ds', 'mx', 'caa', 'tlsIssuer', 'tlsSpkiSha256', 'registrarLock'];
322
+ const domains = manifest.entries.map((entry) => {
323
+ const observation = observationMap.get(entry.domain) ?? null;
324
+ const comparisons = [...fields.map((field) => compareField(entry, observation, field)), renewalComparison(entry, generatedAt)];
325
+ const state = comparisons.some((item) => item.state === 'drift' || item.state === 'due')
326
+ ? 'drift'
327
+ : comparisons.some((item) => item.state === 'partial' || item.state === 'unavailable' || item.state === 'unsupported')
328
+ ? 'partial'
329
+ : 'aligned';
330
+ return Object.freeze({ domain: entry.domain, state, comparisons: Object.freeze(comparisons) });
331
+ });
332
+ const comparisons = domains.flatMap((item) => item.comparisons);
333
+ const counts = Object.fromEntries(['aligned', 'drift', 'partial', 'unavailable', 'unsupported', 'not_configured', 'due']
334
+ .map((state) => [state, comparisons.filter((item) => item.state === state).length]));
335
+ const expired = Date.parse(manifest.expiresAt) <= Date.parse(generatedAt);
336
+ return Object.freeze({
337
+ schema: DOMAIN_CONTROL_REVIEW_SCHEMA,
338
+ version: DOMAIN_CONTROL_REVIEW_VERSION,
339
+ generatedAt,
340
+ state: expired ? 'expired' : domains.some((item) => item.state === 'drift') ? 'drift' : domains.some((item) => item.state === 'partial') ? 'partial' : 'aligned',
341
+ manifest: Object.freeze({
342
+ generatedAt: manifest.generatedAt,
343
+ expiresAt: manifest.expiresAt,
344
+ digestSha256: manifest.integrity.digestSha256,
345
+ expired,
346
+ }),
347
+ counts: Object.freeze(counts),
348
+ domains: Object.freeze(domains),
349
+ ignoredObservationCount: normalizedObservations.filter((item) => !manifest.entries.some((entry) => entry.domain === item.domain)).length,
350
+ limitations: Object.freeze([
351
+ 'This local review compares analyst-authored desired state with separately supplied observations and performs no collection or configuration change.',
352
+ 'Only complete observations can produce drift. Partial, unavailable, unsupported, or missing evidence remains inconclusive.',
353
+ 'A valid manifest digest or signature establishes file integrity, not the correctness of the desired state or supplied observations.',
354
+ ]),
355
+ });
356
+ }
357
+ export function formatDomainControlResult(value) {
358
+ if (value.schema === DOMAIN_CONTROL_MANIFEST_SCHEMA) {
359
+ return [
360
+ 'Domain control manifest',
361
+ `Domains ${value.entries.length}`,
362
+ `Expires ${value.expiresAt}`,
363
+ `Digest ${value.integrity.digestSha256}`,
364
+ '',
365
+ 'The manifest records desired state only and performs no collection or configuration change.',
366
+ '',
367
+ ].join('\n');
368
+ }
369
+ return [
370
+ 'Domain control review',
371
+ `State ${value.state}`,
372
+ `Domains ${value.domains.length}`,
373
+ `Drift ${value.counts.drift ?? 0}`,
374
+ `Partial ${(value.counts.partial ?? 0) + (value.counts.unavailable ?? 0) + (value.counts.unsupported ?? 0)}`,
375
+ `Due ${value.counts.due ?? 0}`,
376
+ '',
377
+ ...value.domains.map((item) => `${item.domain} ${item.state}`),
378
+ '',
379
+ ...value.limitations.map((item) => `Limitation: ${item}`),
380
+ '',
381
+ ].join('\n');
382
+ }
@@ -5,6 +5,7 @@ import { promises as dns } from 'node:dns';
5
5
  import { fetchRdapRecord } from "./rdap.mjs";
6
6
  import { nonEmptyErrorMessage } from "./error-detail.mjs";
7
7
  import { safeFetch, readTextCapped } from "./safe-fetch.mjs";
8
+ import { whoisleuthRequestHeaders } from "./outbound-identity.mjs";
8
9
  import { classifyMxRecords } from "./dns-mx.mjs";
9
10
  import { parseSpfRecords, parseDmarcRecords, parseMtaStsDnsRecords, parseMtaStsPolicy, parseTlsRptRecords, parseBimiRecords, parseDkimRecords, } from "./domain-posture-parsers.mjs";
10
11
  import { buildExternalDependencies, expandSpfPolicy, validateDmarcExternalReporting, } from "./domain-posture-analysis.mjs";
@@ -93,7 +94,7 @@ function spfCheck(query, expansion) {
93
94
  const parsed = parseSpfRecords(query.records);
94
95
  if (parsed.records.length === 0) {
95
96
  return check('spf', 'SPF', 'warning', 'No SPF policy published', {
96
- remediation: 'Publish one SPF TXT record that authorizes every legitimate sender and ends in -all once verified.',
97
+ remediation: 'Publish one SPF TXT record that authorises every legitimate sender and ends in -all once verified.',
97
98
  });
98
99
  }
99
100
  if (!parsed.valid) {
@@ -118,7 +119,7 @@ function spfCheck(query, expansion) {
118
119
  ...parsed.issues,
119
120
  ];
120
121
  if (parsed.terminalPolicy === 'pass') {
121
- return check('spf', 'SPF', 'danger', 'Policy authorizes every sender (+all)', {
122
+ return check('spf', 'SPF', 'danger', 'Policy authorises every sender (+all)', {
122
123
  detail: details.join(' '), records: parsed.records,
123
124
  remediation: 'Replace +all with an explicit sender allowlist and a restrictive terminal policy.',
124
125
  });
@@ -142,7 +143,7 @@ function spfCheck(query, expansion) {
142
143
  return check('spf', 'SPF', 'warning', `Policy ends in ${parsed.terminalPolicy || 'an unknown result'}`, {
143
144
  detail: details.join(' '), records: parsed.records,
144
145
  remediation: parsed.terminalPolicy === 'softfail'
145
- ? 'Move from ~all to -all after confirming every legitimate sending service is authorized.'
146
+ ? 'Move from ~all to -all after confirming every legitimate sending service is authorised.'
146
147
  : 'Add a restrictive -all terminal policy after confirming every legitimate sending service.',
147
148
  });
148
149
  }
@@ -197,7 +198,7 @@ function dmarcCheck(query, authorizations = []) {
197
198
  return check('dmarc', 'DMARC', 'warning', `Enforced at p=${parsed.policy}; external reporting authorization is incomplete`, {
198
199
  detail: `${details.join(' ')} ${unresolvedExternal.map((authorization) => `${authorization.destination}: ${authorization.state}.`).join(' ')}`,
199
200
  records: parsed.records,
200
- remediation: 'Publish the required external reporting authorization record or remove the unavailable destination.',
201
+ remediation: 'Publish the required external reporting authorisation record or remove the unavailable destination.',
201
202
  });
202
203
  }
203
204
  if (!parsed.aggregateReporting) {
@@ -251,7 +252,7 @@ function dnssecCheck(input) {
251
252
  function dnssecDelegationConsistencyCheck(input, registry) {
252
253
  if (registry.error) {
253
254
  return check('dnssec_delegation_consistency', 'DNSSEC delegation consistency', 'info', 'Registry evidence is unavailable', {
254
- detail: 'DNSSEC delegation and retained DS records could not be compared because normalized registry evidence was unavailable.',
255
+ detail: 'DNSSEC delegation and retained DS records could not be compared because normalised registry evidence was unavailable.',
255
256
  });
256
257
  }
257
258
  if (input.error) {
@@ -273,7 +274,7 @@ function dnssecDelegationConsistencyCheck(input, registry) {
273
274
  }
274
275
  if (registry.dsDataTruncated) {
275
276
  return check('dnssec_delegation_consistency', 'DNSSEC delegation consistency', 'info', 'Retained DS evidence is incomplete', {
276
- detail: 'The normalized registry response reported truncated DS data, so apparent disagreement is inconclusive.',
277
+ detail: 'The normalised registry response reported truncated DS data, so apparent disagreement is inconclusive.',
277
278
  });
278
279
  }
279
280
  if (value === 'signed' && !hasDsRecords) {
@@ -413,7 +414,7 @@ function bimiCheck(query, dmarcQuery) {
413
414
  && (dmarc.legacyPct === null || dmarc.legacyPct === 100);
414
415
  if (!enforcementReady) {
415
416
  return check('bimi', 'BIMI', 'warning', 'Record exists but DMARC is not BIMI-ready', {
416
- detail: 'BIMI display generally requires enforced DMARC for the organizational domain and subdomains. Mailbox providers apply additional requirements.',
417
+ detail: 'BIMI display generally requires enforced DMARC for the organisational domain and subdomains. Mailbox providers apply additional requirements.',
417
418
  records: parsed.records,
418
419
  remediation: 'Enforce DMARC at quarantine or reject for the domain and subdomains before relying on BIMI.',
419
420
  });
@@ -533,7 +534,7 @@ function registrationLockCheck(registry) {
533
534
  const statuses = registry.statuses.map((status) => status.toLowerCase().replace(/[^a-z]/gu, ''));
534
535
  if (statuses.length === 0) {
535
536
  return check('registration_lock', 'Registration controls', 'info', 'Registry lock state is unavailable', {
536
- detail: 'No normalized EPP status was returned. This does not describe registrar account security.',
537
+ detail: 'No normalised EPP status was returned. This does not describe registrar account security.',
537
538
  });
538
539
  }
539
540
  const transferLocks = statuses.filter((status) => ['clienttransferprohibited', 'servertransferprohibited'].includes(status));
@@ -608,7 +609,7 @@ async function fetchMtaStsPolicy(domain) {
608
609
  const url = `https://mta-sts.${domain}/.well-known/mta-sts.txt`;
609
610
  const res = await safeFetch(url, {
610
611
  signal: controller.signal,
611
- headers: { 'User-Agent': 'Mozilla/5.0 (compatible; WHOISleuth-Posture/1.0)', Accept: 'text/plain' },
612
+ headers: whoisleuthRequestHeaders({ Accept: 'text/plain' }),
612
613
  });
613
614
  if (!res.ok) {
614
615
  // Not reading this body - release it explicitly instead of leaving an
@@ -705,7 +706,7 @@ async function checkDomainPosture(domain, { dkimSelectors = [], retiredDkimSelec
705
706
  nameservers: [],
706
707
  dsRecordCount: 0,
707
708
  dsDataTruncated: false,
708
- error: rdap && 'error' in rdap ? rdap.error : 'RDAP did not return normalized domain evidence.',
709
+ error: rdap && 'error' in rdap ? rdap.error : 'RDAP did not return normalised domain evidence.',
709
710
  };
710
711
  const externalDependencies = buildExternalDependencies({
711
712
  domain,
@@ -0,0 +1,81 @@
1
+ const ENCRYPTED_DNS_CONTRACT_SCHEMA = 'whoisleuth.encrypted-dns-adapter';
2
+ const ENCRYPTED_DNS_CONTRACT_VERSION = 1;
3
+ const DNS_TYPES = new Set(['A', 'AAAA', 'CAA', 'CNAME', 'DNSKEY', 'DS', 'HTTPS', 'MX', 'NS', 'RRSIG', 'SOA', 'SVCB', 'TLSA', 'TXT']);
4
+ function record(value) {
5
+ return value && typeof value === 'object' && !Array.isArray(value)
6
+ ? value
7
+ : null;
8
+ }
9
+ function httpsUrl(value) {
10
+ if (typeof value !== 'string' || value.length > 2048)
11
+ return null;
12
+ try {
13
+ const url = new URL(value);
14
+ return url.protocol === 'https:' && !url.username && !url.password ? url.toString() : null;
15
+ }
16
+ catch {
17
+ return null;
18
+ }
19
+ }
20
+ function normalizeEncryptedDnsAdapter(value) {
21
+ const source = record(value);
22
+ if (!source)
23
+ throw new TypeError('Encrypted DNS adapter must be an object.');
24
+ const id = typeof source.id === 'string' && /^[a-z][a-z0-9-]{0,47}$/u.test(source.id) ? source.id : null;
25
+ const label = typeof source.label === 'string' && source.label.length <= 80 && !/[\u0000-\u001f\u007f]/u.test(source.label)
26
+ ? source.label.trim()
27
+ : null;
28
+ const endpoint = httpsUrl(source.endpoint);
29
+ const termsUrl = httpsUrl(source.termsUrl);
30
+ const privacyUrl = httpsUrl(source.privacyUrl);
31
+ const reviewedAt = typeof source.reviewedAt === 'string' && Number.isFinite(Date.parse(source.reviewedAt))
32
+ ? new Date(source.reviewedAt).toISOString()
33
+ : null;
34
+ const method = source.method === 'GET' || source.method === 'POST' ? source.method : null;
35
+ const representation = source.representation === 'dns-json' || source.representation === 'dns-wire'
36
+ ? source.representation
37
+ : null;
38
+ const retention = source.queryRetention;
39
+ const maxResponseBytes = Number(source.maxResponseBytes);
40
+ const timeoutMs = Number(source.timeoutMs);
41
+ if (!id || !label || !endpoint || !termsUrl || !privacyUrl || !reviewedAt || !method || !representation
42
+ || !['none', 'limited', 'provider_defined', 'unknown'].includes(String(retention))
43
+ || !Number.isInteger(maxResponseBytes) || maxResponseBytes < 1024 || maxResponseBytes > 2 * 1024 * 1024
44
+ || !Number.isInteger(timeoutMs) || timeoutMs < 250 || timeoutMs > 15_000) {
45
+ throw new TypeError('Encrypted DNS adapter did not satisfy the bounded privacy and transport contract.');
46
+ }
47
+ return Object.freeze({
48
+ id,
49
+ label,
50
+ endpoint,
51
+ method,
52
+ representation,
53
+ termsUrl,
54
+ privacyUrl,
55
+ reviewedAt,
56
+ queryRetention: retention,
57
+ maxResponseBytes,
58
+ timeoutMs,
59
+ });
60
+ }
61
+ function planEncryptedDnsQuery(adapter, input) {
62
+ const name = typeof input.name === 'string' ? input.name.trim().toLowerCase().replace(/\.$/u, '') : '';
63
+ const type = typeof input.type === 'string' ? input.type.trim().toUpperCase() : '';
64
+ if (!name || name.length > 253 || !/^[a-z0-9._-]+$/u.test(name) || !DNS_TYPES.has(type)) {
65
+ throw new TypeError('Encrypted DNS query name or record type was invalid or unsupported.');
66
+ }
67
+ return Object.freeze({
68
+ schema: ENCRYPTED_DNS_CONTRACT_SCHEMA,
69
+ version: ENCRYPTED_DNS_CONTRACT_VERSION,
70
+ state: 'approval_required',
71
+ adapter: Object.freeze({ id: adapter.id, label: adapter.label, endpoint: adapter.endpoint }),
72
+ query: Object.freeze({ name, type }),
73
+ disclosure: Object.freeze({
74
+ target: name,
75
+ recipient: new URL(adapter.endpoint).hostname,
76
+ queryRetention: adapter.queryRetention,
77
+ }),
78
+ limitation: 'This plan does not execute a request. Provider availability, answer authority, validation, filtering, retention, and regional behaviour remain untested.',
79
+ });
80
+ }
81
+ export { ENCRYPTED_DNS_CONTRACT_SCHEMA, ENCRYPTED_DNS_CONTRACT_VERSION, normalizeEncryptedDnsAdapter, planEncryptedDnsQuery, };
@@ -1,7 +1,9 @@
1
1
  import { compareRdapPublications, compareRegistrySources } from "./registry-comparison.mjs";
2
2
  import { buildRegistryInsights } from "./registry-insights.mjs";
3
+ import { WHOISLEUTH_SOURCE_REPOSITORY_URL } from "./project-metadata.mjs";
4
+ import { normalizeBoundedSemanticVersion } from "./semantic-version.mjs";
3
5
  export const LOOKUP_EVIDENCE_SCHEMA = 'whoisleuth.lookup-evidence';
4
- export const LOOKUP_EVIDENCE_SCHEMA_VERSION = 23;
6
+ export const LOOKUP_EVIDENCE_SCHEMA_VERSION = 24;
5
7
  const REGISTRAR_RDAP_STATUSES = new Set([
6
8
  'success', 'partial', 'error', 'unsupported', 'not_found', 'skipped', 'disabled',
7
9
  ]);
@@ -37,6 +39,14 @@ function boundedTimestamp(value) {
37
39
  const time = Date.parse(text);
38
40
  return Number.isFinite(time) ? new Date(time).toISOString() : null;
39
41
  }
42
+ function boundedSemanticVersion(value) {
43
+ try {
44
+ return normalizeBoundedSemanticVersion(value, 'Evidence application');
45
+ }
46
+ catch {
47
+ return null;
48
+ }
49
+ }
40
50
  function boundedEndpoint(value) {
41
51
  const text = boundedString(value, 2048);
42
52
  if (!text)
@@ -359,7 +369,7 @@ function registrarPublicationComparison(body, registryParsed) {
359
369
  });
360
370
  }
361
371
  export function buildLookupEvidence(response, options = {}) {
362
- const { generatedAt = new Date().toISOString(), idnAnalysis = null } = options;
372
+ const { generatedAt = new Date().toISOString(), idnAnalysis = null, applicationVersion = null } = options;
363
373
  const body = recordOrNull(response) || {};
364
374
  const rdap = recordOrNull(body.rdap);
365
375
  const whois = recordOrNull(body.whois);
@@ -389,7 +399,11 @@ export function buildLookupEvidence(response, options = {}) {
389
399
  schema: LOOKUP_EVIDENCE_SCHEMA,
390
400
  schemaVersion: LOOKUP_EVIDENCE_SCHEMA_VERSION,
391
401
  generatedAt,
392
- application: { name: 'WHOISleuth' },
402
+ application: {
403
+ name: 'WHOISleuth',
404
+ version: boundedSemanticVersion(applicationVersion),
405
+ projectUrl: WHOISLEUTH_SOURCE_REPOSITORY_URL,
406
+ },
393
407
  query: {
394
408
  submitted: body.query || null,
395
409
  type: body.type || null,
@@ -38,7 +38,7 @@ function formatLookupEvidenceMarkdown(document, options = {}) {
38
38
  lines.push('', '## Registry-source comparison');
39
39
  appendFields(lines, report.comparison.health);
40
40
  if (!report.comparison.fields.length) {
41
- lines.push('- No comparable normalized fields were published.');
41
+ lines.push('- No comparable normalised fields were published.');
42
42
  }
43
43
  else {
44
44
  for (const field of report.comparison.fields) {
@@ -89,6 +89,7 @@ function reportField(label, value, fallback = 'Not reported') {
89
89
  }
90
90
  function buildLookupEvidenceReport(document, options = {}) {
91
91
  const report = objectOrEmpty(document);
92
+ const application = objectOrEmpty(report.application);
92
93
  const query = objectOrEmpty(report.query);
93
94
  const diagnostics = objectOrEmpty(report.diagnostics);
94
95
  const rdapDiagnostics = objectOrEmpty(diagnostics.rdap);
@@ -300,6 +301,8 @@ function buildLookupEvidenceReport(document, options = {}) {
300
301
  notice: 'Human-readable summary. The versioned JSON export remains the full-fidelity evidence package; raw RDAP JSON and WHOIS response bodies are deliberately omitted here.',
301
302
  metadata: [
302
303
  reportField('Generated', report.generatedAt),
304
+ reportField('Generator', application.version ? `${application.name || 'WHOISleuth'} ${application.version}` : application.name || 'WHOISleuth'),
305
+ reportField('Project', application.projectUrl),
303
306
  reportField('Report contract', `${cleanReportText(report.schema)} v${cleanReportText(report.schemaVersion)}`),
304
307
  ],
305
308
  query: [
@@ -375,7 +378,7 @@ function buildLookupEvidenceReport(document, options = {}) {
375
378
  ...registryAccessFields,
376
379
  ],
377
380
  limitations: [
378
- 'This report summarizes point-in-time observations and registry publications. It does not prove ownership, activity, availability, or maliciousness.',
381
+ 'This report summarises point-in-time observations and registry publications. It does not prove ownership, activity, availability, or maliciousness.',
379
382
  'Missing, skipped, partial, unsupported, or failed sources are inconclusive rather than negative evidence.',
380
383
  'The Risk score is a versioned heuristic review priority, not a probability or determination of maliciousness. Review its listed factors and the underlying source states.',
381
384
  'Raw registry payloads and full WHOIS referral responses are available only in the JSON evidence package and may contain public contact data.',