@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,8 +1,27 @@
1
- import { INVESTIGATION_RECIPES, normalizeInvestigationGuideTemplateSnapshot, } from "./investigation-guide.mjs";
1
+ import { INVESTIGATION_RECIPES, normalizeInvestigationGuideTemplateSnapshot, SAFE_TEMPLATE_ID_RE, } from "./investigation-guide.mjs";
2
2
  import { normalizeExplicitIsoTimestamp } from "../evidence/observation.mjs";
3
3
  import { assertWorkspaceDeclaredVersion, assertWorkspaceInputGraph, assertWorkspacePortableVersion, ordinaryWorkspaceRecord } from "./hostile-input.mjs";
4
+ import { exact, HEX_DIGEST_RE, text } from "../evidence/artifact-structure.mjs";
5
+ import { SORTED_JSON_V2 } from "../evidence/artifact-integrity.mjs";
4
6
  import { INVESTIGATION_TEMPLATE_SCHEMA, INVESTIGATION_TEMPLATE_SUPPORTED_VERSIONS, INVESTIGATION_TEMPLATE_VERSION, MAX_INVESTIGATION_TEMPLATES, MAX_INVESTIGATION_TEMPLATE_STORE_BYTES, } from "../contracts/workspace-portability.mjs";
5
7
  export { INVESTIGATION_TEMPLATE_SCHEMA, INVESTIGATION_TEMPLATE_SUPPORTED_VERSIONS, INVESTIGATION_TEMPLATE_VERSION, MAX_INVESTIGATION_TEMPLATES, MAX_INVESTIGATION_TEMPLATE_IMPORT_BYTES, MAX_INVESTIGATION_TEMPLATE_STORE_BYTES, } from "../contracts/workspace-portability.mjs";
8
+ export function readTemplateLessonRevision(raw) {
9
+ if (raw === undefined)
10
+ return undefined;
11
+ assertWorkspaceInputGraph(raw, 'Template revision provenance');
12
+ const value = exact(raw, ['parentTemplateId', 'parentContentSha256', 'lessonContentSha256', 'canonicalization', 'applicability', 'rationale'], 'Template revision provenance');
13
+ if (typeof value.parentTemplateId !== 'string' || !SAFE_TEMPLATE_ID_RE.test(value.parentTemplateId)
14
+ || value.canonicalization !== SORTED_JSON_V2
15
+ || typeof value.parentContentSha256 !== 'string' || !HEX_DIGEST_RE.test(value.parentContentSha256)
16
+ || typeof value.lessonContentSha256 !== 'string' || !HEX_DIGEST_RE.test(value.lessonContentSha256))
17
+ throw new TypeError('Template revision provenance is invalid.');
18
+ const applicability = text(value.applicability, 'Template applicability', 400).trim();
19
+ const rationale = text(value.rationale, 'Template revision reason', 400).trim();
20
+ if (!applicability || !rationale)
21
+ throw new TypeError('Template applicability and revision reason must not be empty.');
22
+ return { parentTemplateId: value.parentTemplateId, parentContentSha256: value.parentContentSha256, lessonContentSha256: value.lessonContentSha256,
23
+ canonicalization: SORTED_JSON_V2, applicability, rationale };
24
+ }
6
25
  function record(value) {
7
26
  return ordinaryWorkspaceRecord(value, 'Investigation-template input');
8
27
  }
@@ -17,11 +36,13 @@ function template(raw) {
17
36
  const snapshot = normalizeInvestigationGuideTemplateSnapshot(value);
18
37
  const createdAt = timestamp(value?.createdAt);
19
38
  const updatedAt = timestamp(value?.updatedAt);
39
+ const lessonRevision = readTemplateLessonRevision(value?.lessonRevision);
20
40
  return snapshot && createdAt && updatedAt
21
- ? { ...snapshot, createdAt, updatedAt }
41
+ ? { ...snapshot, createdAt, updatedAt, ...(lessonRevision ? { lessonRevision } : {}) }
22
42
  : null;
23
43
  }
24
44
  export function normalizeInvestigationTemplate(raw) {
45
+ assertWorkspaceInputGraph(raw, 'Investigation template');
25
46
  return template(raw);
26
47
  }
