@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
@@ -1,9 +1,10 @@
1
1
  // Pure, framework-neutral analyst-case records, evidence histories, bounded
2
2
  // record normalization, and analyst updates.
3
- import { CASE_SCHEMA_VERSION, CASE_REVIEW_REASONS, CASE_SOURCES, DEFAULT_DISPOSITION, DEFAULT_SOURCE, DEFAULT_STATUS, EVIDENCE_SOURCES, MAX_DOMAIN_LENGTH, MAX_NOTES_PER_CASE, MAX_NOTE_LENGTH, MAX_TAGS_PER_CASE, MAX_TAG_LENGTH, } from "./case-record-contracts.mjs";
3
+ import { CASE_REVIEW_REASONS, CASE_SOURCES, DEFAULT_DISPOSITION, DEFAULT_SOURCE, DEFAULT_STATUS, EVIDENCE_SOURCES, MAX_DOMAIN_LENGTH, MAX_NOTES_PER_CASE, MAX_NOTE_LENGTH, MAX_TAGS_PER_CASE, MAX_TAG_LENGTH, } from "./case-record-contracts.mjs";
4
4
  import { dispositionLabel, isValidDisposition, isValidStatus, statusLabel, } from "./case-record-decisions.mjs";
5
5
  import { normalizeExplicitIsoTimestamp, normalizeLegacyIsoTimestamp } from "../evidence/observation.mjs";
6
- import { canonicalRegistrableDomain } from "../../lib/registrable-domain.mjs";
6
+ import { PUBLISHED_V2_3_CASE_SCHEMA_VERSION } from "../contracts/case-portability.mjs";
7
+ import { canonicalRegistrableDomain } from "../analysis/registrable-domain.mjs";
7
8
  // Forward-version policy (two distinct guarantees):
8
9
  // - A locally-stored envelope that declares a version greater than this is
9
10
  // still read best-effort on load (known fields kept, unknown dropped), but
@@ -163,7 +164,7 @@ export function caseTimestampOrNull(value, sourceVersion) {
163
164
  return explicit;
164
165
  return sourceVersion !== undefined
165
166
  && sourceVersion !== null
166
- && sourceVersion < CASE_SCHEMA_VERSION
167
+ && sourceVersion < PUBLISHED_V2_3_CASE_SCHEMA_VERSION
167
168
  ? normalizeLegacyIsoTimestamp(value)
168
169
  : null;
169
170
  }
@@ -1,103 +1,24 @@
1
1
  // Pure, framework-neutral analyst-case records, evidence histories, bounded
2
2
  // record normalization, and analyst updates.
3
- import { canonicalRegistrableDomain } from "../../lib/registrable-domain.mjs";
3
+ import { normalizeCaseObjective, parseIncidentUrlContext, } from "./case-incident-context.mjs";
4
+ import { selectExistingCase } from "./case-selection.mjs";
5
+ import { emptyCaseWorkflowMetadata, updateCaseWorkflowMetadata } from "./case-workflow-metadata.mjs";
6
+ import { readCaseWorkflowFields } from "./case-workflow-migration.mjs";
4
7
  import { appendCaseAction, appendCaseAssertion, appendCaseClosure, appendCaseDecision, appendCaseEvidencePin, appendCaseEvidencePins, appendCaseManualTrailEvent, appendCaseObservedEffectReview, appendCaseSighting, buildCaseClosureLinkContext, normalizeCaseActions, normalizeCaseAssertions, normalizeCaseClosureHistory, normalizeCaseDecisions, normalizeCaseEvidencePins, normalizeCaseManualTrail, normalizeCaseObservedEffectHistory, normalizeCaseSightings, updateCaseAction, updateCaseAssertion, } from "./case-response-model.mjs";
5
- import { CASE_SCHEMA_VERSION, MAX_CASES, MAX_NOTES_PER_CASE, } from "./case-record-contracts.mjs";
8
+ import { MAX_CASES, MAX_NOTES_PER_CASE, } from "./case-record-contracts.mjs";
9
+ import { MAX_CASE_OBJECTIVE_LENGTH, MAX_RESPONSE_VALUE_LENGTH } from "../contracts/case-portability.mjs";
10
+ import { caseRecordVersionInput } from "./case-record-version-input.mjs";
11
+ import { readCaseAttachments } from "./case-attachment-model.mjs";
12
+ import { readCaseEvidenceLinks, appendCaseEvidenceLink, withdrawCaseEvidenceLink } from "./case-evidence-links.mjs";
13
+ import { assertCurrentRecheckQuestion, assertRecheckNonReproduction, readCaseRecheckAnswerContext } from "./case-recheck-model.mjs";
6
14
  import { caseDispositionSupportsDefensiveResponse, caseStatusIsClosed, caseStatusOptionsForDirectEdit, caseStatusRequiresClosure, isReviewedCaseDisposition, } from "./case-record-decisions.mjs";
7
15
  import { DEFAULT_EVIDENCE_SOURCE, EVIDENCE_SOURCE_SET, caseTimestampOrNull, deterministicId, makeId, normalizeDisposition, normalizeDomain, normalizeNoteBody, normalizeReviewReasonCode, normalizeNotes, normalizeSource, normalizeStatus, normalizeTags, objectRecord, safeId, } from "./case-record-core.mjs";
8
16
  import { assertCaseBrandProfileIds, normalizeCaseBrandProfileIds, } from "./case-brand-profile-references.mjs";
