@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,217 @@
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
+ export { buildCaseIncidentCoverage } from "./case-object-coverage.mjs";
106
+ export function emptyCaseWorkflowMetadata() {
107
+ return { types: [], incidentTargets: [], investigationContext: null };
108
+ }
109
+ function identifier(value) {
110
+ const id = safeId(value);
111
+ if (!id || id !== value)
112
+ throw new TypeError('Case metadata identity is invalid.');
113
+ return id;
114
+ }
115
+ /** Strict admission before persistence; no inferred defaults for typed fields. */
116
+ export function readCaseWorkflowMetadata(value, domain) {
117
+ if (value === undefined)
118
+ return undefined;
119
+ assertWorkspaceInputGraph(value, 'Case workflow metadata');
120
+ const row = exact(value, ['types', 'incidentTargets', 'investigationContext'], 'Case workflow metadata');
121
+ const types = readCaseTypes(row.types);
122
+ const incidentTargets = array(row.incidentTargets, 'Incident target history', MAX_CASE_INCIDENT_TARGET_HISTORY).map(raw => {
123
+ const item = exact(raw, ['id', 'url', 'state', 'createdAt', 'updatedAt'], 'Incident target');
124
+ const url = normalizeCaseIncidentTargetUrl(item.url);
125
+ if (!url || url !== item.url)
126
+ throw new TypeError('Incident target URL is invalid.');
127
+ iso(item.createdAt, 'Incident target creation time');
128
+ iso(item.updatedAt, 'Incident target update time');
129
+ if (Date.parse(item.updatedAt) < Date.parse(item.createdAt))
130
+ throw new TypeError('Incident target update precedes creation.');
131
+ return { id: identifier(item.id), url, state: enumeration(item.state, ['open', 'resolved'], 'Incident target state'),
132
+ createdAt: item.createdAt, updatedAt: item.updatedAt };
133
+ });
134
+ if (new Set(incidentTargets.map(item => item.id)).size !== incidentTargets.length)
135
+ throw new TypeError('Incident target identities must be unique.');
136
+ let investigationContext = null;
137
+ if (row.investigationContext !== null) {
138
+ const item = exact(row.investigationContext, ['id', 'objective', 'incidentUrl', 'urlRetention', 'updatedAt'], 'Incident context');
139
+ const objective = text(item.objective, 'Investigation objective', MAX_CASE_OBJECTIVE_LENGTH);
140
+ if (!objective || normalizeCaseObjective(objective) !== objective)
141
+ throw new TypeError('Investigation objective is invalid.');
142
+ const parsed = parseIncidentUrlContext(item.incidentUrl);
143
+ const urlRetention = enumeration(item.urlRetention, ['exact', 'origin_only'], 'Incident URL retention');
144
+ if (!parsed || parsed.registrableDomain !== domain
145
+ || item.incidentUrl !== (urlRetention === 'exact' ? parsed.exactUrl : parsed.originUrl)) {
146
+ throw new TypeError('Incident context URL does not match its Case domain or retention choice.');
147
+ }
148
+ iso(item.updatedAt, 'Incident context update time');
149
+ investigationContext = { id: identifier(item.id), objective, incidentUrl: item.incidentUrl,
150
+ urlRetention, updatedAt: item.updatedAt };
151
+ }
152
+ return { types, incidentTargets, investigationContext };
153
+ }
154
+ /** Mutations use the existing Case save coordinator and compare-and-swap rules. */
155
+ export function updateCaseWorkflowMetadata(current, patch, domain, now) {
156
+ const next = structuredClone(current);
157
+ if (patch.caseTypes !== undefined) {
158
+ if (patch.expectedCaseTypes !== undefined && JSON.stringify(readCaseTypes(patch.expectedCaseTypes)) !== JSON.stringify(current.types)) {
159
+ throw new Error('The Case types changed after this edit was started. Reopen the Case before saving; your draft was not applied.');
160
+ }
161
+ next.types = readCaseTypes(patch.caseTypes);
162
+ }
163
+ if (patch.incidentTarget !== undefined) {
164
+ const url = normalizeCaseIncidentTargetUrl(patch.incidentTarget);
165
+ if (!url)
166
+ throw new TypeError('Enter an exact HTTP(S) incident URL without embedded credentials.');
167
+ if (next.incidentTargets.some(target => target.state === 'open' && target.url === url))
168
+ throw new Error('That exact incident URL is already active in this Case.');
169
+ if (next.incidentTargets.filter(target => target.state === 'open').length >= MAX_CASE_INCIDENT_TARGETS)
170
+ throw new Error(`A Case can retain at most ${MAX_CASE_INCIDENT_TARGETS} active incident links. Resolve one before adding another.`);
171
+ if (next.incidentTargets.length >= MAX_CASE_INCIDENT_TARGET_HISTORY)
172
+ 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.`);
173
+ next.incidentTargets.push({ id: makeId(), url, state: 'open', createdAt: now, updatedAt: now });
174
+ }
175
+ if (patch.incidentTargetResolution !== undefined) {
176
+ const id = identifier(patch.incidentTargetResolution);
177
+ const target = next.incidentTargets.find(item => item.id === id);
178
+ if (!target || target.state !== 'open')
179
+ throw new Error('That incident target is missing or already resolved. Refresh the Case before continuing.');
180
+ next.incidentTargets = next.incidentTargets.map(item => item.id === id ? { ...item, state: 'resolved', updatedAt: now } : item);
181
+ }
182
+ if (patch.investigationContext !== undefined) {
183
+ assertWorkspaceInputGraph(patch.investigationContext, 'New incident context');
184
+ const raw = exact(patch.investigationContext, ['objective', 'incidentUrl', 'retainExactUrl'], 'New incident context');
185
+ if (typeof raw.retainExactUrl !== 'boolean')
186
+ throw new TypeError('Choose whether to retain the exact incident URL.');
187
+ next.investigationContext = { ...prepareCaseInvestigationContext({ objective: raw.objective, incidentUrl: raw.incidentUrl, retainExactUrl: raw.retainExactUrl }),
188
+ id: current.investigationContext?.id ?? makeId(), updatedAt: now };
189
+ }
190
+ return readCaseWorkflowMetadata(next, domain);
191
+ }
192
+ /** Add missing targets, retaining the newer state for an existing identity. */
193
+ export function mergeCaseWorkflowMetadata(local, incoming, importNewer, domain) {
194
+ const targets = new Map(local.incidentTargets.map(target => [target.id, target]));
195
+ for (const target of incoming.incidentTargets) {
196
+ const existing = targets.get(target.id);
197
+ if (existing && (existing.url !== target.url || existing.createdAt !== target.createdAt))
198
+ throw new TypeError('Imported incident target identity conflicts with retained history. No data was changed.');
199
+ if (!existing || Date.parse(target.updatedAt) > Date.parse(existing.updatedAt))
200
+ targets.set(target.id, target);
201
+ }
202
+ const left = local.investigationContext, right = incoming.investigationContext;
203
+ const investigationContext = right && (!left || Date.parse(right.updatedAt) > Date.parse(left.updatedAt)) ? right : left;
204
+ return readCaseWorkflowMetadata({ types: importNewer ? incoming.types : local.types,
205
+ incidentTargets: [...targets.values()], investigationContext }, domain);
206
+ }
207
+ export function caseIncidentTargets(record, options = {}) {
208
+ return (record.workflowMetadata?.incidentTargets ?? [])
209
+ .filter(target => options.includeResolved || target.state === 'open').map(target => ({ ...target }));
210
+ }
211
+ export function caseResponseIncidentUrls(record) {
212
+ const urls = new Set(caseIncidentTargets(record).map((target) => target.url));
213
+ const context = caseInvestigationContext(record);
214
+ if (context?.urlRetention === 'exact')
215
+ urls.add(context.incidentUrl);
216
+ return [...urls].slice(0, MAX_CASE_INCIDENT_TARGETS);
217
+ }
@@ -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.',
@@ -1,10 +1,16 @@
1
1
  // Only the source metadata needed to qualify an observed relationship crosses
2
2
  // this boundary. Collection and retention times are never source-time fallbacks.
3
- import { normalizeDomain } from "../cases/case-model.mjs";
3
+ import { normalizeDomain } from "../evidence/domain-name.mjs";
4
+ import { canonicalPublicIpAddress } from "../evidence/public-address-policy.mjs";
4
5
  import { normalizeExplicitIsoTimestamp, readObservationEnvelope } from "../evidence/observation.mjs";
5
6
  export const RELATIONSHIP_TYPES = Object.freeze([
6
7
  'nameserver_set', 'ip_address', 'certificate', 'tracking_identifier', 'favicon', 'official_asset',
7
8
  ]);
9
+ export const NON_PUBLIC_RELATIONSHIP_LIMITATION = 'This is an equal non-public DNS answer, not evidence of shared public hosting. Private, loopback, reserved and sinkhole-style answers can recur independently.';
10
+ export const FAVICON_RELATIONSHIP_DESCRIPTION = 'Each domain is connected by exact or bounded perceptual favicon matches, directly or through another member. Not every pair necessarily matches.';
11
+ export function isNonPublicAddressRelationship(type, value) {
12
+ return type === 'ip_address' && !canonicalPublicIpAddress(value);
13
+ }
8
14
  const SOURCES = ['dns', 'tls', 'http', 'page_identity', 'favicon', 'registration_or_dns', 'unknown'];
9
15
  const SOURCES_BY_TYPE = {
10
16
  nameserver_set: ['dns', 'registration_or_dns'], ip_address: ['dns'], certificate: ['tls'],
@@ -3,9 +3,9 @@ import { buildExtractedLifecycleFamily } from "./extracted-domain-lifecycle.mjs"
3
3
  import { defineSchemaLifecycleFamily } from "./schema-lifecycle.mjs";
4
4
  export const ANALYST_INTERCHANGE_CONTRACT_OWNER = 'packages/contracts/analyst-interchange.mts';
5
5
  export const INVESTIGATION_CACAO_SPEC_VERSION = 'cacao-2.0';
6
- export const INVESTIGATION_CACAO_PROFILE_VERSION = 2;
7
- export const INVESTIGATION_CACAO_PROFILE_SEMVER = '2.0.0';
8
- export const INVESTIGATION_CACAO_SUPPORTED_PROFILE_VERSIONS = [INVESTIGATION_CACAO_PROFILE_VERSION];
6
+ export const INVESTIGATION_CACAO_PROFILE_VERSION = 3;
7
+ export const INVESTIGATION_CACAO_PROFILE_SEMVER = '3.0.0';
8
+ export const INVESTIGATION_CACAO_SUPPORTED_PROFILE_VERSIONS = [2, INVESTIGATION_CACAO_PROFILE_VERSION];
9
9
  export const MAX_INVESTIGATION_CACAO_IMPORT_BYTES = 384 * 1024;
10
10
  export const BRAND_PROTECTION_OPERATIONS_REPORT_SCHEMA = 'whoisleuth.brand-protection-operations-report';
11
11
  export const BRAND_PROTECTION_OPERATIONS_REPORT_VERSION = 2;
@@ -18,6 +18,10 @@ export const STIX_INDICATOR_LIFECYCLE_SCHEMA = 'whoisleuth.internal.stix-indicat
18
18
  export const STIX_INDICATOR_EXPORT_VERSION = 2;
19
19
  export const MISP_INDICATOR_LIFECYCLE_SCHEMA = 'whoisleuth.internal.misp-indicators';
20
20
  export const MISP_INDICATOR_EXPORT_VERSION = 2;
21
+ export const MANAGED_INDICATOR_SET_SCHEMA = 'whoisleuth.managed-indicator-set';
22
+ export const MANAGED_INDICATOR_SET_VERSION = 1;
23
+ export const MAX_MANAGED_INDICATOR_SET_BYTES = 4 * 1024 * 1024;
24
+ export const MAX_MANAGED_INDICATOR_PLAN_BYTES = 16 * 1024 * 1024;
21
25
  export const DNS_CHANGE_REHEARSAL_VERSION = 2;
22
26
  export const DNS_CHANGE_REHEARSAL_EXPORT_SCHEMA = 'whoisleuth.dns-change-rehearsal';
23
27
  export const MAIL_REPORT_SCHEMA = 'whoisleuth.mail-report-review';
@@ -29,20 +33,22 @@ export const MAIL_REPORT_CANONICALIZATION_ROUTES = Object.freeze([
29
33
  export const REGISTRATION_DISCLOSURE_PLAN_SCHEMA = 'whoisleuth.registration-disclosure-plan';
30
34
  export const REGISTRATION_DISCLOSURE_PLAN_VERSION = 2;
31
35
  export const STATIC_PAGE_PATTERN_PACK_SCHEMA = 'whoisleuth.static-page-pattern-pack';
32
- export const STATIC_PAGE_PATTERN_PACK_VERSION = 2;
36
+ export const STATIC_PAGE_PATTERN_PACK_VERSION = 3;
37
+ export const STATIC_PAGE_PATTERN_PACK_SUPPORTED_VERSIONS = [2, STATIC_PAGE_PATTERN_PACK_VERSION];
33
38
  export const MAX_STATIC_PAGE_PATTERN_PACK_BYTES = 256 * 1024;
34
39
  export const WEB_CAPTURE_SUMMARY_SCHEMA = 'whoisleuth.web-capture-summary';
35
40
  export const WEB_CAPTURE_SUMMARY_VERSION = 1;
36
41
  export const WEB_CAPTURE_MANIFEST_SCHEMA = 'whoisleuth.web-capture-manifest';
37
- export const WEB_CAPTURE_MANIFEST_VERSION = 2;
42
+ export const WEB_CAPTURE_MANIFEST_VERSION = 3;
43
+ export const WEB_CAPTURE_MANIFEST_SUPPORTED_VERSIONS = [2, WEB_CAPTURE_MANIFEST_VERSION];
38
44
  export const MAX_WEB_CAPTURE_MANIFEST_BYTES = 1024 * 1024;
39
45
  const CACAO_COMPATIBILITY = defineSchemaCompatibility({
40
46
  id: 'export.investigation-cacao-profile', kind: 'export', schema: INVESTIGATION_CACAO_SPEC_VERSION,
41
47
  currentVersion: INVESTIGATION_CACAO_PROFILE_VERSION, supportedVersions: INVESTIGATION_CACAO_SUPPORTED_PROFILE_VERSIONS,
42
- acceptsUnversionedLegacy: false, futureVersionBehavior: 'reject', migration: 'exact_current_only',
48
+ acceptsUnversionedLegacy: false, futureVersionBehavior: 'reject', migration: 'normalize_to_current',
43
49
  writeSemantics: 'non_destructive_merge', byteBudget: MAX_INVESTIGATION_CACAO_IMPORT_BYTES,
44
50
  owner: ANALYST_INTERCHANGE_CONTRACT_OWNER,
45
- note: 'Restricted CACAO 2.0 profile with a connected linear sequence of manual analyst steps; version 2 adds fixed response recipe identifiers while executable commands, branches, targets, credentials, and arbitrary operations remain rejected.',
51
+ note: 'Restricted CACAO 2.0 manual profile. Version 3 retains optional lesson-revision provenance; public profile 2 remains readable. Executable commands, branches, targets and credentials remain rejected.',
46
52
  });
47
53
  const OPERATIONS_REPORT_COMPATIBILITY = defineSchemaCompatibility({
48
54
  id: 'export.brand-protection-operations-report', kind: 'export', schema: BRAND_PROTECTION_OPERATIONS_REPORT_SCHEMA,
@@ -72,6 +78,13 @@ const MISP_INDICATORS_COMPATIBILITY = defineSchemaCompatibility({
72
78
  writeSemantics: 'read_only', byteBudget: null, owner: ANALYST_INTERCHANGE_CONTRACT_OWNER,
73
79
  note: 'Unpublished, non-IDS, non-correlating event for reviewed import. Version 2 omits optional seen times when the source observation time is unknown.',
74
80
  });
81
+ export const MANAGED_INDICATOR_SET_COMPATIBILITY = defineSchemaCompatibility({
82
+ id: 'export.managed-indicator-set', kind: 'export', schema: MANAGED_INDICATOR_SET_SCHEMA,
83
+ currentVersion: MANAGED_INDICATOR_SET_VERSION, supportedVersions: [MANAGED_INDICATOR_SET_VERSION],
84
+ acceptsUnversionedLegacy: false, futureVersionBehavior: 'reject', migration: 'exact_current_only',
85
+ writeSemantics: 'read_only', byteBudget: MAX_MANAGED_INDICATOR_SET_BYTES, owner: ANALYST_INTERCHANGE_CONTRACT_OWNER,
86
+ note: 'File-backed managed indicator revisions retain original observations, stable identities, explicit renewals and permanent withdrawals. Known predecessors prevent history replacement; content digests do not authenticate authorship or an unavailable earlier chain.',
87
+ });
75
88
  const WEB_CAPTURE_SUMMARY_COMPATIBILITY = defineSchemaCompatibility({
76
89
  id: 'export.web-capture-summary', kind: 'export', schema: WEB_CAPTURE_SUMMARY_SCHEMA,
77
90
  currentVersion: WEB_CAPTURE_SUMMARY_VERSION, supportedVersions: [WEB_CAPTURE_SUMMARY_VERSION],
@@ -81,11 +94,11 @@ const WEB_CAPTURE_SUMMARY_COMPATIBILITY = defineSchemaCompatibility({
81
94
  });
82
95
  const WEB_CAPTURE_MANIFEST_COMPATIBILITY = defineSchemaCompatibility({
83
96
  id: 'export.web-capture-manifest', kind: 'export', schema: WEB_CAPTURE_MANIFEST_SCHEMA,
84
- currentVersion: WEB_CAPTURE_MANIFEST_VERSION, supportedVersions: [WEB_CAPTURE_MANIFEST_VERSION],
97
+ currentVersion: WEB_CAPTURE_MANIFEST_VERSION, supportedVersions: WEB_CAPTURE_MANIFEST_SUPPORTED_VERSIONS,
85
98
  acceptsUnversionedLegacy: false, futureVersionBehavior: 'reject', migration: 'read_only',
86
99
  writeSemantics: 'read_only', byteBudget: MAX_WEB_CAPTURE_MANIFEST_BYTES,
87
100
  owner: ANALYST_INTERCHANGE_CONTRACT_OWNER,
88
- note: 'Metadata-only capture manifest; the browser and offline artefact comparison accept the v1.47.4 current-writer version 2.',
101
+ note: 'Metadata-only capture manifest. Version 3 adds bounded page and dependency observations; version 2 remains readable with those observations unavailable.',
89
102
  });
90
103
  const DEFENSIVE_MANIFEST_COMPATIBILITY = defineSchemaCompatibility({
91
104
  id: 'export.defensive-indicator-manifest', kind: 'export', schema: DEFENSIVE_INDICATOR_MANIFEST_SCHEMA,
@@ -124,11 +137,11 @@ const REGISTRATION_DISCLOSURE_COMPATIBILITY = defineSchemaCompatibility({
124
137
  });
125
138
  const STATIC_PATTERN_PACK_COMPATIBILITY = defineSchemaCompatibility({
126
139
  id: 'export.static-page-pattern-pack', kind: 'export', schema: STATIC_PAGE_PATTERN_PACK_SCHEMA,
127
- currentVersion: STATIC_PAGE_PATTERN_PACK_VERSION, supportedVersions: [STATIC_PAGE_PATTERN_PACK_VERSION],
128
- acceptsUnversionedLegacy: false, futureVersionBehavior: 'reject', migration: 'exact_current_only',
140
+ currentVersion: STATIC_PAGE_PATTERN_PACK_VERSION, supportedVersions: STATIC_PAGE_PATTERN_PACK_SUPPORTED_VERSIONS,
141
+ acceptsUnversionedLegacy: false, futureVersionBehavior: 'reject', migration: 'read_only',
129
142
  writeSemantics: 'non_destructive_merge', byteBudget: MAX_STATIC_PAGE_PATTERN_PACK_BYTES,
130
143
  owner: ANALYST_INTERCHANGE_CONTRACT_OWNER,
131
- note: 'Exact-current bounded page-pattern pack imported by explicit analyst action and merged without executing code or starting collection.',
144
+ note: 'Versions 2 and 3 remain readable. Version 3 can use attributed password-form destinations; older rules retain their declared meaning. Import remains explicit and does not execute code or start collection.',
132
145
  });
133
146
  export function serialiseAnalystInterchangeJson(value) {
134
147
  return `${JSON.stringify(value, null, 2)}\n`;
@@ -141,12 +154,17 @@ export const ANALYST_INTERCHANGE_LIFECYCLE_FAMILY = defineSchemaLifecycleFamily(
141
154
  includedCategories: ['reviewed-indicators', 'source-identity', 'provenance', 'completeness', 'omitted-counts', 'limitations'],
142
155
  excludedCategories: ['credentials', 'cookies', 'message-content', 'raw-upstream-responses', 'expanded-contacts'],
143
156
  formats: [
157
+ { descriptor: MANAGED_INDICATOR_SET_COMPATIBILITY, lifecycleSchema: MANAGED_INDICATOR_SET_SCHEMA,
158
+ requiredKeys: ['schema', 'version', 'id', 'producerId', 'revisionId', 'name', 'revision', 'createdAt', 'modifiedAt', 'previous', 'entries', 'integrity'], optionalKeys: [],
159
+ hook: { module: 'packages/interchange/managed-indicator-set.mts', exportName: 'validateManagedIndicatorSet', role: 'structure_validator', runtime: 'shared' },
160
+ fixtures: [{ id: 'managed-indicator-set-v1', path: `${F}managed-indicator-set-v1.json`, bytes: 1_193, sha256: '3a4ffb7256e696d1f1b3bae14743bb8c03ddeb0922186868e935f5a489ba3985', version: MANAGED_INDICATOR_SET_VERSION }] },
144
161
  { descriptor: CACAO_COMPATIBILITY, lifecycleSchema: INVESTIGATION_CACAO_SPEC_VERSION,
145
162
  requiredKeys: ['type', 'spec_version', 'id', 'name', 'created', 'modified', 'workflow_start', 'workflow', 'extension_definitions'],
146
163
  optionalKeys: ['description', 'playbook_types', 'playbook_activities', 'created_by', 'playbook_extensions', 'agent_definitions', 'x_whoisleuth_profile_version'],
147
164
  hook: { module: 'packages/interchange/investigation-playbook-interchange.mts', exportName: 'parseCacaoInvestigationPlaybook', role: 'normaliser', runtime: 'shared' },
148
165
  fixtures: [
149
- { id: 'investigation-cacao-profile-v2', path: `${F}investigation-cacao-profile-v2.json`, bytes: 4_091, sha256: '7e9a3de6e12b83477690c0c58e03e6efbbb8b0c11fd98dc8470bdb6784d42358', version: INVESTIGATION_CACAO_PROFILE_VERSION },
166
+ { id: 'investigation-cacao-profile-v2', path: `${F}investigation-cacao-profile-v2.json`, bytes: 4_091, sha256: '7e9a3de6e12b83477690c0c58e03e6efbbb8b0c11fd98dc8470bdb6784d42358', version: 2 },
167
+ { id: 'investigation-cacao-profile-v3', path: `${F}investigation-cacao-profile-v3.json`, bytes: 4_067, sha256: '608cbb7875c3448ca11f6f8cb2f96ae098e18316532a8f651bbe540ca7f25a2d', version: INVESTIGATION_CACAO_PROFILE_VERSION },
150
168
  ] },
151
169
  { descriptor: OPERATIONS_REPORT_COMPATIBILITY, lifecycleSchema: BRAND_PROTECTION_OPERATIONS_REPORT_SCHEMA,
152
170
  requiredKeys: ['schema', 'version', 'generatedAt', 'window', 'limitations'], optionalKeys: ['sourceState', 'summary', 'counts', 'states', 'actionTypes', 'durations', 'denominators', 'omissions'],
@@ -182,7 +200,8 @@ export const ANALYST_INTERCHANGE_LIFECYCLE_FAMILY = defineSchemaLifecycleFamily(
182
200
  requiredKeys: ['schema', 'schemaVersion', 'source', 'captures'], optionalKeys: [],
183
201
  hook: { module: 'packages/interchange/web-capture-import.mts', exportName: 'parseWebCaptureManifest', role: 'normaliser', runtime: 'shared' },
184
202
  fixtures: [
185
- { id: 'web-capture-manifest-v2', path: `${F}web-capture-manifest-v2.json`, bytes: 494, sha256: 'a2208dc956a832d70e37cd9a5929db81d29148856c485e4fd6ce5b123de5b8e6', version: WEB_CAPTURE_MANIFEST_VERSION },
203
+ { id: 'web-capture-manifest-v2', path: `${F}web-capture-manifest-v2.json`, bytes: 494, sha256: 'a2208dc956a832d70e37cd9a5929db81d29148856c485e4fd6ce5b123de5b8e6', version: 2 },
204
+ { id: 'web-capture-manifest-v3', path: `${F}web-capture-manifest-v3.json`, bytes: 868, sha256: 'bca4e8cb539ac6035dd44586fa7e27774766a14dadbc4fc73cad2b71dace2ac8', version: WEB_CAPTURE_MANIFEST_VERSION },
186
205
  ] },
187
206
  { descriptor: DEFENSIVE_MANIFEST_COMPATIBILITY, lifecycleSchema: DEFENSIVE_INDICATOR_MANIFEST_SCHEMA,
188
207
  requiredKeys: ['schema', 'version', 'generatedAt'], optionalKeys: ['expiresAt', 'reviewRequired', 'explicitSelection', 'includeWildcards', 'entries', 'indicators', 'exclusions', 'limitations'],
@@ -211,6 +230,7 @@ export const ANALYST_INTERCHANGE_LIFECYCLE_FAMILY = defineSchemaLifecycleFamily(
211
230
  { descriptor: STATIC_PATTERN_PACK_COMPATIBILITY, lifecycleSchema: STATIC_PAGE_PATTERN_PACK_SCHEMA,
212
231
  requiredKeys: ['schema', 'version', 'id', 'label', 'description', 'evidenceBoundary', 'relationship', 'confidence', 'rules'], optionalKeys: [],
213
232
  hook: { module: 'packages/interchange/static-page-pattern-packs.mts', exportName: 'validateStaticPagePatternPack', role: 'structure_validator', runtime: 'shared' },
214
- fixtures: [{ id: 'static-page-pattern-pack-v2', path: `${F}static-page-pattern-pack-v2.json`, bytes: 1_094, sha256: 'c409e527850bcb924f5be0708a58c5e2c32d98947c42150f6a79765991c0eb6f', version: STATIC_PAGE_PATTERN_PACK_VERSION }] },
233
+ fixtures: [{ id: 'static-page-pattern-pack-v2', path: `${F}static-page-pattern-pack-v2.json`, bytes: 1_094, sha256: 'c409e527850bcb924f5be0708a58c5e2c32d98947c42150f6a79765991c0eb6f', version: 2 },
234
+ { id: 'static-page-pattern-pack-v3', path: `${F}static-page-pattern-pack-v3.json`, bytes: 746, sha256: '5be19b621ad604de8c430191a34fcd2d624b22b8bdf9a5cb2653ef7d1b86c71a', version: 3 }] },
215
235
  ],
216
236
  }));
@@ -0,0 +1,54 @@
1
+ import { defineSchemaCompatibility } from "./schema-compatibility.mjs";
2
+ import { defineSchemaLifecycleFamily } from "./schema-lifecycle.mjs";
3
+ import { buildExtractedLifecycleFamily } from "./extracted-domain-lifecycle.mjs";
4
+ import { MAX_WATCHLIST_IMPORT_BYTES } from "./workspace-portability.mjs";
5
+ export const CANDIDATE_WATCH_INPUT_SCHEMA = 'whoisleuth.candidate-watch-input';
6
+ export const CANDIDATE_WATCH_INPUT_VERSION = 1;
7
+ export const CANDIDATE_WATCH_INPUT_COMPATIBILITY = defineSchemaCompatibility({
8
+ id: 'cli.candidate-watch-input',
9
+ kind: 'cli_document',
10
+ schema: CANDIDATE_WATCH_INPUT_SCHEMA,
11
+ currentVersion: 1,
12
+ supportedVersions: [1],
13
+ acceptsUnversionedLegacy: false,
14
+ futureVersionBehavior: 'reject',
15
+ migration: 'exact_current_only',
16
+ writeSemantics: 'read_only',
17
+ byteBudget: MAX_WATCHLIST_IMPORT_BYTES,
18
+ owner: 'packages/contracts/candidate-watch-review.mts',
19
+ note: 'Bounded offline analyst selection and optional portable Watchlist document; no collection or schedule authority.',
20
+ });
21
+ export const CANDIDATE_WATCH_REVIEW_LIFECYCLE_FAMILY = defineSchemaLifecycleFamily(buildExtractedLifecycleFamily({
22
+ id: 'candidate-watch-review',
23
+ owner: 'packages/contracts/candidate-watch-review.mts',
24
+ plane: 'node',
25
+ projection: 'review_output',
26
+ retention: 'transient_report',
27
+ serializerModule: 'cli/formatters/json.mts',
28
+ serializerExportName: 'formatJsonDocument',
29
+ includedCategories: ['candidate-provenance', 'analyst-watch-reasons', 'review-priorities'],
30
+ excludedCategories: ['raw-upstream-payloads', 'credentials', 'cookies', 'query-bearing-urls'],
31
+ formats: [
32
+ {
33
+ descriptor: CANDIDATE_WATCH_INPUT_COMPATIBILITY,
34
+ lifecycleSchema: CANDIDATE_WATCH_INPUT_SCHEMA,
35
+ requiredKeys: ['schema', 'version', 'watchlists', 'selection'],
36
+ optionalKeys: [],
37
+ hook: {
38
+ module: 'cli/watchlist-review.mts',
39
+ exportName: 'parseCandidateWatchInput',
40
+ role: 'structure_validator',
41
+ runtime: 'node',
42
+ },
43
+ fixtures: [
44
+ {
45
+ id: 'candidate-watch-input-v1',
46
+ path: 'test/fixtures/brand-candidate-workflow/candidate-watch-input-v1.json',
47
+ bytes: 895,
48
+ sha256: '40a0d73e5aa3afc3b6836bb289c79cf9556dd755a5086a9666506b31468b5d3d',
49
+ version: 1,
50
+ },
51
+ ],
52
+ },
53
+ ],
54
+ }));