@slicedearth/whoisleuth-cli 2.2.0 → 2.4.0

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 (282) hide show
  1. package/NOTICE +6 -0
  2. package/README.md +20 -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/arguments.mjs +454 -1748
  7. package/cli/artifact-validation/case-response.mjs +66 -35
  8. package/cli/artifact-validation/investigation-capsule.mjs +52 -31
  9. package/cli/artifact-validation/lookup-evidence.mjs +12 -6
  10. package/cli/artifact-validation/signed-review.mjs +112 -72
  11. package/cli/artifact-validation/structure-primitives.mjs +1 -105
  12. package/cli/artifact-verify.mjs +90 -24
  13. package/cli/assurance-command-runner.mjs +39 -4
  14. package/cli/bagit-folder.mjs +57 -0
  15. package/cli/bagit-review.mjs +23 -0
  16. package/cli/bounded-json.mjs +1 -1
  17. package/cli/bulk-checkpoint.mjs +34 -3
  18. package/cli/bulk-command-runner.mjs +2 -0
  19. package/cli/bulk-output.mjs +29 -3
  20. package/cli/case-command.mjs +195 -0
  21. package/cli/case-pack.mjs +66 -111
  22. package/cli/command-argument-grammar.mjs +215 -0
  23. package/cli/command-reference.mjs +342 -285
  24. package/cli/completion.mjs +303 -80
  25. package/cli/config-profile.mjs +7 -5
  26. package/cli/ct-event-intake.mjs +12 -12
  27. package/cli/discover.mjs +4 -3
  28. package/cli/discovery-observation-snapshot.mjs +59 -18
  29. package/cli/discovery-scan-command-runner.mjs +21 -0
  30. package/cli/discovery-scan.mjs +13 -4
  31. package/cli/discovery-workflow.mjs +1 -1
  32. package/cli/domain-control-monitor.mjs +50 -20
  33. package/cli/domain-control-observations.mjs +149 -102
  34. package/cli/errors.mjs +33 -1
  35. package/cli/evidence-command-runner.mjs +16 -5
  36. package/cli/evidence-signing.mjs +2 -2
  37. package/cli/external-observation-mapping.mjs +7 -8
  38. package/cli/formatters/terminal-lookup.mjs +18 -46
  39. package/cli/formatters/terminal-shared.mjs +5 -3
  40. package/cli/formatters/terminal-technology.mjs +114 -0
  41. package/cli/inline-command-families.mjs +2 -0
  42. package/cli/input.mjs +116 -0
  43. package/cli/interactive-launcher.mjs +2 -160
  44. package/cli/interchange-report.mjs +18 -8
  45. package/cli/investigation-artifacts.mjs +176 -0
  46. package/cli/investigation-checkpoint.mjs +10 -0
  47. package/cli/investigation-folder.mjs +114 -0
  48. package/cli/investigation-manifest.mjs +1 -112
  49. package/cli/investigation-package-review.mjs +103 -0
  50. package/cli/investigation-plan.mjs +32 -19
  51. package/cli/investigation-run.mjs +188 -33
  52. package/cli/local-document-checkpoint.mjs +170 -0
  53. package/cli/lookalike-calibration.mjs +7 -2
  54. package/cli/lookup-brief.mjs +4 -2
  55. package/cli/lookup-browser-view.mjs +1 -1
  56. package/cli/lookup-browser.mjs +5 -12
  57. package/cli/lookup-command-runner.mjs +17 -6
  58. package/cli/lookup-diff.mjs +37 -3
  59. package/cli/lookup-plan.mjs +11 -5
  60. package/cli/lookup-reconcile.mjs +10 -8
  61. package/cli/mail-header-review.mjs +467 -0
  62. package/cli/mail-review.mjs +66 -14
  63. package/cli/manual.mjs +1 -0
  64. package/cli/network-command-runner.mjs +2 -0
  65. package/cli/output-file.mjs +31 -5
  66. package/cli/registry-doctor.mjs +3 -1
  67. package/cli/registry-support.mjs +1 -1
  68. package/cli/retained-artifact-diff.mjs +2 -2
  69. package/cli/review-command-runner.mjs +70 -0
  70. package/cli/risk-calibration.mjs +19 -17
  71. package/cli/runner.mjs +62 -7
  72. package/cli/sharing-review.mjs +21 -34
  73. package/cli/signer-trust.mjs +104 -0
  74. package/cli/source-reliability.mjs +74 -83
  75. package/cli/terminal-input.mjs +168 -0
  76. package/cli/workflow-command-runner.mjs +68 -13
  77. package/docs/cli-reference.md +77 -24
  78. package/docs/cli.md +300 -25
  79. package/lib/abort.mjs +4 -1
  80. package/lib/availability.mjs +161 -112
  81. package/lib/bounded-file.mjs +3 -1
  82. package/lib/bounded-html-document.mjs +125 -0
  83. package/lib/bounded-json.mjs +51 -8
  84. package/lib/browser-library-profile.mjs +42 -51
  85. package/lib/browser-library-worker.mjs +112 -0
  86. package/lib/client-behavior-profile.mjs +3 -3
  87. package/lib/ct-search.mjs +11 -4
  88. package/lib/dns-convergence-review.mjs +1 -0
  89. package/lib/dns-delegation-health.mjs +24 -14
  90. package/lib/dns-inheritance-review.mjs +217 -0
  91. package/lib/dns-intelligence.mjs +73 -26
  92. package/lib/dns-mx.mjs +1 -1
  93. package/lib/dnssec-chain-validation.mjs +6 -0
  94. package/lib/dnssec-evidence-validation.mjs +11 -3
  95. package/lib/domain-change-packet.mjs +49 -16
  96. package/lib/domain-change-review.mjs +14 -8
  97. package/lib/domain-control-flight-recorder.mjs +221 -117
  98. package/lib/domain-control-manifest.mjs +129 -69
  99. package/lib/domain-posture-analysis.mjs +20 -7
  100. package/lib/domain-posture-parsers.mjs +201 -8
  101. package/lib/domain-posture.mjs +147 -29
  102. package/lib/evidence-export-privacy.mjs +561 -0
  103. package/lib/evidence-export.mjs +11 -543
  104. package/lib/evidence-report.mjs +5 -3
  105. package/lib/favicon.mjs +117 -41
  106. package/lib/generated/cisa-kev-catalog.mjs +12 -4
  107. package/lib/generated/registrar-standing-catalogue.mjs +7 -7
  108. package/lib/generated/retire-browser-catalog.mjs +2335 -940
  109. package/lib/hostname.mjs +2 -38
  110. package/lib/html-signals.mjs +111 -108
  111. package/lib/http-evidence-bounds.mjs +2 -1
  112. package/lib/http-intelligence.mjs +39 -26
  113. package/lib/local-geoip-evidence.mjs +19 -3
  114. package/lib/lookup-cache.mjs +34 -11
  115. package/lib/lookup-child-profile-contract.mjs +24 -15
  116. package/lib/lookup-diagnostics.mjs +24 -1
  117. package/lib/lookup-progress.mjs +30 -13
  118. package/lib/lookup-response-contract.mjs +1048 -0
  119. package/lib/lookup-response.mjs +8 -10
  120. package/lib/lookup-source-progress.mjs +40 -14
  121. package/lib/lookup.mjs +48 -27
  122. package/lib/nameserver-preflight-review.mjs +1 -0
  123. package/lib/observed-network-context.mjs +43 -27
  124. package/lib/outbound-request-bounds.mjs +4 -1
  125. package/lib/page-fingerprints.mjs +69 -133
  126. package/lib/page-role-profile.mjs +12 -8
  127. package/lib/perceptual-hash.mjs +43 -14
  128. package/lib/rdap-bootstrap.mjs +72 -22
  129. package/lib/rdap-capabilities.mjs +1 -1
  130. package/lib/rdap-client.mjs +15 -7
  131. package/lib/rdap-normalization.mjs +33 -18
  132. package/lib/rdap-search-workbench.mjs +1 -1
  133. package/lib/rdap-transport.mjs +47 -20
  134. package/lib/rdap-types.mjs +2 -1
  135. package/lib/rdap.mjs +18 -3
  136. package/lib/registrable-domain.mjs +21 -1
  137. package/lib/registrar-standing-contract.mjs +1 -0
  138. package/lib/registry-capabilities.mjs +5 -1
  139. package/lib/registry-capability-catalogue.mjs +2 -1
  140. package/lib/registry-dates.mjs +2 -190
  141. package/lib/registry-insights.mjs +85 -34
  142. package/lib/response-policy.mjs +86 -48
  143. package/lib/risk-calibration-summary.mjs +18 -6
  144. package/lib/rpki-evidence.mjs +8 -5
  145. package/lib/safe-fetch.mjs +54 -47
  146. package/lib/security-txt.mjs +42 -7
  147. package/lib/service-binding-dns.mjs +8 -0
  148. package/lib/smtp-transport-review.mjs +27 -15
  149. package/lib/sslbl-certificates.generated.mjs +136 -135
  150. package/lib/static-html-analysis.mjs +178 -151
  151. package/lib/structured-data-identity.mjs +1 -0
  152. package/lib/technology-markup.mjs +100 -0
  153. package/lib/threat-intelligence-manifests.mjs +11 -139
  154. package/lib/threat-intelligence-runtime.mjs +24 -52
  155. package/lib/threat-intelligence-targets.mjs +6 -34
  156. package/lib/threat-intelligence-types.mjs +39 -0
  157. package/lib/typosquat-generator.mjs +19 -15
  158. package/lib/urlhaus-intelligence.mjs +8 -3
  159. package/lib/urlscan-intelligence.mjs +1 -1
  160. package/lib/website-security-posture.mjs +30 -6
  161. package/lib/website-technology.mjs +153 -45
  162. package/lib/whois-authority.mjs +14 -2
  163. package/lib/whois-chain.mjs +9 -2
  164. package/lib/whois-common-formats.mjs +5 -5
  165. package/lib/whois-contracts.mjs +2 -1
  166. package/lib/whois-dialects.mjs +2 -2
  167. package/lib/whois-parser-support.mjs +8 -1
  168. package/lib/whois-transport.mjs +23 -7
  169. package/lib/zone-intent-review.mjs +16 -6
  170. package/package.json +2 -1
  171. package/packages/cases/case-attachment-model.mjs +83 -0
  172. package/packages/cases/case-evidence-model.mjs +156 -62
  173. package/packages/cases/case-export-input.mjs +53 -0
  174. package/packages/cases/case-migration-model.mjs +127 -44
  175. package/packages/cases/case-model.mjs +1 -0
  176. package/packages/cases/case-recheck-model.mjs +91 -0
  177. package/packages/cases/case-record-contracts.mjs +5 -20
  178. package/packages/cases/case-record-core.mjs +12 -22
  179. package/packages/cases/case-record-decisions.mjs +72 -0
  180. package/packages/cases/case-record-model.mjs +1 -0
  181. package/packages/cases/case-record-operations.mjs +95 -31
  182. package/packages/cases/case-record-projection.mjs +221 -0
  183. package/packages/cases/case-report.mjs +70 -54
  184. package/packages/cases/case-response-actions.mjs +654 -0
  185. package/packages/cases/case-response-model.mjs +64 -1115
  186. package/packages/cases/case-response-outcomes.mjs +317 -0
  187. package/packages/cases/case-response-packet-render.mjs +153 -0
  188. package/packages/cases/case-response-packet-types.mjs +1 -0
  189. package/packages/cases/case-response-packet-vocabulary.mjs +48 -0
  190. package/packages/cases/case-response-packet.mjs +74 -572
  191. package/packages/cases/case-response-records.mjs +94 -0
  192. package/packages/cases/case-response-review-inputs.mjs +371 -0
  193. package/packages/cases/case-response-values.mjs +85 -0
  194. package/packages/cases/case-review-package.mjs +45 -0
  195. package/packages/cases/case-selection.mjs +46 -0
  196. package/packages/cases/case-storage-model.mjs +31 -2
  197. package/packages/cases/response-route-freshness.mjs +16 -0
  198. package/packages/comparison/capture-context.mjs +61 -0
  199. package/packages/comparison/comparison-ledger-bulk.mjs +13 -11
  200. package/packages/comparison/page-similarity.mjs +5 -2
  201. package/packages/comparison/relationship-evidence.mjs +85 -8
  202. package/packages/comparison/relationship-provenance.mjs +93 -0
  203. package/packages/contracts/analyst-interchange.mjs +216 -0
  204. package/packages/contracts/analyst-review-state-contract.mjs +11 -0
  205. package/packages/contracts/analyst-review-state.mjs +5 -4
  206. package/packages/contracts/case-portability.mjs +366 -83
  207. package/packages/contracts/case-views-contract.mjs +12 -0
  208. package/packages/contracts/case-views.mjs +26 -0
  209. package/packages/contracts/cli-command-semantics.mjs +94 -0
  210. package/packages/contracts/cli-lookup.mjs +0 -1
  211. package/packages/contracts/domain-control-flight-recorder.mjs +80 -42
  212. package/packages/contracts/domain-control-manifest.mjs +127 -73
  213. package/packages/contracts/domain-control-monitor.mjs +48 -45
  214. package/packages/contracts/domain-control-review.mjs +179 -121
  215. package/packages/contracts/domain-name.mjs +21 -0
  216. package/packages/contracts/external-observation-interchange.mjs +2 -1
  217. package/packages/contracts/extracted-domain-lifecycle.mjs +11 -32
  218. package/packages/contracts/http-url.mjs +2 -0
  219. package/packages/contracts/investigation-package-limits.mjs +24 -0
  220. package/packages/contracts/investigation-portability.mjs +56 -27
  221. package/packages/contracts/investigation-run.mjs +16 -0
  222. package/packages/contracts/ip-address.mjs +34 -0
  223. package/packages/contracts/lookup-evidence.mjs +35 -0
  224. package/packages/contracts/offline-comparison.mjs +1 -2
  225. package/packages/contracts/page-fingerprints.mjs +15 -0
  226. package/packages/contracts/risk-calibration.mjs +15 -11
  227. package/packages/contracts/schema-compatibility.mjs +1 -1
  228. package/packages/contracts/schema-lifecycle.mjs +83 -153
  229. package/packages/contracts/selected-file-limits.mjs +6 -0
  230. package/packages/contracts/vulnerability-identifiers.mjs +5 -0
  231. package/packages/contracts/web-capture.mjs +10 -0
  232. package/packages/contracts/workspace-portability.mjs +183 -211
  233. package/packages/evidence/artifact-integrity.mjs +8 -2
  234. package/packages/evidence/artifact-structure.mjs +105 -0
  235. package/packages/evidence/decision-fact.mjs +4 -1
  236. package/packages/evidence/domain-control-runtime.mjs +201 -33
  237. package/packages/evidence/domain-posture-context.mjs +79 -0
  238. package/packages/evidence/image-regions.mjs +56 -0
  239. package/packages/evidence/latest-observations.mjs +28 -0
  240. package/packages/evidence/lookup-target.mjs +104 -0
  241. package/packages/evidence/observation.mjs +16 -1
  242. package/packages/evidence/passphrase-encryption.mjs +44 -0
  243. package/packages/evidence/record-identity.mjs +72 -0
  244. package/packages/evidence/registry-dates.mjs +190 -0
  245. package/packages/evidence/retained-file.mjs +69 -0
  246. package/packages/evidence/review-age.mjs +14 -0
  247. package/packages/interchange/bagit.mjs +342 -0
  248. package/packages/interchange/bounded-zip-extraction.mjs +278 -0
  249. package/packages/interchange/crc32.mjs +17 -0
  250. package/packages/interchange/external-findings-import.mjs +92 -51
  251. package/packages/interchange/web-capture-import.mjs +382 -0
  252. package/packages/investigation/bulk-mail-exposure.mjs +2 -1
  253. package/packages/investigation/investigation-bagit.mjs +14 -0
  254. package/packages/investigation/investigation-capsule.mjs +33 -5
  255. package/packages/investigation/investigation-manifest.mjs +200 -0
  256. package/packages/investigation/investigation-package-crypto.mjs +98 -0
  257. package/packages/investigation/investigation-package.mjs +200 -0
  258. package/packages/investigation/lookup-asset-graph-inputs.mjs +38 -0
  259. package/packages/investigation/lookup-asset-graph.mjs +193 -165
  260. package/packages/investigation/lookup-claim-readiness.mjs +18 -0
  261. package/packages/investigation/lookup-investigation-brief.mjs +3 -3
  262. package/packages/investigation/lookup-task-guidance.mjs +58 -0
  263. package/packages/monitoring/analyst-review-state.mjs +38 -90
  264. package/packages/workspace/brand-profile-model.mjs +276 -22
  265. package/packages/workspace/bulk-columns.mjs +17 -0
  266. package/packages/workspace/bulk-review-model.mjs +23 -11
  267. package/packages/workspace/bulk-session-model.mjs +100 -24
  268. package/packages/workspace/bulk-sort.mjs +1 -0
  269. package/packages/workspace/case-views.mjs +102 -0
  270. package/packages/workspace/detection-rule-model.mjs +70 -22
  271. package/packages/workspace/hostile-input.mjs +29 -8
  272. package/packages/workspace/investigation-template-model.mjs +19 -4
  273. package/packages/workspace/page-baseline.mjs +18 -15
  274. package/packages/workspace/relationship-observation-model.mjs +41 -23
  275. package/packages/workspace/watchlist-history.mjs +2 -2
  276. package/packages/workspace/watchlist-store.mjs +8 -2
  277. package/packages/workspace/website-snapshot-model.mjs +164 -26
  278. package/packages/workspace/workspace-archive-crypto.mjs +41 -61
  279. package/packages/workspace/workspace-archive.mjs +87 -21
  280. package/third-party-notices.txt +29 -2
  281. package/cli/evidence-command-arguments.mjs +0 -156
  282. package/packages/investigation/bulk-review-export.mjs +0 -63
