@slicedearth/whoisleuth-cli 2.4.0 → 2.5.1

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 (300) hide show
  1. package/NOTICE +2 -2
  2. package/cli/argument-values.mjs +55 -0
  3. package/cli/arguments.mjs +24 -470
  4. package/cli/artifact-validation/case-response.mjs +21 -6
  5. package/cli/artifact-validation/lookup-evidence.mjs +3 -3
  6. package/cli/artifact-validation/signed-review.mjs +59 -13
  7. package/cli/artifact-verify.mjs +2 -0
  8. package/cli/assurance-arguments.mjs +56 -0
  9. package/cli/assurance-command-definitions.mjs +179 -0
  10. package/cli/assurance-command-runner.mjs +1 -1
  11. package/cli/bounded-json.mjs +1 -1
  12. package/cli/bulk-checkpoint.mjs +1 -1
  13. package/cli/bulk-command-runner.mjs +2 -2
  14. package/cli/bulk.mjs +2 -3
  15. package/cli/case-command.mjs +20 -3
  16. package/cli/case-pack.mjs +12 -2
  17. package/cli/ci-report.mjs +6 -3
  18. package/cli/collection-arguments.mjs +123 -0
  19. package/cli/collection-command-definitions.mjs +330 -0
  20. package/cli/collection-preflight.mjs +8 -1
  21. package/cli/command-argument-grammar.mjs +8 -210
  22. package/cli/command-definition.mjs +329 -0
  23. package/cli/command-reference.mjs +55 -1388
  24. package/cli/command-seeds.mjs +18 -0
  25. package/cli/compare.mjs +1 -1
  26. package/cli/config-profile.mjs +1 -1
  27. package/cli/ct-event-intake.mjs +4 -4
  28. package/cli/discover.mjs +2 -2
  29. package/cli/discovery-observation-snapshot.mjs +2 -2
  30. package/cli/discovery-scan-command-runner.mjs +3 -3
  31. package/cli/discovery-scan.mjs +1 -1
  32. package/cli/discovery-snapshot.mjs +2 -2
  33. package/cli/discovery-workflow.mjs +4 -4
  34. package/cli/domain-control-monitor.mjs +6 -6
  35. package/cli/domain-control-observations.mjs +2 -1
  36. package/cli/errors.mjs +5 -6
  37. package/cli/evidence-arguments.mjs +46 -0
  38. package/cli/evidence-command-definitions.mjs +69 -0
  39. package/cli/evidence-command-runner.mjs +2 -3
  40. package/cli/evidence-signing.mjs +3 -2
  41. package/cli/external-observation-mapping.mjs +2 -2
  42. package/cli/formatters/terminal-command-formats.mjs +6 -0
  43. package/cli/formatters/terminal-lookup.mjs +6 -0
  44. package/cli/history-arguments.mjs +39 -0
  45. package/cli/history-command-definitions.mjs +88 -0
  46. package/cli/indicator-set-command.mjs +33 -0
  47. package/cli/inline-command-families.mjs +6 -47
  48. package/cli/inline-command-runner.mjs +4 -31
  49. package/cli/input.mjs +2 -2
  50. package/cli/intake-command.mjs +107 -0
  51. package/cli/interchange-report.mjs +4 -2
  52. package/cli/investigation-artifacts.mjs +1 -1
  53. package/cli/investigation-plan.mjs +32 -154
  54. package/cli/investigation-recipes.mjs +177 -0
  55. package/cli/investigation-run.mjs +78 -12
  56. package/cli/local-mmdb-review.mjs +197 -17
  57. package/cli/lookalike-calibration.mjs +1 -1
  58. package/cli/lookup-brief.mjs +2 -2
  59. package/cli/lookup-command-runner.mjs +18 -35
  60. package/cli/lookup-diff.mjs +10 -9
  61. package/cli/lookup-plan.mjs +10 -14
  62. package/cli/lookup-reconcile.mjs +6 -5
  63. package/cli/lookup-timeline.mjs +7 -6
  64. package/cli/mail-header-review.mjs +24 -247
  65. package/cli/mail-review.mjs +9 -7
  66. package/cli/network-arguments.mjs +51 -0
  67. package/cli/network-command-definitions.mjs +130 -0
  68. package/cli/network-command-runner.mjs +43 -31
  69. package/cli/offline-evidence-review.mjs +39 -6
  70. package/cli/posture.mjs +1 -1
  71. package/cli/registry-fixture-scaffold.mjs +1 -1
  72. package/cli/retained-artifact-diff.mjs +8 -7
  73. package/cli/review-arguments.mjs +131 -0
  74. package/cli/review-command-definitions.mjs +273 -0
  75. package/cli/review-command-runner.mjs +68 -158
  76. package/cli/risk-calibration.mjs +8 -8
  77. package/cli/runner.mjs +20 -32
  78. package/cli/saved-lookup.mjs +2 -2
  79. package/cli/selected-input-worker.mjs +63 -0
  80. package/cli/sharing-review.mjs +5 -64
  81. package/cli/source-reliability.mjs +2 -2
  82. package/cli/support-arguments.mjs +49 -0
  83. package/cli/support-command-definitions.mjs +209 -0
  84. package/cli/support-command-runner.mjs +2 -2
  85. package/cli/workflow-arguments.mjs +86 -0
  86. package/cli/workflow-command-definitions.mjs +78 -0
  87. package/cli/workflow-command-runner.mjs +8 -1
  88. package/docs/cli-reference.md +27 -0
  89. package/docs/cli.md +223 -2
  90. package/docs/contextual-reviews.md +155 -0
  91. package/lib/analyst-taxonomy.mjs +2 -40
  92. package/lib/application-version.mjs +1 -1
  93. package/lib/availability.mjs +13 -789
  94. package/lib/bounded-contract-normalizers.mjs +4 -0
  95. package/lib/bounded-json.mjs +2 -330
  96. package/lib/browser-library-profile.mjs +62 -84
  97. package/lib/candidate-provenance-bounds.mjs +2 -0
  98. package/lib/classify.mjs +1 -1
  99. package/lib/ct-query.mjs +2 -27
  100. package/lib/ct-response-bounds.mjs +2 -9
  101. package/lib/ct-search.mjs +83 -47
  102. package/lib/dmarc-discovery.mjs +62 -0
  103. package/lib/dns-convergence-review.mjs +21 -8
  104. package/lib/dns-delegation-health.mjs +12 -8
  105. package/lib/dns-inheritance-review.mjs +53 -63
  106. package/lib/dns-intelligence.mjs +9 -7
  107. package/lib/dns-query-coverage.mjs +40 -0
  108. package/lib/domain-assurance.mjs +1 -1
  109. package/lib/domain-change-packet.mjs +12 -13
  110. package/lib/domain-change-review.mjs +32 -14
  111. package/lib/domain-control-flight-recorder.mjs +103 -27
  112. package/lib/domain-control-manifest.mjs +25 -17
  113. package/lib/domain-posture-analysis.mjs +22 -5
  114. package/lib/domain-posture.mjs +16 -15
  115. package/lib/domain-registration.mjs +392 -0
  116. package/lib/evidence-export-privacy.mjs +22 -15
  117. package/lib/evidence-export.mjs +3 -3
  118. package/lib/evidence-report.mjs +4 -1
  119. package/lib/external-intelligence-risk.mjs +2 -124
  120. package/lib/generated/cisa-kev-catalog.mjs +41 -3
  121. package/lib/generated/registrar-standing-catalogue.mjs +18 -10
  122. package/lib/generated/retire-browser-catalog.mjs +332 -19
  123. package/lib/generated/{unicode-confusables-17.mjs → unicode-confusables.mjs} +33 -33
  124. package/lib/html-signals.mjs +7 -2
  125. package/lib/http-evidence-bounds.mjs +2 -8
  126. package/lib/http-intelligence.mjs +13 -1
  127. package/lib/idn-confusable-policy.mjs +6 -4
  128. package/lib/idn-confusables.mjs +1 -1
  129. package/lib/interchange-fidelity-registry.mjs +10 -0
  130. package/lib/lookup-contract-primitives.mjs +1 -1
  131. package/lib/lookup-diagnostics.mjs +1 -23
  132. package/lib/lookup-network-evidence-bounds.mjs +1 -10
  133. package/lib/lookup-progress.mjs +50 -1
  134. package/lib/lookup-response-contract.mjs +69 -40
  135. package/lib/lookup-response.mjs +5 -15
  136. package/lib/lookup-source-progress.mjs +8 -22
  137. package/lib/lookup.mjs +1 -1
  138. package/lib/opportunity-scoring.mjs +2 -107
  139. package/lib/outbound-identity.mjs +1 -1
  140. package/lib/page-fingerprints.mjs +18 -1
  141. package/lib/perceptual-hash-comparison.mjs +2 -34
  142. package/lib/perceptual-hash.mjs +36 -10
  143. package/lib/portable-generator.mjs +2 -29
  144. package/lib/project-metadata.mjs +2 -8
  145. package/lib/rdap-client.mjs +11 -7
  146. package/lib/registrable-domain.mjs +2 -56
  147. package/lib/registrar-standing-contract.mjs +1 -1
  148. package/lib/registrar-standing.mjs +9 -13
  149. package/lib/registry-capabilities.mjs +2 -2
  150. package/lib/registry-capability-catalogue.mjs +27 -19
  151. package/lib/risk-calibration-summary.mjs +2 -352
  152. package/lib/risk-scoring.mjs +2 -345
  153. package/lib/scoring-evidence-quality.mjs +2 -107
  154. package/lib/semantic-version.mjs +2 -54
  155. package/lib/service-binding-dns.mjs +27 -0
  156. package/lib/smtp-transport-review.mjs +48 -19
  157. package/lib/sslbl-certificates.generated.mjs +141 -139
  158. package/lib/static-html-analysis.mjs +1 -1
  159. package/lib/structured-data-identity.mjs +1 -1
  160. package/lib/threat-intelligence-manifests.mjs +1 -1
  161. package/lib/threat-intelligence-runtime.mjs +1 -1
  162. package/lib/threat-intelligence-targets.mjs +1 -1
  163. package/lib/threat-intelligence-types.mjs +2 -75
  164. package/lib/typosquat-generator.mjs +11 -2
  165. package/lib/website-enrichment.mjs +388 -0
  166. package/lib/whois-authority.mjs +43 -5
  167. package/lib/whois-normalization.mjs +3 -1
  168. package/lib/whois-parser.mjs +5 -3
  169. package/package.json +13 -14
  170. package/packages/analysis/analyst-taxonomy.mjs +40 -0
  171. package/packages/analysis/bounded-json.mjs +330 -0
  172. package/packages/analysis/candidate-provenance-bounds.mjs +4 -0
  173. package/packages/analysis/cli-argument-grammar.mjs +216 -0
  174. package/packages/analysis/ct-query.mjs +27 -0
  175. package/packages/analysis/ct-response-bounds.mjs +9 -0
  176. package/packages/analysis/external-intelligence-risk.mjs +124 -0
  177. package/packages/analysis/http-evidence-bounds.mjs +8 -0
  178. package/packages/analysis/lookup-network-evidence-bounds.mjs +10 -0
  179. package/packages/analysis/opportunity-scoring.mjs +107 -0
  180. package/packages/analysis/perceptual-hash-comparison.mjs +34 -0
  181. package/packages/analysis/portable-generator.mjs +29 -0
  182. package/packages/analysis/project-metadata.mjs +8 -0
  183. package/packages/analysis/registrable-domain.mjs +56 -0
  184. package/packages/analysis/risk-calibration-summary.mjs +352 -0
  185. package/packages/analysis/risk-scoring.mjs +345 -0
  186. package/packages/analysis/scoring-evidence-quality.mjs +107 -0
  187. package/packages/analysis/semantic-version.mjs +54 -0
  188. package/packages/analysis/threat-intelligence-types.mjs +75 -0
  189. package/packages/cases/case-evidence-links.mjs +135 -0
  190. package/packages/cases/case-evidence-model.mjs +78 -12
  191. package/packages/cases/case-evidence-quality.mjs +5 -0
  192. package/packages/cases/case-export-input.mjs +7 -5
  193. package/packages/cases/case-incident-context.mjs +52 -0
  194. package/packages/cases/case-markdown.mjs +11 -0
  195. package/packages/cases/case-migration-model.mjs +40 -24
  196. package/packages/cases/case-recheck-model.mjs +90 -32
  197. package/packages/cases/case-record-contracts.mjs +1 -1
  198. package/packages/cases/case-record-core.mjs +1 -1
  199. package/packages/cases/case-record-operations.mjs +40 -112
  200. package/packages/cases/case-record-projection.mjs +8 -1
  201. package/packages/cases/case-record-version-input.mjs +19 -0
  202. package/packages/cases/case-report.mjs +63 -27
  203. package/packages/cases/case-requested-evidence.mjs +146 -0
  204. package/packages/cases/case-response-actions.mjs +103 -1
  205. package/packages/cases/case-response-model.mjs +9 -1
  206. package/packages/cases/case-response-outcomes.mjs +29 -8
  207. package/packages/cases/case-response-packet-render.mjs +13 -9
  208. package/packages/cases/case-response-packet.mjs +25 -11
  209. package/packages/cases/case-response-records.mjs +0 -1
  210. package/packages/cases/case-response-review-inputs.mjs +14 -3
  211. package/packages/cases/case-storage-model.mjs +1 -1
  212. package/packages/cases/case-workflow-metadata.mjs +225 -0
  213. package/packages/cases/case-workflow-migration.mjs +43 -0
  214. package/packages/cases/http-summary.mjs +6 -1
  215. package/packages/comparison/favicon-similarity.mjs +1 -1
  216. package/packages/comparison/page-similarity.mjs +1 -1
  217. package/packages/comparison/relationship-evidence.mjs +21 -9
  218. package/packages/comparison/relationship-provenance.mjs +7 -1
  219. package/packages/contracts/analyst-interchange.mjs +28 -10
  220. package/packages/contracts/case-portability.mjs +118 -25
  221. package/packages/contracts/cli-command-semantics.mjs +2 -0
  222. package/packages/contracts/cli-grammar.mjs +6 -0
  223. package/packages/contracts/cli-lookup-brief.mjs +2 -0
  224. package/packages/contracts/context-review.mjs +24 -0
  225. package/packages/contracts/document-intake.mjs +11 -0
  226. package/packages/contracts/domain-control-flight-recorder.mjs +30 -14
  227. package/packages/contracts/domain-control-manifest.mjs +1 -1
  228. package/packages/contracts/domain-control-monitor.mjs +13 -2
  229. package/packages/contracts/domain-control-review.mjs +36 -15
  230. package/packages/contracts/har-review.mjs +2 -0
  231. package/packages/contracts/identity-events.mjs +4 -0
  232. package/packages/contracts/investigation-run.mjs +2 -0
  233. package/packages/contracts/ip-address.mjs +10 -0
  234. package/packages/contracts/mail-authentication.mjs +5 -0
  235. package/packages/contracts/message-intake.mjs +31 -0
  236. package/packages/contracts/offline-comparison.mjs +4 -1
  237. package/packages/contracts/passive-mail.mjs +10 -0
  238. package/packages/contracts/web-capture.mjs +2 -2
  239. package/packages/contracts/workspace-portability.mjs +63 -17
  240. package/packages/evidence/collection-quality.mjs +67 -0
  241. package/packages/evidence/dns-query-outcome.mjs +72 -0
  242. package/packages/evidence/email-recipient.mjs +18 -0
  243. package/packages/evidence/lookup-target.mjs +5 -1
  244. package/packages/evidence/observation.mjs +2 -0
  245. package/packages/evidence/png-pixels.mjs +117 -0
  246. package/packages/evidence/public-address-policy.mjs +83 -0
  247. package/packages/interchange/defensive-indicator-export.mjs +264 -0
  248. package/packages/interchange/external-findings-import.mjs +4 -12
  249. package/packages/interchange/managed-indicator-export.mjs +35 -0
  250. package/packages/interchange/managed-indicator-set.mjs +210 -0
  251. package/packages/interchange/misp-indicator-export.mjs +93 -0
  252. package/packages/interchange/sharing-policy.mjs +56 -0
  253. package/packages/interchange/stix-indicator-export.mjs +129 -0
  254. package/packages/interchange/web-capture-import.mjs +22 -15
  255. package/packages/investigation/acquisition-decision-packet.mjs +1 -1
  256. package/packages/investigation/bulk-mail-exposure.mjs +7 -13
  257. package/packages/investigation/capture-coverage.mjs +57 -0
  258. package/packages/investigation/connector-provenance-review.mjs +100 -0
  259. package/packages/investigation/context-review.mjs +35 -0
  260. package/packages/investigation/document-intake.mjs +81 -0
  261. package/packages/investigation/docx-intake.mjs +133 -0
  262. package/packages/investigation/domain-history-review.mjs +100 -0
  263. package/packages/investigation/domain-transition-review.mjs +47 -0
  264. package/packages/investigation/evidence-coverage-ledger.mjs +9 -1
  265. package/packages/investigation/har-intake.mjs +68 -0
  266. package/packages/investigation/identity-event-intake.mjs +131 -0
  267. package/packages/investigation/identity-incident-review.mjs +39 -0
  268. package/packages/investigation/incident-sequence-review.mjs +58 -0
  269. package/packages/investigation/intake-report.mjs +19 -0
  270. package/packages/investigation/investigation-manifest.mjs +2 -2
  271. package/packages/investigation/link-intake.mjs +179 -0
  272. package/packages/investigation/lookup-asset-graph.mjs +1 -1
  273. package/packages/investigation/mail-authentication-review.mjs +222 -0
  274. package/packages/investigation/mail-header-identity.mjs +209 -0
  275. package/packages/investigation/message-intake.mjs +195 -0
  276. package/packages/investigation/page-behaviour.mjs +113 -0
  277. package/packages/investigation/pdf-intake.mjs +155 -0
  278. package/packages/investigation/platform-continuity-review.mjs +60 -0
  279. package/packages/investigation/qr-intake.mjs +56 -0
  280. package/packages/investigation/requested-action-hints.mjs +11 -0
  281. package/packages/investigation/selected-input-review.mjs +17 -0
  282. package/packages/investigation/storefront-review.mjs +70 -0
  283. package/packages/workspace/brand-profile-model.mjs +2 -2
  284. package/packages/workspace/bulk-review-model.mjs +28 -26
  285. package/packages/workspace/bulk-session-model.mjs +23 -13
  286. package/packages/workspace/campaign-model.mjs +1 -1
  287. package/packages/workspace/detection-rule-model.mjs +1 -1
  288. package/packages/workspace/hostile-input.mjs +1 -1
  289. package/packages/workspace/investigation-guide.mjs +2 -2
  290. package/packages/workspace/investigation-template-model.mjs +26 -2
  291. package/packages/workspace/page-baseline.mjs +2 -2
  292. package/packages/workspace/relationship-observation-model.mjs +17 -13
  293. package/packages/workspace/shortlist-model.mjs +14 -5
  294. package/packages/workspace/watchlist-history.mjs +28 -12
  295. package/packages/workspace/watchlist-store.mjs +17 -5
  296. package/packages/workspace/website-snapshot-model.mjs +28 -3
  297. package/packages/workspace/workspace-archive-crypto.mjs +1 -1
  298. package/packages/workspace/workspace-archive.mjs +5 -4
  299. package/third-party-notices.txt +549 -16
  300. package/lib/hostname.mjs +0 -2
