@slicedearth/whoisleuth-cli 2.3.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 (264) hide show
  1. package/NOTICE +6 -0
  2. package/README.md +19 -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 +68 -6
  7. package/cli/artifact-validation/case-response.mjs +47 -41
  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 +83 -61
  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-command-runner.mjs +2 -0
  18. package/cli/bulk-output.mjs +29 -3
  19. package/cli/case-command.mjs +195 -0
  20. package/cli/case-pack.mjs +39 -31
  21. package/cli/command-argument-grammar.mjs +4 -0
  22. package/cli/command-reference.mjs +145 -75
  23. package/cli/completion.mjs +298 -79
  24. package/cli/config-profile.mjs +7 -5
  25. package/cli/ct-event-intake.mjs +12 -12
  26. package/cli/discover.mjs +4 -3
  27. package/cli/discovery-observation-snapshot.mjs +44 -16
  28. package/cli/discovery-scan-command-runner.mjs +2 -0
  29. package/cli/discovery-scan.mjs +7 -4
  30. package/cli/discovery-workflow.mjs +1 -1
  31. package/cli/domain-control-monitor.mjs +50 -20
  32. package/cli/domain-control-observations.mjs +149 -102
  33. package/cli/errors.mjs +33 -1
  34. package/cli/evidence-command-runner.mjs +16 -5
  35. package/cli/evidence-signing.mjs +2 -2
  36. package/cli/external-observation-mapping.mjs +7 -8
  37. package/cli/formatters/terminal-lookup.mjs +18 -46
  38. package/cli/formatters/terminal-shared.mjs +5 -3
  39. package/cli/formatters/terminal-technology.mjs +114 -0
  40. package/cli/inline-command-families.mjs +1 -0
  41. package/cli/interactive-launcher.mjs +2 -160
  42. package/cli/interchange-report.mjs +4 -8
  43. package/cli/investigation-artifacts.mjs +176 -0
  44. package/cli/investigation-checkpoint.mjs +10 -0
  45. package/cli/investigation-folder.mjs +114 -0
  46. package/cli/investigation-manifest.mjs +1 -112
  47. package/cli/investigation-package-review.mjs +103 -0
  48. package/cli/investigation-plan.mjs +32 -19
  49. package/cli/investigation-run.mjs +188 -33
  50. package/cli/local-document-checkpoint.mjs +170 -0
  51. package/cli/lookup-browser.mjs +5 -12
  52. package/cli/lookup-command-runner.mjs +17 -6
  53. package/cli/lookup-diff.mjs +37 -3
  54. package/cli/lookup-plan.mjs +11 -5
  55. package/cli/lookup-reconcile.mjs +10 -8
  56. package/cli/mail-review.mjs +66 -14
  57. package/cli/manual.mjs +1 -0
  58. package/cli/network-command-runner.mjs +2 -0
  59. package/cli/output-file.mjs +31 -5
  60. package/cli/registry-doctor.mjs +3 -1
  61. package/cli/registry-support.mjs +1 -1
  62. package/cli/retained-artifact-diff.mjs +2 -2
  63. package/cli/review-command-runner.mjs +45 -0
  64. package/cli/runner.mjs +55 -6
  65. package/cli/sharing-review.mjs +21 -34
  66. package/cli/signer-trust.mjs +104 -0
  67. package/cli/source-reliability.mjs +74 -83
  68. package/cli/terminal-input.mjs +168 -0
  69. package/cli/workflow-command-runner.mjs +68 -13
  70. package/docs/cli-reference.md +76 -30
  71. package/docs/cli.md +289 -25
  72. package/lib/abort.mjs +4 -1
  73. package/lib/availability.mjs +161 -112
  74. package/lib/bounded-file.mjs +3 -1
  75. package/lib/bounded-html-document.mjs +125 -0
  76. package/lib/bounded-json.mjs +43 -3
  77. package/lib/browser-library-profile.mjs +42 -51
  78. package/lib/browser-library-worker.mjs +112 -0
  79. package/lib/client-behavior-profile.mjs +3 -3
  80. package/lib/ct-search.mjs +11 -4
  81. package/lib/dns-delegation-health.mjs +24 -14
  82. package/lib/dns-inheritance-review.mjs +217 -0
  83. package/lib/dns-intelligence.mjs +36 -23
  84. package/lib/dns-mx.mjs +1 -1
  85. package/lib/dnssec-evidence-validation.mjs +11 -3
  86. package/lib/domain-change-packet.mjs +7 -2
  87. package/lib/domain-change-review.mjs +14 -8
  88. package/lib/domain-control-flight-recorder.mjs +219 -118
  89. package/lib/domain-control-manifest.mjs +117 -57
  90. package/lib/domain-posture-analysis.mjs +20 -7
  91. package/lib/domain-posture-parsers.mjs +88 -6
  92. package/lib/domain-posture.mjs +113 -26
  93. package/lib/evidence-export-privacy.mjs +561 -0
  94. package/lib/evidence-export.mjs +11 -592
  95. package/lib/evidence-report.mjs +3 -1
  96. package/lib/favicon.mjs +117 -41
  97. package/lib/generated/cisa-kev-catalog.mjs +12 -4
  98. package/lib/generated/registrar-standing-catalogue.mjs +7 -7
  99. package/lib/generated/retire-browser-catalog.mjs +2335 -940
  100. package/lib/hostname.mjs +2 -38
  101. package/lib/html-signals.mjs +82 -85
  102. package/lib/http-evidence-bounds.mjs +2 -1
  103. package/lib/http-intelligence.mjs +39 -26
  104. package/lib/lookup-cache.mjs +34 -11
  105. package/lib/lookup-child-profile-contract.mjs +24 -15
  106. package/lib/lookup-diagnostics.mjs +24 -1
  107. package/lib/lookup-progress.mjs +30 -13
  108. package/lib/lookup-response-contract.mjs +1048 -0
  109. package/lib/lookup-response.mjs +7 -10
  110. package/lib/lookup-source-progress.mjs +17 -14
  111. package/lib/lookup.mjs +48 -27
  112. package/lib/observed-network-context.mjs +43 -27
  113. package/lib/outbound-request-bounds.mjs +4 -1
  114. package/lib/page-fingerprints.mjs +69 -133
  115. package/lib/page-role-profile.mjs +12 -8
  116. package/lib/perceptual-hash.mjs +43 -14
  117. package/lib/rdap-bootstrap.mjs +72 -22
  118. package/lib/rdap-client.mjs +15 -7
  119. package/lib/rdap-normalization.mjs +33 -18
  120. package/lib/rdap-search-workbench.mjs +1 -1
  121. package/lib/rdap-transport.mjs +47 -20
  122. package/lib/rdap-types.mjs +2 -1
  123. package/lib/rdap.mjs +18 -3
  124. package/lib/registrable-domain.mjs +21 -1
  125. package/lib/registrar-standing-contract.mjs +1 -0
  126. package/lib/registry-capabilities.mjs +5 -1
  127. package/lib/registry-capability-catalogue.mjs +2 -1
  128. package/lib/registry-dates.mjs +2 -190
  129. package/lib/registry-insights.mjs +56 -18
  130. package/lib/response-policy.mjs +86 -48
  131. package/lib/risk-calibration-summary.mjs +17 -3
  132. package/lib/rpki-evidence.mjs +8 -5
  133. package/lib/safe-fetch.mjs +54 -47
  134. package/lib/security-txt.mjs +10 -7
  135. package/lib/service-binding-dns.mjs +8 -0
  136. package/lib/smtp-transport-review.mjs +27 -15
  137. package/lib/sslbl-certificates.generated.mjs +136 -135
  138. package/lib/static-html-analysis.mjs +117 -146
  139. package/lib/structured-data-identity.mjs +1 -0
  140. package/lib/technology-markup.mjs +100 -0
  141. package/lib/threat-intelligence-manifests.mjs +11 -139
  142. package/lib/threat-intelligence-runtime.mjs +24 -52
  143. package/lib/threat-intelligence-targets.mjs +6 -34
  144. package/lib/threat-intelligence-types.mjs +39 -0
  145. package/lib/typosquat-generator.mjs +19 -15
  146. package/lib/urlhaus-intelligence.mjs +8 -3
  147. package/lib/urlscan-intelligence.mjs +1 -1
  148. package/lib/website-security-posture.mjs +30 -6
  149. package/lib/website-technology.mjs +111 -71
  150. package/lib/whois-authority.mjs +14 -2
  151. package/lib/whois-chain.mjs +9 -2
  152. package/lib/whois-common-formats.mjs +5 -5
  153. package/lib/whois-contracts.mjs +2 -1
  154. package/lib/whois-dialects.mjs +2 -2
  155. package/lib/whois-parser-support.mjs +8 -1
  156. package/lib/whois-transport.mjs +23 -7
  157. package/package.json +2 -1
  158. package/packages/cases/case-attachment-model.mjs +83 -0
  159. package/packages/cases/case-evidence-model.mjs +156 -62
  160. package/packages/cases/case-export-input.mjs +53 -0
  161. package/packages/cases/case-migration-model.mjs +59 -25
  162. package/packages/cases/case-model.mjs +1 -0
  163. package/packages/cases/case-recheck-model.mjs +91 -0
  164. package/packages/cases/case-record-core.mjs +3 -2
  165. package/packages/cases/case-record-operations.mjs +89 -27
  166. package/packages/cases/case-record-projection.mjs +23 -20
  167. package/packages/cases/case-report.mjs +63 -52
  168. package/packages/cases/case-response-actions.mjs +654 -0
  169. package/packages/cases/case-response-model.mjs +41 -1129
  170. package/packages/cases/case-response-outcomes.mjs +317 -0
  171. package/packages/cases/case-response-packet-render.mjs +153 -0
  172. package/packages/cases/case-response-packet-types.mjs +1 -0
  173. package/packages/cases/case-response-packet-vocabulary.mjs +48 -0
  174. package/packages/cases/case-response-packet.mjs +38 -587
  175. package/packages/cases/case-response-records.mjs +94 -0
  176. package/packages/cases/case-response-review-inputs.mjs +371 -0
  177. package/packages/cases/case-response-values.mjs +85 -0
  178. package/packages/cases/case-review-package.mjs +45 -0
  179. package/packages/cases/case-selection.mjs +46 -0
  180. package/packages/cases/case-storage-model.mjs +25 -0
  181. package/packages/cases/response-route-freshness.mjs +16 -0
  182. package/packages/comparison/capture-context.mjs +61 -0
  183. package/packages/comparison/comparison-ledger-bulk.mjs +4 -1
  184. package/packages/comparison/page-similarity.mjs +2 -1
  185. package/packages/comparison/relationship-evidence.mjs +85 -8
  186. package/packages/comparison/relationship-provenance.mjs +93 -0
  187. package/packages/contracts/analyst-interchange.mjs +216 -0
  188. package/packages/contracts/analyst-review-state-contract.mjs +11 -0
  189. package/packages/contracts/analyst-review-state.mjs +5 -4
  190. package/packages/contracts/case-portability.mjs +279 -116
  191. package/packages/contracts/case-views-contract.mjs +12 -0
  192. package/packages/contracts/case-views.mjs +26 -0
  193. package/packages/contracts/cli-command-semantics.mjs +1 -0
  194. package/packages/contracts/cli-lookup.mjs +0 -1
  195. package/packages/contracts/domain-control-flight-recorder.mjs +80 -42
  196. package/packages/contracts/domain-control-manifest.mjs +127 -73
  197. package/packages/contracts/domain-control-monitor.mjs +48 -45
  198. package/packages/contracts/domain-control-review.mjs +179 -121
  199. package/packages/contracts/domain-name.mjs +21 -0
  200. package/packages/contracts/external-observation-interchange.mjs +2 -1
  201. package/packages/contracts/extracted-domain-lifecycle.mjs +11 -32
  202. package/packages/contracts/http-url.mjs +2 -0
  203. package/packages/contracts/investigation-package-limits.mjs +24 -0
  204. package/packages/contracts/investigation-portability.mjs +56 -27
  205. package/packages/contracts/investigation-run.mjs +16 -0
  206. package/packages/contracts/ip-address.mjs +34 -0
  207. package/packages/contracts/lookup-evidence.mjs +35 -0
  208. package/packages/contracts/offline-comparison.mjs +1 -2
  209. package/packages/contracts/page-fingerprints.mjs +15 -0
  210. package/packages/contracts/risk-calibration.mjs +0 -1
  211. package/packages/contracts/schema-compatibility.mjs +1 -1
  212. package/packages/contracts/schema-lifecycle.mjs +83 -153
  213. package/packages/contracts/selected-file-limits.mjs +6 -0
  214. package/packages/contracts/vulnerability-identifiers.mjs +5 -0
  215. package/packages/contracts/web-capture.mjs +10 -0
  216. package/packages/contracts/workspace-portability.mjs +160 -225
  217. package/packages/evidence/artifact-integrity.mjs +8 -2
  218. package/packages/evidence/artifact-structure.mjs +105 -0
  219. package/packages/evidence/decision-fact.mjs +4 -1
  220. package/packages/evidence/domain-control-runtime.mjs +155 -27
  221. package/packages/evidence/domain-posture-context.mjs +79 -0
  222. package/packages/evidence/image-regions.mjs +56 -0
  223. package/packages/evidence/latest-observations.mjs +28 -0
  224. package/packages/evidence/lookup-target.mjs +104 -0
  225. package/packages/evidence/observation.mjs +16 -1
  226. package/packages/evidence/passphrase-encryption.mjs +44 -0
  227. package/packages/evidence/record-identity.mjs +72 -0
  228. package/packages/evidence/registry-dates.mjs +190 -0
  229. package/packages/evidence/retained-file.mjs +69 -0
  230. package/packages/evidence/review-age.mjs +14 -0
  231. package/packages/interchange/bagit.mjs +342 -0
  232. package/packages/interchange/bounded-zip-extraction.mjs +278 -0
  233. package/packages/interchange/crc32.mjs +17 -0
  234. package/packages/interchange/external-findings-import.mjs +89 -49
  235. package/packages/interchange/web-capture-import.mjs +382 -0
  236. package/packages/investigation/bulk-mail-exposure.mjs +2 -1
  237. package/packages/investigation/investigation-bagit.mjs +14 -0
  238. package/packages/investigation/investigation-capsule.mjs +33 -5
  239. package/packages/investigation/investigation-manifest.mjs +200 -0
  240. package/packages/investigation/investigation-package-crypto.mjs +98 -0
  241. package/packages/investigation/investigation-package.mjs +200 -0
  242. package/packages/investigation/lookup-asset-graph-inputs.mjs +38 -0
  243. package/packages/investigation/lookup-asset-graph.mjs +161 -141
  244. package/packages/investigation/lookup-claim-readiness.mjs +18 -0
  245. package/packages/investigation/lookup-investigation-brief.mjs +3 -3
  246. package/packages/investigation/lookup-task-guidance.mjs +58 -0
  247. package/packages/monitoring/analyst-review-state.mjs +38 -90
  248. package/packages/workspace/brand-profile-model.mjs +158 -20
  249. package/packages/workspace/bulk-columns.mjs +17 -0
  250. package/packages/workspace/bulk-review-model.mjs +11 -5
  251. package/packages/workspace/bulk-session-model.mjs +95 -20
  252. package/packages/workspace/bulk-sort.mjs +1 -0
  253. package/packages/workspace/case-views.mjs +102 -0
  254. package/packages/workspace/hostile-input.mjs +29 -8
  255. package/packages/workspace/investigation-template-model.mjs +19 -4
  256. package/packages/workspace/page-baseline.mjs +18 -15
  257. package/packages/workspace/relationship-observation-model.mjs +41 -26
  258. package/packages/workspace/watchlist-history.mjs +2 -2
  259. package/packages/workspace/watchlist-store.mjs +8 -2
  260. package/packages/workspace/website-snapshot-model.mjs +70 -30
  261. package/packages/workspace/workspace-archive-crypto.mjs +41 -61
  262. package/packages/workspace/workspace-archive.mjs +85 -21
  263. package/third-party-notices.txt +29 -2
  264. package/packages/investigation/bulk-review-export.mjs +0 -63
