@slicedearth/whoisleuth-cli 1.39.1 → 1.45.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 (90) hide show
  1. package/README.md +23 -4
  2. package/bin/whoisleuth.mjs +31 -9
  3. package/cli/archive-inspect.mjs +37 -2
  4. package/cli/arguments.mjs +361 -256
  5. package/cli/artifact-verify.mjs +35 -1
  6. package/cli/bulk-checkpoint.mjs +72 -39
  7. package/cli/bulk-command-runner.mjs +34 -4
  8. package/cli/bulk.mjs +18 -7
  9. package/cli/case-pack.mjs +136 -0
  10. package/cli/ci-report.mjs +73 -0
  11. package/cli/collection-preflight.mjs +59 -0
  12. package/cli/command-reference.mjs +395 -0
  13. package/cli/completion.mjs +67 -13
  14. package/cli/config-profile.mjs +183 -0
  15. package/cli/ct-event-intake.mjs +159 -0
  16. package/cli/discovery-observation-snapshot.mjs +8 -14
  17. package/cli/discovery-scan-command-runner.mjs +29 -2
  18. package/cli/discovery-snapshot.mjs +8 -15
  19. package/cli/domain-control-monitor.mjs +104 -0
  20. package/cli/domain-control-observations.mjs +235 -0
  21. package/cli/errors.mjs +7 -1
  22. package/cli/evidence-command-arguments.mjs +11 -0
  23. package/cli/evidence-command-runner.mjs +1 -0
  24. package/cli/external-observation-mapping.mjs +162 -0
  25. package/cli/fail-policy.mjs +64 -0
  26. package/cli/formatters/html.mjs +1 -1
  27. package/cli/formatters/json.mjs +12 -4
  28. package/cli/formatters/terminal.mjs +1 -1
  29. package/cli/http.mjs +2 -1
  30. package/cli/investigation-manifest.mjs +113 -0
  31. package/cli/investigation-run.mjs +112 -0
  32. package/cli/lookup-brief.mjs +116 -0
  33. package/cli/lookup-command-runner.mjs +8 -1
  34. package/cli/lookup-diff.mjs +17 -19
  35. package/cli/lookup-reconcile.mjs +1 -1
  36. package/cli/mail-review.mjs +5 -5
  37. package/cli/manual.mjs +1 -1
  38. package/cli/network-command-runner.mjs +4 -1
  39. package/cli/offline-evidence-review.mjs +105 -8
  40. package/cli/open-asset-model-bridge.mjs +169 -0
  41. package/cli/registry-cohort.mjs +121 -0
  42. package/cli/registry-doctor.mjs +106 -11
  43. package/cli/registry-fixture-scaffold.mjs +74 -0
  44. package/cli/runner.mjs +388 -315
  45. package/docs/cli-reference.md +266 -21
  46. package/docs/cli.md +43 -7
  47. package/frontend/src/lib/analysis/acquisition-due-diligence.js +2 -2
  48. package/frontend/src/lib/analysis/brand-profile-model.js +50 -6
  49. package/frontend/src/lib/analysis/case-evidence-model.js +1 -1
  50. package/frontend/src/lib/analysis/case-record-core.js +1 -1
  51. package/frontend/src/lib/analysis/case-record-operations.js +1 -1
  52. package/frontend/src/lib/analysis/case-report.js +573 -0
  53. package/frontend/src/lib/analysis/case-response-model.js +30 -1
  54. package/frontend/src/lib/analysis/detection-rule-model.js +36 -0
  55. package/frontend/src/lib/analysis/external-findings-import.js +320 -0
  56. package/frontend/src/lib/analysis/lookup-asset-graph.js +8 -8
  57. package/frontend/src/lib/analysis/lookup-decision-support.js +9 -2
  58. package/frontend/src/lib/analysis/lookup-presentation.js +14 -30
  59. package/frontend/src/lib/analysis/lookup-source-refresh.js +36 -63
  60. package/frontend/src/lib/analysis/scoring.js +1 -1
  61. package/frontend/src/lib/analysis/utils.js +1 -1
  62. package/frontend/src/lib/analysis/visualization-models.js +2 -3
  63. package/frontend/src/lib/analysis/workspace-archive-crypto.js +1 -4
  64. package/frontend/src/lib/analysis/workspace-archive.js +5 -7
  65. package/lib/abusech-auth.mjs +1 -1
  66. package/lib/availability.mjs +1 -1
  67. package/lib/bounded-contract-normalizers.mjs +33 -0
  68. package/lib/bounded-file.mjs +69 -0
  69. package/lib/bounded-json-response.mjs +157 -0
  70. package/lib/browser-library-profile.mjs +167 -38
  71. package/lib/dns-convergence-review.mjs +183 -0
  72. package/lib/dns-delegation-health.mjs +196 -3
  73. package/lib/domain-change-packet.mjs +133 -0
  74. package/lib/domain-change-review.mjs +307 -0
  75. package/lib/domain-control-flight-recorder.mjs +268 -0
  76. package/lib/domain-portfolio-review.mjs +151 -0
  77. package/lib/evidence-export.mjs +1 -1
  78. package/lib/html-signals.mjs +5 -11
  79. package/lib/lookup-cache.mjs +5 -1
  80. package/lib/nameserver-preflight-review.mjs +159 -0
  81. package/lib/page-language-signals.mjs +124 -0
  82. package/lib/rdap-transport.mjs +6 -6
  83. package/lib/risk-scoring.mjs +42 -1
  84. package/lib/safe-fetch.mjs +37 -32
  85. package/lib/static-html-analysis.mjs +29 -1
  86. package/lib/threat-intelligence-runtime.mjs +1 -2
  87. package/lib/trust-store-comparison.mjs +146 -0
  88. package/lib/website-technology.mjs +22 -12
  89. package/lib/zone-intent-review.mjs +534 -0
  90. package/package.json +1 -1