27
48
  export function normalizeInvestigationTemplateStore(raw) {
@@ -33,6 +54,9 @@ export function normalizeInvestigationTemplateStore(raw) {
33
54
  throw new Error(`Investigation-template schema ${String(value.version)} is unsupported; no data was changed.`);
34
55
  }
35
56
  const source = Array.isArray(raw) ? raw : Array.isArray(value?.templates) ? value.templates : [];
57
+ if (value?.version === 2 && source.some(candidate => record(candidate)?.lessonRevision !== undefined)) {
58
+ throw new Error('Template revision provenance requires schema 3; no data was changed.');
59
+ }
36
60
  const templates = [];
37
61
  const seen = new Set();
38
62
  for (const candidate of source.slice(0, MAX_INVESTIGATION_TEMPLATES * 2)) {
@@ -2,8 +2,8 @@
2
2
  // deliberately accepts only bounded, normalized page-identity fields. Raw
3
3
  // HTML, URLs, headers, redirect inventories, and parser diagnostics never
4
4
  // cross this storage boundary.
5
- import { normalizeDomain } from "../cases/case-model.mjs";
6
- import { isInformativePerceptualHash as isInformativeFaviconHash } from "../../lib/perceptual-hash-comparison.mjs";
5
+ import { normalizeDomain } from "../evidence/domain-name.mjs";
6
+ import { isInformativePerceptualHash as isInformativeFaviconHash } from "../analysis/perceptual-hash-comparison.mjs";
7
7
  import { normalizeExplicitIsoTimestamp } from "../evidence/observation.mjs";
8
8
  import { PAGE_FINGERPRINT_TOKEN_LIMITS } from "../contracts/page-fingerprints.mjs";
9
9
  import { MAX_BASELINE_IDENTIFIERS, MAX_BASELINE_RESOURCE_HOSTS, MAX_BASELINE_TITLE_LENGTH, PAGE_BASELINE_VERSION, PAGE_FINGERPRINT_PARSERS, PAGE_IDENTITY_VERSION, } from "../contracts/workspace-portability.mjs";
@@ -1,7 +1,7 @@
1
- import { normalizeDomain } from "../cases/case-model.mjs";
1
+ import { normalizeDomain } from "../evidence/domain-name.mjs";
2
2
  import { normalizeExplicitIsoTimestamp } from "../evidence/observation.mjs";
3
- import { RELATIONSHIP_EVIDENCE_VERSION } from "../contracts/offline-comparison.mjs";
4
- import { RELATIONSHIP_TYPES, qualifyRelationshipSources } from "../comparison/relationship-provenance.mjs";
3
+ import { RELATIONSHIP_EVIDENCE_VERSION, RELATIONSHIP_SOURCE_PROVENANCE_VERSION, RELATIONSHIP_PUBLIC_ADDRESS_VERSION } from "../contracts/offline-comparison.mjs";
4
+ import { RELATIONSHIP_TYPES, qualifyRelationshipSources, isNonPublicAddressRelationship, NON_PUBLIC_RELATIONSHIP_LIMITATION, FAVICON_RELATIONSHIP_DESCRIPTION } from "../comparison/relationship-provenance.mjs";
5
5
  import { assertWorkspaceDeclaredVersion, assertWorkspaceInputGraph, assertWorkspacePortableVersion, ordinaryWorkspaceRecord } from "./hostile-input.mjs";
6
6
  import { MAX_RELATIONSHIP_OBSERVATIONS, MAX_RELATIONSHIP_OBSERVATION_DOMAINS, MAX_RELATIONSHIP_OBSERVATION_INPUTS, MAX_RELATIONSHIP_OBSERVATION_LIMITATIONS, MAX_RELATIONSHIP_OBSERVATION_STORE_BYTES, MAX_RELATIONSHIP_OBSERVATION_VALUE_LENGTH, RELATIONSHIP_OBSERVATION_SCHEMA, RELATIONSHIP_OBSERVATION_SCHEMA_VERSION, } from "../contracts/workspace-portability.mjs";
7
7
  export { MAX_RELATIONSHIP_OBSERVATIONS, MAX_RELATIONSHIP_OBSERVATION_DOMAINS, MAX_RELATIONSHIP_OBSERVATION_INPUTS, MAX_RELATIONSHIP_OBSERVATION_LIMITATIONS, MAX_RELATIONSHIP_OBSERVATION_STORE_BYTES, MAX_RELATIONSHIP_OBSERVATION_VALUE_LENGTH, RELATIONSHIP_OBSERVATION_SCHEMA, RELATIONSHIP_OBSERVATION_SCHEMA_VERSION, } from "../contracts/workspace-portability.mjs";
@@ -34,9 +34,9 @@ const TYPE_METADATA = Object.freeze({
34
34
  description: 'These pages exposed the same recognised public tracking identifier in bounded static HTML.',
35
35
  }),
36
36
  favicon: Object.freeze({
37
- label: 'Similar favicon',
37
+ label: 'Connected favicon matches',
38
38
  method: 'Exact SHA-256 or perceptual dHash distance ≤ 6',
39
- description: 'These domains used an identical or perceptually similar favicon in the retained scan.',
39
+ description: FAVICON_RELATIONSHIP_DESCRIPTION,
40
40
  }),
41
41
  official_asset: Object.freeze({
42
42
  label: 'Official asset host match',
@@ -234,16 +234,17 @@ export function normalizeRelationshipObservation(raw, sourceStoreVersion = RELAT
234
234
  throw new TypeError('This retained relationship uses a newer source-evidence version; no evidence was interpreted.');
235
235
  if (sourceStoreVersion === 1 && sourceVersion > 2)
236
236
  throw new TypeError('Relationship schema 1 cannot contain newer source evidence; no evidence was interpreted.');
237
- const qualified = qualifyRelationshipSources(sourceVersion === RELATIONSHIP_EVIDENCE_VERSION ? value.sourceEvidence : [], domains, { truncated: value.truncated === true, type });
237
+ const qualified = qualifyRelationshipSources(sourceVersion >= RELATIONSHIP_SOURCE_PROVENANCE_VERSION ? value.sourceEvidence : [], domains, { truncated: value.truncated === true, type });
238
+ const nonPublicAddress = isNonPublicAddressRelationship(type, normalizedValue);
238
239
  return {
239
240
  id,
240
241
  type,
241
- label: metadata.label,
242
+ label: nonPublicAddress ? 'Shared non-public DNS answer' : metadata.label,
242
243
  method: metadata.method,
243
244
  normalizedValue,
244
245
  displayValue: type === 'favicon' ? '' : normalizedValue,
245
246
  domains,
246
- description: metadata.description,
247
+ description: nonPublicAddress ? NON_PUBLIC_RELATIONSHIP_LIMITATION : metadata.description,
247
248
  classification: 'derived',
248
249
  source: 'bulk_relationship_analysis',
249
250
  sourceVersion,
@@ -255,7 +256,7 @@ export function normalizeRelationshipObservation(raw, sourceStoreVersion = RELAT
255
256
  complete: value.complete === true && qualified.complete,
256
257
  truncated: qualified.truncated
257
258
  || (Array.isArray(value.domains) && value.domains.length > MAX_RELATIONSHIP_OBSERVATION_DOMAINS),
258
- limitations: observationLimitations(value.limitations),
259
+ limitations: observationLimitations([...(nonPublicAddress ? [NON_PUBLIC_RELATIONSHIP_LIMITATION] : []), ...(Array.isArray(value.limitations) ? value.limitations : [])]),
259
260
  };
260
261
  }
261
262
  export function normalizeRelationshipObservationStore(raw) {
@@ -296,7 +297,10 @@ export function createRelationshipObservation(raw, options = {}) {
296
297
  const sourceVersion = Object.hasOwn(options, 'sourceVersion') ? positiveInteger(options.sourceVersion) : RELATIONSHIP_EVIDENCE_VERSION;
297
298
  if (!sourceVersion || sourceVersion > RELATIONSHIP_EVIDENCE_VERSION)
298
299
  throw new TypeError('This relationship needs a supported source-evidence version.');
299
- const qualified = qualifyRelationshipSources(sourceVersion === RELATIONSHIP_EVIDENCE_VERSION ? raw.sourceEvidence : [], domains, { truncated: options.truncated === true, type });
300
+ const nonPublicAddress = isNonPublicAddressRelationship(type, normalizedValue);
301
+ if (nonPublicAddress && sourceVersion >= RELATIONSHIP_PUBLIC_ADDRESS_VERSION)
302
+ throw new TypeError('Non-public DNS answers cannot be retained as new public-infrastructure relationships.');
303
+ const qualified = qualifyRelationshipSources(sourceVersion >= RELATIONSHIP_SOURCE_PROVENANCE_VERSION ? raw.sourceEvidence : [], domains, { truncated: options.truncated === true, type });
300
304
  // Older callers can supply their original scan timestamp. It does not
301
305
  // qualify the missing source metadata, and an explicit current projection
302
306
  // never substitutes that timestamp for unknown contributing-source times.
@@ -308,12 +312,12 @@ export function createRelationshipObservation(raw, options = {}) {
308
312
  return {
309
313
  id: observationId(canonical),
310
314
  type,
311
- label: metadata.label,
315
+ label: nonPublicAddress ? 'Shared non-public DNS answer' : metadata.label,
312
316
  method: metadata.method,
313
317
  normalizedValue,
314
318
  displayValue: type === 'favicon' ? '' : normalizedValue,
315
319
  domains,
316
- description: metadata.description,
320
+ description: nonPublicAddress ? NON_PUBLIC_RELATIONSHIP_LIMITATION : metadata.description,
317
321
  classification: 'derived',
318
322
  source: 'bulk_relationship_analysis',
319
323
  sourceVersion,
@@ -323,7 +327,7 @@ export function createRelationshipObservation(raw, options = {}) {
323
327
  complete: options.complete !== false && qualified.complete,
324
328
  truncated: qualified.truncated
325
329
  || (Array.isArray(raw.domains) && raw.domains.length > MAX_RELATIONSHIP_OBSERVATION_DOMAINS),
326
- limitations: observationLimitations(options.limitations),
330
+ limitations: observationLimitations([...(nonPublicAddress ? [NON_PUBLIC_RELATIONSHIP_LIMITATION] : []), ...(Array.isArray(options.limitations) ? options.limitations : [])]),
327
331
  };
328
332
  }
329
333
  function byteLength(value) {
@@ -2,8 +2,8 @@
2
2
  // and downloads; this module owns schema migration, record normalization,
3
3
  // import merging, and exact serialized-byte accounting.
4
4
  import { compactWatchlistResults } from "./watchlist-history.mjs";
5
- import { normalizeOpportunityModelVersion } from "../../lib/opportunity-scoring.mjs";
6
- import { normalizeRiskModelVersion } from "../../lib/risk-scoring.mjs";
5
+ import { normalizeOpportunityModelVersion } from "../analysis/opportunity-scoring.mjs";
6
+ import { normalizeRiskModelVersion } from "../analysis/risk-scoring.mjs";
7
7
  import { normalizeExplicitIsoTimestamp } from "../evidence/observation.mjs";
8
8
  import { assertWorkspaceDeclaredVersion, assertWorkspaceInputGraph, assertWorkspacePortableVersion, ordinaryWorkspaceRecord } from "./hostile-input.mjs";
9
9
  import { MAX_SHORTLIST_ENTRIES, MAX_SHORTLIST_FACTORS, MAX_SHORTLIST_INPUTS, MAX_SHORTLIST_STORE_BYTES, SHORTLIST_SCHEMA, SHORTLIST_SCHEMA_VERSION, SUPPORTED_SHORTLIST_SCHEMA_VERSIONS, } from "../contracts/workspace-portability.mjs";
@@ -69,9 +69,11 @@ export function normalizeShortlistRecord(raw, options = {}) {
69
69
  const compact = compactWatchlistResults([value])[0];
70
70
  if (!compact)
71
71
  return null;
72
+ // Shortlist's published schema does not retain the newer local watch evidence.
73
+ const { hasExternalPasswordForm: _localWatchEvidence, ...shortlistCompact } = compact;
72
74
  const riskScore = score(value.riskScore);
73
75
  return {
74
- ...compact,
76
+ ...shortlistCompact,
75
77
  availability: typeof compact.availability === 'string' ? compact.availability : 'unknown',
76
78
  riskModelVersion: riskScore === null ? null : normalizeRiskModelVersion(value.riskModelVersion),
77
79
  riskScore,
@@ -120,12 +122,13 @@ export function serializeShortlistStore(records) {
120
122
  return JSON.stringify(assertShortlistStoreBudget(records));
121
123
  }
122
124
  /** Add, refresh, or remove an explicit bounded selection in one transaction. */
123
- export function setShortlistSelection(localRaw, selectedRaw, selected, nowIso = new Date().toISOString()) {
125
+ export function setShortlistSelection(localRaw, selectedRaw, selected, nowIso = new Date().toISOString(), existing = 'refresh') {
124
126
  const local = normalizeShortlistStore(localRaw).entries;
125
127
  const byDomain = new Map(local.map((record) => [record.domain, record]));
126
128
  const candidates = Array.isArray(selectedRaw) ? selectedRaw.slice(0, MAX_SHORTLIST_INPUTS) : [];
127
129
  let added = 0;
128
130
  let updated = 0;
131
+ let retained = 0;
129
132
  let removed = 0;
130
133
  let skipped = Math.max(0, (Array.isArray(selectedRaw) ? selectedRaw.length : 0) - MAX_SHORTLIST_INPUTS);
131
134
  const seen = new Set();
@@ -137,6 +140,10 @@ export function setShortlistSelection(localRaw, selectedRaw, selected, nowIso =
137
140
  }
138
141
  seen.add(record.domain);
139
142
  if (selected) {
143
+ if (existing === 'retain' && byDomain.has(record.domain)) {
144
+ retained += 1;
145
+ continue;
146
+ }
140
147
  if (!byDomain.has(record.domain) && byDomain.size >= MAX_SHORTLIST_ENTRIES) {
141
148
  skipped += 1;
142
149
  continue;
@@ -155,6 +162,7 @@ export function setShortlistSelection(localRaw, selectedRaw, selected, nowIso =
155
162
  entries: assertShortlistStoreBudget([...byDomain.values()]).entries,
156
163
  added,
157
164
  updated,
165
+ retained,
158
166
  removed,
159
167
  skipped,
160
168
  };
@@ -188,14 +196,23 @@ export function mergeShortlistStores(localRaw, importedRaw) {
188
196
  skipped++;
189
197
  continue;
190
198
  }
191
- if (imported.has(record.domain))
199
+ const prior = imported.get(record.domain);
200
+ if (prior)
192
201
  skipped++;
193
- imported.set(record.domain, record);
202
+ if (!prior || Date.parse(record.savedAt) > Date.parse(prior.savedAt))
203
+ imported.set(record.domain, record);
194
204
  }
195
205
  let added = 0;
196
206
  let updated = 0;
197
207
  for (const record of imported.values()) {
198
- if (byDomain.has(record.domain))
208
+ const existing = byDomain.get(record.domain);
209
+ // A backup must not roll back an observation. Equal-time conflicts retain
210
+ // the local record rather than choosing whichever file was imported last.
211
+ if (existing && Date.parse(record.savedAt) <= Date.parse(existing.savedAt)) {
212
+ skipped++;
213
+ continue;
214
+ }
215
+ if (existing)
199
216
  updated++;
200
217
  else if (byDomain.size >= MAX_SHORTLIST_ENTRIES) {
201
218
  skipped++;
@@ -2,12 +2,14 @@
2
2
  // the watchlist entry for rescanning, while history stores compact changes
3
3
  // against a last-known baseline so browser-local storage does not grow by one complete
4
4
  // result set on every check.
5
- import { explainRiskScore, normalizeRiskModelVersion } from "../../lib/risk-scoring.mjs";
5
+ import { explainRiskScore, normalizeRiskModelVersion } from "../analysis/risk-scoring.mjs";
6
6
  import { HTTP_SECURITY_HEADER_TOKENS, normalizeHttpSummary } from "../cases/http-summary.mjs";
7
- import { normalizeDomain } from "../cases/case-model.mjs";
7
+ import { normalizeDomain } from "../evidence/domain-name.mjs";
8
8
  import { normalizeExplicitIsoTimestamp } from "../evidence/observation.mjs";
9
9
  import { registryDateIso } from "../evidence/registry-dates.mjs";
10
- import { MAX_WATCHLIST_CHANGES_PER_EVENT, MAX_WATCHLIST_DOMAINS, MAX_WATCHLIST_HISTORY_DOMAIN_OPTIONS, MAX_WATCHLIST_HISTORY_EVENTS, MAX_WATCHLIST_INPUT_RECORDS, MAX_WATCHLIST_MUTATION_TYPES, MAX_WATCHLIST_NAMESERVERS, } from "../contracts/workspace-portability.mjs";
10
+ import { normalizeWatchDomainMetadata } from "./brand-candidate-workflow.mjs";
11
+ import { mergeWebCollectionQuality, normalizeWebCollectionQuality, webCollectionAllowsComparison } from "../evidence/collection-quality.mjs";
12
+ import { MAX_WATCHLIST_CHANGES_PER_EVENT, MAX_WATCHLIST_DOMAINS, MAX_WATCHLIST_HISTORY_DOMAIN_OPTIONS, MAX_WATCHLIST_HISTORY_EVENTS, MAX_WATCHLIST_INPUT_RECORDS, MAX_WATCHLIST_MUTATION_TYPES, MAX_WATCHLIST_NAMESERVERS, WATCHLIST_RECOVERY_METADATA, } from "../contracts/workspace-portability.mjs";
11
13
  export { MAX_WATCHLIST_CHANGES_PER_EVENT, MAX_WATCHLIST_DOMAINS, MAX_WATCHLIST_HISTORY_DOMAIN_OPTIONS, MAX_WATCHLIST_HISTORY_EVENTS, MAX_WATCHLIST_INPUT_RECORDS, MAX_WATCHLIST_MUTATION_TYPES, MAX_WATCHLIST_NAMESERVERS, } from "../contracts/workspace-portability.mjs";
12
14
  const MAX_TEXT_LENGTH = 300;
13
15
  const MAX_TITLE_LENGTH = 200;
@@ -36,6 +38,7 @@ const DEEP_FIELDS = new Set([
36
38
  'faviconMatch',
37
39
  'faviconNearMatch',
38
40
  'hasPasswordField',
41
+ 'hasExternalPasswordForm',
39
42
  'phishingLanguageMatch',
40
43
  'reusesOfficialAssets',
41
44
  'riskModelVersion',
@@ -67,6 +70,7 @@ const FIELD_LABELS = {
67
70
  faviconMatch: 'Official favicon match',
68
71
  faviconNearMatch: 'Official favicon near-match',
69
72
  hasPasswordField: 'Password form',
73
+ hasExternalPasswordForm: 'External password-form destination',
70
74
  phishingLanguageMatch: 'Phishing language',
71
75
  reusesOfficialAssets: 'Official asset reuse',
72
76
  riskScore: 'Risk score',
@@ -93,6 +97,7 @@ const HISTORY_CATEGORY_FIELDS = {
93
97
  'faviconMatch',
94
98
  'faviconNearMatch',
95
99
  'hasPasswordField',
100
+ 'hasExternalPasswordForm',
96
101
  'phishingLanguageMatch',
97
102
  'reusesOfficialAssets',
98
103
  ]),
@@ -145,9 +150,6 @@ function boundedText(value, maximum = MAX_TEXT_LENGTH, allowNull = true) {
145
150
  const normalized = value.slice(0, maximum * 4).replace(/\s+/g, ' ').trim().slice(0, maximum).trim();
146
151
  return normalized || (allowNull ? null : '');
147
152
  }
148
- function isoTimestamp(value, fallback = new Date(0).toISOString()) {
149
- return normalizeExplicitIsoTimestamp(value) ?? fallback;
150
- }
151
153
  function boundedInteger(value, maximum, fallback = 0) {
152
154
  return Number.isSafeInteger(value) && Number(value) >= 0 && Number(value) <= maximum
153
155
  ? Number(value)
@@ -205,11 +207,14 @@ function compactRecord(value) {
205
207
  if (!domain)
206
208
  return null;
207
209
  const scanDepth = inferredScanDepth(record);
210
+ const webCollectionQuality = normalizeWebCollectionQuality(record.webCollectionQuality, scanDepth);
208
211
  const httpSummary = normalizeHttpSummary(record) || {};
209
212
  const providedRiskScore = typeof record.riskScore === 'number' && Number.isFinite(record.riskScore)
210
213
  ? Math.max(0, Math.min(100, Math.round(record.riskScore)))
211
214
  : null;
212
- const computedRisk = scanDepth === 'deep' && providedRiskScore === null ? explainRiskScore(record) : null;
215
+ const computedRisk = scanDepth === 'deep' && providedRiskScore === null
216
+ && (!webCollectionQuality || (record.riskScore === undefined
217
+ && webCollectionAllowsComparison('riskScore', webCollectionQuality, scanDepth))) ? explainRiskScore(record) : null;
213
218
  const riskScore = scanDepth === 'deep' ? (providedRiskScore ?? computedRisk?.score ?? null) : null;
214
219
  const riskModelVersion = riskScore === null
215
220
  ? null
@@ -219,6 +224,7 @@ function compactRecord(value) {
219
224
  return {
220
225
  domain,
221
226
  scanDepth,
227
+ ...(webCollectionQuality ? { webCollectionQuality } : {}),
222
228
  availability: hasString(AVAILABILITY_VALUES, record.availability) ? record.availability : null,
223
229
  registrarName: boundedText(record.registrarName),
224
230
  nameservers: normalizeNameservers(record.nameservers),
@@ -235,6 +241,7 @@ function compactRecord(value) {
235
241
  faviconMatch: typeof record.faviconMatch === 'boolean' ? record.faviconMatch : null,
236
242
  faviconNearMatch: typeof record.faviconNearMatch === 'boolean' ? record.faviconNearMatch : null,
237
243
  hasPasswordField: typeof record.hasPasswordField === 'boolean' ? record.hasPasswordField : null,
244
+ hasExternalPasswordForm: typeof record.hasExternalPasswordForm === 'boolean' ? record.hasExternalPasswordForm : null,
238
245
  phishingLanguageMatch: boundedText(record.phishingLanguageMatch, MAX_TITLE_LENGTH),
239
246
  reusesOfficialAssets: typeof record.reusesOfficialAssets === 'boolean' ? record.reusesOfficialAssets : null,
240
247
  riskModelVersion,
@@ -260,6 +267,8 @@ export function compactWatchlistResults(results) {
260
267
  function isComparable(field, value, record) {
261
268
  if (DEEP_FIELDS.has(field) && record.scanDepth !== 'deep')
262
269
  return false;
270
+ if (!webCollectionAllowsComparison(field, record.webCollectionQuality, record.scanDepth))
271
+ return false;
263
272
  if (field === 'availability')
264
273
  return hasString(CONCLUSIVE_AVAILABILITY, value);
265
274
  if (field === 'nameservers')
@@ -297,7 +306,7 @@ function classifyChange(field, before, after) {
297
306
  }
298
307
  return { kind: 'availability_changed', tone: 'warn' };
299
308
  }
300
- if (['faviconMatch', 'faviconNearMatch', 'hasPasswordField', 'reusesOfficialAssets'].includes(field) && before === false && after === true) {
309
+ if (['faviconMatch', 'faviconNearMatch', 'hasPasswordField', 'hasExternalPasswordForm', 'reusesOfficialAssets'].includes(field) && before === false && after === true) {
301
310
  return { kind: 'risk_signal_added', tone: 'danger' };
302
311
  }
303
312
  if (field === 'phishingLanguageMatch' && !before && after)
@@ -358,7 +367,7 @@ export function mergeWatchlistBaseline(baseline, current) {
358
367
  return current.map((next) => {
359
368
  const previous = previousByDomain.get(next.domain);
360
369
  const updated = {
361
- ...(previous || {}),
370
+ ...(previous || next),
362
371
  domain: next.domain,
363
372
  scanDepth: previous?.scanDepth === 'deep' || next.scanDepth === 'deep' ? 'deep' : 'fast',
364
373
  };
@@ -370,9 +379,12 @@ export function mergeWatchlistBaseline(baseline, current) {
370
379
  // mean "unversioned" to the comparator, but retaining the key makes the
371
380
  // stored/exported contract unambiguous and prevents consumers from having
372
381
  // to infer why a score was deliberately excluded from comparison.
373
- if (next.scanDepth === 'deep' && typeof next.riskScore === 'number') {
382
+ if (next.scanDepth === 'deep' && typeof next.riskScore === 'number' && isComparable('riskScore', next.riskScore, next)) {
374
383
  updated.riskModelVersion = normalizeRiskModelVersion(next.riskModelVersion);
375
384
  }
385
+ const quality = mergeWebCollectionQuality(previous?.webCollectionQuality, next.webCollectionQuality);
386
+ if (quality)
387
+ updated.webCollectionQuality = quality;
376
388
  return updated;
377
389
  });
378
390
  }
@@ -452,17 +464,28 @@ function normalizeStoredFieldValue(field, value) {
452
464
  }
453
465
  function initialHistoryEvent(entry, baseline) {
454
466
  return {
455
- checkedAt: entry.updatedAt || new Date(0).toISOString(),
467
+ checkedAt: entry.updatedAt,
456
468
  mode: 'saved',
457
469
  resultCount: Array.isArray(entry.results) ? entry.results.length : baseline.length,
458
470
  conclusiveCount: baseline.filter((record) => hasString(CONCLUSIVE_AVAILABILITY, record.availability)).length,
459
471
  changeCount: 0,
460
472
  omittedChanges: 0,
473
+ webComparisonLimitedCount: limitedWebComparisonCount(baseline),
461
474
  changes: [],
462
475
  };
463
476
  }
477
+ /** Active membership excludes historical baselines and includes unobserved candidates. */
478
+ export function watchlistActiveDomains(entry) {
479
+ if (entry.membershipRecovery)
480
+ return [];
481
+ return normalizeWatchDomainMetadata(entry.domainMetadata, entry.results.map(record => record.domain)).map(record => record.domain);
482
+ }
483
+ export function assertWatchlistEditable(entry) {
484
+ if (entry?.membershipRecovery)
485
+ throw new Error('This older watchlist is paused for membership recovery. Export its preserved records and create a separate watchlist with at most 2,000 selected domains.');
486
+ }
464
487
  /** @param {object} entry */
465
- export function normalizeWatchlistEntry(entry) {
488
+ export function normalizeWatchlistEntry(entry, options = {}) {
466
489
  const input = plainRecord(entry) || {};
467
490
  const rawResults = Array.isArray(input.results) ? input.results : [];
468
491
  const results = compactWatchlistResults(rawResults);
@@ -480,7 +503,7 @@ export function normalizeWatchlistEntry(entry) {
480
503
  : [];
481
504
  const rawMode = event.mode;
482
505
  return {
483
- checkedAt: isoTimestamp(event.checkedAt, new Date(0).toISOString()),
506
+ checkedAt: normalizeExplicitIsoTimestamp(event.checkedAt),
484
507
  mode: typeof rawMode === 'string' && ['fast', 'deep', 'saved'].includes(rawMode)
485
508
  ? rawMode
486
509
  : 'saved',
@@ -488,31 +511,47 @@ export function normalizeWatchlistEntry(entry) {
488
511
  conclusiveCount: boundedInteger(event.conclusiveCount, MAX_WATCHLIST_DOMAINS),
489
512
  changeCount: boundedInteger(event.changeCount, MAX_CHANGE_COUNT, changes.length),
490
513
  omittedChanges: boundedInteger(event.omittedChanges, MAX_CHANGE_COUNT),
514
+ ...(event.webComparisonLimitedCount !== undefined
515
+ ? { webComparisonLimitedCount: boundedInteger(event.webComparisonLimitedCount, MAX_WATCHLIST_DOMAINS) } : {}),
491
516
  changes,
492
517
  };
493
518
  }).slice(-MAX_WATCHLIST_HISTORY_EVENTS)
494
519
  : [];
520
+ const metadata = normalizeWatchDomainMetadata(input.domainMetadata);
521
+ const membership = new Set([...metadata.map(record => record.domain), ...results.map(record => record.domain)]);
522
+ const recovery = membership.size > MAX_WATCHLIST_DOMAINS
523
+ && (options.recoverLegacyMembership || input.membershipRecovery === 'legacy_overflow');
524
+ if (input.membershipRecovery !== undefined && (!recovery || input.membershipRecovery !== 'legacy_overflow'))
525
+ throw new TypeError('Watchlist membership recovery state is invalid.');
495
526
  const normalized = {
496
- updatedAt: isoTimestamp(input.updatedAt, new Date(0).toISOString()),
527
+ updatedAt: normalizeExplicitIsoTimestamp(input.updatedAt),
497
528
  results,
498
529
  baseline,
499
530
  history,
531
+ domainMetadata: recovery ? metadata : normalizeWatchDomainMetadata(metadata, results.map(record => record.domain)),
532
+ ...(recovery ? WATCHLIST_RECOVERY_METADATA : {}),
500
533
  };
501
- if (normalized.history.length === 0)
534
+ if (normalized.history.length === 0 && (results.length || baseline.length))
502
535
  normalized.history.push(initialHistoryEvent(normalized, baseline));
503
536
  return normalized;
504
537
  }
538
+ function limitedWebComparisonCount(current, baseline = []) {
539
+ const previous = new Map(baseline.map(record => [record.domain, record]));
540
+ return current.filter(record => record.scanDepth === 'deep' && (!webCollectionAllowsComparison('riskScore', record.webCollectionQuality, record.scanDepth)
541
+ || (previous.has(record.domain) && !webCollectionAllowsComparison('riskScore', previous.get(record.domain).webCollectionQuality, previous.get(record.domain).scanDepth)))).length;
542
+ }
505
543
  /**
506
544
  * @param {object | null} existingEntry
507
545
  * @param {object[]} results
508
546
  * @param {{ checkedAt?: string, mode?: string, ignoredDomains?: Set<string> }} [options]
509
547
  */
510
548
  export function appendWatchlistScan(existingEntry, results, options = {}) {
511
- const checkedAt = isoTimestamp(options.checkedAt, new Date().toISOString());
549
+ const checkedAt = options.checkedAt === undefined ? new Date().toISOString() : normalizeExplicitIsoTimestamp(options.checkedAt);
512
550
  const mode = typeof options.mode === 'string' && ['fast', 'deep', 'saved'].includes(options.mode)
513
551
  ? options.mode
514
552
  : 'saved';
515
553
  const previous = existingEntry ? normalizeWatchlistEntry(existingEntry) : null;
554
+ assertWatchlistEditable(previous);
516
555
  const current = compactWatchlistResults(results);
517
556
  const changes = previous
518
557
  ? diffWatchlistBaseline(previous.baseline, current, options.ignoredDomains || new Set())
@@ -525,6 +564,7 @@ export function appendWatchlistScan(existingEntry, results, options = {}) {
525
564
  conclusiveCount: current.filter((record) => hasString(CONCLUSIVE_AVAILABILITY, record.availability)).length,
526
565
  changeCount: changes.length,
527
566
  omittedChanges: Math.max(0, changes.length - storedChanges.length),
567
+ webComparisonLimitedCount: limitedWebComparisonCount(current, previous?.baseline),
528
568
  changes: storedChanges,
529
569
  };
530
570
  const history = [...(previous?.history || []), event].slice(-MAX_WATCHLIST_HISTORY_EVENTS);
@@ -534,6 +574,7 @@ export function appendWatchlistScan(existingEntry, results, options = {}) {
534
574
  results: current,
535
575
  baseline: mergeWatchlistBaseline(previous?.baseline || [], current),
536
576
  history,
577
+ domainMetadata: normalizeWatchDomainMetadata(previous?.domainMetadata, current.map(record => record.domain)),
537
578
  },
538
579
  changes,
539
580
  };
@@ -578,6 +619,8 @@ export function watchlistHistoryDomains(entry) {
578
619
  if (record?.domain)
579
620
  current.add(record.domain);
580
621
  }
622
+ for (const metadata of normalized.domainMetadata)
623
+ current.add(metadata.domain);
581
624
  for (const event of normalized.history) {
582
625
  for (const change of event.changes) {
583
626
  if (change.domain && !current.has(change.domain))