@slicedearth/whoisleuth-cli 2.4.0 → 2.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (300) hide show
  1. package/NOTICE +2 -2
  2. package/cli/argument-values.mjs +55 -0
  3. package/cli/arguments.mjs +24 -470
  4. package/cli/artifact-validation/case-response.mjs +21 -6
  5. package/cli/artifact-validation/lookup-evidence.mjs +3 -3
  6. package/cli/artifact-validation/signed-review.mjs +59 -13
  7. package/cli/artifact-verify.mjs +2 -0
  8. package/cli/assurance-arguments.mjs +56 -0
  9. package/cli/assurance-command-definitions.mjs +179 -0
  10. package/cli/assurance-command-runner.mjs +1 -1
  11. package/cli/bounded-json.mjs +1 -1
  12. package/cli/bulk-checkpoint.mjs +1 -1
  13. package/cli/bulk-command-runner.mjs +2 -2
  14. package/cli/bulk.mjs +2 -3
  15. package/cli/case-command.mjs +20 -3
  16. package/cli/case-pack.mjs +12 -2
  17. package/cli/ci-report.mjs +6 -3
  18. package/cli/collection-arguments.mjs +123 -0
  19. package/cli/collection-command-definitions.mjs +330 -0
  20. package/cli/collection-preflight.mjs +8 -1
  21. package/cli/command-argument-grammar.mjs +8 -210
  22. package/cli/command-definition.mjs +329 -0
  23. package/cli/command-reference.mjs +55 -1388
  24. package/cli/command-seeds.mjs +18 -0
  25. package/cli/compare.mjs +1 -1
  26. package/cli/config-profile.mjs +1 -1
  27. package/cli/ct-event-intake.mjs +4 -4
  28. package/cli/discover.mjs +2 -2
  29. package/cli/discovery-observation-snapshot.mjs +2 -2
  30. package/cli/discovery-scan-command-runner.mjs +3 -3
  31. package/cli/discovery-scan.mjs +1 -1
  32. package/cli/discovery-snapshot.mjs +2 -2
  33. package/cli/discovery-workflow.mjs +4 -4
  34. package/cli/domain-control-monitor.mjs +6 -6
  35. package/cli/domain-control-observations.mjs +2 -1
  36. package/cli/errors.mjs +5 -6
  37. package/cli/evidence-arguments.mjs +46 -0
  38. package/cli/evidence-command-definitions.mjs +69 -0
  39. package/cli/evidence-command-runner.mjs +2 -3
  40. package/cli/evidence-signing.mjs +3 -2
  41. package/cli/external-observation-mapping.mjs +2 -2
  42. package/cli/formatters/terminal-command-formats.mjs +6 -0
  43. package/cli/formatters/terminal-lookup.mjs +6 -0
  44. package/cli/history-arguments.mjs +39 -0
  45. package/cli/history-command-definitions.mjs +88 -0
  46. package/cli/indicator-set-command.mjs +33 -0
  47. package/cli/inline-command-families.mjs +6 -47
  48. package/cli/inline-command-runner.mjs +4 -31
  49. package/cli/input.mjs +2 -2
  50. package/cli/intake-command.mjs +107 -0
  51. package/cli/interchange-report.mjs +4 -2
  52. package/cli/investigation-artifacts.mjs +1 -1
  53. package/cli/investigation-plan.mjs +32 -154
  54. package/cli/investigation-recipes.mjs +177 -0
  55. package/cli/investigation-run.mjs +78 -12
  56. package/cli/local-mmdb-review.mjs +197 -17
  57. package/cli/lookalike-calibration.mjs +1 -1
  58. package/cli/lookup-brief.mjs +2 -2
  59. package/cli/lookup-command-runner.mjs +18 -35
  60. package/cli/lookup-diff.mjs +10 -9
  61. package/cli/lookup-plan.mjs +10 -14
  62. package/cli/lookup-reconcile.mjs +6 -5
  63. package/cli/lookup-timeline.mjs +7 -6
  64. package/cli/mail-header-review.mjs +24 -247
  65. package/cli/mail-review.mjs +9 -7
  66. package/cli/network-arguments.mjs +51 -0
  67. package/cli/network-command-definitions.mjs +130 -0
  68. package/cli/network-command-runner.mjs +43 -31
  69. package/cli/offline-evidence-review.mjs +39 -6
  70. package/cli/posture.mjs +1 -1
  71. package/cli/registry-fixture-scaffold.mjs +1 -1
  72. package/cli/retained-artifact-diff.mjs +8 -7
  73. package/cli/review-arguments.mjs +131 -0
  74. package/cli/review-command-definitions.mjs +273 -0
  75. package/cli/review-command-runner.mjs +68 -158
  76. package/cli/risk-calibration.mjs +8 -8
  77. package/cli/runner.mjs +20 -32
  78. package/cli/saved-lookup.mjs +2 -2
  79. package/cli/selected-input-worker.mjs +63 -0
  80. package/cli/sharing-review.mjs +5 -64
  81. package/cli/source-reliability.mjs +2 -2
  82. package/cli/support-arguments.mjs +49 -0
  83. package/cli/support-command-definitions.mjs +209 -0
  84. package/cli/support-command-runner.mjs +2 -2
  85. package/cli/workflow-arguments.mjs +86 -0
  86. package/cli/workflow-command-definitions.mjs +78 -0
  87. package/cli/workflow-command-runner.mjs +8 -1
  88. package/docs/cli-reference.md +27 -0
  89. package/docs/cli.md +223 -2
  90. package/docs/contextual-reviews.md +155 -0
  91. package/lib/analyst-taxonomy.mjs +2 -40
  92. package/lib/application-version.mjs +1 -1
  93. package/lib/availability.mjs +13 -789
  94. package/lib/bounded-contract-normalizers.mjs +4 -0
  95. package/lib/bounded-json.mjs +2 -330
  96. package/lib/browser-library-profile.mjs +62 -84
  97. package/lib/candidate-provenance-bounds.mjs +2 -0
  98. package/lib/classify.mjs +1 -1
  99. package/lib/ct-query.mjs +2 -27
  100. package/lib/ct-response-bounds.mjs +2 -9
  101. package/lib/ct-search.mjs +83 -47
  102. package/lib/dmarc-discovery.mjs +62 -0
  103. package/lib/dns-convergence-review.mjs +21 -8
  104. package/lib/dns-delegation-health.mjs +12 -8
  105. package/lib/dns-inheritance-review.mjs +53 -63
  106. package/lib/dns-intelligence.mjs +9 -7
  107. package/lib/dns-query-coverage.mjs +40 -0
  108. package/lib/domain-assurance.mjs +1 -1
  109. package/lib/domain-change-packet.mjs +12 -13
  110. package/lib/domain-change-review.mjs +32 -14
  111. package/lib/domain-control-flight-recorder.mjs +103 -27
  112. package/lib/domain-control-manifest.mjs +25 -17
  113. package/lib/domain-posture-analysis.mjs +22 -5
  114. package/lib/domain-posture.mjs +16 -15
  115. package/lib/domain-registration.mjs +392 -0
  116. package/lib/evidence-export-privacy.mjs +22 -15
  117. package/lib/evidence-export.mjs +3 -3
  118. package/lib/evidence-report.mjs +4 -1
  119. package/lib/external-intelligence-risk.mjs +2 -124
  120. package/lib/generated/cisa-kev-catalog.mjs +41 -3
  121. package/lib/generated/registrar-standing-catalogue.mjs +18 -10
  122. package/lib/generated/retire-browser-catalog.mjs +332 -19
  123. package/lib/generated/{unicode-confusables-17.mjs → unicode-confusables.mjs} +33 -33
  124. package/lib/html-signals.mjs +7 -2
  125. package/lib/http-evidence-bounds.mjs +2 -8
  126. package/lib/http-intelligence.mjs +13 -1
  127. package/lib/idn-confusable-policy.mjs +6 -4
  128. package/lib/idn-confusables.mjs +1 -1
  129. package/lib/interchange-fidelity-registry.mjs +10 -0
  130. package/lib/lookup-contract-primitives.mjs +1 -1
  131. package/lib/lookup-diagnostics.mjs +1 -23
  132. package/lib/lookup-network-evidence-bounds.mjs +1 -10
  133. package/lib/lookup-progress.mjs +50 -1
  134. package/lib/lookup-response-contract.mjs +69 -40
  135. package/lib/lookup-response.mjs +5 -15
  136. package/lib/lookup-source-progress.mjs +8 -22
  137. package/lib/lookup.mjs +1 -1
  138. package/lib/opportunity-scoring.mjs +2 -107
  139. package/lib/outbound-identity.mjs +1 -1
  140. package/lib/page-fingerprints.mjs +18 -1
  141. package/lib/perceptual-hash-comparison.mjs +2 -34
  142. package/lib/perceptual-hash.mjs +36 -10
  143. package/lib/portable-generator.mjs +2 -29
  144. package/lib/project-metadata.mjs +2 -8
  145. package/lib/rdap-client.mjs +11 -7
  146. package/lib/registrable-domain.mjs +2 -56
  147. package/lib/registrar-standing-contract.mjs +1 -1
  148. package/lib/registrar-standing.mjs +9 -13
  149. package/lib/registry-capabilities.mjs +2 -2
  150. package/lib/registry-capability-catalogue.mjs +27 -19
  151. package/lib/risk-calibration-summary.mjs +2 -352
  152. package/lib/risk-scoring.mjs +2 -345
  153. package/lib/scoring-evidence-quality.mjs +2 -107
  154. package/lib/semantic-version.mjs +2 -54
  155. package/lib/service-binding-dns.mjs +27 -0
  156. package/lib/smtp-transport-review.mjs +48 -19
  157. package/lib/sslbl-certificates.generated.mjs +141 -139
  158. package/lib/static-html-analysis.mjs +1 -1
  159. package/lib/structured-data-identity.mjs +1 -1
  160. package/lib/threat-intelligence-manifests.mjs +1 -1
  161. package/lib/threat-intelligence-runtime.mjs +1 -1
  162. package/lib/threat-intelligence-targets.mjs +1 -1
  163. package/lib/threat-intelligence-types.mjs +2 -75
  164. package/lib/typosquat-generator.mjs +11 -2
  165. package/lib/website-enrichment.mjs +388 -0
  166. package/lib/whois-authority.mjs +43 -5
  167. package/lib/whois-normalization.mjs +3 -1
  168. package/lib/whois-parser.mjs +5 -3
  169. package/package.json +13 -14
  170. package/packages/analysis/analyst-taxonomy.mjs +40 -0
  171. package/packages/analysis/bounded-json.mjs +330 -0
  172. package/packages/analysis/candidate-provenance-bounds.mjs +4 -0
  173. package/packages/analysis/cli-argument-grammar.mjs +216 -0
  174. package/packages/analysis/ct-query.mjs +27 -0
  175. package/packages/analysis/ct-response-bounds.mjs +9 -0
  176. package/packages/analysis/external-intelligence-risk.mjs +124 -0
  177. package/packages/analysis/http-evidence-bounds.mjs +8 -0
  178. package/packages/analysis/lookup-network-evidence-bounds.mjs +10 -0
  179. package/packages/analysis/opportunity-scoring.mjs +107 -0
  180. package/packages/analysis/perceptual-hash-comparison.mjs +34 -0
  181. package/packages/analysis/portable-generator.mjs +29 -0
  182. package/packages/analysis/project-metadata.mjs +8 -0
  183. package/packages/analysis/registrable-domain.mjs +56 -0
  184. package/packages/analysis/risk-calibration-summary.mjs +352 -0
  185. package/packages/analysis/risk-scoring.mjs +345 -0
  186. package/packages/analysis/scoring-evidence-quality.mjs +107 -0
  187. package/packages/analysis/semantic-version.mjs +54 -0
  188. package/packages/analysis/threat-intelligence-types.mjs +75 -0
  189. package/packages/cases/case-evidence-links.mjs +135 -0
  190. package/packages/cases/case-evidence-model.mjs +78 -12
  191. package/packages/cases/case-evidence-quality.mjs +5 -0
  192. package/packages/cases/case-export-input.mjs +7 -5
  193. package/packages/cases/case-incident-context.mjs +52 -0
  194. package/packages/cases/case-markdown.mjs +11 -0
  195. package/packages/cases/case-migration-model.mjs +40 -24
  196. package/packages/cases/case-recheck-model.mjs +90 -32
  197. package/packages/cases/case-record-contracts.mjs +1 -1
  198. package/packages/cases/case-record-core.mjs +1 -1
  199. package/packages/cases/case-record-operations.mjs +40 -112
  200. package/packages/cases/case-record-projection.mjs +8 -1
  201. package/packages/cases/case-record-version-input.mjs +19 -0
  202. package/packages/cases/case-report.mjs +63 -27
  203. package/packages/cases/case-requested-evidence.mjs +146 -0
  204. package/packages/cases/case-response-actions.mjs +103 -1
  205. package/packages/cases/case-response-model.mjs +9 -1
  206. package/packages/cases/case-response-outcomes.mjs +29 -8
  207. package/packages/cases/case-response-packet-render.mjs +13 -9
  208. package/packages/cases/case-response-packet.mjs +25 -11
  209. package/packages/cases/case-response-records.mjs +0 -1
  210. package/packages/cases/case-response-review-inputs.mjs +14 -3
  211. package/packages/cases/case-storage-model.mjs +1 -1
  212. package/packages/cases/case-workflow-metadata.mjs +225 -0
  213. package/packages/cases/case-workflow-migration.mjs +43 -0
  214. package/packages/cases/http-summary.mjs +6 -1
  215. package/packages/comparison/favicon-similarity.mjs +1 -1
  216. package/packages/comparison/page-similarity.mjs +1 -1
  217. package/packages/comparison/relationship-evidence.mjs +21 -9
  218. package/packages/comparison/relationship-provenance.mjs +7 -1
  219. package/packages/contracts/analyst-interchange.mjs +28 -10
  220. package/packages/contracts/case-portability.mjs +118 -25
  221. package/packages/contracts/cli-command-semantics.mjs +2 -0
  222. package/packages/contracts/cli-grammar.mjs +6 -0
  223. package/packages/contracts/cli-lookup-brief.mjs +2 -0
  224. package/packages/contracts/context-review.mjs +24 -0
  225. package/packages/contracts/document-intake.mjs +11 -0
  226. package/packages/contracts/domain-control-flight-recorder.mjs +30 -14
  227. package/packages/contracts/domain-control-manifest.mjs +1 -1
  228. package/packages/contracts/domain-control-monitor.mjs +13 -2
  229. package/packages/contracts/domain-control-review.mjs +36 -15
  230. package/packages/contracts/har-review.mjs +2 -0
  231. package/packages/contracts/identity-events.mjs +4 -0
  232. package/packages/contracts/investigation-run.mjs +2 -0
  233. package/packages/contracts/ip-address.mjs +10 -0
  234. package/packages/contracts/mail-authentication.mjs +5 -0
  235. package/packages/contracts/message-intake.mjs +31 -0
  236. package/packages/contracts/offline-comparison.mjs +4 -1
  237. package/packages/contracts/passive-mail.mjs +10 -0
  238. package/packages/contracts/web-capture.mjs +2 -2
  239. package/packages/contracts/workspace-portability.mjs +63 -17
  240. package/packages/evidence/collection-quality.mjs +67 -0
  241. package/packages/evidence/dns-query-outcome.mjs +72 -0
  242. package/packages/evidence/email-recipient.mjs +18 -0
  243. package/packages/evidence/lookup-target.mjs +5 -1
  244. package/packages/evidence/observation.mjs +2 -0
  245. package/packages/evidence/png-pixels.mjs +117 -0
  246. package/packages/evidence/public-address-policy.mjs +83 -0
  247. package/packages/interchange/defensive-indicator-export.mjs +264 -0
  248. package/packages/interchange/external-findings-import.mjs +4 -12
  249. package/packages/interchange/managed-indicator-export.mjs +35 -0
  250. package/packages/interchange/managed-indicator-set.mjs +210 -0
  251. package/packages/interchange/misp-indicator-export.mjs +93 -0
  252. package/packages/interchange/sharing-policy.mjs +56 -0
  253. package/packages/interchange/stix-indicator-export.mjs +129 -0
  254. package/packages/interchange/web-capture-import.mjs +22 -15
  255. package/packages/investigation/acquisition-decision-packet.mjs +1 -1
  256. package/packages/investigation/bulk-mail-exposure.mjs +7 -13
  257. package/packages/investigation/capture-coverage.mjs +57 -0
  258. package/packages/investigation/connector-provenance-review.mjs +100 -0
  259. package/packages/investigation/context-review.mjs +35 -0
  260. package/packages/investigation/document-intake.mjs +81 -0
  261. package/packages/investigation/docx-intake.mjs +133 -0
  262. package/packages/investigation/domain-history-review.mjs +100 -0
  263. package/packages/investigation/domain-transition-review.mjs +47 -0
  264. package/packages/investigation/evidence-coverage-ledger.mjs +9 -1
  265. package/packages/investigation/har-intake.mjs +68 -0
  266. package/packages/investigation/identity-event-intake.mjs +131 -0
  267. package/packages/investigation/identity-incident-review.mjs +39 -0
  268. package/packages/investigation/incident-sequence-review.mjs +58 -0
  269. package/packages/investigation/intake-report.mjs +19 -0
  270. package/packages/investigation/investigation-manifest.mjs +2 -2
  271. package/packages/investigation/link-intake.mjs +179 -0
  272. package/packages/investigation/lookup-asset-graph.mjs +1 -1
  273. package/packages/investigation/mail-authentication-review.mjs +222 -0
  274. package/packages/investigation/mail-header-identity.mjs +209 -0
  275. package/packages/investigation/message-intake.mjs +195 -0
  276. package/packages/investigation/page-behaviour.mjs +113 -0
  277. package/packages/investigation/pdf-intake.mjs +155 -0
  278. package/packages/investigation/platform-continuity-review.mjs +60 -0
  279. package/packages/investigation/qr-intake.mjs +56 -0
  280. package/packages/investigation/requested-action-hints.mjs +11 -0
  281. package/packages/investigation/selected-input-review.mjs +17 -0
  282. package/packages/investigation/storefront-review.mjs +70 -0
  283. package/packages/workspace/brand-profile-model.mjs +2 -2
  284. package/packages/workspace/bulk-review-model.mjs +28 -26
  285. package/packages/workspace/bulk-session-model.mjs +23 -13
  286. package/packages/workspace/campaign-model.mjs +1 -1
  287. package/packages/workspace/detection-rule-model.mjs +1 -1
  288. package/packages/workspace/hostile-input.mjs +1 -1
  289. package/packages/workspace/investigation-guide.mjs +2 -2
  290. package/packages/workspace/investigation-template-model.mjs +26 -2
  291. package/packages/workspace/page-baseline.mjs +2 -2
  292. package/packages/workspace/relationship-observation-model.mjs +17 -13
  293. package/packages/workspace/shortlist-model.mjs +14 -5
  294. package/packages/workspace/watchlist-history.mjs +28 -12
  295. package/packages/workspace/watchlist-store.mjs +17 -5
  296. package/packages/workspace/website-snapshot-model.mjs +28 -3
  297. package/packages/workspace/workspace-archive-crypto.mjs +1 -1
  298. package/packages/workspace/workspace-archive.mjs +5 -4
  299. package/third-party-notices.txt +549 -16
  300. package/lib/hostname.mjs +0 -2
