@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
@@ -1,7 +1,7 @@
1
1
  // Pure Brand Profile normalization and storage model. The browser wrapper owns
2
2
  // persistence and downloads; this module owns schema migration, semantic field
3
3
  // bounds, import merging, and exact serialized-byte accounting.
4
- import { normalizeDomain } from "../cases/case-model.mjs";
4
+ import { normalizeDomain } from "../evidence/domain-name.mjs";
5
5
  import { normalizeExplicitIsoTimestamp } from "../evidence/observation.mjs";
6
6
  import { canonicalDomainControlRecords, normalizeDeclaredDomainControlRecordModes, normalizeDomainControlRecordModes } from "../evidence/domain-control-runtime.mjs";
7
7
  import { DOMAIN_CONTROL_RECORD_LIST_FIELDS } from "../contracts/domain-control-manifest.mjs";
@@ -10,7 +10,9 @@ import { MAX_IDENTITY_DIGEST_BYTES, sha256IdentityHex } from "../evidence/record
10
10
  import { DOMAIN_POSTURE_COMPARISON_VERSION, MAX_POSTURE_CHECKS, MAX_POSTURE_CHECK_RECORDS, MAX_POSTURE_RECORD_LENGTH, normalizeDomainPostureSourceContext, normalizeDomainPostureProfileContext } from "../evidence/domain-posture-context.mjs";
11
11
  import { normalizeOpaqueReferenceId } from "../cases/opaque-reference-id.mjs";
12
12
  import { normalizePageBaseline } from "./page-baseline.mjs";
13
- import { isInformativePerceptualHash as isInformativeFaviconHash } from "../../lib/perceptual-hash-comparison.mjs";
13
+ import { normalizeCandidateObservations, normalizeCandidateExceptions } from "./brand-candidate-workflow.mjs";
14
+ import { normalizeBrandKeywordCampaigns, mergeBrandKeywordCampaigns } from "./brand-keyword-campaign.mjs";
15
+ import { isInformativePerceptualHash as isInformativeFaviconHash } from "../analysis/perceptual-hash-comparison.mjs";
14
16
  import { assertWorkspaceDeclaredVersion, assertWorkspaceInputGraph, assertWorkspacePortableVersion, ordinaryWorkspaceRecord } from "./hostile-input.mjs";
15
17
  import { BRAND_PROFILE_SCHEMA, BRAND_PROFILE_SCHEMA_VERSION, MAX_DESIRED_POSTURE_BASELINES, MAX_DESIRED_POSTURE_CHANGE_WINDOWS, MAX_DESIRED_POSTURE_OBSERVATIONS, MAX_DESIRED_POSTURE_RECORDS, MAX_DESIRED_POSTURE_SUPPRESSIONS, MAX_DKIM_SELECTOR_LENGTH, MAX_DKIM_SELECTORS, MAX_OFFICIAL_CHANNELS, MAX_PROFILES, MAX_PROFILE_DOMAIN_LENGTH, MAX_PROFILE_NAME_LENGTH, MAX_PROFILE_STORE_BYTES, MAX_PROFILE_TEXT_LENGTH, MAX_PROFILE_TLD_LENGTH, MAX_PROFILE_URL_LENGTH, MAX_PROFILE_VALUE_INPUTS, MAX_PROFILE_VALUES, MAX_PROTECTION_ATTESTATIONS, MAX_RIGHTS_REFERENCES, SUPPORTED_BRAND_PROFILE_SCHEMA_VERSIONS, } from "../contracts/workspace-portability.mjs";
16
18
  export { BRAND_PROFILE_SCHEMA, BRAND_PROFILE_SCHEMA_VERSION, MAX_DESIRED_POSTURE_BASELINES, MAX_DESIRED_POSTURE_CHANGE_WINDOWS, MAX_DESIRED_POSTURE_OBSERVATIONS, MAX_DESIRED_POSTURE_RECORDS, MAX_DESIRED_POSTURE_SUPPRESSIONS, MAX_DKIM_SELECTOR_LENGTH, MAX_DKIM_SELECTORS, MAX_OFFICIAL_CHANNELS, MAX_PROFILES, MAX_PROFILE_DOMAIN_LENGTH, MAX_PROFILE_NAME_LENGTH, MAX_PROFILE_STORE_BYTES, MAX_PROFILE_TEXT_LENGTH, MAX_PROFILE_TLD_LENGTH, MAX_PROFILE_URL_LENGTH, MAX_PROFILE_VALUE_INPUTS, MAX_PROFILE_VALUES, MAX_PROTECTION_ATTESTATIONS, MAX_RIGHTS_REFERENCES, SUPPORTED_BRAND_PROFILE_SCHEMA_VERSIONS, } from "../contracts/workspace-portability.mjs";
