@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
@@ -11,6 +11,7 @@ const MAX_AUTHORITIES = 4;
11
11
  const MAX_AUTHORITY_ADDRESSES = 2;
12
12
  const MAX_NAMESERVERS = 16;
13
13
  const MAX_ERROR_LENGTH = 180;
14
+ const MAX_AUTHORITY_RECORD_VALUES = 16;
14
15
  const DNS_DELEGATION_TIMEOUT_MS = 2200;
15
16
  const LAME_CODES = new Set(['ENOTAUTH', 'EREFUSED']);
16
17
  const MAX_SOA_VALUE = 0xffff_ffff;
@@ -47,6 +48,12 @@ function publicAddress(value) {
47
48
  const normalized = value.toLowerCase();
48
49
  return net.isIP(normalized) && !isPrivateAddress(normalized) ? normalized : null;
49
50
  }
51
+ function observedAddress(value) {
52
+ if (typeof value !== 'string' || value.length > 80 || /[\u0000-\u0020\u007f%]/u.test(value))
53
+ return null;
54
+ const normalised = value.toLowerCase();
55
+ return net.isIP(normalised) ? normalised : null;
56
+ }
50
57
  function errorCode(error) {
51
58
  const value = record(error).code;
52
59
  return typeof value === 'string' ? value.slice(0, 40) : '';
@@ -125,6 +132,127 @@ async function defaultAuthorityQuery(input) {
125
132
  error: null,
126
133
  };
127
134
  }