9
17
  import { normalizeEvidenceHistory, } from "./case-evidence-model.mjs";
10
18
  import { appendCaseInvestigationBranch, caseInvestigationBranchReferences, normalizeCaseInvestigationBranches, updateCaseInvestigationBranch, } from "./case-investigation-branch-model.mjs";
11
19
  export { caseDispositionSupportsDefensiveResponse, caseStatusIsClosed, caseStatusOptionsForDirectEdit, caseStatusRequiresClosure, isReviewedCaseDisposition, };
12
- export const MAX_CASE_OBJECTIVE_LENGTH = 320;
13
- export const MAX_CASE_INCIDENT_URL_LENGTH = 1_850;
14
- export const INCIDENT_CONTEXT_STATEMENT_PREFIX = 'Investigate incident URL: ';
15
- const OBJECTIVE_PREFIX = 'Objective: ';
16
- const RETENTION_SEPARATOR = ' | URL retained: ';
17
- function boundedContextText(value, maximum) {
18
- return typeof value === 'string'
19
- ? value.replace(/[\u0000-\u001f\u007f]/gu, ' ').replace(/\s+/gu, ' ').trim().slice(0, maximum)
20
- : '';
21
- }
22
- export function normalizeCaseObjective(value) {
23
- return boundedContextText(value, MAX_CASE_OBJECTIVE_LENGTH);
24
- }
25
- export function parseIncidentUrlContext(value) {
26
- if (typeof value !== 'string' || !value || value.length > MAX_CASE_INCIDENT_URL_LENGTH)
27
- return null;
28
- if (/[\u0000-\u001f\u007f]/u.test(value) || value.trim() !== value)
29
- return null;
30
- let parsed;
31
- try {
32
- parsed = new URL(value);
33
- }
34
- catch {
35
- return null;
36
- }
37
- if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password || !parsed.hostname)
38
- return null;
39
- const hostname = parsed.hostname.toLowerCase().replace(/\.$/u, '');
40
- const registrableDomain = canonicalRegistrableDomain(hostname);
41
- if (!registrableDomain)
42
- return null;
43
- const exactUrl = parsed.toString();
44
- if (exactUrl.length > MAX_CASE_INCIDENT_URL_LENGTH)
45
- return null;
46
- return Object.freeze({
47
- exactUrl,
48
- hostname,
49
- registrableDomain,
50
- originUrl: parsed.origin,
51
- hasPath: parsed.pathname !== '/',
52
- hasQuery: Boolean(parsed.search),
53
- hasFragment: Boolean(parsed.hash),
54
- });
55
- }
56
- export function caseInvestigationContext(record) {
57
- if (!record)
58
- return null;
59
- for (const assertion of [...record.assertions].reverse()) {
60
- if (assertion.kind !== 'next_step' || assertion.state !== 'open'
61
- || !assertion.statement.startsWith(INCIDENT_CONTEXT_STATEMENT_PREFIX))
62
- continue;
63
- const incidentUrl = assertion.statement.slice(INCIDENT_CONTEXT_STATEMENT_PREFIX.length);
64
- const parsed = parseIncidentUrlContext(incidentUrl);
65
- if (!parsed || parsed.registrableDomain !== record.domain)
66
- continue;
67
- const rationale = assertion.rationale ?? '';
68
- const separatorIndex = rationale.lastIndexOf(RETENTION_SEPARATOR);
69
- const objective = separatorIndex > OBJECTIVE_PREFIX.length && rationale.startsWith(OBJECTIVE_PREFIX)
70
- ? normalizeCaseObjective(rationale.slice(OBJECTIVE_PREFIX.length, separatorIndex))
71
- : '';
72
- const retention = separatorIndex >= 0 ? rationale.slice(separatorIndex + RETENTION_SEPARATOR.length) : '';
73
- if (!objective || (retention !== 'exact' && retention !== 'origin_only'))
74
- continue;
75
- return Object.freeze({
76
- objective,
77
- incidentUrl: parsed.exactUrl,
78
- urlRetention: retention,
79
- assertionId: assertion.id,
80
- updatedAt: assertion.updatedAt,
81
- });
82
- }
83
- return null;
84
- }
85
- export function caseInvestigationContextAssertion(input) {
86
- const objective = normalizeCaseObjective(input.objective);
87
- if (!objective)
88
- throw new Error('Enter the investigation objective before retaining Incident context.');
89
- const parsed = parseIncidentUrlContext(input.incidentUrl);
90
- if (!parsed)
91
- throw new Error(`Enter one absolute HTTP(S) Incident URL of at most ${MAX_CASE_INCIDENT_URL_LENGTH} characters without credentials.`);
92
- const retention = input.retainExactUrl ? 'exact' : 'origin_only';
93
- const retainedUrl = input.retainExactUrl ? parsed.exactUrl : parsed.originUrl;
94
- return Object.freeze({
95
- statement: `${INCIDENT_CONTEXT_STATEMENT_PREFIX}${retainedUrl}`,
96
- rationale: `${OBJECTIVE_PREFIX}${objective}${RETENTION_SEPARATOR}${retention}`,
97
- retainedUrl,
98
- retention,
99
- });
100
- }
20
+ export { MAX_CASE_OBJECTIVE_LENGTH };
21
+ export * from "./case-incident-context.mjs";
101
22
  // ---------------------------------------------------------------------------
102
23
  // Case normalization