@@ -4,10 +4,19 @@
4
4
  // scripts. Upstream URLs, filenames, inline bodies, and catalogue expressions
5
5
  // are treated as untrusted inputs and are not retained in the result.
6
6
  import { createHash } from 'node:crypto';
7
+ import { Worker } from 'node:worker_threads';
7
8
  import { RETIRE_BROWSER_CATALOG } from "./generated/retire-browser-catalog.mjs";
8
9
  import { CISA_KEV_CATALOG } from "./generated/cisa-kev-catalog.mjs";
9
10
  import { createObservation } from "./observation.mjs";
10
11
  import { MAX_INLINE_SCRIPT_CHARS, MAX_INLINE_SCRIPT_TOTAL_CHARS, MAX_SCRIPT_ELEMENTS, MAX_STATIC_HTML_CHARS, analyzeStaticHtml, } from "./static-html-analysis.mjs";
12
+ // Catalogue expressions are reviewed third-party inputs. Keep every individual
13
+ // regular-expression evaluation and the aggregate inline-signature work small
14
+ // enough that a target-controlled script cannot monopolise the Node event loop.
15
+ // Full-content hashes still use the complete already-capped script body.
16
+ const MAX_INLINE_LIBRARY_SCAN_CHARS = 1_024;
17
+ const MAX_INLINE_LIBRARY_SCAN_TOTAL_CHARS = 4_096;
18
+ const MAX_INLINE_LIBRARY_SCAN_MS = 750;
19
+ const MAX_INLINE_LIBRARY_WORKER_BYTES = 64 * 1024;
11
20
  const BROWSER_LIBRARY_PROFILE_VERSION = 2;
12
21
  const MAX_LIBRARY_HTML_CHARS = MAX_STATIC_HTML_CHARS;
13
22
  const MAX_LIBRARY_FINDINGS = 16;
@@ -29,6 +38,68 @@ const SEVERITY_WEIGHT = Object.freeze({
29
38
  });
30
39
  const CATALOG_COMPONENTS = RETIRE_BROWSER_CATALOG.components;
31
40
  const KNOWN_EXPLOITED_IDENTIFIERS = new Set(CISA_KEV_CATALOG.identifiers);