135
+ function normaliseAuthorityValueSet(type, value) {
136
+ const input = Array.isArray(value) ? value : [];
137
+ const rows = input.slice(0, 256);
138
+ let rejected = input.length - rows.length;
139
+ const values = rows.flatMap((item) => {
140
+ if (type === 'A' || type === 'AAAA') {
141
+ // Returned record data is evidence, not a transport destination. Retain
142
+ // syntactically valid private and reserved addresses while continuing to
143
+ // require public addresses for the direct nameserver connection itself.
144
+ const address = observedAddress(item);
145
+ if (address && net.isIP(address) === (type === 'A' ? 4 : 6))
146
+ return [address];
147
+ rejected += 1;
148
+ return [];
149
+ }
150
+ const source = record(item);
151
+ if (type === 'MX') {
152
+ const exchange = hostname(source.exchange);
153
+ const priority = Number(source.priority);
154
+ if (exchange && Number.isSafeInteger(priority) && priority >= 0 && priority <= 65_535)
155
+ return [`${priority} ${exchange}`];
156
+ rejected += 1;
157
+ return [];
158
+ }
159
+ const critical = Number(source.critical);
160
+ const properties = ['issue', 'issuewild', 'iodef'].flatMap((key) => {
161
+ const candidate = boundedText(source[key], 500);
162
+ return candidate ? [`${Number.isSafeInteger(critical) && critical >= 0 && critical <= 255 ? critical : 0} ${key} ${candidate}`] : [];
163
+ });
164
+ if (!properties.length)
165
+ rejected += 1;
166
+ return properties;
167
+ });
168
+ const unique = [...new Set(values)].sort();
169
+ const overflow = Math.max(0, unique.length - MAX_AUTHORITY_RECORD_VALUES);
170
+ return {
171
+ values: unique.slice(0, MAX_AUTHORITY_RECORD_VALUES),
172
+ truncated: overflow > 0 || input.length > rows.length,
173
+ discarded: rejected + overflow,
174
+ };
175
+ }
176
+ function normaliseRetainedAuthorityValues(value) {
177
+ const input = Array.isArray(value) ? value : [];
178
+ const rows = input.slice(0, 256);
179
+ const valid = rows.map((item) => boundedText(item, 500)).filter((item) => item !== null);
180
+ const unique = [...new Set(valid)].sort();
181
+ const overflow = Math.max(0, unique.length - MAX_AUTHORITY_RECORD_VALUES);
182
+ return {
183
+ values: unique.slice(0, MAX_AUTHORITY_RECORD_VALUES),
184
+ truncated: overflow > 0 || input.length > rows.length,
185
+ discarded: input.length - rows.length + (rows.length - valid.length) + overflow,
186
+ };
187
+ }
188
+ function normaliseAuthorityValues(type, value) {
189
+ return normaliseAuthorityValueSet(type, value).values;
190
+ }
191
+ async function defaultAuthorityRecordQuery(input) {
192
+ const resolver = new dns.Resolver({
193
+ timeout: Math.max(250, Math.min(input.timeoutMs, DNS_DELEGATION_TIMEOUT_MS)),
194
+ tries: 1,
195
+ });
196
+ resolver.setServers([net.isIP(input.address) === 6 ? `[${input.address}]:53` : input.address]);
197
+ const queries = [
198
+ ['A', resolver.resolve4(input.domain)],
199
+ ['AAAA', resolver.resolve6(input.domain)],
200
+ ['CAA', resolver.resolveCaa(input.domain)],
201
+ ['MX', resolver.resolveMx(input.domain)],
202
+ ];
203
+ return Promise.all(queries.map(async ([type, promise]) => {
204
+ try {
205
+ const response = await withTimeout(promise, input.timeoutMs);
206
+ const normalised = normaliseAuthorityValueSet(type, response);
207
+ return {
208
+ type,
209
+ state: normalised.truncated || normalised.discarded > 0
210
+ ? 'partial'
211
+ : normalised.values.length ? 'success' : 'not_found',
212
+ ...normalised,
213
+ error: normalised.discarded > 0 && !normalised.values.length ? 'No usable record values were retained.' : null,
214
+ };
215
+ }
216
+ catch (error) {
217
+ const code = errorCode(error);
218
+ const missing = code === 'ENODATA' || code === 'ENOTFOUND';
219
+ return {
220
+ type,
221
+ state: missing ? 'not_found' : 'error',
222
+ values: [],
223
+ error: missing ? null : errorDetail(error),
224
+ truncated: false,
225
+ discarded: 0,
226
+ };
227
+ }
228
+ }));
229
+ }
230
+ function authorityRecordMatrix(authorities) {
231
+ const types = ['A', 'AAAA', 'CAA', 'MX'];
232
+ return types.map((type) => {
233
+ const observations = authorities.map((authority) => {
234
+ const result = authority.recordSets.find((item) => item.type === type);
235
+ return {
236
+ nameserver: authority.nameserver,
237
+ state: result?.state ?? 'not_collected',
238
+ values: result?.values ?? [],
239
+ error: result?.error ?? null,
240
+ truncated: result?.truncated === true,
241
+ discarded: typeof result?.discarded === 'number' ? result.discarded : 0,
242
+ };
243
+ });
244
+ const complete = observations.filter((item) => item.state === 'success' || item.state === 'not_found');
245
+ const signatures = new Set(complete.map((item) => JSON.stringify(item.values)));
246
+ return {
247
+ type,
248
+ state: observations.some((item) => item.state === 'error' || item.state === 'partial' || item.truncated || item.discarded > 0) ? 'partial'
249
+ : complete.length < 2 ? 'insufficient'
250
+ : observations.some((item) => item.state === 'not_collected') ? 'partial'
251
+ : signatures.size === 1 ? 'aligned' : 'different',
252
+ observations,
253
+ };
254
+ });
255
+ }
128
256
  function registryProjection(value) {
129
257
  const registry = record(value);
130
258
  const nameserverDetails = (Array.isArray(registry.nameserverDetails)
@@ -185,6 +313,7 @@ function skippedDnsDelegationHealth(detail = 'Authoritative delegation checks we
185
313
  truncated: false,
186
314
  },
187
315
  authorities: [],
316
+ recordMatrix: [],
188
317
  findings: [],
189
318
  };
190
319
  }