@@ -1,7 +1,9 @@
1
1
  // Pure, framework-neutral analyst-case records, evidence histories, bounded
2
2
  // record normalization, and analyst updates.
3
- import { CASE_DISPOSITIONS, CASE_SCHEMA_VERSION, CASE_REVIEW_REASONS, CASE_SOURCES, CASE_STATUSES, DEFAULT_DISPOSITION, DEFAULT_SOURCE, DEFAULT_STATUS, EVIDENCE_SOURCES, MAX_DOMAIN_LENGTH, MAX_NOTES_PER_CASE, MAX_NOTE_LENGTH, MAX_TAGS_PER_CASE, MAX_TAG_LENGTH, } from "./case-record-contracts.mjs";
3
+ import { CASE_REVIEW_REASONS, CASE_SOURCES, DEFAULT_DISPOSITION, DEFAULT_SOURCE, DEFAULT_STATUS, EVIDENCE_SOURCES, MAX_DOMAIN_LENGTH, MAX_NOTES_PER_CASE, MAX_NOTE_LENGTH, MAX_TAGS_PER_CASE, MAX_TAG_LENGTH, } from "./case-record-contracts.mjs";
4
+ import { dispositionLabel, isValidDisposition, isValidStatus, statusLabel, } from "./case-record-decisions.mjs";
4
5
  import { normalizeExplicitIsoTimestamp, normalizeLegacyIsoTimestamp } from "../evidence/observation.mjs";
