@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
- import { normalizeDomain } from "../cases/case-model.mjs";
1
+ import { normalizeDomain } from "../evidence/domain-name.mjs";
2
2
  import { CASE_DISPOSITIONS as CASE_DISPOSITION_OPTIONS } from "../cases/case-record-contracts.mjs";
3
- import { normalizeBulkPresentationSortKey } from "./bulk-sort.mjs";
3
+ import { BULK_SORT_KEYS, normalizeBulkPresentationSortKey } from "./bulk-sort.mjs";
4
+ import { normalizeBulkResultColumns } from "./bulk-columns.mjs";
4
5
  import { normalizeExplicitIsoTimestamp } from "../evidence/observation.mjs";
5
6
  import { assertWorkspaceDeclaredVersion, assertWorkspaceInputGraph, assertWorkspacePortableVersion, ordinaryWorkspaceRecord } from "./hostile-input.mjs";
6
- import { BULK_REVIEW_SCHEMA, BULK_REVIEW_SCHEMA_VERSION, MAX_BULK_REVIEW_NAME_LENGTH, MAX_BULK_REVIEW_PRESETS, MAX_BULK_REVIEW_ROWS, MAX_BULK_REVIEW_STORE_BYTES, } from "../contracts/workspace-portability.mjs";
7
+ import { BULK_REVIEW_SCHEMA, BULK_REVIEW_SCHEMA_VERSION, BULK_REVIEW_EXPORT_SUPPORTED_VERSIONS, MAX_BULK_REVIEW_NAME_LENGTH, MAX_BULK_REVIEW_PRESETS, MAX_BULK_REVIEW_ROWS, MAX_BULK_REVIEW_STORE_BYTES, } from "../contracts/workspace-portability.mjs";
7
8
  export { BULK_REVIEW_SCHEMA, BULK_REVIEW_SCHEMA_VERSION, MAX_BULK_REVIEW_NAME_LENGTH, MAX_BULK_REVIEW_PRESETS, MAX_BULK_REVIEW_ROWS, MAX_BULK_REVIEW_STORE_BYTES, } from "../contracts/workspace-portability.mjs";
8
9
  export const BULK_REVIEW_STATES = ['unreviewed', 'reviewing', 'reviewed', 'deferred'];
9
10
  export const BULK_SOURCE_FILTERS = ['', 'complete', 'limited', 'unrecorded'];
@@ -19,7 +20,7 @@ const LIFECYCLE_FILTERS = new Set(BULK_LIFECYCLE_FILTERS);
19
20
  const AGE_FILTERS = new Set(BULK_AGE_FILTERS);
20
21
  const MAIL_FILTERS = new Set(BULK_MAIL_FILTERS);
21
22
  const GROUPS = new Set(BULK_GROUP_OPTIONS);
22
- const SORT_KEYS = new Set(['domain', 'availability', 'confidence', 'risk', 'opportunity', 'activity', 'registrar', 'mutation']);
23
+ const SORT_KEYS = new Set(BULK_SORT_KEYS);
23
24
  const REVIEW_STATES = new Set(BULK_REVIEW_STATES);
24
25
  const CASE_DISPOSITIONS = new Set(['', 'untracked', ...CASE_DISPOSITION_OPTIONS.map((item) => item.value)]);
25
26
  const SIGNAL_FILTERS = new Set(['favicon', 'password', 'phishing', 'asset_reuse', 'idn']);
@@ -67,6 +68,7 @@ function normalizeView(raw) {
67
68
  groupBy: setValue(value.groupBy, GROUPS),
68
69
  sortKey,
69
70
  sortDirection: value.sortDirection === 1 ? 1 : -1,
71
+ columns: normalizeBulkResultColumns(value.columns),
70
72
  };
71
73
  }