@@ -204,6 +333,8 @@ async function collectDnsDelegationHealth(domainValue, parentQueryValue, options
204
333
  const resolve4 = options.resolve4 || dns.resolve4;
205
334
  const resolve6 = options.resolve6 || dns.resolve6;
206
335
  const queryAuthority = options.queryAuthority || defaultAuthorityQuery;
336
+ const queryAuthorityRecords = options.queryAuthorityRecords
337
+ ?? (options.queryAuthority ? null : defaultAuthorityRecordQuery);
207
338
  const timeoutMs = Math.max(250, Math.min(5000, Number(options.timeoutMs) || DNS_DELEGATION_TIMEOUT_MS));
208
339
  const now = options.now || Date.now;
209
340
  const started = now();
@@ -256,6 +387,47 @@ async function collectDnsDelegationHealth(domainValue, parentQueryValue, options
256
387
  const successful = attempts.find((attempt) => attempt.state === 'success');
257
388
  const partial = attempts.find((attempt) => attempt.state === 'partial');
258
389
  const retained = successful ?? partial;
390
+ let recordSets = [];
391
+ const recordAddress = retained?.address ?? addresses[0];
392
+ if (queryAuthorityRecords && recordAddress) {
393
+ try {
394
+ recordSets = (await withTimeout(queryAuthorityRecords({ domain, nameserver, address: recordAddress, timeoutMs }), timeoutMs))
395
+ .filter((item) => ['A', 'AAAA', 'CAA', 'MX'].includes(item.type))
396
+ .slice(0, 4)
397
+ .map((item) => {
398
+ const normalised = normaliseRetainedAuthorityValues(item.values);
399
+ const suppliedState = ['success', 'not_found', 'partial', 'error'].includes(item.state)
400
+ ? item.state : 'error';
401
+ const discarded = normalised.discarded + (typeof item.discarded === 'number' && Number.isSafeInteger(item.discarded) && item.discarded > 0
402
+ ? Math.min(item.discarded, 10_000) : 0);
403
+ const truncated = item.truncated === true || normalised.truncated;
404
+ const partial = suppliedState === 'partial'
405
+ || truncated
406
+ || discarded > 0
407
+ || (suppliedState === 'success' && normalised.values.length === 0)
408
+ || (suppliedState === 'not_found' && normalised.values.length > 0);
409
+ return {
410
+ type: item.type,
411
+ state: suppliedState === 'error' ? 'error' : partial ? 'partial' : suppliedState,
412
+ values: normalised.values,
413
+ error: boundedText(item.error, MAX_ERROR_LENGTH)
414
+ ?? (partial && !normalised.values.length ? 'No usable record values were retained.' : null),
415
+ truncated,
416
+ discarded,
417
+ };
418
+ });
419
+ }
420
+ catch (error) {
421
+ recordSets = ['A', 'AAAA', 'CAA', 'MX'].map((type) => ({
422
+ type: type,
423
+ state: 'error',
424
+ values: [],
425
+ error: errorDetail(error),
426
+ truncated: false,
427
+ discarded: 0,
428
+ }));
429
+ }
430
+ }
259
431
  return {
260
432
  nameserver,
261
433
  addressSource: glue.length ? 'registry_glue' : 'recursive_address',
@@ -270,6 +442,7 @@ async function collectDnsDelegationHealth(domainValue, parentQueryValue, options
270
442
  nameservers: retained?.nameservers ?? [],
271
443
  soaPrimary: retained?.soaPrimary ?? null,
272
444
  soa: retained?.soa ?? null,
445
+ recordSets,
273
446
  attempts,
274
447
  };
275
448
  }));
