@slicedearth/whoisleuth-cli 2.2.0 → 2.4.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 (282) hide show
  1. package/NOTICE +6 -0
  2. package/README.md +20 -15
  3. package/bin/whoisleuth.mjs +8 -1
  4. package/cli/archive-content-digest.mjs +14 -0
  5. package/cli/archive-inspect.mjs +20 -53
  6. package/cli/arguments.mjs +454 -1748
  7. package/cli/artifact-validation/case-response.mjs +66 -35
  8. package/cli/artifact-validation/investigation-capsule.mjs +52 -31
  9. package/cli/artifact-validation/lookup-evidence.mjs +12 -6
  10. package/cli/artifact-validation/signed-review.mjs +112 -72
  11. package/cli/artifact-validation/structure-primitives.mjs +1 -105
  12. package/cli/artifact-verify.mjs +90 -24
  13. package/cli/assurance-command-runner.mjs +39 -4
  14. package/cli/bagit-folder.mjs +57 -0
  15. package/cli/bagit-review.mjs +23 -0
  16. package/cli/bounded-json.mjs +1 -1
  17. package/cli/bulk-checkpoint.mjs +34 -3
  18. package/cli/bulk-command-runner.mjs +2 -0
  19. package/cli/bulk-output.mjs +29 -3
  20. package/cli/case-command.mjs +195 -0
  21. package/cli/case-pack.mjs +66 -111
  22. package/cli/command-argument-grammar.mjs +215 -0
  23. package/cli/command-reference.mjs +342 -285
  24. package/cli/completion.mjs +303 -80
  25. package/cli/config-profile.mjs +7 -5
  26. package/cli/ct-event-intake.mjs +12 -12
  27. package/cli/discover.mjs +4 -3
  28. package/cli/discovery-observation-snapshot.mjs +59 -18
  29. package/cli/discovery-scan-command-runner.mjs +21 -0
  30. package/cli/discovery-scan.mjs +13 -4
  31. package/cli/discovery-workflow.mjs +1 -1
  32. package/cli/domain-control-monitor.mjs +50 -20
  33. package/cli/domain-control-observations.mjs +149 -102
  34. package/cli/errors.mjs +33 -1
  35. package/cli/evidence-command-runner.mjs +16 -5
  36. package/cli/evidence-signing.mjs +2 -2
  37. package/cli/external-observation-mapping.mjs +7 -8
  38. package/cli/formatters/terminal-lookup.mjs +18 -46
  39. package/cli/formatters/terminal-shared.mjs +5 -3
  40. package/cli/formatters/terminal-technology.mjs +114 -0
  41. package/cli/inline-command-families.mjs +2 -0
  42. package/cli/input.mjs +116 -0
  43. package/cli/interactive-launcher.mjs +2 -160
  44. package/cli/interchange-report.mjs +18 -8
  45. package/cli/investigation-artifacts.mjs +176 -0
  46. package/cli/investigation-checkpoint.mjs +10 -0
  47. package/cli/investigation-folder.mjs +114 -0
  48. package/cli/investigation-manifest.mjs +1 -112
  49. package/cli/investigation-package-review.mjs +103 -0
  50. package/cli/investigation-plan.mjs +32 -19
  51. package/cli/investigation-run.mjs +188 -33
  52. package/cli/local-document-checkpoint.mjs +170 -0
  53. package/cli/lookalike-calibration.mjs +7 -2
  54. package/cli/lookup-brief.mjs +4 -2
  55. package/cli/lookup-browser-view.mjs +1 -1
  56. package/cli/lookup-browser.mjs +5 -12
  57. package/cli/lookup-command-runner.mjs +17 -6
  58. package/cli/lookup-diff.mjs +37 -3
  59. package/cli/lookup-plan.mjs +11 -5
  60. package/cli/lookup-reconcile.mjs +10 -8
  61. package/cli/mail-header-review.mjs +467 -0
  62. package/cli/mail-review.mjs +66 -14
  63. package/cli/manual.mjs +1 -0
  64. package/cli/network-command-runner.mjs +2 -0
  65. package/cli/output-file.mjs +31 -5
  66. package/cli/registry-doctor.mjs +3 -1
  67. package/cli/registry-support.mjs +1 -1
  68. package/cli/retained-artifact-diff.mjs +2 -2
  69. package/cli/review-command-runner.mjs +70 -0
  70. package/cli/risk-calibration.mjs +19 -17
  71. package/cli/runner.mjs +62 -7
  72. package/cli/sharing-review.mjs +21 -34
  73. package/cli/signer-trust.mjs +104 -0
  74. package/cli/source-reliability.mjs +74 -83
  75. package/cli/terminal-input.mjs +168 -0
  76. package/cli/workflow-command-runner.mjs +68 -13
  77. package/docs/cli-reference.md +77 -24
  78. package/docs/cli.md +300 -25
  79. package/lib/abort.mjs +4 -1
  80. package/lib/availability.mjs +161 -112
  81. package/lib/bounded-file.mjs +3 -1
  82. package/lib/bounded-html-document.mjs +125 -0
  83. package/lib/bounded-json.mjs +51 -8
  84. package/lib/browser-library-profile.mjs +42 -51
  85. package/lib/browser-library-worker.mjs +112 -0
  86. package/lib/client-behavior-profile.mjs +3 -3
  87. package/lib/ct-search.mjs +11 -4
  88. package/lib/dns-convergence-review.mjs +1 -0
  89. package/lib/dns-delegation-health.mjs +24 -14
  90. package/lib/dns-inheritance-review.mjs +217 -0
  91. package/lib/dns-intelligence.mjs +73 -26
  92. package/lib/dns-mx.mjs +1 -1
  93. package/lib/dnssec-chain-validation.mjs +6 -0
  94. package/lib/dnssec-evidence-validation.mjs +11 -3
  95. package/lib/domain-change-packet.mjs +49 -16
  96. package/lib/domain-change-review.mjs +14 -8
  97. package/lib/domain-control-flight-recorder.mjs +221 -117
  98. package/lib/domain-control-manifest.mjs +129 -69
  99. package/lib/domain-posture-analysis.mjs +20 -7
  100. package/lib/domain-posture-parsers.mjs +201 -8
  101. package/lib/domain-posture.mjs +147 -29
  102. package/lib/evidence-export-privacy.mjs +561 -0
  103. package/lib/evidence-export.mjs +11 -543
  104. package/lib/evidence-report.mjs +5 -3
  105. package/lib/favicon.mjs +117 -41
  106. package/lib/generated/cisa-kev-catalog.mjs +12 -4
  107. package/lib/generated/registrar-standing-catalogue.mjs +7 -7
  108. package/lib/generated/retire-browser-catalog.mjs +2335 -940
  109. package/lib/hostname.mjs +2 -38
  110. package/lib/html-signals.mjs +111 -108
  111. package/lib/http-evidence-bounds.mjs +2 -1
  112. package/lib/http-intelligence.mjs +39 -26
  113. package/lib/local-geoip-evidence.mjs +19 -3
  114. package/lib/lookup-cache.mjs +34 -11
  115. package/lib/lookup-child-profile-contract.mjs +24 -15
  116. package/lib/lookup-diagnostics.mjs +24 -1
  117. package/lib/lookup-progress.mjs +30 -13
  118. package/lib/lookup-response-contract.mjs +1048 -0
  119. package/lib/lookup-response.mjs +8 -10
  120. package/lib/lookup-source-progress.mjs +40 -14
  121. package/lib/lookup.mjs +48 -27
  122. package/lib/nameserver-preflight-review.mjs +1 -0
  123. package/lib/observed-network-context.mjs +43 -27
  124. package/lib/outbound-request-bounds.mjs +4 -1
  125. package/lib/page-fingerprints.mjs +69 -133
  126. package/lib/page-role-profile.mjs +12 -8
  127. package/lib/perceptual-hash.mjs +43 -14
  128. package/lib/rdap-bootstrap.mjs +72 -22
  129. package/lib/rdap-capabilities.mjs +1 -1
  130. package/lib/rdap-client.mjs +15 -7
  131. package/lib/rdap-normalization.mjs +33 -18
  132. package/lib/rdap-search-workbench.mjs +1 -1
  133. package/lib/rdap-transport.mjs +47 -20
  134. package/lib/rdap-types.mjs +2 -1
  135. package/lib/rdap.mjs +18 -3
  136. package/lib/registrable-domain.mjs +21 -1
  137. package/lib/registrar-standing-contract.mjs +1 -0
  138. package/lib/registry-capabilities.mjs +5 -1
  139. package/lib/registry-capability-catalogue.mjs +2 -1
  140. package/lib/registry-dates.mjs +2 -190
  141. package/lib/registry-insights.mjs +85 -34
  142. package/lib/response-policy.mjs +86 -48
  143. package/lib/risk-calibration-summary.mjs +18 -6
  144. package/lib/rpki-evidence.mjs +8 -5
  145. package/lib/safe-fetch.mjs +54 -47
  146. package/lib/security-txt.mjs +42 -7
  147. package/lib/service-binding-dns.mjs +8 -0
  148. package/lib/smtp-transport-review.mjs +27 -15
  149. package/lib/sslbl-certificates.generated.mjs +136 -135
  150. package/lib/static-html-analysis.mjs +178 -151
  151. package/lib/structured-data-identity.mjs +1 -0
  152. package/lib/technology-markup.mjs +100 -0
  153. package/lib/threat-intelligence-manifests.mjs +11 -139
  154. package/lib/threat-intelligence-runtime.mjs +24 -52
  155. package/lib/threat-intelligence-targets.mjs +6 -34
  156. package/lib/threat-intelligence-types.mjs +39 -0
  157. package/lib/typosquat-generator.mjs +19 -15
  158. package/lib/urlhaus-intelligence.mjs +8 -3
  159. package/lib/urlscan-intelligence.mjs +1 -1
  160. package/lib/website-security-posture.mjs +30 -6
  161. package/lib/website-technology.mjs +153 -45
  162. package/lib/whois-authority.mjs +14 -2
  163. package/lib/whois-chain.mjs +9 -2
  164. package/lib/whois-common-formats.mjs +5 -5
  165. package/lib/whois-contracts.mjs +2 -1
  166. package/lib/whois-dialects.mjs +2 -2
  167. package/lib/whois-parser-support.mjs +8 -1
  168. package/lib/whois-transport.mjs +23 -7
  169. package/lib/zone-intent-review.mjs +16 -6
  170. package/package.json +2 -1
  171. package/packages/cases/case-attachment-model.mjs +83 -0
  172. package/packages/cases/case-evidence-model.mjs +156 -62
  173. package/packages/cases/case-export-input.mjs +53 -0
  174. package/packages/cases/case-migration-model.mjs +127 -44
  175. package/packages/cases/case-model.mjs +1 -0
  176. package/packages/cases/case-recheck-model.mjs +91 -0
  177. package/packages/cases/case-record-contracts.mjs +5 -20
  178. package/packages/cases/case-record-core.mjs +12 -22
  179. package/packages/cases/case-record-decisions.mjs +72 -0
  180. package/packages/cases/case-record-model.mjs +1 -0
  181. package/packages/cases/case-record-operations.mjs +95 -31
  182. package/packages/cases/case-record-projection.mjs +221 -0
  183. package/packages/cases/case-report.mjs +70 -54
  184. package/packages/cases/case-response-actions.mjs +654 -0
  185. package/packages/cases/case-response-model.mjs +64 -1115
  186. package/packages/cases/case-response-outcomes.mjs +317 -0
  187. package/packages/cases/case-response-packet-render.mjs +153 -0
  188. package/packages/cases/case-response-packet-types.mjs +1 -0
  189. package/packages/cases/case-response-packet-vocabulary.mjs +48 -0
  190. package/packages/cases/case-response-packet.mjs +74 -572
  191. package/packages/cases/case-response-records.mjs +94 -0
  192. package/packages/cases/case-response-review-inputs.mjs +371 -0
  193. package/packages/cases/case-response-values.mjs +85 -0
  194. package/packages/cases/case-review-package.mjs +45 -0
  195. package/packages/cases/case-selection.mjs +46 -0
  196. package/packages/cases/case-storage-model.mjs +31 -2
  197. package/packages/cases/response-route-freshness.mjs +16 -0
  198. package/packages/comparison/capture-context.mjs +61 -0
  199. package/packages/comparison/comparison-ledger-bulk.mjs +13 -11
  200. package/packages/comparison/page-similarity.mjs +5 -2
  201. package/packages/comparison/relationship-evidence.mjs +85 -8
  202. package/packages/comparison/relationship-provenance.mjs +93 -0
  203. package/packages/contracts/analyst-interchange.mjs +216 -0
  204. package/packages/contracts/analyst-review-state-contract.mjs +11 -0
  205. package/packages/contracts/analyst-review-state.mjs +5 -4
  206. package/packages/contracts/case-portability.mjs +366 -83
  207. package/packages/contracts/case-views-contract.mjs +12 -0
  208. package/packages/contracts/case-views.mjs +26 -0
  209. package/packages/contracts/cli-command-semantics.mjs +94 -0
  210. package/packages/contracts/cli-lookup.mjs +0 -1
  211. package/packages/contracts/domain-control-flight-recorder.mjs +80 -42
  212. package/packages/contracts/domain-control-manifest.mjs +127 -73
  213. package/packages/contracts/domain-control-monitor.mjs +48 -45
  214. package/packages/contracts/domain-control-review.mjs +179 -121
  215. package/packages/contracts/domain-name.mjs +21 -0
  216. package/packages/contracts/external-observation-interchange.mjs +2 -1
  217. package/packages/contracts/extracted-domain-lifecycle.mjs +11 -32
  218. package/packages/contracts/http-url.mjs +2 -0
  219. package/packages/contracts/investigation-package-limits.mjs +24 -0
  220. package/packages/contracts/investigation-portability.mjs +56 -27
  221. package/packages/contracts/investigation-run.mjs +16 -0
  222. package/packages/contracts/ip-address.mjs +34 -0
  223. package/packages/contracts/lookup-evidence.mjs +35 -0
  224. package/packages/contracts/offline-comparison.mjs +1 -2
  225. package/packages/contracts/page-fingerprints.mjs +15 -0
  226. package/packages/contracts/risk-calibration.mjs +15 -11
  227. package/packages/contracts/schema-compatibility.mjs +1 -1
  228. package/packages/contracts/schema-lifecycle.mjs +83 -153
  229. package/packages/contracts/selected-file-limits.mjs +6 -0
  230. package/packages/contracts/vulnerability-identifiers.mjs +5 -0
  231. package/packages/contracts/web-capture.mjs +10 -0
  232. package/packages/contracts/workspace-portability.mjs +183 -211
  233. package/packages/evidence/artifact-integrity.mjs +8 -2
  234. package/packages/evidence/artifact-structure.mjs +105 -0
  235. package/packages/evidence/decision-fact.mjs +4 -1
  236. package/packages/evidence/domain-control-runtime.mjs +201 -33
  237. package/packages/evidence/domain-posture-context.mjs +79 -0
  238. package/packages/evidence/image-regions.mjs +56 -0
  239. package/packages/evidence/latest-observations.mjs +28 -0
  240. package/packages/evidence/lookup-target.mjs +104 -0
  241. package/packages/evidence/observation.mjs +16 -1
  242. package/packages/evidence/passphrase-encryption.mjs +44 -0
  243. package/packages/evidence/record-identity.mjs +72 -0
  244. package/packages/evidence/registry-dates.mjs +190 -0
  245. package/packages/evidence/retained-file.mjs +69 -0
  246. package/packages/evidence/review-age.mjs +14 -0
  247. package/packages/interchange/bagit.mjs +342 -0
  248. package/packages/interchange/bounded-zip-extraction.mjs +278 -0
  249. package/packages/interchange/crc32.mjs +17 -0
  250. package/packages/interchange/external-findings-import.mjs +92 -51
  251. package/packages/interchange/web-capture-import.mjs +382 -0
  252. package/packages/investigation/bulk-mail-exposure.mjs +2 -1
  253. package/packages/investigation/investigation-bagit.mjs +14 -0
  254. package/packages/investigation/investigation-capsule.mjs +33 -5
  255. package/packages/investigation/investigation-manifest.mjs +200 -0
  256. package/packages/investigation/investigation-package-crypto.mjs +98 -0
  257. package/packages/investigation/investigation-package.mjs +200 -0
  258. package/packages/investigation/lookup-asset-graph-inputs.mjs +38 -0
  259. package/packages/investigation/lookup-asset-graph.mjs +193 -165
  260. package/packages/investigation/lookup-claim-readiness.mjs +18 -0
  261. package/packages/investigation/lookup-investigation-brief.mjs +3 -3
  262. package/packages/investigation/lookup-task-guidance.mjs +58 -0
  263. package/packages/monitoring/analyst-review-state.mjs +38 -90
  264. package/packages/workspace/brand-profile-model.mjs +276 -22
  265. package/packages/workspace/bulk-columns.mjs +17 -0
  266. package/packages/workspace/bulk-review-model.mjs +23 -11
  267. package/packages/workspace/bulk-session-model.mjs +100 -24
  268. package/packages/workspace/bulk-sort.mjs +1 -0
  269. package/packages/workspace/case-views.mjs +102 -0
  270. package/packages/workspace/detection-rule-model.mjs +70 -22
  271. package/packages/workspace/hostile-input.mjs +29 -8
  272. package/packages/workspace/investigation-template-model.mjs +19 -4
  273. package/packages/workspace/page-baseline.mjs +18 -15
  274. package/packages/workspace/relationship-observation-model.mjs +41 -23
  275. package/packages/workspace/watchlist-history.mjs +2 -2
  276. package/packages/workspace/watchlist-store.mjs +8 -2
  277. package/packages/workspace/website-snapshot-model.mjs +164 -26
  278. package/packages/workspace/workspace-archive-crypto.mjs +41 -61
  279. package/packages/workspace/workspace-archive.mjs +87 -21
  280. package/third-party-notices.txt +29 -2
  281. package/cli/evidence-command-arguments.mjs +0 -156
  282. package/packages/investigation/bulk-review-export.mjs +0 -63
