@slicedearth/whoisleuth-cli 2.4.0 → 2.6.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 (340) hide show
  1. package/DISCLOSURE +1 -1
  2. package/NOTICE +2 -2
  3. package/cli/argument-values.mjs +55 -0
  4. package/cli/arguments.mjs +24 -470
  5. package/cli/artifact-validation/case-response.mjs +67 -9
  6. package/cli/artifact-validation/lookup-evidence.mjs +18 -4
  7. package/cli/artifact-validation/signed-review.mjs +61 -64
  8. package/cli/artifact-verify.mjs +11 -2
  9. package/cli/assurance-arguments.mjs +56 -0
  10. package/cli/assurance-command-definitions.mjs +179 -0
  11. package/cli/assurance-command-runner.mjs +1 -1
  12. package/cli/bounded-json.mjs +1 -1
  13. package/cli/bulk-checkpoint.mjs +1 -1
  14. package/cli/bulk-command-runner.mjs +6 -8
  15. package/cli/bulk.mjs +2 -3
  16. package/cli/case-command.mjs +99 -6
  17. package/cli/case-pack.mjs +19 -4
  18. package/cli/ci-report.mjs +6 -3
  19. package/cli/collection-arguments.mjs +123 -0
  20. package/cli/collection-command-definitions.mjs +330 -0
  21. package/cli/collection-preflight.mjs +8 -1
  22. package/cli/command-argument-grammar.mjs +8 -210
  23. package/cli/command-definition.mjs +330 -0
  24. package/cli/command-reference.mjs +55 -1388
  25. package/cli/command-seeds.mjs +18 -0
  26. package/cli/compare.mjs +1 -1
  27. package/cli/config-profile.mjs +1 -1
  28. package/cli/ct-event-intake.mjs +4 -4
  29. package/cli/discover.mjs +2 -2
  30. package/cli/discovery-observation-snapshot.mjs +2 -2
  31. package/cli/discovery-scan-command-runner.mjs +7 -9
  32. package/cli/discovery-scan.mjs +1 -1
  33. package/cli/discovery-snapshot.mjs +2 -2
  34. package/cli/discovery-workflow.mjs +4 -4
  35. package/cli/domain-control-monitor.mjs +6 -6
  36. package/cli/domain-control-observations.mjs +2 -1
  37. package/cli/domain-feed.mjs +61 -0
  38. package/cli/errors.mjs +5 -6
  39. package/cli/evidence-arguments.mjs +46 -0
  40. package/cli/evidence-command-definitions.mjs +69 -0
  41. package/cli/evidence-command-runner.mjs +2 -3
  42. package/cli/evidence-signing.mjs +3 -2
  43. package/cli/external-observation-mapping.mjs +2 -2
  44. package/cli/formatters/json.mjs +1 -13
  45. package/cli/formatters/terminal-command-formats.mjs +6 -0
  46. package/cli/formatters/terminal-lookup.mjs +6 -0
  47. package/cli/history-arguments.mjs +39 -0
  48. package/cli/history-command-definitions.mjs +88 -0
  49. package/cli/indicator-set-command.mjs +32 -0
  50. package/cli/inline-command-families.mjs +6 -47
  51. package/cli/inline-command-runner.mjs +4 -31
  52. package/cli/input.mjs +2 -2
  53. package/cli/intake-command.mjs +147 -0
  54. package/cli/interchange-report.mjs +4 -2
  55. package/cli/investigation-artifacts.mjs +3 -4
  56. package/cli/investigation-package-review.mjs +2 -0
  57. package/cli/investigation-plan.mjs +32 -154
  58. package/cli/investigation-recipes.mjs +177 -0
  59. package/cli/investigation-run.mjs +78 -12
  60. package/cli/local-mmdb-review.mjs +220 -17
  61. package/cli/lookalike-calibration.mjs +1 -1
  62. package/cli/lookup-brief.mjs +2 -2
  63. package/cli/lookup-command-runner.mjs +19 -35
  64. package/cli/lookup-diff.mjs +10 -9
  65. package/cli/lookup-plan.mjs +10 -14
  66. package/cli/lookup-reconcile.mjs +6 -5
  67. package/cli/lookup-timeline.mjs +7 -6
  68. package/cli/mail-header-review.mjs +24 -247
  69. package/cli/mail-review.mjs +9 -7
  70. package/cli/network-arguments.mjs +51 -0
  71. package/cli/network-command-definitions.mjs +130 -0
  72. package/cli/network-command-runner.mjs +43 -31
  73. package/cli/offline-evidence-review.mjs +85 -6
  74. package/cli/posture.mjs +1 -1
  75. package/cli/registry-fixture-scaffold.mjs +1 -1
  76. package/cli/retained-artifact-diff.mjs +8 -7
  77. package/cli/review-arguments.mjs +132 -0
  78. package/cli/review-command-definitions.mjs +279 -0
  79. package/cli/review-command-runner.mjs +72 -159
  80. package/cli/risk-calibration.mjs +14 -12
  81. package/cli/runner.mjs +20 -32
  82. package/cli/saved-lookup.mjs +2 -2
  83. package/cli/selected-input-worker.mjs +73 -0
  84. package/cli/sharing-review.mjs +5 -64
  85. package/cli/source-reliability.mjs +2 -2
  86. package/cli/support-arguments.mjs +49 -0
  87. package/cli/support-command-definitions.mjs +209 -0
  88. package/cli/support-command-runner.mjs +4 -4
  89. package/cli/watchlist-review.mjs +43 -0
  90. package/cli/workflow-arguments.mjs +100 -0
  91. package/cli/workflow-command-definitions.mjs +103 -0
  92. package/cli/workflow-command-runner.mjs +40 -1
  93. package/docs/cli-reference.md +27 -0
  94. package/docs/cli.md +322 -4
  95. package/docs/contextual-reviews.md +178 -0
  96. package/docs/infrastructure-observations.md +126 -0
  97. package/docs/offline-intake-context.md +213 -0
  98. package/lib/analyst-taxonomy.mjs +2 -40
  99. package/lib/application-version.mjs +1 -1
  100. package/lib/availability.mjs +13 -789
  101. package/lib/bounded-contract-normalizers.mjs +4 -0
  102. package/lib/bounded-json.mjs +2 -330
  103. package/lib/browser-library-profile.mjs +62 -84
  104. package/lib/candidate-provenance-bounds.mjs +2 -0
  105. package/lib/classify.mjs +1 -1
  106. package/lib/credential-form-attribution.mjs +136 -0
  107. package/lib/credential-surface-profile.mjs +6 -2
  108. package/lib/ct-query.mjs +2 -27
  109. package/lib/ct-response-bounds.mjs +2 -9
  110. package/lib/ct-search.mjs +83 -47
  111. package/lib/dmarc-discovery.mjs +62 -0
  112. package/lib/dns-convergence-review.mjs +21 -8
  113. package/lib/dns-delegation-health.mjs +35 -15
  114. package/lib/dns-inheritance-review.mjs +53 -63
  115. package/lib/dns-intelligence.mjs +27 -11
  116. package/lib/dns-query-coverage.mjs +40 -0
  117. package/lib/domain-assurance.mjs +1 -1
  118. package/lib/domain-change-packet.mjs +12 -13
  119. package/lib/domain-change-review.mjs +32 -14
  120. package/lib/domain-control-flight-recorder.mjs +103 -27
  121. package/lib/domain-control-manifest.mjs +25 -17
  122. package/lib/domain-posture-analysis.mjs +75 -7
  123. package/lib/domain-posture-parsers.mjs +9 -0
  124. package/lib/domain-posture.mjs +45 -25
  125. package/lib/domain-registration.mjs +392 -0
  126. package/lib/evidence-export-privacy.mjs +27 -17
  127. package/lib/evidence-export.mjs +3 -3
  128. package/lib/evidence-report.mjs +5 -1
  129. package/lib/external-intelligence-risk.mjs +2 -124
  130. package/lib/favicon.mjs +13 -4
  131. package/lib/generated/cisa-kev-catalog.mjs +41 -3
  132. package/lib/generated/registrar-standing-catalogue.mjs +18 -10
  133. package/lib/generated/retire-browser-catalog.mjs +332 -19
  134. package/lib/generated/{unicode-confusables-17.mjs → unicode-confusables.mjs} +33 -33
  135. package/lib/html-signals.mjs +13 -2
  136. package/lib/http-evidence-bounds.mjs +2 -8
  137. package/lib/http-intelligence.mjs +13 -1
  138. package/lib/idn-confusable-policy.mjs +6 -4
  139. package/lib/idn-confusables.mjs +1 -1
  140. package/lib/interchange-fidelity-registry.mjs +10 -0
  141. package/lib/lookup-child-profile-contract.mjs +28 -11
  142. package/lib/lookup-contract-primitives.mjs +1 -1
  143. package/lib/lookup-diagnostics.mjs +1 -23
  144. package/lib/lookup-network-evidence-bounds.mjs +1 -10
  145. package/lib/lookup-progress.mjs +50 -1
  146. package/lib/lookup-response-contract.mjs +70 -40
  147. package/lib/lookup-response.mjs +5 -15
  148. package/lib/lookup-source-progress.mjs +8 -22
  149. package/lib/lookup.mjs +1 -1
  150. package/lib/operation-budget.mjs +4 -0
  151. package/lib/opportunity-scoring.mjs +2 -107
  152. package/lib/outbound-identity.mjs +1 -1
  153. package/lib/page-fingerprints.mjs +18 -1
  154. package/lib/perceptual-hash-comparison.mjs +2 -34
  155. package/lib/perceptual-hash.mjs +36 -10
  156. package/lib/portable-generator.mjs +2 -29
  157. package/lib/project-metadata.mjs +2 -8
  158. package/lib/rdap-client.mjs +11 -7
  159. package/lib/registrable-domain.mjs +2 -56
  160. package/lib/registrar-standing-contract.mjs +1 -1
  161. package/lib/registrar-standing.mjs +9 -13
  162. package/lib/registry-capabilities.mjs +2 -2
  163. package/lib/registry-capability-catalogue.mjs +27 -19
  164. package/lib/risk-calibration-summary.mjs +2 -352
  165. package/lib/risk-scoring.mjs +2 -345
  166. package/lib/scoring-evidence-quality.mjs +2 -107
  167. package/lib/semantic-version.mjs +2 -54
  168. package/lib/service-binding-dns.mjs +46 -2
  169. package/lib/smtp-transport-review.mjs +48 -19
  170. package/lib/sslbl-certificates.generated.mjs +142 -139
  171. package/lib/static-html-analysis.mjs +32 -4
  172. package/lib/structured-data-identity.mjs +1 -1
  173. package/lib/technology-evidence-role.mjs +1 -51
  174. package/lib/threat-intelligence-manifests.mjs +1 -1
  175. package/lib/threat-intelligence-runtime.mjs +1 -1
  176. package/lib/threat-intelligence-targets.mjs +1 -1
  177. package/lib/threat-intelligence-types.mjs +2 -75
  178. package/lib/tls-intelligence.mjs +30 -5
  179. package/lib/typosquat-generator.mjs +11 -2
  180. package/lib/website-enrichment.mjs +404 -0
  181. package/lib/whois-authority.mjs +43 -5
  182. package/lib/whois-chain.mjs +12 -3
  183. package/lib/whois-common-formats.mjs +3 -1
  184. package/lib/whois-contacts.mjs +13 -2
  185. package/lib/whois-normalization.mjs +3 -1
  186. package/lib/whois-parser.mjs +5 -3
  187. package/package.json +15 -14
  188. package/packages/analysis/analyst-taxonomy.mjs +40 -0
  189. package/packages/analysis/bounded-json.mjs +330 -0
  190. package/packages/analysis/candidate-provenance-bounds.mjs +4 -0
  191. package/packages/analysis/cli-argument-grammar.mjs +216 -0
  192. package/packages/analysis/ct-query.mjs +27 -0
  193. package/packages/analysis/ct-response-bounds.mjs +9 -0
  194. package/packages/analysis/external-intelligence-risk.mjs +124 -0
  195. package/packages/analysis/http-evidence-bounds.mjs +8 -0
  196. package/packages/analysis/lookup-network-evidence-bounds.mjs +10 -0
  197. package/packages/analysis/opportunity-scoring.mjs +107 -0
  198. package/packages/analysis/perceptual-hash-comparison.mjs +34 -0
  199. package/packages/analysis/portable-generator.mjs +29 -0
  200. package/packages/analysis/project-metadata.mjs +8 -0
  201. package/packages/analysis/registrable-domain.mjs +56 -0
  202. package/packages/analysis/risk-calibration-summary.mjs +352 -0
  203. package/packages/analysis/risk-scoring.mjs +348 -0
  204. package/packages/analysis/scoring-evidence-quality.mjs +107 -0
  205. package/packages/analysis/semantic-version.mjs +54 -0
  206. package/packages/analysis/threat-intelligence-types.mjs +75 -0
  207. package/packages/cases/case-containment-handoff.mjs +195 -0
  208. package/packages/cases/case-evidence-links.mjs +151 -0
  209. package/packages/cases/case-evidence-model.mjs +89 -12
  210. package/packages/cases/case-evidence-quality.mjs +5 -0
  211. package/packages/cases/case-export-input.mjs +7 -5
  212. package/packages/cases/case-incident-context.mjs +52 -0
  213. package/packages/cases/case-markdown.mjs +11 -0
  214. package/packages/cases/case-migration-model.mjs +40 -24
  215. package/packages/cases/case-object-coverage.mjs +33 -0
  216. package/packages/cases/case-packet-correction.mjs +106 -0
  217. package/packages/cases/case-recheck-model.mjs +124 -36
  218. package/packages/cases/case-record-contracts.mjs +1 -1
  219. package/packages/cases/case-record-core.mjs +1 -1
  220. package/packages/cases/case-record-operations.mjs +111 -118
  221. package/packages/cases/case-record-projection.mjs +23 -4
  222. package/packages/cases/case-record-version-input.mjs +41 -0
  223. package/packages/cases/case-report.mjs +93 -27
  224. package/packages/cases/case-requested-evidence.mjs +146 -0
  225. package/packages/cases/case-response-actions.mjs +159 -1
  226. package/packages/cases/case-response-model.mjs +16 -1
  227. package/packages/cases/case-response-object.mjs +119 -0
  228. package/packages/cases/case-response-outcomes.mjs +108 -20
  229. package/packages/cases/case-response-packet-render.mjs +35 -12
  230. package/packages/cases/case-response-packet.mjs +55 -13
  231. package/packages/cases/case-response-records.mjs +0 -1
  232. package/packages/cases/case-response-review-inputs.mjs +60 -8
  233. package/packages/cases/case-storage-model.mjs +1 -1
  234. package/packages/cases/case-workflow-metadata.mjs +217 -0
  235. package/packages/cases/case-workflow-migration.mjs +43 -0
  236. package/packages/cases/http-summary.mjs +6 -1
  237. package/packages/comparison/favicon-similarity.mjs +1 -1
  238. package/packages/comparison/page-similarity.mjs +1 -1
  239. package/packages/comparison/relationship-evidence.mjs +21 -9
  240. package/packages/comparison/relationship-provenance.mjs +7 -1
  241. package/packages/contracts/analyst-interchange.mjs +35 -15
  242. package/packages/contracts/candidate-watch-review.mjs +54 -0
  243. package/packages/contracts/case-portability.mjs +360 -49
  244. package/packages/contracts/cli-command-semantics.mjs +5 -0
  245. package/packages/contracts/cli-grammar.mjs +6 -0
  246. package/packages/contracts/cli-lookup-brief.mjs +2 -0
  247. package/packages/contracts/context-review.mjs +25 -0
  248. package/packages/contracts/document-intake.mjs +11 -0
  249. package/packages/contracts/domain-control-flight-recorder.mjs +30 -14
  250. package/packages/contracts/domain-control-manifest.mjs +1 -1
  251. package/packages/contracts/domain-control-monitor.mjs +13 -2
  252. package/packages/contracts/domain-control-review.mjs +36 -15
  253. package/packages/contracts/external-observation-interchange.mjs +96 -28
  254. package/packages/contracts/har-review.mjs +2 -0
  255. package/packages/contracts/identity-events.mjs +4 -0
  256. package/packages/contracts/investigation-run.mjs +2 -0
  257. package/packages/contracts/ip-address.mjs +10 -0
  258. package/packages/contracts/lookup-evidence.mjs +3 -2
  259. package/packages/contracts/mail-authentication.mjs +5 -0
  260. package/packages/contracts/message-intake.mjs +37 -0
  261. package/packages/contracts/offline-comparison.mjs +4 -1
  262. package/packages/contracts/passive-mail.mjs +10 -0
  263. package/packages/contracts/risk-calibration.mjs +44 -29
  264. package/packages/contracts/schema-lifecycle.mjs +3 -1
  265. package/packages/contracts/web-capture.mjs +2 -2
  266. package/packages/contracts/workspace-portability.mjs +144 -28
  267. package/packages/evidence/collection-quality.mjs +67 -0
  268. package/packages/evidence/credential-form-attribution.mjs +60 -0
  269. package/packages/evidence/dns-query-outcome.mjs +72 -0
  270. package/packages/evidence/email-recipient.mjs +18 -0
  271. package/packages/evidence/image-regions.mjs +15 -0
  272. package/packages/evidence/lookup-target.mjs +5 -1
  273. package/packages/evidence/observation.mjs +2 -0
  274. package/packages/evidence/png-pixels.mjs +117 -0
  275. package/packages/evidence/public-address-policy.mjs +83 -0
  276. package/packages/evidence/record-identity.mjs +60 -16
  277. package/packages/evidence/technology-evidence-role.mjs +51 -0
  278. package/packages/interchange/defensive-indicator-export.mjs +264 -0
  279. package/packages/interchange/external-findings-import.mjs +36 -29
  280. package/packages/interchange/json-output.mjs +14 -0
  281. package/packages/interchange/managed-indicator-export.mjs +36 -0
  282. package/packages/interchange/managed-indicator-set.mjs +223 -0
  283. package/packages/interchange/misp-indicator-export.mjs +93 -0
  284. package/packages/interchange/sharing-policy.mjs +56 -0
  285. package/packages/interchange/stix-indicator-export.mjs +129 -0
  286. package/packages/interchange/web-capture-import.mjs +22 -15
  287. package/packages/investigation/acquisition-decision-packet.mjs +79 -1
  288. package/packages/investigation/bulk-mail-exposure.mjs +7 -13
  289. package/packages/investigation/capture-coverage.mjs +57 -0
  290. package/packages/investigation/connector-provenance-review.mjs +100 -0
  291. package/packages/investigation/context-review.mjs +35 -0
  292. package/packages/investigation/document-intake.mjs +87 -0
  293. package/packages/investigation/docx-intake.mjs +133 -0
  294. package/packages/investigation/domain-history-review.mjs +132 -0
  295. package/packages/investigation/domain-transition-review.mjs +49 -0
  296. package/packages/investigation/evidence-coverage-ledger.mjs +9 -1
  297. package/packages/investigation/har-intake.mjs +68 -0
  298. package/packages/investigation/identity-event-intake.mjs +131 -0
  299. package/packages/investigation/identity-incident-review.mjs +39 -0
  300. package/packages/investigation/incident-sequence-review.mjs +58 -0
  301. package/packages/investigation/infrastructure-observation.mjs +234 -0
  302. package/packages/investigation/intake-context.mjs +284 -0
  303. package/packages/investigation/intake-indicators.mjs +116 -0
  304. package/packages/investigation/intake-phones.mjs +155 -0
  305. package/packages/investigation/intake-report.mjs +20 -0
  306. package/packages/investigation/investigation-manifest.mjs +35 -9
  307. package/packages/investigation/link-intake.mjs +209 -0
  308. package/packages/investigation/lookup-asset-graph.mjs +1 -1
  309. package/packages/investigation/mail-authentication-review.mjs +222 -0
  310. package/packages/investigation/mail-header-identity.mjs +209 -0
  311. package/packages/investigation/message-intake.mjs +235 -0
  312. package/packages/investigation/page-behaviour.mjs +113 -0
  313. package/packages/investigation/pdf-intake.mjs +155 -0
  314. package/packages/investigation/platform-continuity-review.mjs +60 -0
  315. package/packages/investigation/qr-intake.mjs +56 -0
  316. package/packages/investigation/requested-action-hints.mjs +11 -0
  317. package/packages/investigation/selected-input-review.mjs +17 -0
  318. package/packages/investigation/storefront-review.mjs +70 -0
  319. package/packages/monitoring/domain-feed.mjs +321 -0
  320. package/packages/workspace/brand-candidate-workflow.mjs +351 -0
  321. package/packages/workspace/brand-keyword-campaign.mjs +231 -0
  322. package/packages/workspace/brand-profile-model.mjs +48 -4
  323. package/packages/workspace/bulk-review-model.mjs +28 -26
  324. package/packages/workspace/bulk-session-model.mjs +27 -13
  325. package/packages/workspace/campaign-model.mjs +1 -1
  326. package/packages/workspace/candidate-watch-handoff.mjs +195 -0
  327. package/packages/workspace/detection-rule-model.mjs +11 -3
  328. package/packages/workspace/hostile-input.mjs +1 -1
  329. package/packages/workspace/investigation-guide.mjs +2 -2
  330. package/packages/workspace/investigation-template-model.mjs +26 -2
  331. package/packages/workspace/page-baseline.mjs +2 -2
  332. package/packages/workspace/relationship-observation-model.mjs +17 -13
  333. package/packages/workspace/shortlist-model.mjs +24 -7
  334. package/packages/workspace/watchlist-history.mjs +59 -16
  335. package/packages/workspace/watchlist-store.mjs +152 -11
  336. package/packages/workspace/website-snapshot-model.mjs +28 -3
  337. package/packages/workspace/workspace-archive-crypto.mjs +1 -1
  338. package/packages/workspace/workspace-archive.mjs +5 -4
  339. package/third-party-notices.txt +549 -16
  340. package/lib/hostname.mjs +0 -2