@@ -295,6 +468,11 @@ async function collectDnsDelegationHealth(domainValue, parentQueryValue, options
295
468
  const inBailiwick = candidates.filter((nameserver) => (nameserver === domain || nameserver.endsWith(`.${domain}`)));
296
469
  const missingGlue = inBailiwick.filter((nameserver) => (!(registry.nameserverDetails.find((item) => item.name === nameserver)?.addresses.length)));
297
470
  const dnssecMismatch = (registry.delegationSigned === true && registry.dsRecordCount === 0) || (registry.delegationSigned === false && registry.dsRecordCount > 0);
471
+ const recordMatrix = authorityRecordMatrix(authorities);
472
+ const recordCollectionAttempted = authorities.some((authority) => authority.recordSets.length > 0);
473
+ const recordDifferences = recordMatrix.filter((row) => row.state === 'different');
474
+ const recordPartial = recordMatrix.filter((row) => row.state === 'partial');
475
+ const recordInsufficient = recordMatrix.filter((row) => row.state === 'insufficient');
298
476
  const findings = [
299
477
  finding('parent_registry_ns', 'Parent and registry nameservers', !parentNameservers.length || !registry.nameservers.length
300
478
  ? 'unknown'
@@ -342,11 +520,20 @@ async function collectDnsDelegationHealth(domainValue, parentQueryValue, options
342
520
  : registry.delegationSigned
343
521
  ? `${registry.dsRecordCount} registry DS record${registry.dsRecordCount === 1 ? '' : 's'} observed`
344
522
  : 'Registry publication marks the delegation unsigned', `delegationSigned: ${registry.delegationSigned === null ? 'unavailable' : registry.delegationSigned}. DS records: ${registry.dsRecordCount}.`, 'Confirm the registry DS set matches the intended zone-signing keys before enabling, rotating, or removing DNSSEC.'),
523
+ ...(authorities.some((authority) => authority.recordSets.length) ? [finding('authority_record_consistency', 'Authoritative record consistency', recordDifferences.length ? 'warning'
524
+ : recordPartial.length || recordInsufficient.length ? 'unknown' : 'healthy', recordDifferences.length
525
+ ? `Direct authority answers differ for ${recordDifferences.map((row) => row.type).join(', ')}`
526
+ : recordPartial.length
527
+ ? 'Extended authority record comparison is incomplete'
528
+ : recordInsufficient.length
529
+ ? 'Fewer than two complete authority observations were available for one or more record types'
530
+ : 'Observed A, AAAA, CAA, and MX answers agree across selected authorities', recordMatrix.map((row) => `${row.type}: ${row.state}`).join(' · '), 'Review propagation timing and the intended zone values before relying on a DNS change.')] : []),
345
531
  ];
346
532
  const collectionIncomplete = parentStatus === 'error'
347
533
  || parentQuery.truncated === true
348
534
  || registry.truncated
349
- || authorities.some((authority) => authority.state !== 'success');
535
+ || authorities.some((authority) => authority.state !== 'success')
536
+ || (recordCollectionAttempted && (recordPartial.length > 0 || recordInsufficient.length > 0));
350
537
  const status = !candidates.length && parentStatus === 'error'
351
538
  ? 'error'
352
539
  : collectionIncomplete ? 'partial' : 'success';
@@ -360,10 +547,13 @@ async function collectDnsDelegationHealth(domainValue, parentQueryValue, options
360
547
  source: 'dns_delegation',
361
548
  durationMs: Math.max(0, now() - started),
362
549
  complete: !collectionIncomplete,
363
- truncated: parentQuery.truncated === true || registry.truncated,
550
+ truncated: parentQuery.truncated === true
551
+ || registry.truncated
552
+ || (recordCollectionAttempted && recordMatrix.some((row) => row.observations.some((item) => item.truncated))),
364
553
  limitations: [
365
554
  'The parent nameserver set is a point-in-time recursive resolver view; registry nameservers and glue come from the separately attributed RDAP publication.',
366
555
  'Direct queries use at most four selected nameservers and two validated public addresses per nameserver.',
556
+ 'Extended A, AAAA, CAA, and MX comparison uses only one selected validated public address per nameserver and caps each record set at sixteen values.',
367
557
  'A direct answer does not prove global reachability, and a failed query is not evidence that the record is absent.',
368
558
  'DNS health does not decide registration availability, ownership, control, intent, safety, or maliciousness.',
369
559
  'The DNSSEC check compares bounded registry publication fields; it does not validate the full cryptographic chain.',
@@ -375,6 +565,8 @@ async function collectDnsDelegationHealth(domainValue, parentQueryValue, options
375
565
  partialAuthorityCount: partialAuthorities.length,
376
566
  lameAuthorityCount: lameAuthorities.length,
377
567
  unreachableAuthorityCount: unreachableAuthorities.length,
568
+ truncatedAuthorityRecordSetCount: recordMatrix.reduce((count, row) => (count + row.observations.filter((item) => item.truncated).length), 0),
569
+ discardedAuthorityRecordValueCount: recordMatrix.reduce((count, row) => (count + row.observations.reduce((sum, item) => sum + item.discarded, 0)), 0),
378
570
  },
379
571
  }),
380
572
  detail: status === 'success'
@@ -389,7 +581,8 @@ async function collectDnsDelegationHealth(domainValue, parentQueryValue, options
389
581
  },
390
582
  registry,
391
583
  authorities,
584
+ recordMatrix,
392
585
  findings,
393
586
  };
394
587
  }