@@ -2,7 +2,7 @@ import { Buffer } from 'node:buffer';
2
2
  import { homedir } from 'node:os';
3
3
  import { join, resolve } from 'node:path';
4
4
  import { CliUsageError } from "./errors.mjs";
5
- import { cliMetaActionForInvocation } from "./command-reference.mjs";
5
+ import { cliInvocationOptionIndices, cliMetaActionForInvocation } from "./command-reference.mjs";
6
6
  import { readBoundedRegularTextFile } from "../lib/bounded-file.mjs";
7
7
  import { scanBoundedJson } from "../lib/bounded-json.mjs";
8
8
  export const CLI_CONFIG_SCHEMA = 'whoisleuth.cli.config';
@@ -107,7 +107,8 @@ async function readConfigFile(path) {
107
107
  }
108
108
  function suppliedGroups(argumentsList) {
109
109
  const groups = new Set();
110
- for (const argument of argumentsList) {
110
+ for (const index of cliInvocationOptionIndices(argumentsList)) {
111
+ const argument = argumentsList[index];
111
112
  const group = OPTION_GROUPS[argument];
112
113
  if (group)
113
114
  groups.add(group);
@@ -119,10 +120,11 @@ function suppliedGroups(argumentsList) {
119
120
  export async function resolveCliProfileArguments(argv, options = {}) {
120
121
  if (cliMetaActionForInvocation(argv))
121
122
  return [...argv];
123
+ const optionIndices = cliInvocationOptionIndices(argv);
122
124
  // registry-scaffold owns --profile as the capability template to generate.
123
125
  // Do not reinterpret it as a global CLI-default profile.
124
126
  if (argv[0] === 'registry-scaffold') {
125
- if (argv.includes('--config')) {
127
+ if (argv.some((argument, index) => argument === '--config' && optionIndices.has(index))) {
126
128
  throw new CliUsageError('registry-scaffold does not accept global CLI configuration options.');
127
129
  }
128
130
  return [...argv];
@@ -134,7 +136,7 @@ export async function resolveCliProfileArguments(argv, options = {}) {
134
136
  const argument = argv[index];
135
137
  if (argument === undefined)
136
138
  break;
137
- if (argument === '--config' || argument === '--profile') {
139
+ if (optionIndices.has(index) && (argument === '--config' || argument === '--profile')) {
138
140
  const next = argv[++index];
139
141
  if (!next || next.startsWith('-'))
140
142
  throw new CliUsageError(`${argument} requires one value.`);
@@ -177,7 +179,7 @@ export async function resolveCliProfileArguments(argv, options = {}) {
177
179
  throw new CliUsageError(`CLI profile "${selectedName}" was not found.`);
178
180
  if (!retained.length)
179
181
  throw new CliUsageError('A CLI profile must accompany a command.');
180
- const explicitGroups = suppliedGroups(retained.slice(1));
182
+ const explicitGroups = suppliedGroups(retained);
181
183
  const defaults = [];
182
184
  for (let index = 0; index < selected.arguments.length; index += 1) {
183
185
  const argument = selected.arguments[index];
@@ -1,14 +1,15 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { domainToASCII } from 'node:url';
3
3
  import { exactKeys, requireBoundedString, requireIsoTimestamp, requireRecord, } from "../lib/bounded-contract-normalizers.mjs";
4
+ import { EXTERNAL_FINDINGS_SCHEMA, EXTERNAL_FINDINGS_VERSION, MAX_EXTERNAL_FINDINGS, MAX_EXTERNAL_FINDING_DOMAINS, MAX_EXTERNAL_FINDINGS_PER_DOMAIN, MAX_EXTERNAL_FINDING_LIMITATIONS, MAX_EXTERNAL_FINDING_LIMITATION_LENGTH, retainExternalFindingLimitations, } from "../packages/interchange/external-findings-import.mjs";
4
5
  export const CT_EVENT_BATCH_SCHEMA = 'whoisleuth.ct-event-batch';
5
6
  export const CT_EVENT_BATCH_VERSION = 1;
6
7
  export const MAX_CT_EVENT_INPUT_BYTES = 4 * 1024 * 1024;
7
8
  export const MAX_CT_EVENTS = 500;
8
9
  export const MAX_CT_EVENT_NAMES = 100;
9
- export const MAX_CT_EXTERNAL_FINDINGS = 100;
10
- export const MAX_CT_EXTERNAL_DOMAINS = 25;
11
- export const MAX_CT_EXTERNAL_FINDINGS_PER_DOMAIN = 20;
10
+ export const MAX_CT_EXTERNAL_FINDINGS = MAX_EXTERNAL_FINDINGS;
11
+ export const MAX_CT_EXTERNAL_DOMAINS = MAX_EXTERNAL_FINDING_DOMAINS;
12
+ export const MAX_CT_EXTERNAL_FINDINGS_PER_DOMAIN = MAX_EXTERNAL_FINDINGS_PER_DOMAIN;
12
13
  const ROOT_KEYS = new Set(['schema', 'version', 'source', 'events']);
13
14
  const SOURCE_KEYS = new Set(['name', 'reference', 'collectedAt']);
14
15
  const EVENT_KEYS = new Set([
@@ -47,9 +48,9 @@ function domain(value, label) {
47
48
  function boundedLimitations(value, label) {
48
49
  if (value === undefined)
49
50
  return Object.freeze([]);
50
- if (!Array.isArray(value) || value.length > 8)
51
- throw new TypeError(`${label} must contain no more than 8 entries.`);
52
- return Object.freeze([...new Set(value.map((item, index) => requireBoundedString(item, `${label}[${index}]`, 240)))]);
51
+ if (!Array.isArray(value) || value.length > MAX_EXTERNAL_FINDING_LIMITATIONS)
52
+ throw new TypeError(`${label} must contain no more than ${MAX_EXTERNAL_FINDING_LIMITATIONS} entries.`);
53
+ return Object.freeze([...new Set(value.map((item, index) => requireBoundedString(item, `${label}[${index}]`, MAX_EXTERNAL_FINDING_LIMITATION_LENGTH)))]);
53
54
  }
54
55
  export function buildCtEventFindings(inputRaw) {
55
56
  const input = requireRecord(inputRaw, 'Certificate event batch');
@@ -155,19 +156,18 @@ export function buildCtEventFindings(inputRaw) {
155
156
  namesComplete: finding.structuredObservation.namesComplete
156
157
  && selectedNamesByEvent.get(finding.structuredObservation.eventId) === finding.structuredObservation.dnsNameCount,
157
158
  }),
158
- limitations: Object.freeze([
159
- ...finding.limitations,
159
+ limitations: retainExternalFindingLimitations(finding.limitations, [
160
160
  'The supplied event is an observation, not proof that the certificate was served by the named domain or that the domain operator requested it.',
161
161
  ...(finding.structuredObservation.namesComplete
162
162
  && selectedNamesByEvent.get(finding.structuredObservation.eventId) !== finding.structuredObservation.dnsNameCount
163
163
  ? ['The bounded import did not retain every DNS name from this certificate event.']
164
164
  : []),
165
- ...(truncated ? [`The batch contained ${unique.length} unique domain observations; this import preserves the first ${MAX_CT_EXTERNAL_FINDINGS} in deterministic order.`] : []),
166
- ].slice(0, 8)),
165
+ ...(truncated ? [`The batch contained ${unique.length} unique domain observations; this import preserves the first ${selected.length} in deterministic order.`] : []),
166
+ ]),
167
167
  }));
168
168
  return Object.freeze({
169
- schema: 'whoisleuth.external-findings',
170
- schemaVersion: 4,
169
+ schema: EXTERNAL_FINDINGS_SCHEMA,
170
+ schemaVersion: EXTERNAL_FINDINGS_VERSION,
171
171
  source,
172
172
  findings: Object.freeze(findings),
173
173
  });
package/cli/discover.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  import { Buffer } from 'node:buffer';
2
2
  import { decodeBoundedUtf8 } from "../lib/bounded-file.mjs";
3
3
  import { CliUsageError } from "./arguments.mjs";
4
+ import { normalizeDiscoverySuffix } from "../lib/registrable-domain.mjs";
4
5
  const DEFAULT_DISCOVERY_TLDS = Object.freeze(['com', 'net', 'org']);
5
6
  const MAX_DISCOVERY_TLD_TEXT_LENGTH = 1024;
6
7
  const MAX_DISCOVERY_TLD_TOKENS_INSPECTED = 80;
@@ -40,9 +41,9 @@ function normalizeDiscoveryTlds(raw, maximum) {
40
41
  const values = [];
41
42
  const seen = new Set();
42
43
  for (const token of tokens) {
43
- const value = token.toLowerCase().replace(/^\./, '');
44
- if (!/^[a-z]{2,63}$/.test(value))
45
- throw new CliUsageError(`Invalid TLD "${token}".`);
44
+ const value = normalizeDiscoverySuffix(token);
45
+ if (!value)
46
+ throw new CliUsageError(`Invalid TLD or public suffix "${token}".`);
46
47
  if (seen.has(value))
47
48
  continue;
48
49
  seen.add(value);
@@ -84,7 +84,11 @@ function normalizeObservation(value) {
84
84
  return null;
85
85
  const latestRegistrationState = item.latestRegistrationState;
86
86
  const latestDnsState = item.latestDnsState;
87
+ const collectionOrigin = item.collectionOrigin === undefined
88
+ ? 'current_run'
89
+ : item.collectionOrigin;
87
90
  if (!['observed', 'unavailable'].includes(String(latestRegistrationState))
91
+ || !['current_run', 'resumed_checkpoint'].includes(String(collectionOrigin))
88
92
  || typeof latestDnsState !== 'string' || !latestDnsState || latestDnsState.length > 40
89
93
  || /[\u0000-\u001f\u007f]/u.test(latestDnsState))
90
94
  return null;
@@ -96,6 +100,7 @@ function normalizeObservation(value) {
96
100
  registrationObservedAt,
97
101
  dnsObservedAt,
98
102
  latestAttemptAt: normalizedTimestamp(item.latestAttemptAt, 'Discovery latest-attempt time') ?? '',
103
+ collectionOrigin: collectionOrigin,
99
104
  latestAttemptState: item.latestAttemptState,
100
105
  latestRegistrationState: latestRegistrationState,
101
106
  latestDnsState,
@@ -166,47 +171,80 @@ async function readExisting(path) {
166
171
  throw boundedCliInputError(error, 'Discovery observation snapshot');
167
172
  }
168
173
  }
174
+ const DNS_MATERIAL_FIELDS = ['a', 'aaaa', 'ns', 'mx', 'hasNullMx', 'hasSpf', 'hasDmarc', 'status'];
175
+ function comparableValue(value) {
176
+ return Array.isArray(value) ? [...new Set(value.map((item) => String(item).toLowerCase()))].sort() : value;
177
+ }
178
+ function dnsMaterial(dns) {
179
+ return JSON.stringify(DNS_MATERIAL_FIELDS.map((field) => comparableValue(dns[field])));
180
+ }
181
+ function componentRecency(previousAt, previousMaterial, currentAt, currentMaterial) {
182
+ if (!previousAt || Date.parse(currentAt) > Date.parse(previousAt))
183
+ return 'newer';
184
+ if (Date.parse(currentAt) < Date.parse(previousAt))
185
+ return 'older';
186
+ return previousMaterial === currentMaterial ? 'equal' : 'conflicting';
187
+ }
169
188
  function currentAttempt(domain, item, previous, generatedAt, deep) {
189
+ const suppliedObservedAt = item.observedAt === null || item.observedAt === undefined
190
+ ? null
191
+ : normalizedTimestamp(item.observedAt, 'Discovery checkpoint observation time');
192
+ if (item.collectionOrigin === 'resumed_checkpoint' && !suppliedObservedAt) {
193
+ throw new CliUsageError('Discovery resume requires a valid observation time for retained checkpoint evidence.');
194
+ }
195
+ const attemptAt = suppliedObservedAt ?? generatedAt;
170
196
  const availability = item.ok ? record(record(item.result).availability) : {};
171
197
  const currentAvailabilityState = availabilityState(item) || 'unknown';
172
198
  const registrationObserved = item.ok && currentAvailabilityState !== 'unknown';
199
+ const currentConfidence = registrationObserved && typeof availability.confidence === 'string'
200
+ ? availability.confidence.replace(/[\u0000-\u001f\u007f]+/gu, ' ').trim().slice(0, 40) || 'unknown'
201
+ : 'unknown';
173
202
  const currentDns = bulkDnsSummary(item);
174
203
  const dnsComplete = deep && currentDns.status === 'success';
175
204
  const dnsRetainable = deep && currentDns.status === 'partial';
176
205
  const previousDnsRetainable = previous && ['partial', 'success'].includes(previous.dns.status);
177
- const dns = dnsComplete || (dnsRetainable && !previousDnsRetainable)
178
- ? currentDns
206
+ const registrationRecency = componentRecency(previous?.registrationObservedAt, JSON.stringify([previous?.availabilityState, previous?.confidence]), attemptAt, JSON.stringify([currentAvailabilityState, currentConfidence]));
207
+ const dnsRecency = componentRecency(previous?.dnsObservedAt, previous ? dnsMaterial(previous.dns) : '', attemptAt, dnsMaterial(currentDns));
208
+ const registrationAdmitted = registrationObserved && ['newer', 'equal'].includes(registrationRecency);
209
+ const dnsAdmitted = (dnsComplete || (dnsRetainable && !previousDnsRetainable)) && ['newer', 'equal'].includes(dnsRecency);
210
+ const dns = dnsAdmitted
211
+ ? dnsRecency === 'equal' && previous ? previous.dns : currentDns
179
212
  : previousDnsRetainable ? previous.dns : currentDns;
180
- const registrationObservedAt = registrationObserved ? generatedAt : previous?.registrationObservedAt ?? null;
181
- const dnsObservedAt = dnsComplete || (dnsRetainable && !previousDnsRetainable)
182
- ? generatedAt
213
+ const registrationObservedAt = registrationAdmitted ? attemptAt : previous?.registrationObservedAt ?? null;
214
+ const dnsObservedAt = dnsAdmitted
215
+ ? attemptAt
183
216
  : previous?.dnsObservedAt ?? null;
184
217
  const unavailableComponents = [];
185
- if (!registrationObserved)
218
+ if (!registrationAdmitted)
186
219
  unavailableComponents.push('registration');
187
- if (deep && !dnsComplete)
220
+ if (deep && !(dnsComplete && dnsAdmitted))
188
221
  unavailableComponents.push('dns');
189
222
  const attemptState = !item.ok
190
223
  ? 'error'
191
224
  : unavailableComponents.length ? 'partial' : 'success';
192
- const confidence = registrationObserved && typeof availability.confidence === 'string'
193
- ? availability.confidence.replace(/[\u0000-\u001f\u007f]+/gu, ' ').trim().slice(0, 40) || 'unknown'
225
+ const confidence = registrationAdmitted
226
+ ? currentConfidence
194
227
  : previous?.confidence ?? 'unknown';
195
- const retainedAvailabilityState = registrationObserved
228
+ const retainedAvailabilityState = registrationAdmitted
196
229
  ? currentAvailabilityState
197
230
  : previous?.availabilityState ?? 'unknown';
198
231
  return {
199
- registrationObserved,
200
- dnsComplete,
232
+ registrationObserved: registrationAdmitted,
233
+ dnsComplete: dnsComplete && dnsAdmitted,
201
234
  unavailableComponents,
235
+ withheld: [
236
+ ...(registrationObserved && !registrationAdmitted ? [`${registrationRecency === 'older' ? 'Older' : 'Conflicting equal-time'} registration observations`] : []),
237
+ ...((dnsComplete || dnsRetainable) && ['older', 'conflicting'].includes(dnsRecency) ? [`${dnsRecency === 'older' ? 'Older' : 'Conflicting equal-time'} DNS observations`] : []),
238
+ ],
202
239
  observation: {
203
240
  domain,
204
241
  observedAt: latestObservedAt(registrationObservedAt, dnsObservedAt),
205
242
  registrationObservedAt,
206
243
  dnsObservedAt,
207
- latestAttemptAt: generatedAt,
244
+ latestAttemptAt: attemptAt,
245
+ collectionOrigin: item.collectionOrigin ?? 'current_run',
208
246
  latestAttemptState: attemptState,
209
- latestRegistrationState: registrationObserved ? 'observed' : 'unavailable',
247
+ latestRegistrationState: registrationAdmitted ? 'observed' : 'unavailable',
210
248
  latestDnsState: deep ? currentDns.status : 'not_requested',
211
249
  availabilityState: retainedAvailabilityState,
212
250
  confidence,
@@ -220,7 +258,7 @@ function materialChanges(previous, current, attempt = {
220
258
  }) {
221
259
  const changes = [];
222
260
  const compare = (field, before, after) => {
223
- if (JSON.stringify(before) !== JSON.stringify(after))
261
+ if (JSON.stringify(comparableValue(before)) !== JSON.stringify(comparableValue(after)))
224
262
  changes.push({ field, before, after });
225
263
  };
226
264
  if (attempt.registrationObserved) {
@@ -228,10 +266,9 @@ function materialChanges(previous, current, attempt = {
228
266
  compare('confidence', previous.confidence, current.confidence);
229
267
  }
230
268
  if (attempt.dnsComplete) {
231
- for (const field of ['a', 'aaaa', 'ns', 'mx', 'hasNullMx', 'hasSpf', 'hasDmarc']) {
269
+ for (const field of DNS_MATERIAL_FIELDS) {
232
270
  compare(`dns.${field}`, previous.dns[field], current.dns[field]);
233
271
  }
234
- compare('dns.status', previous.dns.status, current.dns.status);
235
272
  }
236
273
  return changes;
237
274
  }
@@ -252,6 +289,7 @@ async function updateDiscoveryObservationSnapshot(path, candidates, items, confi
252
289
  const changed = [];
253
290
  const unavailable = [];
254
291
  const unavailableComponents = [];
292
+ const withheld = new Map();
255
293
  for (let index = 0; index < domains.length; index++) {
256
294
  const domain = domains[index];
257
295
  const item = items[index];
@@ -260,6 +298,8 @@ async function updateDiscoveryObservationSnapshot(path, candidates, items, confi
260
298
  const previous = priorByDomain.get(domain);
261
299
  const attempt = currentAttempt(domain, item, previous, currentGeneratedAt, configuration.deep);
262
300
  const current = attempt.observation;
301
+ for (const reason of attempt.withheld)
302
+ withheld.set(reason, (withheld.get(reason) ?? 0) + 1);
263
303
  if (attempt.unavailableComponents.length) {
264
304
  unavailable.push(domain);
265
305
  unavailableComponents.push({ domain, components: attempt.unavailableComponents });
@@ -297,7 +337,8 @@ async function updateDiscoveryObservationSnapshot(path, candidates, items, confi
297
337
  unchanged: prior === null ? 0 : domains.filter((domain) => !changed.some((item) => item.domain === domain) && !unavailable.includes(domain)).length,
298
338
  limitations: [
299
339
  'Only bounded registration-state and DNS summaries are retained; raw registry records, contacts, page contents, and request details are excluded.',
300
- 'Registration and DNS retain separate observation times. A failed or partial component preserves its previous usable evidence and is reported as unavailable rather than as a removal or negative finding.',
340
+ 'Registration and DNS retain separate observation times. Failed, incomplete, older or conflicting equal-time components preserve the prior usable baseline and are unavailable for a new comparison, not evidence of removal.',
341
+ ...[...withheld].map(([reason, count]) => `${reason}: ${count}. The retained component baseline was not replaced.`),
301
342
  'Material differences are observation changes for analyst review and do not establish ownership, control, intent, safety, or maliciousness.',
302
343
  ],
303
344
  };
@@ -62,6 +62,9 @@ async function runDiscoveryScanCommand(args, dependencies, context) {
62
62
  const classify = dependencies.classifyQuery || classifyQuery;
63
63
  const allowlist = await readAllowlist(args.allowlistSource, dependencies, context, classify);
64
64
  const checkpointWriter = dependencies.createBulkCheckpointWriter || createBulkCheckpointWriter;
65
+ const collectionContext = resolverServers.length
66
+ ? { dnsResolver: 'analyst_selected', resolverServers: [...resolverServers] }
67
+ : { dnsResolver: 'system_default', resolverServers: [] };
65
68
  const checkpoint = args.checkpoint
66
69
  ? await checkpointWriter({
67
70
  path: args.checkpoint,
@@ -72,6 +75,20 @@ async function runDiscoveryScanCommand(args, dependencies, context) {
72
75
  ...(dependencies.now ? { now: dependencies.now } : {}),
73
76
  })
74
77
  : null;
78
+ for (const item of checkpoint?.initialResults ?? []) {
79
+ const retainedContext = item.collectionContext;
80
+ const contextMatches = retainedContext
81
+ ? retainedContext.dnsResolver === collectionContext.dnsResolver
82
+ && retainedContext.resolverServers.length === collectionContext.resolverServers.length
83
+ && retainedContext.resolverServers.every((server, index) => server === collectionContext.resolverServers[index])
84
+ : collectionContext.dnsResolver === 'system_default';
85
+ if (!contextMatches) {
86
+ throw new CliUsageError('Bulk checkpoint collection context does not match the current DNS resolver selection.');
87
+ }
88
+ if (item.observedAt === null || item.observedAt === undefined) {
89
+ throw new CliUsageError('Discovery resume requires an observation time for every retained checkpoint result.');
90
+ }
91
+ }
75
92
  const indicator = context.beginProgress(`Collecting 0 of ${queries.length} generated candidates`);
76
93
  let completed = checkpoint?.initialResults.length || 0;
77
94
  const resumedItems = new Map((checkpoint?.initialResults ?? []).map((item) => [item.index, item]));
@@ -95,6 +112,7 @@ async function runDiscoveryScanCommand(args, dependencies, context) {
95
112
  ...item,
96
113
  observedAt: context.now(),
97
114
  collectionOrigin: 'current_run',
115
+ collectionContext,
98
116
  };
99
117
  settledItems.set(item.index, observed);
100
118
  completed += 1;
@@ -107,6 +125,7 @@ async function runDiscoveryScanCommand(args, dependencies, context) {
107
125
  ...item,
108
126
  observedAt: null,
109
127
  collectionOrigin: 'current_run',
128
+ collectionContext,
110
129
  });
111
130
  }
112
131
  finally {
@@ -144,6 +163,8 @@ async function runDiscoveryScanCommand(args, dependencies, context) {
144
163
  context.writeStdout(formatDiscoveryScanJsonLines(document));
145
164
  else if (args.output === 'csv')
146
165
  context.writeStdout(formatDiscoveryScanCsv(document));
166
+ else if (args.output === 'csv_metadata')
167
+ context.writeStdout(formatDiscoveryScanCsv(document, true));
147
168
  else if (args.output === 'domains')
148
169
  context.writeStdout(formatDiscoveryScanDomains(document));
149
170
  else
@@ -1,8 +1,9 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { Buffer } from 'node:buffer';
3
3
  import { decodeBoundedUtf8 } from "../lib/bounded-file.mjs";
4
+ import { MAX_DISCOVERY_SUFFIX_LENGTH } from "../lib/registrable-domain.mjs";
4
5
  import { runBulkLookups } from "./bulk.mjs";
5
- import { REGISTERED_STATES, availabilityState, bulkDnsSummary } from "./bulk-output.mjs";
6
+ import { REGISTERED_STATES, BULK_CSV_METADATA_COLUMNS, availabilityState, bulkCsvMetadataValues, bulkDnsSummary } from "./bulk-output.mjs";
6
7
  import { cliCsvCell } from "./csv.mjs";
7
8
  import { CliUsageError } from "./errors.mjs";
8
9
  import { terminalSafeJson } from "./formatters/json.mjs";
@@ -70,7 +71,7 @@ function parseDiscoveryScanAllowlist(text, classifyQuery) {
70
71
  function normalizedCandidate(candidate) {
71
72
  const domain = boundedText(candidate.domain, 253).toLowerCase();
72
73
  const source = boundedText(candidate.source, 253).toLowerCase();
73
- const tld = boundedText(candidate.tld, 63).toLowerCase();
74
+ const tld = boundedText(candidate.tld, MAX_DISCOVERY_SUFFIX_LENGTH).toLowerCase();
74
75
  const mutationTypes = Array.isArray(candidate.mutationTypes)
75
76
  ? [...new Set(candidate.mutationTypes.flatMap((value) => {
76
77
  const normalized = boundedText(value, 80);
@@ -125,6 +126,12 @@ function buildScanItem(candidate, item, allowlist, relationshipIds = []) {
125
126
  dnsSummary: bulkDnsSummary(item),
126
127
  review: { lane: review.lane, reasons: review.reasons },
127
128
  relationshipIds: [...relationshipIds],
129
+ observedAt: item.observedAt ?? null,
130
+ collectionOrigin: item.collectionOrigin ?? 'current_run',
131
+ collectionContext: item.collectionContext ?? {
132
+ dnsResolver: 'system_default',
133
+ resolverServers: [],
134
+ },
128
135
  ...(item.ok ? {
129
136
  availability: result.availability,
130
137
  diagnostics: result.diagnostics,
@@ -302,12 +309,14 @@ function formatDiscoveryScanJsonLines(document) {
302
309
  ? `${document.results.map((item) => terminalSafeJson({ ...item, generatedAt: document.generatedAt })).join('\n')}\n`
303
310
  : '';
304
311
  }
305
- function formatDiscoveryScanCsv(document) {
306
- const header = ['domain', 'availability', 'confidence', 'review_lane', 'mutation_types', 'a', 'aaaa', 'ns', 'mx', 'relationship_ids', 'error'];
312
+ function formatDiscoveryScanCsv(document, includeMetadata = false) {
313
+ const header = ['domain', 'availability', 'confidence', 'review_lane', 'mutation_types', 'a', 'aaaa', 'ns', 'mx', 'relationship_ids', 'error',
314
+ ...(includeMetadata ? BULK_CSV_METADATA_COLUMNS : [])];
307
315
  const rows = document.results.map((item) => [
308
316
  item.domain, item.availabilityState, item.confidence, item.review.lane, item.mutationTypes,
309
317
  item.dnsSummary.a, item.dnsSummary.aaaa, item.dnsSummary.ns, item.dnsSummary.mx,
310
318
  item.relationshipIds, 'error' in item ? item.error : '',
319
+ ...(includeMetadata ? bulkCsvMetadataValues(item, document) : []),
311
320
  ].map(cliCsvCell).join(','));
312
321
  return `${[header.join(','), ...rows].join('\n')}\n`;
313
322
  }
@@ -47,7 +47,7 @@ async function generateDiscoveryCandidates(args, dependencies, context) {
47
47
  ...(args.preset === 'custom' ? { mutationTypes: mutationFamilies } : {}),
48
48
  });
49
49
  if (!result.inputValid) {
50
- throw new CliUsageError('discover requires a valid brand label or domain with one suffix label.');
50
+ throw new CliUsageError('discover requires a valid brand label or registrable domain, without a subdomain or URL.');
51
51
  }
52
52
  const normalizedDictionary = generator.normalizeCustomDictionaryTerms(dictionaryText);
53
53
  const metadata = {
@@ -1,13 +1,13 @@
1
1
  import { classifyQuery } from "../lib/classify.mjs";
2
2
  import { scanBoundedJson } from "../lib/bounded-json.mjs";
3
- import { buildDomainControlFlightRecorder, validateDomainControlFlightRecorderDocument, } from "../lib/domain-control-flight-recorder.mjs";
4
- import { DOMAIN_CONTROL_FLIGHT_RECORDER_SCHEMA, DOMAIN_CONTROL_FLIGHT_RECORDER_INPUT_SCHEMA, DOMAIN_CONTROL_FLIGHT_RECORDER_VERSION, } from "../packages/contracts/domain-control-flight-recorder.mjs";
5
- import { DOMAIN_CONTROL_REVIEW_SCHEMA, DOMAIN_CONTROL_REVIEW_VERSION, CLI_DOMAIN_CONTROL_REVIEW_INPUT_SCHEMA, CLI_DOMAIN_CONTROL_REVIEW_VERSION, } from "../packages/contracts/domain-control-review.mjs";
6
- import { CLI_DOMAIN_CONTROL_MONITOR_SCHEMA, CLI_DOMAIN_CONTROL_MONITOR_VERSION, DOMAIN_CONTROL_MONITOR_COLLECTION_KEYS, DOMAIN_CONTROL_MONITOR_FAILURE_CATEGORIES, DOMAIN_CONTROL_MONITOR_FAILURE_KEYS, DOMAIN_CONTROL_MONITOR_LIMITATIONS, DOMAIN_CONTROL_MONITOR_MANIFEST_KEYS, DOMAIN_CONTROL_MONITOR_ROOT_KEYS, MAX_DOMAIN_CONTROL_MONITOR_CONCURRENCY, MAX_DOMAIN_CONTROL_MONITOR_DOMAINS as CONTRACT_MAX_DOMAIN_CONTROL_MONITOR_DOMAINS, MAX_DOMAIN_CONTROL_MONITOR_ERROR_LENGTH, MAX_DOMAIN_CONTROL_MONITOR_FAILURES, MAX_DOMAIN_CONTROL_MONITOR_INPUT_BYTES, MAX_DOMAIN_CONTROL_MONITOR_JSON_CONTAINER_ITEMS, MAX_DOMAIN_CONTROL_MONITOR_JSON_DEPTH, MAX_DOMAIN_CONTROL_MONITOR_JSON_KEYS, MAX_DOMAIN_CONTROL_MONITOR_JSON_VALUES, MIN_DOMAIN_CONTROL_MONITOR_CONCURRENCY, MIN_DOMAIN_CONTROL_MONITOR_DOMAINS, } from "../packages/contracts/domain-control-monitor.mjs";
3
+ import { buildDomainControlFlightRecorder, validateDomainControlFlightRecorderDocument, normalizeDomainControlFlightRecorderObservations, } from "../lib/domain-control-flight-recorder.mjs";
4
+ import { DOMAIN_CONTROL_FLIGHT_RECORDER_SCHEMA, DOMAIN_CONTROL_FLIGHT_RECORDER_INPUT_SCHEMA, DOMAIN_CONTROL_FLIGHT_RECORDER_VERSION, PUBLIC_DOMAIN_CONTROL_FLIGHT_RECORDER_VERSION, } from "../packages/contracts/domain-control-flight-recorder.mjs";
5
+ import { DOMAIN_CONTROL_REVIEW_SCHEMA, DOMAIN_CONTROL_REVIEW_VERSION, PUBLIC_DOMAIN_CONTROL_REVIEW_VERSION, CLI_DOMAIN_CONTROL_REVIEW_INPUT_SCHEMA, CLI_DOMAIN_CONTROL_REVIEW_VERSION, } from "../packages/contracts/domain-control-review.mjs";
6
+ import { CLI_DOMAIN_CONTROL_MONITOR_SCHEMA, CLI_DOMAIN_CONTROL_MONITOR_VERSION, PUBLIC_CLI_DOMAIN_CONTROL_MONITOR_VERSION, SUPPORTED_CLI_DOMAIN_CONTROL_MONITOR_VERSIONS, DOMAIN_CONTROL_MONITOR_COLLECTION_KEYS, DOMAIN_CONTROL_MONITOR_FAILURE_CATEGORIES, DOMAIN_CONTROL_MONITOR_FAILURE_KEYS, DOMAIN_CONTROL_MONITOR_LIMITATIONS, DOMAIN_CONTROL_MONITOR_MANIFEST_KEYS, DOMAIN_CONTROL_MONITOR_ROOT_KEYS, MAX_DOMAIN_CONTROL_MONITOR_CONCURRENCY, MAX_DOMAIN_CONTROL_MONITOR_DOMAINS as CONTRACT_MAX_DOMAIN_CONTROL_MONITOR_DOMAINS, MAX_DOMAIN_CONTROL_MONITOR_ERROR_LENGTH, MAX_DOMAIN_CONTROL_MONITOR_FAILURES, MAX_DOMAIN_CONTROL_MONITOR_INPUT_BYTES, MAX_DOMAIN_CONTROL_MONITOR_JSON_CONTAINER_ITEMS, MAX_DOMAIN_CONTROL_MONITOR_JSON_DEPTH, MAX_DOMAIN_CONTROL_MONITOR_JSON_KEYS, MAX_DOMAIN_CONTROL_MONITOR_JSON_VALUES, MIN_DOMAIN_CONTROL_MONITOR_CONCURRENCY, MIN_DOMAIN_CONTROL_MONITOR_DOMAINS, } from "../packages/contracts/domain-control-monitor.mjs";
7
7
  import { validateDomainControlReviewDocument, verifyDomainControlManifest, } from "../lib/domain-control-manifest.mjs";
8
8
  import { requireDomainName, requireIsoTimestamp } from "../lib/bounded-contract-normalizers.mjs";
9
9
  import { buildCliLookupDocument } from "./formatters/json.mjs";
10
- import { buildCliDomainControlReview, } from "./domain-control-observations.mjs";
10
+ import { buildCliDomainControlReview, DOMAIN_CONTROL_REVIEW_SOURCE_FIELDS, } from "./domain-control-observations.mjs";
11
11
  import { CliUsageError } from "./errors.mjs";
12
12
  export { CLI_DOMAIN_CONTROL_MONITOR_SCHEMA, CLI_DOMAIN_CONTROL_MONITOR_VERSION };
13
13
  export const MAX_DOMAIN_CONTROL_MONITOR_DOMAINS = CONTRACT_MAX_DOMAIN_CONTROL_MONITOR_DOMAINS;
@@ -58,9 +58,12 @@ function previousSnapshot(input, currentGeneratedAt) {
58
58
  return { observations: [] };
59
59
  const parsed = parseMonitorJson(input, 'Previous monitor snapshot');
60
60
  const root = exactMonitorRecord(parsed, DOMAIN_CONTROL_MONITOR_ROOT_KEYS, 'Previous monitor snapshot');
61
- if (root.schema !== CLI_DOMAIN_CONTROL_MONITOR_SCHEMA || root.version !== CLI_DOMAIN_CONTROL_MONITOR_VERSION) {
62
- throw new CliUsageError(`Previous monitor snapshot must use ${CLI_DOMAIN_CONTROL_MONITOR_SCHEMA} version ${CLI_DOMAIN_CONTROL_MONITOR_VERSION}.`);
61
+ if (root.schema !== CLI_DOMAIN_CONTROL_MONITOR_SCHEMA || !SUPPORTED_CLI_DOMAIN_CONTROL_MONITOR_VERSIONS.some((version) => version === root.version)) {
62
+ throw new CliUsageError(`Previous monitor snapshot must use ${CLI_DOMAIN_CONTROL_MONITOR_SCHEMA} supported version ${SUPPORTED_CLI_DOMAIN_CONTROL_MONITOR_VERSIONS.join(' or ')}.`);
63
63
  }
64
+ const legacy = root.version === PUBLIC_CLI_DOMAIN_CONTROL_MONITOR_VERSION;
65
+ const reviewVersion = legacy ? PUBLIC_DOMAIN_CONTROL_REVIEW_VERSION : DOMAIN_CONTROL_REVIEW_VERSION;
66
+ const flightVersion = legacy ? PUBLIC_DOMAIN_CONTROL_FLIGHT_RECORDER_VERSION : DOMAIN_CONTROL_FLIGHT_RECORDER_VERSION;
64
67
  let previousGeneratedAt;
65
68
  try {
66
69
  previousGeneratedAt = requireIsoTimestamp(root.generatedAt, 'Previous monitor snapshot generatedAt');
@@ -83,7 +86,7 @@ function previousSnapshot(input, currentGeneratedAt) {
83
86
  catch {
84
87
  throw new CliUsageError('Previous monitor snapshot manifest expiresAt must be a valid ISO 8601 timestamp.');
85
88
  }
86
- if (Date.parse(manifest.expiresAt) <= Date.parse(previousGeneratedAt)) {
89
+ if (legacy && Date.parse(manifest.expiresAt) <= Date.parse(previousGeneratedAt)) {
87
90
  throw new CliUsageError('Previous monitor snapshot manifest must have been unexpired when the checkpoint was generated.');
88
91
  }
89
92
  const collection = exactMonitorRecord(root.collection, DOMAIN_CONTROL_MONITOR_COLLECTION_KEYS, 'Previous monitor snapshot collection');
@@ -124,25 +127,31 @@ function previousSnapshot(input, currentGeneratedAt) {
124
127
  let flightRecorder;
125
128
  try {
126
129
  review = validateDomainControlReviewDocument(root.review);
130
+ if (review.version !== reviewVersion)
131
+ throw new TypeError('Mismatched review version.');
127
132
  }
128
133
  catch {
129
- throw new CliUsageError(`Previous monitor snapshot review must use the exact ${DOMAIN_CONTROL_REVIEW_SCHEMA} version ${DOMAIN_CONTROL_REVIEW_VERSION} contract.`);
134
+ throw new CliUsageError(`Previous monitor snapshot review must use the exact ${DOMAIN_CONTROL_REVIEW_SCHEMA} version ${reviewVersion} contract.`);
130
135
  }
131
136
  try {
132
137
  flightRecorder = validateDomainControlFlightRecorderDocument(root.flightRecorder);
138
+ if (flightRecorder.version !== flightVersion)
139
+ throw new TypeError('Mismatched flight-recorder version.');
133
140
  }
134
141
  catch {
135
- throw new CliUsageError(`Previous monitor snapshot flight recorder must use the exact ${DOMAIN_CONTROL_FLIGHT_RECORDER_SCHEMA} version ${DOMAIN_CONTROL_FLIGHT_RECORDER_VERSION} contract.`);
142
+ throw new CliUsageError(`Previous monitor snapshot flight recorder must use the exact ${DOMAIN_CONTROL_FLIGHT_RECORDER_SCHEMA} version ${flightVersion} contract.`);
136
143
  }
137
144
  if (!Array.isArray(root.limitations)
138
145
  || root.limitations.length !== DOMAIN_CONTROL_MONITOR_LIMITATIONS.length
139
146
  || root.limitations.some((value, index) => value !== DOMAIN_CONTROL_MONITOR_LIMITATIONS[index])) {
140
147
  throw new CliUsageError('Previous monitor snapshot limitations are invalid.');
141
148
  }
149
+ let observations;
142
150
  try {
151
+ observations = normalizeDomainControlFlightRecorderObservations(root.observations, flightVersion);
143
152
  buildDomainControlFlightRecorder({
144
153
  schema: DOMAIN_CONTROL_FLIGHT_RECORDER_INPUT_SCHEMA,
145
- version: DOMAIN_CONTROL_FLIGHT_RECORDER_VERSION,
154
+ version: flightVersion,
146
155
  observations: root.observations,
147
156
  approvedWindows: [],
148
157
  }, previousGeneratedAt);
@@ -150,20 +159,30 @@ function previousSnapshot(input, currentGeneratedAt) {
150
159
  catch {
151
160
  throw new CliUsageError('Previous monitor snapshot observations must use the exact bounded flight-recorder input contract.');
152
161
  }
153
- const observations = root.observations;
154
162
  const observationDomainOrder = observations.map((observation) => observation.domain);
155
163
  const observationDomains = new Set(observationDomainOrder);
156
164
  const requestedDomains = review.domains.slice(0, requested).map((item) => item.domain);
157
165
  const expectedObservationDomains = requestedDomains.filter((domain) => !failureDomains.has(domain));
158
166
  const expectedFailureDomains = requestedDomains.filter((domain) => !observationDomains.has(domain));
167
+ const mismatchedSource = !legacy && review.domains.some((item) => {
168
+ const observation = observations.find((candidate) => candidate.domain === item.domain);
169
+ return item.comparisons.some((comparison) => {
170
+ if (comparison.source === null)
171
+ return false;
172
+ const field = observation?.fields.find((candidate) => DOMAIN_CONTROL_REVIEW_SOURCE_FIELDS[candidate.id] === comparison.field);
173
+ return !field || field.source !== comparison.source || field.observedAt !== comparison.observedAt;
174
+ });
175
+ });
159
176
  if (review.domains.length < requested
177
+ || mismatchedSource
160
178
  || observationDomains.size !== observations.length
161
179
  || observationDomainOrder.some((domain, index) => domain !== expectedObservationDomains[index])
162
180
  || observationDomainOrder.length !== expectedObservationDomains.length
163
181
  || failureDomainOrder.some((domain, index) => domain !== expectedFailureDomains[index])
164
182
  || failureDomainOrder.length !== expectedFailureDomains.length
165
- || observations.some((observation) => observation.observedAt !== previousGeneratedAt)
166
- || review.domains.some((item) => item.comparisons.some((comparison) => comparison.observedAt !== null && comparison.observedAt !== previousGeneratedAt))
183
+ || observations.some((observation) => legacy ? observation.capturedAt !== previousGeneratedAt
184
+ : Date.parse(observation.capturedAt) > Date.parse(previousGeneratedAt))
185
+ || review.domains.some((item) => item.comparisons.some((comparison) => legacy && comparison.observedAt !== null && comparison.observedAt !== previousGeneratedAt))
167
186
  || review.generatedAt !== previousGeneratedAt
168
187
  || review.manifest.digestSha256 !== manifest.digestSha256
169
188
  || review.manifest.expiresAt !== manifest.expiresAt
@@ -173,7 +192,11 @@ function previousSnapshot(input, currentGeneratedAt) {
173
192
  || [...observationDomains].some((domain) => !flightRecorder.domains.includes(domain))) {
174
193
  throw new CliUsageError('Previous monitor snapshot embedded documents are inconsistent with its observation projection.');
175
194
  }
176
- return { observations };
195
+ // Published checkpoints recorded run time, not independently retained source
196
+ // clocks. Their values remain available without upgrading that timestamp.
197
+ return { observations: legacy ? observations.map((item) => Object.freeze({
198
+ ...item, fields: Object.freeze(item.fields.map((field) => Object.freeze({ ...field, observedAt: null }))),
199
+ })) : observations };
177
200
  }
178
201
  function monitorFailureMessage() {
179
202
  return DOMAIN_CONTROL_MONITOR_FAILURE_CATEGORIES[0];
@@ -189,9 +212,9 @@ export async function runDomainControlMonitor(manifestInput, previousInput, opti
189
212
  || options.concurrency > MAX_DOMAIN_CONTROL_MONITOR_CONCURRENCY) {
190
213
  throw new CliUsageError(`Domain-control monitor concurrency must be from ${MIN_DOMAIN_CONTROL_MONITOR_CONCURRENCY} to ${MAX_DOMAIN_CONTROL_MONITOR_CONCURRENCY}.`);
191
214
  }
192
- let generatedAt;
215
+ let startedAt;
193
216
  try {
194
- generatedAt = requireIsoTimestamp(options.now(), 'Domain-control monitor time');
217
+ startedAt = requireIsoTimestamp(options.now(), 'Domain-control monitor time');
195
218
  }
196
219
  catch {
197
220
  throw new CliUsageError('Domain-control monitor time must be a valid ISO 8601 timestamp.');
@@ -204,11 +227,11 @@ export async function runDomainControlMonitor(manifestInput, previousInput, opti
204
227
  catch {
205
228
  throw new CliUsageError('Domain-control manifest must satisfy its supported bounded integrity contract.');
206
229
  }
207
- const checkedAt = Date.parse(generatedAt);
230
+ const checkedAt = Date.parse(startedAt);
208
231
  if (Date.parse(manifest.expiresAt) <= checkedAt) {
209
232
  throw new CliUsageError('Domain-control monitor requires an unexpired manifest.');
210
233
  }
211
- const previous = previousSnapshot(previousInput, generatedAt);
234
+ const previous = previousSnapshot(previousInput, startedAt);
212
235
  const entries = manifest.entries.slice(0, Math.min(options.limit, MAX_DOMAIN_CONTROL_MONITOR_DOMAINS));
213
236
  const lookups = new Array(entries.length);
214
237
  const failuresByIndex = new Array(entries.length);
@@ -225,7 +248,10 @@ export async function runDomainControlMonitor(manifestInput, previousInput, opti
225
248
  const classified = classifyQuery(entry.domain);
226
249
  const result = await options.executeLookup(classified, { fast: false, compact: false, ...(options.signal ? { signal: options.signal } : {}) });
227
250
  options.signal?.throwIfAborted();
228
- lookups[index] = buildCliLookupDocument(entry.domain, classified, result, generatedAt, 'deep');
251
+ const capturedAt = requireIsoTimestamp(options.now(), 'Domain-control capture time');
252
+ if (Date.parse(capturedAt) < checkedAt)
253
+ throw new CliUsageError('Domain-control capture time must not precede the run.');
254
+ lookups[index] = buildCliLookupDocument(entry.domain, classified, result, capturedAt, 'deep');
229
255
  }
230
256
  catch {
231
257
  if (options.signal?.aborted) {
@@ -245,6 +271,10 @@ export async function runDomainControlMonitor(manifestInput, previousInput, opti
245
271
  const successful = lookups.filter(Boolean);
246
272
  if (!successful.length)
247
273
  throw new Error('No domain-control monitor lookup completed successfully.');
274
+ const generatedAt = requireIsoTimestamp(options.now(), 'Domain-control completion time');
275
+ if (Date.parse(generatedAt) < checkedAt || successful.some((lookup) => Date.parse(String(lookup.generatedAt)) > Date.parse(generatedAt))) {
276
+ throw new CliUsageError('Domain-control completion time must not precede its captures.');
277
+ }
248
278
  const review = buildCliDomainControlReview(JSON.stringify({
249
279
  schema: CLI_DOMAIN_CONTROL_REVIEW_INPUT_SCHEMA,
250
280
  version: CLI_DOMAIN_CONTROL_REVIEW_VERSION,