6
+ import { PUBLISHED_V2_3_CASE_SCHEMA_VERSION } from "../contracts/case-portability.mjs";
5
7
  import { canonicalRegistrableDomain } from "../../lib/registrable-domain.mjs";
6
8
  // Forward-version policy (two distinct guarantees):
7
9
  // - A locally-stored envelope that declares a version greater than this is
@@ -26,13 +28,9 @@ export const EVIDENCE_SOURCE_RANK = {
26
28
  import: 1,
27
29
  unknown: 0,
28
30
  };
29
- const STATUS_VALUES = new Set(CASE_STATUSES.map((item) => item.value));
30
- const DISPOSITION_VALUES = new Set(CASE_DISPOSITIONS.map((item) => item.value));
31
31
  const REVIEW_REASON_VALUES = new Set(CASE_REVIEW_REASONS.map((item) => item.value).filter(Boolean));
32
32
  const SOURCE_VALUES = new Set(CASE_SOURCES.map((item) => item.value));
33
- const STATUS_LABELS = Object.fromEntries(CASE_STATUSES.map((item) => [item.value, item.label]));
34
- const DISPOSITION_LABELS = Object.fromEntries(CASE_DISPOSITIONS.map((item) => [item.value, item.label]));
35
- const SOURCE_LABELS = Object.fromEntries(CASE_SOURCES.map((item) => [item.value, item.label]));
33
+ const SOURCE_LABELS = new Map(CASE_SOURCES.map((item) => [item.value, item.label]));
36
34
  // Availability tokens that actually assert something about the domain. Anything
