@slicedearth/whoisleuth-cli 2.4.0 → 2.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (300) hide show
  1. package/NOTICE +2 -2
  2. package/cli/argument-values.mjs +55 -0
  3. package/cli/arguments.mjs +24 -470
  4. package/cli/artifact-validation/case-response.mjs +21 -6
  5. package/cli/artifact-validation/lookup-evidence.mjs +3 -3
  6. package/cli/artifact-validation/signed-review.mjs +59 -13
  7. package/cli/artifact-verify.mjs +2 -0
  8. package/cli/assurance-arguments.mjs +56 -0
  9. package/cli/assurance-command-definitions.mjs +179 -0
  10. package/cli/assurance-command-runner.mjs +1 -1
  11. package/cli/bounded-json.mjs +1 -1
  12. package/cli/bulk-checkpoint.mjs +1 -1
  13. package/cli/bulk-command-runner.mjs +2 -2
  14. package/cli/bulk.mjs +2 -3
  15. package/cli/case-command.mjs +20 -3
  16. package/cli/case-pack.mjs +12 -2
  17. package/cli/ci-report.mjs +6 -3
  18. package/cli/collection-arguments.mjs +123 -0
  19. package/cli/collection-command-definitions.mjs +330 -0
  20. package/cli/collection-preflight.mjs +8 -1
  21. package/cli/command-argument-grammar.mjs +8 -210
  22. package/cli/command-definition.mjs +329 -0
  23. package/cli/command-reference.mjs +55 -1388
  24. package/cli/command-seeds.mjs +18 -0
  25. package/cli/compare.mjs +1 -1
  26. package/cli/config-profile.mjs +1 -1
  27. package/cli/ct-event-intake.mjs +4 -4
  28. package/cli/discover.mjs +2 -2
  29. package/cli/discovery-observation-snapshot.mjs +2 -2
  30. package/cli/discovery-scan-command-runner.mjs +3 -3
  31. package/cli/discovery-scan.mjs +1 -1
  32. package/cli/discovery-snapshot.mjs +2 -2
  33. package/cli/discovery-workflow.mjs +4 -4
  34. package/cli/domain-control-monitor.mjs +6 -6
  35. package/cli/domain-control-observations.mjs +2 -1
  36. package/cli/errors.mjs +5 -6
  37. package/cli/evidence-arguments.mjs +46 -0
  38. package/cli/evidence-command-definitions.mjs +69 -0
  39. package/cli/evidence-command-runner.mjs +2 -3
  40. package/cli/evidence-signing.mjs +3 -2
  41. package/cli/external-observation-mapping.mjs +2 -2
  42. package/cli/formatters/terminal-command-formats.mjs +6 -0
  43. package/cli/formatters/terminal-lookup.mjs +6 -0
  44. package/cli/history-arguments.mjs +39 -0
  45. package/cli/history-command-definitions.mjs +88 -0
  46. package/cli/indicator-set-command.mjs +33 -0
  47. package/cli/inline-command-families.mjs +6 -47
  48. package/cli/inline-command-runner.mjs +4 -31
  49. package/cli/input.mjs +2 -2
  50. package/cli/intake-command.mjs +107 -0
  51. package/cli/interchange-report.mjs +4 -2
  52. package/cli/investigation-artifacts.mjs +1 -1
  53. package/cli/investigation-plan.mjs +32 -154
  54. package/cli/investigation-recipes.mjs +177 -0
  55. package/cli/investigation-run.mjs +78 -12
  56. package/cli/local-mmdb-review.mjs +197 -17
  57. package/cli/lookalike-calibration.mjs +1 -1
  58. package/cli/lookup-brief.mjs +2 -2
  59. package/cli/lookup-command-runner.mjs +18 -35
  60. package/cli/lookup-diff.mjs +10 -9
  61. package/cli/lookup-plan.mjs +10 -14
  62. package/cli/lookup-reconcile.mjs +6 -5
  63. package/cli/lookup-timeline.mjs +7 -6
  64. package/cli/mail-header-review.mjs +24 -247
  65. package/cli/mail-review.mjs +9 -7
  66. package/cli/network-arguments.mjs +51 -0
  67. package/cli/network-command-definitions.mjs +130 -0
  68. package/cli/network-command-runner.mjs +43 -31
  69. package/cli/offline-evidence-review.mjs +39 -6
  70. package/cli/posture.mjs +1 -1
  71. package/cli/registry-fixture-scaffold.mjs +1 -1
  72. package/cli/retained-artifact-diff.mjs +8 -7
  73. package/cli/review-arguments.mjs +131 -0
  74. package/cli/review-command-definitions.mjs +273 -0
  75. package/cli/review-command-runner.mjs +68 -158
  76. package/cli/risk-calibration.mjs +8 -8
  77. package/cli/runner.mjs +20 -32
  78. package/cli/saved-lookup.mjs +2 -2
  79. package/cli/selected-input-worker.mjs +63 -0
  80. package/cli/sharing-review.mjs +5 -64
  81. package/cli/source-reliability.mjs +2 -2
  82. package/cli/support-arguments.mjs +49 -0
  83. package/cli/support-command-definitions.mjs +209 -0
  84. package/cli/support-command-runner.mjs +2 -2
  85. package/cli/workflow-arguments.mjs +86 -0
  86. package/cli/workflow-command-definitions.mjs +78 -0
  87. package/cli/workflow-command-runner.mjs +8 -1
  88. package/docs/cli-reference.md +27 -0
  89. package/docs/cli.md +223 -2
  90. package/docs/contextual-reviews.md +155 -0
  91. package/lib/analyst-taxonomy.mjs +2 -40
  92. package/lib/application-version.mjs +1 -1
  93. package/lib/availability.mjs +13 -789
  94. package/lib/bounded-contract-normalizers.mjs +4 -0
  95. package/lib/bounded-json.mjs +2 -330
  96. package/lib/browser-library-profile.mjs +62 -84
  97. package/lib/candidate-provenance-bounds.mjs +2 -0
  98. package/lib/classify.mjs +1 -1
  99. package/lib/ct-query.mjs +2 -27
  100. package/lib/ct-response-bounds.mjs +2 -9
  101. package/lib/ct-search.mjs +83 -47
  102. package/lib/dmarc-discovery.mjs +62 -0
  103. package/lib/dns-convergence-review.mjs +21 -8
  104. package/lib/dns-delegation-health.mjs +12 -8
  105. package/lib/dns-inheritance-review.mjs +53 -63
  106. package/lib/dns-intelligence.mjs +9 -7
  107. package/lib/dns-query-coverage.mjs +40 -0
  108. package/lib/domain-assurance.mjs +1 -1
  109. package/lib/domain-change-packet.mjs +12 -13
  110. package/lib/domain-change-review.mjs +32 -14
  111. package/lib/domain-control-flight-recorder.mjs +103 -27
  112. package/lib/domain-control-manifest.mjs +25 -17
  113. package/lib/domain-posture-analysis.mjs +22 -5
  114. package/lib/domain-posture.mjs +16 -15
  115. package/lib/domain-registration.mjs +392 -0
  116. package/lib/evidence-export-privacy.mjs +22 -15
  117. package/lib/evidence-export.mjs +3 -3
  118. package/lib/evidence-report.mjs +4 -1
  119. package/lib/external-intelligence-risk.mjs +2 -124
  120. package/lib/generated/cisa-kev-catalog.mjs +41 -3
  121. package/lib/generated/registrar-standing-catalogue.mjs +18 -10
  122. package/lib/generated/retire-browser-catalog.mjs +332 -19
  123. package/lib/generated/{unicode-confusables-17.mjs → unicode-confusables.mjs} +33 -33
  124. package/lib/html-signals.mjs +7 -2
  125. package/lib/http-evidence-bounds.mjs +2 -8
  126. package/lib/http-intelligence.mjs +13 -1
  127. package/lib/idn-confusable-policy.mjs +6 -4
  128. package/lib/idn-confusables.mjs +1 -1
  129. package/lib/interchange-fidelity-registry.mjs +10 -0
  130. package/lib/lookup-contract-primitives.mjs +1 -1
  131. package/lib/lookup-diagnostics.mjs +1 -23
  132. package/lib/lookup-network-evidence-bounds.mjs +1 -10
  133. package/lib/lookup-progress.mjs +50 -1
  134. package/lib/lookup-response-contract.mjs +69 -40
  135. package/lib/lookup-response.mjs +5 -15
  136. package/lib/lookup-source-progress.mjs +8 -22
  137. package/lib/lookup.mjs +1 -1
  138. package/lib/opportunity-scoring.mjs +2 -107
  139. package/lib/outbound-identity.mjs +1 -1
  140. package/lib/page-fingerprints.mjs +18 -1
  141. package/lib/perceptual-hash-comparison.mjs +2 -34
  142. package/lib/perceptual-hash.mjs +36 -10
  143. package/lib/portable-generator.mjs +2 -29
  144. package/lib/project-metadata.mjs +2 -8
  145. package/lib/rdap-client.mjs +11 -7
  146. package/lib/registrable-domain.mjs +2 -56
  147. package/lib/registrar-standing-contract.mjs +1 -1
  148. package/lib/registrar-standing.mjs +9 -13
  149. package/lib/registry-capabilities.mjs +2 -2
  150. package/lib/registry-capability-catalogue.mjs +27 -19
  151. package/lib/risk-calibration-summary.mjs +2 -352
  152. package/lib/risk-scoring.mjs +2 -345
  153. package/lib/scoring-evidence-quality.mjs +2 -107
  154. package/lib/semantic-version.mjs +2 -54
  155. package/lib/service-binding-dns.mjs +27 -0
  156. package/lib/smtp-transport-review.mjs +48 -19
  157. package/lib/sslbl-certificates.generated.mjs +141 -139
  158. package/lib/static-html-analysis.mjs +1 -1
  159. package/lib/structured-data-identity.mjs +1 -1
  160. package/lib/threat-intelligence-manifests.mjs +1 -1
  161. package/lib/threat-intelligence-runtime.mjs +1 -1
  162. package/lib/threat-intelligence-targets.mjs +1 -1
  163. package/lib/threat-intelligence-types.mjs +2 -75
  164. package/lib/typosquat-generator.mjs +11 -2
  165. package/lib/website-enrichment.mjs +388 -0
  166. package/lib/whois-authority.mjs +43 -5
  167. package/lib/whois-normalization.mjs +3 -1
  168. package/lib/whois-parser.mjs +5 -3
  169. package/package.json +13 -14
  170. package/packages/analysis/analyst-taxonomy.mjs +40 -0
  171. package/packages/analysis/bounded-json.mjs +330 -0
  172. package/packages/analysis/candidate-provenance-bounds.mjs +4 -0
  173. package/packages/analysis/cli-argument-grammar.mjs +216 -0
  174. package/packages/analysis/ct-query.mjs +27 -0
  175. package/packages/analysis/ct-response-bounds.mjs +9 -0
  176. package/packages/analysis/external-intelligence-risk.mjs +124 -0
  177. package/packages/analysis/http-evidence-bounds.mjs +8 -0
  178. package/packages/analysis/lookup-network-evidence-bounds.mjs +10 -0
  179. package/packages/analysis/opportunity-scoring.mjs +107 -0
  180. package/packages/analysis/perceptual-hash-comparison.mjs +34 -0
  181. package/packages/analysis/portable-generator.mjs +29 -0
  182. package/packages/analysis/project-metadata.mjs +8 -0
  183. package/packages/analysis/registrable-domain.mjs +56 -0
  184. package/packages/analysis/risk-calibration-summary.mjs +352 -0
  185. package/packages/analysis/risk-scoring.mjs +345 -0
  186. package/packages/analysis/scoring-evidence-quality.mjs +107 -0
  187. package/packages/analysis/semantic-version.mjs +54 -0
  188. package/packages/analysis/threat-intelligence-types.mjs +75 -0
  189. package/packages/cases/case-evidence-links.mjs +135 -0
  190. package/packages/cases/case-evidence-model.mjs +78 -12
  191. package/packages/cases/case-evidence-quality.mjs +5 -0
  192. package/packages/cases/case-export-input.mjs +7 -5
  193. package/packages/cases/case-incident-context.mjs +52 -0
  194. package/packages/cases/case-markdown.mjs +11 -0
  195. package/packages/cases/case-migration-model.mjs +40 -24
  196. package/packages/cases/case-recheck-model.mjs +90 -32
  197. package/packages/cases/case-record-contracts.mjs +1 -1
  198. package/packages/cases/case-record-core.mjs +1 -1
  199. package/packages/cases/case-record-operations.mjs +40 -112
  200. package/packages/cases/case-record-projection.mjs +8 -1
  201. package/packages/cases/case-record-version-input.mjs +19 -0
  202. package/packages/cases/case-report.mjs +63 -27
  203. package/packages/cases/case-requested-evidence.mjs +146 -0
  204. package/packages/cases/case-response-actions.mjs +103 -1
  205. package/packages/cases/case-response-model.mjs +9 -1
  206. package/packages/cases/case-response-outcomes.mjs +29 -8
  207. package/packages/cases/case-response-packet-render.mjs +13 -9
  208. package/packages/cases/case-response-packet.mjs +25 -11
  209. package/packages/cases/case-response-records.mjs +0 -1
  210. package/packages/cases/case-response-review-inputs.mjs +14 -3
  211. package/packages/cases/case-storage-model.mjs +1 -1
  212. package/packages/cases/case-workflow-metadata.mjs +225 -0
  213. package/packages/cases/case-workflow-migration.mjs +43 -0
  214. package/packages/cases/http-summary.mjs +6 -1
  215. package/packages/comparison/favicon-similarity.mjs +1 -1
  216. package/packages/comparison/page-similarity.mjs +1 -1
  217. package/packages/comparison/relationship-evidence.mjs +21 -9
  218. package/packages/comparison/relationship-provenance.mjs +7 -1
  219. package/packages/contracts/analyst-interchange.mjs +28 -10
  220. package/packages/contracts/case-portability.mjs +118 -25
  221. package/packages/contracts/cli-command-semantics.mjs +2 -0
  222. package/packages/contracts/cli-grammar.mjs +6 -0
  223. package/packages/contracts/cli-lookup-brief.mjs +2 -0
  224. package/packages/contracts/context-review.mjs +24 -0
  225. package/packages/contracts/document-intake.mjs +11 -0
  226. package/packages/contracts/domain-control-flight-recorder.mjs +30 -14
  227. package/packages/contracts/domain-control-manifest.mjs +1 -1
  228. package/packages/contracts/domain-control-monitor.mjs +13 -2
  229. package/packages/contracts/domain-control-review.mjs +36 -15
  230. package/packages/contracts/har-review.mjs +2 -0
  231. package/packages/contracts/identity-events.mjs +4 -0
  232. package/packages/contracts/investigation-run.mjs +2 -0
  233. package/packages/contracts/ip-address.mjs +10 -0
  234. package/packages/contracts/mail-authentication.mjs +5 -0
  235. package/packages/contracts/message-intake.mjs +31 -0
  236. package/packages/contracts/offline-comparison.mjs +4 -1
  237. package/packages/contracts/passive-mail.mjs +10 -0
  238. package/packages/contracts/web-capture.mjs +2 -2
  239. package/packages/contracts/workspace-portability.mjs +63 -17
  240. package/packages/evidence/collection-quality.mjs +67 -0
  241. package/packages/evidence/dns-query-outcome.mjs +72 -0
  242. package/packages/evidence/email-recipient.mjs +18 -0
  243. package/packages/evidence/lookup-target.mjs +5 -1
  244. package/packages/evidence/observation.mjs +2 -0
  245. package/packages/evidence/png-pixels.mjs +117 -0
  246. package/packages/evidence/public-address-policy.mjs +83 -0
  247. package/packages/interchange/defensive-indicator-export.mjs +264 -0
  248. package/packages/interchange/external-findings-import.mjs +4 -12
  249. package/packages/interchange/managed-indicator-export.mjs +35 -0
  250. package/packages/interchange/managed-indicator-set.mjs +210 -0
  251. package/packages/interchange/misp-indicator-export.mjs +93 -0
  252. package/packages/interchange/sharing-policy.mjs +56 -0
  253. package/packages/interchange/stix-indicator-export.mjs +129 -0
  254. package/packages/interchange/web-capture-import.mjs +22 -15
  255. package/packages/investigation/acquisition-decision-packet.mjs +1 -1
  256. package/packages/investigation/bulk-mail-exposure.mjs +7 -13
  257. package/packages/investigation/capture-coverage.mjs +57 -0
  258. package/packages/investigation/connector-provenance-review.mjs +100 -0
  259. package/packages/investigation/context-review.mjs +35 -0
  260. package/packages/investigation/document-intake.mjs +81 -0
  261. package/packages/investigation/docx-intake.mjs +133 -0
  262. package/packages/investigation/domain-history-review.mjs +100 -0
  263. package/packages/investigation/domain-transition-review.mjs +47 -0
  264. package/packages/investigation/evidence-coverage-ledger.mjs +9 -1
  265. package/packages/investigation/har-intake.mjs +68 -0
  266. package/packages/investigation/identity-event-intake.mjs +131 -0
  267. package/packages/investigation/identity-incident-review.mjs +39 -0
  268. package/packages/investigation/incident-sequence-review.mjs +58 -0
  269. package/packages/investigation/intake-report.mjs +19 -0
  270. package/packages/investigation/investigation-manifest.mjs +2 -2
  271. package/packages/investigation/link-intake.mjs +179 -0
  272. package/packages/investigation/lookup-asset-graph.mjs +1 -1
  273. package/packages/investigation/mail-authentication-review.mjs +222 -0
  274. package/packages/investigation/mail-header-identity.mjs +209 -0
  275. package/packages/investigation/message-intake.mjs +195 -0
  276. package/packages/investigation/page-behaviour.mjs +113 -0
  277. package/packages/investigation/pdf-intake.mjs +155 -0
  278. package/packages/investigation/platform-continuity-review.mjs +60 -0
  279. package/packages/investigation/qr-intake.mjs +56 -0
  280. package/packages/investigation/requested-action-hints.mjs +11 -0
  281. package/packages/investigation/selected-input-review.mjs +17 -0
  282. package/packages/investigation/storefront-review.mjs +70 -0
  283. package/packages/workspace/brand-profile-model.mjs +2 -2
  284. package/packages/workspace/bulk-review-model.mjs +28 -26
  285. package/packages/workspace/bulk-session-model.mjs +23 -13
  286. package/packages/workspace/campaign-model.mjs +1 -1
  287. package/packages/workspace/detection-rule-model.mjs +1 -1
  288. package/packages/workspace/hostile-input.mjs +1 -1
  289. package/packages/workspace/investigation-guide.mjs +2 -2
  290. package/packages/workspace/investigation-template-model.mjs +26 -2
  291. package/packages/workspace/page-baseline.mjs +2 -2
  292. package/packages/workspace/relationship-observation-model.mjs +17 -13
  293. package/packages/workspace/shortlist-model.mjs +14 -5
  294. package/packages/workspace/watchlist-history.mjs +28 -12
  295. package/packages/workspace/watchlist-store.mjs +17 -5
  296. package/packages/workspace/website-snapshot-model.mjs +28 -3
  297. package/packages/workspace/workspace-archive-crypto.mjs +1 -1
  298. package/packages/workspace/workspace-archive.mjs +5 -4
  299. package/third-party-notices.txt +549 -16
  300. package/lib/hostname.mjs +0 -2
