@slicedearth/whoisleuth-cli 2.3.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 (418) hide show
  1. package/NOTICE +8 -2
  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/argument-values.mjs +55 -0
  7. package/cli/arguments.mjs +24 -408
  8. package/cli/artifact-validation/case-response.mjs +65 -44
  9. package/cli/artifact-validation/investigation-capsule.mjs +52 -31
  10. package/cli/artifact-validation/lookup-evidence.mjs +15 -9
  11. package/cli/artifact-validation/signed-review.mjs +136 -68
  12. package/cli/artifact-validation/structure-primitives.mjs +1 -105
  13. package/cli/artifact-verify.mjs +92 -24
  14. package/cli/assurance-arguments.mjs +56 -0
  15. package/cli/assurance-command-definitions.mjs +179 -0
  16. package/cli/assurance-command-runner.mjs +40 -5
  17. package/cli/bagit-folder.mjs +57 -0
  18. package/cli/bagit-review.mjs +23 -0
  19. package/cli/bounded-json.mjs +1 -1
  20. package/cli/bulk-checkpoint.mjs +1 -1
  21. package/cli/bulk-command-runner.mjs +4 -2
  22. package/cli/bulk-output.mjs +29 -3
  23. package/cli/bulk.mjs +2 -3
  24. package/cli/case-command.mjs +212 -0
  25. package/cli/case-pack.mjs +50 -32
  26. package/cli/ci-report.mjs +6 -3
  27. package/cli/collection-arguments.mjs +123 -0
  28. package/cli/collection-command-definitions.mjs +330 -0
  29. package/cli/collection-preflight.mjs +8 -1
  30. package/cli/command-argument-grammar.mjs +8 -206
  31. package/cli/command-definition.mjs +329 -0
  32. package/cli/command-reference.mjs +94 -1357
  33. package/cli/command-seeds.mjs +18 -0
  34. package/cli/compare.mjs +1 -1
  35. package/cli/completion.mjs +298 -79
  36. package/cli/config-profile.mjs +8 -6
  37. package/cli/ct-event-intake.mjs +16 -16
  38. package/cli/discover.mjs +5 -4
  39. package/cli/discovery-observation-snapshot.mjs +46 -18
  40. package/cli/discovery-scan-command-runner.mjs +5 -3
  41. package/cli/discovery-scan.mjs +7 -4
  42. package/cli/discovery-snapshot.mjs +2 -2
  43. package/cli/discovery-workflow.mjs +5 -5
  44. package/cli/domain-control-monitor.mjs +50 -20
  45. package/cli/domain-control-observations.mjs +151 -103
  46. package/cli/errors.mjs +37 -6
  47. package/cli/evidence-arguments.mjs +46 -0
  48. package/cli/evidence-command-definitions.mjs +69 -0
  49. package/cli/evidence-command-runner.mjs +18 -8
  50. package/cli/evidence-signing.mjs +5 -4
  51. package/cli/external-observation-mapping.mjs +9 -10
  52. package/cli/formatters/terminal-command-formats.mjs +6 -0
  53. package/cli/formatters/terminal-lookup.mjs +24 -46
  54. package/cli/formatters/terminal-shared.mjs +5 -3
  55. package/cli/formatters/terminal-technology.mjs +114 -0
  56. package/cli/history-arguments.mjs +39 -0
  57. package/cli/history-command-definitions.mjs +88 -0
  58. package/cli/indicator-set-command.mjs +33 -0
  59. package/cli/inline-command-families.mjs +6 -46
  60. package/cli/inline-command-runner.mjs +4 -31
  61. package/cli/input.mjs +2 -2
  62. package/cli/intake-command.mjs +107 -0
  63. package/cli/interactive-launcher.mjs +2 -160
  64. package/cli/interchange-report.mjs +8 -10
  65. package/cli/investigation-artifacts.mjs +176 -0
  66. package/cli/investigation-checkpoint.mjs +10 -0
  67. package/cli/investigation-folder.mjs +114 -0
  68. package/cli/investigation-manifest.mjs +1 -112
  69. package/cli/investigation-package-review.mjs +103 -0
  70. package/cli/investigation-plan.mjs +38 -147
  71. package/cli/investigation-recipes.mjs +177 -0
  72. package/cli/investigation-run.mjs +256 -35
  73. package/cli/local-document-checkpoint.mjs +170 -0
  74. package/cli/local-mmdb-review.mjs +197 -17
  75. package/cli/lookalike-calibration.mjs +1 -1
  76. package/cli/lookup-brief.mjs +2 -2
  77. package/cli/lookup-browser.mjs +5 -12
  78. package/cli/lookup-command-runner.mjs +33 -39
  79. package/cli/lookup-diff.mjs +47 -12
  80. package/cli/lookup-plan.mjs +16 -14
  81. package/cli/lookup-reconcile.mjs +15 -12
  82. package/cli/lookup-timeline.mjs +7 -6
  83. package/cli/mail-header-review.mjs +24 -247
  84. package/cli/mail-review.mjs +75 -21
  85. package/cli/manual.mjs +1 -0
  86. package/cli/network-arguments.mjs +51 -0
  87. package/cli/network-command-definitions.mjs +130 -0
  88. package/cli/network-command-runner.mjs +45 -31
  89. package/cli/offline-evidence-review.mjs +39 -6
  90. package/cli/output-file.mjs +31 -5
  91. package/cli/posture.mjs +1 -1
  92. package/cli/registry-doctor.mjs +3 -1
  93. package/cli/registry-fixture-scaffold.mjs +1 -1
  94. package/cli/registry-support.mjs +1 -1
  95. package/cli/retained-artifact-diff.mjs +10 -9
  96. package/cli/review-arguments.mjs +131 -0
  97. package/cli/review-command-definitions.mjs +273 -0
  98. package/cli/review-command-runner.mjs +104 -149
  99. package/cli/risk-calibration.mjs +8 -8
  100. package/cli/runner.mjs +74 -37
  101. package/cli/saved-lookup.mjs +2 -2
  102. package/cli/selected-input-worker.mjs +63 -0
  103. package/cli/sharing-review.mjs +8 -80
  104. package/cli/signer-trust.mjs +104 -0
  105. package/cli/source-reliability.mjs +75 -84
  106. package/cli/support-arguments.mjs +49 -0
  107. package/cli/support-command-definitions.mjs +209 -0
  108. package/cli/support-command-runner.mjs +2 -2
  109. package/cli/terminal-input.mjs +168 -0
  110. package/cli/workflow-arguments.mjs +86 -0
  111. package/cli/workflow-command-definitions.mjs +78 -0
  112. package/cli/workflow-command-runner.mjs +75 -13
  113. package/docs/cli-reference.md +103 -30
  114. package/docs/cli.md +510 -25
  115. package/docs/contextual-reviews.md +155 -0
  116. package/lib/abort.mjs +4 -1
  117. package/lib/analyst-taxonomy.mjs +2 -40
  118. package/lib/application-version.mjs +1 -1
  119. package/lib/availability.mjs +27 -754
  120. package/lib/bounded-contract-normalizers.mjs +4 -0
  121. package/lib/bounded-file.mjs +3 -1
  122. package/lib/bounded-html-document.mjs +125 -0
  123. package/lib/bounded-json.mjs +2 -290
  124. package/lib/browser-library-profile.mjs +99 -130
  125. package/lib/browser-library-worker.mjs +112 -0
  126. package/lib/candidate-provenance-bounds.mjs +2 -0
  127. package/lib/classify.mjs +1 -1
  128. package/lib/client-behavior-profile.mjs +3 -3
  129. package/lib/ct-query.mjs +2 -27
  130. package/lib/ct-response-bounds.mjs +2 -9
  131. package/lib/ct-search.mjs +89 -46
  132. package/lib/dmarc-discovery.mjs +62 -0
  133. package/lib/dns-convergence-review.mjs +21 -8
  134. package/lib/dns-delegation-health.mjs +36 -22
  135. package/lib/dns-inheritance-review.mjs +207 -0
  136. package/lib/dns-intelligence.mjs +44 -29
  137. package/lib/dns-mx.mjs +1 -1
  138. package/lib/dns-query-coverage.mjs +40 -0
  139. package/lib/dnssec-evidence-validation.mjs +11 -3
  140. package/lib/domain-assurance.mjs +1 -1
  141. package/lib/domain-change-packet.mjs +18 -14
  142. package/lib/domain-change-review.mjs +40 -16
  143. package/lib/domain-control-flight-recorder.mjs +301 -124
  144. package/lib/domain-control-manifest.mjs +126 -58
  145. package/lib/domain-posture-analysis.mjs +41 -11
  146. package/lib/domain-posture-parsers.mjs +88 -6
  147. package/lib/domain-posture.mjs +120 -32
  148. package/lib/domain-registration.mjs +392 -0
  149. package/lib/evidence-export-privacy.mjs +568 -0
  150. package/lib/evidence-export.mjs +12 -593
  151. package/lib/evidence-report.mjs +7 -2
  152. package/lib/external-intelligence-risk.mjs +2 -124
  153. package/lib/favicon.mjs +117 -41
  154. package/lib/generated/cisa-kev-catalog.mjs +50 -4
  155. package/lib/generated/registrar-standing-catalogue.mjs +18 -10
  156. package/lib/generated/retire-browser-catalog.mjs +2742 -1034
  157. package/lib/generated/{unicode-confusables-17.mjs → unicode-confusables.mjs} +33 -33
  158. package/lib/html-signals.mjs +87 -85
  159. package/lib/http-evidence-bounds.mjs +2 -7
  160. package/lib/http-intelligence.mjs +52 -27
  161. package/lib/idn-confusable-policy.mjs +6 -4
  162. package/lib/idn-confusables.mjs +1 -1
  163. package/lib/interchange-fidelity-registry.mjs +10 -0
  164. package/lib/lookup-cache.mjs +34 -11
  165. package/lib/lookup-child-profile-contract.mjs +24 -15
  166. package/lib/lookup-contract-primitives.mjs +1 -1
  167. package/lib/lookup-diagnostics.mjs +2 -1
  168. package/lib/lookup-network-evidence-bounds.mjs +1 -10
  169. package/lib/lookup-progress.mjs +79 -13
  170. package/lib/lookup-response-contract.mjs +1077 -0
  171. package/lib/lookup-response.mjs +11 -24
  172. package/lib/lookup-source-progress.mjs +24 -35
  173. package/lib/lookup.mjs +49 -28
  174. package/lib/observed-network-context.mjs +43 -27
  175. package/lib/opportunity-scoring.mjs +2 -107
  176. package/lib/outbound-identity.mjs +1 -1
  177. package/lib/outbound-request-bounds.mjs +4 -1
  178. package/lib/page-fingerprints.mjs +87 -134
  179. package/lib/page-role-profile.mjs +12 -8
  180. package/lib/perceptual-hash-comparison.mjs +2 -34
  181. package/lib/perceptual-hash.mjs +79 -24
  182. package/lib/portable-generator.mjs +2 -29
  183. package/lib/project-metadata.mjs +2 -8
  184. package/lib/rdap-bootstrap.mjs +72 -22
  185. package/lib/rdap-client.mjs +26 -14
  186. package/lib/rdap-normalization.mjs +33 -18
  187. package/lib/rdap-search-workbench.mjs +1 -1
  188. package/lib/rdap-transport.mjs +47 -20
  189. package/lib/rdap-types.mjs +2 -1
  190. package/lib/rdap.mjs +18 -3
  191. package/lib/registrable-domain.mjs +2 -36
  192. package/lib/registrar-standing-contract.mjs +2 -1
  193. package/lib/registrar-standing.mjs +9 -13
  194. package/lib/registry-capabilities.mjs +7 -3
  195. package/lib/registry-capability-catalogue.mjs +29 -20
  196. package/lib/registry-dates.mjs +2 -190
  197. package/lib/registry-insights.mjs +56 -18
  198. package/lib/response-policy.mjs +86 -48
  199. package/lib/risk-calibration-summary.mjs +2 -338
  200. package/lib/risk-scoring.mjs +2 -345
  201. package/lib/rpki-evidence.mjs +8 -5
  202. package/lib/safe-fetch.mjs +54 -47
  203. package/lib/scoring-evidence-quality.mjs +2 -107
  204. package/lib/security-txt.mjs +10 -7
  205. package/lib/semantic-version.mjs +2 -54
  206. package/lib/service-binding-dns.mjs +35 -0
  207. package/lib/smtp-transport-review.mjs +75 -34
  208. package/lib/sslbl-certificates.generated.mjs +141 -138
  209. package/lib/static-html-analysis.mjs +117 -146
  210. package/lib/structured-data-identity.mjs +2 -1
  211. package/lib/technology-markup.mjs +100 -0
  212. package/lib/threat-intelligence-manifests.mjs +11 -139
  213. package/lib/threat-intelligence-runtime.mjs +24 -52
  214. package/lib/threat-intelligence-targets.mjs +6 -34
  215. package/lib/threat-intelligence-types.mjs +2 -36
  216. package/lib/typosquat-generator.mjs +29 -16
  217. package/lib/urlhaus-intelligence.mjs +8 -3
  218. package/lib/urlscan-intelligence.mjs +1 -1
  219. package/lib/website-enrichment.mjs +388 -0
  220. package/lib/website-security-posture.mjs +30 -6
  221. package/lib/website-technology.mjs +111 -71
  222. package/lib/whois-authority.mjs +55 -5
  223. package/lib/whois-chain.mjs +9 -2
  224. package/lib/whois-common-formats.mjs +5 -5
  225. package/lib/whois-contracts.mjs +2 -1
  226. package/lib/whois-dialects.mjs +2 -2
  227. package/lib/whois-normalization.mjs +3 -1
  228. package/lib/whois-parser-support.mjs +8 -1
  229. package/lib/whois-parser.mjs +5 -3
  230. package/lib/whois-transport.mjs +23 -7
  231. package/package.json +14 -14
  232. package/packages/analysis/analyst-taxonomy.mjs +40 -0
  233. package/packages/analysis/bounded-json.mjs +330 -0
  234. package/packages/analysis/candidate-provenance-bounds.mjs +4 -0
  235. package/packages/analysis/cli-argument-grammar.mjs +216 -0
  236. package/packages/analysis/ct-query.mjs +27 -0
  237. package/packages/analysis/ct-response-bounds.mjs +9 -0
  238. package/packages/analysis/external-intelligence-risk.mjs +124 -0
  239. package/packages/analysis/http-evidence-bounds.mjs +8 -0
  240. package/packages/analysis/lookup-network-evidence-bounds.mjs +10 -0
  241. package/packages/analysis/opportunity-scoring.mjs +107 -0
  242. package/packages/analysis/perceptual-hash-comparison.mjs +34 -0
  243. package/packages/analysis/portable-generator.mjs +29 -0
  244. package/packages/analysis/project-metadata.mjs +8 -0
  245. package/packages/analysis/registrable-domain.mjs +56 -0
  246. package/packages/analysis/risk-calibration-summary.mjs +352 -0
  247. package/packages/analysis/risk-scoring.mjs +345 -0
  248. package/packages/analysis/scoring-evidence-quality.mjs +107 -0
  249. package/packages/analysis/semantic-version.mjs +54 -0
  250. package/packages/analysis/threat-intelligence-types.mjs +75 -0
  251. package/packages/cases/case-attachment-model.mjs +83 -0
  252. package/packages/cases/case-evidence-links.mjs +135 -0
  253. package/packages/cases/case-evidence-model.mjs +229 -69
  254. package/packages/cases/case-evidence-quality.mjs +5 -0
  255. package/packages/cases/case-export-input.mjs +55 -0
  256. package/packages/cases/case-incident-context.mjs +52 -0
  257. package/packages/cases/case-markdown.mjs +11 -0
  258. package/packages/cases/case-migration-model.mjs +98 -48
  259. package/packages/cases/case-model.mjs +1 -0
  260. package/packages/cases/case-recheck-model.mjs +149 -0
  261. package/packages/cases/case-record-contracts.mjs +1 -1
  262. package/packages/cases/case-record-core.mjs +4 -3
  263. package/packages/cases/case-record-operations.mjs +123 -133
  264. package/packages/cases/case-record-projection.mjs +30 -20
  265. package/packages/cases/case-record-version-input.mjs +19 -0
  266. package/packages/cases/case-report.mjs +114 -67
  267. package/packages/cases/case-requested-evidence.mjs +146 -0
  268. package/packages/cases/case-response-actions.mjs +756 -0
  269. package/packages/cases/case-response-model.mjs +49 -1129
  270. package/packages/cases/case-response-outcomes.mjs +338 -0
  271. package/packages/cases/case-response-packet-render.mjs +157 -0
  272. package/packages/cases/case-response-packet-types.mjs +1 -0
  273. package/packages/cases/case-response-packet-vocabulary.mjs +48 -0
  274. package/packages/cases/case-response-packet.mjs +58 -593
  275. package/packages/cases/case-response-records.mjs +93 -0
  276. package/packages/cases/case-response-review-inputs.mjs +382 -0
  277. package/packages/cases/case-response-values.mjs +85 -0
  278. package/packages/cases/case-review-package.mjs +45 -0
  279. package/packages/cases/case-selection.mjs +46 -0
  280. package/packages/cases/case-storage-model.mjs +26 -1
  281. package/packages/cases/case-workflow-metadata.mjs +225 -0
  282. package/packages/cases/case-workflow-migration.mjs +43 -0
  283. package/packages/cases/http-summary.mjs +6 -1
  284. package/packages/cases/response-route-freshness.mjs +16 -0
  285. package/packages/comparison/capture-context.mjs +61 -0
  286. package/packages/comparison/comparison-ledger-bulk.mjs +4 -1
  287. package/packages/comparison/favicon-similarity.mjs +1 -1
  288. package/packages/comparison/page-similarity.mjs +3 -2
  289. package/packages/comparison/relationship-evidence.mjs +103 -14
  290. package/packages/comparison/relationship-provenance.mjs +99 -0
  291. package/packages/contracts/analyst-interchange.mjs +234 -0
  292. package/packages/contracts/analyst-review-state-contract.mjs +11 -0
  293. package/packages/contracts/analyst-review-state.mjs +5 -4
  294. package/packages/contracts/case-portability.mjs +370 -114
  295. package/packages/contracts/case-views-contract.mjs +12 -0
  296. package/packages/contracts/case-views.mjs +26 -0
  297. package/packages/contracts/cli-command-semantics.mjs +3 -0
  298. package/packages/contracts/cli-grammar.mjs +6 -0
  299. package/packages/contracts/cli-lookup-brief.mjs +2 -0
  300. package/packages/contracts/cli-lookup.mjs +0 -1
  301. package/packages/contracts/context-review.mjs +24 -0
  302. package/packages/contracts/document-intake.mjs +11 -0
  303. package/packages/contracts/domain-control-flight-recorder.mjs +98 -44
  304. package/packages/contracts/domain-control-manifest.mjs +128 -74
  305. package/packages/contracts/domain-control-monitor.mjs +59 -45
  306. package/packages/contracts/domain-control-review.mjs +200 -121
  307. package/packages/contracts/domain-name.mjs +21 -0
  308. package/packages/contracts/external-observation-interchange.mjs +2 -1
  309. package/packages/contracts/extracted-domain-lifecycle.mjs +11 -32
  310. package/packages/contracts/har-review.mjs +2 -0
  311. package/packages/contracts/http-url.mjs +2 -0
  312. package/packages/contracts/identity-events.mjs +4 -0
  313. package/packages/contracts/investigation-package-limits.mjs +24 -0
  314. package/packages/contracts/investigation-portability.mjs +56 -27
  315. package/packages/contracts/investigation-run.mjs +18 -0
  316. package/packages/contracts/ip-address.mjs +44 -0
  317. package/packages/contracts/lookup-evidence.mjs +35 -0
  318. package/packages/contracts/mail-authentication.mjs +5 -0
  319. package/packages/contracts/message-intake.mjs +31 -0
  320. package/packages/contracts/offline-comparison.mjs +4 -2
  321. package/packages/contracts/page-fingerprints.mjs +15 -0
  322. package/packages/contracts/passive-mail.mjs +10 -0
  323. package/packages/contracts/risk-calibration.mjs +0 -1
  324. package/packages/contracts/schema-compatibility.mjs +1 -1
  325. package/packages/contracts/schema-lifecycle.mjs +83 -153
  326. package/packages/contracts/selected-file-limits.mjs +6 -0
  327. package/packages/contracts/vulnerability-identifiers.mjs +5 -0
  328. package/packages/contracts/web-capture.mjs +10 -0
  329. package/packages/contracts/workspace-portability.mjs +219 -238
  330. package/packages/evidence/artifact-integrity.mjs +8 -2
  331. package/packages/evidence/artifact-structure.mjs +105 -0
  332. package/packages/evidence/collection-quality.mjs +67 -0
  333. package/packages/evidence/decision-fact.mjs +4 -1
  334. package/packages/evidence/dns-query-outcome.mjs +72 -0
  335. package/packages/evidence/domain-control-runtime.mjs +155 -27
  336. package/packages/evidence/domain-posture-context.mjs +79 -0
  337. package/packages/evidence/email-recipient.mjs +18 -0
  338. package/packages/evidence/image-regions.mjs +56 -0
  339. package/packages/evidence/latest-observations.mjs +28 -0
  340. package/packages/evidence/lookup-target.mjs +108 -0
  341. package/packages/evidence/observation.mjs +18 -1
  342. package/packages/evidence/passphrase-encryption.mjs +44 -0
  343. package/packages/evidence/png-pixels.mjs +117 -0
  344. package/packages/evidence/public-address-policy.mjs +83 -0
  345. package/packages/evidence/record-identity.mjs +72 -0
  346. package/packages/evidence/registry-dates.mjs +190 -0
  347. package/packages/evidence/retained-file.mjs +69 -0
  348. package/packages/evidence/review-age.mjs +14 -0
  349. package/packages/interchange/bagit.mjs +342 -0
  350. package/packages/interchange/bounded-zip-extraction.mjs +278 -0
  351. package/packages/interchange/crc32.mjs +17 -0
  352. package/packages/interchange/defensive-indicator-export.mjs +264 -0
  353. package/packages/interchange/external-findings-import.mjs +93 -61
  354. package/packages/interchange/managed-indicator-export.mjs +35 -0
  355. package/packages/interchange/managed-indicator-set.mjs +210 -0
  356. package/packages/interchange/misp-indicator-export.mjs +93 -0
  357. package/packages/interchange/sharing-policy.mjs +56 -0
  358. package/packages/interchange/stix-indicator-export.mjs +129 -0
  359. package/packages/interchange/web-capture-import.mjs +389 -0
  360. package/packages/investigation/acquisition-decision-packet.mjs +1 -1
  361. package/packages/investigation/bulk-mail-exposure.mjs +9 -14
  362. package/packages/investigation/capture-coverage.mjs +57 -0
  363. package/packages/investigation/connector-provenance-review.mjs +100 -0
  364. package/packages/investigation/context-review.mjs +35 -0
  365. package/packages/investigation/document-intake.mjs +81 -0
  366. package/packages/investigation/docx-intake.mjs +133 -0
  367. package/packages/investigation/domain-history-review.mjs +100 -0
  368. package/packages/investigation/domain-transition-review.mjs +47 -0
  369. package/packages/investigation/evidence-coverage-ledger.mjs +9 -1
  370. package/packages/investigation/har-intake.mjs +68 -0
  371. package/packages/investigation/identity-event-intake.mjs +131 -0
  372. package/packages/investigation/identity-incident-review.mjs +39 -0
  373. package/packages/investigation/incident-sequence-review.mjs +58 -0
  374. package/packages/investigation/intake-report.mjs +19 -0
  375. package/packages/investigation/investigation-bagit.mjs +14 -0
  376. package/packages/investigation/investigation-capsule.mjs +33 -5
  377. package/packages/investigation/investigation-manifest.mjs +200 -0
  378. package/packages/investigation/investigation-package-crypto.mjs +98 -0
  379. package/packages/investigation/investigation-package.mjs +200 -0
  380. package/packages/investigation/link-intake.mjs +179 -0
  381. package/packages/investigation/lookup-asset-graph-inputs.mjs +38 -0
  382. package/packages/investigation/lookup-asset-graph.mjs +161 -141
  383. package/packages/investigation/lookup-claim-readiness.mjs +18 -0
  384. package/packages/investigation/lookup-investigation-brief.mjs +3 -3
  385. package/packages/investigation/lookup-task-guidance.mjs +58 -0
  386. package/packages/investigation/mail-authentication-review.mjs +222 -0
  387. package/packages/investigation/mail-header-identity.mjs +209 -0
  388. package/packages/investigation/message-intake.mjs +195 -0
  389. package/packages/investigation/page-behaviour.mjs +113 -0
  390. package/packages/investigation/pdf-intake.mjs +155 -0
  391. package/packages/investigation/platform-continuity-review.mjs +60 -0
  392. package/packages/investigation/qr-intake.mjs +56 -0
  393. package/packages/investigation/requested-action-hints.mjs +11 -0
  394. package/packages/investigation/selected-input-review.mjs +17 -0
  395. package/packages/investigation/storefront-review.mjs +70 -0
  396. package/packages/monitoring/analyst-review-state.mjs +38 -90
  397. package/packages/workspace/brand-profile-model.mjs +160 -22
  398. package/packages/workspace/bulk-columns.mjs +17 -0
  399. package/packages/workspace/bulk-review-model.mjs +39 -31
  400. package/packages/workspace/bulk-session-model.mjs +112 -27
  401. package/packages/workspace/bulk-sort.mjs +1 -0
  402. package/packages/workspace/campaign-model.mjs +1 -1
  403. package/packages/workspace/case-views.mjs +102 -0
  404. package/packages/workspace/detection-rule-model.mjs +1 -1
  405. package/packages/workspace/hostile-input.mjs +29 -8
  406. package/packages/workspace/investigation-guide.mjs +2 -2
  407. package/packages/workspace/investigation-template-model.mjs +45 -6
  408. package/packages/workspace/page-baseline.mjs +20 -17
  409. package/packages/workspace/relationship-observation-model.mjs +54 -35
  410. package/packages/workspace/shortlist-model.mjs +14 -5
  411. package/packages/workspace/watchlist-history.mjs +30 -14
  412. package/packages/workspace/watchlist-store.mjs +24 -6
  413. package/packages/workspace/website-snapshot-model.mjs +98 -33
  414. package/packages/workspace/workspace-archive-crypto.mjs +41 -61
  415. package/packages/workspace/workspace-archive.mjs +90 -25
  416. package/third-party-notices.txt +577 -17
  417. package/lib/hostname.mjs +0 -38
  418. package/packages/investigation/bulk-review-export.mjs +0 -63