72
74
  function normalizePreset(raw, fallback) {
@@ -113,6 +115,10 @@ function sourceLists(raw) {
113
115
  export function normalizeBulkReviewStore(raw) {
114
116
  assertWorkspaceInputGraph(raw, 'Bulk-review store');
115
117
  assertWorkspaceDeclaredVersion(raw, 'Bulk-review store');
118
+ const declaredVersion = record(raw).version;
119
+ if (typeof declaredVersion === 'number' && declaredVersion > BULK_REVIEW_SCHEMA_VERSION) {
120
+ throw new Error(`This Bulk review store uses newer schema ${declaredVersion}. Update the app before changing it.`);
121
+ }
116
122
  const fallback = new Date(0).toISOString();
117
123
  const source = sourceLists(raw);
118
124
  const presets = new Map();
@@ -154,7 +160,7 @@ export function enforceBulkReviewBudget(raw) {
154
160
  while (byteLength(JSON.stringify(store)) > MAX_BULK_REVIEW_STORE_BYTES && store.presets.length > 1)
155
161
  store.presets.pop();
156
162
  if (byteLength(JSON.stringify(store)) > MAX_BULK_REVIEW_STORE_BYTES) {
157
- throw new Error('Bulk review preferences exceed the browser-local storage limit.');
163
+ throw new Error('Bulk review preferences exceed the workspace storage limit.');
158
164
  }
159
165
  return store;
160
166
  }
@@ -217,7 +223,7 @@ export function mergeBulkReviewStores(localRaw, importedRaw) {
217
223
  if (importedRecord.schema !== BULK_REVIEW_SCHEMA) {
218
224
  throw new Error('This file is not a WHOISleuth Bulk review export.');
219
225
  }
220
- if (importedRecord.version !== BULK_REVIEW_SCHEMA_VERSION) {
226
+ if (!BULK_REVIEW_EXPORT_SUPPORTED_VERSIONS.some((version) => version === importedRecord.version)) {
221
227
  if (typeof importedRecord.version === 'number' && importedRecord.version > BULK_REVIEW_SCHEMA_VERSION) {
222
228
  throw new Error(`This Bulk review export uses newer schema ${importedRecord.version}. Update the app before importing it.`);
223
229
  }
@@ -229,37 +235,38 @@ export function mergeBulkReviewStores(localRaw, importedRaw) {
229
235
  const rows = new Map(local.rows.map((item) => [item.domain, item]));
230
236
  let added = 0;
231
237
  let updated = 0;
232
- let skipped = 0;
233
- for (const candidate of imported.presets) {
234
- const existing = presets.get(candidate.id);
235
- if (!existing) {
236
- presets.set(candidate.id, candidate);
237
- added += 1;
238
- }
239
- else if (candidate.updatedAt > existing.updatedAt) {
240
- presets.set(candidate.id, candidate);
241
- updated += 1;
242
- }
243
- else {
238
+ const supplied = sourceLists(importedRaw);
239
+ let skipped = supplied.presets.length + supplied.rows.length - imported.presets.length - imported.rows.length;
240
+ let capacitySkipped = 0;
241
+ let bytes = byteLength(JSON.stringify(local));
242
+ if (bytes > MAX_BULK_REVIEW_STORE_BYTES)
243
+ throw new Error('Existing Bulk review preferences exceed the workspace storage limit; no imported changes were applied.');
244
+ function admit(target, key, candidate, maximum) {
245
+ const existing = target.get(key);
246
+ if (existing && candidate.updatedAt <= existing.updatedAt) {
244
247
  skipped += 1;
248
+ return;
245
249
  }
246
- }
247
- for (const candidate of imported.rows) {
248
- const existing = rows.get(candidate.domain);
249
- if (!existing) {
250
- rows.set(candidate.domain, candidate);
251
- added += 1;
252
- }
253
- else if (candidate.updatedAt > existing.updatedAt) {
254
- rows.set(candidate.domain, candidate);
255
- updated += 1;
256
- }
257
- else {
250
+ const delta = byteLength(JSON.stringify(candidate)) - (existing ? byteLength(JSON.stringify(existing)) : 0)
251
+ + (!existing && target.size ? 1 : 0);
252
+ if ((!existing && target.size >= maximum) || bytes + delta > MAX_BULK_REVIEW_STORE_BYTES) {
258
253
  skipped += 1;
254
+ capacitySkipped += 1;
255
+ return;
259
256
  }
257
+ target.set(key, candidate);
258
+ bytes += delta;
259
+ if (existing)
260
+ updated += 1;
261
+ else
262
+ added += 1;
260
263
  }
264
+ for (const candidate of imported.presets)
265
+ admit(presets, candidate.id, candidate, MAX_BULK_REVIEW_PRESETS);
266
+ for (const candidate of imported.rows)
267
+ admit(rows, candidate.domain, candidate, MAX_BULK_REVIEW_ROWS);
261
268
  return {
262
- store: enforceBulkReviewBudget({
269
+ store: normalizeBulkReviewStore({
263
270
  schema: BULK_REVIEW_SCHEMA,
264
271
  version: BULK_REVIEW_SCHEMA_VERSION,
265
272
  presets: [...presets.values()],
@@ -268,5 +275,6 @@ export function mergeBulkReviewStores(localRaw, importedRaw) {
268
275
  added,
269
276
  updated,
270
277
  skipped,
278
+ reason: capacitySkipped ? `${capacitySkipped} imported Bulk review records did not fit the remaining storage capacity. Existing local records are preserved.` : '',
271
279
  };
272
280
  }
@@ -1,9 +1,12 @@
1
1
  // Pure, bounded persistence model for analyst-saved Bulk investigations.
2
2
  // Sessions retain compact derived rows and source states, never raw registry,
3
3
  // WHOIS, HTTP, TLS, page, or provider payloads.
4
- import { normalizeDomain } from "../cases/case-model.mjs";
4
+ import { normalizeDomain } from "../evidence/domain-name.mjs";
5
5
  import { normalizeCaaCritical } from "./dns-record-normalization.mjs";
6
6
  import { normalizeExplicitIsoTimestamp } from "../evidence/observation.mjs";
7
+ import { normalizeWebCollectionQuality, webCollectionAllowsComparison } from "../evidence/collection-quality.mjs";
8
+ import { RELATIONSHIP_EVIDENCE_VERSION, RELATIONSHIP_SOURCE_PROVENANCE_VERSION, SUPPORTED_RELATIONSHIP_EVIDENCE_VERSIONS } from "../contracts/offline-comparison.mjs";
9
+ import { normalizeRelationshipSourceProjection } from "../comparison/relationship-provenance.mjs";
7
10
  import { assertWorkspaceDeclaredVersion, assertWorkspaceInputGraph, assertWorkspacePortableVersion, ordinaryWorkspaceRecord } from "./hostile-input.mjs";
8
11
  import { BULK_SESSION_SCHEMA, BULK_SESSION_SCHEMA_VERSION, MAX_BULK_PROFILE_CONTEXT_LIMITATION_LENGTH, MAX_BULK_SESSIONS, MAX_BULK_SESSION_ARRAY_VALUES, MAX_BULK_SESSION_NAME_LENGTH, MAX_BULK_SESSION_ROWS, MAX_BULK_SESSION_SOURCES, MAX_BULK_SESSION_STORE_BYTES, MAX_BULK_SESSION_TEXT_LENGTH, SUPPORTED_BULK_SESSION_SCHEMA_VERSIONS, } from "../contracts/workspace-portability.mjs";
9
12
  export { BULK_SESSION_SCHEMA, BULK_SESSION_SCHEMA_VERSION, MAX_BULK_PROFILE_CONTEXT_LIMITATION_LENGTH, MAX_BULK_SESSIONS, MAX_BULK_SESSION_ARRAY_VALUES, MAX_BULK_SESSION_NAME_LENGTH, MAX_BULK_SESSION_ROWS, MAX_BULK_SESSION_SOURCES, MAX_BULK_SESSION_STORE_BYTES, MAX_BULK_SESSION_TEXT_LENGTH, SUPPORTED_BULK_SESSION_SCHEMA_VERSIONS, } from "../contracts/workspace-portability.mjs";
@@ -92,7 +95,7 @@ export function normalizeBulkProfileContext(value, fallbackLimitation = BULK_PRO
92
95
  }
93
96
  return unavailableBulkProfileContext(profileContextLimitation(item?.limitation, fallbackLimitation));
94
97
  }
95
- function sameProfileContext(left, right) {
98
+ export function bulkProfileContextsMatch(left, right) {
96
99
  return left.sourceState === right.sourceState
97
100
  && left.activeProfileId === right.activeProfileId
98
101
  && left.profileUpdatedAt === right.profileUpdatedAt
@@ -102,7 +105,7 @@ export function summarizeBulkProfileContexts(rows) {
102
105
  const first = rows[0]?.profileContext;
103
106
  if (!first)
104
107
  return unavailableBulkProfileContext('This saved Bulk session contains no settled row with profile-context provenance.');
105
- if (rows.every((row) => sameProfileContext(first, row.profileContext)))
108
+ if (rows.every((row) => bulkProfileContextsMatch(first, row.profileContext)))
106
109
  return { ...first };
107
110
  return {
108
111
  sourceState: 'mixed',
@@ -175,7 +178,7 @@ function normalizeSourceCoverage(value) {
175
178
  if (!SOURCE_RE.test(source) || !SOURCE_STATES.has(state) || seen.has(source))
176
179
  continue;
177
180
  seen.add(source);
178
- output.push({ source, state: state });
181
+ output.push({ source, state: state, observedAt: normalizeExplicitIsoTimestamp(item?.observedAt) });
179
182
  if (output.length >= MAX_BULK_SESSION_SOURCES)
180
183
  break;
181
184
  }
@@ -198,8 +201,11 @@ function nullableHash(value, expression) {
198
201
  }
199
202
  function normalizeRelationship(value) {
200
203
  const item = record(value);
204
+ if (item?.version !== undefined && !SUPPORTED_RELATIONSHIP_EVIDENCE_VERSIONS.some(version => version === item.version)) {
205
+ throw new TypeError('This Bulk relationship projection uses an unsupported version. No source evidence was interpreted.');
206
+ }
201
207
  return {
202
- version: 2,
208
+ version: RELATIONSHIP_EVIDENCE_VERSION,
203
209
  nameservers: boundedStrings(item?.nameservers, 20),
204
210
  ipAddresses: boundedStrings(item?.ipAddresses, 50),
205
211
  trackingIdentifiers: boundedTrackingIdentifiers(item?.trackingIdentifiers),
@@ -207,6 +213,7 @@ function normalizeRelationship(value) {
207
213
  faviconHash: nullableHash(item?.faviconHash, HASH_64_RE),
208
214
  faviconPHash: nullableHash(item?.faviconPHash, PHASH_RE),
209
215
  certificateFingerprint: nullableHash(item?.certificateFingerprint, HASH_64_RE),
216
+ sourceEvidence: Number(item?.version) >= RELATIONSHIP_SOURCE_PROVENANCE_VERSION ? normalizeRelationshipSourceProjection(item?.sourceEvidence) : {},
210
217
  truncated: item?.truncated === true,
211
218
  };
212
219
  }
@@ -270,14 +277,14 @@ export function normalizeBulkComparisonEvidence(value) {
270
277
  },
271
278
  };
272
279
  }
273
- export function normalizeBulkSessionResult(value) {
280
+ export function normalizeBulkSessionResult(value, inheritedProfileContext) {
274
281
  const item = record(value);
275
282
  const domain = normalizeDomain(item?.domain);
276
283
  const status = boundedText(item?.status, 20);
277
284
  const scanDepth = boundedText(item?.scanDepth, 10);
278
285
  if (!item || !domain || !RESULT_STATES.has(status) || !MODES.has(scanDepth))
279
286
  return null;
280
- const rawProfileContext = record(item.profileContext);
287
+ const rawProfileContext = record(Object.hasOwn(item, 'profileContext') ? item.profileContext : inheritedProfileContext);
281
288
  if (!rawProfileContext || !['ready', 'unavailable'].includes(String(rawProfileContext.sourceState)))
282
289
  return null;
283
290
  const profileContext = normalizeBulkProfileContext(rawProfileContext);
@@ -298,6 +305,7 @@ export function normalizeBulkSessionResult(value) {
298
305
  const profileClaimsUsable = profileContextReady && !impossibleNoProfileClaim;
299
306
  if (!profileClaimsUsable || readyWithoutActiveProfile)
300
307
  relationship.officialAssetHosts = [];
308
+ const webCollectionQuality = normalizeWebCollectionQuality(item.webCollectionQuality, scanDepth);
301
309
  return {
302
310
  domain,
303
311
  status: status,
@@ -311,6 +319,8 @@ export function normalizeBulkSessionResult(value) {
311
319
  trusted: profileClaimsUsable && !readyWithoutActiveProfile && TRUST_STATES.has(trusted) ? trusted : null,
312
320
  error: boundedText(item.error),
313
321
  scanDepth: scanDepth,
322
+ ...(webCollectionQuality ? { webCollectionQuality } : {}),
323
+ observedAt: normalizeExplicitIsoTimestamp(item.observedAt),
314
324
  createdDate: boundedText(item.createdDate, 64) || null,
315
325
  expiryDate: boundedText(item.expiryDate, 64) || null,
316
326
  privacyProtected: nullableBoolean(item.privacyProtected),
@@ -343,7 +353,7 @@ export function normalizeBulkSessionResult(value) {
343
353
  profileContext,
344
354
  };
345
355
  }
346
- export function normalizeBulkSession(value) {
356
+ export function normalizeBulkSession(value, sourceStoreVersion) {
347
357
  const item = record(value);
348
358
  const id = boundedText(item?.id, 128);
349
359
  const name = boundedText(item?.name, MAX_BULK_SESSION_NAME_LENGTH);
@@ -367,24 +377,47 @@ export function normalizeBulkSession(value) {
367
377
  return null;
368
378
  if (!Array.isArray(item.results))
369
379
  return null;
380
+ const rawProfileContext = record(item.profileContext);
381
+ if (!rawProfileContext)
382
+ return null;
383
+ const declaredProfileContext = normalizeBulkProfileContext(rawProfileContext);
384
+ // Only an explicitly versioned current document may omit identical row
385
+ // context. Bare runtime rows and public schema 4 must carry their own context;
386
+ // an explicit invalid/null row value never falls back to the session.
387
+ const inheritedProfileContext = sourceStoreVersion !== undefined && sourceStoreVersion >= 5
388
+ && declaredProfileContext.sourceState !== 'mixed'
389
+ && rawProfileContext.sourceState === declaredProfileContext.sourceState
390
+ ? declaredProfileContext
391
+ : undefined;
370
392
  const allowed = new Set(domains);
371
393
  const results = [];
372
394
  const seen = new Set();
373
395
  for (const candidate of Array.isArray(item.results) ? item.results.slice(0, MAX_BULK_SESSION_ROWS * 2) : []) {
374
- const result = normalizeBulkSessionResult(candidate);
396
+ if (sourceStoreVersion !== undefined && sourceStoreVersion < 6 && record(candidate)?.webCollectionQuality !== undefined) {
397
+ throw new TypeError('Web collection quality requires Bulk schema 6 or later; historical evidence was not reinterpreted.');
398
+ }
399
+ const publicLegacyRow = sourceStoreVersion === 4;
400
+ if (publicLegacyRow && Number(record(record(candidate)?.relationship)?.version) >= RELATIONSHIP_SOURCE_PROVENANCE_VERSION) {
401
+ throw new TypeError('Bulk schema 4 cannot contain newer relationship source evidence; no evidence was interpreted.');
402
+ }
403
+ const result = normalizeBulkSessionResult(candidate, inheritedProfileContext);
375
404
  if (!result || !allowed.has(result.domain) || seen.has(result.domain)) {
376
405
  return null;
377
406
  }
407
+ // Public schema 4 never recorded a row observation time or qualified
408
+ // relationship sources. Ignore added fields rather than backdating them.
409
+ if (publicLegacyRow) {
410
+ result.observedAt = null;
411
+ result.relationship.sourceEvidence = {};
412
+ result.sourceCoverage = result.sourceCoverage.map((source) => ({ ...source, observedAt: null }));
413
+ }
378
414
  seen.add(result.domain);
379
415
  results.push(result);
380
416
  }
381
417
  if ((state === 'complete') !== (results.length === domains.length))
382
418
  return null;
383
419
  const profileContext = summarizeBulkProfileContexts(results);
384
- if (!record(item.profileContext))
385
- return null;
386
- const declaredProfileContext = normalizeBulkProfileContext(item.profileContext);
387
- if (!sameProfileContext(declaredProfileContext, profileContext))
420
+ if (!bulkProfileContextsMatch(declaredProfileContext, profileContext))
388
421
  return null;
389
422
  return {
390
423
  id,
@@ -407,7 +440,9 @@ export function bulkSessionStoreVersion(raw) {
407
440
  : null;
408
441
  }
409
442
  export function normalizeBulkSessionStore(raw) {
410
- assertWorkspaceInputGraph(raw, 'Bulk-session store');
443
+ // Preparation may combine an existing store and one incoming store before
444
+ // whole-session eviction. The persisted document still has its 4 MiB cap.
445
+ assertWorkspaceInputGraph(raw, 'Bulk-session store', { maximumBytes: MAX_BULK_SESSION_STORE_BYTES * 2 });
411
446
  assertWorkspaceDeclaredVersion(raw, 'Bulk-session store');
412
447
  const value = record(raw);
413
448
  if (value?.schema === BULK_SESSION_SCHEMA
@@ -423,7 +458,7 @@ export function normalizeBulkSessionStore(raw) {
423
458
  : [];
424
459
  const byId = new Map();
425
460
  for (const candidate of candidates.slice(0, MAX_BULK_SESSIONS * 4)) {
426
- const session = normalizeBulkSession(candidate);
461
+ const session = normalizeBulkSession(candidate, bulkSessionStoreVersion(raw) ?? undefined);
427
462
  if (!session)
428
463
  continue;
429
464
  const existing = byId.get(session.id);
@@ -438,8 +473,21 @@ export function normalizeBulkSessionStore(raw) {
438
473
  .slice(0, MAX_BULK_SESSIONS),
439
474
  };
440
475
  }
476
+ /** Current wire representation of an already-normalized session. */
477
+ export function bulkSessionStorageValue(session) {
478
+ return {
479
+ ...session,
480
+ results: session.results.map(({ profileContext, ...row }) => bulkProfileContextsMatch(profileContext, session.profileContext)
481
+ ? row
482
+ : { ...row, profileContext }),
483
+ };
484
+ }
485
+ /** Current wire bytes for sessions already admitted by the collection owner. */
486
+ export function serializeNormalizedBulkSessions(sessions) {
487
+ return JSON.stringify({ schema: BULK_SESSION_SCHEMA, version: BULK_SESSION_SCHEMA_VERSION, sessions: sessions.map(bulkSessionStorageValue) });
488
+ }
441
489
  export function serializeBulkSessionStore(raw) {
442
- return JSON.stringify(normalizeBulkSessionStore(raw));
490
+ return serializeNormalizedBulkSessions(normalizeBulkSessionStore(raw).sessions);
443
491
  }
444
492
  function byteLength(value) {
445
493
  return new TextEncoder().encode(value).byteLength;
@@ -447,11 +495,16 @@ function byteLength(value) {
447
495
  export function enforceBulkSessionStoreBudget(raw) {
448
496
  const store = normalizeBulkSessionStore(raw);
449
497
  let pruned = 0;
450
- while (byteLength(JSON.stringify(store)) > MAX_BULK_SESSION_STORE_BYTES && store.sessions.length > 1) {
498
+ const sessionBytes = store.sessions.map((session) => byteLength(JSON.stringify(bulkSessionStorageValue(session))));
499
+ let bytes = byteLength(JSON.stringify({ ...store, sessions: [] }))
500
+ + sessionBytes.reduce((total, size) => total + size, 0)
501
+ + Math.max(0, sessionBytes.length - 1);
502
+ while (bytes > MAX_BULK_SESSION_STORE_BYTES && store.sessions.length > 1) {
503
+ bytes -= sessionBytes.pop() + 1;
451
504
  store.sessions.pop();
452
505
  pruned += 1;
453
506
  }
454
- if (byteLength(JSON.stringify(store)) > MAX_BULK_SESSION_STORE_BYTES) {
507
+ if (bytes > MAX_BULK_SESSION_STORE_BYTES) {
455
508
  throw new Error('This Bulk session exceeds the 4 MiB browser-local session limit.');
456
509
  }
457
510
  return { store, pruned };
@@ -474,9 +527,29 @@ export function upsertBulkSession(raw, sessionRaw) {
474
527
  sessions: bounded.store.sessions,
475
528
  session,
476
529
  added: index < 0,
477
- pruned: bounded.pruned,
530
+ pruned: Math.max(0, next.length - bounded.store.sessions.length),
478
531
  };
479
532
  }
533
+ /** Ephemeral retention preview, never part of the stored or exported format. */
534
+ export function prepareBulkSessionSave(raw, sessionRaw) {
535
+ const current = normalizeBulkSessionStore(raw).sessions;
536
+ const result = upsertBulkSession(current, sessionRaw);
537
+ if (!result.sessions.some((session) => session.id === result.session.id)) {
538
+ throw new Error('This Bulk session cannot fit alongside the saved sessions. Export or remove saved sessions before trying again. Nothing was changed.');
539
+ }
540
+ const retained = new Set(result.sessions.map((session) => session.id));
541
+ const removed = current.filter((session) => !retained.has(session.id));
542
+ return {
543
+ ...result,
544
+ current,
545
+ removed,
546
+ removedBytes: removed.reduce((total, session) => total + byteLength(JSON.stringify(bulkSessionStorageValue(session))), 0),
547
+ };
548
+ }
549
+ /** Confirmation applies to the exact records displayed, not just their IDs. */
550
+ export function bulkSessionSavePreviewIsCurrent(raw, preview) {
551
+ return serializeBulkSessionStore(raw) === serializeNormalizedBulkSessions(preview.current);
552
+ }
480
553
  export function deleteBulkSession(raw, idRaw) {
481
554
  const id = boundedText(idRaw, 128);
482
555
  return normalizeBulkSessionStore({
@@ -491,8 +564,10 @@ function sourceStateMap(value) {
491
564
  export function areBulkRiskScoresComparable(previous, current) {
492
565
  return previous.profileContext.sourceState === 'ready'
493
566
  && current.profileContext.sourceState === 'ready'
494
- && sameProfileContext(previous.profileContext, current.profileContext)
567
+ && bulkProfileContextsMatch(previous.profileContext, current.profileContext)
495
568
  && previous.scanDepth === current.scanDepth
569
+ && webCollectionAllowsComparison('riskScore', previous.webCollectionQuality, previous.scanDepth)
570
+ && webCollectionAllowsComparison('riskScore', current.webCollectionQuality, current.scanDepth)
496
571
  && previous.risk !== null
497
572
  && current.risk !== null
498
573
  && previous.riskModelVersion !== null
@@ -507,7 +582,9 @@ function resultChanges(previous, current) {
507
582
  }
508
583
  if (previous.registrar !== current.registrar)
509
584
  changes.push(`Registrar: ${previous.registrar} → ${current.registrar}`);
510
- if (previous.activity !== current.activity)
585
+ if (previous.activity !== current.activity
586
+ && webCollectionAllowsComparison('activityStatus', previous.webCollectionQuality, previous.scanDepth)
587
+ && webCollectionAllowsComparison('activityStatus', current.webCollectionQuality, current.scanDepth))
511
588
  changes.push(`Website: ${previous.activity} → ${current.activity}`);
512
589
  const previousComparison = previous.comparisonEvidence;
513
590
  const currentComparison = current.comparisonEvidence;
@@ -577,7 +654,7 @@ export function compareBulkSessions(baselineRaw, currentRaw) {
577
654
  'A source-state change may reflect collection availability rather than a change to the domain.',
578
655
  'A missing row means it was not completed in that saved session; it does not establish domain removal.',
579
656
  ...(riskIncomparable
580
- ? ['Risk deltas are omitted unless both rows retain the same ready Brand Profile provenance, scan depth, and versioned Risk model.']
657
+ ? ['Risk deltas are omitted unless both rows retain the same ready Brand Profile provenance, scan depth, versioned Risk model and complete web collection for Deep observations.']
581
658
  : []),
582
659
  ],
583
660
  };
@@ -588,7 +665,7 @@ export function buildBulkSessionExport(sessions, generatedAt = new Date().toISOS
588
665
  schema: BULK_SESSION_SCHEMA,
589
666
  version: BULK_SESSION_SCHEMA_VERSION,
590
667
  generatedAt: timestamp(generatedAt, new Date().toISOString()),
591
- sessions: store.sessions,
668
+ sessions: store.sessions.map(bulkSessionStorageValue),
592
669
  limitations: [
593
670
  'Compact Bulk results and source states only; raw source payloads and contact records are excluded.',
594
671
  'Import is non-destructive and does not resume network collection automatically.',
@@ -596,8 +673,8 @@ export function buildBulkSessionExport(sessions, generatedAt = new Date().toISOS
596
673
  };
597
674
  }
598
675
  export function mergeBulkSessions(localRaw, importedRaw) {
599
- assertWorkspaceInputGraph(localRaw, 'Local Bulk-session store');
600
- assertWorkspaceInputGraph(importedRaw, 'Imported Bulk-session document');
676
+ assertWorkspaceInputGraph(localRaw, 'Local Bulk-session store', { maximumBytes: MAX_BULK_SESSION_STORE_BYTES * 2 });
677
+ assertWorkspaceInputGraph(importedRaw, 'Imported Bulk-session document', { maximumBytes: MAX_BULK_SESSION_STORE_BYTES * 2 });
601
678
  assertWorkspacePortableVersion(importedRaw, BULK_SESSION_SCHEMA_VERSION, 'Imported Bulk-session document');
602
679
  const imported = record(importedRaw);
603
680
  if (!imported || imported.schema !== BULK_SESSION_SCHEMA || !Array.isArray(imported.sessions)) {
@@ -614,7 +691,7 @@ export function mergeBulkSessions(localRaw, importedRaw) {
614
691
  let updated = 0;
615
692
  let skipped = 0;
616
693
  for (const candidate of imported.sessions.slice(0, MAX_BULK_SESSIONS * 4)) {
617
- const normalized = normalizeBulkSession(candidate);
694
+ const normalized = normalizeBulkSession(candidate, Number(imported.version));
618
695
  if (!normalized) {
619
696
  skipped += 1;
620
697
  continue;
@@ -649,8 +726,16 @@ export function mergeBulkSessions(localRaw, importedRaw) {
649
726
  skipped += 1;
650
727
  continue;
651
728
  }
729
+ // An import is additive: count and byte pressure must not silently remove
730
+ // a local session (or an earlier admitted import). The preview reports
731
+ // the skipped candidate and the analyst can export/remove data explicitly.
732
+ const merged = upsertBulkSession(sessions, session);
733
+ if (merged.pruned > 0 || !merged.sessions.some((item) => item.id === session.id)) {
734
+ skipped += 1;
735
+ continue;
736
+ }
652
737
  added += 1;
653
- sessions = upsertBulkSession(sessions, session).sessions;
738
+ sessions = merged.sessions;
654
739
  }
655
740
  const bounded = enforceBulkSessionStoreBudget({
656
741
  schema: BULK_SESSION_SCHEMA,
@@ -1,3 +1,4 @@
1
+ export const BULK_SORT_KEYS = ['domain', 'availability', 'confidence', 'risk', 'opportunity', 'activity', 'registrar', 'mutation'];
1
2
  /** Opportunity remains a retained data/export field, but is no longer a Bulk presentation sort. */
2
3
  export function normalizeBulkPresentationSortKey(key) {
3
4
  return key === 'opportunity' ? 'risk' : key;
@@ -1,7 +1,7 @@
1
1
  // Pure, framework-neutral browser-local campaign model. Campaigns deliberately
2
2
  // reference normalized case domains rather than copying case evidence, notes,
3
3
  // or mutable case ids. The browser wrapper owns persistence and downloads.
4
- import { normalizeDomain } from "../cases/case-model.mjs";
4
+ import { normalizeDomain } from "../evidence/domain-name.mjs";
5
5
  import { normalizeExplicitIsoTimestamp } from "../evidence/observation.mjs";
6
6
  import { assertWorkspaceDeclaredVersion, assertWorkspaceInputGraph, assertWorkspacePortableVersion, ordinaryWorkspaceRecord } from "./hostile-input.mjs";
7
7
  import { CAMPAIGN_SCHEMA, CAMPAIGN_SCHEMA_VERSION, MAX_CAMPAIGNS, MAX_CAMPAIGN_DESCRIPTION_LENGTH, MAX_CAMPAIGN_DOMAINS, MAX_CAMPAIGN_INPUT_RECORDS, MAX_CAMPAIGN_NAME_LENGTH, MAX_CAMPAIGN_STORE_BYTES, } from "../contracts/workspace-portability.mjs";
@@ -0,0 +1,102 @@
1
+ import { CASE_VIEWS_SCHEMA, CASE_VIEWS_VERSION, CASE_VIEW_SORTS, MAX_CASE_VIEWS, MAX_CASE_VIEWS_BYTES, MAX_CASE_VIEW_NAME_LENGTH, MAX_CASE_VIEW_SEARCH_LENGTH, } from "../contracts/case-views-contract.mjs";
2
+ import { isValidDisposition, isValidStatus } from "../cases/case-record-decisions.mjs";
3
+ import { normalizeExplicitIsoTimestamp } from "../evidence/observation.mjs";
4
+ import { assertWorkspaceInputGraph, ordinaryWorkspaceRecord } from "./hostile-input.mjs";
5
+ const encoder = new TextEncoder();
6
+ const control = /[\u0000-\u001f\u007f]/u;
7
+ function object(raw, keys, label) {
8
+ const value = ordinaryWorkspaceRecord(raw, label);
9
+ if (!value || Object.keys(value).length !== keys.length || Object.keys(value).some(key => !keys.includes(key)))
10
+ throw new Error(`${label} has missing or unsupported fields.`);
11
+ return value;
12
+ }
13
+ function text(raw, maximum, label) {
14
+ if (typeof raw !== 'string' || raw.length > maximum || control.test(raw))
15
+ throw new Error(`${label} is invalid or exceeds ${maximum} characters.`);
16
+ return raw;
17
+ }
18
+ function time(raw) {
19
+ const value = normalizeExplicitIsoTimestamp(raw);
20
+ if (!value)
21
+ throw new Error('A saved Case view has an invalid timestamp.');
22
+ return value;
23
+ }
24
+ export function normalizeCaseViewFilters(raw) {
25
+ const value = object(raw, ['status', 'disposition', 'search', 'sort'], 'Case filters');
26
+ if (value.status !== '' && !isValidStatus(value.status))
27
+ throw new Error('Unsupported Case status filter.');
28
+ if (value.disposition !== '' && !isValidDisposition(value.disposition))
29
+ throw new Error('Unsupported Case disposition filter.');
30
+ const sort = CASE_VIEW_SORTS.find(option => option.value === value.sort)?.value;
31
+ if (!sort)
32
+ throw new Error('Unsupported Case sort order.');
33
+ return { status: value.status, disposition: value.disposition, search: text(value.search, MAX_CASE_VIEW_SEARCH_LENGTH, 'Case search'), sort };
34
+ }
35
+ export function emptyCaseViewsStore() {
36
+ return { schema: CASE_VIEWS_SCHEMA, version: CASE_VIEWS_VERSION, views: [] };
37
+ }
38
+ export function caseViewsStoreVersion(raw) {
39
+ const value = ordinaryWorkspaceRecord(raw, 'Case views');
40
+ return value?.schema === CASE_VIEWS_SCHEMA && Number.isSafeInteger(value.version) ? value.version : null;
41
+ }
42
+ /** Strict, lossless admission: invalid views and capacity excess never prune saved work. */
43
+ export function normalizeCaseViewsStore(raw) {
44
+ if (raw === null || raw === undefined)
45
+ return emptyCaseViewsStore();
46
+ assertWorkspaceInputGraph(raw, 'Saved Case views', { maximumBytes: MAX_CASE_VIEWS_BYTES });
47
+ const store = object(raw, ['schema', 'version', 'views'], 'Case views');
48
+ if (store.schema !== CASE_VIEWS_SCHEMA || store.version !== CASE_VIEWS_VERSION)
49
+ throw new Error('Unsupported saved Case view format. No views were changed.');
50
+ if (!Array.isArray(store.views) || store.views.length > MAX_CASE_VIEWS)
51
+ throw new Error(`Keep at most ${MAX_CASE_VIEWS} saved Case views. Delete unwanted views explicitly before adding more.`);
52
+ const ids = new Set();
53
+ const views = [];
54
+ let bytes = encoder.encode(JSON.stringify(emptyCaseViewsStore())).byteLength;
55
+ for (const candidate of store.views) {
56
+ const value = object(candidate, ['id', 'name', 'filters', 'createdAt', 'updatedAt'], 'Saved Case view');
57
+ if (typeof value.id !== 'string' || !/^[A-Za-z0-9_-]{1,128}$/u.test(value.id) || ids.has(value.id))
58
+ throw new Error('Invalid or duplicate saved Case view identity.');
59
+ ids.add(value.id);
60
+ const name = text(value.name, MAX_CASE_VIEW_NAME_LENGTH, 'Case view name').trim();
61
+ if (!name)
62
+ throw new Error('Name this Case view.');
63
+ const createdAt = time(value.createdAt);
64
+ const updatedAt = time(value.updatedAt);
65
+ if (Date.parse(updatedAt) < Date.parse(createdAt))
66
+ throw new Error('A saved Case view update precedes its creation.');
67
+ const view = { id: value.id, name, filters: normalizeCaseViewFilters(value.filters), createdAt, updatedAt };
68
+ bytes += encoder.encode(JSON.stringify(view)).byteLength + (views.length ? 1 : 0);
69
+ if (bytes > MAX_CASE_VIEWS_BYTES)
70
+ throw new Error('Saved Case views exceed their storage limit. Existing views were not removed.');
71
+ views.push(view);
72
+ }
73
+ return { ...emptyCaseViewsStore(), views };
74
+ }
75
+ export function serializeCaseViewsStore(raw) {
76
+ return JSON.stringify(normalizeCaseViewsStore(raw));
77
+ }
78
+ export function serialiseCaseViewsJson(raw) {
79
+ return `${JSON.stringify(normalizeCaseViewsStore(raw), null, 2)}\n`;
80
+ }
81
+ export function mergeCaseViews(local, incoming) {
82
+ const current = normalizeCaseViewsStore(local);
83
+ const imported = normalizeCaseViewsStore(incoming);
84
+ const views = new Map(current.views.map(view => [view.id, view]));
85
+ let added = 0, updated = 0, skipped = 0;
86
+ for (const view of imported.views) {
87
+ const existing = views.get(view.id);
88
+ if (!existing) {
89
+ views.set(view.id, view);
90
+ added += 1;
91
+ }
92
+ else if (JSON.stringify(existing) === JSON.stringify(view))
93
+ continue;
94
+ else if (Date.parse(view.updatedAt) > Date.parse(existing.updatedAt) && view.createdAt === existing.createdAt) {
95
+ views.set(view.id, view);
96
+ updated += 1;
97
+ }
98
+ else
99
+ skipped += 1;
100
+ }
101
+ return { store: normalizeCaseViewsStore({ ...current, views: [...views.values()] }), added, updated, skipped };
102
+ }
@@ -1,7 +1,7 @@
1
1
  // Pure browser-local custom detection-rule model. Rules are deliberately a
2
2
  // small structured language: field names and operators come from allowlists,
3
3
  // so imported rules cannot execute code or reach outside bounded case evidence.
4
- import { latestCaseEvidence } from "../cases/case-model.mjs";
4
+ import { latestCaseEvidence } from "../cases/case-evidence-model.mjs";
5
5
  import { CASE_DISPOSITIONS, CASE_STATUSES } from "../cases/case-record-contracts.mjs";
6
6
  import { assertWorkspaceDeclaredVersion, assertWorkspaceInputGraph, assertWorkspacePortableVersion, ordinaryWorkspaceRecord } from "./hostile-input.mjs";
7
7
  import { DETECTION_RULE_SCHEMA, DETECTION_RULE_SCHEMA_VERSION, MAX_CONDITION_VALUE_LENGTH, MAX_CUSTOM_RISK_TOTAL, MAX_DETECTION_RULES, MAX_RULE_CONDITIONS, MAX_RULE_INPUT_RECORDS, MAX_RULE_NAME_LENGTH, MAX_RULE_RISK_DELTA, MAX_RULE_STORE_BYTES, MAX_RULE_TAG_LENGTH, } from "../contracts/workspace-portability.mjs";