395
- export { DNS_DELEGATION_HEALTH_VERSION, DNS_DELEGATION_TIMEOUT_MS, MAX_AUTHORITIES, MAX_AUTHORITY_ADDRESSES, collectDnsDelegationHealth, skippedDnsDelegationHealth, };
588
+ export { DNS_DELEGATION_HEALTH_VERSION, DNS_DELEGATION_TIMEOUT_MS, MAX_AUTHORITIES, MAX_AUTHORITY_ADDRESSES, collectDnsDelegationHealth, normaliseAuthorityValues, skippedDnsDelegationHealth, };
@@ -0,0 +1,133 @@
1
+ import { exactKeys, requireBoundedString, requireDomainName, requireIsoTimestamp, requireRecord, } from "./bounded-contract-normalizers.mjs";
2
+ import { DOMAIN_ASSURANCE_INPUT_SCHEMA, buildDomainAssurance, } from "./domain-assurance.mjs";
3
+ import { DOMAIN_CHANGE_INPUT_SCHEMA, reviewDomainChange, } from "./domain-change-review.mjs";
4
+ import { sha256ArtifactDigest } from "../frontend/src/lib/analysis/artifact-integrity.js";
5
+ export const DOMAIN_CHANGE_PACKET_INPUT_SCHEMA = 'whoisleuth.domain-change-packet.input';
6
+ export const DOMAIN_CHANGE_PACKET_SCHEMA = 'whoisleuth.domain-change-packet';
7
+ export const DOMAIN_CHANGE_PACKET_VERSION = 1;
8
+ export const MAX_DOMAIN_CHANGE_PACKET_INPUT_BYTES = 6 * 1024 * 1024;
9
+ const ROOT_KEYS = new Set([
10
+ 'schema',
11
+ 'version',
12
+ 'domain',
13
+ 'reference',
14
+ 'preChange',
15
+ 'postChange',
16
+ 'assurance',
17
+ ]);
18
+ function record(value, label) {
19
+ return requireRecord(value, label);
20
+ }
21
+ function domainFromInput(value, label) {
22
+ const input = record(value, label);
23
+ if (input.schema !== DOMAIN_CHANGE_INPUT_SCHEMA) {
24
+ throw new TypeError(`${label} must use ${DOMAIN_CHANGE_INPUT_SCHEMA}.`);
25
+ }
26
+ return requireDomainName(input.domain, `${label}.domain`);
27
+ }
28
+ function assuranceDomain(value) {
29
+ const input = record(value, 'assurance');
30
+ if (input.schema !== DOMAIN_ASSURANCE_INPUT_SCHEMA || input.kind !== 'planned-change') {
31
+ throw new TypeError('assurance must contain a planned-change domain assurance input.');
32
+ }
33
+ return requireDomainName(input.domain, 'assurance.domain');
34
+ }
35
+ function changeSummary(before, after) {
36
+ const rows = (review) => new Map(review.authoritativeRecordMatrix.map((row) => [`${row.owner}\u0000${row.type}`, row]));
37
+ const beforeRows = rows(before);
38
+ const afterRows = rows(after);
39
+ const keys = [...new Set([...beforeRows.keys(), ...afterRows.keys()])].sort();
40
+ return Object.freeze(keys.slice(0, 500).flatMap((key) => {
41
+ const left = beforeRows.get(key);
42
+ const right = afterRows.get(key);
43
+ const leftValues = left?.observations.flatMap((item) => item.values) ?? [];
44
+ const rightValues = right?.observations.flatMap((item) => item.values) ?? [];
45
+ const beforeValues = [...new Set(leftValues)].sort();
46
+ const afterValues = [...new Set(rightValues)].sort();
47
+ if (JSON.stringify(beforeValues) === JSON.stringify(afterValues))
48
+ return [];
49
+ const [owner, type] = key.split('\u0000');
50
+ return [Object.freeze({
51
+ owner: owner ?? '',
52
+ type: type ?? '',
53
+ beforeValues: Object.freeze(beforeValues),
54
+ afterValues: Object.freeze(afterValues),
55
+ })];
56
+ }));
57
+ }
58
+ export async function buildDomainChangePacket(inputRaw, generatedAtValue = new Date().toISOString()) {
59
+ const input = record(inputRaw, 'Domain change packet input');
60
+ if (input.schema !== DOMAIN_CHANGE_PACKET_INPUT_SCHEMA || input.version !== 1) {
61
+ throw new TypeError(`Domain change packet input must use ${DOMAIN_CHANGE_PACKET_INPUT_SCHEMA} version 1.`);
62
+ }
63
+ exactKeys(input, ROOT_KEYS, 'Domain change packet input');
64
+ const domain = requireDomainName(input.domain, 'domain');
65
+ const domains = [
66
+ domainFromInput(input.preChange, 'preChange'),
67
+ domainFromInput(input.postChange, 'postChange'),
68
+ assuranceDomain(input.assurance),
69
+ ];
70
+ if (domains.some((candidate) => candidate !== domain)) {
71
+ throw new TypeError('The packet, change reviews, and assurance plan must refer to the same domain.');
72
+ }
73
+ const generatedAt = requireIsoTimestamp(generatedAtValue, 'generatedAt');
74
+ const preChange = reviewDomainChange(input.preChange, generatedAt);
75
+ const postChange = reviewDomainChange(input.postChange, generatedAt);
76
+ const assurance = buildDomainAssurance(input.assurance, generatedAt);
77
+ const assuranceReasons = assurance.result.review.reasons;
78
+ const gateReasons = Object.freeze([
79
+ ...preChange.gate.reasons.map((reason) => `Pre-change evidence: ${reason}`),
80
+ ...postChange.gate.reasons.map((reason) => `Post-change evidence: ${reason}`),
81
+ ...assuranceReasons.map((reason) => `Change plan: ${reason}`),
82
+ ].slice(0, 100));
83
+ const unsigned = Object.freeze({
84
+ schema: DOMAIN_CHANGE_PACKET_SCHEMA,
85
+ version: DOMAIN_CHANGE_PACKET_VERSION,
86
+ generatedAt,
87
+ domain,
88
+ reference: requireBoundedString(input.reference, 'reference', 200),
89
+ state: gateReasons.length ? 'review' : 'ready',
90
+ gate: Object.freeze({ pass: gateReasons.length === 0, reasons: gateReasons }),
91
+ summary: Object.freeze({
92
+ changedAuthoritativeRecordSets: changeSummary(preChange, postChange),
93
+ preChangeState: preChange.state,
94
+ postChangeState: postChange.state,
95
+ assuranceState: assurance.result.review.state,
96
+ }),
97
+ evidence: Object.freeze({ preChange, postChange, assurance }),
98
+ limitations: Object.freeze([
99
+ 'This packet is assembled only from analyst-supplied observations and planning metadata and makes no network request or configuration change.',
100
+ 'A ready result means the supplied bounded checks passed; it does not prove control, propagation completion, successful recovery, or absence of unobserved dependencies.',
101
+ 'The integrity digest detects later changes to this packet but does not authenticate its author. Use a separately managed signing key when signer authentication is required.',
102
+ ]),
103
+ });
104
+ return Object.freeze({
105
+ ...unsigned,
106
+ integrity: Object.freeze({
107
+ algorithm: 'SHA-256',
108
+ digestSha256: await sha256ArtifactDigest(unsigned),
109
+ }),
110
+ });
111
+ }
112
+ export function formatDomainChangePacket(packet) {
113
+ const lines = [
114
+ 'Domain change assurance packet',
115
+ `Domain ${packet.domain}`,
116
+ `Reference ${packet.reference}`,
117
+ `State ${packet.state}`,
118
+ `Changed record sets ${packet.summary.changedAuthoritativeRecordSets.length}`,
119
+ `Pre-change review ${packet.summary.preChangeState}`,
120
+ `Post-change review ${packet.summary.postChangeState}`,
121
+ `Change plan ${packet.summary.assuranceState}`,
122
+ `Integrity ${packet.integrity.digestSha256}`,
123
+ ];
124
+ if (packet.gate.reasons.length) {
125
+ lines.push('', 'Review reasons:');
126
+ for (const reason of packet.gate.reasons)
127
+ lines.push(` - ${reason}`);
128
+ }
129
+ lines.push('', 'Limitations:');
130
+ for (const limitation of packet.limitations)
131
+ lines.push(` - ${limitation}`);
132
+ return `${lines.join('\n')}\n`;
133
+ }