@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,135 @@
1
+ import { array, enumeration, exact, exactOptional, iso, text } from "../evidence/artifact-structure.mjs";
2
+ import { assertWorkspaceInputGraph } from "../workspace/hostile-input.mjs";
3
+ import { MAX_CASE_EVIDENCE_PINS, MAX_RESPONSE_RATIONALE_LENGTH } from "../contracts/case-portability.mjs";
4
+ import { SAFE_ID_RE, freshId } from "./case-response-values.mjs";
5
+ export const CASE_EVIDENCE_LINK_KINDS = ['derived_from', 'shared_source'];
6
+ /** Enough for both directed relationships between every admitted pin pair. */
7
+ export const MAX_CASE_EVIDENCE_LINKS = MAX_CASE_EVIDENCE_PINS * (MAX_CASE_EVIDENCE_PINS - 1) * CASE_EVIDENCE_LINK_KINDS.length;
8
+ function identifier(value) {
9
+ const id = text(value, 'Evidence link identity', 64);
10
+ if (!SAFE_ID_RE.test(id))
11
+ throw new TypeError('Evidence link identity is invalid.');
12
+ return id;
13
+ }
14
+ function explanation(value) {
15
+ const result = text(value, 'Evidence relationship basis', MAX_RESPONSE_RATIONALE_LENGTH).trim();
16
+ if (!result)
17
+ throw new TypeError('Explain the evidence relationship.');
18
+ return result;
19
+ }
20
+ export function readCaseEvidenceLinks(value) {
21
+ if (value === undefined)
22
+ return undefined;
23
+ assertWorkspaceInputGraph(value, 'Evidence relationships');
24
+ const links = array(value, 'Evidence relationships', MAX_CASE_EVIDENCE_LINKS).map(raw => {
25
+ const row = exactOptional(raw, ['id', 'fromPinId', 'toPinId', 'kind', 'basis', 'createdAt'], ['withdrawal'], 'Evidence relationship');
26
+ const fromPinId = identifier(row.fromPinId), toPinId = identifier(row.toPinId);
27
+ if (fromPinId === toPinId)
28
+ throw new TypeError('An evidence pin cannot derive from or share a source with itself.');
29
+ iso(row.createdAt, 'Relationship recording time');
30
+ let withdrawal;
31
+ if (row.withdrawal !== undefined) {
32
+ const item = exact(row.withdrawal, ['at', 'reason'], 'Relationship withdrawal');
33
+ iso(item.at, 'Withdrawal time');
34
+ if (Date.parse(item.at) < Date.parse(row.createdAt))
35
+ throw new TypeError('Withdrawal precedes the relationship.');
36
+ withdrawal = { at: item.at, reason: explanation(item.reason) };
37
+ }
38
+ return { id: identifier(row.id), fromPinId, toPinId,
39
+ kind: enumeration(row.kind, CASE_EVIDENCE_LINK_KINDS, 'Evidence relationship kind'),
40
+ basis: explanation(row.basis), createdAt: row.createdAt, ...(withdrawal ? { withdrawal } : {}) };
41
+ });
42
+ if (new Set(links.map(link => link.id)).size !== links.length)
43
+ throw new TypeError('Evidence relationship identities must be unique.');
44
+ return links;
45
+ }
46
+ function derivationGraph(links) {
47
+ const graph = new Map();
48
+ for (const link of links)
49
+ if (!link.withdrawal && link.kind === 'derived_from') {
50
+ const neighbours = graph.get(link.fromPinId) ?? [];
51
+ neighbours.push(link.toPinId);
52
+ graph.set(link.fromPinId, neighbours);
53
+ }
54
+ return graph;
55
+ }
56
+ function reaches(graph, from, to) {
57
+ const pending = [from], visited = new Set();
58
+ while (pending.length) {
59
+ const id = pending.pop();
60
+ if (id === to)
61
+ return true;
62
+ if (visited.has(id))
63
+ continue;
64
+ visited.add(id);
65
+ pending.push(...(graph.get(id) ?? []));
66
+ }
67
+ return false;
68
+ }
69
+ export function appendCaseEvidenceLink(current, input, pins, now) {
70
+ const row = exact(input, ['fromPinId', 'toPinId', 'kind', 'basis'], 'New evidence relationship');
71
+ const link = readCaseEvidenceLinks([{ ...row, id: freshId('evidence-link'), createdAt: now }])[0];
72
+ const ids = new Set(pins.map(pin => pin.id));
73
+ if (!ids.has(link.fromPinId) || !ids.has(link.toPinId))
74
+ throw new TypeError('Select two retained evidence pins.');
75
+ if (current.some(item => !item.withdrawal && item.kind === link.kind
76
+ && ((item.fromPinId === link.fromPinId && item.toPinId === link.toPinId)
77
+ || (link.kind === 'shared_source' && item.fromPinId === link.toPinId && item.toPinId === link.fromPinId)))) {
78
+ throw new TypeError('This evidence relationship is already recorded. Withdraw it before recording a replacement.');
79
+ }
80
+ if (link.kind === 'derived_from' && reaches(derivationGraph(current), link.toPinId, link.fromPinId))
81
+ throw new TypeError('This derivation would create a cycle. Review the existing links first.');
82
+ return readCaseEvidenceLinks([...current, link]);
83
+ }
84
+ export function withdrawCaseEvidenceLink(current, input, now) {
85
+ const row = exact(input, ['id', 'reason'], 'Evidence relationship withdrawal');
86
+ const id = identifier(row.id), existing = current.find(link => link.id === id);
87
+ if (!existing || existing.withdrawal)
88
+ throw new TypeError('This relationship is missing or already withdrawn. Refresh before reviewing it.');
89
+ return readCaseEvidenceLinks(current.map(link => link.id === id ? { ...link, withdrawal: { at: now, reason: explanation(row.reason) } } : link));
90
+ }
91
+ /** Keep unresolved references; an imported pin can have been omitted by its source. */
92
+ export function mergeCaseEvidenceLinks(local, incoming) {
93
+ if (local === undefined && incoming === undefined)
94
+ return undefined;
95
+ const merged = new Map((readCaseEvidenceLinks(local) ?? []).map(link => [link.id, link]));
96
+ for (const link of readCaseEvidenceLinks(incoming) ?? []) {
97
+ const existing = merged.get(link.id);
98
+ if (!existing) {
99
+ merged.set(link.id, link);
100
+ continue;
101
+ }
102
+ const { withdrawal: left, ...a } = existing, { withdrawal: right, ...b } = link;
103
+ if (JSON.stringify(a) !== JSON.stringify(b) || (left && right && JSON.stringify(left) !== JSON.stringify(right))) {
104
+ throw new TypeError('An imported evidence relationship conflicts with retained provenance. No relationship was overwritten.');
105
+ }
106
+ merged.set(link.id, { ...a, ...((left ?? right) ? { withdrawal: left ?? right } : {}) });
107
+ }
108
+ return readCaseEvidenceLinks([...merged.values()].sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
109
+ }
110
+ export function caseEvidenceLinkIssues(links, pins) {
111
+ const ids = new Set(pins.map(pin => pin.id));
112
+ const graph = derivationGraph(links);
113
+ return links.map(link => ({ link,
114
+ missingPinIds: [link.fromPinId, link.toPinId].filter(id => !ids.has(id)),
115
+ cyclic: !link.withdrawal && link.kind === 'derived_from' && reaches(graph, link.toPinId, link.fromPinId),
116
+ }));
117
+ }
118
+ /** Matching context is disclosed, never counted as independent corroboration. */
119
+ export function caseEvidenceSharedContext(pins) {
120
+ const groups = new Map();
121
+ function add(kind, identity, label, id) {
122
+ if (!identity?.trim())
123
+ return;
124
+ const key = JSON.stringify([kind, identity]);
125
+ const group = groups.get(key) ?? { kind, label, pinIds: [] };
126
+ group.pinIds.push(id);
127
+ groups.set(key, group);
128
+ }
129
+ for (const pin of pins) {
130
+ add('checkpoint', pin.checkpointId, 'Same collection checkpoint', pin.id);
131
+ add('import', pin.importContentSha256, 'Same imported content', pin.id);
132
+ add('source', pin.source, `Same declared source: ${pin.source}`, pin.id);
133
+ }
134
+ return [...groups.values()].filter(group => group.pinIds.length > 1);
135
+ }
@@ -1,8 +1,13 @@
1
1
  // Pure, framework-neutral analyst-case records, evidence histories, bounded
2
2
  // record normalization, and analyst updates.
3
- import { normalizeHttpSummary } from "./http-summary.mjs";
4
- import { normalizeOpportunityModelVersion } from "../../lib/opportunity-scoring.mjs";
5
- import { normalizeRiskModelVersion } from "../../lib/risk-scoring.mjs";
3
+ import { httpSummaryFieldIsObserved, normalizeHttpSummary } from "./http-summary.mjs";
4
+ import { normalizeOpportunityModelVersion } from "../analysis/opportunity-scoring.mjs";
5
+ import { normalizeRiskModelVersion } from "../analysis/risk-scoring.mjs";
6
+ import { latestObservationCohort } from "../evidence/latest-observations.mjs";
7
+ import { normalizeExplicitIsoTimestamp } from "../evidence/observation.mjs";
8
+ import { EVIDENCE_FOLLOW_UP_CASE_SCHEMA_VERSION, PUBLISHED_V2_3_CASE_SCHEMA_VERSION } from "../contracts/case-portability.mjs";
9
+ import { validWebObservationMode } from "../evidence/lookup-target.mjs";
10
+ import { normalizeWebCollectionQuality, webCollectionAllowsComparison } from "../evidence/collection-quality.mjs";
6
11
  import { 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, } from "./case-record-contracts.mjs";
7
12
  import { CONCLUSIVE_AVAILABILITY, DEFAULT_EVIDENCE_SOURCE, EVIDENCE_SOURCE_RANK, EVIDENCE_SOURCE_SET, REGISTERED_LIKE, caseTimestampOrNull, hashString, isoOrNull, normalizeEvidenceHostnameForCase, objectRecord, safeId, } from "./case-record-core.mjs";
8
13
  function evidenceString(value, max = MAX_EVIDENCE_STRING_LENGTH) {
@@ -24,7 +29,10 @@ function boolOrNull(value) {
24
29
  // are deduplicated and the result is sorted deterministically (largest
25
30
  // contribution first, then label) so input order alone can never change a
26
31
  // snapshot's fingerprint and two equal factor sets in different order collapse.
27
- function normalizeFactors(value) {
32
+ function compareFactors(a, b) {
33
+ return b.points - a.points || (a.label < b.label ? -1 : a.label > b.label ? 1 : 0);
34
+ }
35
+ function normalizeFactors(value, legacyOrder = false) {
28
36
  if (!Array.isArray(value))
29
37
  return [];
30
38
  const seen = new Set();
@@ -45,7 +53,7 @@ function normalizeFactors(value) {
45
53
  seen.add(key);
46
54
  out.push({ label, points });
47
55
  }
48
- out.sort((a, b) => b.points - a.points || a.label.localeCompare(b.label));
56
+ out.sort(legacyOrder ? (a, b) => b.points - a.points || a.label.localeCompare(b.label) : compareFactors);
49
57
  return out.slice(0, MAX_EVIDENCE_FACTORS);
50
58
  }
51
59
  // Case-insensitive, terminal-dot-stripped, deduplicated and sorted so a
@@ -112,7 +120,10 @@ const DEEP_SIGNAL_FIELDS = [
112
120
  // another. Deterministic ordering here is what makes the fingerprint stable.
113
121
  const MATERIAL_FIELD_ORDER = [
114
122
  'inputHostname',
123
+ 'observationHostname',
124
+ 'webObservationMode',
115
125
  'scanDepth',
126
+ 'webCollectionQuality',
116
127
  'availability', 'confidence', 'riskModelVersion', 'riskScore', 'opportunityModelVersion', 'opportunityScore',
117
128
  'riskFactors', 'opportunityFactors',
118
129
  'registrar', 'createdDate', 'expiryDate', 'nameservers',
@@ -128,7 +139,7 @@ const MATERIAL_FIELD_ORDER = [
128
139
  // The canonical, comparison-safe value of a material field. Registrar casing,
129
140
  // nameserver order, and sub-day timestamps are collapsed so they can never
130
141
  // count as a "change"; a non-conclusive availability contributes nothing.
131
- function materialValue(field, snapshot) {
142
+ function materialValue(field, snapshot, legacyOrder = false) {
132
143
  switch (field) {
133
144
  case 'availability':
134
145
  return typeof snapshot.availability === 'string' && CONCLUSIVE_AVAILABILITY.has(snapshot.availability)
@@ -147,9 +158,9 @@ function materialValue(field, snapshot) {
147
158
  case 'mutationTypes':
148
159
  return snapshot.mutationTypes;
149
160
  case 'riskFactors':
150
- return snapshot.riskFactors.map((factor) => [factor.label, factor.points]);
161
+ return (legacyOrder ? snapshot.riskFactors : [...snapshot.riskFactors].sort(compareFactors)).map((factor) => [factor.label, factor.points]);
151
162
  case 'opportunityFactors':
152
- return snapshot.opportunityFactors.map((factor) => [factor.label, factor.points]);
163
+ return (legacyOrder ? snapshot.opportunityFactors : [...snapshot.opportunityFactors].sort(compareFactors)).map((factor) => [factor.label, factor.points]);
153
164
  default:
154
165
  return snapshot[field] ?? null;
155
166
  }
@@ -165,7 +176,7 @@ function isEmptyMaterial(value) {
165
176
  }
166
177
  // Fields that describe the capture rather than assert evidence, so they never
167
178
  // on their own keep an otherwise-empty snapshot alive.
168
- const NON_EVIDENCE_MATERIAL = new Set(['inputHostname', 'scanDepth', 'confidence']);
179
+ const NON_EVIDENCE_MATERIAL = new Set(['inputHostname', 'observationHostname', 'webObservationMode', 'scanDepth', 'webCollectionQuality', 'confidence']);
169
180
  // A snapshot with no material evidence (only timestamps/source/depth, or only a
170
181
  // bare confidence/unknown-availability) is dropped rather than added to a
171
182
  // timeline.
@@ -179,14 +190,13 @@ function hasMaterialEvidence(snapshot) {
179
190
  return false;
180
191
  }
181
192
  // Deterministic string form of the material identity, keys in fixed order.
182
- function canonicalMaterialString(snapshot) {
193
+ function canonicalMaterialString(snapshot, legacyOrder = false) {
183
194
  const canonical = {};
184
195
  for (const field of MATERIAL_FIELD_ORDER) {
185
- const value = materialValue(field, snapshot);
186
- // Preserve historical fingerprints when the new v14 observation-context
187
- // field is absent. A retained hostname is still material and therefore
188
- // separates otherwise-identical captures.
189
- if (field === 'inputHostname' && value === null)
196
+ const value = materialValue(field, snapshot, legacyOrder);
197
+ // Optional collection context does not alter historical fingerprints when
198
+ // absent; recorded context separates otherwise-identical captures.
199
+ if ((field === 'inputHostname' || field === 'observationHostname' || field === 'webObservationMode' || field === 'webCollectionQuality') && value === null)
190
200
  continue;
191
201
  canonical[field] = value;
192
202
  }
@@ -213,23 +223,46 @@ function buildSnapshot(raw, options) {
213
223
  if (!raw || typeof raw !== 'object')
214
224
  return null;
215
225
  const record = objectRecord(raw);
226
+ if (record.factorOrder !== undefined && record.factorOrder !== 'code-unit-v1')
227
+ return null;
228
+ const historicalSchema = options.sourceVersion !== undefined && Number(options.sourceVersion) <= PUBLISHED_V2_3_CASE_SCHEMA_VERSION;
229
+ if (historicalSchema && record.factorOrder !== undefined)
230
+ return null;
231
+ // Fieldless retained snapshots keep their original identity algorithm. New
232
+ // captures declare code-unit ordering; the declaration survives every save.
233
+ const legacyOrder = historicalSchema || (record.factorOrder === undefined && typeof record.fingerprint === 'string');
216
234
  const scanDepth = normalizeScanDepth(record.scanDepth);
217
235
  const httpSummary = normalizeHttpSummary(record);
236
+ const webCollectionQuality = normalizeWebCollectionQuality(record.webCollectionQuality, scanDepth);
237
+ if (webCollectionQuality && options.sourceVersion != null && options.sourceVersion < EVIDENCE_FOLLOW_UP_CASE_SCHEMA_VERSION) {
238
+ throw new TypeError('Web collection quality requires Case schema 17 or later; historical evidence was not reinterpreted.');
239
+ }
218
240
  const acceptsProfileContext = options.sourceVersion === undefined || Number(options.sourceVersion) >= 12;
219
241
  const acceptsInputHostname = options.sourceVersion === undefined || Number(options.sourceVersion) >= 13;
242
+ const acceptsObservationHostname = options.sourceVersion === undefined || Number(options.sourceVersion) > PUBLISHED_V2_3_CASE_SCHEMA_VERSION;
243
+ const observationHostname = acceptsObservationHostname
244
+ ? normalizeEvidenceHostnameForCase(record.observationHostname, options.caseDomain)
245
+ : null;
246
+ if (acceptsObservationHostname && record.observationHostname != null && !observationHostname)
247
+ return null;
248
+ if (acceptsObservationHostname && !validWebObservationMode(record.webObservationMode))
249
+ return null;
220
250
  const fields = {
221
251
  inputHostname: acceptsInputHostname
222
252
  ? normalizeEvidenceHostnameForCase(record.inputHostname, options.caseDomain)
223
253
  : null,
254
+ ...(observationHostname ? { observationHostname } : {}),
255
+ ...(acceptsObservationHostname && scanDepth !== 'fast' && record.webObservationMode === 'selected_url' ? { webObservationMode: record.webObservationMode } : {}),
224
256
  scanDepth,
257
+ ...(webCollectionQuality ? { webCollectionQuality } : {}),
225
258
  availability: evidenceString(record.availability),
226
259
  confidence: evidenceString(record.confidence),
227
260
  riskModelVersion: normalizeRiskModelVersion(record.riskModelVersion),
228
261
  riskScore: clampScore(record.riskScore),
229
262
  opportunityModelVersion: normalizeOpportunityModelVersion(record.opportunityModelVersion),
230
263
  opportunityScore: clampScore(record.opportunityScore),
231
- riskFactors: normalizeFactors(record.riskFactors),
232
- opportunityFactors: normalizeFactors(record.opportunityFactors),
264
+ riskFactors: normalizeFactors(record.riskFactors, legacyOrder),
265
+ opportunityFactors: normalizeFactors(record.opportunityFactors, legacyOrder),
233
266
  registrar: evidenceString(record.registrar),
234
267
  createdDate: lifecycleTimestamp(record.createdDate, options.sourceVersion),
235
268
  expiryDate: lifecycleTimestamp(record.expiryDate, options.sourceVersion),
@@ -274,6 +307,12 @@ function buildSnapshot(raw, options) {
274
307
  fields.riskModelVersion = null;
275
308
  if (fields.opportunityScore === null && fields.opportunityFactors.length === 0)
276
309
  fields.opportunityModelVersion = null;
310
+ if (webCollectionQuality) {
311
+ for (const field of ['hasPasswordField', 'hasExternalFormAction', 'pageBaselineMatch', 'faviconMatch', 'faviconNearMatch', 'reusesOfficialAssets']) {
312
+ if (fields[field] === false && !webCollectionAllowsComparison(field, webCollectionQuality, scanDepth))
313
+ fields[field] = null;
314
+ }
315
+ }
277
316
  // A fast capture never evaluates the deep signals, so any value supplied for
278
317
  // them (e.g. a profile's default `false`) is discarded as unevaluated.
279
318
  if (scanDepth === 'fast') {
@@ -317,10 +356,11 @@ function buildSnapshot(raw, options) {
317
356
  ? options.source
318
357
  : DEFAULT_EVIDENCE_SOURCE;
319
358
  const material = canonicalMaterialString(fields);
320
- const fingerprint = hashString(material);
359
+ const fingerprint = hashString(legacyOrder ? canonicalMaterialString(fields, true) : material);
321
360
  const snapshot = {
322
361
  id: `ev-${fingerprint}`,
323
362
  fingerprint,
363
+ ...(!legacyOrder ? { factorOrder: 'code-unit-v1' } : {}),
324
364
  firstCapturedAt,
325
365
  capturedAt,
326
366
  source,
@@ -356,7 +396,10 @@ function mergeDuplicateSnapshots(kept, incoming) {
356
396
  ? incoming.capturedAt
357
397
  : kept.capturedAt;
358
398
  const source = chooseSource(kept, incoming);
359
- return { ...kept, firstCapturedAt, capturedAt, source };
399
+ // Preserve an existing historical identifier when a new capture contains
400
+ // the same evidence under the newer ordering declaration.
401
+ const base = kept.factorOrder && !incoming.factorOrder ? incoming : kept;
402
+ return { ...base, firstCapturedAt, capturedAt, source };
360
403
  }
361
404
  function compareSnapshotChrono(a, b) {
362
405
  return (Date.parse(a.capturedAt) - Date.parse(b.capturedAt) ||
@@ -374,6 +417,11 @@ function compareSnapshotChrono(a, b) {
374
417
  * @returns {CaseEvidenceSnapshot[]}
375
418
  */
376
419
  export function normalizeEvidenceHistory(rawList, options = {}) {
420
+ const ordered = [...evidenceByMaterial(rawList, options).values()].sort(compareSnapshotChrono);
421
+ const kept = ordered.slice(Math.max(0, ordered.length - MAX_EVIDENCE_SNAPSHOTS_PER_CASE));
422
+ return assignUniqueSnapshotIds(kept);
423
+ }
424
+ function evidenceByMaterial(rawList, options) {
377
425
  const list = Array.isArray(rawList) ? rawList : [];
378
426
  const byMaterial = new Map();
379
427
  for (const raw of list) {
@@ -385,9 +433,24 @@ export function normalizeEvidenceHistory(rawList, options = {}) {
385
433
  // collision can never merge two genuinely different snapshots.
386
434
  byMaterial.set(built.material, existing ? mergeDuplicateSnapshots(existing, built.snapshot) : built.snapshot);
387
435
  }
388
- const ordered = [...byMaterial.values()].sort(compareSnapshotChrono);
389
- const kept = ordered.slice(Math.max(0, ordered.length - MAX_EVIDENCE_SNAPSHOTS_PER_CASE));
390
- return assignUniqueSnapshotIds(kept);
436
+ return byMaterial;
437
+ }
438
+ /** Imports add observations without evicting existing local material. Ordinary
439
+ * collection still uses the newest-history policy in normalizeEvidenceHistory. */
440
+ export function mergeImportedEvidenceHistory(local, imported, caseDomain) {
441
+ const options = { source: 'import', fallback: null, caseDomain };
442
+ const retained = evidenceByMaterial(normalizeEvidenceHistory(local, options), options);
443
+ let omitted = 0;
444
+ for (const [material, incoming] of evidenceByMaterial(imported, options)) {
445
+ const existing = retained.get(material);
446
+ if (existing)
447
+ retained.set(material, mergeDuplicateSnapshots(existing, incoming));
448
+ else if (retained.size < MAX_EVIDENCE_SNAPSHOTS_PER_CASE)
449
+ retained.set(material, incoming);
450
+ else
451
+ omitted += 1;
452
+ }
453
+ return { records: assignUniqueSnapshotIds([...retained.values()].sort(compareSnapshotChrono)), omitted };
391
454
  }
392
455
  function assignUniqueSnapshotIds(snapshots) {
393
456
  const used = new Set();
@@ -403,15 +466,72 @@ function assignUniqueSnapshotIds(snapshots) {
403
466
  return snapshot.id === id ? snapshot : { ...snapshot, id };
404
467
  });
405
468
  }
406
- /**
407
- * The most recent snapshot, or null. Lets UI render "the latest evidence"
408
- * without knowing the history is a bounded, deduplicated timeline.
409
- * @param {{ evidenceHistory?: CaseEvidenceSnapshot[] } | null | undefined} record
410
- * @returns {CaseEvidenceSnapshot | null}
411
- */
412
- export function latestCaseEvidence(record) {
469
+ /** Select from an admitted history without assigning order to equal or unknown capture times. */
470
+ export function currentCaseEvidence(record) {
413
471
  const history = record && Array.isArray(record.evidenceHistory) ? record.evidenceHistory : [];
414
- return history.at(-1) ?? null;
472
+ const cohort = latestObservationCohort(history, (snapshot) => snapshot.capturedAt);
473
+ const snapshot = cohort.undated.length === 0 && cohort.latest.length === 1 ? cohort.latest[0] : null;
474
+ return {
475
+ snapshot,
476
+ candidates: [...cohort.latest, ...cohort.undated],
477
+ capturedAt: cohort.observedAt,
478
+ limitation: cohort.undated.length
479
+ ? 'Retained snapshots include an unknown capture time. No single latest assessment is selected.'
480
+ : cohort.latest.length > 1
481
+ ? 'Distinct snapshots share the latest capture time. No single latest assessment is selected.'
482
+ : null,
483
+ };
484
+ }
485
+ /** The unique latest retained snapshot, or null for empty or temporally ambiguous evidence. */
486
+ export function latestCaseEvidence(record) {
487
+ return currentCaseEvidence(record).snapshot;
488
+ }
489
+ /** Chronological presentation and comparison admission shared by the browser and Case report. */
490
+ export function caseEvidenceTimeline(history) {
491
+ return buildEvidenceTimeline(history, true);
492
+ }
493
+ /** Reproduce published report fields only; not current analyst comparisons. */
494
+ export function publishedCaseEvidenceTimelineForVerification(history) {
495
+ return buildEvidenceTimeline(history, false);
496
+ }
497
+ function buildEvidenceTimeline(history, enforceCollectionQuality) {
498
+ const dated = (history ?? []).map((snapshot) => ({
499
+ snapshot,
500
+ at: normalizeExplicitIsoTimestamp(snapshot.capturedAt),
501
+ }));
502
+ const counts = new Map();
503
+ for (const { at } of dated)
504
+ if (at)
505
+ counts.set(at, (counts.get(at) ?? 0) + 1);
506
+ const hasUndated = dated.some(({ at }) => at === null);
507
+ dated.sort((a, b) => (a.at === b.at ? 0 : a.at === null ? 1 : b.at === null ? -1 : Date.parse(a.at) - Date.parse(b.at))
508
+ || (a.snapshot.id < b.snapshot.id ? -1 : a.snapshot.id > b.snapshot.id ? 1 : 0));
509
+ return dated.map(({ snapshot, at }, index) => {
510
+ const previous = dated[index - 1];
511
+ const isBaseline = index === 0 && !hasUndated && at !== null && counts.get(at) === 1;
512
+ const orderingLimitation = !at || (previous && !previous.at)
513
+ ? 'Capture order is unknown; no temporal change is inferred.'
514
+ : counts.get(at) !== 1 || (previous && counts.get(previous.at) !== 1)
515
+ ? 'Equal-time snapshots have no unique before-and-after order; no temporal change is inferred.'
516
+ : null;
517
+ const comparable = previous && !orderingLimitation;
518
+ const changes = comparable ? compareEvidence(previous.snapshot, snapshot, enforceCollectionQuality) : [];
519
+ const incomparableReasons = orderingLimitation
520
+ ? ['other']
521
+ : comparable ? incomparableReasonsForEvidence(previous.snapshot, snapshot, enforceCollectionQuality) : [];
522
+ if (comparable && !changes.length && !incomparableReasons.length
523
+ && canonicalMaterialString(previous.snapshot) !== canonicalMaterialString(snapshot))
524
+ incomparableReasons.push('other');
525
+ return {
526
+ snapshot,
527
+ isBaseline,
528
+ hasRepeatedObservation: snapshot.firstCapturedAt !== snapshot.capturedAt,
529
+ changes: changes.length ? changes : null,
530
+ hasIncomparableChange: incomparableReasons.length > 0,
531
+ incomparableReasons,
532
+ orderingLimitation,
533
+ };
534
+ });
415
535
  }
416
536
  /**
417
537
  * Returns the exact hostname from the latest retained observation when it is
@@ -433,38 +553,38 @@ export function caseLookupTarget(record) {
433
553
  // a mode change is never reported.
434
554
  // (absent) - always comparable (data available in every capture).
435
555
  const COMPARE_FIELDS = [
436
- { field: 'availability', label: 'Availability', type: 'availability' },
437
- { field: 'confidence', label: 'Confidence', type: 'token' },
438
- { field: 'riskScore', label: 'Risk score', type: 'score', depthGate: 'comparable', modelGate: 'risk', direction: 'risk' },
439
- { field: 'riskFactors', label: 'Risk factors', type: 'factors', depthGate: 'comparable', modelGate: 'risk' },
440
- { field: 'opportunityScore', label: 'Opportunity score', type: 'score', modelGate: 'opportunity' },
441
- { field: 'opportunityFactors', label: 'Opportunity factors', type: 'factors', modelGate: 'opportunity' },
442
- { field: 'registrar', label: 'Registrar', type: 'registrar' },
443
- { field: 'createdDate', label: 'Creation date', type: 'date' },
444
- { field: 'expiryDate', label: 'Expiry date', type: 'date' },
445
- { field: 'nameservers', label: 'Nameservers', type: 'set', emptyGuard: true },
446
- { field: 'hasMx', label: 'MX', type: 'bool', depthGate: 'both-deep' },
447
- { field: 'hasSpf', label: 'SPF', type: 'bool', depthGate: 'both-deep' },
448
- { field: 'hasDmarc', label: 'DMARC', type: 'bool', depthGate: 'both-deep' },
449
- { field: 'activityStatus', label: 'Website activity', type: 'token', depthGate: 'both-deep' },
450
- { field: 'websiteProbeDetail', label: 'Website check detail', type: 'text', depthGate: 'both-deep' },
451
- { field: 'pageTitle', label: 'Page title', type: 'text', depthGate: 'both-deep' },
452
- { field: 'httpEvidenceStatus', label: 'HTTP evidence status', type: 'token', depthGate: 'both-deep' },
453
- { field: 'httpFinalOrigin', label: 'Final website origin', type: 'text', depthGate: 'both-deep' },
454
- { field: 'httpResponseStatus', label: 'HTTP response status', type: 'number', depthGate: 'both-deep' },
455
- { field: 'httpTransportSecurity', label: 'Website transport', type: 'http-transport', depthGate: 'both-deep' },
456
- { field: 'httpRedirectCount', label: 'HTTP redirect count', type: 'number', depthGate: 'both-deep' },
457
- { field: 'httpCrossOriginRedirect', label: 'Cross-origin redirect', type: 'http-signal', depthGate: 'both-deep' },
458
- { field: 'httpHttpsDowngrade', label: 'HTTPS downgrade', type: 'signal', depthGate: 'both-deep' },
459
- { field: 'httpContentType', label: 'Website content type', type: 'token', depthGate: 'both-deep' },
460
- { field: 'httpSecurityHeaders', label: 'Observed security headers', type: 'set', depthGate: 'both-deep' },
461
- { field: 'faviconMatch', label: 'Official favicon match', type: 'signal', depthGate: 'both-deep' },
462
- { field: 'faviconNearMatch', label: 'Official favicon near-match', type: 'signal', depthGate: 'both-deep' },
463
- { field: 'reusesOfficialAssets', label: 'Official asset reuse', type: 'signal', depthGate: 'both-deep' },
464
- { field: 'hasPasswordField', label: 'Password form', type: 'signal', depthGate: 'both-deep' },
465
- { field: 'hasExternalFormAction', label: 'External form action', type: 'signal', depthGate: 'both-deep' },
466
- { field: 'phishingLanguageMatch', label: 'Phishing language', type: 'phishing', depthGate: 'both-deep' },
467
- { field: 'mutationTypes', label: 'Mutation types', type: 'set' },
556
+ { field: 'availability', scope: 'registration', label: 'Availability', type: 'availability' },
557
+ { field: 'confidence', scope: 'registration', label: 'Confidence', type: 'token' },
558
+ { field: 'riskScore', scope: 'web', label: 'Risk score', type: 'score', depthGate: 'comparable', modelGate: 'risk', direction: 'risk' },
559
+ { field: 'riskFactors', scope: 'web', label: 'Risk factors', type: 'factors', depthGate: 'comparable', modelGate: 'risk' },
560
+ { field: 'opportunityScore', scope: 'web', label: 'Opportunity score', type: 'score', modelGate: 'opportunity' },
561
+ { field: 'opportunityFactors', scope: 'web', label: 'Opportunity factors', type: 'factors', modelGate: 'opportunity' },
562
+ { field: 'registrar', scope: 'registration', label: 'Registrar', type: 'registrar' },
563
+ { field: 'createdDate', scope: 'registration', label: 'Creation date', type: 'date' },
564
+ { field: 'expiryDate', scope: 'registration', label: 'Expiry date', type: 'date' },
565
+ { field: 'nameservers', scope: 'registration', label: 'Nameservers', type: 'set', emptyGuard: true },
566
+ { field: 'hasMx', scope: 'hostname', label: 'MX', type: 'bool', depthGate: 'both-deep' },
567
+ { field: 'hasSpf', scope: 'hostname', label: 'SPF', type: 'bool', depthGate: 'both-deep' },
568
+ { field: 'hasDmarc', scope: 'hostname', label: 'DMARC', type: 'bool', depthGate: 'both-deep' },
569
+ { field: 'activityStatus', scope: 'web', label: 'Website activity', type: 'token', depthGate: 'both-deep' },
570
+ { field: 'websiteProbeDetail', scope: 'web', label: 'Website check detail', type: 'text', depthGate: 'both-deep' },
571
+ { field: 'pageTitle', scope: 'web', label: 'Page title', type: 'text', depthGate: 'both-deep' },
572
+ { field: 'httpEvidenceStatus', scope: 'web', label: 'HTTP evidence status', type: 'token', depthGate: 'both-deep' },
573
+ { field: 'httpFinalOrigin', scope: 'web', label: 'Final website origin', type: 'text', depthGate: 'both-deep' },
574
+ { field: 'httpResponseStatus', scope: 'web', label: 'HTTP response status', type: 'number', depthGate: 'both-deep' },
575
+ { field: 'httpTransportSecurity', scope: 'web', label: 'Website transport', type: 'http-transport', depthGate: 'both-deep' },
576
+ { field: 'httpRedirectCount', scope: 'web', label: 'HTTP redirect count', type: 'number', depthGate: 'both-deep' },
577
+ { field: 'httpCrossOriginRedirect', scope: 'web', label: 'Cross-origin redirect', type: 'http-signal', depthGate: 'both-deep' },
578
+ { field: 'httpHttpsDowngrade', scope: 'web', label: 'HTTPS downgrade', type: 'signal', depthGate: 'both-deep' },
579
+ { field: 'httpContentType', scope: 'web', label: 'Website content type', type: 'token', depthGate: 'both-deep' },
580
+ { field: 'httpSecurityHeaders', scope: 'web', label: 'Observed security headers', type: 'set', depthGate: 'both-deep' },
581
+ { field: 'faviconMatch', scope: 'web', label: 'Official favicon match', type: 'signal', depthGate: 'both-deep' },
582
+ { field: 'faviconNearMatch', scope: 'web', label: 'Official favicon near-match', type: 'signal', depthGate: 'both-deep' },
583
+ { field: 'reusesOfficialAssets', scope: 'web', label: 'Official asset reuse', type: 'signal', depthGate: 'both-deep' },
584
+ { field: 'hasPasswordField', scope: 'web', label: 'Password form', type: 'signal', depthGate: 'both-deep' },
585
+ { field: 'hasExternalFormAction', scope: 'web', label: 'External form action', type: 'signal', depthGate: 'both-deep' },
586
+ { field: 'phishingLanguageMatch', scope: 'web', label: 'Phishing language', type: 'phishing', depthGate: 'both-deep' },
587
+ { field: 'mutationTypes', scope: 'hostname', label: 'Mutation types', type: 'set' },
468
588
  ];
469
589
  function depthComparable(a, b) {
470
590
  return a === b && (a === 'fast' || a === 'deep');
@@ -482,6 +602,13 @@ function opportunityModelComparable(previous, current) {
482
602
  function valuesMateriallyEqual(field, previous, current) {
483
603
  return JSON.stringify(materialValue(field, previous)) === JSON.stringify(materialValue(field, current));
484
604
  }
605
+ function collectionFieldComparable(field, snapshot) {
606
+ if (field === 'registrar' || field === 'createdDate' || field === 'expiryDate')
607
+ return isPresent(snapshot[field]);
608
+ return field.startsWith('http')
609
+ ? httpSummaryFieldIsObserved(field, snapshot)
610
+ : webCollectionAllowsComparison(field, snapshot.webCollectionQuality, snapshot.scanDepth);
611
+ }
485
612
  /**
486
613
  * Explains material fields that comparison gates deliberately suppress. The
487
614
  * reasons are stable machine values for UI/report wording; they are not risk
@@ -489,14 +616,27 @@ function valuesMateriallyEqual(field, previous, current) {
489
616
  * in the same observation produced an ordinary material change.
490
617
  * @param {CaseEvidenceSnapshot | null | undefined} previous
491
618
  * @param {CaseEvidenceSnapshot | null | undefined} current
492
- * @returns {Array<'observation-context' | 'opportunity-model' | 'scan-depth' | 'risk-model'>}
619
+ * @returns {Array<'observation-context' | 'opportunity-model' | 'scan-depth' | 'risk-model' | 'collection-quality'>}
493
620
  */
494
621
  export function caseEvidenceIncomparableReasons(previous, current) {
622
+ return incomparableReasonsForEvidence(previous, current, true);
623
+ }
624
+ function incomparableReasonsForEvidence(previous, current, enforceCollectionQuality) {
495
625
  if (!previous || !current || previous.fingerprint === current.fingerprint)
496
626
  return [];
497
627
  const reasons = [];
498
- if (previous.inputHostname !== current.inputHostname)
628
+ if (!sameObservationContext(previous, current))
499
629
  reasons.push('observation-context');
630
+ const missingRegistrationValue = ['registrar', 'createdDate', 'expiryDate'].some(field => !valuesMateriallyEqual(field, previous, current) && (!isPresent(previous[field]) || !isPresent(current[field])));
631
+ const incompleteWebFields = previous.scanDepth === 'deep' && current.scanDepth === 'deep'
632
+ && (JSON.stringify(previous.webCollectionQuality) !== JSON.stringify(current.webCollectionQuality)
633
+ || COMPARE_FIELDS.some(spec => (spec.modelGate !== 'risk' || riskModelComparable(previous, current))
634
+ && (spec.modelGate !== 'opportunity' || opportunityModelComparable(previous, current))
635
+ && !valuesMateriallyEqual(spec.field, previous, current)
636
+ && (!collectionFieldComparable(spec.field, previous)
637
+ || !collectionFieldComparable(spec.field, current))));
638
+ if (enforceCollectionQuality && (missingRegistrationValue || incompleteWebFields))
639
+ reasons.push('collection-quality');
500
640
  const hasRiskEvidence = previous.riskScore !== null || current.riskScore !== null
501
641
  || previous.riskFactors.length > 0 || current.riskFactors.length > 0;
502
642
  if (hasRiskEvidence && !riskModelComparable(previous, current))
@@ -523,6 +663,16 @@ function isPresent(value) {
523
663
  return value.length > 0;
524
664
  return true;
525
665
  }
666
+ function sameObservationHostname(previous, current) {
667
+ return previous.inputHostname === current.inputHostname
668
+ && (previous.observationHostname ?? null) === (current.observationHostname ?? null);
669
+ }
670
+ function sameObservationContext(previous, current) {
671
+ // Compact Case snapshots deliberately omit URL paths and queries. A selected
672
+ // page cannot therefore establish a comparable website target here.
673
+ return sameObservationHostname(previous, current)
674
+ && previous.webObservationMode !== 'selected_url' && current.webObservationMode !== 'selected_url';
675
+ }
526
676
  function setsEqual(a, b) {
527
677
  return JSON.stringify(a) === JSON.stringify(b);
528
678
  }
@@ -648,11 +798,9 @@ function compareField(spec, before, after) {
648
798
  return { before: b, after: a, tone: 'neutral' };
649
799
  }
650
800
  case 'factors': {
651
- // Factors are already normalized (deduped + deterministically sorted), so
652
- // a set comparison ignores input order and reports a genuine change in the
653
- // score's composition even when the total is unchanged.
654
- const b = Array.isArray(before) ? before : [];
655
- const a = Array.isArray(after) ? after : [];
801
+ // Historical identity ordering is not a change in the factor set.
802
+ const b = normalizeFactors(before);
803
+ const a = normalizeFactors(after);
656
804
  if (setsEqual(b, a))
657
805
  return null;
658
806
  return { before: b, after: a, tone: 'neutral' };
@@ -675,14 +823,23 @@ function compareField(spec, before, after) {
675
823
  * @returns {Array<{ field: string, label: string, before: unknown, after: unknown, tone: string }>}
676
824
  */
677
825
  export function compareCaseEvidence(previous, current) {
826
+ return compareEvidence(previous, current, true);
827
+ }
828
+ function compareEvidence(previous, current, enforceCollectionQuality) {
678
829
  if (!previous || !current)
679
830
  return [];
680
831
  const bothDeep = previous.scanDepth === 'deep' && current.scanDepth === 'deep';
681
832
  const comparableDepth = depthComparable(previous.scanDepth, current.scanDepth);
682
833
  const comparableRiskModel = riskModelComparable(previous, current);
683
834
  const comparableOpportunityModel = opportunityModelComparable(previous, current);
835
+ const sameHostname = sameObservationHostname(previous, current);
836
+ const sameWebContext = sameObservationContext(previous, current);
684
837
  const changes = [];
685
838
  for (const spec of COMPARE_FIELDS) {
839
+ if (spec.scope === 'hostname' && !sameHostname)
840
+ continue;
841
+ if (spec.scope === 'web' && !sameWebContext)
842
+ continue;
686
843
  if (spec.depthGate === 'both-deep' && !bothDeep)
687
844
  continue;
688
845
  if (spec.depthGate === 'comparable' && !comparableDepth)
@@ -691,6 +848,9 @@ export function compareCaseEvidence(previous, current) {
691
848
  continue;
692
849
  if (spec.modelGate === 'opportunity' && !comparableOpportunityModel)
693
850
  continue;
851
+ if (enforceCollectionQuality && (!collectionFieldComparable(spec.field, previous)
852
+ || !collectionFieldComparable(spec.field, current)))
853
+ continue;
694
854
  const result = compareField(spec, previous[spec.field], current[spec.field]);
695
855
  if (result) {
696
856
  changes.push({ field: spec.field, label: spec.label, before: result.before, after: result.after, tone: result.tone });
@@ -0,0 +1,5 @@
1
+ /** Collection completeness does not establish a usable date, comparison or conclusion. */
2
+ export function casePinHasCompleteObservation(pin) {
3
+ return pin.completeness === 'complete' && !pin.truncated
4
+ && ['complete', 'success', 'reviewed', 'not_found'].includes(pin.sourceState ?? '');
5
+ }