41
+ const INLINE_EXTRACTOR_CATALOGUE = Object.freeze(Object.entries(CATALOG_COMPONENTS).map(([component, value]) => {
42
+ const extractors = record(record(value).extractors);
43
+ return Object.freeze({
44
+ component,
45
+ patterns: Object.freeze((Array.isArray(extractors.filecontent) ? extractors.filecontent : [])
46
+ .filter((pattern) => typeof pattern === 'string' && pattern.length > 0 && pattern.length <= 2_048)
47
+ .slice(0, 64)),
48
+ replacements: Object.freeze((Array.isArray(extractors.filecontentreplace) ? extractors.filecontentreplace : [])
49
+ .filter((pattern) => typeof pattern === 'string' && pattern.length > 0 && pattern.length <= 2_048)
50
+ .slice(0, 64)),
51
+ });
52
+ }));
53
+ const INLINE_REGEX_WORKER_SOURCE = String.raw `
54
+ const { parentPort, workerData } = require('node:worker_threads');
55
+ const versions = /^[0-9][0-9.a-z_-]{0,63}$/i;
56
+ parentPort.on('message', (job) => {
57
+ const control = new Int32Array(job.control);
58
+ const output = new Uint8Array(job.output);
59
+ const matches = [];
60
+ const add = (component, version) => {
61
+ const normalized = String(version).replace(/(?:\.|-)?min$/i, '').slice(0, 64);
62
+ if (versions.test(normalized) && matches.length < 64) matches.push([component, normalized]);
63
+ };
64
+ try {
65
+ for (const entry of workerData.catalogue) {
66
+ for (const pattern of entry.patterns) {
67
+ let expression;
68
+ try { expression = new RegExp(pattern, 'g'); } catch { continue; }
69
+ let match;
70
+ let count = 0;
71
+ while ((match = expression.exec(job.value)) && count < 4) {
72
+ if (typeof match[1] === 'string') add(entry.component, match[1]);
73
+ if (match[0] === '') expression.lastIndex += 1;
74
+ count += 1;
75
+ }
76
+ }
77
+ for (const descriptorValue of entry.replacements) {
78
+ const descriptor = /^\/(.*[^\\])\/([^/]+)\/$/.exec(descriptorValue);
79
+ if (!descriptor || !descriptor[1]) continue;
80
+ let expression;
81
+ try { expression = new RegExp(descriptor[1], 'g'); } catch { continue; }
82
+ let match;
83
+ let count = 0;
84
+ while ((match = expression.exec(job.value)) && count < 4) {
85
+ let replaced = match[0];
86
+ try { replaced = match[0].replace(new RegExp(descriptor[1]), descriptor[2]); } catch { break; }
87
+ add(entry.component, replaced);
88
+ if (match[0] === '') expression.lastIndex += 1;
89
+ count += 1;
90
+ }
91
+ }
92
+ }
93
+ const encoded = Buffer.from(JSON.stringify(matches));
94
+ if (encoded.length > output.length) Atomics.store(control, 0, -2);
95
+ else { output.set(encoded); Atomics.store(control, 0, encoded.length); }
96
+ } catch {
97
+ Atomics.store(control, 0, -1);
98
+ }
99
+ Atomics.notify(control, 0);
100
+ });
101
+ `;
102
+ let inlineRegexWorker = null;
32
103
  function record(value) {
33
104
  return value !== null && typeof value === 'object' && !Array.isArray(value)
34
105
  ? value
@@ -106,32 +177,6 @@ function matchPattern(pattern, value) {
106
177
  }
107
178
  return matches;
108
179
  }
109
- function matchReplacementPattern(pattern, value) {
110
- if (typeof pattern !== 'string' || pattern.length === 0 || pattern.length > 2_048)
111
- return [];
112
- const descriptor = /^\/(.*[^\\])\/([^/]+)\/$/.exec(pattern);
113
- const expressionSource = descriptor?.[1];
114
- const replacement = descriptor?.[2];
115
- if (!expressionSource || replacement === undefined)
116
- return [];
117
- let expression;
118
- try {
119
- expression = new RegExp(expressionSource, 'g');
120
- }
121
- catch {
122
- return [];
123
- }
124
- const matches = [];
125
- let match;
126
- while ((match = expression.exec(value)) && matches.length < MAX_MATCHES_PER_PATTERN) {
127
- const replaced = match[0].replace(new RegExp(expressionSource), replacement).replace(/(?:\.|-)?min$/i, '');
128
- if (VERSION_RE.test(replaced))
129
- matches.push(replaced);
130
- if (match[0] === '')
131
- expression.lastIndex += 1;
132
- }
133
- return matches;
134
- }
135
180
  function addDetected(detected, component, version, method) {
136
181
  const normalizedVersion = version.replace(/(?:\.|-)?min$/i, '').slice(0, MAX_VERSION_LENGTH);
137
182
  if (!COMPONENT_RE.test(component) || !VERSION_RE.test(normalizedVersion))
@@ -174,24 +219,87 @@ function scanFilename(detected, reference) {
174
219
  }
175
220
  }
176
221
  }