@@ -1,7 +1,5 @@
1
1
  import { RESPONSE_AUTHORISATION_CONFIRMATION_IDS, responseContactLabel as contactLabel } from "./case-response-packet-vocabulary.mjs";
2
- function escapeMarkdown(value) {
3
- return value.replace(/([\\`*_[\]<>|])/gu, '\\$1').replace(/\r?\n/gu, ' ');
4
- }
2
+ import { escapeCaseMarkdownInline as escapeMarkdown } from "./case-markdown.mjs";
5
3
  /** Render only the projected packet, never the source Case or private inputs. */
6
4
  export function renderCaseResponsePacket(packet) {
7
5
  const { profile, case: caseRecord, incident: { category, affectedParty, abusiveUrls, observedHarm, observedAt }, generatedAt: normalizedGeneratedAt, recipientRoute, escalationHistory, escalationHistoryOmitted, escalationHistoryLimitations, authorisation, readiness, selectedEvidence, artefactReferences, responseLifecycle, preflight, provenance: { limitations, evidencePinCount, decisionCount, assertionCount, observationAge: age }, integrity: { digestSha256 }, } = packet;
@@ -49,10 +47,16 @@ export function renderCaseResponsePacket(packet) {
49
47
  ...(action.providerOutcome ? [` - Typed provider outcome: ${escapeMarkdown(action.providerOutcome.replaceAll('_', ' '))}`] : []),
50
48
  ...(action.outcomeDetail ? [` - Outcome detail: ${escapeMarkdown(action.outcomeDetail)}`] : []),
51
49
  ...(action.originActionId ? [` - Originating action: ${escapeMarkdown(action.originActionId)}`] : []),
50
+ ...(action.amendment ? [` - Amendment of submitted packet SHA-256: ${action.amendment.packetDigestSha256}`, ` - Prepared request events: ${action.amendment.requestEventIds.map(escapeMarkdown).join(', ')}`] : []),
52
51
  ...action.transitions.flatMap((event) => [
53
52
  ` - ${event.occurredAt}: ${escapeMarkdown(event.previousState ?? 'none')} → ${escapeMarkdown(event.nextState)} · ${escapeMarkdown(event.sourceClass)} · ${escapeMarkdown(event.provenance)}${event.providerOutcome ? ` · ${escapeMarkdown(event.providerOutcome.replaceAll('_', ' '))}` : ''}${event.applied ? '' : ' · retained conflict'}`,
54
53
  ...(event.reference ? [` - Reference: ${escapeMarkdown(event.reference)}`] : []),
55
54
  ...(event.evidencePinId ? [` - Evidence pin: ${escapeMarkdown(event.evidencePinId)}`] : []),
55
+ ...(event.evidenceRequest ? [
56
+ ` - Requested evidence (${event.evidenceRequest.state}): ${escapeMarkdown(event.evidenceRequest.summary)}`,
57
+ ` - Original packet SHA-256: ${event.evidenceRequest.packetDigestSha256}; deadline: ${event.evidenceRequest.dueAt ?? 'not provided'}`,
58
+ ` - Prepared pins: ${event.evidenceRequest.evidencePinIds.map(escapeMarkdown).join(', ') || 'none'}${event.evidenceRequest.rationale ? `; ${escapeMarkdown(event.evidenceRequest.rationale)}` : ''}`,
59
+ ] : []),
56
60
  ...(event.originActionId ? [` - Originating action: ${escapeMarkdown(event.originActionId)}`] : []),
57
61
  ...event.limitations.map((limitation) => ` - Limitation: ${escapeMarkdown(limitation)}`),
58
62
  ]),
@@ -136,12 +140,12 @@ export function renderCaseResponsePacket(packet) {
136
140
  `Reviewed packet SHA-256: ${digestSha256}`,
137
141
  'Attach the reviewed packet (and any separately reviewed evidence files) through the recipient’s approved submission channel; this message does not embed or transmit attachments.',
138
142
  '',
139
- responseLifecycle.latestProviderOutcome
140
- ? `Provider outcome time: ${responseLifecycle.latestProviderOutcome.occurredAt} (${responseLifecycle.latestProviderOutcome.outcome.replaceAll('_', ' ')})`
141
- : `Provider outcome time: Withheld because the typed event state is ${responseLifecycle.providerOutcomeState}.`,
142
- responseLifecycle.latestObservedChangeAt
143
- ? `Independently observed change time: ${responseLifecycle.latestObservedChangeAt}`
144
- : `Independently observed change time: Withheld because the independent change state is ${responseLifecycle.observedChangeState}.`,
143
+ ...(responseLifecycle.latestProviderOutcome
144
+ ? [`Provider outcome time: ${responseLifecycle.latestProviderOutcome.occurredAt} (${responseLifecycle.latestProviderOutcome.outcome.replaceAll('_', ' ')})`]
145
+ : []),
146
+ ...(responseLifecycle.latestObservedChangeAt
147
+ ? [`Independently observed change time: ${responseLifecycle.latestObservedChangeAt}`]
148
+ : []),
145
149
  '',
146
150
  'Please review this report under the applicable abuse and acceptable-use policies.',
147
151
  '',
@@ -2,21 +2,21 @@ import { renderCaseResponsePacket } from "./case-response-packet-render.mjs";
2
2
  import { responseContactLabel as contactLabel } from "./case-response-packet-vocabulary.mjs";
3
3
  // Pure abuse-evidence packet builder. It creates local review artifacts only:
4
4
  // no network requests, mailto links, submissions, or provider side effects.
5
- import { assertBoundedJsonStructure, } from "../../lib/bounded-json.mjs";
5
+ import { assertPacketAmendmentSelection } from "./case-requested-evidence.mjs";
6
+ import { assertBoundedJsonStructure, } from "../analysis/bounded-json.mjs";
6
7
  import { CASE_RESPONSE_PACKET_SCHEMA, CASE_RESPONSE_PACKET_VERSION, CASE_RESPONSE_REVIEW_INPUTS_SCHEMA, CASE_RESPONSE_REVIEW_INPUTS_VERSION, MAX_ABUSE_CATEGORY_LENGTH, MAX_ABUSIVE_URLS, MAX_AFFECTED_PARTY_LENGTH, MAX_EXACT_URL_LENGTH, MAX_RESPONSE_ACTION_HISTORY, MAX_RESPONSE_ARTEFACT_REFERENCES, MAX_RESPONSE_AUTHORISATION_CLOCK_SKEW_MS, MAX_RESPONSE_CONTRADICTIONS, MAX_RESPONSE_HARM_LENGTH, MAX_RESPONSE_SELECTED_EVIDENCE, SUPPORTED_CASE_RESPONSE_PACKET_VERSIONS, } from "../contracts/case-portability.mjs";
7
8
  import { SORTED_JSON_V2, canonicalArtifactJsonV2, } from "../evidence/artifact-integrity.mjs";
8
9
  import { normalizeExplicitIsoTimestamp, } from "../evidence/observation.mjs";
9
10
  import { latestCaseEvidence, } from "./case-evidence-model.mjs";
10
11
  import {} from "./case-model.mjs";
11
- import { caseDispositionSupportsDefensiveResponse, } from "./case-record-operations.mjs";
12
- import { buildCaseActionOutcomeSummary, buildCaseResponseLifecycleSummary, } from "./case-response-model.mjs";
12
+ import { caseDispositionSupportsDefensiveResponse } from "./case-record-decisions.mjs";
13
+ import { buildCaseActionOutcomeSummary, buildCaseResponseLifecycleSummary, caseActionCompletesResponseDecision, countEvidenceLinkedCaseDecisions, } from "./case-response-model.mjs";
13
14
  import { RESPONSE_AUTHORISATION_CONFIRMATION_IDS, RESPONSE_PACKET_PROFILE_IDS, RESPONSE_READINESS_STATES, } from "./case-response-packet-vocabulary.mjs";
14
15
  import { validateCaseResponseReviewInputs, } from "./case-response-review-inputs.mjs";
15
16
  import { responseRouteFreshness, } from "./response-route-freshness.mjs";
16
17
  export * from "./case-response-packet-vocabulary.mjs";
17
18
  export { validateCaseResponseReviewInputs, } from "./case-response-review-inputs.mjs";
18
- export { CASE_RESPONSE_PACKET_SCHEMA, CASE_RESPONSE_PACKET_VERSION, PUBLISHED_V2_2_CASE_RESPONSE_PACKET_VERSION, PUBLISHED_V2_3_CASE_RESPONSE_PACKET_VERSION, PUBLIC_CASE_RESPONSE_PACKET_VERSION, PUBLISHED_V2_CASE_RESPONSE_PACKET_VERSION, CASE_RESPONSE_REVIEW_INPUTS_SCHEMA, CASE_RESPONSE_REVIEW_INPUTS_VERSION, PUBLISHED_V2_2_CASE_RESPONSE_REVIEW_INPUTS_VERSION, PUBLISHED_V2_3_CASE_RESPONSE_REVIEW_INPUTS_VERSION, PUBLISHED_V2_CASE_RESPONSE_REVIEW_INPUTS_VERSION, MAX_ABUSE_CATEGORY_LENGTH, MAX_ABUSIVE_URLS, MAX_AFFECTED_PARTY_LENGTH, MAX_EXACT_URL_LENGTH, MAX_RESPONSE_ACTION_HISTORY, MAX_RESPONSE_ARTEFACT_REFERENCES, MAX_RESPONSE_AUTHORISATION_CLOCK_SKEW_MS, MAX_RESPONSE_CONTACTS, MAX_RESPONSE_CONTRADICTIONS, MAX_RESPONSE_HARM_LENGTH, MAX_RESPONSE_SELECTED_EVIDENCE, RESPONSE_ROUTE_STALE_AFTER_DAYS, SUPPORTED_CASE_RESPONSE_PACKET_VERSIONS, } from "../contracts/case-portability.mjs";
19
- export { LATEST_PUBLIC_CASE_RESPONSE_PACKET_VERSION, LATEST_PUBLIC_CASE_RESPONSE_REVIEW_INPUTS_VERSION, } from "../contracts/case-portability.mjs";
19
+ export { CASE_RESPONSE_PACKET_SCHEMA, CASE_RESPONSE_PACKET_VERSION, PUBLISHED_V2_2_CASE_RESPONSE_PACKET_VERSION, PUBLISHED_V2_3_CASE_RESPONSE_PACKET_VERSION, PUBLIC_CASE_RESPONSE_PACKET_VERSION, PUBLISHED_V2_CASE_RESPONSE_PACKET_VERSION, CASE_RESPONSE_REVIEW_INPUTS_SCHEMA, CASE_RESPONSE_REVIEW_INPUTS_VERSION, PUBLISHED_V2_2_CASE_RESPONSE_REVIEW_INPUTS_VERSION, PUBLISHED_V2_3_CASE_RESPONSE_REVIEW_INPUTS_VERSION, LATEST_PUBLIC_CASE_RESPONSE_PACKET_VERSION, LATEST_PUBLIC_CASE_RESPONSE_REVIEW_INPUTS_VERSION, PUBLISHED_V2_CASE_RESPONSE_REVIEW_INPUTS_VERSION, MAX_ABUSE_CATEGORY_LENGTH, MAX_ABUSIVE_URLS, MAX_AFFECTED_PARTY_LENGTH, MAX_EXACT_URL_LENGTH, MAX_RESPONSE_ACTION_HISTORY, MAX_RESPONSE_ARTEFACT_REFERENCES, MAX_RESPONSE_AUTHORISATION_CLOCK_SKEW_MS, MAX_RESPONSE_CONTACTS, MAX_RESPONSE_CONTRADICTIONS, MAX_RESPONSE_HARM_LENGTH, MAX_RESPONSE_SELECTED_EVIDENCE, RESPONSE_ROUTE_STALE_AFTER_DAYS, SUPPORTED_CASE_RESPONSE_PACKET_VERSIONS, } from "../contracts/case-portability.mjs";
20
20
  export const CASE_RESPONSE_PREFLIGHT_EVIDENCE_SCOPE = Object.freeze({
21
21
  version: 1,
22
22
  owner: 'case',
@@ -218,6 +218,11 @@ function selectedPacketAction(caseRecord, input) {
218
218
  : null;
219
219
  return actionId ? caseRecord.actions.find((action) => action.id === actionId) ?? null : null;
220
220
  }
221
+ function assertAmendmentEvidence(caseRecord, input) {
222
+ const action = selectedPacketAction(caseRecord, input);
223
+ const selected = new Set(Array.isArray(input.selectedEvidencePinIds) ? input.selectedEvidencePinIds : []);
224
+ assertPacketAmendmentSelection(caseRecord.actions, action?.id ?? null, new Set(caseRecord.evidencePins.filter(pin => selected.has(pin.id)).map(pin => pin.id)));
225
+ }
221
226
  function packetActionLineage(caseRecord, input) {
222
227
  const selected = selectedPacketAction(caseRecord, input);
223
228
  if (!selected)
@@ -534,6 +539,13 @@ export function buildCaseResponsePreflight(caseRecord, input, generatedAt = new
534
539
  const observedAt = timestamp(input.observedAt);
535
540
  const normalizedGeneratedAt = timestamp(generatedAt) || new Date().toISOString();
536
541
  const binding = bindPacketRoute(caseRecord, input, normalizedGeneratedAt);
542
+ let amendmentError = null;
543
+ try {
544
+ assertAmendmentEvidence(caseRecord, input);
545
+ }
546
+ catch (error) {
547
+ amendmentError = error instanceof Error ? error.message : 'Review this amendment and its retained evidence.';
548
+ }
537
549
  const urls = normalizeUrls(input.abusiveUrls);
538
550
  const selectedEvidence = normalizeSelectedEvidence(caseRecord, input.selectedEvidencePinIds);
539
551
  const requiredComplete = Boolean(text(input.category, MAX_ABUSE_CATEGORY_LENGTH)
@@ -546,10 +558,9 @@ export function buildCaseResponsePreflight(caseRecord, input, generatedAt = new
546
558
  .filter((item) => item.kind === 'contradiction' && item.state === 'open')
547
559
  .length;
548
560
  const actionSummary = buildCaseActionOutcomeSummary(caseRecord.actions, normalizedGeneratedAt);
549
- const retainedPinIds = new Set(caseRecord.evidencePins.map((pin) => pin.id));
550
- const evidenceLinkedDecisionCount = caseRecord.decisions.filter((decision) => decision.evidencePinIds.some((evidencePinId) => retainedPinIds.has(evidencePinId))).length;
561
+ const evidenceLinkedDecisionCount = countEvidenceLinkedCaseDecisions(caseRecord.decisions, caseRecord.evidencePins);
551
562
  const responseDisposition = caseDispositionSupportsDefensiveResponse(caseRecord.disposition);
552
- const reviewedActionCount = caseRecord.actions.filter((action) => ['reviewed', 'authorised', 'submitted', 'acknowledged', 'terminal'].includes(action.state)).length;
563
+ const reviewedActionCount = caseRecord.actions.filter((action) => caseActionCompletesResponseDecision(action.state)).length;
553
564
  const profile = responsePacketProfile(input.profile);
554
565
  const checks = [
555
566
  {
@@ -637,10 +648,10 @@ export function buildCaseResponsePreflight(caseRecord, input, generatedAt = new
637
648
  {
638
649
  id: 'packet_action',
639
650
  label: 'Packet action',
640
- state: binding.actionBinding.state === 'selected' ? 'pass' : profile.id === 'internal_soc' ? 'caution' : 'block',
641
- detail: binding.actionBinding.state === 'selected'
651
+ state: amendmentError ? 'block' : binding.actionBinding.state === 'selected' ? 'pass' : profile.id === 'internal_soc' ? 'caution' : 'block',
652
+ detail: amendmentError ?? (binding.actionBinding.state === 'selected'
642
653
  ? `Action ${binding.actionBinding.selectedActionId} owns this packet and its retained origin lineage.`
643
- : 'Select the retained Case action this packet prepares or documents.',
654
+ : 'Select the retained Case action this packet prepares or documents.'),
644
655
  },
645
656
  {
646
657
  id: 'action_tracking',
@@ -675,6 +686,7 @@ function normalizeActionHistory(caseRecord, input) {
675
686
  .map((action) => ({
676
687
  actionId: action.id,
677
688
  type: text(action.type, 80),
689
+ ...(action.amendment ? { amendment: structuredClone(action.amendment) } : {}),
678
690
  recipient: text(action.recipient, 320),
679
691
  contactSource: text(action.contactSource, 120),
680
692
  routeObservedAt: timestamp(action.routeObservedAt),
@@ -688,6 +700,7 @@ function normalizeActionHistory(caseRecord, input) {
688
700
  historyLimitations: normalizeLimitations(action.historyLimitations),
689
701
  transitions: action.history.map((event) => ({
690
702
  id: event.id,
703
+ ...(event.evidenceRequest ? { evidenceRequest: structuredClone(event.evidenceRequest) } : {}),
691
704
  previousState: event.previousState,
692
705
  nextState: event.nextState,
693
706
  occurredAt: event.occurredAt,
@@ -773,6 +786,7 @@ async function sha256(value) {
773
786
  return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, '0')).join('');
774
787
  }
775
788
  export function buildCaseResponseReviewInputs(caseRecord, input, generatedAt) {
789
+ assertAmendmentEvidence(caseRecord, input);
776
790
  const profile = buildResponsePacketProfilePreview(caseRecord, input);
777
791
  const category = text(input.category, MAX_ABUSE_CATEGORY_LENGTH);
778
792
  const selectedEvidence = normalizeSelectedEvidence(caseRecord, input.selectedEvidencePinIds);
@@ -1,4 +1,3 @@
1
- // Framework-neutral response record types and vocabulary.
2
1
  export const CASE_EVIDENCE_RELATION_STANCES = ['supports', 'contradicts', 'unresolved'];
3
2
  export const CASE_PIN_COMPLETENESS = ['complete', 'partial', 'inconclusive', 'unknown'];
4
3
  export const CASE_TRANSITION_EXPECTATIONS = ['preserve', 'change', 'review'];
@@ -1,8 +1,9 @@
1
1
  // Exact, bounded validation of current and published response-review inputs.
2
+ import { readCaseEvidenceRequest, readCasePacketAmendment, assertEvidenceRequestEvent, assertEvidenceRequestHistory, assertPacketAmendmentSelection } from "./case-requested-evidence.mjs";
2
3
  import { CASE_RESPONSE_REVIEW_INPUTS_SCHEMA, MAX_ABUSE_CATEGORY_LENGTH, MAX_ABUSIVE_URLS, MAX_AFFECTED_PARTY_LENGTH, MAX_CASE_ACTIONS, MAX_CASE_ACTION_EVENTS_PER_ACTION, MAX_CASE_ACTION_EVENTS_PER_CASE, MAX_EXACT_URL_LENGTH, MAX_RESPONSE_ACTION_HISTORY, MAX_RESPONSE_ARTEFACT_REFERENCES, MAX_RESPONSE_CONTACTS, MAX_RESPONSE_CONTRADICTIONS, MAX_RESPONSE_HARM_LENGTH, MAX_RESPONSE_LIMITATIONS, MAX_RESPONSE_LIMITATION_LENGTH, MAX_RESPONSE_REFERENCE_LENGTH, MAX_RESPONSE_SELECTED_EVIDENCE, MAX_RESPONSE_VALUE_LENGTH, PUBLISHED_V2_2_CASE_RESPONSE_REVIEW_INPUTS_VERSION, PUBLISHED_V2_3_CASE_RESPONSE_REVIEW_INPUTS_VERSION, SUPPORTED_CASE_RESPONSE_REVIEW_INPUTS_VERSIONS, } from "../contracts/case-portability.mjs";
3
4
  import { CASE_ACTION_EVENT_SOURCE_CLASSES, CASE_ACTION_STATES, CASE_ACTION_TYPES, CASE_ASSERTION_STATES, CASE_CLOSURE_REASONS, CASE_OBSERVED_EFFECT_SOURCE_CLASSES, CASE_OBSERVED_EFFECT_STATES, CASE_PIN_COMPLETENESS, CASE_PROVIDER_OUTCOMES, } from "./case-response-records.mjs";
4
5
  import { RESPONSE_CONTACT_KINDS, RESPONSE_PACKET_PROFILE_IDS, RESPONSE_READINESS_ROW_IDS, RESPONSE_READINESS_STATES, } from "./case-response-packet-vocabulary.mjs";
5
- import { isValidAsciiHostname } from "../../lib/hostname.mjs";
6
+ import { isValidAsciiHostname } from "../contracts/domain-name.mjs";
6
7
  const CONTACT_KINDS = new Set(RESPONSE_CONTACT_KINDS);
7
8
  const PRE_PLATFORM_CONTACT_KINDS = new Set(RESPONSE_CONTACT_KINDS.filter((kind) => kind !== 'application_platform'));
8
9
  const CONTROL_RE = /[\u0000-\u001f\u007f]/u;
@@ -318,7 +319,8 @@ export function validateCaseResponseReviewInputs(value) {
318
319
  ...(hasPlatformRoutes ? ['routeReviewAfter'] : []),
319
320
  'providerOutcome', 'outcomeDetail', 'originActionId', 'historyOmitted',
320
321
  'historyLimitations', 'transitions', 'createdAt', 'updatedAt',
321
- ], 'Case-response escalation action');
322
+ ], 'Case-response escalation action', version >= 5 ? ['amendment'] : []);
323
+ readCasePacketAmendment(action.amendment);
322
324
  for (const key of ['actionId', 'recipient', 'contactSource', 'createdAt', 'updatedAt']) {
323
325
  reviewText(action[key], MAX_RESPONSE_VALUE_LENGTH, `Case-response action ${key}`);
324
326
  }
@@ -338,12 +340,16 @@ export function validateCaseResponseReviewInputs(value) {
338
340
  reviewNullableEnum(action.providerOutcome, CASE_PROVIDER_OUTCOMES, 'Case-response action provider outcome');
339
341
  reviewCount(action.historyOmitted, MAX_CASE_ACTION_EVENTS_PER_CASE, 'Case-response omitted transition count');
340
342
  reviewStrings(action.historyLimitations, MAX_RESPONSE_LIMITATIONS, MAX_RESPONSE_LIMITATION_LENGTH, 'Case-response action history limitations');
343
+ const requestEvents = [];
341
344
  for (const candidateTransition of boundedReviewArray(action.transitions, MAX_CASE_ACTION_EVENTS_PER_ACTION, 'Case-response transitions')) {
342
345
  const transition = exactReviewRecord(candidateTransition, [
343
346
  'id', 'previousState', 'nextState', 'occurredAt', 'sourceClass', 'provenance',
344
347
  'reference', 'evidencePinId', 'limitations', 'providerOutcome', 'outcomeDetail',
345
348
  'originActionId', 'applied',
346
- ], 'Case-response transition');
349
+ ], 'Case-response transition', version >= 5 ? ['evidenceRequest'] : []);
350
+ const evidenceRequest = readCaseEvidenceRequest(transition.evidenceRequest);
351
+ assertEvidenceRequestEvent(evidenceRequest, transition);
352
+ requestEvents.push({ id: transition.id, ...(evidenceRequest ? { evidenceRequest } : {}) });
347
353
  for (const key of ['id', 'occurredAt', 'provenance']) {
348
354
  reviewText(transition[key], MAX_RESPONSE_VALUE_LENGTH, `Case-response transition ${key}`);
349
355
  }
@@ -358,6 +364,7 @@ export function validateCaseResponseReviewInputs(value) {
358
364
  throw new TypeError('Case-response transition applied state is invalid.');
359
365
  reviewStrings(transition.limitations, MAX_RESPONSE_LIMITATIONS, MAX_RESPONSE_LIMITATION_LENGTH, 'Case-response transition limitations');
360
366
  }
367
+ assertEvidenceRequestHistory(requestEvents, Number(action.historyOmitted) > 0);
361
368
  }
362
369
  if (currentLineageActionIds
363
370
  && (currentLineageActionIds.length !== escalationActionIds.length
@@ -365,6 +372,10 @@ export function validateCaseResponseReviewInputs(value) {
365
372
  throw new TypeError('Case-response escalation history must match the selected action lineage in order.');
366
373
  }
367
374
  reviewCount(source.escalationHistoryOmitted, MAX_CASE_ACTIONS, 'Case-response omitted action count');
375
+ if (version >= 5)
376
+ assertPacketAmendmentSelection(source.escalationHistory.map(action => ({
377
+ id: action.actionId, history: action.transitions, originActionId: action.originActionId, amendment: action.amendment,
378
+ })), currentLineageActionIds?.[0] ?? null, new Set(source.selectedEvidence.map(pin => pin.id)));
368
379
  reviewStrings(source.escalationHistoryLimitations, MAX_RESPONSE_LIMITATIONS, MAX_RESPONSE_LIMITATION_LENGTH, 'Case-response escalation limitations');
369
380
  validateReviewLifecycle(source.responseLifecycle);
370
381
  return recursivelyFreezeReviewValue(structuredClone(source));
@@ -1,4 +1,4 @@
1
- import { CASE_SCHEMA_VERSION, MAX_CASE_STORE_BYTES, } from "./case-record-model.mjs";
1
+ import { CASE_SCHEMA_VERSION, MAX_CASE_STORE_BYTES } from "../contracts/case-portability.mjs";
2
2
  import { normalizeCaseStore } from "./case-migration-model.mjs";
3
3
  import { projectCaseForDurableWrite } from "./case-record-projection.mjs";
4
4
  /** The exact versioned string persisted by the browser storage adapter. */
@@ -0,0 +1,225 @@
1
+ // Typed analyst metadata; ordinary tags and assertions are always literal.
2
+ import { MAX_CASE_ASSERTIONS, MAX_RESPONSE_RATIONALE_LENGTH, MAX_CASE_OBJECTIVE_LENGTH } from "../contracts/case-portability.mjs";
3
+ import { caseInvestigationContext, prepareCaseInvestigationContext, parseIncidentUrlContext, normalizeCaseObjective } from "./case-incident-context.mjs";
4
+ import { safeId, makeId } from "./case-record-core.mjs";
5
+ import { array, enumeration, exact, iso, text } from "../evidence/artifact-structure.mjs";
6
+ import { assertWorkspaceInputGraph } from "../workspace/hostile-input.mjs";
7
+ // Preserve the maximum URL previously admitted inside a 21-character label.
8
+ export const MAX_CASE_INCIDENT_TARGET_URL_LENGTH = MAX_RESPONSE_RATIONALE_LENGTH - 21;
9
+ export const MAX_CASE_INCIDENT_TARGETS = 20;
10
+ export const MAX_CASE_INCIDENT_TARGET_HISTORY = MAX_CASE_ASSERTIONS;
11
+ export const CASE_TYPES = Object.freeze([
12
+ Object.freeze({ id: 'phishing', label: 'Phishing', description: 'Deceptive content or messages intended to obtain credentials or other sensitive information.' }),
13
+ Object.freeze({ id: 'impersonation', label: 'Impersonation', description: 'A domain, account or page presenting itself as another person or organisation.' }),
14
+ Object.freeze({ id: 'lookalike_cybersquatting', label: 'Lookalike or cybersquatting', description: 'A domain or account selected for review because it resembles or may exploit another name, brand or identifier.' }),
15
+ Object.freeze({ id: 'trademark_infringement', label: 'Trademark infringement', description: 'Potential unauthorised trademark use that may cause confusion about source or affiliation.' }),
16
+ Object.freeze({ id: 'copyright_infringement', label: 'Copyright infringement', description: 'Potential unauthorised copying or distribution of protected material.' }),
17
+ Object.freeze({ id: 'counterfeit_goods', label: 'Counterfeit goods', description: 'Promotion or sale of goods presented as genuine branded products.' }),
18
+ Object.freeze({ id: 'scam_fraud', label: 'Scam or fraud', description: 'Deceptive activity intended to obtain money, property or another benefit.' }),
19
+ Object.freeze({ id: 'malware_distribution', label: 'Malware distribution', description: 'Delivery, hosting or promotion of malicious software.' }),
20
+ Object.freeze({ id: 'credential_theft', label: 'Credential theft', description: 'Collection, sale or misuse of account credentials.' }),
21
+ Object.freeze({ id: 'spam_platform_abuse', label: 'Spam or platform abuse', description: 'Unsolicited or abusive platform activity that does not fit a narrower type.' }),
22
+ Object.freeze({ id: 'privacy_personal_data', label: 'Privacy or personal data', description: 'Potential exposure or misuse of personal information.' }),
23
+ Object.freeze({ id: 'account_compromise', label: 'Account compromise', description: 'Suspected unauthorised control or use of an account.' }),
24
+ Object.freeze({ id: 'other', label: 'Other', description: 'A reviewed type not covered by the current taxonomy.' }),
25
+ ]);
26
+ const CASE_TYPE_IDS = new Set(CASE_TYPES.map((item) => item.id));
27
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu;
28
+ const CASE_REFERENCE_ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
29
+ export function readCaseTypes(value) {
30
+ assertWorkspaceInputGraph(value, 'Case types');
31
+ const selected = array(value, 'Case types', CASE_TYPES.length).map(id => {
32
+ if (typeof id !== 'string' || !CASE_TYPE_IDS.has(id))
33
+ throw new TypeError('Unknown Case type.');
34
+ return id;
35
+ });
36
+ if (new Set(selected).size !== selected.length)
37
+ throw new TypeError('Case types must be unique.');
38
+ return CASE_TYPES.map(item => item.id).filter(id => selected.includes(id));
39
+ }
40
+ export function caseTypeIds(record) {
41
+ return [...(record.workflowMetadata?.types ?? [])];
42
+ }
43
+ export function caseTypeRecords(record) {
44
+ const selected = new Set(caseTypeIds(record));
45
+ return CASE_TYPES.filter((item) => selected.has(item.id));
46
+ }
47
+ export function caseTypeSummary(record) {
48
+ const labels = caseTypeRecords(record).map((item) => item.label);
49
+ if (!labels.length)
50
+ return '';
51
+ if (labels.length <= 2)
52
+ return labels.join(' and ');
53
+ return `${labels.slice(0, 2).join(', ')} and ${labels.length - 2} more`;
54
+ }
55
+ function encodeCaseReference(value, minimumLength) {
56
+ let remainder = value;
57
+ let encoded = '';
58
+ do {
59
+ encoded = CASE_REFERENCE_ALPHABET[Number(remainder % 32n)] + encoded;
60
+ remainder /= 32n;
61
+ } while (remainder > 0n);
62
+ return encoded.padStart(minimumLength, '0');
63
+ }
64
+ /**
65
+ * Returns an unambiguous reference derived injectively from the immutable Case
66
+ * id. UUIDs use their complete 128-bit value. Legacy ids use every UTF-8 byte,
67
+ * its encoded length and a distinct namespace, so no shared counter or server
68
+ * is needed and imports retain the same reference.
69
+ */
70
+ export function caseNumber(caseId) {
71
+ const id = typeof caseId === 'string' && caseId ? caseId : 'unknown';
72
+ if (UUID_RE.test(id)) {
73
+ return `WS-${encodeCaseReference(BigInt(`0x${id.replaceAll('-', '')}`), 26)}`;
74
+ }
75
+ const bytes = new TextEncoder().encode(id);
76
+ let encoded = 0n;
77
+ for (const byte of bytes)
78
+ encoded = encoded * 256n + BigInt(byte);
79
+ return `WS-L${encodeCaseReference(BigInt(bytes.length), 2)}-${encodeCaseReference(encoded, Math.ceil(bytes.length * 8 / 5))}`;
80
+ }
81
+ export function formattedCaseNumber(caseId) {
82
+ const value = caseNumber(caseId);
83
+ if (value.startsWith('WS-L'))
84
+ return value;
85
+ const separator = value.indexOf('-');
86
+ const prefix = value.slice(0, separator + 1);
87
+ const body = value.slice(separator + 1);
88
+ return `${prefix}${body.match(/.{1,5}/gu)?.join('-') ?? body}`;
89
+ }
90
+ export function normalizeCaseIncidentTargetUrl(value) {
91
+ if (typeof value !== 'string' || value.length > MAX_CASE_INCIDENT_TARGET_URL_LENGTH || !value.trim())
92
+ return null;
93
+ let parsed;
94
+ try {
95
+ parsed = new URL(value.trim());
96
+ }
97
+ catch {
98
+ return null;
99
+ }
100
+ if (!['http:', 'https:'].includes(parsed.protocol) || !parsed.hostname || parsed.username || parsed.password)
101
+ return null;
102
+ const normalized = parsed.toString();
103
+ return normalized.length <= MAX_CASE_INCIDENT_TARGET_URL_LENGTH ? normalized : null;
104
+ }
105
+ /** Local review only: analyst link state is not independent observed coverage. */
106
+ export function buildCaseIncidentCoverage(record) {
107
+ return caseIncidentTargets(record, { includeResolved: true }).map(target => ({
108
+ target,
109
+ hostname: new URL(target.url).hostname,
110
+ actionCoverage: 'unknown',
111
+ observationCoverage: 'unknown',
112
+ }));
113
+ }
114
+ export function emptyCaseWorkflowMetadata() {
115
+ return { types: [], incidentTargets: [], investigationContext: null };
116
+ }
117
+ function identifier(value) {
118
+ const id = safeId(value);
119
+ if (!id || id !== value)
120
+ throw new TypeError('Case metadata identity is invalid.');
121
+ return id;
122
+ }
123
+ /** Strict admission before persistence; no inferred defaults for typed fields. */
124
+ export function readCaseWorkflowMetadata(value, domain) {
125
+ if (value === undefined)
126
+ return undefined;
127
+ assertWorkspaceInputGraph(value, 'Case workflow metadata');
128
+ const row = exact(value, ['types', 'incidentTargets', 'investigationContext'], 'Case workflow metadata');
129
+ const types = readCaseTypes(row.types);
130
+ const incidentTargets = array(row.incidentTargets, 'Incident target history', MAX_CASE_INCIDENT_TARGET_HISTORY).map(raw => {
131
+ const item = exact(raw, ['id', 'url', 'state', 'createdAt', 'updatedAt'], 'Incident target');
132
+ const url = normalizeCaseIncidentTargetUrl(item.url);
133
+ if (!url || url !== item.url)
134
+ throw new TypeError('Incident target URL is invalid.');
135
+ iso(item.createdAt, 'Incident target creation time');
136
+ iso(item.updatedAt, 'Incident target update time');
137
+ if (Date.parse(item.updatedAt) < Date.parse(item.createdAt))
138
+ throw new TypeError('Incident target update precedes creation.');
139
+ return { id: identifier(item.id), url, state: enumeration(item.state, ['open', 'resolved'], 'Incident target state'),
140
+ createdAt: item.createdAt, updatedAt: item.updatedAt };
141
+ });
142
+ if (new Set(incidentTargets.map(item => item.id)).size !== incidentTargets.length)
143
+ throw new TypeError('Incident target identities must be unique.');
144
+ let investigationContext = null;
145
+ if (row.investigationContext !== null) {
146
+ const item = exact(row.investigationContext, ['id', 'objective', 'incidentUrl', 'urlRetention', 'updatedAt'], 'Incident context');
147
+ const objective = text(item.objective, 'Investigation objective', MAX_CASE_OBJECTIVE_LENGTH);
148
+ if (!objective || normalizeCaseObjective(objective) !== objective)
149
+ throw new TypeError('Investigation objective is invalid.');
150
+ const parsed = parseIncidentUrlContext(item.incidentUrl);
151
+ const urlRetention = enumeration(item.urlRetention, ['exact', 'origin_only'], 'Incident URL retention');
152
+ if (!parsed || parsed.registrableDomain !== domain
153
+ || item.incidentUrl !== (urlRetention === 'exact' ? parsed.exactUrl : parsed.originUrl)) {
154
+ throw new TypeError('Incident context URL does not match its Case domain or retention choice.');
155
+ }
156
+ iso(item.updatedAt, 'Incident context update time');
157
+ investigationContext = { id: identifier(item.id), objective, incidentUrl: item.incidentUrl,
158
+ urlRetention, updatedAt: item.updatedAt };
159
+ }
160
+ return { types, incidentTargets, investigationContext };
161
+ }
162
+ /** Mutations use the existing Case save coordinator and compare-and-swap rules. */
163
+ export function updateCaseWorkflowMetadata(current, patch, domain, now) {
164
+ const next = structuredClone(current);
165
+ if (patch.caseTypes !== undefined) {
166
+ if (patch.expectedCaseTypes !== undefined && JSON.stringify(readCaseTypes(patch.expectedCaseTypes)) !== JSON.stringify(current.types)) {
167
+ throw new Error('The Case types changed after this edit was started. Reopen the Case before saving; your draft was not applied.');
168
+ }
169
+ next.types = readCaseTypes(patch.caseTypes);
170
+ }
171
+ if (patch.incidentTarget !== undefined) {
172
+ const url = normalizeCaseIncidentTargetUrl(patch.incidentTarget);
173
+ if (!url)
174
+ throw new TypeError('Enter an exact HTTP(S) incident URL without embedded credentials.');
175
+ if (next.incidentTargets.some(target => target.state === 'open' && target.url === url))
176
+ throw new Error('That exact incident URL is already active in this Case.');
177
+ if (next.incidentTargets.filter(target => target.state === 'open').length >= MAX_CASE_INCIDENT_TARGETS)
178
+ throw new Error(`A Case can retain at most ${MAX_CASE_INCIDENT_TARGETS} active incident links. Resolve one before adding another.`);
179
+ if (next.incidentTargets.length >= MAX_CASE_INCIDENT_TARGET_HISTORY)
180
+ throw new Error(`The retained incident link history is full (${MAX_CASE_INCIDENT_TARGET_HISTORY}). Export this Case and open another; no history was removed.`);
181
+ next.incidentTargets.push({ id: makeId(), url, state: 'open', createdAt: now, updatedAt: now });
182
+ }
183
+ if (patch.incidentTargetResolution !== undefined) {
184
+ const id = identifier(patch.incidentTargetResolution);
185
+ const target = next.incidentTargets.find(item => item.id === id);
186
+ if (!target || target.state !== 'open')
187
+ throw new Error('That incident target is missing or already resolved. Refresh the Case before continuing.');
188
+ next.incidentTargets = next.incidentTargets.map(item => item.id === id ? { ...item, state: 'resolved', updatedAt: now } : item);
189
+ }
190
+ if (patch.investigationContext !== undefined) {
191
+ assertWorkspaceInputGraph(patch.investigationContext, 'New incident context');
192
+ const raw = exact(patch.investigationContext, ['objective', 'incidentUrl', 'retainExactUrl'], 'New incident context');
193
+ if (typeof raw.retainExactUrl !== 'boolean')
194
+ throw new TypeError('Choose whether to retain the exact incident URL.');
195
+ next.investigationContext = { ...prepareCaseInvestigationContext({ objective: raw.objective, incidentUrl: raw.incidentUrl, retainExactUrl: raw.retainExactUrl }),
196
+ id: current.investigationContext?.id ?? makeId(), updatedAt: now };
197
+ }
198
+ return readCaseWorkflowMetadata(next, domain);
199
+ }
200
+ /** Add missing targets, retaining the newer state for an existing identity. */
201
+ export function mergeCaseWorkflowMetadata(local, incoming, importNewer, domain) {
202
+ const targets = new Map(local.incidentTargets.map(target => [target.id, target]));
203
+ for (const target of incoming.incidentTargets) {
204
+ const existing = targets.get(target.id);
205
+ if (existing && (existing.url !== target.url || existing.createdAt !== target.createdAt))
206
+ throw new TypeError('Imported incident target identity conflicts with retained history. No data was changed.');
207
+ if (!existing || Date.parse(target.updatedAt) > Date.parse(existing.updatedAt))
208
+ targets.set(target.id, target);
209
+ }
210
+ const left = local.investigationContext, right = incoming.investigationContext;
211
+ const investigationContext = right && (!left || Date.parse(right.updatedAt) > Date.parse(left.updatedAt)) ? right : left;
212
+ return readCaseWorkflowMetadata({ types: importNewer ? incoming.types : local.types,
213
+ incidentTargets: [...targets.values()], investigationContext }, domain);
214
+ }
215
+ export function caseIncidentTargets(record, options = {}) {
216
+ return (record.workflowMetadata?.incidentTargets ?? [])
217
+ .filter(target => options.includeResolved || target.state === 'open').map(target => ({ ...target }));
218
+ }
219
+ export function caseResponseIncidentUrls(record) {
220
+ const urls = new Set(caseIncidentTargets(record).map((target) => target.url));
221
+ const context = caseInvestigationContext(record);
222
+ if (context?.urlRetention === 'exact')
223
+ urls.add(context.incidentUrl);
224
+ return [...urls].slice(0, MAX_CASE_INCIDENT_TARGETS);
225
+ }
@@ -0,0 +1,43 @@
1
+ // Read legacy text conventions once at the versioned storage/import boundary.
2
+ // Keep the original assertions for references and historical evidence.
3
+ import { CASE_TYPES, normalizeCaseIncidentTargetUrl, readCaseWorkflowMetadata } from "./case-workflow-metadata.mjs";
4
+ import { normalizeTags } from "./case-record-core.mjs";
5
+ import { normalizeCaseObjective, parseIncidentUrlContext } from "./case-incident-context.mjs";
6
+ import { EVIDENCE_FOLLOW_UP_CASE_SCHEMA_VERSION } from "../contracts/case-portability.mjs";
7
+ const TYPE_PREFIX = 'case-type:';
8
+ const TARGET_PREFIX = 'Incident target URL: ';
9
+ const CONTEXT_PREFIX = 'Investigate incident URL: ';
10
+ const OBJECTIVE_PREFIX = 'Objective: ';
11
+ const RETENTION_SEPARATOR = ' | URL retained: ';
12
+ export function readCaseWorkflowFields(raw, domain, assertions, sourceVersion) {
13
+ const tags = normalizeTags(raw.tags);
14
+ if (raw.workflowMetadata !== undefined) {
15
+ if (sourceVersion != null && sourceVersion < EVIDENCE_FOLLOW_UP_CASE_SCHEMA_VERSION)
16
+ throw new TypeError('Typed workflow metadata requires the current Case schema.');
17
+ return { tags, workflowMetadata: readCaseWorkflowMetadata(raw.workflowMetadata, domain) };
18
+ }
19
+ const types = CASE_TYPES.filter(type => tags.some(tag => tag.toLowerCase() === `${TYPE_PREFIX}${type.id}`)).map(type => type.id);
20
+ const workflowMetadata = { types, incidentTargets: [], investigationContext: null };
21
+ for (const assertion of assertions) {
22
+ if (assertion.statement.startsWith(TARGET_PREFIX)) {
23
+ const url = normalizeCaseIncidentTargetUrl(assertion.statement.slice(TARGET_PREFIX.length));
24
+ if (url)
25
+ workflowMetadata.incidentTargets.push({ id: assertion.id, url, state: assertion.state, createdAt: assertion.createdAt, updatedAt: assertion.updatedAt });
26
+ }
27
+ if (assertion.kind !== 'next_step' || assertion.state !== 'open' || !assertion.statement.startsWith(CONTEXT_PREFIX))
28
+ continue;
29
+ const parsed = parseIncidentUrlContext(assertion.statement.slice(CONTEXT_PREFIX.length));
30
+ const rationale = assertion.rationale ?? '';
31
+ const index = rationale.lastIndexOf(RETENTION_SEPARATOR);
32
+ const objective = rationale.startsWith(OBJECTIVE_PREFIX) && index > OBJECTIVE_PREFIX.length
33
+ ? normalizeCaseObjective(rationale.slice(OBJECTIVE_PREFIX.length, index)) : '';
34
+ const retention = index >= 0 ? rationale.slice(index + RETENTION_SEPARATOR.length) : '';
35
+ if (!parsed || parsed.registrableDomain !== domain || !objective || (retention !== 'exact' && retention !== 'origin_only'))
36
+ continue;
37
+ workflowMetadata.investigationContext = { id: assertion.id, objective, urlRetention: retention,
38
+ incidentUrl: retention === 'exact' ? parsed.exactUrl : parsed.originUrl, updatedAt: assertion.updatedAt };
39
+ }
40
+ // Preserve historical text byte-for-byte where already normalised. Typed
41
+ // metadata becomes authoritative; subsequent tag edits never change types.
42
+ return { tags, workflowMetadata: readCaseWorkflowMetadata(workflowMetadata, domain) };
43
+ }
@@ -3,7 +3,7 @@
3
3
  // connection attempts. Bulk/watchlist/case records deliberately keep only this
4
4
  // smaller derived shape: no paths, queries, header values, attempt errors, or
5
5
  // redirect inventories.
6
- import { MAX_HTTP_EVIDENCE_REDIRECTS } from "../../lib/http-evidence-bounds.mjs";
6
+ import { MAX_HTTP_EVIDENCE_REDIRECTS } from "../analysis/http-evidence-bounds.mjs";
7
7
  export const HTTP_SUMMARY_VERSION = 1;
8
8
  export const MAX_HTTP_SUMMARY_ORIGIN_LENGTH = 300;
9
9
  export const MAX_HTTP_SUMMARY_CONTENT_TYPE_LENGTH = 100;
@@ -154,6 +154,11 @@ export function normalizeHttpSummary(value) {
154
154
  httpSecurityHeaders: securityHeaderTokens(record.httpSecurityHeaders),
155
155
  };
156
156
  }
157
+ /** A partial body still permits comparison of observed response metadata. */
158
+ export function httpSummaryFieldIsObserved(field, value) {
159
+ const summary = normalizeHttpSummary(value);
160
+ return summary !== null && Object.hasOwn(summary, field) && summary[field] !== null;
161
+ }
157
162
  export function httpSecurityHeaderLabel(token) {
158
163
  const labels = {
159
164
  'content-security-policy': 'Content Security Policy',
@@ -1,4 +1,4 @@
1
- import { hammingDistanceHex, isInformativePerceptualHash, isPerceptualHash, } from "../../lib/perceptual-hash-comparison.mjs";
1
+ import { hammingDistanceHex, isInformativePerceptualHash, isPerceptualHash, } from "../analysis/perceptual-hash-comparison.mjs";
2
2
  function plainRecord(value) {
3
3
  return value && typeof value === 'object' && !Array.isArray(value)
4
4
  ? value
@@ -3,7 +3,7 @@
3
3
  // produce an aggregate similarity score, risk factor, or maliciousness
4
4
  // verdict. Inputs pass through the strict baseline normalizer before use.
5
5
  import { normalizePageBaseline } from "../workspace/page-baseline.mjs";
6
- import { hammingDistanceHex } from "../../lib/perceptual-hash-comparison.mjs";
6
+ import { hammingDistanceHex } from "../analysis/perceptual-hash-comparison.mjs";
7
7
  import { PAGE_COMPARISON_VERSION } from "../contracts/offline-comparison.mjs";
8
8
  export { PAGE_COMPARISON_VERSION };
9
9
  function component(id, label, method, status, outcome, detail, partial) {
@@ -3,11 +3,12 @@
3
3
  // proof of common ownership, coordination, intent, or maliciousness. This
4
4
  // module deliberately produces no aggregate score and is never a persistence
5
5
  // boundary.
6
- import { normalizeDomain } from "../cases/case-model.mjs";
6
+ import { normalizeDomain } from "../evidence/domain-name.mjs";
7
+ import { canonicalPublicIpAddress } from "../evidence/public-address-policy.mjs";
7
8
  import { groupBySimilarFavicon } from "./favicon-similarity.mjs";
8
- import { qualifyRelationshipSources, relationshipSourceEvidence, normalizeRelationshipSourceProjection, unknownRelationshipSource, MAX_RELATIONSHIP_SOURCES_PER_DOMAIN, } from "./relationship-provenance.mjs";
9
- import { RELATIONSHIP_EVIDENCE_SCHEMA, RELATIONSHIP_EVIDENCE_VERSION, SUPPORTED_TLS_RELATIONSHIP_PROFILE_VERSIONS, TLS_RELATIONSHIP_PROFILE_VERSION, } from "../contracts/offline-comparison.mjs";
10
- export { RELATIONSHIP_EVIDENCE_SCHEMA, RELATIONSHIP_EVIDENCE_VERSION, SUPPORTED_TLS_RELATIONSHIP_PROFILE_VERSIONS, TLS_RELATIONSHIP_PROFILE_VERSION, };
9
+ import { qualifyRelationshipSources, relationshipSourceEvidence, normalizeRelationshipSourceProjection, FAVICON_RELATIONSHIP_DESCRIPTION, unknownRelationshipSource, MAX_RELATIONSHIP_SOURCES_PER_DOMAIN, } from "./relationship-provenance.mjs";
10
+ import { RELATIONSHIP_EVIDENCE_SCHEMA, RELATIONSHIP_EVIDENCE_VERSION, SUPPORTED_RELATIONSHIP_EVIDENCE_VERSIONS, RELATIONSHIP_SOURCE_PROVENANCE_VERSION, SUPPORTED_TLS_RELATIONSHIP_PROFILE_VERSIONS, TLS_RELATIONSHIP_PROFILE_VERSION, } from "../contracts/offline-comparison.mjs";
11
+ export { RELATIONSHIP_EVIDENCE_SCHEMA, RELATIONSHIP_EVIDENCE_VERSION, SUPPORTED_RELATIONSHIP_EVIDENCE_VERSIONS, SUPPORTED_TLS_RELATIONSHIP_PROFILE_VERSIONS, TLS_RELATIONSHIP_PROFILE_VERSION, };
11
12
  export const MAX_RELATIONSHIP_ROWS = 2000;
12
13
  export const MAX_RELATIONSHIP_GROUPS = 100;
13
14
  export const MAX_RELATIONSHIP_DOMAINS = 50;
@@ -217,7 +218,7 @@ export function buildScanRelationships(rawRows) {
217
218
  for (const raw of input.slice(0, MAX_RELATIONSHIP_ROWS)) {
218
219
  const domain = normalizeDomain(raw?.domain);
219
220
  const observation = record(raw?.relationship);
220
- if (!domain || raw?.trusted || !observation || ![2, RELATIONSHIP_EVIDENCE_VERSION].some((version) => version === observation.version))
221
+ if (!domain || raw?.trusted || !observation || !SUPPORTED_RELATIONSHIP_EVIDENCE_VERSIONS.some((version) => version === observation.version))
221
222
  continue;
222
223
  rows.push({ domain, observation });
223
224
  if (observation.truncated === true)
@@ -228,6 +229,7 @@ export function buildScanRelationships(rawRows) {
228
229
  const identifiers = new Map();
229
230
  const certificates = new Map();
230
231
  const officialAssets = new Map();
232
+ let excludedNonPublicAddresses = 0;
231
233
  for (const { domain, observation } of rows) {
232
234
  const nameserverSource = Array.isArray(observation.nameservers) ? observation.nameservers : [];
233
235
  const addressSource = Array.isArray(observation.ipAddresses) ? observation.ipAddresses : [];
@@ -239,8 +241,16 @@ export function buildScanRelationships(rawRows) {
239
241
  const nameservers = nameserverSource.slice(0, MAX_NAMESERVERS_PER_ROW).map(hostname).filter(Boolean);
240
242
  if (nameservers.length)
241
243
  addBucket(nameserverSets, [...new Set(nameservers)].sort().join(' · '), domain);
242
- for (const value of addressSource.slice(0, MAX_IPS_PER_ROW))
243
- addBucket(addresses, ipAddress(value), domain);
244
+ for (const value of addressSource.slice(0, MAX_IPS_PER_ROW)) {
245
+ const address = ipAddress(value);
246
+ if (!address)
247
+ continue;
248
+ const publicAddress = canonicalPublicIpAddress(address);
249
+ if (publicAddress)
250
+ addBucket(addresses, publicAddress, domain);
251
+ else
252
+ excludedNonPublicAddresses += 1;
253
+ }
244
254
  for (const value of identifierSource.slice(0, MAX_TRACKING_IDS_PER_ROW)) {
245
255
  if (typeof value === 'string' && /^[a-z-]{1,40}:[A-Z0-9-]{1,64}$/.test(value)) {
246
256
  addBucket(identifiers, value, domain);
@@ -288,7 +298,7 @@ export function buildScanRelationships(rawRows) {
288
298
  evidence?.faviconPHash ? `dhash:${evidence.faviconPHash}` : '',
289
299
  ].filter(Boolean).join(',')}`;
290
300
  }).join('|');
291
- output.push(group('favicon', 'Similar favicon', 'Exact SHA-256 or perceptual dHash distance ≤ 6', '', distinctDomains, 'These domains used an identical or perceptually similar favicon in this scan.', normalizedValue));
301
+ output.push(group('favicon', 'Connected favicon matches', 'Exact SHA-256 or perceptual dHash distance ≤ 6', '', distinctDomains, FAVICON_RELATIONSHIP_DESCRIPTION, normalizedValue));
292
302
  }
293
303
  }
