@slicedearth/whoisleuth-cli 1.39.1 → 1.43.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/README.md +15 -0
  2. package/bin/whoisleuth.mjs +13 -2
  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 +7 -11
  7. package/cli/bulk-command-runner.mjs +19 -2
  8. package/cli/case-pack.mjs +136 -0
  9. package/cli/ci-report.mjs +73 -0
  10. package/cli/collection-preflight.mjs +59 -0
  11. package/cli/command-reference.mjs +395 -0
  12. package/cli/completion.mjs +33 -10
  13. package/cli/config-profile.mjs +180 -0
  14. package/cli/ct-event-intake.mjs +159 -0
  15. package/cli/discovery-scan-command-runner.mjs +15 -1
  16. package/cli/domain-control-monitor.mjs +104 -0
  17. package/cli/domain-control-observations.mjs +235 -0
  18. package/cli/evidence-command-arguments.mjs +11 -0
  19. package/cli/evidence-command-runner.mjs +1 -0
  20. package/cli/external-observation-mapping.mjs +162 -0
  21. package/cli/fail-policy.mjs +64 -0
  22. package/cli/formatters/html.mjs +1 -1
  23. package/cli/investigation-manifest.mjs +113 -0
  24. package/cli/investigation-run.mjs +112 -0
  25. package/cli/lookup-brief.mjs +116 -0
  26. package/cli/lookup-command-runner.mjs +8 -1
  27. package/cli/lookup-diff.mjs +17 -19
  28. package/cli/network-command-runner.mjs +4 -1
  29. package/cli/offline-evidence-review.mjs +105 -8
  30. package/cli/open-asset-model-bridge.mjs +169 -0
  31. package/cli/registry-cohort.mjs +121 -0
  32. package/cli/registry-doctor.mjs +106 -11
  33. package/cli/registry-fixture-scaffold.mjs +73 -0
  34. package/cli/runner.mjs +368 -306
  35. package/docs/cli-reference.md +240 -14
  36. package/docs/cli.md +42 -7
  37. package/frontend/src/lib/analysis/acquisition-due-diligence.js +1 -1
  38. package/frontend/src/lib/analysis/brand-profile-model.js +46 -4
  39. package/frontend/src/lib/analysis/case-report.js +573 -0
  40. package/frontend/src/lib/analysis/case-response-model.js +30 -1
  41. package/frontend/src/lib/analysis/detection-rule-model.js +36 -0
  42. package/frontend/src/lib/analysis/external-findings-import.js +320 -0
  43. package/frontend/src/lib/analysis/lookup-decision-support.js +7 -0
  44. package/frontend/src/lib/analysis/lookup-presentation.js +10 -20
  45. package/frontend/src/lib/analysis/scoring.js +1 -1
  46. package/frontend/src/lib/analysis/visualization-models.js +2 -3
  47. package/frontend/src/lib/analysis/workspace-archive.js +2 -0
  48. package/lib/abusech-auth.mjs +1 -1
  49. package/lib/availability.mjs +1 -1
  50. package/lib/bounded-contract-normalizers.mjs +33 -0
  51. package/lib/dns-convergence-review.mjs +183 -0
  52. package/lib/dns-delegation-health.mjs +196 -3
  53. package/lib/domain-change-packet.mjs +133 -0
  54. package/lib/domain-change-review.mjs +307 -0
  55. package/lib/domain-control-flight-recorder.mjs +268 -0
  56. package/lib/domain-portfolio-review.mjs +151 -0
  57. package/lib/evidence-export.mjs +1 -1
  58. package/lib/html-signals.mjs +5 -11
  59. package/lib/nameserver-preflight-review.mjs +159 -0
  60. package/lib/page-language-signals.mjs +124 -0
  61. package/lib/risk-scoring.mjs +42 -1
  62. package/lib/static-html-analysis.mjs +29 -1
  63. package/lib/trust-store-comparison.mjs +146 -0
  64. package/lib/website-technology.mjs +24 -10
  65. package/lib/zone-intent-review.mjs +534 -0
  66. package/package.json +1 -1