@@ -583,6 +585,9 @@ export function normalizeBrandProfile(raw, options = {}) {
583
585
  approvedPartnerDomains: normalizeProfileDomains(value.approvedPartnerDomains),
584
586
  allowlistedDomains: normalizeProfileDomains(value.allowlistedDomains),
585
587
  allowlistedRegistrars: normalizeProfileTextValues(value.allowlistedRegistrars),
588
+ candidateObservations: normalizeCandidateObservations(value.candidateObservations),
589
+ candidateExceptions: normalizeCandidateExceptions(value.candidateExceptions),
590
+ keywordCampaigns: existing ? mergeBrandKeywordCampaigns(existing.keywordCampaigns, value.keywordCampaigns) : normalizeBrandKeywordCampaigns(value.keywordCampaigns),
586
591
  dkimSelectors,
587
592
  retiredDkimSelectors: normalizeDkimSelectors(value.retiredDkimSelectors)
588
593
  .filter((selector) => !dkimSelectors.includes(selector)),
@@ -602,6 +607,9 @@ export function normalizeBrandProfile(raw, options = {}) {
602
607
  const BRAND_PROFILE_FIELD_PATCH_KEYS = Object.freeze([
603
608
  'allowlistedDomains',
604
609
  'allowlistedRegistrars',
610
+ 'candidateObservations',
611
+ 'candidateExceptions',
612
+ 'keywordCampaigns',
605
613
  'desiredPostureBaselines',
606
614
  'protectionAttestations',
607
615
  ]);
@@ -638,11 +646,24 @@ export function brandProfileStoreVersion(raw) {
638
646
  const value = record(raw);
639
647
  return typeof value.version === 'number' && Number.isFinite(value.version) && value.version > 0 ? value.version : null;
640
648
  }
649
+ function assertHistoricalCandidateFields(raw, version) {
650
+ if (Array.isArray(raw) || version === null || version >= 11)
651
+ return;
652
+ for (const input of profileList(raw).slice(0, MAX_PROFILES * 4)) {
653
+ const profile = record(input);
654
+ if (version < 10 && (Object.hasOwn(profile, 'candidateObservations') || Object.hasOwn(profile, 'candidateExceptions'))) {
655
+ throw new TypeError('Candidate observations and scoped exceptions require Brand Profile schema 10; historical records were not reinterpreted.');
656
+ }
657
+ if (Object.hasOwn(profile, 'keywordCampaigns'))
658
+ throw new TypeError('Keyword campaigns require Brand Profile schema 11; historical records were not reinterpreted.');
659
+ }
660
+ }
641
661
  /** Normalize an internal profile collection or current stored envelope. */
642
662
  export function normalizeBrandProfileStore(raw) {
643
663
  assertWorkspaceInputGraph(raw, 'Brand Profile store', { maximumBytes: MAX_PROFILE_STORE_BYTES });
644
664
  assertWorkspaceDeclaredVersion(raw, 'Brand Profile store');
645
665
  const sourceVersion = brandProfileStoreVersion(raw);
666
+ assertHistoricalCandidateFields(raw, sourceVersion);
646
667
  if (!Array.isArray(raw) && sourceVersion !== null
647
668
  && !SUPPORTED_BRAND_PROFILE_SCHEMA_VERSIONS.includes(sourceVersion)) {
648
669
  throw new Error(`Brand Profile schema ${sourceVersion} is unsupported; no data was changed.`);
@@ -655,8 +676,8 @@ export function normalizeBrandProfileStore(raw) {
655
676
  if (!profile)
656
677
  continue;
657
678
  const previous = byId.get(profile.id);
658
- if (!previous || profile.updatedAt > previous.updatedAt)
659
- byId.set(profile.id, profile);
679
+ const keywordCampaigns = mergeBrandKeywordCampaigns(previous?.keywordCampaigns, profile.keywordCampaigns);
680
+ byId.set(profile.id, { ...(previous && profile.updatedAt <= previous.updatedAt ? previous : profile), keywordCampaigns });
660
681
  if (byId.size >= MAX_PROFILES)
661
682
  break;
662
683
  }
@@ -691,6 +712,7 @@ export function mergeBrandProfiles(localRaw, importedRaw, options = {}) {
691
712
  throw new Error('Expected a current WHOISleuth Brand Profile export.');
692
713
  }
693
714
  const importedVersion = brandProfileStoreVersion(importedRaw);
715
+ assertHistoricalCandidateFields(importedRaw, importedVersion);
694
716
  if (importedVersion !== null && importedVersion > BRAND_PROFILE_SCHEMA_VERSION) {
695
717
  throw new Error(`This Brand Profile file uses newer schema ${importedVersion}. Update the app before importing it.`);
696
718
  }
@@ -727,8 +749,17 @@ export function mergeBrandProfiles(localRaw, importedRaw, options = {}) {
727
749
  if (rawId && rawName)
728
750
  retainIdName(rawId, rawName);
729
751
  const existing = rawName ? byName.get(rawName.toLowerCase()) : null;
752
+ if (existing && rawId !== existing.id && ((Array.isArray(value.candidateObservations) && value.candidateObservations.length) || (Array.isArray(value.candidateExceptions) && value.candidateExceptions.length) || (Array.isArray(value.keywordCampaigns) && value.keywordCampaigns.length))) {
753
+ throw new TypeError('Candidate metadata belongs to another Brand identifier with this name. Rename or review its exact Brand identity before importing; no decisions were reassigned.');
754
+ }
755
+ const keywordCampaigns = mergeBrandKeywordCampaigns(existing?.keywordCampaigns, value.keywordCampaigns);
730
756
  const incomingUpdatedAt = timestamp(value.updatedAt, null);
731
757
  if (existing && (!incomingUpdatedAt || incomingUpdatedAt <= existing.updatedAt)) {
758
+ if (JSON.stringify(keywordCampaigns) !== JSON.stringify(existing.keywordCampaigns ?? [])) {
759
+ byName.set(existing.name.toLowerCase(), { ...existing, keywordCampaigns });
760
+ updated++;
761
+ continue;
762
+ }
732
763
  skipped++;
733
764
  continue;
734
765
  }
@@ -742,6 +773,19 @@ export function mergeBrandProfiles(localRaw, importedRaw, options = {}) {
742
773
  skipped++;
743
774
  continue;
744
775
  }
776
+ if (existing) {
777
+ profile.keywordCampaigns = keywordCampaigns;
778
+ profile.candidateObservations = normalizeCandidateObservations([...existing.candidateObservations, ...profile.candidateObservations]);
779
+ const exceptions = new Map(existing.candidateExceptions.map(exception => [exception.id, exception]));
780
+ for (const incoming of profile.candidateExceptions) {
781
+ const old = exceptions.get(incoming.id);
782
+ if (old && (old.domain !== incoming.domain || old.ruleKey !== incoming.ruleKey || old.purpose !== incoming.purpose))
783
+ throw new Error('An imported exception identifier changes its exact scope or purpose; no profiles were imported.');
784
+ if (!old || incoming.revision > old.revision)
785
+ exceptions.set(incoming.id, incoming);
786
+ }
787
+ profile.candidateExceptions = normalizeCandidateExceptions([...exceptions.values()]);
788
+ }
745
789
  retainIdName(profile.id, profile.name);
746
790
  if (existing) {
747
791
  byName.set(profile.name.toLowerCase(), profile);
@@ -1,4 +1,4 @@
1
- import { normalizeDomain } from "../cases/case-model.mjs";
1
+ import { normalizeDomain } from "../evidence/domain-name.mjs";
2
2
  import { CASE_DISPOSITIONS as CASE_DISPOSITION_OPTIONS } from "../cases/case-record-contracts.mjs";
3
3
  import { BULK_SORT_KEYS, normalizeBulkPresentationSortKey } from "./bulk-sort.mjs";
4
4
  import { normalizeBulkResultColumns } from "./bulk-columns.mjs";
@@ -235,37 +235,38 @@ export function mergeBulkReviewStores(localRaw, importedRaw) {
235
235
  const rows = new Map(local.rows.map((item) => [item.domain, item]));
236
236
  let added = 0;
237
237
  let updated = 0;
238
- let skipped = 0;
239
- for (const candidate of imported.presets) {
240
- const existing = presets.get(candidate.id);
241
- if (!existing) {
242
- presets.set(candidate.id, candidate);
243
- added += 1;
244
- }
245
- else if (candidate.updatedAt > existing.updatedAt) {
246
- presets.set(candidate.id, candidate);
247
- updated += 1;
248
- }
249
- else {
238
+ const supplied = sourceLists(importedRaw);
239
+ let skipped = supplied.presets.length + supplied.rows.length - imported.presets.length - imported.rows.length;
240
+ let capacitySkipped = 0;
241
+ let bytes = byteLength(JSON.stringify(local));
242
+ if (bytes > MAX_BULK_REVIEW_STORE_BYTES)
243
+ throw new Error('Existing Bulk review preferences exceed the workspace storage limit; no imported changes were applied.');
244
+ function admit(target, key, candidate, maximum) {
245
+ const existing = target.get(key);
246
+ if (existing && candidate.updatedAt <= existing.updatedAt) {
250
247
  skipped += 1;
248
+ return;
251
249
  }
252
- }
253
- for (const candidate of imported.rows) {
254
- const existing = rows.get(candidate.domain);
255
- if (!existing) {
256
- rows.set(candidate.domain, candidate);
257
- added += 1;
258
- }
259
- else if (candidate.updatedAt > existing.updatedAt) {
260
- rows.set(candidate.domain, candidate);
261
- updated += 1;
262
- }
263
- else {
250
+ const delta = byteLength(JSON.stringify(candidate)) - (existing ? byteLength(JSON.stringify(existing)) : 0)
251
+ + (!existing && target.size ? 1 : 0);
252
+ if ((!existing && target.size >= maximum) || bytes + delta > MAX_BULK_REVIEW_STORE_BYTES) {
264
253
  skipped += 1;
254
+ capacitySkipped += 1;
255
+ return;
265
256
  }
257
+ target.set(key, candidate);
258
+ bytes += delta;
259
+ if (existing)
260
+ updated += 1;
261
+ else
262
+ added += 1;
266
263
  }
264
+ for (const candidate of imported.presets)
265
+ admit(presets, candidate.id, candidate, MAX_BULK_REVIEW_PRESETS);
266
+ for (const candidate of imported.rows)
267
+ admit(rows, candidate.domain, candidate, MAX_BULK_REVIEW_ROWS);
267
268
  return {
268
- store: enforceBulkReviewBudget({
269
+ store: normalizeBulkReviewStore({
269
270
  schema: BULK_REVIEW_SCHEMA,
270
271
  version: BULK_REVIEW_SCHEMA_VERSION,
271
272
  presets: [...presets.values()],
@@ -274,5 +275,6 @@ export function mergeBulkReviewStores(localRaw, importedRaw) {
274
275
  added,
275
276
  updated,
276
277
  skipped,
278
+ reason: capacitySkipped ? `${capacitySkipped} imported Bulk review records did not fit the remaining storage capacity. Existing local records are preserved.` : '',
277
279
  };
278
280
  }
@@ -1,10 +1,11 @@
1
1
  // Pure, bounded persistence model for analyst-saved Bulk investigations.
2
2
  // Sessions retain compact derived rows and source states, never raw registry,
3
3
  // WHOIS, HTTP, TLS, page, or provider payloads.
4
- import { normalizeDomain } from "../cases/case-model.mjs";
4
+ import { normalizeDomain } from "../evidence/domain-name.mjs";
5
5
  import { normalizeCaaCritical } from "./dns-record-normalization.mjs";
6
6
  import { normalizeExplicitIsoTimestamp } from "../evidence/observation.mjs";
7
- import { RELATIONSHIP_EVIDENCE_VERSION } from "../contracts/offline-comparison.mjs";
7
+ import { normalizeWebCollectionQuality, webCollectionAllowsComparison } from "../evidence/collection-quality.mjs";
8
+ import { RELATIONSHIP_EVIDENCE_VERSION, RELATIONSHIP_SOURCE_PROVENANCE_VERSION, SUPPORTED_RELATIONSHIP_EVIDENCE_VERSIONS } from "../contracts/offline-comparison.mjs";
8
9
  import { normalizeRelationshipSourceProjection } from "../comparison/relationship-provenance.mjs";
9
10
  import { assertWorkspaceDeclaredVersion, assertWorkspaceInputGraph, assertWorkspacePortableVersion, ordinaryWorkspaceRecord } from "./hostile-input.mjs";
10
11
  import { BULK_SESSION_SCHEMA, BULK_SESSION_SCHEMA_VERSION, MAX_BULK_PROFILE_CONTEXT_LIMITATION_LENGTH, MAX_BULK_SESSIONS, MAX_BULK_SESSION_ARRAY_VALUES, MAX_BULK_SESSION_NAME_LENGTH, MAX_BULK_SESSION_ROWS, MAX_BULK_SESSION_SOURCES, MAX_BULK_SESSION_STORE_BYTES, MAX_BULK_SESSION_TEXT_LENGTH, SUPPORTED_BULK_SESSION_SCHEMA_VERSIONS, } from "../contracts/workspace-portability.mjs";
@@ -94,7 +95,7 @@ export function normalizeBulkProfileContext(value, fallbackLimitation = BULK_PRO
94
95
  }
95
96
  return unavailableBulkProfileContext(profileContextLimitation(item?.limitation, fallbackLimitation));
96
97
  }
97
- function sameProfileContext(left, right) {
98
+ export function bulkProfileContextsMatch(left, right) {
98
99
  return left.sourceState === right.sourceState
99
100
  && left.activeProfileId === right.activeProfileId
100
101
  && left.profileUpdatedAt === right.profileUpdatedAt
@@ -104,7 +105,7 @@ export function summarizeBulkProfileContexts(rows) {
104
105
  const first = rows[0]?.profileContext;
105
106
  if (!first)
106
107
  return unavailableBulkProfileContext('This saved Bulk session contains no settled row with profile-context provenance.');
107
- if (rows.every((row) => sameProfileContext(first, row.profileContext)))
108
+ if (rows.every((row) => bulkProfileContextsMatch(first, row.profileContext)))
108
109
  return { ...first };
109
110
  return {
110
111
  sourceState: 'mixed',
@@ -200,7 +201,7 @@ function nullableHash(value, expression) {
200
201
  }
201
202
  function normalizeRelationship(value) {
202
203
  const item = record(value);
203
- if (item?.version !== undefined && item.version !== 2 && item.version !== RELATIONSHIP_EVIDENCE_VERSION) {
204
+ if (item?.version !== undefined && !SUPPORTED_RELATIONSHIP_EVIDENCE_VERSIONS.some(version => version === item.version)) {
204
205
  throw new TypeError('This Bulk relationship projection uses an unsupported version. No source evidence was interpreted.');
205
206
  }
206
207
  return {
@@ -212,7 +213,7 @@ function normalizeRelationship(value) {
212
213
  faviconHash: nullableHash(item?.faviconHash, HASH_64_RE),
213
214
  faviconPHash: nullableHash(item?.faviconPHash, PHASH_RE),
214
215
  certificateFingerprint: nullableHash(item?.certificateFingerprint, HASH_64_RE),
215
- sourceEvidence: item?.version === RELATIONSHIP_EVIDENCE_VERSION ? normalizeRelationshipSourceProjection(item.sourceEvidence) : {},
216
+ sourceEvidence: Number(item?.version) >= RELATIONSHIP_SOURCE_PROVENANCE_VERSION ? normalizeRelationshipSourceProjection(item?.sourceEvidence) : {},
216
217
  truncated: item?.truncated === true,
217
218
  };
218
219
  }
@@ -304,6 +305,7 @@ export function normalizeBulkSessionResult(value, inheritedProfileContext) {
304
305
  const profileClaimsUsable = profileContextReady && !impossibleNoProfileClaim;
305
306
  if (!profileClaimsUsable || readyWithoutActiveProfile)
306
307
  relationship.officialAssetHosts = [];
308
+ const webCollectionQuality = normalizeWebCollectionQuality(item.webCollectionQuality, scanDepth);
307
309
  return {
308
310
  domain,
309
311
  status: status,
@@ -317,6 +319,7 @@ export function normalizeBulkSessionResult(value, inheritedProfileContext) {
317
319
  trusted: profileClaimsUsable && !readyWithoutActiveProfile && TRUST_STATES.has(trusted) ? trusted : null,
318
320
  error: boundedText(item.error),
319
321
  scanDepth: scanDepth,
322
+ ...(webCollectionQuality ? { webCollectionQuality } : {}),
320
323
  observedAt: normalizeExplicitIsoTimestamp(item.observedAt),
321
324
  createdDate: boundedText(item.createdDate, 64) || null,
322
325
  expiryDate: boundedText(item.expiryDate, 64) || null,
@@ -335,6 +338,7 @@ export function normalizeBulkSessionResult(value, inheritedProfileContext) {
335
338
  reusesOfficialAssets: profileClaimsUsable ? nullableBoolean(item.reusesOfficialAssets) : null,
336
339
  hasPasswordField: item.hasPasswordField === true,
337
340
  hasExternalFormAction: nullableBoolean(item.hasExternalFormAction),
341
+ hasExternalPasswordForm: nullableBoolean(item.hasExternalPasswordForm),
338
342
  phishingLanguageMatch: boundedText(item.phishingLanguageMatch, 300) || null,
339
343
  idnReferenceMatch: profileClaimsUsable ? nullableBoolean(item.idnReferenceMatch) : null,
340
344
  pageBaselineMatch: profileClaimsUsable ? nullableBoolean(item.pageBaselineMatch) : null,
@@ -381,7 +385,7 @@ export function normalizeBulkSession(value, sourceStoreVersion) {
381
385
  // Only an explicitly versioned current document may omit identical row
382
386
  // context. Bare runtime rows and public schema 4 must carry their own context;
383
387
  // an explicit invalid/null row value never falls back to the session.
384
- const inheritedProfileContext = sourceStoreVersion === 5
388
+ const inheritedProfileContext = sourceStoreVersion !== undefined && sourceStoreVersion >= 5
385
389
  && declaredProfileContext.sourceState !== 'mixed'
386
390
  && rawProfileContext.sourceState === declaredProfileContext.sourceState
387
391
  ? declaredProfileContext
@@ -390,8 +394,14 @@ export function normalizeBulkSession(value, sourceStoreVersion) {
390
394
  const results = [];
391
395
  const seen = new Set();
392
396
  for (const candidate of Array.isArray(item.results) ? item.results.slice(0, MAX_BULK_SESSION_ROWS * 2) : []) {
397
+ if (sourceStoreVersion !== undefined && sourceStoreVersion < 7 && Object.hasOwn(record(candidate) ?? {}, 'hasExternalPasswordForm')) {
398
+ throw new TypeError('Form attribution requires Bulk schema 7 or later; historical evidence was not reinterpreted.');
399
+ }
400
+ if (sourceStoreVersion !== undefined && sourceStoreVersion < 6 && record(candidate)?.webCollectionQuality !== undefined) {
401
+ throw new TypeError('Web collection quality requires Bulk schema 6 or later; historical evidence was not reinterpreted.');
402
+ }
393
403
  const publicLegacyRow = sourceStoreVersion === 4;
394
- if (publicLegacyRow && record(record(candidate)?.relationship)?.version === RELATIONSHIP_EVIDENCE_VERSION) {
404
+ if (publicLegacyRow && Number(record(record(candidate)?.relationship)?.version) >= RELATIONSHIP_SOURCE_PROVENANCE_VERSION) {
395
405
  throw new TypeError('Bulk schema 4 cannot contain newer relationship source evidence; no evidence was interpreted.');
396
406
  }
397
407
  const result = normalizeBulkSessionResult(candidate, inheritedProfileContext);
@@ -411,7 +421,7 @@ export function normalizeBulkSession(value, sourceStoreVersion) {
411
421
  if ((state === 'complete') !== (results.length === domains.length))
412
422
  return null;
413
423
  const profileContext = summarizeBulkProfileContexts(results);
414
- if (!sameProfileContext(declaredProfileContext, profileContext))
424
+ if (!bulkProfileContextsMatch(declaredProfileContext, profileContext))
415
425
  return null;
416
426
  return {
417
427
  id,
@@ -471,7 +481,7 @@ export function normalizeBulkSessionStore(raw) {
471
481
  export function bulkSessionStorageValue(session) {
472
482
  return {
473
483
  ...session,
474
- results: session.results.map(({ profileContext, ...row }) => sameProfileContext(profileContext, session.profileContext)
484
+ results: session.results.map(({ profileContext, ...row }) => bulkProfileContextsMatch(profileContext, session.profileContext)
475
485
  ? row
476
486
  : { ...row, profileContext }),
477
487
  };
@@ -558,8 +568,10 @@ function sourceStateMap(value) {
558
568
  export function areBulkRiskScoresComparable(previous, current) {
559
569
  return previous.profileContext.sourceState === 'ready'
560
570
  && current.profileContext.sourceState === 'ready'
561
- && sameProfileContext(previous.profileContext, current.profileContext)
571
+ && bulkProfileContextsMatch(previous.profileContext, current.profileContext)
562
572
  && previous.scanDepth === current.scanDepth
573
+ && webCollectionAllowsComparison('riskScore', previous.webCollectionQuality, previous.scanDepth)
574
+ && webCollectionAllowsComparison('riskScore', current.webCollectionQuality, current.scanDepth)
563
575
  && previous.risk !== null
564
576
  && current.risk !== null
565
577
  && previous.riskModelVersion !== null
@@ -574,7 +586,9 @@ function resultChanges(previous, current) {
574
586
  }
575
587
  if (previous.registrar !== current.registrar)
576
588
  changes.push(`Registrar: ${previous.registrar} → ${current.registrar}`);
577
- if (previous.activity !== current.activity)
589
+ if (previous.activity !== current.activity
590
+ && webCollectionAllowsComparison('activityStatus', previous.webCollectionQuality, previous.scanDepth)
591
+ && webCollectionAllowsComparison('activityStatus', current.webCollectionQuality, current.scanDepth))
578
592
  changes.push(`Website: ${previous.activity} → ${current.activity}`);
579
593
  const previousComparison = previous.comparisonEvidence;
580
594
  const currentComparison = current.comparisonEvidence;
@@ -644,7 +658,7 @@ export function compareBulkSessions(baselineRaw, currentRaw) {
644
658
  'A source-state change may reflect collection availability rather than a change to the domain.',
645
659
  'A missing row means it was not completed in that saved session; it does not establish domain removal.',
646
660
  ...(riskIncomparable
647
- ? ['Risk deltas are omitted unless both rows retain the same ready Brand Profile provenance, scan depth, and versioned Risk model.']
661
+ ? ['Risk deltas are omitted unless both rows retain the same ready Brand Profile provenance, scan depth, versioned Risk model and complete web collection for Deep observations.']
648
662
  : []),
649
663
  ],
650
664
  };
@@ -1,7 +1,7 @@
1
1
  // Pure, framework-neutral browser-local campaign model. Campaigns deliberately
2
2
  // reference normalized case domains rather than copying case evidence, notes,
3
3
  // or mutable case ids. The browser wrapper owns persistence and downloads.
4
- import { normalizeDomain } from "../cases/case-model.mjs";
4
+ import { normalizeDomain } from "../evidence/domain-name.mjs";
5
5
  import { normalizeExplicitIsoTimestamp } from "../evidence/observation.mjs";
6
6
  import { assertWorkspaceDeclaredVersion, assertWorkspaceInputGraph, assertWorkspacePortableVersion, ordinaryWorkspaceRecord } from "./hostile-input.mjs";
7
7
  import { CAMPAIGN_SCHEMA, CAMPAIGN_SCHEMA_VERSION, MAX_CAMPAIGNS, MAX_CAMPAIGN_DESCRIPTION_LENGTH, MAX_CAMPAIGN_DOMAINS, MAX_CAMPAIGN_INPUT_RECORDS, MAX_CAMPAIGN_NAME_LENGTH, MAX_CAMPAIGN_STORE_BYTES, } from "../contracts/workspace-portability.mjs";
@@ -0,0 +1,195 @@
1
+ import { normalizeDomain } from "../evidence/domain-name.mjs";
2
+ import { normalizeExplicitIsoTimestamp } from "../evidence/observation.mjs";
3
+ import { assertWorkspaceInputGraph } from "./hostile-input.mjs";
4
+ import { MAX_WATCHLISTS, normalizeWatchlistName, normalizeWatchlistStore, assertWatchlistStoreBudget, } from "./watchlist-store.mjs";
5
+ import { MAX_WATCHLIST_DOMAINS } from "../contracts/workspace-portability.mjs";
6
+ import { assertWatchlistEditable } from "./watchlist-history.mjs";
7
+ import { MAX_WATCH_CONTEXTS, WATCH_PRIORITIES, mergeWatchDomainMetadata, normalizeCandidateObservation, candidateMaterialFingerprint, } from "./brand-candidate-workflow.mjs";
8
+ function admitInput(input) {
9
+ assertWorkspaceInputGraph(input, 'Candidate watch handoff');
10
+ if (!normalizeWatchlistName(input.name))
11
+ throw new TypeError('Choose a safe watchlist name containing 1–100 characters.');
12
+ if (!Array.isArray(input.candidates) || input.candidates.length > MAX_WATCHLIST_DOMAINS)
13
+ throw new RangeError(`Select at most ${MAX_WATCHLIST_DOMAINS} candidate domains.`);
14
+ if (input.brandProfileId !== null &&
15
+ (typeof input.brandProfileId !== 'string' ||
16
+ !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u.test(input.brandProfileId)))
17
+ throw new TypeError('The Brand context identifier is invalid.');
18
+ if (!WATCH_PRIORITIES.some((option) => option.value === input.priority))
19
+ throw new TypeError('Choose an admitted review priority.');
20
+ if (typeof input.reason !== 'string' ||
21
+ /[\u0000-\u001f\u007f]/u.test(input.reason) ||
22
+ input.reason.trim().length > 300 ||
23
+ !input.reason.trim())
24
+ throw new TypeError('Enter a watch reason containing 1–300 characters and no control characters.');
25
+ if (input.reviewDueAt != null && !normalizeExplicitIsoTimestamp(input.reviewDueAt))
26
+ throw new TypeError('Use a valid explicit review time. A review time is not a scan schedule.');
27
+ if (input.replaceExistingContext !== undefined &&
28
+ typeof input.replaceExistingContext !== 'boolean')
29
+ throw new TypeError('Existing-context replacement must be selected explicitly.');
30
+ return {
31
+ ...input,
32
+ name: normalizeWatchlistName(input.name),
33
+ reason: input.reason.trim(),
34
+ reviewDueAt: normalizeExplicitIsoTimestamp(input.reviewDueAt),
35
+ };
36
+ }
37
+ /** Pure exact-domain preview. It grants no collection authority and makes no requests. */
38
+ export function planCandidateWatchHandoff(localRaw, rawInput) {
39
+ const input = admitInput(rawInput), local = normalizeWatchlistStore(localRaw).watchlists;
40
+ const name = Object.keys(local).find((name) => name.toLowerCase() === input.name.toLowerCase()) ??
41
+ input.name;
42
+ const entry = local[name], existing = new Map(entry?.domainMetadata.map((record) => [record.domain, record]) ?? []);
43
+ assertWatchlistEditable(entry);
44
+ const seen = new Set(), rows = [];
45
+ let admitted = existing.size;
46
+ for (const raw of input.candidates) {
47
+ const candidate = normalizeCandidateObservation(raw), domain = candidate?.domain ?? normalizeDomain(raw?.domain) ?? '';
48
+ let reason = '';
49
+ if (!candidate || !domain)
50
+ reason = 'Invalid candidate domain or provenance.';
51
+ else if (seen.has(domain))
52
+ reason = 'Repeated selection; the first exact domain is retained.';
53
+ else if (!entry && Object.keys(local).length >= MAX_WATCHLISTS)
54
+ reason = 'Watchlist count capacity is exhausted.';
55
+ else if (!existing.has(domain) && admitted >= MAX_WATCHLIST_DOMAINS)
56
+ reason = 'Domain capacity is exhausted.';
57
+ else if (existing.get(domain)?.contexts.length === MAX_WATCH_CONTEXTS &&
58
+ !existing
59
+ .get(domain)
60
+ ?.contexts.some((context) => context.brandProfileId === input.brandProfileId))
61
+ reason = 'This domain has reached its separate-context capacity.';
62
+ seen.add(domain);
63
+ const previousContext = existing
64
+ .get(domain)
65
+ ?.contexts.find((context) => context.brandProfileId === input.brandProfileId) ?? null;
66
+ const materialFingerprint = candidate
67
+ ? candidateMaterialFingerprint(candidate, input.brandProfileId ?? '')
68
+ : null;
69
+ if (reason)
70
+ rows.push({ domain, state: 'rejected', reason, previousContext, materialFingerprint });
71
+ else {
72
+ const context = existing
73
+ .get(domain)
74
+ ?.contexts.find((context) => context.brandProfileId === input.brandProfileId);
75
+ rows.push({
76
+ domain,
77
+ state: existing.has(domain) ? 'existing' : 'add',
78
+ previousContext,
79
+ materialFingerprint,
80
+ reason: context
81
+ ? input.replaceExistingContext
82
+ ? `Explicitly replace this context (${context.priority}) with ${input.priority}; preserve evidence and other Brand contexts.`
83
+ : `Preserve existing deliberate ${context.priority} priority and reason; merge provenance only.`
84
+ : existing.has(domain)
85
+ ? 'Already tracked; add this separate Brand context, preserving other contexts.'
86
+ : 'Add metadata only; no scan or baseline is invented.',
87
+ });
88
+ if (!existing.has(domain))
89
+ admitted++;
90
+ }
91
+ }
92
+ return {
93
+ destination: name,
94
+ brandProfileId: input.brandProfileId,
95
+ priority: input.priority,
96
+ reason: input.reason,
97
+ reviewDueAt: input.reviewDueAt ?? null,
98
+ replaceExistingContext: input.replaceExistingContext ?? false,
99
+ rows,
100
+ additionalRequests: 0,
101
+ collectionAuthorised: false,
102
+ };
103
+ }
104
+ export function applyCandidateWatchHandoff(localRaw, rawInput, nowRaw) {
105
+ const input = admitInput(rawInput), now = normalizeExplicitIsoTimestamp(nowRaw);
106
+ if (!now)
107
+ throw new TypeError('The watch metadata change time must use an explicit timezone.');
108
+ if (input.reviewDueAt && input.reviewDueAt <= now)
109
+ throw new TypeError('The review time must be later than this change time.');
110
+ const local = normalizeWatchlistStore(localRaw).watchlists, plan = planCandidateWatchHandoff(local, input);
111
+ const candidates = new Map();
112
+ for (const raw of input.candidates) {
113
+ const candidate = normalizeCandidateObservation(raw);
114
+ if (candidate && !candidates.has(candidate.domain))
115
+ candidates.set(candidate.domain, candidate);
116
+ }
117
+ const context = {
118
+ brandProfileId: input.brandProfileId,
119
+ priority: input.priority,
120
+ reason: input.reason,
121
+ changedAt: now,
122
+ reviewDueAt: input.reviewDueAt ?? null,
123
+ };
124
+ const entry = local[plan.destination] ?? {
125
+ updatedAt: null,
126
+ results: [],
127
+ baseline: [],
128
+ history: [],
129
+ domainMetadata: [],
130
+ };
131
+ if (input.replaceExistingContext &&
132
+ plan.rows.some((row) => row.state !== 'rejected' &&
133
+ row.previousContext?.changedAt &&
134
+ now <= row.previousContext.changedAt))
135
+ throw new Error('The change clock does not follow the retained context. Review the clock before replacing a manual priority.');
136
+ const metadata = plan.rows
137
+ .filter((row) => row.state !== 'rejected')
138
+ .map((row) => ({
139
+ domain: row.domain,
140
+ contexts: !input.replaceExistingContext &&
141
+ entry.domainMetadata.some((record) => record.domain === row.domain &&
142
+ record.contexts.some((old) => old.brandProfileId === input.brandProfileId))
143
+ ? []
144
+ : [context],
145
+ candidate: candidates.get(row.domain) ?? null,
146
+ }));
147
+ if (!metadata.length)
148
+ return { watchlists: local, plan };
149
+ Object.defineProperty(local, plan.destination, {
150
+ value: { ...entry, domainMetadata: mergeWatchDomainMetadata(entry.domainMetadata, metadata) },
151
+ enumerable: true,
152
+ configurable: true,
153
+ writable: true,
154
+ });
155
+ return { watchlists: assertWatchlistStoreBudget(local).watchlists, plan };
156
+ }
157
+ /** Field-scoped optimistic edits preserve unrelated domains and contexts. */
158
+ export function setWatchDomainContext(localRaw, name, domainRaw, input, expected) {
159
+ return setWatchDomainContexts(localRaw, name, [{ domain: domainRaw, input, expected }]);
160
+ }
161
+ export function setWatchDomainContexts(localRaw, name, edits) {
162
+ if (!Array.isArray(edits) || edits.length > MAX_WATCHLIST_DOMAINS)
163
+ throw new RangeError('The reviewed metadata edit exceeds its domain bound.');
164
+ const local = normalizeWatchlistStore(localRaw).watchlists, entry = local[name];
165
+ if (!entry)
166
+ throw new Error('The selected watchlist was deleted; nothing was overwritten.');
167
+ assertWatchlistEditable(entry);
168
+ const metadata = new Map(entry.domainMetadata.map((record) => [record.domain, record])), seen = new Set();
169
+ for (const edit of edits) {
170
+ const domain = normalizeDomain(edit.domain), record = domain ? metadata.get(domain) : null;
171
+ const key = `${domain}\0${edit.input.brandProfileId ?? ''}`, old = record?.contexts.find((context) => context.brandProfileId === edit.input.brandProfileId) ??
172
+ null;
173
+ if (!domain ||
174
+ !record ||
175
+ seen.has(key) ||
176
+ JSON.stringify(old) !== JSON.stringify(edit.expected))
177
+ throw new Error('A reviewed domain context changed, was repeated or was deleted. Reopen the selection; nothing was overwritten.');
178
+ const input = admitInput({ name, candidates: [], ...edit.input });
179
+ const changedAt = normalizeExplicitIsoTimestamp(edit.input.changedAt);
180
+ if (!changedAt ||
181
+ (old?.changedAt && changedAt <= old.changedAt) ||
182
+ (input.reviewDueAt && input.reviewDueAt <= changedAt))
183
+ throw new TypeError('Use a current change time and a later optional review time.');
184
+ seen.add(key);
185
+ metadata.set(domain, {
186
+ ...record,
187
+ contexts: [
188
+ ...record.contexts.filter((context) => context.brandProfileId !== edit.input.brandProfileId),
189
+ { ...edit.input, reason: input.reason, changedAt, reviewDueAt: input.reviewDueAt ?? null },
190
+ ],
191
+ });
192
+ }
193
+ local[name] = { ...entry, domainMetadata: [...metadata.values()] };
194
+ return assertWatchlistStoreBudget(local).watchlists;
195
+ }
@@ -1,10 +1,10 @@
1
1
  // Pure browser-local custom detection-rule model. Rules are deliberately a
2
2
  // small structured language: field names and operators come from allowlists,
3
3
  // so imported rules cannot execute code or reach outside bounded case evidence.
4
- import { latestCaseEvidence } from "../cases/case-model.mjs";
4
+ import { latestCaseEvidence } from "../cases/case-evidence-model.mjs";
5
5
  import { CASE_DISPOSITIONS, CASE_STATUSES } from "../cases/case-record-contracts.mjs";
6
6
  import { assertWorkspaceDeclaredVersion, assertWorkspaceInputGraph, assertWorkspacePortableVersion, ordinaryWorkspaceRecord } from "./hostile-input.mjs";
7
- import { DETECTION_RULE_SCHEMA, DETECTION_RULE_SCHEMA_VERSION, MAX_CONDITION_VALUE_LENGTH, MAX_CUSTOM_RISK_TOTAL, MAX_DETECTION_RULES, MAX_RULE_CONDITIONS, MAX_RULE_INPUT_RECORDS, MAX_RULE_NAME_LENGTH, MAX_RULE_RISK_DELTA, MAX_RULE_STORE_BYTES, MAX_RULE_TAG_LENGTH, } from "../contracts/workspace-portability.mjs";
7
+ import { DETECTION_RULE_SCHEMA, DETECTION_RULE_SCHEMA_VERSION, DETECTION_RULE_EXPORT_SUPPORTED_VERSIONS, MAX_CONDITION_VALUE_LENGTH, MAX_CUSTOM_RISK_TOTAL, MAX_DETECTION_RULES, MAX_RULE_CONDITIONS, MAX_RULE_INPUT_RECORDS, MAX_RULE_NAME_LENGTH, MAX_RULE_RISK_DELTA, MAX_RULE_STORE_BYTES, MAX_RULE_TAG_LENGTH, } from "../contracts/workspace-portability.mjs";
8
8
  export { DETECTION_RULE_SCHEMA, DETECTION_RULE_SCHEMA_VERSION, MAX_CONDITION_VALUE_LENGTH, MAX_CUSTOM_RISK_TOTAL, MAX_DETECTION_RULES, MAX_RULE_CONDITIONS, MAX_RULE_IMPORT_BYTES, MAX_RULE_INPUT_RECORDS, MAX_RULE_NAME_LENGTH, MAX_RULE_RISK_DELTA, MAX_RULE_STORE_BYTES, MAX_RULE_TAG_LENGTH, } from "../contracts/workspace-portability.mjs";
9
9
  const SAFE_ID_RE = /^[A-Za-z0-9_-]{1,64}$/;
10
10
  const TEXT_CONTROL_RE = /[\x00-\x1f\x7f]/;
@@ -24,6 +24,7 @@ export const RULE_FIELD_DEFINITIONS = Object.freeze([
24
24
  { value: 'reusesOfficialAssets', label: 'Official assets reused', kind: 'boolean' },
25
25
  { value: 'phishingLanguageMatch', label: 'Phishing-language signal', kind: 'text' },
26
26
  { value: 'hasExternalFormAction', label: 'External form action observed', kind: 'boolean' },
27
+ { value: 'hasExternalPasswordForm', label: 'Password form declares external destination', kind: 'boolean' },
27
28
  { value: 'mutationTypes', label: 'Mutation type', kind: 'list' },
28
29
  { value: 'nameservers', label: 'Nameserver', kind: 'list' },
29
30
  { value: 'httpSecurityHeaders', label: 'HTTP security header', kind: 'list' },
@@ -164,8 +165,12 @@ export function recoverDetectionRuleStore(raw) {
164
165
  assertWorkspaceDeclaredVersion(raw, 'Detection-rule store');
165
166
  const byId = new Map();
166
167
  const source = ruleList(raw);
168
+ const sourceVersion = detectionRuleStoreVersion(raw);
167
169
  let rejected = Math.max(0, source.length - MAX_RULE_INPUT_RECORDS);
168
170
  for (const item of source.slice(0, MAX_RULE_INPUT_RECORDS)) {
171
+ if (sourceVersion === 1 && Array.isArray(record(item).conditions) && record(item).conditions.some(condition => record(condition).field === 'hasExternalPasswordForm')) {
172
+ throw new Error('Form-attribution conditions require custom-rule schema 2; historical rules were not reinterpreted.');
173
+ }
169
174
  const rule = normalizeDetectionRule(item);
170
175
  if (!rule?.id || byId.has(rule.id)) {
171
176
  rejected += 1;
@@ -326,12 +331,15 @@ export function mergeDetectionRules(localRaw, importedRaw) {
326
331
  if (version !== null && version > DETECTION_RULE_SCHEMA_VERSION) {
327
332
  throw new Error(`This custom-rule file uses newer schema ${version}. Update the app before importing it.`);
328
333
  }
329
- if (version !== DETECTION_RULE_SCHEMA_VERSION) {
334
+ if (version === null || !DETECTION_RULE_EXPORT_SUPPORTED_VERSIONS.includes(version)) {
330
335
  throw new Error(`Expected custom-rule schema ${DETECTION_RULE_SCHEMA_VERSION}.`);
331
336
  }
332
337
  const local = normalizeDetectionRuleStore(localRaw).rules;
333
338
  const byId = new Map(local.map((rule) => [rule.id, rule]));
334
339
  const importedList = ruleList(importedRaw);
340
+ if (version === 1 && importedList.some(item => Array.isArray(record(item).conditions) && record(item).conditions.some(condition => record(condition).field === 'hasExternalPasswordForm'))) {
341
+ throw new Error('Form-attribution conditions require custom-rule schema 2; historical rules were not reinterpreted.');
342
+ }
335
343
  let added = 0;
336
344
  let updated = 0;
337
345
  let skipped = Math.max(0, importedList.length - MAX_RULE_INPUT_RECORDS);
@@ -1,5 +1,5 @@
1
1
  import { MAX_WORKSPACE_INPUT_ARRAY_LENGTH, MAX_WORKSPACE_INPUT_GRAPH_DEPTH, MAX_WORKSPACE_INPUT_GRAPH_NODES, MAX_WORKSPACE_INPUT_OBJECT_KEYS, MAX_WORKSPACE_INPUT_STRING_CODE_UNITS, } from "../contracts/workspace-portability.mjs";
2
- import { boundedJsonLimitsForBytes } from "../../lib/bounded-json.mjs";
2
+ import { boundedJsonLimitsForBytes } from "../analysis/bounded-json.mjs";
3
3
  function inputError(label, detail) {
4
4
  return new TypeError(`${label} must be bounded ordinary JSON data; ${detail}.`);
5
5
  }
@@ -1,4 +1,4 @@
1
- import { normalizeDomain } from "../cases/case-model.mjs";
1
+ import { normalizeDomain } from "../evidence/domain-name.mjs";
2
2
  import { parse } from 'tldts';
3
3
  import { normalizeExplicitIsoTimestamp } from "../evidence/observation.mjs";
4
4
  export const INVESTIGATION_GUIDE_SCHEMA = 'whoisleuth.investigation-recipe';
@@ -13,7 +13,7 @@ export const MAX_INVESTIGATION_GUIDE_TIMESTAMP_LENGTH = 64;
13
13
  export const MAX_INVESTIGATION_GUIDE_SERIALIZED_BYTES = 12_288;
14
14
  export const MAX_INVESTIGATION_GUIDE_EXPORT_BYTES = 16_384;
15
15
  const CONTROL_RE = /[\x00-\x1f\x7f]/u;
16
- const SAFE_TEMPLATE_ID_RE = /^[A-Za-z0-9_-]{1,128}$/u;
16
+ export const SAFE_TEMPLATE_ID_RE = /^[A-Za-z0-9_-]{1,128}$/u;
17
17
  const GUIDE_STATUSES = new Set(['active', 'paused']);
18
18
  const GUIDE_OUTCOMES = new Set(['pending', 'complete', 'partial', 'skipped']);
19
19
  function stage(id, workspace, label, detail, expectedEvidence, requestImpact, prerequisite, completionCriteria, instructions, requiresApproval) {