177
- function scanInlineContent(detected, content) {
222
+ function scanInlineSignatures(detected, value) {
223
+ if (!value)
224
+ return { timedOut: false, unavailable: false };
225
+ const controlBuffer = new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT);
226
+ const outputBuffer = new SharedArrayBuffer(MAX_INLINE_LIBRARY_WORKER_BYTES);
227
+ const control = new Int32Array(controlBuffer);
228
+ try {
229
+ if (!inlineRegexWorker) {
230
+ const createdWorker = new Worker(INLINE_REGEX_WORKER_SOURCE, {
231
+ eval: true,
232
+ workerData: { catalogue: INLINE_EXTRACTOR_CATALOGUE },
233
+ });
234
+ inlineRegexWorker = createdWorker;
235
+ createdWorker.unref();
236
+ createdWorker.once('exit', () => {
237
+ if (inlineRegexWorker === createdWorker)
238
+ inlineRegexWorker = null;
239
+ });
240
+ createdWorker.once('error', () => {
241
+ if (inlineRegexWorker === createdWorker)
242
+ inlineRegexWorker = null;
243
+ });
244
+ }
245
+ inlineRegexWorker.postMessage({ value, control: controlBuffer, output: outputBuffer });
246
+ }
247
+ catch {
248
+ void inlineRegexWorker?.terminate().catch(() => { });
249
+ inlineRegexWorker = null;
250
+ return { timedOut: false, unavailable: true };
251
+ }
252
+ const wait = Atomics.wait(control, 0, 0, MAX_INLINE_LIBRARY_SCAN_MS);
253
+ const length = Atomics.load(control, 0);
254
+ if (wait === 'timed-out') {
255
+ void inlineRegexWorker?.terminate().catch(() => { });
256
+ inlineRegexWorker = null;
257
+ return { timedOut: true, unavailable: false };
258
+ }
259
+ if (length < 1 || length > MAX_INLINE_LIBRARY_WORKER_BYTES) {
260
+ return { timedOut: false, unavailable: true };
261
+ }
262
+ try {
263
+ const parsed = JSON.parse(Buffer.from(new Uint8Array(outputBuffer, 0, length)).toString('utf8'));
264
+ if (!Array.isArray(parsed))
265
+ return { timedOut: false, unavailable: true };
266
+ for (const item of parsed.slice(0, MAX_LIBRARY_FINDINGS * 4)) {
267
+ if (!Array.isArray(item) || item.length !== 2)
268
+ continue;
269
+ const [component, version] = item;
270
+ if (typeof component === 'string' && typeof version === 'string') {
271
+ addDetected(detected, component, version, 'inline signature');
272
+ }
273
+ }
274
+ return { timedOut: false, unavailable: false };
275
+ }
276
+ catch {
277
+ return { timedOut: false, unavailable: true };
278
+ }
279
+ }
280
+ function scanInlineContent(detected, content, maximumSignatureCharacters) {
178
281
  const normalized = content.replace(/\r\n?|\n/g, '\n');
179
282
  const digest = createHash('sha1').update(normalized).digest('hex');
180
- scanExtractor(detected, 'filecontent', normalized, 'inline signature');
283
+ const retainedCharacters = Math.max(0, Math.min(MAX_INLINE_LIBRARY_SCAN_CHARS, maximumSignatureCharacters, normalized.length));
284
+ const headCharacters = Math.ceil(retainedCharacters / 2);
285
+ const tailCharacters = retainedCharacters - headCharacters;
286
+ const signatureContent = retainedCharacters === 0
287
+ ? ''
288
+ : normalized.length <= retainedCharacters
289
+ ? normalized
290
+ : `${normalized.slice(0, headCharacters)}${tailCharacters ? normalized.slice(-tailCharacters) : ''}`;
181
291
  for (const [componentName, rawComponent] of Object.entries(CATALOG_COMPONENTS)) {
182
- const component = record(rawComponent);
183
- const extractors = record(component.extractors);
184
- const replacementPatterns = Array.isArray(extractors.filecontentreplace) ? extractors.filecontentreplace : [];
185
- for (const pattern of replacementPatterns.slice(0, 64)) {
186
- for (const version of matchReplacementPattern(pattern, normalized)) {
187
- addDetected(detected, componentName, version, 'inline signature');
188
- }
189
- }
292
+ const extractors = record(record(rawComponent).extractors);
190
293
  const hashes = record(extractors.hashes);
191
294
  const version = hashes[digest];
192
295
  if (typeof version === 'string')
193
296
  addDetected(detected, componentName, version, 'inline hash');
194
297
  }
298
+ return {
299
+ charactersExamined: retainedCharacters,
300
+ truncated: retainedCharacters < normalized.length,
301
+ signatureContent,
302
+ };
195
303
  }