@@ -1,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,
@@ -381,7 +384,7 @@ export function normalizeBulkSession(value, sourceStoreVersion) {
381
384
  // Only an explicitly versioned current document may omit identical row
382
385
  // context. Bare runtime rows and public schema 4 must carry their own context;
383
386
  // an explicit invalid/null row value never falls back to the session.
384
- const inheritedProfileContext = sourceStoreVersion === 5
387
+ const inheritedProfileContext = sourceStoreVersion !== undefined && sourceStoreVersion >= 5
385
388
  && declaredProfileContext.sourceState !== 'mixed'
386
389
  && rawProfileContext.sourceState === declaredProfileContext.sourceState
387
390
  ? declaredProfileContext
@@ -390,8 +393,11 @@ export function normalizeBulkSession(value, sourceStoreVersion) {
390
393
  const results = [];
391
394
  const seen = new Set();
392
395
  for (const candidate of Array.isArray(item.results) ? item.results.slice(0, MAX_BULK_SESSION_ROWS * 2) : []) {
396
+ if (sourceStoreVersion !== undefined && sourceStoreVersion < 6 && record(candidate)?.webCollectionQuality !== undefined) {
397
+ throw new TypeError('Web collection quality requires Bulk schema 6 or later; historical evidence was not reinterpreted.');
398
+ }
393
399
  const publicLegacyRow = sourceStoreVersion === 4;
394
- if (publicLegacyRow && record(record(candidate)?.relationship)?.version === RELATIONSHIP_EVIDENCE_VERSION) {
400
+ if (publicLegacyRow && Number(record(record(candidate)?.relationship)?.version) >= RELATIONSHIP_SOURCE_PROVENANCE_VERSION) {
395
401
  throw new TypeError('Bulk schema 4 cannot contain newer relationship source evidence; no evidence was interpreted.');
396
402
  }
397
403
  const result = normalizeBulkSessionResult(candidate, inheritedProfileContext);
@@ -411,7 +417,7 @@ export function normalizeBulkSession(value, sourceStoreVersion) {
411
417
  if ((state === 'complete') !== (results.length === domains.length))
412
418
  return null;
413
419
  const profileContext = summarizeBulkProfileContexts(results);
414
- if (!sameProfileContext(declaredProfileContext, profileContext))
420
+ if (!bulkProfileContextsMatch(declaredProfileContext, profileContext))
415
421
  return null;
416
422
  return {
417
423
  id,
@@ -471,7 +477,7 @@ export function normalizeBulkSessionStore(raw) {
471
477
  export function bulkSessionStorageValue(session) {
472
478
  return {
473
479
  ...session,
474
- results: session.results.map(({ profileContext, ...row }) => sameProfileContext(profileContext, session.profileContext)
480
+ results: session.results.map(({ profileContext, ...row }) => bulkProfileContextsMatch(profileContext, session.profileContext)
475
481
  ? row
476
482
  : { ...row, profileContext }),
477
483
  };
@@ -558,8 +564,10 @@ function sourceStateMap(value) {
558
564
  export function areBulkRiskScoresComparable(previous, current) {
559
565
  return previous.profileContext.sourceState === 'ready'
560
566
  && current.profileContext.sourceState === 'ready'
561
- && sameProfileContext(previous.profileContext, current.profileContext)
567
+ && bulkProfileContextsMatch(previous.profileContext, current.profileContext)
562
568
  && previous.scanDepth === current.scanDepth
569
+ && webCollectionAllowsComparison('riskScore', previous.webCollectionQuality, previous.scanDepth)
570
+ && webCollectionAllowsComparison('riskScore', current.webCollectionQuality, current.scanDepth)
563
571
  && previous.risk !== null
564
572
  && current.risk !== null
565
573
  && previous.riskModelVersion !== null
@@ -574,7 +582,9 @@ function resultChanges(previous, current) {
574
582
  }
575
583
  if (previous.registrar !== current.registrar)
576
584
  changes.push(`Registrar: ${previous.registrar} → ${current.registrar}`);
577
- if (previous.activity !== current.activity)
585
+ if (previous.activity !== current.activity
586
+ && webCollectionAllowsComparison('activityStatus', previous.webCollectionQuality, previous.scanDepth)
587
+ && webCollectionAllowsComparison('activityStatus', current.webCollectionQuality, current.scanDepth))
578
588
  changes.push(`Website: ${previous.activity} → ${current.activity}`);
579
589
  const previousComparison = previous.comparisonEvidence;
580
590
  const currentComparison = current.comparisonEvidence;
@@ -644,7 +654,7 @@ export function compareBulkSessions(baselineRaw, currentRaw) {
644
654
  'A source-state change may reflect collection availability rather than a change to the domain.',
645
655
  'A missing row means it was not completed in that saved session; it does not establish domain removal.',
646
656
  ...(riskIncomparable
647
- ? ['Risk deltas are omitted unless both rows retain the same ready Brand Profile provenance, scan depth, and versioned Risk model.']
657
+ ? ['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
658
  : []),
649
659
  ],
650
660
  };
@@ -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";
@@ -1,7 +1,7 @@
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
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";
@@ -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) {
@@ -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";
@@ -188,14 +188,23 @@ export function mergeShortlistStores(localRaw, importedRaw) {
188
188
  skipped++;
189
189
  continue;
190
190
  }
191
- if (imported.has(record.domain))
191
+ const prior = imported.get(record.domain);
192
+ if (prior)
192
193
  skipped++;
193
- imported.set(record.domain, record);
194
+ if (!prior || Date.parse(record.savedAt) > Date.parse(prior.savedAt))
195
+ imported.set(record.domain, record);
194
196
  }
195
197
  let added = 0;
196
198
  let updated = 0;
197
199
  for (const record of imported.values()) {
198
- if (byDomain.has(record.domain))
200
+ const existing = byDomain.get(record.domain);
201
+ // A backup must not roll back an observation. Equal-time conflicts retain
202
+ // the local record rather than choosing whichever file was imported last.
203
+ if (existing && Date.parse(record.savedAt) <= Date.parse(existing.savedAt)) {
204
+ skipped++;
205
+ continue;
206
+ }
207
+ if (existing)
199
208
  updated++;
200
209
  else if (byDomain.size >= MAX_SHORTLIST_ENTRIES) {
201
210
  skipped++;
@@ -2,11 +2,12 @@
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 { mergeWebCollectionQuality, normalizeWebCollectionQuality, webCollectionAllowsComparison } from "../evidence/collection-quality.mjs";
10
11
  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";
11
12
  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
13
  const MAX_TEXT_LENGTH = 300;
@@ -145,9 +146,6 @@ function boundedText(value, maximum = MAX_TEXT_LENGTH, allowNull = true) {
145
146
  const normalized = value.slice(0, maximum * 4).replace(/\s+/g, ' ').trim().slice(0, maximum).trim();
146
147
  return normalized || (allowNull ? null : '');
147
148
  }
148
- function isoTimestamp(value, fallback = new Date(0).toISOString()) {
149
- return normalizeExplicitIsoTimestamp(value) ?? fallback;
150
- }
151
149
  function boundedInteger(value, maximum, fallback = 0) {
152
150
  return Number.isSafeInteger(value) && Number(value) >= 0 && Number(value) <= maximum
153
151
  ? Number(value)
@@ -205,11 +203,14 @@ function compactRecord(value) {
205
203
  if (!domain)
206
204
  return null;
207
205
  const scanDepth = inferredScanDepth(record);
206
+ const webCollectionQuality = normalizeWebCollectionQuality(record.webCollectionQuality, scanDepth);
208
207
  const httpSummary = normalizeHttpSummary(record) || {};
209
208
  const providedRiskScore = typeof record.riskScore === 'number' && Number.isFinite(record.riskScore)
210
209
  ? Math.max(0, Math.min(100, Math.round(record.riskScore)))
211
210
  : null;
212
- const computedRisk = scanDepth === 'deep' && providedRiskScore === null ? explainRiskScore(record) : null;
211
+ const computedRisk = scanDepth === 'deep' && providedRiskScore === null
212
+ && (!webCollectionQuality || (record.riskScore === undefined
213
+ && webCollectionAllowsComparison('riskScore', webCollectionQuality, scanDepth))) ? explainRiskScore(record) : null;
213
214
  const riskScore = scanDepth === 'deep' ? (providedRiskScore ?? computedRisk?.score ?? null) : null;
214
215
  const riskModelVersion = riskScore === null
215
216
  ? null
@@ -219,6 +220,7 @@ function compactRecord(value) {
219
220
  return {
220
221
  domain,
221
222
  scanDepth,
223
+ ...(webCollectionQuality ? { webCollectionQuality } : {}),
222
224
  availability: hasString(AVAILABILITY_VALUES, record.availability) ? record.availability : null,
223
225
  registrarName: boundedText(record.registrarName),
224
226
  nameservers: normalizeNameservers(record.nameservers),
@@ -260,6 +262,8 @@ export function compactWatchlistResults(results) {
260
262
  function isComparable(field, value, record) {
261
263
  if (DEEP_FIELDS.has(field) && record.scanDepth !== 'deep')
262
264
  return false;
265
+ if (!webCollectionAllowsComparison(field, record.webCollectionQuality, record.scanDepth))
266
+ return false;
263
267
  if (field === 'availability')
264
268
  return hasString(CONCLUSIVE_AVAILABILITY, value);
265
269
  if (field === 'nameservers')
@@ -358,7 +362,7 @@ export function mergeWatchlistBaseline(baseline, current) {
358
362
  return current.map((next) => {
359
363
  const previous = previousByDomain.get(next.domain);
360
364
  const updated = {
361
- ...(previous || {}),
365
+ ...(previous || next),
362
366
  domain: next.domain,
363
367
  scanDepth: previous?.scanDepth === 'deep' || next.scanDepth === 'deep' ? 'deep' : 'fast',
364
368
  };
@@ -370,9 +374,12 @@ export function mergeWatchlistBaseline(baseline, current) {
370
374
  // mean "unversioned" to the comparator, but retaining the key makes the
371
375
  // stored/exported contract unambiguous and prevents consumers from having
372
376
  // to infer why a score was deliberately excluded from comparison.
373
- if (next.scanDepth === 'deep' && typeof next.riskScore === 'number') {
377
+ if (next.scanDepth === 'deep' && typeof next.riskScore === 'number' && isComparable('riskScore', next.riskScore, next)) {
374
378
  updated.riskModelVersion = normalizeRiskModelVersion(next.riskModelVersion);
375
379
  }
380
+ const quality = mergeWebCollectionQuality(previous?.webCollectionQuality, next.webCollectionQuality);
381
+ if (quality)
382
+ updated.webCollectionQuality = quality;
376
383
  return updated;
377
384
  });
378
385
  }
@@ -452,12 +459,13 @@ function normalizeStoredFieldValue(field, value) {
452
459
  }
453
460
  function initialHistoryEvent(entry, baseline) {
454
461
  return {
455
- checkedAt: entry.updatedAt || new Date(0).toISOString(),
462
+ checkedAt: entry.updatedAt,
456
463
  mode: 'saved',
457
464
  resultCount: Array.isArray(entry.results) ? entry.results.length : baseline.length,
458
465
  conclusiveCount: baseline.filter((record) => hasString(CONCLUSIVE_AVAILABILITY, record.availability)).length,
459
466
  changeCount: 0,
460
467
  omittedChanges: 0,
468
+ webComparisonLimitedCount: limitedWebComparisonCount(baseline),
461
469
  changes: [],
462
470
  };
463
471
  }
@@ -480,7 +488,7 @@ export function normalizeWatchlistEntry(entry) {
480
488
  : [];
481
489
  const rawMode = event.mode;
482
490
  return {
483
- checkedAt: isoTimestamp(event.checkedAt, new Date(0).toISOString()),
491
+ checkedAt: normalizeExplicitIsoTimestamp(event.checkedAt),
484
492
  mode: typeof rawMode === 'string' && ['fast', 'deep', 'saved'].includes(rawMode)
485
493
  ? rawMode
486
494
  : 'saved',
@@ -488,12 +496,14 @@ export function normalizeWatchlistEntry(entry) {
488
496
  conclusiveCount: boundedInteger(event.conclusiveCount, MAX_WATCHLIST_DOMAINS),
489
497
  changeCount: boundedInteger(event.changeCount, MAX_CHANGE_COUNT, changes.length),
490
498
  omittedChanges: boundedInteger(event.omittedChanges, MAX_CHANGE_COUNT),
499
+ ...(event.webComparisonLimitedCount !== undefined
500
+ ? { webComparisonLimitedCount: boundedInteger(event.webComparisonLimitedCount, MAX_WATCHLIST_DOMAINS) } : {}),
491
501
  changes,
492
502
  };
493
503
  }).slice(-MAX_WATCHLIST_HISTORY_EVENTS)
494
504
  : [];
495
505
  const normalized = {
496
- updatedAt: isoTimestamp(input.updatedAt, new Date(0).toISOString()),
506
+ updatedAt: normalizeExplicitIsoTimestamp(input.updatedAt),
497
507
  results,
498
508
  baseline,
499
509
  history,
@@ -502,13 +512,18 @@ export function normalizeWatchlistEntry(entry) {
502
512
  normalized.history.push(initialHistoryEvent(normalized, baseline));
503
513
  return normalized;
504
514
  }
515
+ function limitedWebComparisonCount(current, baseline = []) {
516
+ const previous = new Map(baseline.map(record => [record.domain, record]));
517
+ return current.filter(record => record.scanDepth === 'deep' && (!webCollectionAllowsComparison('riskScore', record.webCollectionQuality, record.scanDepth)
518
+ || (previous.has(record.domain) && !webCollectionAllowsComparison('riskScore', previous.get(record.domain).webCollectionQuality, previous.get(record.domain).scanDepth)))).length;
519
+ }
505
520
  /**
506
521
  * @param {object | null} existingEntry
507
522
  * @param {object[]} results
508
523
  * @param {{ checkedAt?: string, mode?: string, ignoredDomains?: Set<string> }} [options]
509
524
  */
510
525
  export function appendWatchlistScan(existingEntry, results, options = {}) {
511
- const checkedAt = isoTimestamp(options.checkedAt, new Date().toISOString());
526
+ const checkedAt = options.checkedAt === undefined ? new Date().toISOString() : normalizeExplicitIsoTimestamp(options.checkedAt);
512
527
  const mode = typeof options.mode === 'string' && ['fast', 'deep', 'saved'].includes(options.mode)
513
528
  ? options.mode
514
529
  : 'saved';
@@ -525,6 +540,7 @@ export function appendWatchlistScan(existingEntry, results, options = {}) {
525
540
  conclusiveCount: current.filter((record) => hasString(CONCLUSIVE_AVAILABILITY, record.availability)).length,
526
541
  changeCount: changes.length,
527
542
  omittedChanges: Math.max(0, changes.length - storedChanges.length),
543
+ webComparisonLimitedCount: limitedWebComparisonCount(current, previous?.baseline),
528
544
  changes: storedChanges,
529
545
  };
530
546
  const history = [...(previous?.history || []), event].slice(-MAX_WATCHLIST_HISTORY_EVENTS);
@@ -4,7 +4,7 @@
4
4
  import { MAX_WATCHLIST_DOMAINS, normalizeWatchlistEntry } from "./watchlist-history.mjs";
5
5
  import { normalizeExplicitIsoTimestamp } from "../evidence/observation.mjs";
6
6
  import { assertWorkspaceDeclaredVersion, assertWorkspaceInputGraph, assertWorkspacePortableVersion, ordinaryWorkspaceRecord } from "./hostile-input.mjs";
7
- import { MAX_WATCHLIST_INPUTS, MAX_WATCHLIST_NAME_LENGTH, MAX_WATCHLISTS, MAX_WATCHLIST_STORE_BYTES, WATCHLIST_SCHEMA, WATCHLIST_SCHEMA_VERSION, } from "../contracts/workspace-portability.mjs";
7
+ import { MAX_WATCHLIST_INPUTS, MAX_WATCHLIST_NAME_LENGTH, MAX_WATCHLISTS, MAX_WATCHLIST_STORE_BYTES, WATCHLIST_SCHEMA, WATCHLIST_SCHEMA_VERSION, WATCHLIST_BROWSER_SUPPORTED_VERSIONS, WATCHLIST_EXPORT_SUPPORTED_VERSIONS, } from "../contracts/workspace-portability.mjs";
8
8
  export { MAX_WATCHLIST_INPUTS, MAX_WATCHLIST_NAME_LENGTH, MAX_WATCHLISTS, MAX_WATCHLIST_STORE_BYTES, WATCHLIST_SCHEMA, WATCHLIST_SCHEMA_VERSION, } from "../contracts/workspace-portability.mjs";
9
9
  const BLOCKED_NAMES = new Set(['__proto__', 'prototype', 'constructor']);
10
10
  const CONTROL_RE = /[\x00-\x1f\x7f]/;
@@ -39,12 +39,21 @@ export function watchlistStoreVersion(raw) {
39
39
  function defineEntry(target, name, entry) {
40
40
  Object.defineProperty(target, name, { value: entry, writable: true, enumerable: true, configurable: true });
41
41
  }
42
+ function assertHistoricalQuality(entry, version) {
43
+ if (version !== 2)
44
+ return;
45
+ for (const values of [entry.results, entry.baseline]) {
46
+ if (Array.isArray(values) && values.some(value => plainRecord(value)?.webCollectionQuality !== undefined)) {
47
+ throw new TypeError('Web collection quality requires Watchlist schema 3 or later; historical evidence was not reinterpreted.');
48
+ }
49
+ }
50
+ }
42
51
  export function normalizeWatchlistStore(raw) {
43
52
  assertWorkspaceInputGraph(raw, 'Watchlist store');
44
53
  const root = plainRecord(raw);
45
54
  if (root?.schema === WATCHLIST_SCHEMA)
46
55
  assertWorkspaceDeclaredVersion(raw, 'Watchlist store');
47
- if (root?.schema === WATCHLIST_SCHEMA && watchlistStoreVersion(root) !== WATCHLIST_SCHEMA_VERSION) {
56
+ if (root?.schema === WATCHLIST_SCHEMA && !WATCHLIST_BROWSER_SUPPORTED_VERSIONS.includes(Number(watchlistStoreVersion(root)))) {
48
57
  throw new Error(`Watchlist schema ${String(root.version)} is unsupported; no data was changed.`);
49
58
  }
50
59
  const source = watchlistMap(raw);
@@ -56,6 +65,7 @@ export function normalizeWatchlistStore(raw) {
56
65
  const entry = plainRecord(rawEntry);
57
66
  if (!name || !entry || !Array.isArray(entry.results) || entry.results.length > MAX_WATCHLIST_DOMAINS)
58
67
  continue;
68
+ assertHistoricalQuality(entry, root?.schema === WATCHLIST_SCHEMA ? watchlistStoreVersion(root) : null);
59
69
  defineEntry(watchlists, name, normalizeWatchlistEntry(entry));
60
70
  if (Object.keys(watchlists).length >= MAX_WATCHLISTS)
61
71
  break;
@@ -93,8 +103,8 @@ export function mergeWatchlistStores(localRaw, importedRaw) {
93
103
  if (importedVersion !== null && importedVersion > WATCHLIST_SCHEMA_VERSION) {
94
104
  throw new Error(`This watchlist file uses newer schema ${importedVersion}. Update the app before importing it.`);
95
105
  }
96
- if (importedVersion !== WATCHLIST_SCHEMA_VERSION) {
97
- throw new Error(`Expected a WHOISleuth watchlist export using schema ${WATCHLIST_SCHEMA_VERSION}.`);
106
+ if (!WATCHLIST_EXPORT_SUPPORTED_VERSIONS.includes(Number(importedVersion))) {
107
+ throw new Error('Expected a supported WHOISleuth watchlist export.');
98
108
  }
99
109
  const local = normalizeWatchlistStore(localRaw).watchlists;
100
110
  const source = watchlistMap(importedRaw) || {};
@@ -109,9 +119,11 @@ export function mergeWatchlistStores(localRaw, importedRaw) {
109
119
  skipped++;
110
120
  continue;
111
121
  }
122
+ assertHistoricalQuality(entry, importedVersion);
112
123
  const normalized = normalizeWatchlistEntry(entry);
113
124
  if (Object.prototype.hasOwnProperty.call(local, name)) {
114
- if (normalized.updatedAt <= local[name].updatedAt) {
125
+ const localTime = local[name].updatedAt;
126
+ if (!normalized.updatedAt || !localTime || normalized.updatedAt <= localTime) {
115
127
  skipped++;
116
128
  continue;
117
129
  }