@@ -0,0 +1,55 @@
1
+ import { boundedJsonLimitsForBytes, parseBoundedJsonObject } from "../analysis/bounded-json.mjs";
2
+ import { canonicalArtifactJsonV2 } from "../evidence/artifact-integrity.mjs";
3
+ import { normalizeExplicitIsoTimestamp } from "../evidence/observation.mjs";
4
+ import { CASE_SCHEMA_VERSION, CLI_CASE_PACK_INPUT_CASE_VERSIONS, MAX_CASE_STORE_BYTES, MAX_EDITABLE_CASE_INPUT_BYTES, PUBLISHED_V2_3_CASE_SCHEMA_VERSION, EVIDENCE_FOLLOW_UP_CASE_SCHEMA_VERSION } from "../contracts/case-portability.mjs";
5
+ import { normalizeCaseStore } from "./case-migration-model.mjs";
6
+ import { serializeCaseStore } from "./case-storage-model.mjs";
7
+ /** Historical wire shape remains independent of current in-memory defaults. */
8
+ export function canonicalCaseExportProjection(cases, caseVersion) {
9
+ return cases.map((record) => {
10
+ const { workflowMetadata: _workflow, ...historical } = record;
11
+ const value = caseVersion < EVIDENCE_FOLLOW_UP_CASE_SCHEMA_VERSION ? historical : record;
12
+ if (caseVersion !== PUBLISHED_V2_3_CASE_SCHEMA_VERSION)
13
+ return value;
14
+ if ([...value.evidencePins, ...value.sightings].some((item) => item.observedAt === null)) {
15
+ throw new TypeError('The published Case 15 format requires a known observation time on each retained pin and sighting.');
16
+ }
17
+ const { title: _title, ...published } = value;
18
+ return {
19
+ ...published,
20
+ sightings: [...value.sightings].sort((left, right) => Date.parse(left.observedAt) - Date.parse(right.observedAt)
21
+ || (left.id < right.id ? -1 : left.id > right.id ? 1 : 0)),
22
+ };
23
+ });
24
+ }
25
+ /** Editing never silently repairs, drops or truncates an imported record. */
26
+ export function readEditableCaseExport(input) {
27
+ const root = parseBoundedJsonObject(input, {
28
+ label: 'Case file', maximumBytes: MAX_EDITABLE_CASE_INPUT_BYTES,
29
+ limits: boundedJsonLimitsForBytes(MAX_CASE_STORE_BYTES),
30
+ });
31
+ if (Object.keys(root).some(key => !['version', 'exportedAt', 'cases'].includes(key))) {
32
+ throw new TypeError('Case editing requires an ordinary Case export, not a response packet or an envelope with additional fields.');
33
+ }
34
+ if (!CLI_CASE_PACK_INPUT_CASE_VERSIONS.some(version => version === root.version) || !Array.isArray(root.cases)) {
35
+ throw new TypeError(`Case editing reads exact schemas ${CLI_CASE_PACK_INPUT_CASE_VERSIONS.join(', ')} and writes schema ${CASE_SCHEMA_VERSION}. Re-export other supported historical formats through the ordinary import workflow first.`);
36
+ }
37
+ if (Object.hasOwn(root, 'exportedAt') && !normalizeExplicitIsoTimestamp(root.exportedAt)) {
38
+ throw new TypeError('The Case export time is invalid.');
39
+ }
40
+ const cases = normalizeCaseStore(root).cases;
41
+ const projected = canonicalCaseExportProjection(cases, root.version);
42
+ const byId = new Map(projected.map(record => [record.id, record]));
43
+ const seen = new Set();
44
+ if (cases.length !== root.cases.length || root.cases.some(value => {
45
+ if (!value || typeof value !== 'object' || typeof value.id !== 'string' || seen.has(value.id))
46
+ return true;
47
+ seen.add(value.id);
48
+ return canonicalArtifactJsonV2(value) !== canonicalArtifactJsonV2(byId.get(value.id));
49
+ }))
50
+ throw new TypeError('The Case file would lose, repair or truncate retained data during normalisation. Nothing was changed.');
51
+ if (new TextEncoder().encode(serializeCaseStore(cases)).byteLength > MAX_CASE_STORE_BYTES) {
52
+ throw new TypeError('The Case file exceeds the canonical store byte budget. Select fewer Cases; no retained evidence was pruned.');
53
+ }
54
+ return cases;
55
+ }
@@ -0,0 +1,52 @@
1
+ // Pure incident context parsing and retention projection; no storage or Case mutation.
2
+ import { canonicalRegistrableDomain } from "../analysis/registrable-domain.mjs";
3
+ import { MAX_CASE_OBJECTIVE_LENGTH } from "../contracts/case-portability.mjs";
4
+ import { parseCredentialFreeHttpUrl } from "../evidence/lookup-target.mjs";
5
+ export const MAX_CASE_INCIDENT_URL_LENGTH = 1_850;
6
+ function boundedContextText(value, maximum) {
7
+ return typeof value === 'string'
8
+ ? value.replace(/[\u0000-\u001f\u007f]/gu, ' ').replace(/\s+/gu, ' ').trim().slice(0, maximum)
9
+ : '';
10
+ }
11
+ export function normalizeCaseObjective(value) {
12
+ return boundedContextText(value, MAX_CASE_OBJECTIVE_LENGTH);
13
+ }
14
+ export function parseIncidentUrlContext(value) {
15
+ const parsed = parseCredentialFreeHttpUrl(value, MAX_CASE_INCIDENT_URL_LENGTH);
16
+ if (!parsed)
17
+ return null;
18
+ const hostname = parsed.hostname.toLowerCase().replace(/\.$/u, '');
19
+ const registrableDomain = canonicalRegistrableDomain(hostname);
20
+ if (!registrableDomain)
21
+ return null;
22
+ const exactUrl = parsed.toString();
23
+ if (exactUrl.length > MAX_CASE_INCIDENT_URL_LENGTH)
24
+ return null;
25
+ return Object.freeze({
26
+ exactUrl,
27
+ hostname,
28
+ registrableDomain,
29
+ originUrl: parsed.origin,
30
+ hasPath: parsed.pathname !== '/',
31
+ hasQuery: Boolean(parsed.search),
32
+ hasFragment: Boolean(parsed.hash),
33
+ });
34
+ }
35
+ export function caseInvestigationContext(record) {
36
+ return record?.workflowMetadata?.investigationContext ?? null;
37
+ }
38
+ export function prepareCaseInvestigationContext(input) {
39
+ const objective = normalizeCaseObjective(input.objective);
40
+ if (!objective)
41
+ throw new Error('Enter the investigation objective before retaining Incident context.');
42
+ const parsed = parseIncidentUrlContext(input.incidentUrl);
43
+ if (!parsed)
44
+ throw new Error(`Enter one absolute HTTP(S) Incident URL of at most ${MAX_CASE_INCIDENT_URL_LENGTH} characters without credentials.`);
45
+ const retention = input.retainExactUrl ? 'exact' : 'origin_only';
46
+ const retainedUrl = input.retainExactUrl ? parsed.exactUrl : parsed.originUrl;
47
+ return Object.freeze({
48
+ objective,
49
+ incidentUrl: retainedUrl,
50
+ urlRetention: retention,
51
+ });
52
+ }
@@ -0,0 +1,11 @@
1
+ /** Keep untrusted Case prose literal, including block syntax and GFM autolinks. */
2
+ export function escapeCaseMarkdownInline(text) {
3
+ return String(text)
4
+ .replace(/[\r\n\u2028\u2029]+/g, ' ')
5
+ .replace(/([\\`*_{}\[\]<>()#+!|~])/g, '\\$1')
6
+ .replace(/^(\s*)([-=]+)(?=\s|$)/u, '$1\\$2')
7
+ .replace(/^(\s*\d+)\.(?=\s)/u, '$1\\.')
8
+ // Break automatic links without altering the underlying evidence value.
9
+ .replace(/\b([a-z][a-z0-9+.-]{1,31}):(?=\/\/)/gi, '$1\\:')
10
+ .replace(/\bwww\.(?=\S)/gi, 'www\\.');
11
+ }
@@ -1,10 +1,22 @@
1
- import { CASE_IMPORT_VERSIONS, CASE_SCHEMA_VERSION, DEFAULT_DISPOSITION, DEFAULT_SOURCE, DEFAULT_STATUS, MAX_CASES, MAX_NOTES_PER_CASE, deterministicId, caseTimestampOrNull, normalizeCase, normalizeDomain, normalizeEvidenceHistory, normalizeNotes, normalizeReviewReasonCode, normalizeTags, objectRecord, safeId, isValidDisposition, isValidSource, isValidStatus, } from "./case-record-model.mjs";
2
- import { CASE_REPORT_SCHEMA, PUBLIC_CASE_SCHEMA_VERSION, caseReportVersionMatchesCase, CLI_CASE_PACK_CURRENT_REDACTION_KEYS, CLI_CASE_PACK_INTEGRITY_KEYS, CLI_CASE_PACK_LIMITATIONS, CLI_CASE_PACK_PACKET_KEYS, CLI_CASE_PACK_PUBLIC_REPORT_KEYS, CLI_CASE_PACK_REPORT_KEYS, CLI_CASE_PACK_ROOT_KEYS, CLI_CASE_PACK_SCHEMA, CLI_CASE_PACK_VERSION, } from "../contracts/case-portability.mjs";
1
+ import { CASE_IMPORT_VERSIONS, CASE_SCHEMA_VERSION, MAX_CASES, MAX_NOTES_PER_CASE, } from "../contracts/case-portability.mjs";
2
+ import { DEFAULT_DISPOSITION, DEFAULT_STATUS, isValidDisposition, isValidStatus } from "./case-record-decisions.mjs";
3
+ import { DEFAULT_SOURCE } from "./case-record-contracts.mjs";
4
+ import { deterministicId, caseTimestampOrNull, normalizeNotes, normalizeReviewReasonCode, normalizeTags, objectRecord, safeId, isValidSource, } from "./case-record-core.mjs";
5
+ import { normalizeCase } from "./case-record-operations.mjs";
6
+ import { normalizeCaseObjective } from "./case-incident-context.mjs";
7
+ import { normalizeDomain } from "../evidence/domain-name.mjs";
8
+ import { mergeImportedEvidenceHistory, normalizeEvidenceHistory } from "./case-evidence-model.mjs";
9
+ import { CASE_REPORT_SCHEMA, PUBLIC_CASE_SCHEMA_VERSION, PUBLISHED_V2_3_CASE_SCHEMA_VERSION, INCIDENT_CASE_SCHEMA_VERSION, caseReportVersionMatchesCase, CLI_CASE_PACK_CURRENT_REDACTION_KEYS, CLI_CASE_PACK_INTEGRITY_KEYS, CLI_CASE_PACK_LIMITATIONS, CLI_CASE_PACK_PACKET_KEYS, CLI_CASE_PACK_PUBLIC_REPORT_KEYS, CLI_CASE_PACK_REPORT_KEYS, CLI_CASE_PACK_ROOT_KEYS, CLI_CASE_PACK_SCHEMA, CLI_CASE_PACK_VERSION, } from "../contracts/case-portability.mjs";
3
10
  import { canonicalArtifactJsonV2 } from "../evidence/artifact-integrity.mjs";
4
- import { assertBoundedJsonStructure } from "../../lib/bounded-json.mjs";
11
+ import { readCaseAttachments, mergeCaseAttachments } from "./case-attachment-model.mjs";
12
+ import { readCaseEvidenceLinks, mergeCaseEvidenceLinks } from "./case-evidence-links.mjs";
13
+ import { EVIDENCE_FOLLOW_UP_CASE_SCHEMA_VERSION } from "../contracts/case-portability.mjs";
14
+ import { assertBoundedJsonStructure } from "../analysis/bounded-json.mjs";
5
15
  import { inspectCaseBrandProfileIds, unionCaseBrandProfileIds, } from "./case-brand-profile-references.mjs";
6
16
  import { caseInvestigationBranchReferences, mergeCaseInvestigationBranches, normalizeCaseInvestigationBranches, } from "./case-investigation-branch-model.mjs";
7
17
  import { buildCaseClosureLinkContext, mergeCaseActions, mergeCaseClosureHistories, mergeCaseObservedEffectHistories, MAX_CASE_ASSERTIONS, MAX_CASE_DECISIONS, MAX_CASE_EVIDENCE_PINS, MAX_CASE_MANUAL_TRAIL_EVENTS, MAX_CASE_SIGHTINGS, normalizeCaseActions, normalizeCaseAssertions, normalizeCaseClosureHistory, normalizeCaseDecisions, normalizeCaseEvidencePins, normalizeCaseManualTrail, normalizeCaseObservedEffectHistory, normalizeCaseSightings, } from "./case-response-model.mjs";
18
+ import { readCaseWorkflowFields } from "./case-workflow-migration.mjs";
19
+ import { emptyCaseWorkflowMetadata, mergeCaseWorkflowMetadata } from "./case-workflow-metadata.mjs";
8
20
  export const MAX_CASE_INPUT_RECORDS = 2_000;
9
21
  function compareCodeUnits(left, right) {
10
22
  return left < right ? -1 : left > right ? 1 : 0;
@@ -32,8 +44,8 @@ function assignUniqueIds(cases) {
32
44
  }
33
45
  /**
34
46
  * Recovers a clean, bounded store from the current versioned envelope or an
35
- * internal bare array, drops malformed records, keeps a single
36
- * case per domain (most recently updated wins), caps to MAX_CASES by recency,
47
+ * internal bare array, drops malformed records, preserves distinct current
48
+ * Case identities, caps to MAX_CASES by recency,
37
49
  * and guarantees globally unique safe ids. Retired, future, accessor-bearing,
38
50
  * sparse, or otherwise non-JSON envelopes fail before normalisation.
39
51
  * @param {unknown} raw
@@ -53,44 +65,53 @@ export function normalizeCaseStore(raw) {
53
65
  if (sourceVersion > CASE_SCHEMA_VERSION) {
54
66
  throw new TypeError(`Case schema ${sourceVersion} is newer than the supported schema ${CASE_SCHEMA_VERSION}; no data was changed.`);
55
67
  }
56
- if (sourceVersion === CASE_SCHEMA_VERSION) {
57
- assertCurrentCaseShape(raw);
68
+ if (sourceVersion >= PUBLISHED_V2_3_CASE_SCHEMA_VERSION) {
69
+ assertModernCaseShape(raw, sourceVersion);
58
70
  }
59
71
  }
60
- const byDomain = new Map();
72
+ const byIdentity = new Map();
61
73
  for (const item of boundedCaseList(raw).items) {
62
74
  const normalized = normalizeCase(item, undefined, fallback, sourceVersion ?? CASE_SCHEMA_VERSION);
63
75
  if (!normalized)
64
76
  continue;
65
- const existing = byDomain.get(normalized.domain);
77
+ // Published domain-centred stores retain their original recovery rule.
78
+ // Current incidents share a domain without sharing analyst decisions.
79
+ const key = sourceVersion !== null && sourceVersion < INCIDENT_CASE_SCHEMA_VERSION
80
+ ? normalized.domain : `${normalized.domain}\u0000${normalized.id}`;
81
+ const existing = byIdentity.get(key);
66
82
  if (!existing || Date.parse(normalized.updatedAt) >= Date.parse(existing.updatedAt)) {
67
- byDomain.set(normalized.domain, normalized);
83
+ byIdentity.set(key, normalized);
68
84
  }
69
85
  }
70
- const cases = [...byDomain.values()]
86
+ const cases = [...byIdentity.values()]
71
87
  .sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt))
72
88
  .slice(0, MAX_CASES);
73
89
  assignUniqueIds(cases);
74
90
  return { version: CASE_SCHEMA_VERSION, cases };
75
91
  }
76
- function assertCurrentCaseShape(raw) {
92
+ function assertModernCaseShape(raw, sourceVersion) {
77
93
  for (const item of boundedCaseList(raw).items) {
78
94
  const itemRecord = objectRecord(item);
95
+ if (sourceVersion < EVIDENCE_FOLLOW_UP_CASE_SCHEMA_VERSION && Object.hasOwn(itemRecord, 'evidenceLinks'))
96
+ throw new TypeError('Evidence relationships require the current Case schema.');
97
+ if (sourceVersion < INCIDENT_CASE_SCHEMA_VERSION && Object.hasOwn(itemRecord, 'attachments')) {
98
+ throw new TypeError('Retained file references require the current Case schema; no data was changed.');
99
+ }
79
100
  const evidenceHistory = itemRecord.evidenceHistory;
80
101
  for (const snapshot of Array.isArray(evidenceHistory) ? evidenceHistory : []) {
81
102
  const record = objectRecord(snapshot);
82
103
  if (Object.keys(record).length > 0 && !Object.hasOwn(record, 'inputHostname')) {
83
- throw new TypeError(`Case schema ${CASE_SCHEMA_VERSION} evidence snapshots must declare their exact submitted hostname, including null; unreleased local Case checkpoints are not interpreted as the v2 format and no data was changed.`);
104
+ throw new TypeError(`Case schema ${sourceVersion} evidence snapshots must declare their exact submitted hostname, including null; no data was changed.`);
84
105
  }
85
106
  }
86
107
  const actions = itemRecord.actions;
87
108
  for (const action of Array.isArray(actions) ? actions : []) {
88
109
  const record = objectRecord(action);
89
110
  if (Object.keys(record).length > 0 && !Object.hasOwn(record, 'routeObservedAt')) {
90
- throw new TypeError(`Case schema ${CASE_SCHEMA_VERSION} response actions must declare their route observation time, including null; unreleased local Case checkpoints are not interpreted as the current format and no data was changed.`);
111
+ throw new TypeError(`Case schema ${sourceVersion} response actions must declare their route observation time, including null; no data was changed.`);
91
112
  }
92
113
  if (Object.keys(record).length > 0 && !Object.hasOwn(record, 'routeReviewAfter')) {
93
- throw new TypeError(`Case schema ${CASE_SCHEMA_VERSION} response actions must declare their route review deadline, including null; unreleased local Case checkpoints are not interpreted as the current format and no data was changed.`);
114
+ throw new TypeError(`Case schema ${sourceVersion} response actions must declare their route review deadline, including null; no data was changed.`);
94
115
  }
95
116
  }
96
117
  const decisions = itemRecord.decisions;
@@ -98,7 +119,7 @@ function assertCurrentCaseShape(raw) {
98
119
  const record = objectRecord(decision);
99
120
  if (Object.keys(record).length > 0
100
121
  && (!Object.hasOwn(record, 'confidence') || !Object.hasOwn(record, 'confidenceBasis'))) {
101
- throw new TypeError(`Case schema ${CASE_SCHEMA_VERSION} analyst decisions must declare confidence and its basis; unreleased local Case checkpoints are not interpreted as the current format and no data was changed.`);
122
+ throw new TypeError(`Case schema ${sourceVersion} analyst decisions must declare confidence and its basis; no data was changed.`);
102
123
  }
103
124
  }
104
125
  }
@@ -160,6 +181,8 @@ function boundedCaseList(raw) {
160
181
  */
161
182
  function extractImportPatch(raw, importedVersion) {
162
183
  const record = objectRecord(raw);
184
+ if (record.evidenceLinks !== undefined && importedVersion < EVIDENCE_FOLLOW_UP_CASE_SCHEMA_VERSION)
185
+ throw new TypeError('Evidence relationships require the current Case schema.');
163
186
  const domain = normalizeDomain(record.domain);
164
187
  if (!domain)
165
188
  return null;
@@ -168,7 +191,7 @@ function extractImportPatch(raw, importedVersion) {
168
191
  || null;
169
192
  const normalizedFallback = importFallback || '1970-01-01T00:00:00.000Z';
170
193
  const timestampOptions = {
171
- legacyTimestamps: importedVersion < CASE_SCHEMA_VERSION,
194
+ legacyTimestamps: importedVersion < PUBLISHED_V2_3_CASE_SCHEMA_VERSION,
172
195
  sourceVersion: importedVersion,
173
196
  };
174
197
  const rawEvidence = Array.isArray(record.evidenceHistory) ? record.evidenceHistory : [];
@@ -196,9 +219,13 @@ function extractImportPatch(raw, importedVersion) {
196
219
  [record.sightings, sightings],
197
220
  ].reduce((total, [candidates, retained]) => total
198
221
  + Math.max(0, (Array.isArray(candidates) ? candidates.length : 0) - retained.length), 0);
222
+ const workflow = readCaseWorkflowFields(record, domain, assertions, importedVersion);
223
+ const hasWorkflow = record.workflowMetadata !== undefined || workflow.workflowMetadata.types.length > 0
224
+ || workflow.workflowMetadata.incidentTargets.length > 0 || workflow.workflowMetadata.investigationContext !== null;
199
225
  return {
200
226
  domain,
201
227
  rawId: typeof record.id === 'string' ? record.id : null,
228
+ title: typeof record.title === 'string' && importedVersion >= INCIDENT_CASE_SCHEMA_VERSION ? normalizeCaseObjective(record.title) : undefined,
202
229
  status: isValidStatus(record.status) ? record.status : undefined,
203
230
  disposition: isValidDisposition(record.disposition) ? record.disposition : undefined,
204
231
  reviewReasonCode: Object.hasOwn(record, 'reviewReasonCode')
@@ -225,7 +252,10 @@ function extractImportPatch(raw, importedVersion) {
225
252
  branches: importedVersion >= 11
226
253
  ? normalizeCaseInvestigationBranches(record.branches, normalizedFallback, branchReferences, timestampOptions)
227
254
  : [],
228
- tags: normalizeTags(record.tags),
255
+ attachments: importedVersion >= INCIDENT_CASE_SCHEMA_VERSION ? readCaseAttachments(record.attachments) : undefined,
256
+ evidenceLinks: readCaseEvidenceLinks(record.evidenceLinks),
257
+ tags: workflow.tags,
258
+ workflowMetadata: hasWorkflow ? workflow.workflowMetadata : undefined,
229
259
  // Imported notes fall back only to the imported record's own timestamps
230
260
  // (never "now"), so a timestamp-less note gets a stable, deterministic time
231
261
  // and id, and re-importing the same file cannot manufacture a duplicate or a
@@ -235,16 +265,6 @@ function extractImportPatch(raw, importedVersion) {
235
265
  updatedAt: caseTimestampOrNull(record.updatedAt, importedVersion),
236
266
  };
237
267
  }
238
- /** @param {CaseNote[]} a @param {CaseNote[]} b @returns {CaseNote[]} */
239
- function unionNotes(a, b) {
240
- const byId = new Map();
241
- for (const note of [...a, ...b]) {
242
- if (!byId.has(note.id))
243
- byId.set(note.id, note);
244
- }
245
- const notes = [...byId.values()].sort((left, right) => Date.parse(left.createdAt) - Date.parse(right.createdAt));
246
- return notes.slice(Math.max(0, notes.length - MAX_NOTES_PER_CASE));
247
- }
248
268
  function retainLocalAuthoredRecords(local, imported, maximum) {
249
269
  const records = [...local];
250
270
  const retainedIds = new Set(records.map((item) => item.id));
@@ -261,23 +281,18 @@ function retainLocalAuthoredRecords(local, imported, maximum) {
261
281
  }
262
282
  return { records, omitted };
263
283
  }
264
- // Additive, deduplicated union of two evidence histories. Identical material
265
- // collapses (earliest firstCapturedAt, latest capturedAt), distinct snapshots
266
- // are retained subject to the per-case bound, and an older import can never
267
- // move an existing observation backwards.
268
- function mergeEvidenceHistories(local, imported, caseDomain) {
269
- return normalizeEvidenceHistory([...local, ...imported], { source: 'import', fallback: null, caseDomain });
270
- }
271
284
  /** @param {ImportPatch} patch @param {string} now @returns {CaseRecord} */
272
285
  function caseFromPatch(patch, now) {
273
286
  return {
274
287
  id: '', // assigned by mergeCases so it can guarantee uniqueness against locals
275
288
  domain: patch.domain,
289
+ title: patch.title ?? '',
276
290
  status: patch.status ?? DEFAULT_STATUS,
277
291
  disposition: patch.disposition ?? DEFAULT_DISPOSITION,
278
292
  reviewReasonCode: patch.reviewReasonCode ?? null,
279
293
  brandProfileIds: patch.brandProfileIds,
280
294
  tags: patch.tags,
295
+ workflowMetadata: patch.workflowMetadata ?? emptyCaseWorkflowMetadata(),
281
296
  notes: patch.notes,
282
297
  source: patch.source ?? DEFAULT_SOURCE,
283
298
  evidenceHistory: patch.evidenceHistory,
@@ -290,6 +305,8 @@ function caseFromPatch(patch, now) {
290
305
  observedEffects: patch.observedEffects,
291
306
  closures: patch.closures,
292
307
  branches: patch.branches,
308
+ ...(patch.attachments === undefined ? {} : { attachments: patch.attachments }),
309
+ ...(patch.evidenceLinks === undefined ? {} : { evidenceLinks: patch.evidenceLinks }),
293
310
  createdAt: patch.createdAt || patch.updatedAt || now,
294
311
  updatedAt: patch.updatedAt || patch.createdAt || now,
295
312
  };
@@ -306,6 +323,8 @@ function caseFromPatch(patch, now) {
306
323
  function applyImportPatch(local, patch) {
307
324
  const importNewer = patch.updatedAt !== null && Date.parse(patch.updatedAt) > Date.parse(local.updatedAt);
308
325
  const fallback = patch.updatedAt || local.updatedAt;
326
+ const noteSelection = retainLocalAuthoredRecords(local.notes, patch.notes, MAX_NOTES_PER_CASE);
327
+ const historySelection = mergeImportedEvidenceHistory(local.evidenceHistory, patch.evidenceHistory, local.domain);
309
328
  const pinSelection = retainLocalAuthoredRecords(local.evidencePins, patch.evidencePins, MAX_CASE_EVIDENCE_PINS);
310
329
  const evidencePins = normalizeCaseEvidencePins(pinSelection.records, fallback);
311
330
  const pinIds = new Set(evidencePins.map((item) => item.id));
@@ -323,7 +342,10 @@ function applyImportPatch(local, patch) {
323
342
  const decisions = normalizeCaseDecisions(decisionSelection.records, fallback, pinIds);
324
343
  const trailSelection = retainLocalAuthoredRecords(local.manualTrail, patch.manualTrail, MAX_CASE_MANUAL_TRAIL_EVENTS);
325
344
  const manualTrail = normalizeCaseManualTrail(trailSelection.records, fallback);
345
+ const attachments = mergeCaseAttachments(local.attachments, patch.attachments);
346
+ const evidenceLinks = mergeCaseEvidenceLinks(local.evidenceLinks, patch.evidenceLinks);
326
347
  const authoredHistoryOmitted = patch.authoredHistoryOmitted
348
+ + noteSelection.omitted
327
349
  + pinSelection.omitted
328
350
  + decisionSelection.omitted
329
351
  + assertionSelection.omitted
@@ -331,12 +353,13 @@ function applyImportPatch(local, patch) {
331
353
  + sightingSelection.omitted;
332
354
  return { record: {
333
355
  ...local,
356
+ title: patch.title !== undefined && importNewer ? patch.title : local.title ?? '',
334
357
  status: patch.status !== undefined && importNewer ? patch.status : local.status,
335
358
  disposition: patch.disposition !== undefined && importNewer ? patch.disposition : local.disposition,
336
359
  reviewReasonCode: patch.reviewReasonCode !== undefined && importNewer ? patch.reviewReasonCode : local.reviewReasonCode ?? null,
337
360
  brandProfileIds: brandProfileReferences.ids,
338
361
  source: patch.source !== undefined && importNewer ? patch.source : local.source,
339
- evidenceHistory: mergeEvidenceHistories(local.evidenceHistory, patch.evidenceHistory, local.domain),
362
+ evidenceHistory: historySelection.records,
340
363
  evidencePins,
341
364
  decisions,
342
365
  actions,
@@ -346,11 +369,16 @@ function applyImportPatch(local, patch) {
346
369
  observedEffects,
347
370
  closures,
348
371
  branches: mergeCaseInvestigationBranches(local.branches ?? [], patch.branches, fallback, branchReferences),
372
+ ...(attachments === undefined ? {} : { attachments }),
373
+ ...(evidenceLinks === undefined ? {} : { evidenceLinks }),
349
374
  tags: normalizeTags([...local.tags, ...patch.tags]),
350
- notes: unionNotes(local.notes, patch.notes),
375
+ workflowMetadata: patch.workflowMetadata === undefined
376
+ ? local.workflowMetadata ?? emptyCaseWorkflowMetadata()
377
+ : mergeCaseWorkflowMetadata(local.workflowMetadata ?? emptyCaseWorkflowMetadata(), patch.workflowMetadata, importNewer, local.domain),
378
+ notes: normalizeNotes(noteSelection.records, fallback),
351
379
  createdAt: patch.createdAt && Date.parse(patch.createdAt) < Date.parse(local.createdAt) ? patch.createdAt : local.createdAt,
352
380
  updatedAt: importNewer ? (patch.updatedAt ?? local.updatedAt) : local.updatedAt,
353
- }, brandProfileReferencesOmitted: brandProfileReferences.omitted, authoredHistoryOmitted };
381
+ }, brandProfileReferencesOmitted: brandProfileReferences.omitted, authoredHistoryOmitted, evidenceHistoryOmitted: historySelection.omitted };
354
382
  }
355
383
  function pickFreeId(preferred, domain, used) {
356
384
  const wanted = safeId(preferred);
@@ -456,14 +484,14 @@ function caseCollectionImportEnvelope(importedRaw) {
456
484
  }
457
485
  /**
458
486
  * Merges an imported (already parsed) value into the local cases. Predictable
459
- * and idempotent: unknown records are skipped, existing domains merge without
487
+ * and idempotent: unknown records are skipped, matching Case identities merge without
460
488
  * losing newer local decisions, and new ones are added until the store bound is
461
489
  * reached. An imported envelope that declares a schema version newer than we
462
490
  * support is rejected up front (before any local data is touched) rather than
463
491
  * reinterpreted.
464
492
  * @param {CaseRecord[]} localCases
465
493
  * @param {unknown} importedRaw
466
- * @returns {{ cases: CaseRecord[], added: number, updated: number, skipped: number, brandProfileReferencesOmitted: number, authoredHistoryOmitted: number }}
494
+ * @returns {{ cases: CaseRecord[], added: number, updated: number, skipped: number, brandProfileReferencesOmitted: number, authoredHistoryOmitted: number, evidenceHistoryOmitted: number }}
467
495
  */
468
496
  export function mergeCases(localCases, importedRaw) {
469
497
  assertBoundedJsonStructure(importedRaw, 'Case import', CASE_INPUT_JSON_LIMITS);
@@ -480,11 +508,11 @@ export function mergeCases(localCases, importedRaw) {
480
508
  || !Array.isArray(importedEnvelope.cases)) {
481
509
  throw new Error(`Expected a well-formed WHOISleuth Case export using schema ${CASE_SCHEMA_VERSION}; no data was changed.`);
482
510
  }
483
- if (importedVersion === CASE_SCHEMA_VERSION)
484
- assertCurrentCaseShape(importedEnvelope);
511
+ if (importedVersion !== null && importedVersion >= PUBLISHED_V2_3_CASE_SCHEMA_VERSION)
512
+ assertModernCaseShape(importedEnvelope, importedVersion);
485
513
  const local = normalizeCaseStore(localCases).cases;
486
514
  const supportedImportedVersion = importedVersion ?? 0;
487
- const byDomain = new Map(local.map((item) => [item.domain, item]));
515
+ const byId = new Map(local.map((item) => [item.id, item]));
488
516
  const usedIds = new Set(local.map((item) => item.id));
489
517
  let added = 0;
490
518
  let updated = 0;
@@ -492,26 +520,47 @@ export function mergeCases(localCases, importedRaw) {
492
520
  let skipped = imported.omitted;
493
521
  let brandProfileReferencesOmitted = 0;
494
522
  let authoredHistoryOmitted = 0;
523
+ let evidenceHistoryOmitted = 0;
495
524
  const fallback = new Date(0).toISOString();
525
+ const importedIds = new Set();
496
526
  for (const item of imported.items) {
497
527
  const patch = extractImportPatch(item, supportedImportedVersion);
498
528
  if (!patch) {
499
529
  skipped += 1;
500
530
  continue;
501
531
  }
502
- const existing = byDomain.get(patch.domain);
532
+ const importedId = safeId(patch.rawId);
533
+ if (supportedImportedVersion >= INCIDENT_CASE_SCHEMA_VERSION && importedId) {
534
+ if (importedIds.has(importedId))
535
+ throw new Error('Incident Case imports cannot repeat a declared Case ID. No data was changed.');
536
+ importedIds.add(importedId);
537
+ }
538
+ let existing = importedId ? byId.get(importedId) : undefined;
539
+ if (existing && existing.domain !== patch.domain) {
540
+ if (supportedImportedVersion >= INCIDENT_CASE_SCHEMA_VERSION)
541
+ throw new Error('An imported Case ID belongs to a different domain in this workspace. No data was changed.');
542
+ existing = undefined;
543
+ }
544
+ if (!existing && (supportedImportedVersion < INCIDENT_CASE_SCHEMA_VERSION || !importedId)) {
545
+ const matchingDomain = [...byId.values()].filter(record => record.domain === patch.domain);
546
+ if (matchingDomain.length > 1) {
547
+ throw new Error(`The imported record for ${patch.domain} has no matching Case identity and several incidents exist. Import it into a separate workspace to review it without merging incidents; no data was changed.`);
548
+ }
549
+ existing = matchingDomain[0];
550
+ }
503
551
  if (existing) {
504
552
  const merged = applyImportPatch(existing, patch);
505
- byDomain.set(patch.domain, merged.record);
553
+ byId.set(existing.id, merged.record);
506
554
  brandProfileReferencesOmitted += patch.brandProfileReferencesOmitted + merged.brandProfileReferencesOmitted;
507
555
  authoredHistoryOmitted += merged.authoredHistoryOmitted;
556
+ evidenceHistoryOmitted += merged.evidenceHistoryOmitted;
508
557
  updated += 1;
509
558
  }
510
- else if (byDomain.size < MAX_CASES) {
559
+ else if (byId.size < MAX_CASES) {
511
560
  const record = caseFromPatch(patch, fallback);
512
561
  record.id = pickFreeId(patch.rawId, patch.domain, usedIds);
513
562
  usedIds.add(record.id);
514
- byDomain.set(patch.domain, record);
563
+ byId.set(record.id, record);
515
564
  brandProfileReferencesOmitted += patch.brandProfileReferencesOmitted;
516
565
  authoredHistoryOmitted += patch.authoredHistoryOmitted;
517
566
  added += 1;
@@ -521,11 +570,12 @@ export function mergeCases(localCases, importedRaw) {
521
570
  }
522
571
  }
523
572
  return {
524
- cases: normalizeCaseStore([...byDomain.values()]).cases,
573
+ cases: normalizeCaseStore([...byId.values()]).cases,
525
574
  added,
526
575
  updated,
527
576
  skipped,
528
577
  brandProfileReferencesOmitted,
529
578
  authoredHistoryOmitted,
579
+ evidenceHistoryOmitted,
530
580
  };
531
581
  }
@@ -3,3 +3,4 @@
3
3
  export * from "./case-record-model.mjs";
4
4
  export * from "./case-migration-model.mjs";
5
5
  export * from "./case-storage-model.mjs";
6
+ export * from "./case-selection.mjs";
@@ -0,0 +1,149 @@
1
+ import { isValidAsciiHostname } from "../contracts/domain-name.mjs";
2
+ import { INCIDENT_CASE_SCHEMA_VERSION, MAX_RESPONSE_RATIONALE_LENGTH, } from "../contracts/case-portability.mjs";
3
+ import { enumeration, exact, text } from "../evidence/artifact-structure.mjs";
4
+ import { casePinHasCompleteObservation } from "./case-evidence-quality.mjs";
5
+ export const CASE_RECHECK_CONDITIONS = Object.freeze({
6
+ unknown: 'Not established',
7
+ comparable: 'Comparable for this question',
8
+ different: 'Different conditions',
9
+ });
10
+ function reference(value, label) {
11
+ const id = text(value, label, 64);
12
+ if (!/^[A-Za-z0-9_-]+$/u.test(id))
13
+ throw new TypeError(`${label} is invalid.`);
14
+ return id;
15
+ }
16
+ function contextFields(item) {
17
+ const targetHostname = text(item.targetHostname, 'Recheck target hostname', 253);
18
+ if (targetHostname !== targetHostname.toLowerCase() || !isValidAsciiHostname(targetHostname)) {
19
+ throw new TypeError('Use one normalised hostname for the recheck target, without a path or query.');
20
+ }
21
+ const conditions = text(item.conditions, 'Recheck comparison conditions', MAX_RESPONSE_RATIONALE_LENGTH);
22
+ if (!conditions.trim())
23
+ throw new TypeError('Describe the conditions needed to compare the observations.');
24
+ return Object.freeze({
25
+ targetHostname,
26
+ conditions,
27
+ baselinePinId: item.baselinePinId === null ? null : reference(item.baselinePinId, 'Recheck baseline pin'),
28
+ });
29
+ }
30
+ /** Optional current-format context; historical records have no invented question. */
31
+ export function readCaseRecheckContext(value, sourceVersion) {
32
+ if (value === undefined)
33
+ return undefined;
34
+ if (sourceVersion != null && sourceVersion < INCIDENT_CASE_SCHEMA_VERSION)
35
+ throw new TypeError('This Case format cannot contain structured recheck questions.');
36
+ return contextFields(exact(value, ['targetHostname', 'baselinePinId', 'conditions'], 'Recheck question context'));
37
+ }
38
+ export function readCaseRecheckAnswerContext(value, sourceVersion) {
39
+ if (value === undefined)
40
+ return undefined;
41
+ if (sourceVersion != null && sourceVersion < INCIDENT_CASE_SCHEMA_VERSION)
42
+ throw new TypeError('This Case format cannot contain structured recheck answers.');
43
+ const item = exact(value, ['targetHostname', 'baselinePinId', 'conditions', 'questionId', 'question', 'conditionsMatch'], 'Recheck answer context');
44
+ const question = text(item.question, 'Recheck question', MAX_RESPONSE_RATIONALE_LENGTH);
45
+ if (!question.trim())
46
+ throw new TypeError('A recheck answer requires its original question.');
47
+ return Object.freeze({
48
+ ...contextFields(item),
49
+ questionId: reference(item.questionId, 'Recheck question ID'),
50
+ question,
51
+ conditionsMatch: enumeration(item.conditionsMatch, Object.keys(CASE_RECHECK_CONDITIONS), 'Recheck condition comparison'),
52
+ });
53
+ }
54
+ export function caseRecheckQuestions(assertions) {
55
+ return assertions.filter((item) => item.kind === 'next_step' && item.recheck && item.state === 'open');
56
+ }
57
+ export function caseRecheckAnswerContext(question, conditionsMatch) {
58
+ if (question.kind !== 'next_step' || !question.recheck)
59
+ throw new TypeError('Select a saved recheck question.');
60
+ return readCaseRecheckAnswerContext({
61
+ ...question.recheck,
62
+ questionId: question.id,
63
+ question: question.statement,
64
+ conditionsMatch,
65
+ });
66
+ }
67
+ /** Shared read projection; each form retains its draft and existing save coordinator. */
68
+ export function selectCaseRecheckQuestion(assertions, selection) {
69
+ const questions = caseRecheckQuestions(assertions);
70
+ const question = questions.find((item) => item.id === selection.questionId);
71
+ return {
72
+ questions,
73
+ context: question ? caseRecheckAnswerContext(question, selection.conditionsMatch) : undefined,
74
+ stale: Boolean(selection.questionId) && (!question || question.updatedAt !== selection.questionUpdatedAt),
75
+ };
76
+ }
77
+ /** A saved answer is a snapshot, not a live reference to an editable plan. */
78
+ export function assertCurrentRecheckQuestion(answer, assertions) {
79
+ const question = caseRecheckQuestions(assertions).find((item) => item.id === answer.questionId);
80
+ if (!question ||
81
+ JSON.stringify(caseRecheckAnswerContext(question, answer.conditionsMatch)) !==
82
+ JSON.stringify(answer)) {
83
+ throw new Error('The recheck question changed or was resolved. Refresh and review its current conditions before saving this answer.');
84
+ }
85
+ }
86
+ const RECHECK_COMPARISON_MESSAGES = {
87
+ different_conditions: 'The comparison conditions differ.',
88
+ unconfirmed_conditions: 'Comparable conditions have not been confirmed.',
89
+ missing_baseline: 'The baseline evidence is no longer available.',
90
+ current_target_mismatch: 'The current evidence concerns a different or unknown hostname.',
91
+ incomplete_current: 'The current source does not establish a complete observation.',
92
+ later_observation_needed: 'A later source observation is needed; reviewing the baseline again is not a recheck.',
93
+ baseline_target_mismatch: 'The baseline concerns a different hostname.',
94
+ different_field_or_source: 'The observations use different fields or sources.',
95
+ };
96
+ /** Admissibility depends on these reasons, not on presentation warnings. */
97
+ export function caseRecheckComparisonBlockers(context, pins, current) {
98
+ const blockers = [];
99
+ if (context.conditionsMatch !== 'comparable') {
100
+ blockers.push(context.conditionsMatch === 'different'
101
+ ? 'different_conditions'
102
+ : 'unconfirmed_conditions');
103
+ }
104
+ const baseline = pins.find((pin) => pin.id === context.baselinePinId);
105
+ if (context.baselinePinId && !baseline)
106
+ blockers.push('missing_baseline');
107
+ if (current) {
108
+ if (current.observationHostname !== context.targetHostname)
109
+ blockers.push('current_target_mismatch');
110
+ if (!casePinHasCompleteObservation(current))
111
+ blockers.push('incomplete_current');
112
+ if (baseline) {
113
+ const sameObservation = baseline.id === current.id;
114
+ const unknownObservationTime = !baseline.observedAt || !current.observedAt;
115
+ const nonLaterObservation = !unknownObservationTime &&
116
+ Date.parse(current.observedAt) <= Date.parse(baseline.observedAt);
117
+ if (sameObservation || unknownObservationTime || nonLaterObservation) {
118
+ blockers.push('later_observation_needed');
119
+ }
120
+ if (baseline.observationHostname && baseline.observationHostname !== context.targetHostname)
121
+ blockers.push('baseline_target_mismatch');
122
+ const differentField = Boolean(baseline.field) && current.field !== baseline.field;
123
+ const differentSource = baseline.source !== current.source;
124
+ if (differentField || differentSource)
125
+ blockers.push('different_field_or_source');
126
+ }
127
+ }
128
+ return blockers;
129
+ }
130
+ export function caseRecheckComparisonWarnings(context, pins, current) {
131
+ return caseRecheckComparisonBlockers(context, pins, current)
132
+ .map((reason) => RECHECK_COMPARISON_MESSAGES[reason]);
133
+ }
134
+ export function assertRecheckNonReproduction(state, context, completeness, pins, current, observedAt) {
135
+ if (state !== 'not_reproduced')
136
+ return;
137
+ const blockers = caseRecheckComparisonBlockers(context, pins, current);
138
+ const baseline = pins.find((pin) => pin.id === context.baselinePinId);
139
+ let laterIndependentObservationNeeded = false;
140
+ if (!current && baseline) {
141
+ const missingObservationTime = !baseline.observedAt || !observedAt;
142
+ const invalidObservationTime = observedAt ? !Number.isFinite(Date.parse(observedAt)) : false;
143
+ const nonLaterObservation = !missingObservationTime && Date.parse(observedAt) <= Date.parse(baseline.observedAt);
144
+ laterIndependentObservationNeeded = missingObservationTime || invalidObservationTime || nonLaterObservation;
145
+ }
146
+ if (completeness !== 'complete' || blockers.length || laterIndependentObservationNeeded) {
147
+ throw new Error('Not reproduced requires a complete observation under comparable conditions. Record unavailable or describe the limited observation instead.');
148
+ }
149
+ }
@@ -1,4 +1,4 @@
1
- import { ANALYST_REVIEW_REASONS } from "../../lib/analyst-taxonomy.mjs";
1
+ import { ANALYST_REVIEW_REASONS } from "../analysis/analyst-taxonomy.mjs";
2
2
  import { CASE_IMPORT_VERSIONS, CASE_SCHEMA_VERSION, PUBLIC_CASE_SCHEMA_VERSION, MAX_CASE_BRAND_PROFILE_ID_CANDIDATES, MAX_CASE_BRAND_PROFILE_IDS, MAX_CASE_IMPORT_BYTES, MAX_CASE_STORE_BYTES, MAX_CASES, MAX_DOMAIN_LENGTH, MAX_EVIDENCE_CHANGES, MAX_EVIDENCE_DETAIL_LENGTH, MAX_EVIDENCE_FACTORS, MAX_EVIDENCE_MUTATIONS, MAX_EVIDENCE_NAMESERVERS, MAX_EVIDENCE_SNAPSHOTS_PER_CASE, MAX_EVIDENCE_STRING_LENGTH, MAX_EVIDENCE_TITLE_LENGTH, MAX_NOTE_LENGTH, MAX_NOTES_PER_CASE, MAX_TAG_LENGTH, MAX_TAGS_PER_CASE, } from "../contracts/case-portability.mjs";
3
3
  import { CASE_DISPOSITIONS, CASE_STATUSES, DEFAULT_DISPOSITION, DEFAULT_STATUS, } from "./case-record-decisions.mjs";
4
4
  export { CASE_IMPORT_VERSIONS, CASE_SCHEMA_VERSION, PUBLIC_CASE_SCHEMA_VERSION, MAX_CASE_BRAND_PROFILE_ID_CANDIDATES, MAX_CASE_BRAND_PROFILE_IDS, MAX_CASE_IMPORT_BYTES, MAX_CASE_STORE_BYTES, MAX_CASES, MAX_DOMAIN_LENGTH, MAX_EVIDENCE_CHANGES, MAX_EVIDENCE_DETAIL_LENGTH, MAX_EVIDENCE_FACTORS, MAX_EVIDENCE_MUTATIONS, MAX_EVIDENCE_NAMESERVERS, MAX_EVIDENCE_SNAPSHOTS_PER_CASE, MAX_EVIDENCE_STRING_LENGTH, MAX_EVIDENCE_TITLE_LENGTH, MAX_NOTE_LENGTH, MAX_NOTES_PER_CASE, MAX_TAG_LENGTH, MAX_TAGS_PER_CASE, };