37
35
  // else ('unknown', 'error', empty) is not, on its own, material evidence.
38
36
  export const CONCLUSIVE_AVAILABILITY = new Set([
@@ -59,21 +57,13 @@ export function objectRecord(value) {
59
57
  ? value
60
58
  : {};
61
59
  }
62
- export function statusLabel(value) {
63
- return (typeof value === 'string' ? STATUS_LABELS[value] : '') || String(value || '');
64
- }
65
- export function dispositionLabel(value) {
66
- return (typeof value === 'string' ? DISPOSITION_LABELS[value] : '') || String(value || '');
67
- }
68
60
  export function sourceLabel(value) {
69
- return (typeof value === 'string' ? SOURCE_LABELS[value] : '') || String(value || '');
70
- }
71
- export function isValidStatus(value) {
72
- return typeof value === 'string' && STATUS_VALUES.has(value);
61
+ return isValidSource(value) ? SOURCE_LABELS.get(value) : String(value || '');
73
62
  }
74
- export function isValidDisposition(value) {
75
- return typeof value === 'string' && DISPOSITION_VALUES.has(value);
63
+ export function isValidSource(value) {
64
+ return typeof value === 'string' && SOURCE_VALUES.has(value);
76
65
  }
66
+ export { dispositionLabel, isValidDisposition, isValidStatus, statusLabel, };
77
67
  /** Fresh, safe, effectively-unique id for a brand-new local record. */
78
68
  export function makeId() {
79
69
  if (typeof globalThis.crypto?.randomUUID === 'function')
@@ -147,16 +137,16 @@ export function normalizeEvidenceHostnameForCase(value, caseDomain) {
147
137
  return canonicalRegistrableDomain(hostname) === canonicalCase ? hostname : null;
148
138
  }
149
139
  export function normalizeStatus(value) {
150
- return typeof value === 'string' && STATUS_VALUES.has(value) ? value : DEFAULT_STATUS;
140
+ return isValidStatus(value) ? value : DEFAULT_STATUS;
151
141
  }
152
142
  export function normalizeDisposition(value) {
153
- return typeof value === 'string' && DISPOSITION_VALUES.has(value) ? value : DEFAULT_DISPOSITION;
143
+ return isValidDisposition(value) ? value : DEFAULT_DISPOSITION;
154
144
  }
155
145
  export function normalizeReviewReasonCode(value) {
156
146
  return typeof value === 'string' && REVIEW_REASON_VALUES.has(value) ? value : null;
157
147
  }
158
148
  export function normalizeSource(value) {
159
- return typeof value === 'string' && SOURCE_VALUES.has(value) ? value : DEFAULT_SOURCE;
149
+ return isValidSource(value) ? value : DEFAULT_SOURCE;
160
150
  }
161
151
  /** Parsed ISO string, or null when missing/invalid (used for import ordering). */
162
152
  export function isoOrNull(value) {
@@ -174,7 +164,7 @@ export function caseTimestampOrNull(value, sourceVersion) {
174
164
  return explicit;
175
165
  return sourceVersion !== undefined
176
166
  && sourceVersion !== null
177
- && sourceVersion < CASE_SCHEMA_VERSION
167
+ && sourceVersion < PUBLISHED_V2_3_CASE_SCHEMA_VERSION
178
168
  ? normalizeLegacyIsoTimestamp(value)
179
169
  : null;
180
170
  }
@@ -0,0 +1,72 @@
1
+ // Lightweight canonical Case status and disposition definitions. Persistence,
2
+ // portability and presentation consumers derive these identities and policies
3
+ // without importing the wider Case record or serialisation graph.
4
+ export const CASE_STATUSES = Object.freeze([
5
+ { value: 'new', label: 'New' },
6
+ { value: 'reviewing', label: 'Reviewing' },
7
+ { value: 'monitoring', label: 'Monitoring' },
8
+ { value: 'escalated', label: 'Escalated' },
9
+ { value: 'resolved', label: 'Resolved' },
10
+ ]);
11
+ export const CASE_DISPOSITIONS = Object.freeze([
12
+ { value: 'unreviewed', label: 'Unreviewed' },
13
+ { value: 'suspicious', label: 'Suspicious' },
14
+ { value: 'confirmed_abuse', label: 'Confirmed abuse' },
15
+ { value: 'false_positive', label: 'False positive' },
16
+ { value: 'expected', label: 'Expected' },
17
+ { value: 'closed_no_action', label: 'Closed without action' },
18
+ ]);
19
+ export const DEFAULT_STATUS = 'new';
20
+ export const DEFAULT_DISPOSITION = 'unreviewed';
21
+ const CASE_STATUS_OPERATION_POLICIES = Object.freeze({
22
+ new: { closed: false, closureRequired: false, directEdit: 'allowed' },
23
+ reviewing: { closed: false, closureRequired: false, directEdit: 'allowed' },
24
+ monitoring: { closed: false, closureRequired: false, directEdit: 'allowed' },
25
+ escalated: { closed: false, closureRequired: false, directEdit: 'allowed' },
26
+ resolved: { closed: true, closureRequired: true, directEdit: 'existing_only' },
27
+ });
28
+ const CASE_DISPOSITION_DECISION_POLICIES = Object.freeze({
29
+ unreviewed: { reviewed: false, supportsDefensiveResponse: false },
30
+ suspicious: { reviewed: true, supportsDefensiveResponse: true },
31
+ confirmed_abuse: { reviewed: true, supportsDefensiveResponse: true },
32
+ false_positive: { reviewed: true, supportsDefensiveResponse: false },
33
+ expected: { reviewed: true, supportsDefensiveResponse: false },
34
+ closed_no_action: { reviewed: true, supportsDefensiveResponse: false },
35
+ });
36
+ function statusOption(value) {
37
+ return CASE_STATUSES.find((option) => option.value === value);
38
+ }
39
+ function dispositionOption(value) {
40
+ return CASE_DISPOSITIONS.find((option) => option.value === value);
41
+ }
42
+ export function isValidStatus(value) {
43
+ return Boolean(statusOption(value));
44
+ }
45
+ export function isValidDisposition(value) {
46
+ return Boolean(dispositionOption(value));
47
+ }
48
+ export function statusLabel(value) {
49
+ return statusOption(value)?.label ?? String(value || '');
50
+ }
51
+ export function dispositionLabel(value) {
52
+ return dispositionOption(value)?.label ?? String(value || '');
53
+ }
54
+ export function caseStatusRequiresClosure(value) {
55
+ const option = statusOption(value);
56
+ return option ? CASE_STATUS_OPERATION_POLICIES[option.value].closureRequired : false;
57
+ }
58
+ export function caseStatusIsClosed(value) {
59
+ const option = statusOption(value);
60
+ return option ? CASE_STATUS_OPERATION_POLICIES[option.value].closed : false;
61
+ }
62
+ export function isReviewedCaseDisposition(value) {
63
+ const option = dispositionOption(value);
64
+ return option ? CASE_DISPOSITION_DECISION_POLICIES[option.value].reviewed : false;
65
+ }
66
+ export function caseDispositionSupportsDefensiveResponse(value) {
67
+ const option = dispositionOption(value);
68
+ return option ? CASE_DISPOSITION_DECISION_POLICIES[option.value].supportsDefensiveResponse : false;
69
+ }
70
+ export function caseStatusOptionsForDirectEdit(currentStatus) {
71
+ return CASE_STATUSES.filter((option) => (CASE_STATUS_OPERATION_POLICIES[option.value].directEdit === 'allowed' || option.value === currentStatus));
72
+ }
@@ -6,4 +6,5 @@ export * from "./case-record-core.mjs";
6
6
  export * from "./case-brand-profile-references.mjs";
7
7
  export * from "./case-evidence-model.mjs";
8
8
  export * from "./case-record-operations.mjs";
9
+ export * from "./case-record-projection.mjs";
9
10
  export * from "./case-investigation-branch-model.mjs";
@@ -1,13 +1,20 @@
1
1
  // Pure, framework-neutral analyst-case records, evidence histories, bounded
2
2
  // record normalization, and analyst updates.
3
3
  import { canonicalRegistrableDomain } from "../../lib/registrable-domain.mjs";
4
+ import { parseCredentialFreeHttpUrl } from "../evidence/lookup-target.mjs";
5
+ import { selectExistingCase } from "./case-selection.mjs";
4
6
  import { appendCaseAction, appendCaseAssertion, appendCaseClosure, appendCaseDecision, appendCaseEvidencePin, appendCaseEvidencePins, appendCaseManualTrailEvent, appendCaseObservedEffectReview, appendCaseSighting, buildCaseClosureLinkContext, normalizeCaseActions, normalizeCaseAssertions, normalizeCaseClosureHistory, normalizeCaseDecisions, normalizeCaseEvidencePins, normalizeCaseManualTrail, normalizeCaseObservedEffectHistory, normalizeCaseSightings, updateCaseAction, updateCaseAssertion, } from "./case-response-model.mjs";
5
- import { CASE_SCHEMA_VERSION, MAX_CASES, MAX_NOTES_PER_CASE, } from "./case-record-contracts.mjs";
7
+ import { MAX_CASES, MAX_NOTES_PER_CASE, } from "./case-record-contracts.mjs";
8
+ import { PUBLISHED_V2_3_CASE_SCHEMA_VERSION, INCIDENT_CASE_SCHEMA_VERSION, MAX_CASE_OBJECTIVE_LENGTH } from "../contracts/case-portability.mjs";
9
+ import { readCaseAttachments } from "./case-attachment-model.mjs";
10
+ import { assertCurrentRecheckQuestion, assertRecheckNonReproduction, readCaseRecheckAnswerContext } from "./case-recheck-model.mjs";
11
+ import { caseDispositionSupportsDefensiveResponse, caseStatusIsClosed, caseStatusOptionsForDirectEdit, caseStatusRequiresClosure, isReviewedCaseDisposition, } from "./case-record-decisions.mjs";
6
12
  import { DEFAULT_EVIDENCE_SOURCE, EVIDENCE_SOURCE_SET, caseTimestampOrNull, deterministicId, makeId, normalizeDisposition, normalizeDomain, normalizeNoteBody, normalizeReviewReasonCode, normalizeNotes, normalizeSource, normalizeStatus, normalizeTags, objectRecord, safeId, } from "./case-record-core.mjs";
7
13
  import { assertCaseBrandProfileIds, normalizeCaseBrandProfileIds, } from "./case-brand-profile-references.mjs";
8
14
  import { normalizeEvidenceHistory, } from "./case-evidence-model.mjs";
9
15
  import { appendCaseInvestigationBranch, caseInvestigationBranchReferences, normalizeCaseInvestigationBranches, updateCaseInvestigationBranch, } from "./case-investigation-branch-model.mjs";
10
- export const MAX_CASE_OBJECTIVE_LENGTH = 320;
16
+ export { caseDispositionSupportsDefensiveResponse, caseStatusIsClosed, caseStatusOptionsForDirectEdit, caseStatusRequiresClosure, isReviewedCaseDisposition, };
17
+ export { MAX_CASE_OBJECTIVE_LENGTH };
11
18
  export const MAX_CASE_INCIDENT_URL_LENGTH = 1_850;
12
19
  export const INCIDENT_CONTEXT_STATEMENT_PREFIX = 'Investigate incident URL: ';
13
20
  const OBJECTIVE_PREFIX = 'Objective: ';
@@ -21,18 +28,8 @@ export function normalizeCaseObjective(value) {
21
28
  return boundedContextText(value, MAX_CASE_OBJECTIVE_LENGTH);
22
29
  }
23
30
  export function parseIncidentUrlContext(value) {
24
- if (typeof value !== 'string' || !value || value.length > MAX_CASE_INCIDENT_URL_LENGTH)
25
- return null;
26
- if (/[\u0000-\u001f\u007f]/u.test(value) || value.trim() !== value)
27
- return null;
28
- let parsed;
29
- try {
30
- parsed = new URL(value);
31
- }
32
- catch {
33
- return null;
34
- }
35
- if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password || !parsed.hostname)
31
+ const parsed = parseCredentialFreeHttpUrl(value, MAX_CASE_INCIDENT_URL_LENGTH);
32
+ if (!parsed)
36
33
  return null;
37
34
  const hostname = parsed.hostname.toLowerCase().replace(/\.$/u, '');
38
35
  const registrableDomain = canonicalRegistrableDomain(hostname);
@@ -140,7 +137,7 @@ export function normalizeCase(raw, existing, nowIso, sourceVersion) {
140
137
  const createdAt = existing ? existing.createdAt : caseTimestampOrNull(record.createdAt, sourceVersion) || now;
141
138
  const updatedAt = caseTimestampOrNull(record.updatedAt, sourceVersion) || createdAt;
142
139
  const timestampOptions = {
143
- legacyTimestamps: sourceVersion != null && sourceVersion < CASE_SCHEMA_VERSION,
140
+ legacyTimestamps: sourceVersion != null && sourceVersion < PUBLISHED_V2_3_CASE_SCHEMA_VERSION,
144
141
  ...(sourceVersion === undefined ? {} : { sourceVersion }),
145
142
  };
146
143
  const evidencePins = normalizeCaseEvidencePins(record.evidencePins, updatedAt, timestampOptions);
@@ -153,10 +150,12 @@ export function normalizeCase(raw, existing, nowIso, sourceVersion) {
153
150
  const closures = normalizeCaseClosureHistory(sourceVersion != null && sourceVersion < 13 ? undefined : record.closures, updatedAt, new Set(observedEffects.reviews.map((item) => item.id)), new Set(actions.map((item) => item.id)), timestampOptions, buildCaseClosureLinkContext(observedEffects, actions));
154
151
  const normalizedStatus = normalizeStatus(record.status);
155
152
  const branchReferences = caseInvestigationBranchReferences({ evidencePins, actions, assertions });
153
+ const attachments = readCaseAttachments(record.attachments);
156
154
  return {
157
155
  id: existing ? existing.id : safeId(record.id) || deterministicId(domain),
158
156
  domain,
159
- status: normalizedStatus === 'resolved'
157
+ title: sourceVersion != null && sourceVersion < INCIDENT_CASE_SCHEMA_VERSION ? '' : normalizeCaseObjective(record.title),
158
+ status: caseStatusRequiresClosure(normalizedStatus)
160
159
  && closures.records.length === 0 && !closures.preV13HistoryUnavailable
161
160
  ? 'reviewing'
162
161
  : normalizedStatus,
@@ -176,6 +175,7 @@ export function normalizeCase(raw, existing, nowIso, sourceVersion) {
176
175
  observedEffects,
177
176
  closures,
178
177
  branches: normalizeCaseInvestigationBranches(record.branches, updatedAt, branchReferences, timestampOptions),
178
+ ...(attachments === undefined ? {} : { attachments }),
179
179
  createdAt,
180
180
  updatedAt,
181
181
  };
@@ -211,15 +211,22 @@ export function createCase(input, nowIso) {
211
211
  const observedEffects = input.observedEffectReview !== undefined
212
212
  ? appendCaseObservedEffectReview(normalizeCaseObservedEffectHistory(undefined, now), input.observedEffectReview, now, pinIds, new Set(sightings.map((item) => item.id)))
213
213
  : normalizeCaseObservedEffectHistory(undefined, now);
214
+ for (const review of observedEffects.reviews) {
215
+ if (!review.recheck)
216
+ continue;
217
+ assertCurrentRecheckQuestion(review.recheck, assertions);
218
+ assertRecheckNonReproduction(review.state, review.recheck, review.completeness, evidencePins, evidencePins.find(pin => pin.id === review.evidencePinId), review.observedAt);
219
+ }
214
220
  const closures = input.closure !== undefined
215
221
  ? appendCaseClosure(normalizeCaseClosureHistory(undefined, now), input.closure, now, observedEffects, actions)
216
222
  : normalizeCaseClosureHistory(undefined, now);
217
- if (normalizeStatus(input.status) === 'resolved' && input.closure === undefined) {
223
+ if (caseStatusRequiresClosure(normalizeStatus(input.status)) && input.closure === undefined) {
218
224
  throw new Error('Opening a resolved case requires a deliberate closure reason and its linked review context.');
219
225
  }
220
226
  return {
221
227
  id: makeId(),
222
228
  domain,
229
+ title: normalizeCaseObjective(input.title),
223
230
  status: input.closure !== undefined ? 'resolved' : normalizeStatus(input.status),
224
231
  disposition: normalizeDisposition(input.disposition),
225
232
  reviewReasonCode: normalizeReviewReasonCode(input.reviewReasonCode),
@@ -252,25 +259,46 @@ export function createCase(input, nowIso) {
252
259
  };
253
260
  }
254
261
  /**
255
- * Opens the existing case for a domain, or creates one. Returns a new array
262
+ * Opens an explicitly selected or unambiguous Case, or creates one. Returns a new array
256
263
  * (callers persist it) plus the resolved record and whether it was created.
257
264
  * @param {CaseRecord[]} cases
258
265
  * @param {{ domain: unknown, status?: unknown, disposition?: unknown, source?: unknown, tags?: unknown, evidence?: unknown, note?: unknown }} input
259
266
  * @param {string} [nowIso]
260
267
  * @returns {{ cases: CaseRecord[], record: CaseRecord, created: boolean }}
261
268
  */
262
- export function openOrCreateCase(cases, input, nowIso) {
269
+ export function openOrCreateCase(cases, input, nowIso, selection = {}) {
263
270
  const domain = normalizeDomain(input.domain);
264
271
  if (!domain)
265
272
  throw new Error('A valid domain is required to open a case.');
266
- const existing = cases.find((item) => item.domain === domain);
273
+ const existing = selectExistingCase(cases, domain, selection);
267
274
  if (existing)
268
275
  return { cases, record: existing, created: false };
269
276
  if (cases.length >= MAX_CASES)
270
277
  throw new Error(`Cases are limited to ${MAX_CASES}. Delete or export some first.`);
271
278
  const record = createCase({ ...input, domain }, nowIso);
279
+ if (cases.some(existingCase => existingCase.id === record.id))
280
+ throw new Error('A unique Case ID could not be allocated. No data was changed; try again.');
272
281
  return { cases: [record, ...cases], record, created: true };
273
282
  }
283
+ /** Start a separate incident; reuse only the deliberately selected observation. */
284
+ export function createCaseIncident(cases, input, nowIso) {
285
+ const domain = normalizeDomain(input.domain);
286
+ const title = normalizeCaseObjective(input.title);
287
+ if (!domain)
288
+ throw new Error('Enter a valid domain for the incident.');
289
+ if (!title)
290
+ throw new Error('Enter a title that distinguishes this incident.');
291
+ if (typeof input.title === 'string' && input.title.trim().length > MAX_CASE_OBJECTIVE_LENGTH)
292
+ throw new Error(`Incident titles are limited to ${MAX_CASE_OBJECTIVE_LENGTH} characters.`);
293
+ let evidence;
294
+ if (input.reuse) {
295
+ const source = cases.find(record => record.id === input.reuse?.caseId && record.domain === domain);
296
+ evidence = source?.evidenceHistory.find(snapshot => snapshot.id === input.reuse?.snapshotId);
297
+ if (!evidence)
298
+ throw new Error('The selected source observation is no longer available for this domain. No Case was created.');
299
+ }
300
+ return openOrCreateCase(cases, { domain, title, ...(evidence ? { evidence: structuredClone(evidence) } : {}) }, nowIso, { newIncident: true });
301
+ }
274
302
  /**
275
303
  * Applies a partial update to one case by id, bumping updatedAt. A `note` in
276
304
  * the patch is appended (respecting the per-case note bound); a `tags` array
@@ -291,6 +319,9 @@ export function updateCase(cases, id, patch, nowIso) {
291
319
  const current = cases[index];
292
320
  if (!current)
293
321
  throw new Error('That case no longer exists.');
322
+ if (patch.title !== undefined && patch.expectedTitle !== undefined && patch.expectedTitle !== (current.title ?? '')) {
323
+ throw new Error('The incident title changed after this draft was started. Reload and review the current title before saving; your draft has not been applied.');
324
+ }
294
325
  let notes = current.notes;
295
326
  if (patch.note !== undefined) {
296
327
  const body = normalizeNoteBody(patch.note);
@@ -350,17 +381,24 @@ export function updateCase(cases, id, patch, nowIso) {
350
381
  }
351
382
  let observedEffects = normalizeCaseObservedEffectHistory(current.observedEffects, current.updatedAt, pinIds, new Set(sightings.map((item) => item.id)));
352
383
  if (patch.observedEffectReview !== undefined) {
384
+ const review = objectRecord(patch.observedEffectReview);
385
+ const context = readCaseRecheckAnswerContext(review.recheck);
386
+ if (context) {
387
+ assertCurrentRecheckQuestion(context, assertions);
388
+ assertRecheckNonReproduction(review.state, context, String(review.completeness), evidencePins, evidencePins.find(pin => pin.id === review.evidencePinId), typeof review.observedAt === 'string' ? review.observedAt : null);
389
+ }
353
390
  observedEffects = appendCaseObservedEffectReview(observedEffects, patch.observedEffectReview, now, pinIds, new Set(sightings.map((item) => item.id)));
354
391
  }
355
392
  let closures = normalizeCaseClosureHistory(current.closures, current.updatedAt, new Set(observedEffects.reviews.map((item) => item.id)), new Set(actions.map((item) => item.id)), {}, buildCaseClosureLinkContext(observedEffects, actions));
356
393
  if (patch.closure !== undefined) {
357
394
  closures = appendCaseClosure(closures, patch.closure, now, observedEffects, actions);
358
395
  }
359
- if (patch.status === 'resolved' && patch.closure === undefined) {
396
+ if (caseStatusRequiresClosure(patch.status) && patch.closure === undefined) {
360
397
  throw new Error('Resolve this case through the deliberate closure review so the reason and evidence state remain explicit.');
361
398
  }
362
399
  const record = {
363
400
  ...current,
401
+ title: patch.title === undefined ? current.title ?? '' : normalizeCaseObjective(patch.title),
364
402
  status: patch.closure !== undefined
365
403
  ? 'resolved'
366
404
  : patch.status !== undefined ? normalizeStatus(patch.status) : current.status,
@@ -392,8 +430,8 @@ export function updateCase(cases, id, patch, nowIso) {
392
430
  }
393
431
  /**
394
432
  * Records one reviewed conclusion as a single pure Case mutation. The selected
395
- * observations become bounded Case pins before the decision is created, so the
396
- * decision cannot point at absent evidence. Counterevidence is retained as a
433
+ * observations become bounded Case pins or reference existing retained pins,
434
+ * so the decision cannot point at absent evidence. Counterevidence is retained as a
397
435
  * separate resolved contradiction assertion rather than being hidden inside a
398
436
  * favourable disposition.
399
437
  */
@@ -401,7 +439,7 @@ export function recordCaseConclusion(cases, id, input, nowIso) {
401
439
  const now = caseTimestampOrNull(nowIso) || new Date().toISOString();
402
440
  const disposition = normalizeDisposition(input.disposition);
403
441
  const reviewReasonCode = normalizeReviewReasonCode(input.reviewReasonCode);
404
- if (disposition === 'unreviewed') {
442
+ if (!isReviewedCaseDisposition(disposition)) {
405
443
  throw new Error('Select a reviewed disposition before recording a conclusion.');
406
444
  }
407
445
  if (!reviewReasonCode) {
@@ -420,14 +458,36 @@ export function recordCaseConclusion(cases, id, input, nowIso) {
420
458
  if (!current)
421
459
  throw new Error('That case no longer exists.');
422
460
  const existingPinIds = new Set(current.evidencePins.map((pin) => pin.id));
423
- const withPins = updateCase(cases, id, {
424
- evidencePins: input.evidence.map((item) => item.pin),
425
- }, now);
461
+ const selectedExisting = new Set();
462
+ for (const item of input.evidence) {
463
+ if (Object.hasOwn(item, 'pin') === Object.hasOwn(item, 'pinId'))
464
+ throw new Error('Select one new pin or one retained pin ID for each conclusion fact.');
465
+ if (Object.hasOwn(item, 'pinId')) {
466
+ if (typeof item.pinId !== 'string' || !existingPinIds.has(item.pinId))
467
+ throw new Error('The selected conclusion pin is no longer retained in this Case.');
468
+ if (selectedExisting.has(item.pinId))
469
+ throw new Error('Select each retained conclusion pin once.');
470
+ selectedExisting.add(item.pinId);
471
+ }
472
+ }
473
+ const newEvidence = input.evidence.filter(item => Object.hasOwn(item, 'pin'));
474
+ const withPins = newEvidence.length
475
+ ? updateCase(cases, id, { evidencePins: newEvidence.map(item => item.pin) }, now)
476
+ : { cases, record: current };
426
477
  const addedPins = withPins.record.evidencePins.filter((pin) => !existingPinIds.has(pin.id));
427
- if (addedPins.length !== input.evidence.length) {
478
+ if (addedPins.length !== newEvidence.length) {
428
479
  throw new Error('The selected conclusion evidence could not be retained completely.');
429
480
  }
430
- const supportingPinIds = addedPins.flatMap((pin, index) => (input.evidence[index]?.stance === 'supports' ? [pin.id] : []));
481
+ let nextAdded = 0;
482
+ const selectedPins = input.evidence.map(item => {
483
+ const pin = Object.hasOwn(item, 'pinId')
484
+ ? withPins.record.evidencePins.find(pin => pin.id === item.pinId)
485
+ : addedPins[nextAdded++];
486
+ if (!pin)
487
+ throw new Error('The selected conclusion evidence could not be retained completely.');
488
+ return pin;
489
+ });
490
+ const supportingPinIds = selectedPins.flatMap((pin, index) => (input.evidence[index]?.stance === 'supports' ? [pin.id] : []));
431
491
  let concluded = updateCase(withPins.cases, id, {
432
492
  disposition,
433
493
  reviewReasonCode,
@@ -437,7 +497,7 @@ export function recordCaseConclusion(cases, id, input, nowIso) {
437
497
  evidencePinIds: supportingPinIds,
438
498
  },
439
499
  }, now);
440
- const contradictionRelations = addedPins.flatMap((pin, index) => (input.evidence[index]?.stance === 'contradicts'
500
+ const contradictionRelations = selectedPins.flatMap((pin, index) => (input.evidence[index]?.stance === 'contradicts'
441
501
  ? [{ evidencePinId: pin.id, stance: 'contradicts' }]
442
502
  : []));
443
503
  if (contradictionRelations.length) {
@@ -452,7 +512,7 @@ export function recordCaseConclusion(cases, id, input, nowIso) {
452
512
  },
453
513
  }, now);
454
514
  }
455
- const unresolvedRelations = addedPins.flatMap((pin, index) => (input.evidence[index]?.stance === 'unresolved'
515
+ const unresolvedRelations = selectedPins.flatMap((pin, index) => (input.evidence[index]?.stance === 'unresolved'
456
516
  ? [{ evidencePinId: pin.id, stance: 'unresolved' }]
457
517
  : []));
458
518
  if (unresolvedRelations.length) {
@@ -503,6 +563,8 @@ export function recordCaseRecheckOutcome(cases, id, input, nowIso) {
503
563
  const current = cases.find((item) => item.id === id);
504
564
  if (!current)
505
565
  throw new Error('That case no longer exists.');
566
+ if (input.recheck && input.observationHostname !== input.recheck.targetHostname)
567
+ throw new Error('This recheck concerns a different or unknown hostname. Recollect the saved question target before recording its answer.');
506
568
  const beforePinIds = new Set(current.evidencePins.map((pin) => pin.id));
507
569
  const withPin = updateCase(cases, id, {
508
570
  evidencePin: {
@@ -514,6 +576,7 @@ export function recordCaseRecheckOutcome(cases, id, input, nowIso) {
514
576
  sourceState: 'reviewed',
515
577
  observedAt: input.observedAt,
516
578
  collectionDepth: input.collectionDepth,
579
+ observationHostname: input.observationHostname,
517
580
  completeness: input.completeness,
518
581
  truncated: false,
519
582
  limitations: input.limitations,
@@ -532,6 +595,7 @@ export function recordCaseRecheckOutcome(cases, id, input, nowIso) {
532
595
  evidencePinId: comparisonPin.id,
533
596
  followUpAt: input.followUpAt,
534
597
  limitations: input.limitations,
598
+ ...(input.recheck ? { recheck: input.recheck } : {}),
535
599
  },
536
600
  }, now);
537
601
  }