@@ -4,6 +4,7 @@ import { buildBulkDomainComparison, } from "../packages/investigation/bulk-domai
4
4
  import { relationshipObservation } from "../packages/comparison/relationship-evidence.mjs";
5
5
  import { CliUsageError } from "./errors.mjs";
6
6
  import { parseSavedLookupDocument } from "./saved-lookup.mjs";
7
+ import { lookupObservationHostname } from "../packages/evidence/lookup-target.mjs";
7
8
  export const CLI_LOOKUP_DIFF_SCHEMA = 'whoisleuth.cli.lookup-diff';
8
9
  export const CLI_LOOKUP_DIFF_VERSION = 1;
9
10
  function text(value, maximum = 300) {
@@ -129,19 +130,22 @@ function lookupComparisonInput(document) {
129
130
  function buildCliLookupDiff(leftText, rightText, generatedAt = new Date().toISOString(), options = {}) {
130
131
  const left = parseSavedLookupDocument(leftText, { label: 'Left lookup input' });
131
132
  const right = parseSavedLookupDocument(rightText, { label: 'Right lookup input' });
132
- const domainMode = options.domainMode ?? 'different';
133
+ const domainMode = options.domainMode === 'auto'
134
+ ? left.registrableDomain === right.registrableDomain ? 'same' : 'different'
135
+ : options.domainMode ?? 'different';
133
136
  if (domainMode === 'different' && left.registrableDomain === right.registrableDomain) {
134
137
  throw new CliUsageError('Lookup diff requires documents for two different domains.');
135
138
  }
136
139
  if (domainMode === 'same' && left.registrableDomain !== right.registrableDomain) {
137
140
  throw new CliUsageError('Lookup history requires observations for the same domain.');
138
141
  }
139
- const comparison = buildBulkDomainComparison(lookupComparisonInput(left), lookupComparisonInput(right), domainMode === 'same' ? right.generatedAt : null, {
142
+ const measuredComparison = buildBulkDomainComparison(lookupComparisonInput(left), lookupComparisonInput(right), domainMode === 'same' ? right.generatedAt : null, {
140
143
  allowSameDomain: domainMode === 'same',
141
144
  ...(domainMode === 'same' ? { now: Date.parse(right.generatedAt) } : {}),
142
145
  });
143
- if (!comparison)
146
+ if (!measuredComparison)
144
147
  throw new CliUsageError('Lookup documents could not be compared.');
148
+ const comparison = qualifyLookupComparison(measuredComparison, left, right, domainMode === 'same');
145
149
  return {
146
150
  schema: CLI_LOOKUP_DIFF_SCHEMA,
147
151
  version: CLI_LOOKUP_DIFF_VERSION,
@@ -160,6 +164,36 @@ function buildCliLookupDiff(leftText, rightText, generatedAt = new Date().toISOS
160
164
  ],
161
165
  };
162
166
  }
167
+ function qualifyLookupComparison(comparison, left, right, temporal) {
168
+ const leftScope = recordOrEmpty(left.availability);
169
+ const rightScope = recordOrEmpty(right.availability);
170
+ const hostnameChanged = temporal && lookupObservationHostname(leftScope) !== lookupObservationHostname(rightScope);
171
+ const selectedPage = leftScope.webObservationMode === 'selected_url' || rightScope.webObservationMode === 'selected_url';
172
+ if (!hostnameChanged && !selectedPage)
173
+ return comparison;
174
+ const limitation = 'The retained collection hostname differs or a selected URL has no retained complete path/query identity. Values remain visible, but those fields cannot establish change at the same target.';
175
+ const rows = comparison.rows.map((row) => {
176
+ const web = ['web', 'technology', 'identity'].includes(row.category);
177
+ const incomparable = hostnameChanged && !['registration', 'lifecycle', 'source'].includes(row.category)
178
+ || temporal && selectedPage && web;
179
+ return {
180
+ ...row,
181
+ ...(selectedPage && web ? { method: 'Bounded website observation; one or both captures concern a selected URL rather than the homepage' } : {}),
182
+ ...(incomparable ? {
183
+ state: 'not_recorded',
184
+ source: `${row.source}; comparable target identity not recorded`,
185
+ leftSourceState: 'not_recorded',
186
+ rightSourceState: 'not_recorded',
187
+ limitations: [...row.limitations, limitation],
188
+ } : {}),
189
+ };
190
+ });
191
+ return {
192
+ ...comparison, rows,
193
+ counts: Object.fromEntries(Object.keys(comparison.counts).map((state) => [state, rows.filter((row) => row.state === state).length])),
194
+ limitations: [...comparison.limitations, limitation],
195
+ };
196
+ }
163
197
  function formatCliLookupDiff(document) {
164
198
  const rows = document.comparison.rows.filter((row) => row.state !== 'equal');
165
199
  const output = [
@@ -21,7 +21,7 @@ const FAST_DOMAIN_EVIDENCE = Object.freeze({
21
21
  const DEEP_DOMAIN_EVIDENCE = Object.freeze({
22
22
  source: 'domain_evidence',
23
23
  purpose: 'Collect bounded DNS, HTTP, TLS, page-identity, technology, and security-posture evidence.',
24
- disclosure: 'DNS resolvers and the target website infrastructure receive the hostname through bounded probes.',
24
+ disclosure: 'DNS, TLS and website probes use the submitted hostname. Registration-delegation queries use the registrable domain.',
25
25
  conditional: false,
26
26
  });
27
27
  const REGISTRAR_RDAP = Object.freeze({
@@ -42,19 +42,23 @@ const REVERSE_DNS = Object.freeze({
42
42
  disclosure: 'A DNS resolver receives the reverse lookup for the normalised IP address.',
43
43
  conditional: false,
44
44
  });
45
- function plannedSources(classified, deep) {
45
+ function plannedSources(classified, deep, selectedUrl) {
46
46
  if (!deep) {
47
47
  return Object.freeze(classified.type === 'domain' ? [RDAP, FAST_DOMAIN_EVIDENCE] : [RDAP]);
48
48
  }
49
49
  if (classified.type === 'domain') {
50
- return Object.freeze([RDAP, WHOIS, DEEP_DOMAIN_EVIDENCE, REGISTRAR_RDAP, NETWORK_CONTEXT]);
50
+ const domainEvidence = selectedUrl ? Object.freeze({
51
+ ...DEEP_DOMAIN_EVIDENCE,
52
+ disclosure: 'The selected URL path and query are sent to the website and followed redirects. The fragment is not sent. DNS and TLS use its hostname; registration uses the registrable domain. Saved HTTP provenance omits queries, but page-derived text may contain sensitive information.',
53
+ }) : DEEP_DOMAIN_EVIDENCE;
54
+ return Object.freeze([RDAP, WHOIS, domainEvidence, REGISTRAR_RDAP, NETWORK_CONTEXT]);
51
55
  }
52
56
  if (classified.type === 'ipv4' || classified.type === 'ipv6') {
53
57
  return Object.freeze([RDAP, WHOIS, REVERSE_DNS]);
54
58
  }
55
59
  return Object.freeze([RDAP, WHOIS]);
56
60
  }
57
- function buildCliLookupPlan(query, classified, deep) {
61
+ function buildCliLookupPlan(query, classified, deep, selectedUrl = false) {
58
62
  return Object.freeze({
59
63
  schema: CLI_LOOKUP_PLAN_SCHEMA,
60
64
  version: CLI_LOOKUP_PLAN_VERSION,
@@ -69,7 +73,7 @@ function buildCliLookupPlan(query, classified, deep) {
69
73
  planning: Object.freeze({
70
74
  networkRequestsMade: false,
71
75
  collectionRequiresNetwork: true,
72
- sources: plannedSources(classified, deep),
76
+ sources: plannedSources(classified, deep, selectedUrl),
73
77
  }),
74
78
  limitations: Object.freeze([
75
79
  'This is a local preflight. It does not test source availability, feature configuration, cache state, redirects, referrals, or the exact number of requests a completed lookup may require.',
@@ -81,6 +85,8 @@ function formatCliLookupPlan(plan) {
81
85
  const lines = [
82
86
  'WHOISleuth lookup preflight',
83
87
  `Target: ${plan.target.normalized}`,
88
+ ...(plan.target.inputHostname && plan.target.inputHostname !== plan.target.normalized
89
+ ? [`Submitted hostname: ${plan.target.inputHostname}`] : []),
84
90
  `Type: ${plan.target.type}`,
85
91
  `Mode: ${plan.mode}`,
86
92
  'Network requests made: no',
@@ -1,6 +1,7 @@
1
1
  import { CliUsageError } from "./errors.mjs";
2
2
  import { buildCliLookupDiff } from "./lookup-diff.mjs";
3
3
  import { parseSavedLookupDocument } from "./saved-lookup.mjs";
4
+ import { compareObservationContexts } from "../packages/comparison/capture-context.mjs";
4
5
  const CLI_LOOKUP_RECONCILIATION_SCHEMA = 'whoisleuth.cli.lookup-reconciliation';
5
6
  const CLI_LOOKUP_RECONCILIATION_VERSION = 1;
6
7
  const MAX_LOOKUP_RECONCILIATION_INPUT_BYTES = 32 * 1024 * 1024;
@@ -79,15 +80,11 @@ function buildCliLookupReconciliation(inputs, generatedAt = new Date().toISOStri
79
80
  };
80
81
  });
81
82
  const observations = documents.map(observationContext);
82
- const identities = observations.map((observation) => (observation.observerLabel && observation.vantageLabel
83
- ? `${observation.observerLabel}\u0000${observation.vantageLabel}`
84
- : null));
85
- const labelsComplete = identities.every((identity) => identity !== null);
86
- const labelsDistinct = labelsComplete && new Set(identities).size === identities.length;
87
- const independence = labelsDistinct
83
+ const context = compareObservationContexts(observations.map(observation => ({ ...observation, observedAt: observation.generatedAt })));
84
+ const independence = context.labels === 'distinct'
88
85
  ? {
89
86
  state: 'verified_distinct_labels',
90
- reason: 'Every observation carries a different analyst-supplied observer and vantage label.',
87
+ reason: 'Every observation carries a distinct analyst-supplied observer/vantage label pair. Actual collection independence is not verified.',
91
88
  }
92
89
  : {
93
90
  state: 'unverified',
@@ -117,15 +114,19 @@ function buildCliLookupReconciliation(inputs, generatedAt = new Date().toISOStri
117
114
  };
118
115
  }
119
116
  function formatCliLookupReconciliation(document) {
117
+ const context = compareObservationContexts(document.observations.map(observation => ({ ...observation, observedAt: observation.generatedAt })));
120
118
  const output = [
121
119
  'Lookup observation reconciliation',
122
120
  `Domain ${document.domain}`,
123
121
  `Observations ${document.summary.observationCount}`,
124
- `Independence ${document.independence.state.replaceAll('_', ' ')}`,
122
+ `Declared labels ${context.labels}; collection independence not verified`,
123
+ `Capture time span ${context.time.spanMilliseconds === null ? 'unknown' : `${context.time.spanMilliseconds / 1000} seconds`}`,
125
124
  `Agreement ${document.summary.agreement}`,
126
125
  `Disagreement ${document.summary.disagreement}`,
127
126
  `Non-comparable ${document.summary.nonComparable}`,
128
127
  '',
128
+ ...document.observations.map((observation, index) => `Observation ${index + 1}: ${observation.generatedAt} · ${observation.mode} · observer ${observation.observerLabel ?? 'not declared'} · vantage ${observation.vantageLabel ?? 'not declared'}`),
129
+ '',
129
130
  ];
130
131
  for (const field of document.fields.filter((candidate) => candidate.state !== 'agreement')) {
131
132
  output.push(`${field.label} [${field.state.replaceAll('_', ' ')}]`);
@@ -136,6 +137,7 @@ function formatCliLookupReconciliation(document) {
136
137
  output.push('', 'Limitations:');
137
138
  for (const limitation of document.limitations)
138
139
  output.push(` - ${limitation}`);
140
+ output.push(' - These selected observations do not establish worldwide availability, removal or takedown.');
139
141
  return `${output.join('\n')}\n`;
140
142
  }
141
143
  export { CLI_LOOKUP_RECONCILIATION_SCHEMA, CLI_LOOKUP_RECONCILIATION_VERSION, MAX_LOOKUP_RECONCILIATION_INPUT_BYTES, buildCliLookupReconciliation, formatCliLookupReconciliation, };
@@ -3,9 +3,11 @@ import { getDomain } from 'tldts';
3
3
  import { scanBoundedJson } from "../lib/bounded-json.mjs";
4
4
  import { isValidAsciiDomainName } from "../lib/hostname.mjs";
5
5
  import { isRecord, recordOrEmpty } from "../lib/json-record.mjs";
6
+ import { classifyQuery } from "../lib/classify.mjs";
7
+ import { normalizeExplicitIsoTimestamp } from "../packages/evidence/observation.mjs";
6
8
  import { CliUsageError } from "./errors.mjs";
7
9
  export const CLI_MAIL_REVIEW_SCHEMA = 'whoisleuth.cli.mail-review';
8
- export const CLI_MAIL_REVIEW_VERSION = 3;
10
+ export const CLI_MAIL_REVIEW_VERSION = 4;
9
11
  export const MAX_MAIL_REVIEW_INPUT_BYTES = 16 * 1024 * 1024;
10
12
  export const MAX_MAIL_REVIEW_ROWS = 500;
11
13
  const UNSAFE_TEXT_RE = /[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u202a-\u202e\u2060-\u206f]/u;
@@ -20,10 +22,10 @@ function boolean(value) {
20
22
  return typeof value === 'boolean' ? value : null;
21
23
  }
22
24
  function timestamp(value, label) {
23
- if (typeof value !== 'string' || value.length > 64 || !Number.isFinite(Date.parse(value))) {
24
- throw new CliUsageError(`${label} must be a valid bounded timestamp.`);
25
- }
26
- return new Date(value).toISOString();
25
+ const result = normalizeExplicitIsoTimestamp(value);
26
+ if (!result)
27
+ throw new CliUsageError(`${label} must be a valid bounded timestamp with an explicit timezone.`);
28
+ return result;
27
29
  }
28
30
  function booleanOrNull(value, label) {
29
31
  if (value === null || typeof value === 'boolean')
@@ -87,8 +89,8 @@ function validateMxEvidence(availability, summary, label, hasMx, hasNullMx) {
87
89
  function validateMailRow(value, index, expectedVersion) {
88
90
  const item = record(value);
89
91
  const label = `Mail review row ${index + 1}`;
90
- const version = Number(item.version);
91
- if (item.schema !== 'whoisleuth.cli.bulk.item' || ![2, 3].includes(version)
92
+ const version = item.version;
93
+ if (item.schema !== 'whoisleuth.cli.bulk.item' || typeof version !== 'number' || ![2, 3].includes(version)
92
94
  || (expectedVersion !== null && version !== expectedVersion)) {
93
95
  throw new CliUsageError(`${label} must use the matching WHOISleuth Bulk item schema version 2 or 3.`);
94
96
  }
@@ -193,6 +195,7 @@ function normalizeMailRow(value) {
193
195
  const providers = providerDomains(hosts);
194
196
  return {
195
197
  domain,
198
+ provenance: mailRowProvenance(item),
196
199
  state,
197
200
  dnsStatus,
198
201
  hasMx,
@@ -210,6 +213,29 @@ function normalizeMailRow(value) {
210
213
  ],
211
214
  };
212
215
  }
216
+ function mailRowProvenance(item) {
217
+ return Object.freeze({
218
+ sourceGeneratedAt: timestamp(item.generatedAt, 'Bulk row generation time'),
219
+ observedAt: item.version === 3 ? normalizeExplicitIsoTimestamp(item.observedAt) : null,
220
+ dnsObservedAt: item.ok === true ? normalizeExplicitIsoTimestamp(record(record(item.availability).dns).observedAt) : null,
221
+ collectionOrigin: item.version === 3
222
+ ? item.collectionOrigin
223
+ : 'unknown',
224
+ });
225
+ }
226
+ function failedMailRow(item, index) {
227
+ let target = null;
228
+ try {
229
+ target = classifyQuery(String(item.query)).value;
230
+ }
231
+ catch { /* Invalid targets remain identifiable by input position. */ }
232
+ return Object.freeze({
233
+ rowNumber: index + 1,
234
+ target,
235
+ state: 'collection_failed',
236
+ provenance: mailRowProvenance(item),
237
+ });
238
+ }
213
239
  function parseInput(textValue) {
214
240
  if (typeof textValue !== 'string' || Buffer.byteLength(textValue, 'utf8') > MAX_MAIL_REVIEW_INPUT_BYTES) {
215
241
  throw new CliUsageError(`Mail review input is limited to ${MAX_MAIL_REVIEW_INPUT_BYTES} bytes.`);
@@ -223,12 +249,12 @@ function parseInput(textValue) {
223
249
  scanBoundedJson(textInput);
224
250
  const parsed = JSON.parse(textInput);
225
251
  const document = record(parsed);
226
- if (document.schema === 'whoisleuth.cli.bulk' && [2, 3].includes(Number(document.version)) && Array.isArray(document.results)) {
252
+ if (document.schema === 'whoisleuth.cli.bulk' && typeof document.version === 'number' && [2, 3].includes(document.version) && Array.isArray(document.results)) {
227
253
  values = document.results;
228
254
  containerVersion = Number(document.version);
229
255
  timestamp(document.generatedAt, 'Mail review Bulk generation time');
230
256
  }
231
- else if (document.schema === 'whoisleuth.cli.bulk.item' && [2, 3].includes(Number(document.version))) {
257
+ else if (document.schema === 'whoisleuth.cli.bulk.item' && typeof document.version === 'number' && [2, 3].includes(document.version)) {
232
258
  values = [document];
233
259
  }
234
260
  else {
@@ -258,6 +284,13 @@ function buildCliMailReview(textValue, generatedAt = new Date().toISOString()) {
258
284
  const rows = inputRows.map(normalizeMailRow).filter((row) => Boolean(row));
259
285
  if (!rows.length)
260
286
  throw new CliUsageError('Mail review input contains no completed domain results.');
287
+ const failedRows = inputRows.flatMap((row, index) => row.ok === false ? [failedMailRow(row, index)] : []);
288
+ const inputCoverage = Object.freeze({
289
+ inputRows: inputRows.length,
290
+ reviewedRows: rows.length,
291
+ failedRows: failedRows.length,
292
+ unmeasuredObservationRows: [...rows, ...failedRows].filter((row) => row.provenance.observedAt === null).length,
293
+ });
261
294
  const counts = {
262
295
  authenticated_mail: 0,
263
296
  evidence_incomplete: 0,
@@ -295,10 +328,14 @@ function buildCliMailReview(textValue, generatedAt = new Date().toISOString()) {
295
328
  })
296
329
  .sort((left, right) => left.providerDomain.localeCompare(right.providerDomain));
297
330
  const providerRelationships = allProviderRelationships.slice(0, 100);
331
+ const rowsWithIncompleteDns = rows.filter((row) => row.dnsStatus !== 'success' || row.hasMx === null || row.hasNullMx === null).length;
298
332
  const providerCoverage = {
299
- complete: rows.every((row) => !row.providerDomainsTruncated)
333
+ complete: failedRows.length === 0 && rowsWithIncompleteDns === 0
334
+ && rows.every((row) => !row.providerDomainsTruncated)
300
335
  && allProviderRelationships.length <= 100
301
336
  && allProviderRelationships.every((relationship) => !relationship.domainsTruncated),
337
+ failedRows: failedRows.length,
338
+ rowsWithIncompleteDns,
302
339
  rowsWithOmittedProviders: rows.filter((row) => row.providerDomainsTruncated).length,
303
340
  omittedProviderDomains: rows.reduce((sum, row) => sum + row.providerDomainsOmitted, 0),
304
341
  relationshipCount: allProviderRelationships.length,
@@ -309,16 +346,20 @@ function buildCliMailReview(textValue, generatedAt = new Date().toISOString()) {
309
346
  return {
310
347
  schema: CLI_MAIL_REVIEW_SCHEMA,
311
348
  version: CLI_MAIL_REVIEW_VERSION,
312
- generatedAt,
349
+ generatedAt: timestamp(generatedAt, 'Mail review generation time'),
313
350
  counts,
314
351
  rows,
352
+ failedRows,
353
+ inputCoverage,
315
354
  providerRelationships,
316
355
  providerCoverage,
317
356
  limitations: [
318
357
  'This review is passive and uses DNS evidence already retained in a WHOISleuth Bulk result; it makes no network request.',
319
358
  'Null MX, no explicit MX, receiving mail, authentication gaps, and incomplete evidence remain separate states.',
320
359
  'SMTP delivery, mailbox existence, catch-all behaviour, banner collection, and message acceptance were not tested.',
321
- ...(!providerCoverage.complete
360
+ ...(failedRows.length ? [`${failedRows.length} input target${failedRows.length === 1 ? '' : 's'} failed collection and cannot contribute mail evidence. Input positions and normalised targets are retained; consult the original input for failure diagnostics.`] : []),
361
+ ...(rowsWithIncompleteDns ? [`${rowsWithIncompleteDns} reviewed domain${rowsWithIncompleteDns === 1 ? ' has' : 's have'} incomplete DNS evidence; unobserved provider relationships remain unknown.`] : []),
362
+ ...(providerCoverage.rowsWithOmittedProviders || providerCoverage.omittedRelationships || providerCoverage.omittedRelationshipDomains
322
363
  ? ['Mail-provider relationship coverage reached a configured row, relationship, or domain bound; omitted relationships remain unknown.']
323
364
  : []),
324
365
  ],
@@ -327,19 +368,30 @@ function buildCliMailReview(textValue, generatedAt = new Date().toISOString()) {
327
368
  function formatCliMailReview(document) {
328
369
  const lines = [
329
370
  'Passive mail exposure review',
371
+ `Input targets ${document.inputCoverage.inputRows}`,
330
372
  `Domains ${document.rows.length}`,
373
+ `Failed targets ${document.inputCoverage.failedRows}`,
331
374
  `Authenticated ${document.counts.authenticated_mail}`,
332
375
  `Auth gaps ${document.counts.mail_auth_gap}`,
333
376
  `Null MX ${document.counts.null_mx}`,
334
377
  `No explicit MX ${document.counts.no_explicit_mx}`,
335
- `Incomplete ${document.counts.evidence_incomplete + document.counts.mail_auth_incomplete}`,
378
+ `Incomplete ${document.counts.evidence_incomplete + document.counts.mail_auth_incomplete + document.inputCoverage.failedRows}`,
336
379
  '',
337
380
  ];
338
381
  for (const row of document.rows) {
339
382
  lines.push(`${row.domain} ${row.state.replaceAll('_', ' ')}`);
383
+ lines.push(` Observed ${row.provenance.observedAt ?? 'unknown'} · ${row.provenance.collectionOrigin.replaceAll('_', ' ')}`);
384
+ lines.push(` DNS observed ${row.provenance.dnsObservedAt ?? 'unknown'} · Source output ${row.provenance.sourceGeneratedAt}`);
340
385
  lines.push(` MX providers ${row.providerDomains.join(', ') || 'None observed'}${row.providerDomainsOmitted ? ` · +${row.providerDomainsOmitted} omitted` : ''}`);
341
386
  lines.push(` SPF / DMARC ${row.hasSpf === null ? 'unknown' : row.hasSpf ? 'observed' : 'not observed'} / ${row.hasDmarc === null ? 'unknown' : row.hasDmarc ? 'observed' : 'not observed'}`);
342
387
  }
388
+ if (document.failedRows.length) {
389
+ lines.push('', 'Failed collection inputs');
390
+ for (const row of document.failedRows) {
391
+ lines.push(`Input ${row.rowNumber} ${row.target ?? 'unrecognised target'} · collection failed`);
392
+ lines.push(` Observed ${row.provenance.observedAt ?? 'unknown'} · ${row.provenance.collectionOrigin.replaceAll('_', ' ')} · Source output ${row.provenance.sourceGeneratedAt}`);
393
+ }
394
+ }
343
395
  if (document.providerRelationships.length) {
344
396
  lines.push('', 'Shared mail-provider relationships');
345
397
  for (const relationship of document.providerRelationships) {
@@ -347,7 +399,7 @@ function formatCliMailReview(document) {
347
399
  }
348
400
  }
349
401
  if (!document.providerCoverage.complete) {
350
- lines.push('', `Provider coverage partial ${document.providerCoverage.omittedProviderDomains} row providers · ${document.providerCoverage.omittedRelationships} relationships · ${document.providerCoverage.omittedRelationshipDomains} relationship domains omitted`);
402
+ lines.push('', `Provider coverage partial ${document.providerCoverage.failedRows} failed targets · ${document.providerCoverage.rowsWithIncompleteDns} incomplete DNS rows · ${document.providerCoverage.omittedProviderDomains} row providers · ${document.providerCoverage.omittedRelationships} relationships · ${document.providerCoverage.omittedRelationshipDomains} relationship domains omitted`);
351
403
  }
352
404
  lines.push('', 'Limitations:');
353
405
  for (const limitation of document.limitations)
package/cli/manual.mjs CHANGED
@@ -24,6 +24,7 @@ ${commands}
24
24
  Human-readable output is the default. Versioned JSON and JSONL are available where documented. Diagnostics and optional progress events use standard error. Use --output and optional --force for atomic private file output. Use --palette auto, light, or dark for a fixed terminal palette. Lookup --browse provides an interactive terminal view.
25
25
  .SH EXIT STATUS
26
26
  0 indicates command completion, 2 invalid usage, 3 a collection or comparison failure, 4 an explicitly detected partial result, 70 an internal bootstrap failure, 130 analyst cancellation, and 143 service termination.
27
+ For verify-artifact, use --strict-exit in scripts: incomplete verification then returns 4. Without it, producing a verification report can return 0 even when its checks fail. Read the report state and checks.
27
28
  .SH PRIVACY
28
29
  Network commands disclose the target to the sources named in focused help. Offline commands read local input only. Output files use private permissions and remain on the operator's machine.
29
30
  .SH LICENSE
@@ -44,6 +44,8 @@ async function runNetworkCommand(args, dependencies, context) {
44
44
  dkimSelectors,
45
45
  retiredDkimSelectors,
46
46
  mailProtectionProfile: args.mailProfile,
47
+ ...(args.includeInheritedDns ? { includeInheritedDns: true } : {}),
48
+ ...(dependencies.signal ? { signal: dependencies.signal } : {}),
47
49
  }));
48
50
  const document = buildCliPostureDocument(requestedDomain, report, context.now());
49
51
  if (!args.quiet) {
@@ -3,6 +3,7 @@ import { unlinkSync } from 'node:fs';
3
3
  import { link, open, rename, unlink } from 'node:fs/promises';
4
4
  import { basename, dirname, join, resolve } from 'node:path';
5
5
  import { CliUsageError } from "./errors.mjs";
6
+ import { MAX_ENCRYPTED_INVESTIGATION_PACKAGE_BYTES } from "../packages/contracts/investigation-package-limits.mjs";
6
7
  export const MAX_CLI_OUTPUT_BYTES = 32 * 1024 * 1024;
7
8
  const MAX_CLI_OUTPUT_PATH_LENGTH = 4096;
8
9
  const pendingOutputFiles = new Map();
@@ -22,14 +23,26 @@ function safeOutputPath(value) {
22
23
  }
23
24
  return resolve(value);
24
25
  }
25
- function createBufferedOutput() {
26
+ function createBufferedOutput(options = { binary: false }) {
26
27
  const chunks = [];
28
+ const binaryChunks = [];
27
29
  let bytes = 0;
30
+ const writeBinary = (chunk) => {
31
+ if (!options.binary || !(chunk instanceof Uint8Array))
32
+ throw new CliUsageError('Binary output requires an explicit package destination.');
33
+ if (chunk.byteLength > MAX_ENCRYPTED_INVESTIGATION_PACKAGE_BYTES - bytes)
34
+ throw new CliUsageError('Binary package output exceeds its byte limit.');
35
+ bytes += chunk.byteLength;
36
+ binaryChunks.push(new Uint8Array(chunk));
37
+ };
28
38
  return Object.freeze({
39
+ writeBinary,
29
40
  stream: {
30
41
  isTTY: false,
31
42
  columns: 80,
32
43
  write(chunk) {
44
+ if (options.binary)
45
+ throw new CliUsageError('Binary package output cannot contain terminal text.');
33
46
  const value = typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8');
34
47
  bytes += Buffer.byteLength(value, 'utf8');
35
48
  if (bytes > MAX_CLI_OUTPUT_BYTES) {
@@ -39,14 +52,26 @@ function createBufferedOutput() {
39
52
  return true;
40
53
  },
41
54
  },
42
- value: () => chunks.join(''),
55
+ value: () => {
56
+ if (!options.binary)
57
+ return chunks.join('');
58
+ const result = new Uint8Array(bytes);
59
+ let offset = 0;
60
+ for (const chunk of binaryChunks) {
61
+ result.set(chunk, offset);
62
+ offset += chunk.byteLength;
63
+ }
64
+ return result;
65
+ },
43
66
  });
44
67
  }
45
68
  async function writePrivateFile(pathValue, content, options = {}, operations = OUTPUT_FILE_OPERATIONS) {
46
69
  const target = safeOutputPath(pathValue);
47
- if (Buffer.byteLength(content, 'utf8') > MAX_CLI_OUTPUT_BYTES) {
48
- throw new CliUsageError(`Generated output is limited to ${MAX_CLI_OUTPUT_BYTES} bytes.`);
70
+ const maximumBytes = typeof content === 'string' ? MAX_CLI_OUTPUT_BYTES : MAX_ENCRYPTED_INVESTIGATION_PACKAGE_BYTES;
71
+ if ((typeof content === 'string' ? Buffer.byteLength(content, 'utf8') : content.byteLength) > maximumBytes) {
72
+ throw new CliUsageError(`Generated output is limited to ${maximumBytes} bytes.`);
49
73
  }
74
+ const captured = typeof content === 'string' ? content : new Uint8Array(content);
50
75
  const directory = dirname(target);
51
76
  const temporary = join(directory, `.${basename(target)}.${process.pid}.${operations.randomUUID()}.tmp`);
52
77
  let temporaryCreated = false;
@@ -59,12 +84,13 @@ async function writePrivateFile(pathValue, content, options = {}, operations = O
59
84
  settled: false,
60
85
  });
61
86
  try {
62
- await handle.writeFile(content, { encoding: 'utf8' });
87
+ await handle.writeFile(captured, { encoding: 'utf8' });
63
88
  await handle.sync();
64
89
  }
65
90
  finally {
66
91
  await handle.close();
67
92
  }
93
+ await options.beforePublish?.();
68
94
  if (options.force) {
69
95
  await operations.rename(temporary, target);
70
96
  temporaryCreated = false;
@@ -189,7 +189,9 @@ function buildRegistryDoctorReport(raw, generatedAt = new Date().toISOString())
189
189
  ...sources.filter((source) => source.alignment === 'investigate').map((source) => (source.expected === 'allowed'
190
190
  ? `Review ${source.source.toUpperCase()} source health, transport attempts, and fixture coverage; the catalogue expected collection to be available.`
191
191
  : `Review the ${source.source.toUpperCase()} result and capability profile; the observed state did not match the declared access constraint.`)),
192
- ...sources.filter((source) => source.alignment === 'unexpected_observation').map((source) => (`Review why ${source.source.toUpperCase()} produced normalized evidence despite the declared ${source.expected.replaceAll('_', ' ')} access profile.`)),
192
+ ...sources.filter((source) => source.alignment === 'unexpected_observation').map((source) => (source.source === 'rdap' && source.expected === 'unsupported'
193
+ ? 'Review whether the retained no-service RDAP profile needs refreshing; current bootstrap discovery may identify a newly published service.'
194
+ : `Review why ${source.source.toUpperCase()} produced normalized evidence despite the declared ${source.expected.replaceAll('_', ' ')} access profile.`)),
193
195
  ...sources.filter((source) => source.alignment === 'observed' && source.objectIdentifier === 'not_observed').map((source) => (`${source.source.toUpperCase()} succeeded without a retained registry object identifier; this can be a valid publication omission and should remain unknown rather than an error.`)),
194
196
  ].slice(0, 12);
195
197
  return {
@@ -1,7 +1,7 @@
1
1
  import { registryStandardsCoverageSnapshot, } from "../lib/registry-capabilities.mjs";
2
+ import { REGISTRY_STANDARDS_COVERAGE_SCHEMA } from "../lib/registry-capability-catalogue.mjs";
2
3
  const REGISTRY_SUPPORT_SCHEMA = 'whoisleuth.cli.registry-support';
3
4
  const REGISTRY_SUPPORT_SCHEMA_VERSION = 4;
4
- const REGISTRY_STANDARDS_COVERAGE_SCHEMA = 'whoisleuth.registry-standards-coverage';
5
5
  const MAX_REGISTRY_SUPPORT_TEXT_LENGTH = 2048;
6
6
  const MAX_REGISTRY_SUPPORT_REFERENCES = 20;
7
7
  const MAX_REGISTRY_SUPPORT_REFERENCE_LENGTH = 2048;
@@ -62,7 +62,7 @@ function parseBulkExport(root) {
62
62
  const rawSession = record(candidate);
63
63
  const rawResults = Array.isArray(rawSession?.results) ? rawSession.results : null;
64
64
  const rawDomains = Array.isArray(rawSession?.domains) ? rawSession.domains : null;
65
- const session = normalizeBulkSession(candidate);
65
+ const session = normalizeBulkSession(candidate, version);
66
66
  if (!session || !rawResults || !rawDomains
67
67
  || rawResults.length > MAX_BULK_SESSION_ROWS
68
68
  || rawResults.length !== session.results.length
@@ -338,7 +338,7 @@ export function buildCliRetainedArtifactDiff(leftRaw, rightRaw, options = {}, ge
338
338
  if (left.schema === SAVED_LOOKUP_SCHEMA) {
339
339
  if (options.leftSessionId || options.rightSessionId)
340
340
  throw new CliUsageError('Saved-session selectors apply only to Bulk session exports.');
341
- return buildCliLookupDiff(leftRaw, rightRaw, generatedAt);
341
+ return buildCliLookupDiff(leftRaw, rightRaw, generatedAt, { domainMode: 'auto' });
342
342
  }
343
343
  if (left.schema === BULK_SESSION_SCHEMA)
344
344
  return buildBulkLedger(leftRaw, rightRaw, left, right, options, generatedAt);
@@ -1,3 +1,10 @@
1
+ import { readBoundedRegularFile } from "../lib/bounded-file.mjs";
2
+ import { MAX_ENCRYPTED_INVESTIGATION_PACKAGE_BYTES } from "../packages/contracts/investigation-package-limits.mjs";
3
+ import { verifyOfflineInvestigationFolder, verifyOfflineInvestigationPackage } from "./investigation-package-review.mjs";
4
+ import { readInvestigationFolder } from "./investigation-folder.mjs";
5
+ import { readBagItFolder } from "./bagit-folder.mjs";
6
+ import { verifyOfflineBagIt } from "./bagit-review.mjs";
7
+ import { MAX_BAGIT_ZIP_BYTES } from "../packages/interchange/bagit.mjs";
1
8
  import { MAX_OFFLINE_ARTIFACT_BYTES, formatOfflineArtifactVerification, isCompleteOfflineArtifactVerification, verifyOfflineArtifact, } from "./artifact-verify.mjs";
2
9
  import { MAX_CASE_PACK_INPUT_BYTES, buildCliCasePack, formatCliCasePack } from "./case-pack.mjs";
3
10
  import { MAX_COMPARE_INPUT_BYTES, compareLookupDocument, parseCliLookupDocument, } from "./compare.mjs";
@@ -5,6 +12,7 @@ import { boundedCliErrorMessage, CliUsageError } from "./errors.mjs";
5
12
  import EXIT_CODES from "./exit-codes.mjs";
6
13
  import { MAX_OFFLINE_EVIDENCE_INPUT_BYTES, buildOfflineEvidenceReview, buildOfflineEvidenceReviewWithLocalResources, formatOfflineEvidenceReview, } from "./offline-evidence-review.mjs";
7
14
  import { buildCliLookupBrief, formatCliLookupBrief } from "./lookup-brief.mjs";
15
+ import { runCaseCommand } from "./case-command.mjs";
8
16
  import { MAX_MAIL_REVIEW_INPUT_BYTES, buildCliMailReview, formatCliMailReview, } from "./mail-review.mjs";
9
17
  import { MAX_MAIL_HEADER_INPUT_BYTES, buildCliMailHeaderReview, formatCliMailHeaderReview, } from "./mail-header-review.mjs";
10
18
  import { buildCliPageComparison, formatCliPageComparison } from "./page-compare.mjs";
@@ -17,6 +25,42 @@ import { REVIEW_INLINE_COMMANDS } from "./inline-command-families.mjs";
17
25
  import { runDiscriminatedCommandHandler } from "./discriminated-command-handlers.mjs";
18
26
  async function runVerifyArtifactCommand(args, dependencies, context) {
19
27
  context.setFailureLabel('Artefact verification');
28
+ if (args.folder) {
29
+ let files;
30
+ try {
31
+ files = await (args.bagit ? readBagItFolder : readInvestigationFolder)(args.folder, dependencies.signal);
32
+ }
33
+ catch (cause) {
34
+ dependencies.signal?.throwIfAborted();
35
+ const reason = cause instanceof TypeError ? boundedCliErrorMessage(cause) : 'The selected folder is unavailable or could not be read.';
36
+ throw new CliUsageError(`Could not read evidence folder: ${reason}`);
37
+ }
38
+ const report = await (args.bagit ? verifyOfflineBagIt : verifyOfflineInvestigationFolder)(files);
39
+ dependencies.signal?.throwIfAborted();
40
+ if (!args.quiet)
41
+ context.writeStdout(args.output === 'json' ? formatJsonDocument(report) : context.terminal(formatOfflineArtifactVerification(report), args.color));
42
+ return args.strictExit && !isCompleteOfflineArtifactVerification(report) ? EXIT_CODES.PARTIAL_FAILURE : EXIT_CODES.SUCCESS;
43
+ }
44
+ if (args.package) {
45
+ if (!args.source || args.source === '-')
46
+ throw new CliUsageError('--package requires a selected ZIP file; binary stdin is not accepted.');
47
+ let bytes;
48
+ try {
49
+ bytes = dependencies.readBinaryArtifactInput ? await dependencies.readBinaryArtifactInput(args.source)
50
+ : await readBoundedRegularFile(args.source, { maximumBytes: args.bagit ? MAX_BAGIT_ZIP_BYTES : MAX_ENCRYPTED_INVESTIGATION_PACKAGE_BYTES, minimumBytes: 22, label: 'Investigation package', ...(dependencies.signal ? { signal: dependencies.signal } : {}) });
51
+ }
52
+ catch (error) {
53
+ if (error instanceof CliUsageError)
54
+ throw error;
55
+ throw new CliUsageError(`Could not read package input: ${boundedCliErrorMessage(error, 'Input could not be read')}`);
56
+ }
57
+ const passphrase = args.passphraseSource ? await context.readPassphraseSource(args.passphraseSource) : undefined;
58
+ const report = args.bagit ? await verifyOfflineBagIt(bytes) : await verifyOfflineInvestigationPackage(bytes, passphrase);
59
+ dependencies.signal?.throwIfAborted();
60
+ if (!args.quiet)
61
+ context.writeStdout(args.output === 'json' ? formatJsonDocument(report) : context.terminal(formatOfflineArtifactVerification(report), args.color));
62
+ return args.strictExit && !isCompleteOfflineArtifactVerification(report) ? EXIT_CODES.PARTIAL_FAILURE : EXIT_CODES.SUCCESS;
63
+ }
20
64
  let input;
21
65
  try {
22
66
  input = dependencies.readArtifactInput
@@ -286,6 +330,7 @@ const REVIEW_COMMAND_HANDLERS = Object.freeze({
286
330
  'mail-headers': runMailHeadersCommand,
287
331
  'review-evidence': runOfflineEvidenceReviewCommand,
288
332
  'brief': runBriefOrCasePackCommand,
333
+ 'case': runCaseCommand,
289
334
  'case-pack': runBriefOrCasePackCommand,
290
335
  });
291
336
  function runReviewCommand(args, dependencies, context) {