@@ -0,0 +1,273 @@
1
+ import { CASE_SCHEMA_VERSION, CLI_CASE_PACK_INPUT_CASE_VERSIONS, MAX_CASE_STORE_BYTES, MAX_EDITABLE_CASE_INPUT_BYTES } from "../packages/contracts/case-portability.mjs";
2
+ import { MESSAGE_INTAKE_KINDS, MESSAGE_INTAKE_SCHEMA } from "../packages/contracts/message-intake.mjs";
3
+ import { IDENTITY_EVENTS_INPUT_SCHEMA } from "../packages/contracts/identity-events.mjs";
4
+ import { CONTEXT_INPUT_SCHEMAS, CONTEXT_REVIEW_SCHEMA } from "../packages/contracts/context-review.mjs";
5
+ import { MANAGED_INDICATOR_SET_SCHEMA, MAX_MANAGED_INDICATOR_SET_BYTES, MAX_MANAGED_INDICATOR_PLAN_BYTES } from "../packages/contracts/analyst-interchange.mjs";
6
+ import { CLI_CASE_OPERATIONS, CLI_INDICATOR_OPERATIONS, positional, OPTIONAL_FILE_POSITIONAL, constraint, EMPTY_CONSTRAINTS, commandSeed } from "./command-definition.mjs";
7
+ export const REVIEW_COMMAND_DEFINITIONS = Object.freeze({
8
+ "verify-artifact": commandSeed({
9
+ reference: {
10
+ description: 'Validate a supported archive, ordinary Case export, claim passport, packet, manifest, saved Lookup or Lookup-evidence export without printing evidence contents. Use --package for an evidence ZIP or encrypted package, with --passphrase-file to unlock it; use --folder ./evidence for an unencrypted evidence folder. Add --bagit with --package or --folder to verify BagIt 1.0.',
11
+ example: 'whoisleuth verify-artifact report.json --manifest manifest.json --manifest-entry artifact-2 --json --strict-exit',
12
+ boundary: 'Verification is offline and redacted. ZIP and folder entries are reported separately without importing them. Case exports are checked without repairing content; ordinary package review also counts original references with matching bytes. Ordinary folders allow only the declared layout; BagIt allows bounded nested payloads and checks SHA-256/SHA-512 manifests without interpreting payloads. Symbolic links are refused. BagIt fetch.txt is never fetched; missing files, mismatches and unsupported algorithms remain explicit. Package digests describe bytes, not filesystem metadata or authenticity. In scripts, use --strict-exit: incomplete verification returns 4. Unencrypted workspace archives always return 4 under --strict-exit because section digests do not cover mutable root metadata; structure-only formats can pass their narrower contract. Default exit 0 means the report was produced, not that its checks passed.',
13
+ },
14
+ collection: { scope: 'Reads one selected bounded artefact, ZIP or explicit evidence folder and, when explicitly supplied, one manifest whose selected entry is compared by exact bytes and canonical identity.' },
15
+ summary: 'Validate saved evidence offline',
16
+ options: ['--passphrase-file', '--manifest', '--manifest-entry', '--package', '--bagit', '--folder', '--json', '--strict-exit', '--quiet', '--no-color'],
17
+ positionals: Object.freeze([positional('source', 'file', 0, 1, [], 'argv_or_stdin', ['--package'])]),
18
+ constraints: Object.freeze([
19
+ constraint({ kind: 'requires_any', option: '--bagit', requiredOptions: ['--package', '--folder'] }),
20
+ constraint({ kind: 'excludes_all', option: '--bagit', excludedOptions: ['--passphrase-file', '--manifest', '--manifest-entry'] }),
21
+ constraint({ kind: 'requires_all', option: '--manifest', requiredOptions: ['--manifest-entry'] }),
22
+ constraint({ kind: 'requires_all', option: '--manifest-entry', requiredOptions: ['--manifest'] }),
23
+ constraint({ kind: 'excludes_all', option: '--package', excludedOptions: ['--manifest', '--manifest-entry'] }),
24
+ constraint({ kind: 'excludes_all', option: '--folder', excludedOptions: ['--package', '--manifest', '--manifest-entry', '--passphrase-file'] }),
25
+ ]),
26
+ handlerOwner: 'review',
27
+ networkEffect: 'offline',
28
+ common: true,
29
+ schemaIdentifiers: Object.freeze(['whoisleuth.offline-artifact-verification']),
30
+ primaryArtefacts: Object.freeze(['Offline verification report']),
31
+ planSupport: false,
32
+ additionalOutputFormats: Object.freeze([]),
33
+ bootstrapProfile: 'allowed',
34
+ }),
35
+ "interchange-report": commandSeed({
36
+ reference: {
37
+ description: 'Report what one recognised portable artefact preserves, excludes, and supports across browser and CLI workflows.',
38
+ example: 'whoisleuth interchange-report workspace.json --json',
39
+ boundary: 'The report is offline and metadata-only. It does not echo targets, contacts, notes, passphrases, evidence values, or an unrecognised schema string.',
40
+ },
41
+ collection: { scope: 'Reads one selected bounded portable artefact and emits fixed compatibility metadata only.' },
42
+ summary: 'Report portable artefact fidelity offline',
43
+ options: ['--passphrase-file', '--json', '--quiet', '--no-color'],
44
+ positionals: OPTIONAL_FILE_POSITIONAL,
45
+ constraints: EMPTY_CONSTRAINTS,
46
+ handlerOwner: 'review',
47
+ networkEffect: 'offline',
48
+ common: false,
49
+ schemaIdentifiers: Object.freeze(['whoisleuth.interchange-fidelity-report']),
50
+ primaryArtefacts: Object.freeze([]),
51
+ planSupport: false,
52
+ additionalOutputFormats: Object.freeze([]),
53
+ bootstrapProfile: 'allowed',
54
+ }),
55
+ "source-report": commandSeed({
56
+ reference: {
57
+ description: 'Create a target-free reliability summary from a saved lookup.',
58
+ example: 'whoisleuth source-report lookup.json --json',
59
+ boundary: 'The report retains source states and timings but excludes targets, queries, endpoints, and raw evidence.',
60
+ },
61
+ collection: { scope: 'Reads bounded saved evidence and emits target-free source reliability data.' },
62
+ summary: 'Build a target-free source report',
63
+ options: ['--json', '--quiet', '--no-color'],
64
+ positionals: OPTIONAL_FILE_POSITIONAL,
65
+ constraints: EMPTY_CONSTRAINTS,
66
+ handlerOwner: 'review',
67
+ networkEffect: 'offline',
68
+ common: false,
69
+ schemaIdentifiers: Object.freeze(['whoisleuth.source-reliability-report']),
70
+ primaryArtefacts: Object.freeze([]),
71
+ planSupport: false,
72
+ additionalOutputFormats: Object.freeze([]),
73
+ bootstrapProfile: 'allowed',
74
+ }),
75
+ compare: commandSeed({
76
+ reference: {
77
+ description: 'Compare separately attributed registry publications in a saved lookup.',
78
+ example: 'whoisleuth compare lookup.json --json',
79
+ boundary: 'Comparison is offline. Differences are review context and do not by themselves prove which publication is current.',
80
+ },
81
+ collection: { scope: 'Reads one saved Lookup and compares its separately attributed registry publications.' },
82
+ summary: 'Compare registry publications in one lookup',
83
+ options: ['--json', '--quiet', '--no-color'],
84
+ positionals: OPTIONAL_FILE_POSITIONAL,
85
+ constraints: EMPTY_CONSTRAINTS,
86
+ handlerOwner: 'review',
87
+ networkEffect: 'offline',
88
+ common: false,
89
+ schemaIdentifiers: Object.freeze(['whoisleuth.cli.compare']),
90
+ primaryArtefacts: Object.freeze([]),
91
+ planSupport: false,
92
+ additionalOutputFormats: Object.freeze([]),
93
+ bootstrapProfile: 'allowed',
94
+ }),
95
+ "page-compare": commandSeed({
96
+ reference: {
97
+ description: 'Compare static page identity, favicon, technology, and TLS evidence in two saved deep lookups.',
98
+ example: 'whoisleuth page-compare official.json candidate.json --json',
99
+ boundary: 'Comparison is offline and component-based. It executes no page code and produces no aggregate similarity or maliciousness score.',
100
+ },
101
+ collection: { scope: 'Reads two saved Lookup documents and executes no page code.' },
102
+ summary: 'Compare saved static page evidence',
103
+ options: ['--json', '--quiet', '--no-color'],
104
+ positionals: Object.freeze([positional('sources', 'file', 2, 2)]),
105
+ constraints: EMPTY_CONSTRAINTS,
106
+ handlerOwner: 'review',
107
+ networkEffect: 'offline',
108
+ common: false,
109
+ schemaIdentifiers: Object.freeze(['whoisleuth.cli.page-compare']),
110
+ primaryArtefacts: Object.freeze([]),
111
+ planSupport: false,
112
+ additionalOutputFormats: Object.freeze([]),
113
+ bootstrapProfile: 'allowed',
114
+ }),
115
+ "mail-review": commandSeed({
116
+ reference: {
117
+ description: 'Review passive MX, null MX, SPF, DMARC, and shared mail-provider evidence from saved Bulk results.',
118
+ example: 'whoisleuth mail-review candidates.json --json',
119
+ boundary: 'Review is offline and sends no SMTP traffic. Missing or partial DNS evidence remains inconclusive.',
120
+ },
121
+ collection: { scope: 'Reads one saved Bulk result and sends no DNS or SMTP traffic.' },
122
+ summary: 'Review saved passive mail evidence',
123
+ options: ['--json', '--quiet', '--no-color'],
124
+ positionals: OPTIONAL_FILE_POSITIONAL,
125
+ constraints: EMPTY_CONSTRAINTS,
126
+ handlerOwner: 'review',
127
+ networkEffect: 'offline',
128
+ common: false,
129
+ schemaIdentifiers: Object.freeze(['whoisleuth.cli.mail-review']),
130
+ primaryArtefacts: Object.freeze([]),
131
+ planSupport: false,
132
+ additionalOutputFormats: Object.freeze([]),
133
+ bootstrapProfile: 'allowed',
134
+ }),
135
+ "mail-headers": commandSeed({
136
+ reference: {
137
+ description: 'Review identity, reported authentication, domain alignment, and Received routing from selected message headers.',
138
+ example: 'whoisleuth mail-headers message.eml --json',
139
+ boundary: 'Review is offline. It makes no DNS, SMTP, HTTP, registry, or provider request, and does not retain address local parts, display names, subjects, message bodies, attachments, or raw header values. Reported authentication is not independently validated.',
140
+ },
141
+ collection: { scope: 'Parses only the bounded header block from one selected message or standard input.' },
142
+ summary: 'Review message headers offline',
143
+ options: ['--trusted-auth-header', '--json', '--quiet', '--no-color'],
144
+ positionals: OPTIONAL_FILE_POSITIONAL,
145
+ constraints: EMPTY_CONSTRAINTS,
146
+ handlerOwner: 'review',
147
+ networkEffect: 'offline',
148
+ common: false,
149
+ schemaIdentifiers: Object.freeze(['whoisleuth.cli.mail-header-review']),
150
+ primaryArtefacts: Object.freeze([]),
151
+ planSupport: false,
152
+ additionalOutputFormats: Object.freeze([]),
153
+ bootstrapProfile: 'allowed',
154
+ }),
155
+ intake: commandSeed({
156
+ reference: {
157
+ description: 'Review message identities, document links, QR destinations, HTTP archives or selected identity events locally.',
158
+ example: 'whoisleuth intake email message.eml --json',
159
+ boundary: 'Offline only: no destination, embedded command or QR payload is opened or executed. Output omits original bodies, subjects, address local parts, URL paths, queries and fragments. Authentication headers remain attributed claims. Selected PDF and DOCX review retains part identities and extraction coverage; embedded raster QR support does not imply complete rendered-page review. HAR retains minimised request order and timings, never replay authority, headers, cookies or bodies. Use --strict-exit to return 4 for partial coverage.',
160
+ },
161
+ collection: { scope: 'Reads one selected text, MIME email, calendar, PNG, PDF, DOCX, HAR or identity-event JSON file. No collection or automatic Case write.' },
162
+ summary: 'Review selected files and identity events offline',
163
+ options: ['--json', '--reported-action', '--trusted-auth-header', '--strict-exit', '--quiet', '--no-color'],
164
+ positionals: Object.freeze([positional('kind', 'enum', 1, 1, MESSAGE_INTAKE_KINDS), positional('source', 'file', 0, 1, [], 'argv_or_stdin')]),
165
+ constraints: EMPTY_CONSTRAINTS, handlerOwner: 'review', networkEffect: 'offline', common: true,
166
+ schemaIdentifiers: Object.freeze([MESSAGE_INTAKE_SCHEMA, IDENTITY_EVENTS_INPUT_SCHEMA]), primaryArtefacts: Object.freeze([]),
167
+ planSupport: false, additionalOutputFormats: Object.freeze([]), bootstrapProfile: 'allowed',
168
+ }),
169
+ "review-evidence": commandSeed({
170
+ reference: {
171
+ description: 'Review versioned protocol evidence, incident sequences, domain history, platform objects, storefronts or connector configuration offline.',
172
+ example: 'whoisleuth review-evidence domain-change.json --json --strict-exit',
173
+ boundary: 'The command reads only the supplied document and, with --mmdb, one explicitly selected local database up to 512 MiB. MMDB parsing and lookup run in one disposable worker with a ten-second processing deadline and an 8 KiB result limit. Version-2 MMDB queries require a justified age policy; unavailable or partial current reviews return 4 under --strict-exit. Historical version-1 MMDB queries preserve their output without freshness admission. No database is bundled, downloaded, updated or transmitted. It performs no DNS, RDAP, BGP, GeoIP-provider, TLS, HTTP, certificate-authority, or SMTP request.',
174
+ },
175
+ collection: { scope: 'Reads one bounded versioned evidence or request-planning document and, only with --mmdb, one selected bounded local database. Performs no collection.' },
176
+ summary: 'Review supplied evidence offline',
177
+ options: ['--mmdb', '--json', '--strict-exit', '--quiet', '--no-color'],
178
+ positionals: OPTIONAL_FILE_POSITIONAL,
179
+ constraints: EMPTY_CONSTRAINTS,
180
+ handlerOwner: 'review',
181
+ networkEffect: 'offline',
182
+ common: true,
183
+ schemaIdentifiers: Object.freeze([
184
+ CONTEXT_REVIEW_SCHEMA,
185
+ ...CONTEXT_INPUT_SCHEMAS,
186
+ 'whoisleuth.cli.offline-evidence-review',
187
+ 'whoisleuth.rdap-search-input',
188
+ 'whoisleuth.dnssec-evidence-input',
189
+ 'whoisleuth.tlsa-evidence-input',
190
+ 'whoisleuth.rpki-route-input',
191
+ 'whoisleuth.local-geoip-query',
192
+ 'whoisleuth.local-mmdb-query',
193
+ 'whoisleuth.local-mmdb-review',
194
+ 'whoisleuth.encrypted-dns-plan-input',
195
+ ]),
196
+ primaryArtefacts: Object.freeze([]),
197
+ planSupport: false,
198
+ additionalOutputFormats: Object.freeze([]),
199
+ bootstrapProfile: 'allowed',
200
+ }),
201
+ brief: commandSeed({
202
+ reference: {
203
+ description: 'Turn one saved Lookup into a compact decision brief with facts, unknowns, contradictions, and next actions.',
204
+ example: 'whoisleuth brief lookup.json --json',
205
+ boundary: 'The command is offline, excludes raw upstream payloads, and does not create an analyst assertion or claim that the saved observation is current.',
206
+ },
207
+ collection: { scope: 'Reads one bounded saved Lookup and emits a compact source-attributed decision brief.' },
208
+ summary: 'Build a decision brief from a saved lookup',
209
+ options: ['--json', '--quiet', '--no-color'],
210
+ positionals: OPTIONAL_FILE_POSITIONAL,
211
+ constraints: EMPTY_CONSTRAINTS,
212
+ handlerOwner: 'review',
213
+ networkEffect: 'offline',
214
+ common: false,
215
+ schemaIdentifiers: Object.freeze(['whoisleuth.cli.lookup-brief']),
216
+ primaryArtefacts: Object.freeze([]),
217
+ planSupport: false,
218
+ additionalOutputFormats: Object.freeze([]),
219
+ bootstrapProfile: 'allowed',
220
+ }),
221
+ case: commandSeed({
222
+ reference: {
223
+ description: 'Show or open a local Case, append a note or evidence pin, link evidence, withdraw a link, record an assessment, or retain an offline recheck. Use --input for pin, link, withdraw-link, assessment and recheck JSON; --text or --note-file for a note. Link input contains fromPinId, toPinId, kind (derived_from or shared_source), and basis. Withdrawal input contains id and reason. Mutations require --output and always write the complete current Case export.',
224
+ example: 'whoisleuth case open --domain example.test --output cases.json\n whoisleuth case show cases.json\n whoisleuth case note cases.json --text "Review the retained observation" --output cases.json --force',
225
+ boundary: 'No database, browser launch, request or external report is created. Select --case-id when a file contains multiple Cases. Existing files require --force; --expect-file-digest sha256:<digest> additionally checks the exact file reviewed earlier. Source and output leases reject concurrent changes. Interrupted .workflow.lock files require deliberate inspection. Recheck records supplied observations; it does not collect them. Not reproduced requires an existing saved question, a complete observation and comparable conditions. Working exports include private analyst content and file references, not attached file bytes.',
226
+ },
227
+ collection: { scope: `Reads exact Case schemas ${CLI_CASE_PACK_INPUT_CASE_VERSIONS.join(' or ')}. Input is bounded to ${MAX_EDITABLE_CASE_INPUT_BYTES / 1024 / 1024} MiB including formatting; the complete canonical Case store must fit ${MAX_CASE_STORE_BYTES / 1024 / 1024} MiB without pruning. Writes current schema ${CASE_SCHEMA_VERSION}.` },
228
+ summary: 'Review and update ordinary local Case files',
229
+ options: ['--case-id', '--domain', '--title', '--new-incident', '--text', '--note-file', '--input', '--expect-file-digest', '--json', '--no-color'],
230
+ positionals: Object.freeze([positional('operation', 'enum', 1, 1, CLI_CASE_OPERATIONS), positional('source', 'file', 0, 1)]),
231
+ constraints: Object.freeze([constraint({ kind: 'mutually_exclusive', options: ['--text', '--note-file'] })]),
232
+ handlerOwner: 'review', networkEffect: 'offline', common: true,
233
+ schemaIdentifiers: Object.freeze(['whoisleuth.case-export']),
234
+ primaryArtefacts: Object.freeze(['Case export']), planSupport: false,
235
+ additionalOutputFormats: Object.freeze([]), bootstrapProfile: 'allowed',
236
+ }),
237
+ 'indicator-set': commandSeed({
238
+ reference: {
239
+ description: 'Create or revise a file-based indicator set, inspect its identities and review windows, or export the exact revision as STIX or MISP JSON. Revise takes a plan containing basis, name, expiresAt, reviewed rows and selectedDomains; later plans embed previous and explicitly select renewIds, withdrawIds or reintroduceDomains. Use --json with revise to retain the manifest. STIX and MISP operations always output JSON.',
240
+ example: 'whoisleuth indicator-set revise indicator-plan.json --json --output indicators.json\n whoisleuth indicator-set inspect indicators.json\n whoisleuth indicator-set stix indicators.json --output indicators.stix.json',
241
+ boundary: 'Offline files only; no submission, feed synchronisation or automatic deletion. Missing candidates are unchanged. Renewal changes the review window, not the original observation. Withdrawn identities stay withdrawn; reintroduction requires a new identity. Keep prior manifests: a digest verifies content, not authorship or the complete revision chain. MISP expiry is a review deadline, not automatic enforcement.',
242
+ },
243
+ collection: { scope: `Reads a selected ${MAX_MANAGED_INDICATOR_PLAN_BYTES / 1024 / 1024} MiB revision plan or ${MAX_MANAGED_INDICATOR_SET_BYTES / 1024 / 1024} MiB manifest. Makes no requests and does not alter the source file.` },
244
+ summary: 'Manage reviewed indicator revisions offline',
245
+ options: ['--json', '--quiet', '--no-color'],
246
+ positionals: Object.freeze([positional('operation', 'enum', 1, 1, CLI_INDICATOR_OPERATIONS), positional('source', 'file', 0, 1)]),
247
+ constraints: EMPTY_CONSTRAINTS, handlerOwner: 'review', networkEffect: 'offline', common: false,
248
+ schemaIdentifiers: Object.freeze([MANAGED_INDICATOR_SET_SCHEMA]), primaryArtefacts: Object.freeze(['Managed indicator revision']),
249
+ planSupport: false, additionalOutputFormats: Object.freeze([]), bootstrapProfile: 'allowed',
250
+ }),
251
+ "case-pack": commandSeed({
252
+ reference: {
253
+ description: `Package browser-created Case records from schemas ${CLI_CASE_PACK_INPUT_CASE_VERSIONS.join(' or ')} as a reviewed, audience-specific Case-pack v2 with current schema ${CASE_SCHEMA_VERSION}.`,
254
+ example: 'whoisleuth case-pack cases.json --audience trusted --reviewed --json',
255
+ boundary: 'The command creates a separate offline package without changing the source or durable Cases and requires an explicit review acknowledgement. Every audience, including public, retains domain evidence, Case, pin and decision identifiers, tags, decision summaries and rationale. Review that analyst-authored content as well as the exclusions before sharing.',
256
+ },
257
+ collection: { scope: `Reads one bounded Case export from schemas ${CLI_CASE_PACK_INPUT_CASE_VERSIONS.join(' or ')} and writes a separate audience-specific Case-pack v2.` },
258
+ summary: 'Build a reviewed case package',
259
+ options: ['--audience', '--reviewed', '--json', '--quiet', '--no-color'],
260
+ positionals: OPTIONAL_FILE_POSITIONAL,
261
+ constraints: Object.freeze([
262
+ constraint({ kind: 'required', options: ['--audience', '--reviewed'] }),
263
+ ]),
264
+ handlerOwner: 'review',
265
+ networkEffect: 'offline',
266
+ common: true,
267
+ schemaIdentifiers: Object.freeze(['whoisleuth.cli.case-pack', 'whoisleuth.case-report']),
268
+ primaryArtefacts: Object.freeze(['Reviewed Case-pack v2']),
269
+ planSupport: false,
270
+ additionalOutputFormats: Object.freeze([]),
271
+ bootstrapProfile: 'allowed',
272
+ }),
273
+ });
@@ -1,3 +1,6 @@
1
+ import { runIntakeCommand } from "./intake-command.mjs";
2
+ import { runIndicatorSetCommand } from "./indicator-set-command.mjs";
3
+ import { CONTEXT_REVIEW_KINDS } from "../packages/contracts/context-review.mjs";
1
4
  import { readBoundedRegularFile } from "../lib/bounded-file.mjs";