294
304
  for (const [value, domains] of officialAssets)
@@ -317,7 +327,7 @@ export function buildScanRelationships(rawRows) {
317
327
  const field = ARRAY_RELATIONSHIP_FIELDS[item.type];
318
328
  if (field && Array.isArray(observation[field.field]) && observation[field.field].length > field.maximum)
319
329
  groupTruncated = true;
320
- const projection = observation.version === RELATIONSHIP_EVIDENCE_VERSION
330
+ const projection = Number(observation.version) >= RELATIONSHIP_SOURCE_PROVENANCE_VERSION
321
331
  ? normalizeRelationshipSourceProjection(observation.sourceEvidence) : {};
322
332
  for (const source of projection[item.type] ?? [unknownRelationshipSource()]) {
323
333
  if (contributors.length >= MAX_RELATIONSHIP_DOMAINS * MAX_RELATIONSHIP_SOURCES_PER_DOMAIN)
@@ -331,8 +341,10 @@ export function buildScanRelationships(rawRows) {
331
341
  return {
332
342
  version: RELATIONSHIP_EVIDENCE_VERSION,
333
343
  groups,
344
+ excludedNonPublicAddresses,
334
345
  truncated,
335
346
  limitations: [
347
+ ...(excludedNonPublicAddresses ? [`${excludedNonPublicAddresses} non-public address observations were excluded from public-infrastructure grouping; their DNS answers remain in the scan evidence.`] : []),
336
348
  'Shared observations are investigation pivots, not proof of common ownership, coordination, intent, or maliciousness.',
337
349
  'Certificate relationships use exact native TLS leaf-certificate SHA-256 values only. Certificate Transparency counts and hostnames are never treated as certificate reuse.',
338
350
  'A shared certificate can reflect a multi-domain certificate, shared hosting, CDN, or managed platform and does not establish common control.',