@@ -0,0 +1,307 @@
1
+ import { exactKeys, requireBoundedString, requireDomainName, requireIsoTimestamp, requireRecord, } from "./bounded-contract-normalizers.mjs";
2
+ import { normaliseRdata } from "./zone-intent-review.mjs";
3
+ export const DOMAIN_CHANGE_INPUT_SCHEMA = 'whoisleuth.domain-change.input';
4
+ export const DOMAIN_CHANGE_REVIEW_SCHEMA = 'whoisleuth.domain-change.review';
5
+ export const DOMAIN_CHANGE_REVIEW_VERSION = 1;
6
+ export const MAX_DOMAIN_CHANGE_VANTAGES = 16;
7
+ export const MAX_DOMAIN_CHANGE_RECORDS = 500;
8
+ const ROOT_KEYS = new Set(['schema', 'version', 'domain', 'authoritySnapshots', 'resolverSnapshots', 'acmeDependencies', 'certificate', 'hsts']);
9
+ const SNAPSHOT_KEYS = new Set(['label', 'source', 'state', 'observedAt', 'records']);
10
+ const RECORD_KEYS = new Set(['owner', 'type', 'value', 'ttl']);
11
+ const ACME_KEYS = new Set(['method', 'owner', 'target', 'provider', 'state']);
12
+ const CERTIFICATE_KEYS = new Set(['state', 'observedAt', 'currentSpkiSha256', 'plannedSpkiSha256', 'mustStaple', 'ocspStapled', 'embeddedSctCount']);
13
+ const HSTS_KEYS = new Set(['state', 'observedAt', 'header', 'preloadState', 'source']);
14
+ const TYPES = new Set(['A', 'AAAA', 'CAA', 'CDNSKEY', 'CDS', 'CNAME', 'CSYNC', 'HTTPS', 'MX', 'NS', 'SRV', 'SVCB', 'TLSA', 'TXT']);
15
+ function text(value, label, maximum = 500) {
16
+ return requireBoundedString(value, label, maximum);
17
+ }
18
+ function optionalText(value, label, maximum = 500) {
19
+ return value === null || value === undefined || value === '' ? null : text(value, label, maximum);
20
+ }
21
+ function timestamp(value, label) {
22
+ return requireIsoTimestamp(value, label);
23
+ }
24
+ function domain(value, label) {
25
+ return requireDomainName(value, label);
26
+ }
27
+ function owner(value, apex, label) {
28
+ const raw = text(value, label, 253).toLowerCase().replace(/\.$/u, '');
29
+ const candidate = raw === '@' ? apex : raw;
30
+ if (candidate.startsWith('*.'))
31
+ return `*.${domain(candidate.slice(2), label)}`;
32
+ const service = candidate.split('.');
33
+ if (candidate.length > 253 || service.some((part) => !/^[a-z0-9_](?:[a-z0-9_-]{0,61}[a-z0-9_])?$/u.test(part))) {
34
+ throw new TypeError(`${label} must be a valid DNS owner name.`);
35
+ }
36
+ return candidate;
37
+ }
38
+ function evidenceState(value, label) {
39
+ if (value !== 'observed' && value !== 'partial' && value !== 'unavailable')
40
+ throw new TypeError(`${label} is unsupported.`);
41
+ return value;
42
+ }
43
+ function normaliseValue(type, value, label) {
44
+ const raw = text(value, label, 16_384);
45
+ const normalised = normaliseRdata(type, raw, null);
46
+ return {
47
+ value: normalised.value,
48
+ treatment: normalised.valueTreatment,
49
+ };
50
+ }
51
+ function normaliseRecord(value, apex, label) {
52
+ const input = requireRecord(value, label);
53
+ exactKeys(input, RECORD_KEYS, label);
54
+ const type = text(input.type, `${label}.type`, 16).toUpperCase();
55
+ if (!TYPES.has(type))
56
+ throw new TypeError(`${label}.type is unsupported.`);
57
+ const ttl = input.ttl === undefined || input.ttl === null
58
+ ? null
59
+ : Number.isSafeInteger(input.ttl) && Number(input.ttl) >= 0 && Number(input.ttl) <= 0x7fff_ffff
60
+ ? Number(input.ttl)
61
+ : (() => { throw new TypeError(`${label}.ttl is outside its supported range.`); })();
62
+ return Object.freeze({ owner: owner(input.owner, apex, `${label}.owner`), type, ttl, ...normaliseValue(type, input.value, `${label}.value`) });
63
+ }
64
+ function normaliseSnapshots(value, apex, label) {
65
+ if (!Array.isArray(value) || value.length > MAX_DOMAIN_CHANGE_VANTAGES)
66
+ throw new TypeError(`${label} must contain no more than ${MAX_DOMAIN_CHANGE_VANTAGES} snapshots.`);
67
+ let totalRecords = 0;
68
+ const snapshots = value.map((raw, index) => {
69
+ const item = requireRecord(raw, `${label}[${index}]`);
70
+ exactKeys(item, SNAPSHOT_KEYS, `${label}[${index}]`);
71
+ const state = evidenceState(item.state, `${label}[${index}].state`);
72
+ if (!Array.isArray(item.records))
73
+ throw new TypeError(`${label}[${index}].records must be an array.`);
74
+ totalRecords += item.records.length;
75
+ if (totalRecords > MAX_DOMAIN_CHANGE_RECORDS)
76
+ throw new TypeError(`Domain change review is limited to ${MAX_DOMAIN_CHANGE_RECORDS} records.`);
77
+ const records = item.records.map((recordValue, recordIndex) => normaliseRecord(recordValue, apex, `${label}[${index}].records[${recordIndex}]`));
78
+ if (state === 'unavailable' && records.length)
79
+ throw new TypeError(`${label}[${index}] cannot contain records when unavailable.`);
80
+ return Object.freeze({
81
+ label: text(item.label, `${label}[${index}].label`, 120),
82
+ source: text(item.source, `${label}[${index}].source`, 240),
83
+ state,
84
+ observedAt: timestamp(item.observedAt, `${label}[${index}].observedAt`),
85
+ records: Object.freeze(records),
86
+ });
87
+ });
88
+ if (new Set(snapshots.map((item) => item.label.toLowerCase())).size !== snapshots.length)
89
+ throw new TypeError(`${label} labels must be unique.`);
90
+ return Object.freeze(snapshots);
91
+ }
92
+ function recordMatrix(snapshots) {
93
+ const keys = [...new Set(snapshots.flatMap((snapshot) => snapshot.records.map((item) => `${item.owner}\u0000${item.type}`)))].sort();
94
+ return Object.freeze(keys.map((key) => {
95
+ const [recordOwner, type] = key.split('\u0000');
96
+ const observations = snapshots.map((snapshot) => {
97
+ const matches = snapshot.records.filter((item) => item.owner === recordOwner && item.type === type);
98
+ const ttls = matches.map((item) => item.ttl).filter((ttl) => ttl !== null);
99
+ return Object.freeze({
100
+ label: snapshot.label,
101
+ source: snapshot.source,
102
+ state: snapshot.state,
103
+ values: Object.freeze([...new Set(matches.map((item) => item.value))].sort()),
104
+ ttlRange: ttls.length ? Object.freeze({
105
+ minimum: Math.min(...ttls),
106
+ maximum: Math.max(...ttls),
107
+ }) : null,
108
+ });
109
+ });
110
+ const complete = observations.filter((item) => item.state === 'observed');
111
+ const signatures = new Set(complete.map((item) => JSON.stringify(item.values)));
112
+ return Object.freeze({
113
+ owner: recordOwner,
114
+ type,
115
+ state: complete.length < 2 ? 'insufficient' : signatures.size === 1 ? 'aligned' : 'different',
116
+ observations: Object.freeze(observations),
117
+ });
118
+ }));
119
+ }
120
+ function reviewDnssecAutomation(matrix) {
121
+ const automation = matrix.filter((row) => row.type === 'CDS' || row.type === 'CDNSKEY' || row.type === 'CSYNC');
122
+ const byType = (type) => automation.find((row) => row.type === type);
123
+ const cds = byType('CDS');
124
+ const cdnskey = byType('CDNSKEY');
125
+ const csync = byType('CSYNC');
126
+ const conflicts = automation.filter((row) => row.state === 'different');
127
+ const partial = automation.some((row) => row.observations.some((observation) => observation.state !== 'observed'));
128
+ return Object.freeze({
129
+ state: !automation.length ? 'not_observed'
130
+ : conflicts.length ? 'conflict'
131
+ : partial ? 'partial'
132
+ : 'review_ready',
133
+ cdsObserved: Boolean(cds),
134
+ cdnskeyObserved: Boolean(cdnskey),
135
+ csyncObserved: Boolean(csync),
136
+ conflictingTypes: Object.freeze(conflicts.map((row) => row.type)),
137
+ detail: !automation.length
138
+ ? 'No CDS, CDNSKEY, or CSYNC record was supplied.'
139
+ : conflicts.length
140
+ ? `Complete authority observations differ for ${conflicts.map((row) => row.type).join(', ')}.`
141
+ : partial
142
+ ? 'Automation records were supplied, but at least one authority observation is partial or unavailable.'
143
+ : 'Supplied complete authority observations agree for the observed automation records.',
144
+ });
145
+ }
146
+ function normaliseAcme(value, apex) {
147
+ if (!Array.isArray(value) || value.length > 64)
148
+ throw new TypeError('acmeDependencies must contain no more than 64 entries.');
149
+ return Object.freeze(value.map((raw, index) => {
150
+ const item = requireRecord(raw, `acmeDependencies[${index}]`);
151
+ exactKeys(item, ACME_KEYS, `acmeDependencies[${index}]`);
152
+ const method = item.method;
153
+ if (method !== 'dns-01' && method !== 'http-01' && method !== 'tls-alpn-01')
154
+ throw new TypeError(`acmeDependencies[${index}].method is unsupported.`);
155
+ const state = item.state;
156
+ if (state !== 'confirmed' && state !== 'partial' && state !== 'unknown')
157
+ throw new TypeError(`acmeDependencies[${index}].state is unsupported.`);
158
+ return Object.freeze({
159
+ method,
160
+ owner: owner(item.owner, apex, `acmeDependencies[${index}].owner`),
161
+ target: optionalText(item.target, `acmeDependencies[${index}].target`, 253),
162
+ provider: optionalText(item.provider, `acmeDependencies[${index}].provider`, 120),
163
+ state,
164
+ });
165
+ }));
166
+ }
167
+ function normaliseCertificate(value) {
168
+ if (value === null || value === undefined)
169
+ return null;
170
+ const item = requireRecord(value, 'certificate');
171
+ exactKeys(item, CERTIFICATE_KEYS, 'certificate');
172
+ const state = evidenceState(item.state, 'certificate.state');
173
+ const digest = (candidate, label) => {
174
+ const supplied = optionalText(candidate, label, 80);
175
+ if (supplied && !/^[a-f0-9]{64}$/iu.test(supplied))
176
+ throw new TypeError(`${label} must be a SHA-256 hexadecimal digest.`);
177
+ return supplied?.toLowerCase() ?? null;
178
+ };
179
+ const nullableBoolean = (candidate, label) => {
180
+ if (candidate === null || candidate === undefined)
181
+ return null;
182
+ if (typeof candidate !== 'boolean')
183
+ throw new TypeError(`${label} must be true, false, or null.`);
184
+ return candidate;
185
+ };
186
+ const embeddedSctCount = item.embeddedSctCount === null || item.embeddedSctCount === undefined
187
+ ? null
188
+ : Number.isSafeInteger(item.embeddedSctCount) && Number(item.embeddedSctCount) >= 0 && Number(item.embeddedSctCount) <= 100
189
+ ? Number(item.embeddedSctCount)
190
+ : (() => { throw new TypeError('certificate.embeddedSctCount is outside its supported range.'); })();
191
+ return Object.freeze({
192
+ state,
193
+ observedAt: timestamp(item.observedAt, 'certificate.observedAt'),
194
+ currentSpkiSha256: digest(item.currentSpkiSha256, 'certificate.currentSpkiSha256'),
195
+ plannedSpkiSha256: digest(item.plannedSpkiSha256, 'certificate.plannedSpkiSha256'),
196
+ mustStaple: nullableBoolean(item.mustStaple, 'certificate.mustStaple'),
197
+ ocspStapled: nullableBoolean(item.ocspStapled, 'certificate.ocspStapled'),
198
+ embeddedSctCount,
199
+ });
200
+ }
201
+ function reviewCertificate(certificate) {
202
+ if (!certificate)
203
+ return Object.freeze({ state: 'not_supplied', continuity: 'unknown', findings: Object.freeze([]) });
204
+ const continuity = !certificate.currentSpkiSha256 || !certificate.plannedSpkiSha256
205
+ ? 'unknown'
206
+ : certificate.currentSpkiSha256 === certificate.plannedSpkiSha256 ? 'retained' : 'changes';
207
+ const findings = [
208
+ ...(certificate.mustStaple === true && certificate.ocspStapled !== true
209
+ ? ['The certificate requires OCSP stapling, but stapled-response evidence was not confirmed.'] : []),
210
+ ...(certificate.embeddedSctCount === 0
211
+ ? ['No embedded certificate-transparency timestamp was observed; this does not establish that the certificate was not logged by another mechanism.'] : []),
212
+ ];
213
+ return Object.freeze({ state: certificate.state, continuity, findings: Object.freeze(findings) });
214
+ }
215
+ function normaliseHsts(value) {
216
+ if (value === null || value === undefined)
217
+ return null;
218
+ const item = requireRecord(value, 'hsts');
219
+ exactKeys(item, HSTS_KEYS, 'hsts');
220
+ const preloadState = item.preloadState;
221
+ if (preloadState !== 'listed' && preloadState !== 'not_listed' && preloadState !== 'unavailable')
222
+ throw new TypeError('hsts.preloadState is unsupported.');
223
+ return Object.freeze({
224
+ state: evidenceState(item.state, 'hsts.state'),
225
+ observedAt: timestamp(item.observedAt, 'hsts.observedAt'),
226
+ header: optionalText(item.header, 'hsts.header', 1_024),
227
+ preloadState,
228
+ source: text(item.source, 'hsts.source', 240),
229
+ });
230
+ }
231
+ function serviceInventory(snapshots) {
232
+ return Object.freeze(snapshots.flatMap((snapshot) => snapshot.records
233
+ .filter((item) => item.type === 'SRV')
234
+ .map((item) => Object.freeze({ owner: item.owner, value: item.value, source: snapshot.source, state: snapshot.state, observedAt: snapshot.observedAt })))
235
+ .sort((left, right) => left.owner.localeCompare(right.owner) || left.value.localeCompare(right.value)));
236
+ }
237
+ export function reviewDomainChange(inputRaw, generatedAtValue = new Date().toISOString()) {
238
+ const input = requireRecord(inputRaw, 'Domain change input');
239
+ if (input.schema !== DOMAIN_CHANGE_INPUT_SCHEMA || input.version !== 1)
240
+ throw new TypeError(`Domain change input must use ${DOMAIN_CHANGE_INPUT_SCHEMA} version 1.`);
241
+ exactKeys(input, ROOT_KEYS, 'Domain change input');
242
+ const apex = domain(input.domain, 'domain');
243
+ const authoritySnapshots = normaliseSnapshots(input.authoritySnapshots ?? [], apex, 'authoritySnapshots');
244
+ const resolverSnapshots = normaliseSnapshots(input.resolverSnapshots ?? [], apex, 'resolverSnapshots');
245
+ const authorityMatrix = recordMatrix(authoritySnapshots);
246
+ const resolverMatrix = recordMatrix(resolverSnapshots);
247
+ const acmeDependencies = normaliseAcme(input.acmeDependencies ?? [], apex);
248
+ const certificate = normaliseCertificate(input.certificate);
249
+ const hsts = normaliseHsts(input.hsts);
250
+ const certificateReview = reviewCertificate(certificate);
251
+ const differentAuthorityRows = authorityMatrix.filter((row) => row.state === 'different');
252
+ const differentResolverRows = resolverMatrix.filter((row) => row.state === 'different');
253
+ const insufficientAuthorityRows = authorityMatrix.filter((row) => row.state === 'insufficient');
254
+ const insufficientResolverRows = resolverMatrix.filter((row) => row.state === 'insufficient');
255
+ const observedAuthorityCount = authoritySnapshots.filter((item) => item.state === 'observed').length;
256
+ const observedResolverCount = resolverSnapshots.filter((item) => item.state === 'observed').length;
257
+ const incompleteVantages = [...authoritySnapshots, ...resolverSnapshots].filter((item) => item.state !== 'observed');
258
+ const gateReasons = [
259
+ ...(observedAuthorityCount < 2
260
+ ? ['At least two complete authority observations are required before a domain change can be marked ready.'] : []),
261
+ ...(!authorityMatrix.length
262
+ ? ['No comparable authority record evidence was supplied.'] : []),
263
+ ...differentAuthorityRows.map((row) => `Authority observations differ for ${row.owner} ${row.type}.`),
264
+ ...insufficientAuthorityRows.map((row) => `Authority evidence is insufficient for ${row.owner} ${row.type}.`),
265
+ ...(resolverSnapshots.length > 0 && observedResolverCount < 2
266
+ ? ['Supplied resolver evidence requires at least two complete observations.'] : []),
267
+ ...differentResolverRows.map((row) => `Resolver observations differ for ${row.owner} ${row.type}.`),
268
+ ...insufficientResolverRows.map((row) => `Resolver evidence is insufficient for ${row.owner} ${row.type}.`),
269
+ ...incompleteVantages.map((item) => `${item.label} evidence is ${item.state}.`),
270
+ ...acmeDependencies.filter((item) => item.state !== 'confirmed').map((item) => `${item.method} dependency for ${item.owner} is ${item.state}.`),
271
+ ...(certificate && certificate.state !== 'observed'
272
+ ? [`Certificate evidence is ${certificate.state}.`] : []),
273
+ ...(certificate?.state === 'observed' && certificateReview.continuity === 'unknown'
274
+ ? ['Certificate continuity cannot be reviewed without both current and planned public-key fingerprints.'] : []),
275
+ ...certificateReview.findings,
276
+ ...(hsts && hsts.state !== 'observed'
277
+ ? [`HSTS evidence is ${hsts.state}.`] : []),
278
+ ...(hsts?.state === 'observed' && hsts.preloadState === 'unavailable'
279
+ ? ['HSTS preload evidence is unavailable.'] : []),
280
+ ];
281
+ const reviewRequired = gateReasons.length > 0;
282
+ return Object.freeze({
283
+ schema: DOMAIN_CHANGE_REVIEW_SCHEMA,
284
+ version: DOMAIN_CHANGE_REVIEW_VERSION,
285
+ generatedAt: timestamp(generatedAtValue, 'generatedAt'),
286
+ domain: apex,
287
+ state: reviewRequired ? 'review' : 'ready',
288
+ authoritativeRecordMatrix: authorityMatrix,
289
+ resolverDivergenceMatrix: resolverMatrix,
290
+ dnssecAutomation: reviewDnssecAutomation(authorityMatrix),
291
+ acmeDependencies,
292
+ certificate: certificateReview,
293
+ services: serviceInventory([...authoritySnapshots, ...resolverSnapshots]),
294
+ hsts,
295
+ gate: Object.freeze({
296
+ pass: !reviewRequired,
297
+ reasons: Object.freeze(gateReasons),
298
+ }),
299
+ limitations: Object.freeze([
300
+ 'This local review uses only analyst-supplied observations and makes no DNS, HTTP, certificate, certificate-authority, preload-list, or provider request.',
301
+ 'Different observations can reflect propagation, caching, split-horizon DNS, resolver policy, or collection timing; they do not establish misconfiguration by themselves.',
302
+ 'Partial or unavailable evidence is never treated as record absence. TXT values are represented only by SHA-256 digests.',
303
+ 'DNSSEC automation readiness describes CDS, CDNSKEY, and CSYNC publication consistency; it does not validate a cryptographic chain or authorise a registry-side change.',
304
+ 'HSTS preload state is an analyst-supplied, source-attributed observation and must be refreshed against the nominated source before a change.',
305
+ ]),
306
+ });
307
+ }
@@ -0,0 +1,268 @@
1
+ import { exactKeys, requireBoundedString, requireDomainName, requireIsoTimestamp, requireRecord, } from "./bounded-contract-normalizers.mjs";
2
+ export const DOMAIN_CONTROL_FLIGHT_RECORDER_INPUT_SCHEMA = 'whoisleuth.domain-control-flight-recorder.input';
3
+ export const DOMAIN_CONTROL_FLIGHT_RECORDER_SCHEMA = 'whoisleuth.domain-control-flight-recorder';
4
+ export const DOMAIN_CONTROL_FLIGHT_RECORDER_VERSION = 1;
5
+ export const MAX_FLIGHT_RECORDER_OBSERVATIONS = 200;
6
+ export const MAX_FLIGHT_RECORDER_WINDOWS = 40;
7
+ export const MAX_FLIGHT_RECORDER_FIELDS = 24;
8
+ export const MAX_FLIGHT_RECORDER_VALUES = 32;
9
+ export const DOMAIN_CONTROL_FLIGHT_RECORDER_FIELDS = [
10
+ 'registrar',
11
+ 'registrar_lock',
12
+ 'registry_dnssec',
13
+ 'registry_nameservers',
14
+ 'whois_nameservers',
15
+ 'delegated_nameservers',
16
+ 'delegation_ds',
17
+ 'mail_exchangers',
18
+ 'caa_policy',
19
+ 'tls_certificate',
20
+ 'tls_public_key',
21
+ 'http_origin',
22
+ 'page_identity',
23
+ ];
24
+ const ROOT_KEYS = new Set(['schema', 'version', 'observations', 'approvedWindows']);
25
+ const OBSERVATION_KEYS = new Set(['domain', 'observedAt', 'collectionDepth', 'fields']);
26
+ const FIELD_KEYS = new Set(['id', 'source', 'state', 'values']);
27
+ const WINDOW_KEYS = new Set(['id', 'domain', 'startsAt', 'endsAt', 'fields', 'reason']);
28
+ const FIELD_IDS = new Set(DOMAIN_CONTROL_FLIGHT_RECORDER_FIELDS);
29
+ const STATES = new Set(['observed', 'partial', 'unavailable', 'unsupported']);
30
+ const CONTROL_REPLACE_RE = /[\u0000-\u001f\u007f]+/gu;
31
+ function text(value, label, maximum = 240) {
32
+ return requireBoundedString(value, label, maximum)
33
+ .replace(CONTROL_REPLACE_RE, ' ')
34
+ .replace(/\s+/gu, ' ')
35
+ .trim();
36
+ }
37
+ function values(value, label) {
38
+ if (!Array.isArray(value) || value.length > MAX_FLIGHT_RECORDER_VALUES * 2) {
39
+ throw new TypeError(`${label} must contain no more than ${MAX_FLIGHT_RECORDER_VALUES * 2} bounded values.`);
40
+ }
41
+ const output = new Set();
42
+ for (const [index, item] of value.entries()) {
43
+ const normalized = text(item, `${label}[${index}]`, 500).toLowerCase();
44
+ if (normalized)
45
+ output.add(normalized);
46
+ if (output.size >= MAX_FLIGHT_RECORDER_VALUES)
47
+ break;
48
+ }
49
+ return [...output].sort();
50
+ }
51
+ function observation(value, index) {
52
+ const item = requireRecord(value, `observations[${index}]`);
53
+ exactKeys(item, OBSERVATION_KEYS, `observations[${index}]`);
54
+ if (!Array.isArray(item.fields) || item.fields.length > MAX_FLIGHT_RECORDER_FIELDS) {
55
+ throw new TypeError(`observations[${index}].fields must contain no more than ${MAX_FLIGHT_RECORDER_FIELDS} fields.`);
56
+ }
57
+ const seen = new Set();
58
+ const fields = item.fields.map((raw, fieldIndex) => {
59
+ const field = requireRecord(raw, `observations[${index}].fields[${fieldIndex}]`);
60
+ exactKeys(field, FIELD_KEYS, `observations[${index}].fields[${fieldIndex}]`);
61
+ if (typeof field.id !== 'string' || !FIELD_IDS.has(field.id) || seen.has(field.id)) {
62
+ throw new TypeError(`observations[${index}] contains an unsupported or duplicate field.`);
63
+ }
64
+ if (typeof field.state !== 'string' || !STATES.has(field.state)) {
65
+ throw new TypeError(`observations[${index}].fields[${fieldIndex}].state is unsupported.`);
66
+ }
67
+ seen.add(field.id);
68
+ return Object.freeze({
69
+ id: field.id,
70
+ source: text(field.source, `observations[${index}].fields[${fieldIndex}].source`, 120),
71
+ state: field.state,
72
+ values: Object.freeze(values(field.values, `observations[${index}].fields[${fieldIndex}].values`)),
73
+ });
74
+ }).sort((left, right) => left.id.localeCompare(right.id));
75
+ const collectionDepth = item.collectionDepth === 'deep' || item.collectionDepth === 'fast'
76
+ ? item.collectionDepth
77
+ : item.collectionDepth === 'unknown'
78
+ ? 'unknown'
79
+ : null;
80
+ if (!collectionDepth)
81
+ throw new TypeError(`observations[${index}].collectionDepth is unsupported.`);
82
+ return Object.freeze({
83
+ domain: requireDomainName(item.domain, `observations[${index}].domain`),
84
+ observedAt: requireIsoTimestamp(item.observedAt, `observations[${index}].observedAt`),
85
+ collectionDepth,
86
+ fields: Object.freeze(fields),
87
+ });
88
+ }
89
+ function approvedWindow(value, index) {
90
+ const item = requireRecord(value, `approvedWindows[${index}]`);
91
+ exactKeys(item, WINDOW_KEYS, `approvedWindows[${index}]`);
92
+ if (!Array.isArray(item.fields) || item.fields.length < 1 || item.fields.length > MAX_FLIGHT_RECORDER_FIELDS) {
93
+ throw new TypeError(`approvedWindows[${index}].fields must contain from 1 to ${MAX_FLIGHT_RECORDER_FIELDS} fields.`);
94
+ }
95
+ const fields = [...new Set(item.fields.map((field) => {
96
+ if (typeof field !== 'string' || !FIELD_IDS.has(field)) {
97
+ throw new TypeError(`approvedWindows[${index}] contains an unsupported field.`);
98
+ }
99
+ return field;
100
+ }))].sort();
101
+ const startsAt = requireIsoTimestamp(item.startsAt, `approvedWindows[${index}].startsAt`);
102
+ const endsAt = requireIsoTimestamp(item.endsAt, `approvedWindows[${index}].endsAt`);
103
+ if (Date.parse(endsAt) <= Date.parse(startsAt)) {
104
+ throw new TypeError(`approvedWindows[${index}].endsAt must be later than startsAt.`);
105
+ }
106
+ return Object.freeze({
107
+ id: text(item.id, `approvedWindows[${index}].id`, 64),
108
+ domain: requireDomainName(item.domain, `approvedWindows[${index}].domain`),
109
+ startsAt,
110
+ endsAt,
111
+ fields: Object.freeze(fields),
112
+ reason: text(item.reason, `approvedWindows[${index}].reason`, 400),
113
+ });
114
+ }
115
+ function sameValues(left, right) {
116
+ return left.length === right.length && left.every((value, index) => value === right[index]);
117
+ }
118
+ function matchingWindow(windows, domain, field, observedAt) {
119
+ const time = Date.parse(observedAt);
120
+ return windows.find((window) => (window.domain === domain
121
+ && window.fields.includes(field)
122
+ && Date.parse(window.startsAt) <= time
123
+ && time <= Date.parse(window.endsAt))) ?? null;
124
+ }
125
+ export function buildDomainControlFlightRecorder(inputRaw, generatedAtValue = new Date().toISOString()) {
126
+ const input = requireRecord(inputRaw, 'Domain control flight-recorder input');
127
+ if (input.schema !== DOMAIN_CONTROL_FLIGHT_RECORDER_INPUT_SCHEMA || input.version !== 1) {
128
+ throw new TypeError(`Domain control flight-recorder input must use ${DOMAIN_CONTROL_FLIGHT_RECORDER_INPUT_SCHEMA} version 1.`);
129
+ }
130
+ exactKeys(input, ROOT_KEYS, 'Domain control flight-recorder input');
131
+ if (!Array.isArray(input.observations)
132
+ || input.observations.length < 1
133
+ || input.observations.length > MAX_FLIGHT_RECORDER_OBSERVATIONS) {
134
+ throw new TypeError(`Domain control flight-recorder input must contain from 1 to ${MAX_FLIGHT_RECORDER_OBSERVATIONS} observations.`);
135
+ }
136
+ if (!Array.isArray(input.approvedWindows) || input.approvedWindows.length > MAX_FLIGHT_RECORDER_WINDOWS) {
137
+ throw new TypeError(`approvedWindows must contain no more than ${MAX_FLIGHT_RECORDER_WINDOWS} entries.`);
138
+ }
139
+ const observations = input.observations.map(observation)
140
+ .sort((left, right) => Date.parse(left.observedAt) - Date.parse(right.observedAt) || left.domain.localeCompare(right.domain));
141
+ const windows = input.approvedWindows.map(approvedWindow);
142
+ if (new Set(windows.map((window) => window.id)).size !== windows.length) {
143
+ throw new TypeError('Approved change-window ids must be unique.');
144
+ }
145
+ const generatedAt = requireIsoTimestamp(generatedAtValue, 'generatedAt');
146
+ const latest = new Map();
147
+ const events = [];
148
+ for (const item of observations) {
149
+ for (const field of item.fields) {
150
+ const key = `${item.domain}\u0000${field.id}`;
151
+ const previous = latest.get(key);
152
+ latest.set(key, field);
153
+ if (!previous) {
154
+ if (field.state !== 'observed')
155
+ continue;
156
+ events.push(Object.freeze({
157
+ id: `${item.domain}:${field.id}:${item.observedAt}`,
158
+ domain: item.domain,
159
+ field: field.id,
160
+ observedAt: item.observedAt,
161
+ kind: 'first_observation',
162
+ state: 'observed',
163
+ before: Object.freeze([]),
164
+ after: field.values,
165
+ source: field.source,
166
+ approvedWindow: null,
167
+ explanation: 'The first complete value for this source-attributed field was retained.',
168
+ }));
169
+ continue;
170
+ }
171
+ const window = matchingWindow(windows, item.domain, field.id, item.observedAt);
172
+ if (previous.state === 'observed' && field.state === 'observed') {
173
+ if (sameValues(previous.values, field.values))
174
+ continue;
175
+ events.push(Object.freeze({
176
+ id: `${item.domain}:${field.id}:${item.observedAt}`,
177
+ domain: item.domain,
178
+ field: field.id,
179
+ observedAt: item.observedAt,
180
+ kind: 'observed_change',
181
+ state: 'observed',
182
+ before: previous.values,
183
+ after: field.values,
184
+ source: field.source,
185
+ approvedWindow: window ? Object.freeze({ id: window.id, reason: window.reason }) : null,
186
+ explanation: window
187
+ ? 'A complete observed value changed during an analyst-approved change window. The evidence is retained and labelled expected rather than removed.'
188
+ : 'Two complete source-attributed observations contain different values.',
189
+ }));
190
+ }
191
+ else if (previous.state !== 'observed' && field.state === 'observed') {
192
+ events.push(Object.freeze({
193
+ id: `${item.domain}:${field.id}:${item.observedAt}`,
194
+ domain: item.domain,
195
+ field: field.id,
196
+ observedAt: item.observedAt,
197
+ kind: 'recovered',
198
+ state: 'observed',
199
+ before: Object.freeze([]),
200
+ after: field.values,
201
+ source: field.source,
202
+ approvedWindow: null,
203
+ explanation: 'Collection returned a complete value after an earlier incomplete source state.',
204
+ }));
205
+ }
206
+ else if (previous.state === 'observed' && field.state !== 'observed') {
207
+ events.push(Object.freeze({
208
+ id: `${item.domain}:${field.id}:${item.observedAt}`,
209
+ domain: item.domain,
210
+ field: field.id,
211
+ observedAt: item.observedAt,
212
+ kind: 'collection_change',
213
+ state: 'partial',
214
+ before: previous.values,
215
+ after: Object.freeze([]),
216
+ source: field.source,
217
+ approvedWindow: null,
218
+ explanation: `The source became ${field.state}; this is a collection-quality change, not evidence that the prior value disappeared.`,
219
+ }));
220
+ }
221
+ }
222
+ }
223
+ const domains = [...new Set(observations.map((item) => item.domain))].sort();
224
+ const observedChanges = events.filter((event) => event.kind === 'observed_change');
225
+ const collectionChanges = events.filter((event) => event.kind === 'collection_change');
226
+ return Object.freeze({
227
+ schema: DOMAIN_CONTROL_FLIGHT_RECORDER_SCHEMA,
228
+ version: DOMAIN_CONTROL_FLIGHT_RECORDER_VERSION,
229
+ generatedAt,
230
+ domains: Object.freeze(domains),
231
+ observationCount: observations.length,
232
+ events: Object.freeze(events),
233
+ summary: Object.freeze({
234
+ firstObservations: events.filter((event) => event.kind === 'first_observation').length,
235
+ observedChanges: observedChanges.length,
236
+ approvedChanges: observedChanges.filter((event) => event.approvedWindow !== null).length,
237
+ unexpectedChanges: observedChanges.filter((event) => event.approvedWindow === null).length,
238
+ collectionChanges: collectionChanges.length,
239
+ recoveredSources: events.filter((event) => event.kind === 'recovered').length,
240
+ }),
241
+ limitations: Object.freeze([
242
+ 'The flight recorder compares only supplied bounded observations and performs no collection or configuration change.',
243
+ 'Source fields remain separate. A collection failure, partial result, unsupported source, or missing field never becomes evidence that a prior value disappeared.',
244
+ 'Approved windows label expected timing but do not delete evidence or establish that a change was authorised, successful, safe, or complete.',
245
+ 'Observed changes can reflect publication lag or different collection conditions and require analyst review.',
246
+ ]),
247
+ });
248
+ }
249
+ export function formatDomainControlFlightRecorder(document) {
250
+ const lines = [
251
+ 'Domain-control flight recorder',
252
+ `Domains ${document.domains.length}`,
253
+ `Observations ${document.observationCount}`,
254
+ `Unexpected changes ${document.summary.unexpectedChanges}`,
255
+ `Approved changes ${document.summary.approvedChanges}`,
256
+ `Collection changes ${document.summary.collectionChanges}`,
257
+ '',
258
+ ];
259
+ for (const event of document.events.filter((item) => item.kind !== 'first_observation')) {
260
+ lines.push(`${event.observedAt} ${event.domain} ${event.field.replaceAll('_', ' ')} ${event.kind.replaceAll('_', ' ')}`);
261
+ lines.push(` ${event.explanation}`);
262
+ }
263
+ if (!document.events.some((item) => item.kind !== 'first_observation')) {
264
+ lines.push('No retained transition was available between the supplied observations.');
265
+ }
266
+ lines.push('', ...document.limitations.map((item) => `Limitation: ${item}`), '');
267
+ return lines.join('\n');
268
+ }