2
5
  import { MAX_ENCRYPTED_INVESTIGATION_PACKAGE_BYTES } from "../packages/contracts/investigation-package-limits.mjs";
3
6
  import { verifyOfflineInvestigationFolder, verifyOfflineInvestigationPackage } from "./investigation-package-review.mjs";
@@ -9,6 +12,7 @@ import { MAX_OFFLINE_ARTIFACT_BYTES, formatOfflineArtifactVerification, isComple
9
12
  import { MAX_CASE_PACK_INPUT_BYTES, buildCliCasePack, formatCliCasePack } from "./case-pack.mjs";
10
13
  import { MAX_COMPARE_INPUT_BYTES, compareLookupDocument, parseCliLookupDocument, } from "./compare.mjs";
11
14
  import { boundedCliErrorMessage, CliUsageError } from "./errors.mjs";
15
+ import { LOCAL_MMDB_REVIEW_SCHEMA, LOCAL_MMDB_REVIEW_VERSION } from "./local-mmdb-review.mjs";
12
16
  import EXIT_CODES from "./exit-codes.mjs";
13
17
  import { MAX_OFFLINE_EVIDENCE_INPUT_BYTES, buildOfflineEvidenceReview, buildOfflineEvidenceReviewWithLocalResources, formatOfflineEvidenceReview, } from "./offline-evidence-review.mjs";
14
18
  import { buildCliLookupBrief, formatCliLookupBrief } from "./lookup-brief.mjs";
@@ -23,6 +27,28 @@ import { formatTerminalCompare } from "./formatters/terminal.mjs";
23
27
  import { MAX_SAVED_LOOKUP_INPUT_BYTES } from "./saved-lookup.mjs";
24
28
  import { REVIEW_INLINE_COMMANDS } from "./inline-command-families.mjs";
25
29
  import { runDiscriminatedCommandHandler } from "./discriminated-command-handlers.mjs";
30
+ /** Readers retain their command-specific bounds and injection points. Only
31
+ * usage-error presentation and empty-input handling are shared. */
32
+ async function readReviewInput(read, label, emptyMessage) {
33
+ let input;
34
+ try {
35
+ input = await read();
36
+ }
37
+ catch (error) {
38
+ if (error instanceof CliUsageError)
39
+ throw error;
40
+ throw new CliUsageError(`Could not read ${label}: ${boundedCliErrorMessage(error, 'Input could not be read')}`);
41
+ }
42
+ if (emptyMessage && !input.trim())
43
+ throw new CliUsageError(emptyMessage);
44
+ return input;
45
+ }
46
+ function writeReviewReport(context, args, report, format) {
47
+ if (!args.quiet)
48
+ context.writeStdout(args.output === 'json'
49
+ ? formatJsonDocument(report)
50
+ : context.terminal(format(report), args.color));
51
+ }
26
52
  async function runVerifyArtifactCommand(args, dependencies, context) {
27
53
  context.setFailureLabel('Artefact verification');
28
54
  if (args.folder) {
@@ -37,8 +63,7 @@ async function runVerifyArtifactCommand(args, dependencies, context) {
37
63
  }
38
64
  const report = await (args.bagit ? verifyOfflineBagIt : verifyOfflineInvestigationFolder)(files);
39
65
  dependencies.signal?.throwIfAborted();
40
- if (!args.quiet)
41
- context.writeStdout(args.output === 'json' ? formatJsonDocument(report) : context.terminal(formatOfflineArtifactVerification(report), args.color));
66
+ writeReviewReport(context, args, report, formatOfflineArtifactVerification);
42
67
  return args.strictExit && !isCompleteOfflineArtifactVerification(report) ? EXIT_CODES.PARTIAL_FAILURE : EXIT_CODES.SUCCESS;
43
68
  }
44
69
  if (args.package) {
@@ -57,23 +82,12 @@ async function runVerifyArtifactCommand(args, dependencies, context) {
57
82
  const passphrase = args.passphraseSource ? await context.readPassphraseSource(args.passphraseSource) : undefined;
58
83
  const report = args.bagit ? await verifyOfflineBagIt(bytes) : await verifyOfflineInvestigationPackage(bytes, passphrase);
59
84
  dependencies.signal?.throwIfAborted();
60
- if (!args.quiet)
61
- context.writeStdout(args.output === 'json' ? formatJsonDocument(report) : context.terminal(formatOfflineArtifactVerification(report), args.color));
85
+ writeReviewReport(context, args, report, formatOfflineArtifactVerification);
62
86
  return args.strictExit && !isCompleteOfflineArtifactVerification(report) ? EXIT_CODES.PARTIAL_FAILURE : EXIT_CODES.SUCCESS;
63
87
  }
64
- let input;
65
- try {
66
- input = dependencies.readArtifactInput
67
- ? await dependencies.readArtifactInput(args.source)
68
- : await context.readInput(args.source, MAX_OFFLINE_ARTIFACT_BYTES, 'Artefact input');
69
- }
70
- catch (error) {
71
- if (error instanceof CliUsageError)
72
- throw error;
73
- throw new CliUsageError(`Could not read artifact input: ${boundedCliErrorMessage(error, 'Input could not be read')}`);
74
- }
75
- if (!input.trim())
76
- throw new CliUsageError('verify-artifact requires one JSON file or an artefact on stdin.');
88
+ const input = await readReviewInput(async () => dependencies.readArtifactInput
89
+ ? await dependencies.readArtifactInput(args.source)
90
+ : await context.readInput(args.source, MAX_OFFLINE_ARTIFACT_BYTES, 'Artefact input'), 'artifact input', 'verify-artifact requires one JSON file or an artefact on stdin.');
77
91
  const passphrase = args.passphraseSource ? await context.readPassphraseSource(args.passphraseSource) : null;
78
92
  let manifest = null;
79
93
  if (args.manifestSource && args.manifestEntryId) {
@@ -93,90 +107,44 @@ async function runVerifyArtifactCommand(args, dependencies, context) {
93
107
  manifest = Object.freeze({ raw, entryId: args.manifestEntryId });
94
108
  }
95
109
  const report = await verifyOfflineArtifact(input, { passphrase, manifest });
96
- if (!args.quiet) {
97
- context.writeStdout(args.output === 'json'
98
- ? formatJsonDocument(report)
99
- : context.terminal(formatOfflineArtifactVerification(report), args.color));
100
- }
110
+ writeReviewReport(context, args, report, formatOfflineArtifactVerification);
101
111
  return args.strictExit && !isCompleteOfflineArtifactVerification(report)
102
112
  ? EXIT_CODES.PARTIAL_FAILURE
103
113
  : EXIT_CODES.SUCCESS;
104
114
  }
105
115
  async function runInterchangeReportCommand(args, dependencies, context) {
106
116
  context.setFailureLabel('Interchange fidelity report');
107
- let input;
108
- try {
109
- input = dependencies.readArtifactInput
110
- ? await dependencies.readArtifactInput(args.source)
111
- : await context.readInput(args.source, MAX_OFFLINE_ARTIFACT_BYTES, 'Interchange input');
112
- }
113
- catch (error) {
114
- if (error instanceof CliUsageError)
115
- throw error;
116
- throw new CliUsageError(`Could not read interchange input: ${boundedCliErrorMessage(error, 'Input could not be read')}`);
117
- }
118
- if (!input.trim())
119
- throw new CliUsageError('interchange-report requires one JSON file or an artefact on stdin.');
117
+ const input = await readReviewInput(async () => dependencies.readArtifactInput
118
+ ? await dependencies.readArtifactInput(args.source)
119
+ : await context.readInput(args.source, MAX_OFFLINE_ARTIFACT_BYTES, 'Interchange input'), 'interchange input', 'interchange-report requires one JSON file or an artefact on stdin.');
120
120
  const passphrase = args.passphraseSource ? await context.readPassphraseSource(args.passphraseSource) : null;
121
121
  const report = await buildInterchangeFidelityReport(input, {
122
122
  generatedAt: context.now(),
123
123
  passphrase,
124
124
  });
125
- if (!args.quiet) {
126
- context.writeStdout(args.output === 'json'
127
- ? formatJsonDocument(report)
128
- : context.terminal(formatInterchangeFidelityReport(report), args.color));
129
- }
125
+ writeReviewReport(context, args, report, formatInterchangeFidelityReport);
130
126
  return EXIT_CODES.SUCCESS;
131
127
  }
132
128
  async function runSourceReportCommand(args, dependencies, context) {
133
129
  context.setFailureLabel('Source reliability report');
134
- let input;
135
- try {
136
- input = dependencies.readSourceReliabilityInput
137
- ? await dependencies.readSourceReliabilityInput(args.source)
138
- : await context.readInput(args.source, MAX_SOURCE_RELIABILITY_INPUT_BYTES, 'Source reliability input');
139
- }
140
- catch (error) {
141
- if (error instanceof CliUsageError)
142
- throw error;
143
- throw new CliUsageError(`Could not read source reliability input: ${boundedCliErrorMessage(error, 'Input could not be read')}`);
144
- }
145
- if (!input.trim())
146
- throw new CliUsageError('source-report requires one JSON file or lookup documents on stdin.');
130
+ const input = await readReviewInput(async () => dependencies.readSourceReliabilityInput
131
+ ? await dependencies.readSourceReliabilityInput(args.source)
132
+ : await context.readInput(args.source, MAX_SOURCE_RELIABILITY_INPUT_BYTES, 'Source reliability input'), 'source reliability input', 'source-report requires one JSON file or lookup documents on stdin.');
147
133
  const report = buildSourceReliabilityReport(input, context.now());
148
- if (!args.quiet) {
149
- context.writeStdout(args.output === 'json'
150
- ? formatJsonDocument(report)
151
- : context.terminal(formatSourceReliabilityReport(report), args.color));
152
- }
134
+ writeReviewReport(context, args, report, formatSourceReliabilityReport);
153
135
  return EXIT_CODES.SUCCESS;
154
136
  }
155
137
  async function runCompareCommand(args, dependencies, context) {
156
138
  context.setFailureLabel('Registry comparison');
157
- let input;
158
- try {
159
- input = dependencies.readCompareInput
160
- ? await dependencies.readCompareInput(args.source)
161
- : await context.readInput(args.source, MAX_COMPARE_INPUT_BYTES, 'Comparison input');
162
- }
163
- catch (error) {
164
- if (error instanceof CliUsageError)
165
- throw error;
166
- throw new CliUsageError(`Could not read comparison input: ${boundedCliErrorMessage(error, 'Input could not be read')}`);
167
- }
168
- if (!input.trim())
169
- throw new CliUsageError('compare requires one lookup JSON file or a lookup document on stdin.');
139
+ const input = await readReviewInput(async () => dependencies.readCompareInput
140
+ ? await dependencies.readCompareInput(args.source)
141
+ : await context.readInput(args.source, MAX_COMPARE_INPUT_BYTES, 'Comparison input'), 'comparison input', 'compare requires one lookup JSON file or a lookup document on stdin.');
170
142
  const parsed = parseCliLookupDocument(input);
171
143
  const loadComparison = dependencies.loadRegistryComparison || (() => import("../lib/registry-comparison.mjs"));
172
144
  const comparisonModule = await loadComparison();
173
145
  const result = compareLookupDocument(parsed, comparisonModule.compareRegistrySources, comparisonModule.compareRdapPublications);
174
146
  const document = buildCliCompareDocument(result, context.now());
175
- if (!args.quiet) {
176
- context.writeStdout(args.output === 'json'
177
- ? formatJsonDocument(document)
178
- : context.terminal(formatTerminalCompare(document), args.color));
179
- }
147
+ writeReviewReport(context, args, document, formatTerminalCompare);
180
148
  return EXIT_CODES.SUCCESS;
181
149
  }
182
150
  async function runPageCompareCommand(args, dependencies, context) {
@@ -197,80 +165,36 @@ async function runPageCompareCommand(args, dependencies, context) {
197
165
  throw new CliUsageError(`Could not read page comparison input: ${boundedCliErrorMessage(error, 'Input could not be read')}`);
198
166
  }
199
167
  const document = buildCliPageComparison(leftInput, rightInput, context.now());
200
- if (!args.quiet) {
201
- context.writeStdout(args.output === 'json'
202
- ? formatJsonDocument(document)
203
- : context.terminal(formatCliPageComparison(document), args.color));
204
- }
168
+ writeReviewReport(context, args, document, formatCliPageComparison);
205
169
  return EXIT_CODES.SUCCESS;
206
170
  }
207
171
  async function runMailReviewCommand(args, dependencies, context) {
208
172
  context.setFailureLabel('Passive mail review');
209
- let input;
210
- try {
211
- input = dependencies.readMailReviewInput
212
- ? await dependencies.readMailReviewInput(args.source)
213
- : await context.readInput(args.source, MAX_MAIL_REVIEW_INPUT_BYTES, 'Mail review input');
214
- }
215
- catch (error) {
216
- if (error instanceof CliUsageError)
217
- throw error;
218
- throw new CliUsageError(`Could not read mail review input: ${boundedCliErrorMessage(error, 'Input could not be read')}`);
219
- }
173
+ const input = await readReviewInput(async () => dependencies.readMailReviewInput
174
+ ? await dependencies.readMailReviewInput(args.source)
175
+ : await context.readInput(args.source, MAX_MAIL_REVIEW_INPUT_BYTES, 'Mail review input'), 'mail review input');
220
176
  const document = buildCliMailReview(input, context.now());
221
- if (!args.quiet) {
222
- context.writeStdout(args.output === 'json'
223
- ? formatJsonDocument(document)
224
- : context.terminal(formatCliMailReview(document), args.color));
225
- }
177
+ writeReviewReport(context, args, document, formatCliMailReview);
226
178
  return EXIT_CODES.SUCCESS;
227
179
  }
228
180
  async function runMailHeadersCommand(args, dependencies, context) {
229
181
  context.setFailureLabel('Mail-header review');
230
- let input;
231
- try {
232
- input = dependencies.readMailHeaderInput
233
- ? await dependencies.readMailHeaderInput(args.source)
234
- : await context.readHeaderInput(args.source, MAX_MAIL_HEADER_INPUT_BYTES, 'Mail-header input');
235
- }
236
- catch (error) {
237
- if (error instanceof CliUsageError)
238
- throw error;
239
- throw new CliUsageError(`Could not read mail-header input: ${boundedCliErrorMessage(error, 'Input could not be read')}`);
240
- }
241
- if (!input.trim())
242
- throw new CliUsageError('mail-headers requires one message or header file, or headers on stdin.');
243
- const document = buildCliMailHeaderReview(input, context.now());
244
- if (!args.quiet) {
245
- context.writeStdout(args.output === 'json'
246
- ? formatJsonDocument(document)
247
- : context.terminal(formatCliMailHeaderReview(document), args.color));
248
- }
182
+ const input = await readReviewInput(async () => dependencies.readMailHeaderInput
183
+ ? await dependencies.readMailHeaderInput(args.source)
184
+ : await context.readHeaderInput(args.source, MAX_MAIL_HEADER_INPUT_BYTES, 'Mail-header input'), 'mail-header input', 'mail-headers requires one message or header file, or headers on stdin.');
185
+ const document = buildCliMailHeaderReview(input, context.now(), args.trustedAuthHeaders);
186
+ writeReviewReport(context, args, document, formatCliMailHeaderReview);
249
187
  return EXIT_CODES.SUCCESS;
250
188
  }
251
189
  async function runOfflineEvidenceReviewCommand(args, dependencies, context) {
252
190
  context.setFailureLabel('Offline evidence review');
253
- let input;
254
- try {
255
- input = dependencies.readArtifactInput
256
- ? await dependencies.readArtifactInput(args.source)
257
- : await context.readInput(args.source, MAX_OFFLINE_EVIDENCE_INPUT_BYTES, 'Offline evidence input');
258
- }
259
- catch (error) {
260
- if (error instanceof CliUsageError)
261
- throw error;
262
- throw new CliUsageError(`Could not read offline evidence input: ${boundedCliErrorMessage(error, 'Input could not be read')}`);
263
- }
264
- if (!input.trim())
265
- throw new CliUsageError('review-evidence requires one JSON file or a document on stdin.');
191
+ const input = await readReviewInput(async () => dependencies.readArtifactInput
192
+ ? await dependencies.readArtifactInput(args.source)
193
+ : await context.readInput(args.source, MAX_OFFLINE_EVIDENCE_INPUT_BYTES, 'Offline evidence input'), 'offline evidence input', 'review-evidence requires one JSON file or a document on stdin.');
266
194
  const document = args.mmdbSource
267
195
  ? await buildOfflineEvidenceReviewWithLocalResources(input, context.now(), { mmdbPath: args.mmdbSource })
268
196
  : buildOfflineEvidenceReview(input, context.now());
269
- if (!args.quiet) {
270
- context.writeStdout(args.output === 'json'
271
- ? formatJsonDocument(document)
272
- : context.terminal(formatOfflineEvidenceReview(document), args.color));
273
- }
197
+ writeReviewReport(context, args, document, formatOfflineEvidenceReview);
274
198
  if (args.strictExit) {
275
199
  const result = document.result && typeof document.result === 'object' && !Array.isArray(document.result)
276
200
  ? document.result
@@ -281,7 +205,9 @@ async function runOfflineEvidenceReviewCommand(args, dependencies, context) {
281
205
  const zoneMismatch = document.kind === 'zone_intent' && (result.complete !== true
282
206
  || (result.counts && typeof result.counts === 'object' && !Array.isArray(result.counts)
283
207
  && ['different', 'missing', 'unexpected', 'incomplete'].some((key) => Number(result.counts[key]) > 0)));
284
- if (gate?.pass === false || zoneMismatch)
208
+ const contextPartial = CONTEXT_REVIEW_KINDS.includes(document.kind) && result.state === 'partial';
209
+ const mmdbIncomplete = result.schema === LOCAL_MMDB_REVIEW_SCHEMA && result.version === LOCAL_MMDB_REVIEW_VERSION && result.completeness !== 'complete';
210
+ if (gate?.pass === false || zoneMismatch || contextPartial || mmdbIncomplete)
285
211
  return EXIT_CODES.PARTIAL_FAILURE;
286
212
  }
287
213
  return EXIT_CODES.SUCCESS;
@@ -289,34 +215,16 @@ async function runOfflineEvidenceReviewCommand(args, dependencies, context) {
289
215
  async function runBriefOrCasePackCommand(args, dependencies, context) {
290
216
  const isBrief = args.action === 'brief';
291
217
  context.setFailureLabel(isBrief ? 'Lookup brief' : 'Case pack');
292
- let input;
293
- try {
294
- input = dependencies.readArtifactInput
295
- ? await dependencies.readArtifactInput(args.source)
296
- : await context.readInput(args.source, isBrief ? MAX_SAVED_LOOKUP_INPUT_BYTES : MAX_CASE_PACK_INPUT_BYTES, isBrief ? 'Lookup brief input' : 'Case-pack input');
297
- }
298
- catch (error) {
299
- if (error instanceof CliUsageError)
300
- throw error;
301
- throw new CliUsageError(`Could not read ${isBrief ? 'Lookup brief' : 'case-pack'} input: ${boundedCliErrorMessage(error, 'Input could not be read')}`);
302
- }
303
- if (!input.trim())
304
- throw new CliUsageError(`${args.action} requires one JSON file or a document on stdin.`);
218
+ const input = await readReviewInput(async () => dependencies.readArtifactInput
219
+ ? await dependencies.readArtifactInput(args.source)
220
+ : await context.readInput(args.source, isBrief ? MAX_SAVED_LOOKUP_INPUT_BYTES : MAX_CASE_PACK_INPUT_BYTES, isBrief ? 'Lookup brief input' : 'Case-pack input'), `${isBrief ? 'Lookup brief' : 'case-pack'} input`, `${args.action} requires one JSON file or a document on stdin.`);
305
221
  if (args.action === 'brief') {
306
222
  const document = buildCliLookupBrief(input, context.now());
307
- if (!args.quiet) {
308
- context.writeStdout(args.output === 'json'
309
- ? formatJsonDocument(document)
310
- : context.terminal(formatCliLookupBrief(document), args.color));
311
- }
223
+ writeReviewReport(context, args, document, formatCliLookupBrief);
312
224
  }
313
225
  else {
314
226
  const document = buildCliCasePack(input, { audience: args.audience, reviewed: args.reviewed }, context.now());
315
- if (!args.quiet) {
316
- context.writeStdout(args.output === 'json'
317
- ? formatJsonDocument(document)
318
- : context.terminal(formatCliCasePack(document), args.color));
319
- }
227
+ writeReviewReport(context, args, document, formatCliCasePack);
320
228
  }
321
229
  return EXIT_CODES.SUCCESS;
322
230
  }
@@ -328,10 +236,12 @@ const REVIEW_COMMAND_HANDLERS = Object.freeze({
328
236
  'page-compare': runPageCompareCommand,
329
237
  'mail-review': runMailReviewCommand,
330
238
  'mail-headers': runMailHeadersCommand,
239
+ intake: runIntakeCommand,
331
240
  'review-evidence': runOfflineEvidenceReviewCommand,
332
241
  'brief': runBriefOrCasePackCommand,
333
242
  'case': runCaseCommand,
334
243
  'case-pack': runBriefOrCasePackCommand,
244
+ 'indicator-set': runIndicatorSetCommand,
335
245
  });
336
246
  function runReviewCommand(args, dependencies, context) {
337
247
  return runDiscriminatedCommandHandler(REVIEW_COMMAND_HANDLERS, args, dependencies, context);