@@ -0,0 +1,195 @@
1
+ import { projectCaseForAudience } from "./case-record-projection.mjs";
2
+ import { readEditableCaseExport } from "./case-export-input.mjs";
3
+ import { array, enumeration, exact, iso, text } from "../evidence/artifact-structure.mjs";
4
+ import { escapeCaseMarkdownInline } from "./case-markdown.mjs";
5
+ import { MAX_CASE_ASSERTIONS, MAX_CASE_EVIDENCE_PINS } from "../contracts/case-portability.mjs";
6
+ import { caseSelectedEvidenceSourceLimitations } from "./case-evidence-links.mjs";
7
+ export const CASE_CONTAINMENT_INPUT_SCHEMA = 'whoisleuth.internal-containment.input';
8
+ export const CASE_CONTAINMENT_HANDOFF_SCHEMA = 'whoisleuth.internal-containment';
9
+ export const CASE_CONTAINMENT_VERSION = 1;
10
+ export const MAX_CONTAINMENT_ASSERTIONS = 20;
11
+ export const MAX_CONTAINMENT_PINS = 40;
12
+ export const MAX_CONTAINMENT_OUTPUT_BYTES = 128 * 1024;
13
+ export const CONTAINMENT_RECIPIENT_ROLES = [
14
+ 'security_operations',
15
+ 'identity_response',
16
+ 'endpoint_response',
17
+ 'network_response',
18
+ ];
19
+ const ROLE_GUIDANCE = {
20
+ security_operations: [
21
+ 'Confirm the responsible internal owner and track the selected requests independently of external removal.',
22
+ 'Separate reported actions, retained observations and unresolved evidence before authorising a control.',
23
+ ],
24
+ identity_response: [
25
+ 'Review the selected requests against authorised account, session and application-grant evidence in the identity system.',
26
+ 'Do not treat a reported password entry, sign-in approval or provider resolution as proof of account compromise or recovery.',
27
+ ],
28
+ endpoint_response: [
29
+ 'Review the selected requests against authorised device evidence before deciding whether isolation, collection or remediation is needed.',
30
+ 'Do not execute supplied commands or attachments; a reported execution is not a verified device outcome.',
31
+ ],
32
+ network_response: [
33
+ 'Review exact scope, collateral effects and an appropriate review/expiry period before applying an approved network control.',
34
+ 'Shared infrastructure and literal indicators do not establish ownership, maliciousness or an appropriate blocking scope.',
35
+ ],
36
+ };
37
+ function uniqueIds(value, maximum, label) {
38
+ const ids = array(value, label, maximum).map((id) => text(id, label, 240));
39
+ if (new Set(ids).size !== ids.length)
40
+ throw new TypeError(`${label} must be unique.`);
41
+ return ids;
42
+ }
43
+ export function readContainmentSelection(raw) {
44
+ const value = exact(raw, ['audience', 'recipientRole', 'assertionIds', 'evidencePinIds'], 'Containment handoff selection');
45
+ return {
46
+ audience: enumeration(value.audience, ['internal', 'trusted', 'public'], 'Handoff audience'),
47
+ recipientRole: enumeration(value.recipientRole, CONTAINMENT_RECIPIENT_ROLES, 'Internal recipient role'),
48
+ assertionIds: uniqueIds(value.assertionIds, MAX_CONTAINMENT_ASSERTIONS, 'Selected follow-ups'),
49
+ evidencePinIds: uniqueIds(value.evidencePinIds, MAX_CONTAINMENT_PINS, 'Selected supporting pins'),
50
+ };
51
+ }
52
+ /** Select retained assertions; never infer completed containment from Case or provider status. */
53
+ export function previewCaseContainmentHandoff(record, raw, generatedAt) {
54
+ iso(generatedAt, 'Handoff generation time');
55
+ const selection = readContainmentSelection(raw);
56
+ array(record.assertions, 'Retained Case assertions', MAX_CASE_ASSERTIONS);
57
+ array(record.evidencePins, 'Retained Case pins', MAX_CASE_EVIDENCE_PINS);
58
+ const followUps = record.assertions.filter((item) => item.kind === 'next_step');
59
+ const original = selection.assertionIds.map((id) => {
60
+ const matches = followUps.filter((item) => item.id === id);
61
+ if (matches.length !== 1)
62
+ throw new TypeError('A selected follow-up is missing or ambiguous; refresh the selection.');
63
+ return matches[0];
64
+ });
65
+ const linkedIds = new Set(original.flatMap((item) => item.evidencePinIds));
66
+ for (const id of selection.evidencePinIds) {
67
+ if (!linkedIds.has(id) || record.evidencePins.filter((pin) => pin.id === id).length !== 1)
68
+ throw new TypeError('Select only uniquely retained pins linked to the selected follow-ups.');
69
+ }
70
+ const permitted = selection.audience !== 'public';
71
+ // Reuse the canonical audience policy before constructing a narrow projection.
72
+ const projected = projectCaseForAudience(record, selection.audience);
73
+ const pins = permitted
74
+ ? projected.evidencePins.filter((pin) => selection.evidencePinIds.includes(pin.id))
75
+ : [];
76
+ const assertions = permitted
77
+ ? original.map((item) => {
78
+ const selected = projected.assertions.find((assertion) => assertion.id === item.id);
79
+ return {
80
+ id: selected.id,
81
+ kind: 'next_step',
82
+ statement: selected.statement,
83
+ rationale: selected.rationale,
84
+ state: selected.state,
85
+ createdAt: selected.createdAt,
86
+ updatedAt: selected.updatedAt,
87
+ provenance: selected.provenance ?? null,
88
+ recheck: selected.recheck ?? null,
89
+ evidence: selected.evidencePinIds.map((id) => ({
90
+ evidencePinId: id,
91
+ stance: selected.evidenceRelations?.find((relation) => relation.evidencePinId === id)
92
+ ?.stance ?? 'not_recorded',
93
+ state: pins.some((pin) => pin.id === id)
94
+ ? 'included'
95
+ : record.evidencePins.some((pin) => pin.id === id)
96
+ ? 'not_selected'
97
+ : 'unavailable',
98
+ })),
99
+ };
100
+ })
101
+ : [];
102
+ const missingContext = assertions.some((item) => !item.evidence.length || item.evidence.some((link) => link.state !== 'included'));
103
+ const report = {
104
+ schema: CASE_CONTAINMENT_HANDOFF_SCHEMA,
105
+ version: CASE_CONTAINMENT_VERSION,
106
+ generatedAt,
107
+ audience: selection.audience,
108
+ recipientRole: selection.recipientRole,
109
+ case: { id: projected.id, domain: projected.domain, status: projected.status },
110
+ state: !permitted ||
111
+ !assertions.length ||
112
+ missingContext ||
113
+ pins.some((pin) => pin.completeness !== 'complete' || pin.truncated === true)
114
+ ? 'partial'
115
+ : 'reviewed',
116
+ reviewRequired: true,
117
+ actionsPerformed: false,
118
+ assertions,
119
+ evidencePins: pins,
120
+ disclosure: {
121
+ exportAllowed: permitted && assertions.length > 0,
122
+ included: permitted
123
+ ? [
124
+ 'Case identifier, domain and recorded status',
125
+ 'Only selected next-step statements, rationale, states and recording times',
126
+ 'Exact selected supporting pins and separately qualified evidence relationships',
127
+ ]
128
+ : ['Case identifier, domain and recorded status only'],
129
+ excluded: [
130
+ 'Unselected assertions and evidence',
131
+ 'Case title, notes, contacts, action recipients and original files',
132
+ 'Raw upstream responses, automatic assignments and control execution',
133
+ ...(permitted ? [] : ['Internal follow-up statements and their supporting pins']),
134
+ ],
135
+ caution: 'Selected statements and pin values can contain sensitive analyst text or exact scope. Review each included value for the intended recipient; minimisation is not automatic anonymisation.',
136
+ },
137
+ nextSteps: [...ROLE_GUIDANCE[selection.recipientRole]],
138
+ limitations: [
139
+ 'These are retained analyst requests, not verified assignments, executed controls or independent recovery results.',
140
+ 'Open and resolved assertion states are preserved as recorded. External provider resolution, object closure and Case status do not resolve open internal requests.',
141
+ 'Unselected or missing supporting pins remain explicit. A linked pin may support, contradict or leave a request unresolved; linkage does not establish truth.',
142
+ 'This local handoff sends no message, changes no Case state and includes no original attachment bytes.',
143
+ ...caseSelectedEvidenceSourceLimitations(pins, record.evidenceLinks, pins.map(pin => pin.id)),
144
+ ],
145
+ };
146
+ if (new TextEncoder().encode(JSON.stringify(report)).byteLength > MAX_CONTAINMENT_OUTPUT_BYTES)
147
+ throw new TypeError('The selected handoff exceeds 128 KiB. Choose fewer follow-ups or pins; nothing was silently omitted.');
148
+ return report;
149
+ }
150
+ export function buildCaseContainmentHandoff(record, selection, generatedAt, disclosureReviewed) {
151
+ const report = previewCaseContainmentHandoff(record, selection, generatedAt);
152
+ if (!disclosureReviewed || !report.disclosure.exportAllowed)
153
+ throw new TypeError('Review the audience disclosure and select at least one retained follow-up for an internal or trusted handoff.');
154
+ return report;
155
+ }
156
+ export function readCaseContainmentInput(raw, generatedAt) {
157
+ const input = exact(raw, ['schema', 'version', 'caseExport', 'caseId', 'selection', 'disclosureReviewed'], 'Containment handoff input');
158
+ if (input.schema !== CASE_CONTAINMENT_INPUT_SCHEMA || input.version !== CASE_CONTAINMENT_VERSION)
159
+ throw new TypeError('Unsupported containment handoff input version.');
160
+ const caseId = text(input.caseId, 'Selected Case ID', 240);
161
+ const selected = readEditableCaseExport(JSON.stringify(input.caseExport)).filter((record) => record.id === caseId);
162
+ if (selected.length !== 1)
163
+ throw new TypeError('Select one unambiguous Case from the supported export.');
164
+ if (input.disclosureReviewed !== true)
165
+ throw new TypeError('The selected handoff requires an explicit audience-disclosure review.');
166
+ return buildCaseContainmentHandoff(selected[0], readContainmentSelection(input.selection), generatedAt, true);
167
+ }
168
+ export function formatCaseContainmentHandoff(report) {
169
+ const escape = escapeCaseMarkdownInline;
170
+ const lines = [
171
+ `# Internal containment handoff`,
172
+ '',
173
+ `Audience: ${report.audience} · ${report.recipientRole.replaceAll('_', ' ')}`,
174
+ `Case: ${escape(report.case.domain)} · ${escape(report.case.id)} · recorded status ${escape(report.case.status)}`,
175
+ `Generated: ${report.generatedAt} · coverage ${report.state} · no actions performed`,
176
+ '',
177
+ '## Selected retained requests',
178
+ '',
179
+ ];
180
+ for (const item of report.assertions) {
181
+ lines.push(`- ${escape(item.statement)} — ${item.state} (${escape(item.id)})`);
182
+ if (item.rationale)
183
+ lines.push(` Rationale: ${escape(item.rationale)}`);
184
+ lines.push(` Recorded ${escape(item.createdAt)}; updated ${escape(item.updatedAt)}.`);
185
+ for (const link of item.evidence)
186
+ lines.push(` Evidence ${escape(link.evidencePinId)}: ${link.stance}; ${link.state.replaceAll('_', ' ')}.`);
187
+ if (!item.evidence.length)
188
+ lines.push(' No supporting context was linked.');
189
+ }
190
+ lines.push('', '## Selected supporting evidence', '');
191
+ for (const pin of report.evidencePins)
192
+ lines.push(`- ${escape(pin.label)} (${escape(pin.id)}): ${escape(pin.value)}`, ` Source: ${escape(pin.source)}; observed ${escape(pin.observedAt ?? 'unknown')}; ${pin.completeness}${pin.truncated ? '; truncated' : ''}.`, ...pin.limitations.map((value) => ` Limitation: ${escape(value)}`));
193
+ lines.push('', '## Recipient review', '', ...report.nextSteps.map((value) => `- ${value}`), '', '## Boundaries', '', ...report.limitations.map((value) => `- ${value}`));
194
+ return `${lines.join('\n')}\n`;
195
+ }
@@ -0,0 +1,151 @@
1
+ import { array, enumeration, exact, exactOptional, iso, text } from "../evidence/artifact-structure.mjs";
2
+ import { assertWorkspaceInputGraph } from "../workspace/hostile-input.mjs";
3
+ import { MAX_CASE_EVIDENCE_PINS, MAX_RESPONSE_RATIONALE_LENGTH } from "../contracts/case-portability.mjs";
4
+ import { SAFE_ID_RE, freshId } from "./case-response-values.mjs";
5
+ export const CASE_EVIDENCE_LINK_KINDS = ['derived_from', 'shared_source'];
6
+ /** Enough for both directed relationships between every admitted pin pair. */
7
+ export const MAX_CASE_EVIDENCE_LINKS = MAX_CASE_EVIDENCE_PINS * (MAX_CASE_EVIDENCE_PINS - 1) * CASE_EVIDENCE_LINK_KINDS.length;
8
+ function identifier(value) {
9
+ const id = text(value, 'Evidence link identity', 64);
10
+ if (!SAFE_ID_RE.test(id))
11
+ throw new TypeError('Evidence link identity is invalid.');
12
+ return id;
13
+ }
14
+ function explanation(value) {
15
+ const result = text(value, 'Evidence relationship basis', MAX_RESPONSE_RATIONALE_LENGTH).trim();
16
+ if (!result)
17
+ throw new TypeError('Explain the evidence relationship.');
18
+ return result;
19
+ }
20
+ export function readCaseEvidenceLinks(value) {
21
+ if (value === undefined)
22
+ return undefined;
23
+ assertWorkspaceInputGraph(value, 'Evidence relationships');
24
+ const links = array(value, 'Evidence relationships', MAX_CASE_EVIDENCE_LINKS).map(raw => {
25
+ const row = exactOptional(raw, ['id', 'fromPinId', 'toPinId', 'kind', 'basis', 'createdAt'], ['withdrawal'], 'Evidence relationship');
26
+ const fromPinId = identifier(row.fromPinId), toPinId = identifier(row.toPinId);
27
+ if (fromPinId === toPinId)
28
+ throw new TypeError('An evidence pin cannot derive from or share a source with itself.');
29
+ iso(row.createdAt, 'Relationship recording time');
30
+ let withdrawal;
31
+ if (row.withdrawal !== undefined) {
32
+ const item = exact(row.withdrawal, ['at', 'reason'], 'Relationship withdrawal');
33
+ iso(item.at, 'Withdrawal time');
34
+ if (Date.parse(item.at) < Date.parse(row.createdAt))
35
+ throw new TypeError('Withdrawal precedes the relationship.');
36
+ withdrawal = { at: item.at, reason: explanation(item.reason) };
37
+ }
38
+ return { id: identifier(row.id), fromPinId, toPinId,
39
+ kind: enumeration(row.kind, CASE_EVIDENCE_LINK_KINDS, 'Evidence relationship kind'),
40
+ basis: explanation(row.basis), createdAt: row.createdAt, ...(withdrawal ? { withdrawal } : {}) };
41
+ });
42
+ if (new Set(links.map(link => link.id)).size !== links.length)
43
+ throw new TypeError('Evidence relationship identities must be unique.');
44
+ return links;
45
+ }
46
+ function derivationGraph(links) {
47
+ const graph = new Map();
48
+ for (const link of links)
49
+ if (!link.withdrawal && link.kind === 'derived_from') {
50
+ const neighbours = graph.get(link.fromPinId) ?? [];
51
+ neighbours.push(link.toPinId);
52
+ graph.set(link.fromPinId, neighbours);
53
+ }
54
+ return graph;
55
+ }
56
+ function reaches(graph, from, to) {
57
+ const pending = [from], visited = new Set();
58
+ while (pending.length) {
59
+ const id = pending.pop();
60
+ if (id === to)
61
+ return true;
62
+ if (visited.has(id))
63
+ continue;
64
+ visited.add(id);
65
+ pending.push(...(graph.get(id) ?? []));
66
+ }
67
+ return false;
68
+ }
69
+ export function appendCaseEvidenceLink(current, input, pins, now) {
70
+ const row = exact(input, ['fromPinId', 'toPinId', 'kind', 'basis'], 'New evidence relationship');
71
+ const link = readCaseEvidenceLinks([{ ...row, id: freshId('evidence-link'), createdAt: now }])[0];
72
+ const ids = new Set(pins.map(pin => pin.id));
73
+ if (!ids.has(link.fromPinId) || !ids.has(link.toPinId))
74
+ throw new TypeError('Select two retained evidence pins.');
75
+ if (current.some(item => !item.withdrawal && item.kind === link.kind
76
+ && ((item.fromPinId === link.fromPinId && item.toPinId === link.toPinId)
77
+ || (link.kind === 'shared_source' && item.fromPinId === link.toPinId && item.toPinId === link.fromPinId)))) {
78
+ throw new TypeError('This evidence relationship is already recorded. Withdraw it before recording a replacement.');
79
+ }
80
+ if (link.kind === 'derived_from' && reaches(derivationGraph(current), link.toPinId, link.fromPinId))
81
+ throw new TypeError('This derivation would create a cycle. Review the existing links first.');
82
+ return readCaseEvidenceLinks([...current, link]);
83
+ }
84
+ export function withdrawCaseEvidenceLink(current, input, now) {
85
+ const row = exact(input, ['id', 'reason'], 'Evidence relationship withdrawal');
86
+ const id = identifier(row.id), existing = current.find(link => link.id === id);
87
+ if (!existing || existing.withdrawal)
88
+ throw new TypeError('This relationship is missing or already withdrawn. Refresh before reviewing it.');
89
+ return readCaseEvidenceLinks(current.map(link => link.id === id ? { ...link, withdrawal: { at: now, reason: explanation(row.reason) } } : link));
90
+ }
91
+ /** Keep unresolved references; an imported pin can have been omitted by its source. */
92
+ export function mergeCaseEvidenceLinks(local, incoming) {
93
+ if (local === undefined && incoming === undefined)
94
+ return undefined;
95
+ const merged = new Map((readCaseEvidenceLinks(local) ?? []).map(link => [link.id, link]));
96
+ for (const link of readCaseEvidenceLinks(incoming) ?? []) {
97
+ const existing = merged.get(link.id);
98
+ if (!existing) {
99
+ merged.set(link.id, link);
100
+ continue;
101
+ }
102
+ const { withdrawal: left, ...a } = existing, { withdrawal: right, ...b } = link;
103
+ if (JSON.stringify(a) !== JSON.stringify(b) || (left && right && JSON.stringify(left) !== JSON.stringify(right))) {
104
+ throw new TypeError('An imported evidence relationship conflicts with retained provenance. No relationship was overwritten.');
105
+ }
106
+ merged.set(link.id, { ...a, ...((left ?? right) ? { withdrawal: left ?? right } : {}) });
107
+ }
108
+ return readCaseEvidenceLinks([...merged.values()].sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
109
+ }
110
+ export function caseEvidenceLinkIssues(links, pins) {
111
+ const ids = new Set(pins.map(pin => pin.id));
112
+ const graph = derivationGraph(links);
113
+ return links.map(link => ({ link,
114
+ missingPinIds: [link.fromPinId, link.toPinId].filter(id => !ids.has(id)),
115
+ cyclic: !link.withdrawal && link.kind === 'derived_from' && reaches(graph, link.toPinId, link.fromPinId),
116
+ }));
117
+ }
118
+ /** Matching context is disclosed, never counted as independent corroboration. */
119
+ export function caseEvidenceSharedContext(pins) {
120
+ const groups = new Map();
121
+ function add(kind, identity, label, id) {
122
+ if (!identity?.trim())
123
+ return;
124
+ const key = JSON.stringify([kind, identity]);
125
+ const group = groups.get(key) ?? { kind, label, pinIds: [] };
126
+ group.pinIds.push(id);
127
+ groups.set(key, group);
128
+ }
129
+ for (const pin of pins) {
130
+ add('checkpoint', pin.checkpointId, 'Same collection checkpoint', pin.id);
131
+ add('import', pin.importContentSha256, 'Same imported content', pin.id);
132
+ add('source', pin.source, `Same declared source: ${pin.source}`, pin.id);
133
+ }
134
+ return [...groups.values()].filter(group => group.pinIds.length > 1);
135
+ }
136
+ export const CASE_SELECTED_EVIDENCE_SOURCE_LIMITATION = 'Selected evidence has retained shared-source or derivation context. Multiple references do not establish independent observations; underlying reporting or collection methods may remain unknown. Relationship details are not included in this output.';
137
+ /** Qualify a narrow disclosure without exposing relationship identities or private basis text. */
138
+ export function caseSelectedEvidenceSourceLimitations(pins, links, selectedPinIds) {
139
+ array(pins, 'Retained evidence pins', MAX_CASE_EVIDENCE_PINS);
140
+ array(links ?? [], 'Retained evidence relationships', MAX_CASE_EVIDENCE_LINKS);
141
+ array(selectedPinIds, 'Selected evidence pins', MAX_CASE_EVIDENCE_PINS);
142
+ const requested = new Set(selectedPinIds);
143
+ const selectedPins = pins.filter(pin => requested.has(pin.id));
144
+ const selected = new Set(selectedPins.map(pin => pin.id));
145
+ if (!selected.size)
146
+ return [];
147
+ const declared = links?.some(link => !link.withdrawal
148
+ && (selected.has(link.fromPinId) || selected.has(link.toPinId)));
149
+ return declared || caseEvidenceSharedContext(selectedPins).length
150
+ ? [CASE_SELECTED_EVIDENCE_SOURCE_LIMITATION] : [];
151
+ }
@@ -1,12 +1,13 @@
1
1
  // Pure, framework-neutral analyst-case records, evidence histories, bounded
2
2
  // record normalization, and analyst updates.
3
- import { normalizeHttpSummary } from "./http-summary.mjs";
4
- import { normalizeOpportunityModelVersion } from "../../lib/opportunity-scoring.mjs";
5
- import { normalizeRiskModelVersion } from "../../lib/risk-scoring.mjs";
3
+ import { httpSummaryFieldIsObserved, normalizeHttpSummary } from "./http-summary.mjs";
4
+ import { normalizeOpportunityModelVersion } from "../analysis/opportunity-scoring.mjs";
5
+ import { normalizeRiskModelVersion } from "../analysis/risk-scoring.mjs";
6
6
  import { latestObservationCohort } from "../evidence/latest-observations.mjs";
7
7
  import { normalizeExplicitIsoTimestamp } from "../evidence/observation.mjs";
8
- import { PUBLISHED_V2_3_CASE_SCHEMA_VERSION } from "../contracts/case-portability.mjs";
8
+ import { EVIDENCE_FOLLOW_UP_CASE_SCHEMA_VERSION, OBJECT_RESPONSE_CASE_SCHEMA_VERSION, PUBLISHED_V2_3_CASE_SCHEMA_VERSION } from "../contracts/case-portability.mjs";
9
9
  import { validWebObservationMode } from "../evidence/lookup-target.mjs";
10
+ import { normalizeWebCollectionQuality, webCollectionAllowsComparison } from "../evidence/collection-quality.mjs";
10
11
  import { MAX_EVIDENCE_CHANGES, MAX_EVIDENCE_DETAIL_LENGTH, MAX_EVIDENCE_FACTORS, MAX_EVIDENCE_MUTATIONS, MAX_EVIDENCE_NAMESERVERS, MAX_EVIDENCE_SNAPSHOTS_PER_CASE, MAX_EVIDENCE_STRING_LENGTH, MAX_EVIDENCE_TITLE_LENGTH, } from "./case-record-contracts.mjs";
11
12
  import { CONCLUSIVE_AVAILABILITY, DEFAULT_EVIDENCE_SOURCE, EVIDENCE_SOURCE_RANK, EVIDENCE_SOURCE_SET, REGISTERED_LIKE, caseTimestampOrNull, hashString, isoOrNull, normalizeEvidenceHostnameForCase, objectRecord, safeId, } from "./case-record-core.mjs";
12
13
  function evidenceString(value, max = MAX_EVIDENCE_STRING_LENGTH) {
@@ -112,6 +113,7 @@ const DEEP_SIGNAL_FIELDS = [
112
113
  'httpCrossOriginRedirect', 'httpHttpsDowngrade', 'httpContentType', 'httpSecurityHeaders',
113
114
  'faviconMatch', 'faviconNearMatch', 'reusesOfficialAssets', 'hasPasswordField', 'hasExternalFormAction', 'phishingLanguageMatch',
114
115
  'pageBaselineMatch',
116
+ 'hasExternalPasswordForm',
115
117
  ];
116
118
  // Ordered list of the fields that make up a snapshot's *material* identity -
117
119
  // everything except capture timestamps, snapshot id and source. `scanDepth` is
@@ -122,6 +124,7 @@ const MATERIAL_FIELD_ORDER = [
122
124
  'observationHostname',
123
125
  'webObservationMode',
124
126
  'scanDepth',
127
+ 'webCollectionQuality',
125
128
  'availability', 'confidence', 'riskModelVersion', 'riskScore', 'opportunityModelVersion', 'opportunityScore',
126
129
  'riskFactors', 'opportunityFactors',
127
130
  'registrar', 'createdDate', 'expiryDate', 'nameservers',
@@ -133,6 +136,7 @@ const MATERIAL_FIELD_ORDER = [
133
136
  'privacyProtected', 'idnReferenceMatch', 'pageBaselineMatch', 'hasActiveBrandProfile',
134
137
  'profileContextState', 'profileContextLimitation',
135
138
  'mutationTypes',
139
+ 'hasExternalPasswordForm',
136
140
  ];
137
141
  // The canonical, comparison-safe value of a material field. Registrar casing,
138
142
  // nameserver order, and sub-day timestamps are collapsed so they can never
@@ -174,7 +178,7 @@ function isEmptyMaterial(value) {
174
178
  }
175
179
  // Fields that describe the capture rather than assert evidence, so they never
176
180
  // on their own keep an otherwise-empty snapshot alive.
177
- const NON_EVIDENCE_MATERIAL = new Set(['inputHostname', 'observationHostname', 'webObservationMode', 'scanDepth', 'confidence']);
181
+ const NON_EVIDENCE_MATERIAL = new Set(['inputHostname', 'observationHostname', 'webObservationMode', 'scanDepth', 'webCollectionQuality', 'confidence']);
178
182
  // A snapshot with no material evidence (only timestamps/source/depth, or only a
179
183
  // bare confidence/unknown-availability) is dropped rather than added to a
180
184
  // timeline.
@@ -194,7 +198,7 @@ function canonicalMaterialString(snapshot, legacyOrder = false) {
194
198
  const value = materialValue(field, snapshot, legacyOrder);
195
199
  // Optional collection context does not alter historical fingerprints when
196
200
  // absent; recorded context separates otherwise-identical captures.
197
- if ((field === 'inputHostname' || field === 'observationHostname' || field === 'webObservationMode') && value === null)
201
+ if ((field === 'inputHostname' || field === 'observationHostname' || field === 'webObservationMode' || field === 'webCollectionQuality' || field === 'hasExternalPasswordForm') && value === null)
198
202
  continue;
199
203
  canonical[field] = value;
200
204
  }
@@ -221,6 +225,8 @@ function buildSnapshot(raw, options) {
221
225
  if (!raw || typeof raw !== 'object')
222
226
  return null;
223
227
  const record = objectRecord(raw);
228
+ if (record.hasExternalPasswordForm !== undefined && options.sourceVersion != null && options.sourceVersion < OBJECT_RESPONSE_CASE_SCHEMA_VERSION)
229
+ throw new TypeError('Password-form attribution requires Case schema 18 or later; historical evidence was not reinterpreted.');
224
230
  if (record.factorOrder !== undefined && record.factorOrder !== 'code-unit-v1')
225
231
  return null;
226
232
  const historicalSchema = options.sourceVersion !== undefined && Number(options.sourceVersion) <= PUBLISHED_V2_3_CASE_SCHEMA_VERSION;
@@ -231,6 +237,10 @@ function buildSnapshot(raw, options) {
231
237
  const legacyOrder = historicalSchema || (record.factorOrder === undefined && typeof record.fingerprint === 'string');
232
238
  const scanDepth = normalizeScanDepth(record.scanDepth);
233
239
  const httpSummary = normalizeHttpSummary(record);
240
+ const webCollectionQuality = normalizeWebCollectionQuality(record.webCollectionQuality, scanDepth);
241
+ if (webCollectionQuality && options.sourceVersion != null && options.sourceVersion < EVIDENCE_FOLLOW_UP_CASE_SCHEMA_VERSION) {
242
+ throw new TypeError('Web collection quality requires Case schema 17 or later; historical evidence was not reinterpreted.');
243
+ }
234
244
  const acceptsProfileContext = options.sourceVersion === undefined || Number(options.sourceVersion) >= 12;
235
245
  const acceptsInputHostname = options.sourceVersion === undefined || Number(options.sourceVersion) >= 13;
236
246
  const acceptsObservationHostname = options.sourceVersion === undefined || Number(options.sourceVersion) > PUBLISHED_V2_3_CASE_SCHEMA_VERSION;
@@ -248,6 +258,7 @@ function buildSnapshot(raw, options) {
248
258
  ...(observationHostname ? { observationHostname } : {}),
249
259
  ...(acceptsObservationHostname && scanDepth !== 'fast' && record.webObservationMode === 'selected_url' ? { webObservationMode: record.webObservationMode } : {}),
250
260
  scanDepth,
261
+ ...(webCollectionQuality ? { webCollectionQuality } : {}),
251
262
  availability: evidenceString(record.availability),
252
263
  confidence: evidenceString(record.confidence),
253
264
  riskModelVersion: normalizeRiskModelVersion(record.riskModelVersion),
@@ -281,6 +292,7 @@ function buildSnapshot(raw, options) {
281
292
  reusesOfficialAssets: boolOrNull(record.reusesOfficialAssets),
282
293
  hasPasswordField: boolOrNull(record.hasPasswordField),
283
294
  hasExternalFormAction: boolOrNull(record.hasExternalFormAction),
295
+ ...(record.hasExternalPasswordForm === undefined ? {} : { hasExternalPasswordForm: boolOrNull(record.hasExternalPasswordForm) }),
284
296
  phishingLanguageMatch: evidenceString(record.phishingLanguageMatch),
285
297
  privacyProtected: boolOrNull(record.privacyProtected),
286
298
  idnReferenceMatch: boolOrNull(record.idnReferenceMatch),
@@ -300,6 +312,12 @@ function buildSnapshot(raw, options) {
300
312
  fields.riskModelVersion = null;
301
313
  if (fields.opportunityScore === null && fields.opportunityFactors.length === 0)
302
314
  fields.opportunityModelVersion = null;
315
+ if (webCollectionQuality) {
316
+ for (const field of ['hasPasswordField', 'hasExternalFormAction', 'hasExternalPasswordForm', 'pageBaselineMatch', 'faviconMatch', 'faviconNearMatch', 'reusesOfficialAssets']) {
317
+ if (fields[field] === false && !webCollectionAllowsComparison(field, webCollectionQuality, scanDepth))
318
+ fields[field] = null;
319
+ }
320
+ }
303
321
  // A fast capture never evaluates the deep signals, so any value supplied for
304
322
  // them (e.g. a profile's default `false`) is discarded as unevaluated.
305
323
  if (scanDepth === 'fast') {
@@ -325,6 +343,7 @@ function buildSnapshot(raw, options) {
325
343
  reusesOfficialAssets: null,
326
344
  hasPasswordField: null,
327
345
  hasExternalFormAction: null,
346
+ ...(record.hasExternalPasswordForm === undefined ? {} : { hasExternalPasswordForm: null }),
328
347
  phishingLanguageMatch: null,
329
348
  pageBaselineMatch: null,
330
349
  });
@@ -404,6 +423,11 @@ function compareSnapshotChrono(a, b) {
404
423
  * @returns {CaseEvidenceSnapshot[]}
405
424
  */
406
425
  export function normalizeEvidenceHistory(rawList, options = {}) {
426
+ const ordered = [...evidenceByMaterial(rawList, options).values()].sort(compareSnapshotChrono);
427
+ const kept = ordered.slice(Math.max(0, ordered.length - MAX_EVIDENCE_SNAPSHOTS_PER_CASE));
428
+ return assignUniqueSnapshotIds(kept);
429
+ }
430
+ function evidenceByMaterial(rawList, options) {
407
431
  const list = Array.isArray(rawList) ? rawList : [];
408
432
  const byMaterial = new Map();
409
433
  for (const raw of list) {
@@ -415,9 +439,24 @@ export function normalizeEvidenceHistory(rawList, options = {}) {
415
439
  // collision can never merge two genuinely different snapshots.
416
440
  byMaterial.set(built.material, existing ? mergeDuplicateSnapshots(existing, built.snapshot) : built.snapshot);
417
441
  }
418
- const ordered = [...byMaterial.values()].sort(compareSnapshotChrono);
419
- const kept = ordered.slice(Math.max(0, ordered.length - MAX_EVIDENCE_SNAPSHOTS_PER_CASE));
420
- return assignUniqueSnapshotIds(kept);
442
+ return byMaterial;
443
+ }
444
+ /** Imports add observations without evicting existing local material. Ordinary
445
+ * collection still uses the newest-history policy in normalizeEvidenceHistory. */
446
+ export function mergeImportedEvidenceHistory(local, imported, caseDomain) {
447
+ const options = { source: 'import', fallback: null, caseDomain };
448
+ const retained = evidenceByMaterial(normalizeEvidenceHistory(local, options), options);
449
+ let omitted = 0;
450
+ for (const [material, incoming] of evidenceByMaterial(imported, options)) {
451
+ const existing = retained.get(material);
452
+ if (existing)
453
+ retained.set(material, mergeDuplicateSnapshots(existing, incoming));
454
+ else if (retained.size < MAX_EVIDENCE_SNAPSHOTS_PER_CASE)
455
+ retained.set(material, incoming);
456
+ else
457
+ omitted += 1;
458
+ }
459
+ return { records: assignUniqueSnapshotIds([...retained.values()].sort(compareSnapshotChrono)), omitted };
421
460
  }
422
461
  function assignUniqueSnapshotIds(snapshots) {
423
462
  const used = new Set();
@@ -455,6 +494,13 @@ export function latestCaseEvidence(record) {
455
494
  }
456
495
  /** Chronological presentation and comparison admission shared by the browser and Case report. */
457
496
  export function caseEvidenceTimeline(history) {
497
+ return buildEvidenceTimeline(history, true);
498
+ }
499
+ /** Reproduce published report fields only; not current analyst comparisons. */
500
+ export function publishedCaseEvidenceTimelineForVerification(history) {
501
+ return buildEvidenceTimeline(history, false);
502
+ }
503
+ function buildEvidenceTimeline(history, enforceCollectionQuality) {
458
504
  const dated = (history ?? []).map((snapshot) => ({
459
505
  snapshot,
460
506
  at: normalizeExplicitIsoTimestamp(snapshot.capturedAt),
@@ -475,10 +521,10 @@ export function caseEvidenceTimeline(history) {
475
521
  ? 'Equal-time snapshots have no unique before-and-after order; no temporal change is inferred.'
476
522
  : null;
477
523
  const comparable = previous && !orderingLimitation;
478
- const changes = comparable ? compareCaseEvidence(previous.snapshot, snapshot) : [];
524
+ const changes = comparable ? compareEvidence(previous.snapshot, snapshot, enforceCollectionQuality) : [];
479
525
  const incomparableReasons = orderingLimitation
480
526
  ? ['other']
481
- : comparable ? caseEvidenceIncomparableReasons(previous.snapshot, snapshot) : [];
527
+ : comparable ? incomparableReasonsForEvidence(previous.snapshot, snapshot, enforceCollectionQuality) : [];
482
528
  if (comparable && !changes.length && !incomparableReasons.length
483
529
  && canonicalMaterialString(previous.snapshot) !== canonicalMaterialString(snapshot))
484
530
  incomparableReasons.push('other');
@@ -543,6 +589,7 @@ const COMPARE_FIELDS = [
543
589
  { field: 'reusesOfficialAssets', scope: 'web', label: 'Official asset reuse', type: 'signal', depthGate: 'both-deep' },
544
590
  { field: 'hasPasswordField', scope: 'web', label: 'Password form', type: 'signal', depthGate: 'both-deep' },
545
591
  { field: 'hasExternalFormAction', scope: 'web', label: 'External form action', type: 'signal', depthGate: 'both-deep' },
592
+ { field: 'hasExternalPasswordForm', scope: 'web', label: 'Password form with declared external destination', type: 'signal', depthGate: 'both-deep' },
546
593
  { field: 'phishingLanguageMatch', scope: 'web', label: 'Phishing language', type: 'phishing', depthGate: 'both-deep' },
547
594
  { field: 'mutationTypes', scope: 'hostname', label: 'Mutation types', type: 'set' },
548
595
  ];
@@ -562,6 +609,13 @@ function opportunityModelComparable(previous, current) {
562
609
  function valuesMateriallyEqual(field, previous, current) {
563
610
  return JSON.stringify(materialValue(field, previous)) === JSON.stringify(materialValue(field, current));
564
611
  }
612
+ function collectionFieldComparable(field, snapshot) {
613
+ if (field === 'registrar' || field === 'createdDate' || field === 'expiryDate')
614
+ return isPresent(snapshot[field]);
615
+ return field.startsWith('http')
616
+ ? httpSummaryFieldIsObserved(field, snapshot)
617
+ : webCollectionAllowsComparison(field, snapshot.webCollectionQuality, snapshot.scanDepth);
618
+ }
565
619
  /**
566
620
  * Explains material fields that comparison gates deliberately suppress. The
567
621
  * reasons are stable machine values for UI/report wording; they are not risk
@@ -569,14 +623,27 @@ function valuesMateriallyEqual(field, previous, current) {
569
623
  * in the same observation produced an ordinary material change.
570
624
  * @param {CaseEvidenceSnapshot | null | undefined} previous
571
625
  * @param {CaseEvidenceSnapshot | null | undefined} current
572
- * @returns {Array<'observation-context' | 'opportunity-model' | 'scan-depth' | 'risk-model'>}
626
+ * @returns {Array<'observation-context' | 'opportunity-model' | 'scan-depth' | 'risk-model' | 'collection-quality'>}
573
627
  */
574
628
  export function caseEvidenceIncomparableReasons(previous, current) {
629
+ return incomparableReasonsForEvidence(previous, current, true);
630
+ }
631
+ function incomparableReasonsForEvidence(previous, current, enforceCollectionQuality) {
575
632
  if (!previous || !current || previous.fingerprint === current.fingerprint)
576
633
  return [];
577
634
  const reasons = [];
578
635
  if (!sameObservationContext(previous, current))
579
636
  reasons.push('observation-context');
637
+ const missingRegistrationValue = ['registrar', 'createdDate', 'expiryDate'].some(field => !valuesMateriallyEqual(field, previous, current) && (!isPresent(previous[field]) || !isPresent(current[field])));
638
+ const incompleteWebFields = previous.scanDepth === 'deep' && current.scanDepth === 'deep'
639
+ && (JSON.stringify(previous.webCollectionQuality) !== JSON.stringify(current.webCollectionQuality)
640
+ || COMPARE_FIELDS.some(spec => (spec.modelGate !== 'risk' || riskModelComparable(previous, current))
641
+ && (spec.modelGate !== 'opportunity' || opportunityModelComparable(previous, current))
642
+ && !valuesMateriallyEqual(spec.field, previous, current)
643
+ && (!collectionFieldComparable(spec.field, previous)
644
+ || !collectionFieldComparable(spec.field, current))));
645
+ if (enforceCollectionQuality && (missingRegistrationValue || incompleteWebFields))
646
+ reasons.push('collection-quality');
580
647
  const hasRiskEvidence = previous.riskScore !== null || current.riskScore !== null
581
648
  || previous.riskFactors.length > 0 || current.riskFactors.length > 0;
582
649
  if (hasRiskEvidence && !riskModelComparable(previous, current))
@@ -749,6 +816,10 @@ function compareField(spec, before, after) {
749
816
  return null;
750
817
  }
751
818
  }
819
+ /** Field scope is owned by the same specification that gates comparisons. */
820
+ export function caseEvidenceFieldScope(field) {
821
+ return COMPARE_FIELDS.find(spec => spec.field === field)?.scope ?? null;
822
+ }
752
823
  /**
753
824
  * Diffs two normalized snapshots into a bounded, stably-ordered list of
754
825
  * material changes. Timestamps, source, id and fingerprint are ignored;
@@ -763,6 +834,9 @@ function compareField(spec, before, after) {
763
834
  * @returns {Array<{ field: string, label: string, before: unknown, after: unknown, tone: string }>}
764
835
  */
765
836
  export function compareCaseEvidence(previous, current) {
837
+ return compareEvidence(previous, current, true);
838
+ }
839
+ function compareEvidence(previous, current, enforceCollectionQuality) {
766
840
  if (!previous || !current)
767
841
  return [];
768
842
  const bothDeep = previous.scanDepth === 'deep' && current.scanDepth === 'deep';
@@ -785,6 +859,9 @@ export function compareCaseEvidence(previous, current) {
785
859
  continue;
786
860
  if (spec.modelGate === 'opportunity' && !comparableOpportunityModel)
787
861
  continue;
862
+ if (enforceCollectionQuality && (!collectionFieldComparable(spec.field, previous)
863
+ || !collectionFieldComparable(spec.field, current)))
864
+ continue;
788
865
  const result = compareField(spec, previous[spec.field], current[spec.field]);
789
866
  if (result) {
790
867
  changes.push({ field: spec.field, label: spec.label, before: result.before, after: result.after, tone: result.tone });
@@ -0,0 +1,5 @@
1
+ /** Collection completeness does not establish a usable date, comparison or conclusion. */
2
+ export function casePinHasCompleteObservation(pin) {
3
+ return pin.completeness === 'complete' && !pin.truncated
4
+ && ['complete', 'success', 'reviewed', 'not_found'].includes(pin.sourceState ?? '');
5
+ }
@@ -1,14 +1,16 @@
1
- import { boundedJsonLimitsForBytes, parseBoundedJsonObject } from "../../lib/bounded-json.mjs";
1
+ import { boundedJsonLimitsForBytes, parseBoundedJsonObject } from "../analysis/bounded-json.mjs";
2
2
  import { canonicalArtifactJsonV2 } from "../evidence/artifact-integrity.mjs";
3
3
  import { normalizeExplicitIsoTimestamp } from "../evidence/observation.mjs";
4
- import { CASE_SCHEMA_VERSION, CLI_CASE_PACK_INPUT_CASE_VERSIONS, MAX_CASE_STORE_BYTES, MAX_EDITABLE_CASE_INPUT_BYTES, PUBLISHED_V2_3_CASE_SCHEMA_VERSION } from "../contracts/case-portability.mjs";
4
+ import { CASE_SCHEMA_VERSION, CLI_CASE_PACK_INPUT_CASE_VERSIONS, MAX_CASE_STORE_BYTES, MAX_EDITABLE_CASE_INPUT_BYTES, PUBLISHED_V2_3_CASE_SCHEMA_VERSION, EVIDENCE_FOLLOW_UP_CASE_SCHEMA_VERSION } from "../contracts/case-portability.mjs";
5
5
  import { normalizeCaseStore } from "./case-migration-model.mjs";
6
6
  import { serializeCaseStore } from "./case-storage-model.mjs";
7
7
  /** Historical wire shape remains independent of current in-memory defaults. */
8
8
  export function canonicalCaseExportProjection(cases, caseVersion) {
9
- if (caseVersion !== PUBLISHED_V2_3_CASE_SCHEMA_VERSION)
10
- return [...cases];
11
- return cases.map((value) => {
9
+ return cases.map((record) => {
10
+ const { workflowMetadata: _workflow, ...historical } = record;
11
+ const value = caseVersion < EVIDENCE_FOLLOW_UP_CASE_SCHEMA_VERSION ? historical : record;
12
+ if (caseVersion !== PUBLISHED_V2_3_CASE_SCHEMA_VERSION)
13
+ return value;
12
14
  if ([...value.evidencePins, ...value.sightings].some((item) => item.observedAt === null)) {
13
15
  throw new TypeError('The published Case 15 format requires a known observation time on each retained pin and sighting.');
14
16
  }