196
304
  function severity(value) {
197
305
  return typeof value === 'string' && Object.hasOwn(SEVERITY_WEIGHT, value)
@@ -243,6 +351,9 @@ function analyzeBrowserLibraries(input = {}) {
243
351
  const detected = new Map();
244
352
  let referencesExamined = 0;
245
353
  let inlineScriptsExamined = 0;
354
+ let inlineSignatureCharactersExamined = 0;
355
+ let inlineSignatureLimitReached = false;
356
+ const inlineSignatureSamples = [];
246
357
  for (const script of htmlAnalysis.scripts) {
247
358
  if (script.reference) {
248
359
  referencesExamined += 1;
@@ -251,9 +362,16 @@ function analyzeBrowserLibraries(input = {}) {
251
362
  }
252
363
  else if (script.inlineContent && script.mediaType !== 'application/ld+json') {
253
364
  inlineScriptsExamined += 1;
254
- scanInlineContent(detected, script.inlineContent);
365
+ const remaining = Math.max(0, MAX_INLINE_LIBRARY_SCAN_TOTAL_CHARS - inlineSignatureCharactersExamined);
366
+ const signatureScan = scanInlineContent(detected, script.inlineContent, remaining);
367
+ inlineSignatureCharactersExamined += signatureScan.charactersExamined;
368
+ inlineSignatureLimitReached ||= signatureScan.truncated;
369
+ if (signatureScan.signatureContent)
370
+ inlineSignatureSamples.push(signatureScan.signatureContent);
255
371
  }
256
372
  }
373
+ const inlineSignatureResult = scanInlineSignatures(detected, inlineSignatureSamples.join('\u0000'));
374
+ const inlineSignatureUnavailable = inlineSignatureResult.timedOut || inlineSignatureResult.unavailable;
257
375
  const allFindings = [...detected.values()]
258
376
  .map(findingFromDetection)
259
377
  .sort((left, right) => ((right.highestSeverity ? SEVERITY_WEIGHT[right.highestSeverity] : -1)
@@ -267,6 +385,8 @@ function analyzeBrowserLibraries(input = {}) {
267
385
  || htmlAnalysis.tagLimitReached
268
386
  || htmlAnalysis.scriptLimitReached
269
387
  || htmlAnalysis.inlineLimitReached
388
+ || inlineSignatureLimitReached
389
+ || inlineSignatureUnavailable
270
390
  || findingLimitReached;
271
391
  const limitations = [
272
392
  'Library versions are inferred from passive static signatures and may be absent, transformed, or misleading.',
@@ -284,6 +404,12 @@ function analyzeBrowserLibraries(input = {}) {
284
404
  limitations.push(`Only the first ${MAX_SCRIPT_ELEMENTS} script elements were evaluated.`);
285
405
  if (htmlAnalysis.inlineLimitReached)
286
406
  limitations.push(`Inline script evaluation reached its ${MAX_INLINE_SCRIPT_TOTAL_CHARS}-character cumulative boundary.`);
407
+ if (inlineSignatureLimitReached)
408
+ limitations.push(`Passive library signature matching evaluated a deterministic ${MAX_INLINE_LIBRARY_SCAN_TOTAL_CHARS}-character cumulative sample across inline scripts; full-content hashes still used the complete bounded script content.`);
409
+ if (inlineSignatureResult.timedOut)
410
+ limitations.push(`Passive library signature matching exceeded its ${MAX_INLINE_LIBRARY_SCAN_MS} ms isolated-worker deadline; hash evidence was still evaluated.`);
411
+ if (inlineSignatureResult.unavailable)
412
+ limitations.push('Passive library signature matching was unavailable in its isolated worker; hash evidence was still evaluated.');
287
413
  if (findingLimitReached)
288
414
  limitations.push(`Only the first ${MAX_LIBRARY_FINDINGS} library findings were retained.`);
289
415
  return {
@@ -311,6 +437,9 @@ function analyzeBrowserLibraries(input = {}) {
311
437
  referencesExamined,
312
438
  inlineScriptsExamined,
313
439
  inlineCharactersExamined: htmlAnalysis.inlineCharactersExamined,
440
+ inlineSignatureCharactersExamined,
441
+ inlineSignatureTimedOut: inlineSignatureResult.timedOut,
442
+ inlineSignatureUnavailable: inlineSignatureResult.unavailable,
314
443
  catalogComponents: Object.keys(CATALOG_COMPONENTS).length,
315
444
  findings: allFindings.length,
316
445
  advisoryMatches: allFindings.reduce((sum, finding) => sum + finding.advisoryCount, 0),
@@ -320,4 +449,4 @@ function analyzeBrowserLibraries(input = {}) {
320
449
  findings: allFindings.slice(0, MAX_LIBRARY_FINDINGS),
321
450
  };
322
451
  }
323
- export { BROWSER_LIBRARY_PROFILE_VERSION, MAX_INLINE_SCRIPT_CHARS, MAX_INLINE_SCRIPT_TOTAL_CHARS, MAX_LIBRARY_FINDINGS, MAX_LIBRARY_HTML_CHARS, MAX_SCRIPT_ELEMENTS, analyzeBrowserLibraries, };
452
+ export { BROWSER_LIBRARY_PROFILE_VERSION, MAX_INLINE_SCRIPT_CHARS, MAX_INLINE_SCRIPT_TOTAL_CHARS, MAX_INLINE_LIBRARY_SCAN_CHARS, MAX_INLINE_LIBRARY_SCAN_TOTAL_CHARS, MAX_LIBRARY_FINDINGS, MAX_LIBRARY_HTML_CHARS, MAX_SCRIPT_ELEMENTS, analyzeBrowserLibraries, };
@@ -0,0 +1,183 @@
1
+ import { exactKeys, requireBoundedString, requireDomainName, requireIsoTimestamp, requireRecord, } from "./bounded-contract-normalizers.mjs";
2
+ import { normaliseRdata } from "./zone-intent-review.mjs";
3
+ export const DNS_CONVERGENCE_INPUT_SCHEMA = 'whoisleuth.dns-convergence.input';
4
+ export const DNS_CONVERGENCE_REVIEW_SCHEMA = 'whoisleuth.dns-convergence.review';
5
+ export const DNS_CONVERGENCE_REVIEW_VERSION = 1;
6
+ export const MAX_DNS_CONVERGENCE_SNAPSHOTS = 100;
7
+ export const MAX_DNS_CONVERGENCE_RECORDS = 2_000;
8
+ const RECORD_TYPES = new Set(['A', 'AAAA', 'CAA', 'CNAME', 'MX', 'NS', 'SOA', 'SRV', 'SVCB', 'HTTPS', 'TLSA', 'TXT']);
9
+ const ROOT_KEYS = new Set(['schema', 'version', 'domain', 'expected', 'snapshots']);
10
+ const EXPECTED_KEYS = new Set(['owner', 'type', 'values']);
11
+ const SNAPSHOT_KEYS = new Set(['observer', 'source', 'observedAt', 'state', 'records']);
12
+ const RECORD_KEYS = new Set(['owner', 'type', 'value', 'ttl']);
13
+ function type(value, label) {
14
+ const candidate = requireBoundedString(value, label, 16).toUpperCase();
15
+ if (!RECORD_TYPES.has(candidate))
16
+ throw new TypeError(`${label} is unsupported.`);
17
+ return candidate;
18
+ }
19
+ function owner(value, apex, label) {
20
+ const candidate = requireBoundedString(value, label, 253).toLowerCase().replace(/\.$/u, '');
21
+ if (candidate === '@')
22
+ return apex;
23
+ if (candidate.length > 253 || candidate.split('.').some((part) => !/^[a-z0-9_](?:[a-z0-9_-]{0,61}[a-z0-9_])?$/u.test(part))) {
24
+ throw new TypeError(`${label} must be a valid DNS owner name.`);
25
+ }
26
+ return candidate;
27
+ }
28
+ function state(value, label) {
29
+ if (value !== 'observed' && value !== 'partial' && value !== 'unavailable')
30
+ throw new TypeError(`${label} is unsupported.`);
31
+ return value;
32
+ }
33
+ function ttl(value, label) {
34
+ if (value === null || value === undefined)
35
+ return null;
36
+ if (!Number.isSafeInteger(value) || Number(value) < 0 || Number(value) > 0x7fff_ffff)
37
+ throw new TypeError(`${label} is outside its supported range.`);
38
+ return Number(value);
39
+ }
40
+ function normaliseValue(recordType, value) {
41
+ if (recordType !== 'SOA')
42
+ return normaliseRdata(recordType, value, null);
43
+ const fields = value.trim().split(/\s+/u);
44
+ if (fields.length >= 2) {
45
+ fields[0] = fields[0]?.toLowerCase().replace(/\.$/u, '') ?? '';
46
+ fields[1] = fields[1]?.toLowerCase().replace(/\.$/u, '') ?? '';
47
+ }
48
+ return { value: fields.join(' '), valueTreatment: 'normalised' };
49
+ }
50
+ function record(value, apex, label) {
51
+ const input = requireRecord(value, label);
52
+ exactKeys(input, RECORD_KEYS, label);
53
+ const recordType = type(input.type, `${label}.type`);
54
+ const normalised = normaliseValue(recordType, requireBoundedString(input.value, `${label}.value`, 16_384));
55
+ return Object.freeze({
56
+ owner: owner(input.owner, apex, `${label}.owner`),
57
+ type: recordType,
58
+ value: normalised.value,
59
+ valueTreatment: normalised.valueTreatment,
60
+ ttl: ttl(input.ttl, `${label}.ttl`),
61
+ });
62
+ }
63
+ function expectedRows(value, apex) {
64
+ if (value === null || value === undefined)
65
+ return Object.freeze([]);
66
+ if (!Array.isArray(value) || value.length > 100)
67
+ throw new TypeError('expected must contain no more than 100 rows.');
68
+ const keys = new Set();
69
+ return Object.freeze(value.map((raw, index) => {
70
+ const input = requireRecord(raw, `expected[${index}]`);
71
+ exactKeys(input, EXPECTED_KEYS, `expected[${index}]`);
72
+ const recordType = type(input.type, `expected[${index}].type`);
73
+ const recordOwner = owner(input.owner, apex, `expected[${index}].owner`);
74
+ const key = `${recordOwner}\u0000${recordType}`;
75
+ if (keys.has(key))
76
+ throw new TypeError(`expected[${index}] duplicates an owner and type.`);
77
+ keys.add(key);
78
+ if (!Array.isArray(input.values) || input.values.length > 100)
79
+ throw new TypeError(`expected[${index}].values must contain no more than 100 entries.`);
80
+ const values = [...new Set(input.values.map((entry, valueIndex) => normaliseValue(recordType, requireBoundedString(entry, `expected[${index}].values[${valueIndex}]`, 16_384)).value))].sort();
81
+ return Object.freeze({ owner: recordOwner, type: recordType, values: Object.freeze(values) });
82
+ }));
83
+ }
84
+ export function reviewDnsConvergence(inputRaw, generatedAtValue = new Date().toISOString()) {
85
+ const input = requireRecord(inputRaw, 'DNS convergence input');
86
+ if (input.schema !== DNS_CONVERGENCE_INPUT_SCHEMA || input.version !== 1) {
87
+ throw new TypeError(`DNS convergence input must use ${DNS_CONVERGENCE_INPUT_SCHEMA} version 1.`);
88
+ }
89
+ exactKeys(input, ROOT_KEYS, 'DNS convergence input');
90
+ const apex = requireDomainName(input.domain, 'domain');
91
+ const expected = expectedRows(input.expected, apex);
92
+ if (!Array.isArray(input.snapshots) || input.snapshots.length < 2 || input.snapshots.length > MAX_DNS_CONVERGENCE_SNAPSHOTS) {
93
+ throw new TypeError(`snapshots must contain between 2 and ${MAX_DNS_CONVERGENCE_SNAPSHOTS} entries.`);
94
+ }
95
+ let recordCount = 0;
96
+ const snapshots = input.snapshots.map((raw, index) => {
97
+ const item = requireRecord(raw, `snapshots[${index}]`);
98
+ exactKeys(item, SNAPSHOT_KEYS, `snapshots[${index}]`);
99
+ if (!Array.isArray(item.records))
100
+ throw new TypeError(`snapshots[${index}].records must be an array.`);
101
+ recordCount += item.records.length;
102
+ if (recordCount > MAX_DNS_CONVERGENCE_RECORDS)
103
+ throw new TypeError(`DNS convergence input is limited to ${MAX_DNS_CONVERGENCE_RECORDS} records.`);
104
+ const observationState = state(item.state, `snapshots[${index}].state`);
105
+ if (observationState === 'unavailable' && item.records.length)
106
+ throw new TypeError(`snapshots[${index}] cannot contain records when unavailable.`);
107
+ return Object.freeze({
108
+ observer: requireBoundedString(item.observer, `snapshots[${index}].observer`, 120),
109
+ source: requireBoundedString(item.source, `snapshots[${index}].source`, 240),
110
+ observedAt: requireIsoTimestamp(item.observedAt, `snapshots[${index}].observedAt`),
111
+ state: observationState,
112
+ records: Object.freeze(item.records.map((entry, recordIndex) => record(entry, apex, `snapshots[${index}].records[${recordIndex}]`))),
113
+ });
114
+ }).sort((left, right) => left.observedAt.localeCompare(right.observedAt) || left.observer.localeCompare(right.observer));
115
+ const observers = [...new Set(snapshots.map((item) => item.observer))];
116
+ if (observers.length < 2)
117
+ throw new TypeError('DNS convergence review requires at least two distinct observer labels.');
118
+ const latestByObserver = observers.map((observer) => snapshots.filter((item) => item.observer === observer).at(-1));
119
+ const keys = [...new Set([
120
+ ...expected.map((item) => `${item.owner}\u0000${item.type}`),
121
+ ...snapshots.flatMap((item) => item.records.map((entry) => `${entry.owner}\u0000${entry.type}`)),
122
+ ])].sort();
123
+ const rows = keys.map((key) => {
124
+ const [recordOwner = '', recordType = ''] = key.split('\u0000');
125
+ const expectedRow = expected.find((item) => item.owner === recordOwner && item.type === recordType);
126
+ const observations = latestByObserver.map((snapshot) => {
127
+ const matching = snapshot.records.filter((entry) => entry.owner === recordOwner && entry.type === recordType);
128
+ const values = [...new Set(matching.map((entry) => entry.value))].sort();
129
+ const ttls = matching.flatMap((entry) => entry.ttl === null ? [] : [entry.ttl]);
130
+ const cacheUntil = ttls.length
131
+ ? new Date(Date.parse(snapshot.observedAt) + Math.max(...ttls) * 1_000).toISOString()
132
+ : null;
133
+ return Object.freeze({
134
+ observer: snapshot.observer,
135
+ source: snapshot.source,
136
+ observedAt: snapshot.observedAt,
137
+ state: snapshot.state,
138
+ values: Object.freeze(values),
139
+ cacheUntil,
140
+ });
141
+ });
142
+ const complete = observations.filter((item) => item.state === 'observed');
143
+ const incomplete = observations.some((item) => item.state !== 'observed');
144
+ const signatures = new Set(complete.map((item) => JSON.stringify(item.values)));
145
+ const aligned = complete.length >= 2 && signatures.size === 1;
146
+ const matchesExpected = expectedRow
147
+ ? aligned && JSON.stringify(complete[0]?.values ?? []) === JSON.stringify(expectedRow.values)
148
+ : null;
149
+ const rowState = incomplete ? 'incomplete'
150
+ : complete.length < 2 ? 'insufficient'
151
+ : !aligned ? 'divergent'
152
+ : matchesExpected === false ? 'unexpected'
153
+ : 'converged';
154
+ return Object.freeze({
155
+ owner: recordOwner,
156
+ type: recordType,
157
+ state: rowState,
158
+ expectedValues: expectedRow?.values ?? null,
159
+ observations: Object.freeze(observations),
160
+ });
161
+ });
162
+ const reasons = Object.freeze([
163
+ ...rows.filter((row) => row.state !== 'converged').map((row) => `${row.owner} ${row.type} is ${row.state}.`),
164
+ ...latestByObserver.filter((item) => item.state !== 'observed').map((item) => `${item.observer} latest evidence is ${item.state}.`),
165
+ ].slice(0, 200));
166
+ return Object.freeze({
167
+ schema: DNS_CONVERGENCE_REVIEW_SCHEMA,
168
+ version: DNS_CONVERGENCE_REVIEW_VERSION,
169
+ generatedAt: requireIsoTimestamp(generatedAtValue, 'generatedAt'),
170
+ domain: apex,
171
+ state: reasons.length ? 'review' : 'converged',
172
+ observers: Object.freeze(observers),
173
+ snapshots: Object.freeze(snapshots),
174
+ rows: Object.freeze(rows),
175
+ gate: Object.freeze({ pass: reasons.length === 0, reasons }),
176
+ limitations: Object.freeze([
177
+ 'This local workbench compares only supplied observations and makes no DNS request.',
178
+ 'Resolver differences can reflect caches, split-horizon policy, interception, collection timing, or propagation; they do not establish which value is authoritative.',
179
+ 'Cache-until times are simple observation-time plus supplied TTL projections and do not reveal actual cache age, refresh behaviour, or resolver policy.',
180
+ 'Partial and unavailable snapshots remain incomplete and are never interpreted as record absence.',
181
+ ]),
182
+ });
183
+ }