103
24
  // ---------------------------------------------------------------------------
@@ -135,29 +56,29 @@ function normalizeCaseEvidence(record, domain, createdAt, updatedAt, now, source
135
56
  */
136
57
  export function normalizeCase(raw, existing, nowIso, sourceVersion) {
137
58
  const now = caseTimestampOrNull(nowIso) || new Date().toISOString();
138
- const record = objectRecord(raw);
139
- const domain = normalizeDomain(existing ? existing.domain : record.domain);
59
+ const input = objectRecord(raw);
60
+ const domain = normalizeDomain(existing ? existing.domain : input.domain);
140
61
  if (!domain)
141
62
  return null;
63
+ const { record, timestampOptions } = caseRecordVersionInput(input, sourceVersion);
142
64
  const createdAt = existing ? existing.createdAt : caseTimestampOrNull(record.createdAt, sourceVersion) || now;
143
65
  const updatedAt = caseTimestampOrNull(record.updatedAt, sourceVersion) || createdAt;
144
- const timestampOptions = {
145
- legacyTimestamps: sourceVersion != null && sourceVersion < CASE_SCHEMA_VERSION,
146
- ...(sourceVersion === undefined ? {} : { sourceVersion }),
147
- };
148
66
  const evidencePins = normalizeCaseEvidencePins(record.evidencePins, updatedAt, timestampOptions);
149
67
  const pinIds = new Set(evidencePins.map((item) => item.id));
150
68
  const actions = normalizeCaseActions(record.actions, updatedAt, { ...timestampOptions, validEvidencePinIds: pinIds });
151
69
  const assertions = normalizeCaseAssertions(record.assertions, updatedAt, pinIds, timestampOptions);
152
70
  const sightings = normalizeCaseSightings(record.sightings, updatedAt, pinIds, timestampOptions);
153
71
  const sightingIds = new Set(sightings.map((item) => item.id));
154
- const observedEffects = normalizeCaseObservedEffectHistory(sourceVersion != null && sourceVersion < 13 ? undefined : record.observedEffects, updatedAt, pinIds, sightingIds, timestampOptions);
155
- const closures = normalizeCaseClosureHistory(sourceVersion != null && sourceVersion < 13 ? undefined : record.closures, updatedAt, new Set(observedEffects.reviews.map((item) => item.id)), new Set(actions.map((item) => item.id)), timestampOptions, buildCaseClosureLinkContext(observedEffects, actions));
72
+ const observedEffects = normalizeCaseObservedEffectHistory(record.observedEffects, updatedAt, pinIds, sightingIds, timestampOptions);
73
+ const closures = normalizeCaseClosureHistory(record.closures, updatedAt, new Set(observedEffects.reviews.map((item) => item.id)), new Set(actions.map((item) => item.id)), timestampOptions, buildCaseClosureLinkContext(observedEffects, actions));
156
74
  const normalizedStatus = normalizeStatus(record.status);
157
75
  const branchReferences = caseInvestigationBranchReferences({ evidencePins, actions, assertions });
76
+ const attachments = readCaseAttachments(record.attachments);
77
+ const evidenceLinks = readCaseEvidenceLinks(record.evidenceLinks);
158
78
  return {
159
79
  id: existing ? existing.id : safeId(record.id) || deterministicId(domain),
160
80
  domain,
81
+ title: normalizeCaseObjective(record.title),
161
82
  status: caseStatusRequiresClosure(normalizedStatus)
162
83
  && closures.records.length === 0 && !closures.preV13HistoryUnavailable
163
84
  ? 'reviewing'
@@ -165,7 +86,7 @@ export function normalizeCase(raw, existing, nowIso, sourceVersion) {
165
86
  disposition: normalizeDisposition(record.disposition),
166
87
  reviewReasonCode: normalizeReviewReasonCode(record.reviewReasonCode),
167
88
  brandProfileIds: normalizeCaseBrandProfileIds(record.brandProfileIds),
168
- tags: normalizeTags(record.tags),
89
+ ...readCaseWorkflowFields(record, domain, assertions, sourceVersion),
169
90
  notes: normalizeNotes(record.notes, now, sourceVersion),
170
91
  source: normalizeSource(record.source),
171
92
  evidenceHistory: normalizeCaseEvidence(record, domain, createdAt, updatedAt, now, sourceVersion),
@@ -178,6 +99,8 @@ export function normalizeCase(raw, existing, nowIso, sourceVersion) {
178
99
  observedEffects,
179
100
  closures,
180
101
  branches: normalizeCaseInvestigationBranches(record.branches, updatedAt, branchReferences, timestampOptions),
102
+ ...(attachments === undefined ? {} : { attachments }),
103
+ ...(evidenceLinks === undefined ? {} : { evidenceLinks }),
181
104
  createdAt,
182
105
  updatedAt,
183
106
  };
@@ -213,6 +136,12 @@ export function createCase(input, nowIso) {
213
136
  const observedEffects = input.observedEffectReview !== undefined
214
137
  ? appendCaseObservedEffectReview(normalizeCaseObservedEffectHistory(undefined, now), input.observedEffectReview, now, pinIds, new Set(sightings.map((item) => item.id)))
215
138
  : normalizeCaseObservedEffectHistory(undefined, now);
139
+ for (const review of observedEffects.reviews) {
140
+ if (!review.recheck)
141
+ continue;
142
+ assertCurrentRecheckQuestion(review.recheck, assertions);
143
+ assertRecheckNonReproduction(review.state, review.recheck, review.completeness, evidencePins, evidencePins.find(pin => pin.id === review.evidencePinId), review.observedAt);
144
+ }
216
145
  const closures = input.closure !== undefined
217
146
  ? appendCaseClosure(normalizeCaseClosureHistory(undefined, now), input.closure, now, observedEffects, actions)
218
147
  : normalizeCaseClosureHistory(undefined, now);
@@ -222,11 +151,13 @@ export function createCase(input, nowIso) {
222
151
  return {
223
152
  id: makeId(),
224
153
  domain,
154
+ title: normalizeCaseObjective(input.title),
225
155
  status: input.closure !== undefined ? 'resolved' : normalizeStatus(input.status),
226
156
  disposition: normalizeDisposition(input.disposition),
227
157
  reviewReasonCode: normalizeReviewReasonCode(input.reviewReasonCode),
228
158
  brandProfileIds: input.brandProfileIds === undefined ? [] : assertCaseBrandProfileIds(input.brandProfileIds),
229
159
  tags: normalizeTags(input.tags),
160
+ workflowMetadata: updateCaseWorkflowMetadata(emptyCaseWorkflowMetadata(), input, domain, now),
230
161
  notes: noteBody ? [{ id: makeId(), body: noteBody, createdAt: now }] : [],
231
162
  source,
232
163
  evidenceHistory: normalizeEvidenceHistory(input.evidence ? [input.evidence] : [], {
@@ -238,6 +169,7 @@ export function createCase(input, nowIso) {
238
169
  decisions: input.decision !== undefined
239
170
  ? appendCaseDecision([], input.decision, now)
240
171
  : [],
172
+ ...(input.evidenceLink === undefined ? {} : { evidenceLinks: appendCaseEvidenceLink([], input.evidenceLink, evidencePins, now) }),
241
173
  actions,
242
174
  assertions,
243
175
  manualTrail: input.trailEvent !== undefined
@@ -254,25 +186,46 @@ export function createCase(input, nowIso) {
254
186
  };
255
187
  }
256
188
  /**
257
- * Opens the existing case for a domain, or creates one. Returns a new array
189
+ * Opens an explicitly selected or unambiguous Case, or creates one. Returns a new array
258
190
  * (callers persist it) plus the resolved record and whether it was created.
259
191
  * @param {CaseRecord[]} cases
260
192
  * @param {{ domain: unknown, status?: unknown, disposition?: unknown, source?: unknown, tags?: unknown, evidence?: unknown, note?: unknown }} input
261
193
  * @param {string} [nowIso]
262
194
  * @returns {{ cases: CaseRecord[], record: CaseRecord, created: boolean }}
263
195
  */
264
- export function openOrCreateCase(cases, input, nowIso) {
196
+ export function openOrCreateCase(cases, input, nowIso, selection = {}) {
265
197
  const domain = normalizeDomain(input.domain);
266
198
  if (!domain)
267
199
  throw new Error('A valid domain is required to open a case.');
268
- const existing = cases.find((item) => item.domain === domain);
200
+ const existing = selectExistingCase(cases, domain, selection);
269
201
  if (existing)
270
202
  return { cases, record: existing, created: false };
271
203
  if (cases.length >= MAX_CASES)
272
204
  throw new Error(`Cases are limited to ${MAX_CASES}. Delete or export some first.`);
273
205
  const record = createCase({ ...input, domain }, nowIso);
206
+ if (cases.some(existingCase => existingCase.id === record.id))
207
+ throw new Error('A unique Case ID could not be allocated. No data was changed; try again.');
274
208
  return { cases: [record, ...cases], record, created: true };
275
209
  }
210
+ /** Start a separate incident; reuse only the deliberately selected observation. */
211
+ export function createCaseIncident(cases, input, nowIso) {
212
+ const domain = normalizeDomain(input.domain);
213
+ const title = normalizeCaseObjective(input.title);
214
+ if (!domain)
215
+ throw new Error('Enter a valid domain for the incident.');
216
+ if (!title)
217
+ throw new Error('Enter a title that distinguishes this incident.');
218
+ if (typeof input.title === 'string' && input.title.trim().length > MAX_CASE_OBJECTIVE_LENGTH)
219
+ throw new Error(`Incident titles are limited to ${MAX_CASE_OBJECTIVE_LENGTH} characters.`);
220
+ let evidence;
221
+ if (input.reuse) {
222
+ const source = cases.find(record => record.id === input.reuse?.caseId && record.domain === domain);
223
+ evidence = source?.evidenceHistory.find(snapshot => snapshot.id === input.reuse?.snapshotId);
224
+ if (!evidence)
225
+ throw new Error('The selected source observation is no longer available for this domain. No Case was created.');
226
+ }
227
+ return openOrCreateCase(cases, { domain, title, ...(evidence ? { evidence: structuredClone(evidence) } : {}) }, nowIso, { newIncident: true });
228
+ }
276
229
  /**
277
230
  * Applies a partial update to one case by id, bumping updatedAt. A `note` in
278
231
  * the patch is appended (respecting the per-case note bound); a `tags` array
@@ -293,6 +246,21 @@ export function updateCase(cases, id, patch, nowIso) {
293
246
  const current = cases[index];
294
247
  if (!current)
295
248
  throw new Error('That case no longer exists.');
249
+ if (patch.evidenceLink !== undefined && patch.evidenceLinkWithdrawal !== undefined)
250
+ throw new TypeError('Record or withdraw one evidence relationship at a time.');
251
+ if (patch.title !== undefined && patch.expectedTitle !== undefined && patch.expectedTitle !== (current.title ?? '')) {
252
+ throw new Error('The incident title changed after this draft was started. Reload and review the current title before saving; your draft has not been applied.');
253
+ }
254
+ for (const [value, expected, actual, label] of [
255
+ [patch.status, patch.expectedStatus, current.status, 'status'],
256
+ [patch.disposition, patch.expectedDisposition, current.disposition, 'disposition'],
257
+ [patch.reviewReasonCode, patch.expectedReviewReasonCode, current.reviewReasonCode ?? null, 'review reason'],
258
+ [patch.tags, patch.expectedTags, current.tags, 'tags'],
259
+ ]) {
260
+ if (value !== undefined && expected !== undefined && JSON.stringify(expected) !== JSON.stringify(actual)) {
261
+ throw new Error(`The Case ${label} changed after this edit was started. Reopen the Case to review the current value; your edit has not been applied.`);
262
+ }
263
+ }
296
264
  let notes = current.notes;
297
265
  if (patch.note !== undefined) {
298
266
  const body = normalizeNoteBody(patch.note);
@@ -352,6 +320,12 @@ export function updateCase(cases, id, patch, nowIso) {
352
320
  }
353
321
  let observedEffects = normalizeCaseObservedEffectHistory(current.observedEffects, current.updatedAt, pinIds, new Set(sightings.map((item) => item.id)));
354
322
  if (patch.observedEffectReview !== undefined) {
323
+ const review = objectRecord(patch.observedEffectReview);
324
+ const context = readCaseRecheckAnswerContext(review.recheck);
325
+ if (context) {
326
+ assertCurrentRecheckQuestion(context, assertions);
327
+ assertRecheckNonReproduction(review.state, context, String(review.completeness), evidencePins, evidencePins.find(pin => pin.id === review.evidencePinId), typeof review.observedAt === 'string' ? review.observedAt : null);
328
+ }
355
329
  observedEffects = appendCaseObservedEffectReview(observedEffects, patch.observedEffectReview, now, pinIds, new Set(sightings.map((item) => item.id)));
356
330
  }
357
331
  let closures = normalizeCaseClosureHistory(current.closures, current.updatedAt, new Set(observedEffects.reviews.map((item) => item.id)), new Set(actions.map((item) => item.id)), {}, buildCaseClosureLinkContext(observedEffects, actions));
@@ -363,6 +337,9 @@ export function updateCase(cases, id, patch, nowIso) {
363
337
  }
364
338
  const record = {
365
339
  ...current,
340
+ workflowMetadata: updateCaseWorkflowMetadata(current.workflowMetadata
341
+ ?? readCaseWorkflowFields(current, current.domain, current.assertions).workflowMetadata, patch, current.domain, now),
342
+ title: patch.title === undefined ? current.title ?? '' : normalizeCaseObjective(patch.title),
366
343
  status: patch.closure !== undefined
367
344
  ? 'resolved'
368
345
  : patch.status !== undefined ? normalizeStatus(patch.status) : current.status,
@@ -377,6 +354,11 @@ export function updateCase(cases, id, patch, nowIso) {
377
354
  source,
378
355
  evidenceHistory,
379
356
  evidencePins,
357
+ ...((patch.evidenceLink !== undefined || patch.evidenceLinkWithdrawal !== undefined) ? {
358
+ evidenceLinks: patch.evidenceLink !== undefined
359
+ ? appendCaseEvidenceLink(current.evidenceLinks ?? [], patch.evidenceLink, evidencePins, now)
360
+ : withdrawCaseEvidenceLink(current.evidenceLinks ?? [], patch.evidenceLinkWithdrawal, now),
361
+ } : {}),
380
362
  decisions,
381
363
  actions,
382
364
  assertions,
@@ -394,8 +376,8 @@ export function updateCase(cases, id, patch, nowIso) {
394
376
  }
395
377
  /**
396
378
  * Records one reviewed conclusion as a single pure Case mutation. The selected
397
- * observations become bounded Case pins before the decision is created, so the
398
- * decision cannot point at absent evidence. Counterevidence is retained as a
379
+ * observations become bounded Case pins or reference existing retained pins,
380
+ * so the decision cannot point at absent evidence. Counterevidence is retained as a
399
381
  * separate resolved contradiction assertion rather than being hidden inside a
400
382
  * favourable disposition.
401
383
  */
@@ -422,14 +404,36 @@ export function recordCaseConclusion(cases, id, input, nowIso) {
422
404
  if (!current)
423
405
  throw new Error('That case no longer exists.');
424
406
  const existingPinIds = new Set(current.evidencePins.map((pin) => pin.id));
425
- const withPins = updateCase(cases, id, {
426
- evidencePins: input.evidence.map((item) => item.pin),
427
- }, now);
407
+ const selectedExisting = new Set();
408
+ for (const item of input.evidence) {
409
+ if (Object.hasOwn(item, 'pin') === Object.hasOwn(item, 'pinId'))
410
+ throw new Error('Select one new pin or one retained pin ID for each conclusion fact.');
411
+ if (Object.hasOwn(item, 'pinId')) {
412
+ if (typeof item.pinId !== 'string' || !existingPinIds.has(item.pinId))
413
+ throw new Error('The selected conclusion pin is no longer retained in this Case.');
414
+ if (selectedExisting.has(item.pinId))
415
+ throw new Error('Select each retained conclusion pin once.');
416
+ selectedExisting.add(item.pinId);
417
+ }
418
+ }
419
+ const newEvidence = input.evidence.filter(item => Object.hasOwn(item, 'pin'));
420
+ const withPins = newEvidence.length
421
+ ? updateCase(cases, id, { evidencePins: newEvidence.map(item => item.pin) }, now)
422
+ : { cases, record: current };
428
423
  const addedPins = withPins.record.evidencePins.filter((pin) => !existingPinIds.has(pin.id));
429
- if (addedPins.length !== input.evidence.length) {
424
+ if (addedPins.length !== newEvidence.length) {
430
425
  throw new Error('The selected conclusion evidence could not be retained completely.');
431
426
  }
432
- const supportingPinIds = addedPins.flatMap((pin, index) => (input.evidence[index]?.stance === 'supports' ? [pin.id] : []));
427
+ let nextAdded = 0;
428
+ const selectedPins = input.evidence.map(item => {
429
+ const pin = Object.hasOwn(item, 'pinId')
430
+ ? withPins.record.evidencePins.find(pin => pin.id === item.pinId)
431
+ : addedPins[nextAdded++];
432
+ if (!pin)
433
+ throw new Error('The selected conclusion evidence could not be retained completely.');
434
+ return pin;
435
+ });
436
+ const supportingPinIds = selectedPins.flatMap((pin, index) => (input.evidence[index]?.stance === 'supports' ? [pin.id] : []));
433
437
  let concluded = updateCase(withPins.cases, id, {
434
438
  disposition,
435
439
  reviewReasonCode,
@@ -439,7 +443,7 @@ export function recordCaseConclusion(cases, id, input, nowIso) {
439
443
  evidencePinIds: supportingPinIds,
440
444
  },
441
445
  }, now);
442
- const contradictionRelations = addedPins.flatMap((pin, index) => (input.evidence[index]?.stance === 'contradicts'
446
+ const contradictionRelations = selectedPins.flatMap((pin, index) => (input.evidence[index]?.stance === 'contradicts'
443
447
  ? [{ evidencePinId: pin.id, stance: 'contradicts' }]
444
448
  : []));
445
449
  if (contradictionRelations.length) {
@@ -454,7 +458,7 @@ export function recordCaseConclusion(cases, id, input, nowIso) {
454
458
  },
455
459
  }, now);
456
460
  }
457
- const unresolvedRelations = addedPins.flatMap((pin, index) => (input.evidence[index]?.stance === 'unresolved'
461
+ const unresolvedRelations = selectedPins.flatMap((pin, index) => (input.evidence[index]?.stance === 'unresolved'
458
462
  ? [{ evidencePinId: pin.id, stance: 'unresolved' }]
459
463
  : []));
460
464
  if (unresolvedRelations.length) {
@@ -479,32 +483,15 @@ export function recordCaseInvestigationContext(cases, id, input, nowIso) {
479
483
  if (!parsed || parsed.registrableDomain !== current.domain) {
480
484
  throw new Error(`The Incident URL must belong to the Case domain ${current.domain}.`);
481
485
  }
482
- const context = caseInvestigationContextAssertion(input);
483
- const existing = caseInvestigationContext(current);
484
- return updateCase(cases, id, existing
485
- ? {
486
- assertionUpdate: {
487
- id: existing.assertionId,
488
- statement: context.statement,
489
- rationale: context.rationale,
490
- state: 'open',
491
- },
492
- }
493
- : {
494
- assertion: {
495
- kind: 'next_step',
496
- statement: context.statement,
497
- rationale: context.rationale,
498
- evidenceRelations: [],
499
- state: 'open',
500
- },
501
- }, nowIso);
486
+ return updateCase(cases, id, { investigationContext: input }, nowIso);
502
487
  }
503
488
  export function recordCaseRecheckOutcome(cases, id, input, nowIso) {
504
489
  const now = caseTimestampOrNull(nowIso) || new Date().toISOString();
505
490
  const current = cases.find((item) => item.id === id);
506
491
  if (!current)
507
492
  throw new Error('That case no longer exists.');
493
+ if (input.recheck && input.observationHostname !== input.recheck.targetHostname)
494
+ throw new Error('This recheck concerns a different or unknown hostname. Recollect the saved question target before recording its answer.');
508
495
  const beforePinIds = new Set(current.evidencePins.map((pin) => pin.id));
509
496
  const withPin = updateCase(cases, id, {
510
497
  evidencePin: {
@@ -516,8 +503,10 @@ export function recordCaseRecheckOutcome(cases, id, input, nowIso) {
516
503
  sourceState: 'reviewed',
517
504
  observedAt: input.observedAt,
518
505
  collectionDepth: input.collectionDepth,
506
+ observationHostname: input.observationHostname,
519
507
  completeness: input.completeness,
520
- truncated: false,
508
+ truncated: input.comparisonTruncated === true
509
+ || (typeof input.comparisonSummary === 'string' && input.comparisonSummary.length > MAX_RESPONSE_VALUE_LENGTH),
521
510
  limitations: input.limitations,
522
511
  },
523
512
  }, now);
@@ -534,6 +523,7 @@ export function recordCaseRecheckOutcome(cases, id, input, nowIso) {
534
523
  evidencePinId: comparisonPin.id,
535
524
  followUpAt: input.followUpAt,
536
525
  limitations: input.limitations,
526
+ ...(input.recheck ? { recheck: input.recheck } : {}),
537
527
  },
538
528
  }, now);
539
529
  }
@@ -1,4 +1,4 @@
1
- import { PUBLIC_CASE_SCHEMA_VERSION } from "../contracts/case-portability.mjs";
1
+ import { PUBLIC_CASE_SCHEMA_VERSION, INCIDENT_CASE_SCHEMA_VERSION, EVIDENCE_FOLLOW_UP_CASE_SCHEMA_VERSION } from "../contracts/case-portability.mjs";
2
2
  import { caseStatusIsClosed } from "./case-record-decisions.mjs";
3
3
  const PRESERVE = Object.freeze({
4
4
  durable: 'preserve', internal: 'preserve', trusted: 'preserve', public: 'preserve',
@@ -71,6 +71,12 @@ function publicClosureHistory(preV13HistoryUnavailable) {
71
71
  const CASE_FIELD_RULES = Object.freeze({
72
72
  id: preservedField('id'),
73
73
  domain: preservedField('domain'),
74
+ title: fieldRule('title', SHARED_EXCLUDE, (record, profile) => (profile === 'trusted' || profile === 'public' ? '' : record.title ?? ''), {
75
+ audienceExclusions: {
76
+ trusted: { label: 'Case titles', order: 1, sinceVersion: INCIDENT_CASE_SCHEMA_VERSION },
77
+ public: { label: 'Case titles', order: 1, sinceVersion: INCIDENT_CASE_SCHEMA_VERSION },
78
+ },
79
+ }),
74
80
  status: fieldRule('status', PUBLIC_TRANSFORM, (record, profile) => (profile === 'public' && caseStatusIsClosed(record.status) && record.closures.records.length
75
81
  ? 'reviewing'
76
82
  : record.status)),
@@ -80,6 +86,12 @@ const CASE_FIELD_RULES = Object.freeze({
80
86
  audienceExclusions: { public: { label: 'Brand Profile references', order: 2 } },
81
87
  }),
82
88
  tags: fieldRule('tags', PRESERVE, (record) => [...record.tags]),
89
+ workflowMetadata: fieldRule('workflowMetadata', PUBLIC_REDACT, (record, profile) => (record.workflowMetadata === undefined ? undefined : profile === 'public'
90
+ ? { types: [...record.workflowMetadata.types], incidentTargets: [], investigationContext: null }
91
+ : structuredClone(record.workflowMetadata)), {
92
+ nestedSensitiveFields: ['incidentTargets', 'investigationContext'],
93
+ audienceExclusions: { public: { label: 'Private incident links and investigation context', order: 4, sinceVersion: EVIDENCE_FOLLOW_UP_CASE_SCHEMA_VERSION } },
94
+ }),
83
95
  notes: fieldRule('notes', SHARED_EXCLUDE, (record, profile) => (profile === 'trusted' || profile === 'public'
84
96
  ? []
85
97
  : record.notes.map((item) => ({ ...item }))), {
@@ -91,6 +103,7 @@ const CASE_FIELD_RULES = Object.freeze({
91
103
  source: preservedField('source'),
92
104
  evidenceHistory: fieldRule('evidenceHistory', PRESERVE, (record) => structuredClone(record.evidenceHistory)),
93
105
  evidencePins: fieldRule('evidencePins', PRESERVE, (record) => structuredClone(record.evidencePins)),
106
+ evidenceLinks: fieldRule('evidenceLinks', PUBLIC_EXCLUDE, (record, profile) => (profile === 'public' ? undefined : structuredClone(record.evidenceLinks)), { audienceExclusions: { public: { label: 'Analyst evidence relationships', order: 4, sinceVersion: EVIDENCE_FOLLOW_UP_CASE_SCHEMA_VERSION } } }),
94
107
  decisions: fieldRule('decisions', PRESERVE, (record) => structuredClone(record.decisions)),
95
108
  actions: fieldRule('actions', TRUSTED_REDACT_PUBLIC_EXCLUDE, (record, profile) => (profile === 'public'
96
109
  ? []
@@ -133,6 +146,12 @@ const CASE_FIELD_RULES = Object.freeze({
133
146
  branches: fieldRule('branches', PUBLIC_EXCLUDE, (record, profile) => (profile === 'public' ? [] : structuredClone(record.branches ?? [])), {
134
147
  audienceExclusions: { public: { label: 'Investigation branches', order: 5 } },
135
148
  }),
149
+ attachments: fieldRule('attachments', SHARED_EXCLUDE, (record, profile) => (profile === 'trusted' || profile === 'public' ? undefined : structuredClone(record.attachments)), {
150
+ audienceExclusions: {
151
+ trusted: { label: 'Retained file references and provenance', order: 5, sinceVersion: INCIDENT_CASE_SCHEMA_VERSION },
152
+ public: { label: 'Retained file references and provenance', order: 5, sinceVersion: INCIDENT_CASE_SCHEMA_VERSION },
153
+ },
154
+ }),
136
155
  createdAt: preservedField('createdAt'),
137
156
  updatedAt: preservedField('updatedAt'),
138
157
  });
@@ -140,10 +159,13 @@ function projectCase(record, profile) {
140
159
  const projected = {};
141
160
  for (const key of Object.keys(CASE_FIELD_RULES)) {
142
161
  const rule = CASE_FIELD_RULES[key];
162
+ const value = rule.value(record, profile);
163
+ if (value === undefined)
164
+ continue;
143
165
  Object.defineProperty(projected, key, {
144
166
  configurable: true,
145
167
  enumerable: true,
146
- value: rule.value(record, profile),
168
+ value,
147
169
  writable: true,
148
170
  });
149
171
  }
@@ -167,38 +189,26 @@ const OUTSIDE_SCHEMA_EXCLUSIONS = Object.freeze({
167
189
  trusted: Object.freeze({ label: 'Raw upstream payloads and credentials', order: 4 }),
168
190
  public: Object.freeze({ label: 'Raw upstream payloads and credentials', order: 7 }),
169
191
  });
170
- function currentAudienceExclusions(audience) {
192
+ function currentAudienceExclusions(audience, caseVersion) {
171
193
  const entries = [
172
194
  ...Object.values(CASE_FIELD_RULES).flatMap((rule) => (rule.audienceExclusions[audience] ? [rule.audienceExclusions[audience]] : [])),
173
195
  OUTSIDE_SCHEMA_EXCLUSIONS[audience],
174
196
  ];
175
197
  const orderByLabel = new Map();
176
- const labelByOrder = new Map();
177
198
  for (const entry of entries) {
199
+ if (entry.sinceVersion !== undefined && caseVersion < entry.sinceVersion)
200
+ continue;
178
201
  if (!entry.label || entry.label.length > 160 || entry.label.trim() !== entry.label
179
202
  || !Number.isSafeInteger(entry.order) || entry.order < 1 || entry.order > 64) {
180
203
  throw new TypeError('Case audience exclusion metadata is malformed or exceeds its bound.');
181
204
  }
182
205
  const prior = orderByLabel.get(entry.label);
183
- if (prior !== undefined && prior !== entry.order) {
184
- throw new TypeError(`Case audience exclusion ${entry.label} has conflicting presentation order.`);
185
- }
186
- const priorLabel = labelByOrder.get(entry.order);
187
- if (priorLabel !== undefined && priorLabel !== entry.label) {
188
- throw new TypeError(`Case audience exclusions ${priorLabel} and ${entry.label} share a presentation order.`);
189
- }
190
- orderByLabel.set(entry.label, entry.order);
191
- labelByOrder.set(entry.order, entry.label);
206
+ orderByLabel.set(entry.label, Math.min(prior ?? entry.order, entry.order));
192
207
  }
193
208
  return Object.freeze([...orderByLabel]
194
- .sort((left, right) => left[1] - right[1])
209
+ .sort((left, right) => left[1] - right[1] || (left[0] < right[0] ? -1 : left[0] > right[0] ? 1 : 0))
195
210
  .map(([label]) => label));
196
211
  }
197
- const CURRENT_AUDIENCE_EXCLUSIONS = Object.freeze({
198
- internal: currentAudienceExclusions('internal'),
199
- trusted: currentAudienceExclusions('trusted'),
200
- public: currentAudienceExclusions('public'),
201
- });
202
212
  // Schema 12 is an immutable public compatibility contract and deliberately
203
213
  // remains independent of the current field-policy projection.
204
214
  const PUBLIC_V12_EXCLUSIONS = Object.freeze([
@@ -213,6 +223,6 @@ const PUBLIC_V12_EXCLUSIONS = Object.freeze([
213
223
  export function caseAudienceExclusions(audience, caseVersion) {
214
224
  return audience === 'public' && caseVersion === PUBLIC_CASE_SCHEMA_VERSION
215
225
  ? PUBLIC_V12_EXCLUSIONS
216
- : CURRENT_AUDIENCE_EXCLUSIONS[audience];
226
+ : currentAudienceExclusions(audience, caseVersion);
217
227
  }
218
228
  export { CASE_FIELD_RULES };
@@ -0,0 +1,19 @@
1
+ import { EVIDENCE_FOLLOW_UP_CASE_SCHEMA_VERSION, INCIDENT_CASE_SCHEMA_VERSION, PUBLISHED_V2_CASE_SCHEMA_VERSION, PUBLISHED_V2_3_CASE_SCHEMA_VERSION, } from "../contracts/case-portability.mjs";
2
+ /** Adapt declared historical fields before ordinary current-record recovery. */
3
+ export function caseRecordVersionInput(record, sourceVersion) {
4
+ const declared = sourceVersion != null;
5
+ if (record.evidenceLinks !== undefined && declared && sourceVersion < EVIDENCE_FOLLOW_UP_CASE_SCHEMA_VERSION) {
6
+ throw new TypeError('Evidence relationships require the current Case schema.');
7
+ }
8
+ return {
9
+ record: declared && sourceVersion < INCIDENT_CASE_SCHEMA_VERSION ? {
10
+ ...record,
11
+ ...(sourceVersion < PUBLISHED_V2_CASE_SCHEMA_VERSION ? { observedEffects: undefined, closures: undefined } : {}),
12
+ title: '',
13
+ } : record,
14
+ timestampOptions: {
15
+ legacyTimestamps: declared && sourceVersion < PUBLISHED_V2_3_CASE_SCHEMA_VERSION,
16
+ ...(sourceVersion === undefined ? {} : { sourceVersion }),
17
+ },
18
+ };
19
+ }