@slicedearth/whoisleuth-cli 2.2.0 → 2.4.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 (282) hide show
  1. package/NOTICE +6 -0
  2. package/README.md +20 -15
  3. package/bin/whoisleuth.mjs +8 -1
  4. package/cli/archive-content-digest.mjs +14 -0
  5. package/cli/archive-inspect.mjs +20 -53
  6. package/cli/arguments.mjs +454 -1748
  7. package/cli/artifact-validation/case-response.mjs +66 -35
  8. package/cli/artifact-validation/investigation-capsule.mjs +52 -31
  9. package/cli/artifact-validation/lookup-evidence.mjs +12 -6
  10. package/cli/artifact-validation/signed-review.mjs +112 -72
  11. package/cli/artifact-validation/structure-primitives.mjs +1 -105
  12. package/cli/artifact-verify.mjs +90 -24
  13. package/cli/assurance-command-runner.mjs +39 -4
  14. package/cli/bagit-folder.mjs +57 -0
  15. package/cli/bagit-review.mjs +23 -0
  16. package/cli/bounded-json.mjs +1 -1
  17. package/cli/bulk-checkpoint.mjs +34 -3
  18. package/cli/bulk-command-runner.mjs +2 -0
  19. package/cli/bulk-output.mjs +29 -3
  20. package/cli/case-command.mjs +195 -0
  21. package/cli/case-pack.mjs +66 -111
  22. package/cli/command-argument-grammar.mjs +215 -0
  23. package/cli/command-reference.mjs +342 -285
  24. package/cli/completion.mjs +303 -80
  25. package/cli/config-profile.mjs +7 -5
  26. package/cli/ct-event-intake.mjs +12 -12
  27. package/cli/discover.mjs +4 -3
  28. package/cli/discovery-observation-snapshot.mjs +59 -18
  29. package/cli/discovery-scan-command-runner.mjs +21 -0
  30. package/cli/discovery-scan.mjs +13 -4
  31. package/cli/discovery-workflow.mjs +1 -1
  32. package/cli/domain-control-monitor.mjs +50 -20
  33. package/cli/domain-control-observations.mjs +149 -102
  34. package/cli/errors.mjs +33 -1
  35. package/cli/evidence-command-runner.mjs +16 -5
  36. package/cli/evidence-signing.mjs +2 -2
  37. package/cli/external-observation-mapping.mjs +7 -8
  38. package/cli/formatters/terminal-lookup.mjs +18 -46
  39. package/cli/formatters/terminal-shared.mjs +5 -3
  40. package/cli/formatters/terminal-technology.mjs +114 -0
  41. package/cli/inline-command-families.mjs +2 -0
  42. package/cli/input.mjs +116 -0
  43. package/cli/interactive-launcher.mjs +2 -160
  44. package/cli/interchange-report.mjs +18 -8
  45. package/cli/investigation-artifacts.mjs +176 -0
  46. package/cli/investigation-checkpoint.mjs +10 -0
  47. package/cli/investigation-folder.mjs +114 -0
  48. package/cli/investigation-manifest.mjs +1 -112
  49. package/cli/investigation-package-review.mjs +103 -0
  50. package/cli/investigation-plan.mjs +32 -19
  51. package/cli/investigation-run.mjs +188 -33
  52. package/cli/local-document-checkpoint.mjs +170 -0
  53. package/cli/lookalike-calibration.mjs +7 -2
  54. package/cli/lookup-brief.mjs +4 -2
  55. package/cli/lookup-browser-view.mjs +1 -1
  56. package/cli/lookup-browser.mjs +5 -12
  57. package/cli/lookup-command-runner.mjs +17 -6
  58. package/cli/lookup-diff.mjs +37 -3
  59. package/cli/lookup-plan.mjs +11 -5
  60. package/cli/lookup-reconcile.mjs +10 -8
  61. package/cli/mail-header-review.mjs +467 -0
  62. package/cli/mail-review.mjs +66 -14
  63. package/cli/manual.mjs +1 -0
  64. package/cli/network-command-runner.mjs +2 -0
  65. package/cli/output-file.mjs +31 -5
  66. package/cli/registry-doctor.mjs +3 -1
  67. package/cli/registry-support.mjs +1 -1
  68. package/cli/retained-artifact-diff.mjs +2 -2
  69. package/cli/review-command-runner.mjs +70 -0
  70. package/cli/risk-calibration.mjs +19 -17
  71. package/cli/runner.mjs +62 -7
  72. package/cli/sharing-review.mjs +21 -34
  73. package/cli/signer-trust.mjs +104 -0
  74. package/cli/source-reliability.mjs +74 -83
  75. package/cli/terminal-input.mjs +168 -0
  76. package/cli/workflow-command-runner.mjs +68 -13
  77. package/docs/cli-reference.md +77 -24
  78. package/docs/cli.md +300 -25
  79. package/lib/abort.mjs +4 -1
  80. package/lib/availability.mjs +161 -112
  81. package/lib/bounded-file.mjs +3 -1
  82. package/lib/bounded-html-document.mjs +125 -0
  83. package/lib/bounded-json.mjs +51 -8
  84. package/lib/browser-library-profile.mjs +42 -51
  85. package/lib/browser-library-worker.mjs +112 -0
  86. package/lib/client-behavior-profile.mjs +3 -3
  87. package/lib/ct-search.mjs +11 -4
  88. package/lib/dns-convergence-review.mjs +1 -0
  89. package/lib/dns-delegation-health.mjs +24 -14
  90. package/lib/dns-inheritance-review.mjs +217 -0
  91. package/lib/dns-intelligence.mjs +73 -26
  92. package/lib/dns-mx.mjs +1 -1
  93. package/lib/dnssec-chain-validation.mjs +6 -0
  94. package/lib/dnssec-evidence-validation.mjs +11 -3
  95. package/lib/domain-change-packet.mjs +49 -16
  96. package/lib/domain-change-review.mjs +14 -8
  97. package/lib/domain-control-flight-recorder.mjs +221 -117
  98. package/lib/domain-control-manifest.mjs +129 -69
  99. package/lib/domain-posture-analysis.mjs +20 -7
  100. package/lib/domain-posture-parsers.mjs +201 -8
  101. package/lib/domain-posture.mjs +147 -29
  102. package/lib/evidence-export-privacy.mjs +561 -0
  103. package/lib/evidence-export.mjs +11 -543
  104. package/lib/evidence-report.mjs +5 -3
  105. package/lib/favicon.mjs +117 -41
  106. package/lib/generated/cisa-kev-catalog.mjs +12 -4
  107. package/lib/generated/registrar-standing-catalogue.mjs +7 -7
  108. package/lib/generated/retire-browser-catalog.mjs +2335 -940
  109. package/lib/hostname.mjs +2 -38
  110. package/lib/html-signals.mjs +111 -108
  111. package/lib/http-evidence-bounds.mjs +2 -1
  112. package/lib/http-intelligence.mjs +39 -26
  113. package/lib/local-geoip-evidence.mjs +19 -3
  114. package/lib/lookup-cache.mjs +34 -11
  115. package/lib/lookup-child-profile-contract.mjs +24 -15
  116. package/lib/lookup-diagnostics.mjs +24 -1
  117. package/lib/lookup-progress.mjs +30 -13
  118. package/lib/lookup-response-contract.mjs +1048 -0
  119. package/lib/lookup-response.mjs +8 -10
  120. package/lib/lookup-source-progress.mjs +40 -14
  121. package/lib/lookup.mjs +48 -27
  122. package/lib/nameserver-preflight-review.mjs +1 -0
  123. package/lib/observed-network-context.mjs +43 -27
  124. package/lib/outbound-request-bounds.mjs +4 -1
  125. package/lib/page-fingerprints.mjs +69 -133
  126. package/lib/page-role-profile.mjs +12 -8
  127. package/lib/perceptual-hash.mjs +43 -14
  128. package/lib/rdap-bootstrap.mjs +72 -22
  129. package/lib/rdap-capabilities.mjs +1 -1
  130. package/lib/rdap-client.mjs +15 -7
  131. package/lib/rdap-normalization.mjs +33 -18
  132. package/lib/rdap-search-workbench.mjs +1 -1
  133. package/lib/rdap-transport.mjs +47 -20
  134. package/lib/rdap-types.mjs +2 -1
  135. package/lib/rdap.mjs +18 -3
  136. package/lib/registrable-domain.mjs +21 -1
  137. package/lib/registrar-standing-contract.mjs +1 -0
  138. package/lib/registry-capabilities.mjs +5 -1
  139. package/lib/registry-capability-catalogue.mjs +2 -1
  140. package/lib/registry-dates.mjs +2 -190
  141. package/lib/registry-insights.mjs +85 -34
  142. package/lib/response-policy.mjs +86 -48
  143. package/lib/risk-calibration-summary.mjs +18 -6
  144. package/lib/rpki-evidence.mjs +8 -5
  145. package/lib/safe-fetch.mjs +54 -47
  146. package/lib/security-txt.mjs +42 -7
  147. package/lib/service-binding-dns.mjs +8 -0
  148. package/lib/smtp-transport-review.mjs +27 -15
  149. package/lib/sslbl-certificates.generated.mjs +136 -135
  150. package/lib/static-html-analysis.mjs +178 -151
  151. package/lib/structured-data-identity.mjs +1 -0
  152. package/lib/technology-markup.mjs +100 -0
  153. package/lib/threat-intelligence-manifests.mjs +11 -139
  154. package/lib/threat-intelligence-runtime.mjs +24 -52
  155. package/lib/threat-intelligence-targets.mjs +6 -34
  156. package/lib/threat-intelligence-types.mjs +39 -0
  157. package/lib/typosquat-generator.mjs +19 -15
  158. package/lib/urlhaus-intelligence.mjs +8 -3
  159. package/lib/urlscan-intelligence.mjs +1 -1
  160. package/lib/website-security-posture.mjs +30 -6
  161. package/lib/website-technology.mjs +153 -45
  162. package/lib/whois-authority.mjs +14 -2
  163. package/lib/whois-chain.mjs +9 -2
  164. package/lib/whois-common-formats.mjs +5 -5
  165. package/lib/whois-contracts.mjs +2 -1
  166. package/lib/whois-dialects.mjs +2 -2
  167. package/lib/whois-parser-support.mjs +8 -1
  168. package/lib/whois-transport.mjs +23 -7
  169. package/lib/zone-intent-review.mjs +16 -6
  170. package/package.json +2 -1
  171. package/packages/cases/case-attachment-model.mjs +83 -0
  172. package/packages/cases/case-evidence-model.mjs +156 -62
  173. package/packages/cases/case-export-input.mjs +53 -0
  174. package/packages/cases/case-migration-model.mjs +127 -44
  175. package/packages/cases/case-model.mjs +1 -0
  176. package/packages/cases/case-recheck-model.mjs +91 -0
  177. package/packages/cases/case-record-contracts.mjs +5 -20
  178. package/packages/cases/case-record-core.mjs +12 -22
  179. package/packages/cases/case-record-decisions.mjs +72 -0
  180. package/packages/cases/case-record-model.mjs +1 -0
  181. package/packages/cases/case-record-operations.mjs +95 -31
  182. package/packages/cases/case-record-projection.mjs +221 -0
  183. package/packages/cases/case-report.mjs +70 -54
  184. package/packages/cases/case-response-actions.mjs +654 -0
  185. package/packages/cases/case-response-model.mjs +64 -1115
  186. package/packages/cases/case-response-outcomes.mjs +317 -0
  187. package/packages/cases/case-response-packet-render.mjs +153 -0
  188. package/packages/cases/case-response-packet-types.mjs +1 -0
  189. package/packages/cases/case-response-packet-vocabulary.mjs +48 -0
  190. package/packages/cases/case-response-packet.mjs +74 -572
  191. package/packages/cases/case-response-records.mjs +94 -0
  192. package/packages/cases/case-response-review-inputs.mjs +371 -0
  193. package/packages/cases/case-response-values.mjs +85 -0
  194. package/packages/cases/case-review-package.mjs +45 -0
  195. package/packages/cases/case-selection.mjs +46 -0
  196. package/packages/cases/case-storage-model.mjs +31 -2
  197. package/packages/cases/response-route-freshness.mjs +16 -0
  198. package/packages/comparison/capture-context.mjs +61 -0
  199. package/packages/comparison/comparison-ledger-bulk.mjs +13 -11
  200. package/packages/comparison/page-similarity.mjs +5 -2
  201. package/packages/comparison/relationship-evidence.mjs +85 -8
  202. package/packages/comparison/relationship-provenance.mjs +93 -0
  203. package/packages/contracts/analyst-interchange.mjs +216 -0
  204. package/packages/contracts/analyst-review-state-contract.mjs +11 -0
  205. package/packages/contracts/analyst-review-state.mjs +5 -4
  206. package/packages/contracts/case-portability.mjs +366 -83
  207. package/packages/contracts/case-views-contract.mjs +12 -0
  208. package/packages/contracts/case-views.mjs +26 -0
  209. package/packages/contracts/cli-command-semantics.mjs +94 -0
  210. package/packages/contracts/cli-lookup.mjs +0 -1
  211. package/packages/contracts/domain-control-flight-recorder.mjs +80 -42
  212. package/packages/contracts/domain-control-manifest.mjs +127 -73
  213. package/packages/contracts/domain-control-monitor.mjs +48 -45
  214. package/packages/contracts/domain-control-review.mjs +179 -121
  215. package/packages/contracts/domain-name.mjs +21 -0
  216. package/packages/contracts/external-observation-interchange.mjs +2 -1
  217. package/packages/contracts/extracted-domain-lifecycle.mjs +11 -32
  218. package/packages/contracts/http-url.mjs +2 -0
  219. package/packages/contracts/investigation-package-limits.mjs +24 -0
  220. package/packages/contracts/investigation-portability.mjs +56 -27
  221. package/packages/contracts/investigation-run.mjs +16 -0
  222. package/packages/contracts/ip-address.mjs +34 -0
  223. package/packages/contracts/lookup-evidence.mjs +35 -0
  224. package/packages/contracts/offline-comparison.mjs +1 -2
  225. package/packages/contracts/page-fingerprints.mjs +15 -0
  226. package/packages/contracts/risk-calibration.mjs +15 -11
  227. package/packages/contracts/schema-compatibility.mjs +1 -1
  228. package/packages/contracts/schema-lifecycle.mjs +83 -153
  229. package/packages/contracts/selected-file-limits.mjs +6 -0
  230. package/packages/contracts/vulnerability-identifiers.mjs +5 -0
  231. package/packages/contracts/web-capture.mjs +10 -0
  232. package/packages/contracts/workspace-portability.mjs +183 -211
  233. package/packages/evidence/artifact-integrity.mjs +8 -2
  234. package/packages/evidence/artifact-structure.mjs +105 -0
  235. package/packages/evidence/decision-fact.mjs +4 -1
  236. package/packages/evidence/domain-control-runtime.mjs +201 -33
  237. package/packages/evidence/domain-posture-context.mjs +79 -0
  238. package/packages/evidence/image-regions.mjs +56 -0
  239. package/packages/evidence/latest-observations.mjs +28 -0
  240. package/packages/evidence/lookup-target.mjs +104 -0
  241. package/packages/evidence/observation.mjs +16 -1
  242. package/packages/evidence/passphrase-encryption.mjs +44 -0
  243. package/packages/evidence/record-identity.mjs +72 -0
  244. package/packages/evidence/registry-dates.mjs +190 -0
  245. package/packages/evidence/retained-file.mjs +69 -0
  246. package/packages/evidence/review-age.mjs +14 -0
  247. package/packages/interchange/bagit.mjs +342 -0
  248. package/packages/interchange/bounded-zip-extraction.mjs +278 -0
  249. package/packages/interchange/crc32.mjs +17 -0
  250. package/packages/interchange/external-findings-import.mjs +92 -51
  251. package/packages/interchange/web-capture-import.mjs +382 -0
  252. package/packages/investigation/bulk-mail-exposure.mjs +2 -1
  253. package/packages/investigation/investigation-bagit.mjs +14 -0
  254. package/packages/investigation/investigation-capsule.mjs +33 -5
  255. package/packages/investigation/investigation-manifest.mjs +200 -0
  256. package/packages/investigation/investigation-package-crypto.mjs +98 -0
  257. package/packages/investigation/investigation-package.mjs +200 -0
  258. package/packages/investigation/lookup-asset-graph-inputs.mjs +38 -0
  259. package/packages/investigation/lookup-asset-graph.mjs +193 -165
  260. package/packages/investigation/lookup-claim-readiness.mjs +18 -0
  261. package/packages/investigation/lookup-investigation-brief.mjs +3 -3
  262. package/packages/investigation/lookup-task-guidance.mjs +58 -0
  263. package/packages/monitoring/analyst-review-state.mjs +38 -90
  264. package/packages/workspace/brand-profile-model.mjs +276 -22
  265. package/packages/workspace/bulk-columns.mjs +17 -0
  266. package/packages/workspace/bulk-review-model.mjs +23 -11
  267. package/packages/workspace/bulk-session-model.mjs +100 -24
  268. package/packages/workspace/bulk-sort.mjs +1 -0
  269. package/packages/workspace/case-views.mjs +102 -0
  270. package/packages/workspace/detection-rule-model.mjs +70 -22
  271. package/packages/workspace/hostile-input.mjs +29 -8
  272. package/packages/workspace/investigation-template-model.mjs +19 -4
  273. package/packages/workspace/page-baseline.mjs +18 -15
  274. package/packages/workspace/relationship-observation-model.mjs +41 -23
  275. package/packages/workspace/watchlist-history.mjs +2 -2
  276. package/packages/workspace/watchlist-store.mjs +8 -2
  277. package/packages/workspace/website-snapshot-model.mjs +164 -26
  278. package/packages/workspace/workspace-archive-crypto.mjs +41 -61
  279. package/packages/workspace/workspace-archive.mjs +87 -21
  280. package/third-party-notices.txt +29 -2
  281. package/cli/evidence-command-arguments.mjs +0 -156
  282. package/packages/investigation/bulk-review-export.mjs +0 -63
@@ -16,38 +16,45 @@ function errorMessage(value, fallback) {
16
16
  // A referral hostname is upstream-controlled, so resolve and validate its
17
17
  // addresses before connecting. Connect to the selected address directly to
18
18
  // prevent a second DNS lookup from bypassing the public-address decision.
19
- function queryWhoisAddress(address, server, query, { port = 43, timeoutMs = 10000, totalDeadlineMs = WHOIS_HOP_DEADLINE_MS, createConnection = net.createConnection, } = {}) {
19
+ function queryWhoisAddress(address, server, query, { port = 43, timeoutMs = 10000, totalDeadlineMs = WHOIS_HOP_DEADLINE_MS, createConnection = net.createConnection, signal, } = {}) {
20
20
  return new Promise((resolve, reject) => {
21
+ signal?.throwIfAborted();
21
22
  const socket = createConnection({ host: address, port }, () => {
22
- socket.write(query + '\r\n');
23
+ if (!signal?.aborted)
24
+ socket.write(query + '\r\n');
23
25
  });
24
26
  const chunks = [];
25
27
  let totalBytes = 0;
26
28
  let settled = false;
27
29
  const deadline = setTimeout(() => {
28
- settled = true;
30
+ settle(reject, new Error(`WHOIS request to ${server} exceeded the total time limit`));
29
31
  socket.destroy();
30
- reject(new Error(`WHOIS request to ${server} exceeded the total time limit`));
31
32
  }, totalDeadlineMs);
32
33
  function settle(fn, value) {
33
34
  if (settled)
34
35
  return;
35
36
  settled = true;
36
37
  clearTimeout(deadline);
38
+ signal?.removeEventListener('abort', abort);
37
39
  fn(value);
38
40
  }
41
+ const abort = () => { settle(reject, signal.reason); socket.destroy(); };
39
42
  socket.setTimeout(Math.min(timeoutMs, totalDeadlineMs));
40
43
  socket.on('data', (chunk) => {
44
+ if (settled)
45
+ return;
41
46
  const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
42
47
  totalBytes += buffer.length;
43
48
  if (totalBytes > MAX_WHOIS_BYTES) {
44
- socket.destroy();
45
49
  settle(reject, new Error(`WHOIS response from ${server} exceeded ${MAX_WHOIS_BYTES} bytes`));
50
+ socket.destroy();
46
51
  return;
47
52
  }
48
53
  chunks.push(buffer);
49
54
  });
50
55
  const settleResponse = () => {
56
+ if (settled)
57
+ return;
51
58
  try {
52
59
  const responseText = new TextDecoder('utf-8', { fatal: true })
53
60
  .decode(Buffer.concat(chunks, totalBytes));
@@ -60,13 +67,17 @@ function queryWhoisAddress(address, server, query, { port = 43, timeoutMs = 1000
60
67
  socket.on('end', settleResponse);
61
68
  socket.on('close', settleResponse);
62
69
  socket.on('timeout', () => {
63
- socket.destroy();
64
70
  settle(reject, new Error(`WHOIS request to ${server} timed out`));
71
+ socket.destroy();
65
72
  });
66
73
  socket.on('error', (error) => settle(reject, error));
74
+ signal?.addEventListener('abort', abort, { once: true });
75
+ if (signal?.aborted)
76
+ abort();
67
77
  });
68
78
  }
69
- async function whoisQuery(server, query, { port = 43, timeoutMs = 10000, totalDeadlineMs = WHOIS_HOP_DEADLINE_MS, resolveAddresses = resolvePublicAddresses, queryAddress = queryWhoisAddress, now = Date.now, onAddressSelected = (_address) => { }, } = {}) {
79
+ async function whoisQuery(server, query, { port = 43, timeoutMs = 10000, totalDeadlineMs = WHOIS_HOP_DEADLINE_MS, resolveAddresses = resolvePublicAddresses, queryAddress = queryWhoisAddress, now = Date.now, onAddressSelected = (_address) => { }, signal, } = {}) {
80
+ signal?.throwIfAborted();
70
81
  const startedAt = now();
71
82
  let resolutionTimer;
72
83
  const records = await Promise.race([
@@ -75,10 +86,12 @@ async function whoisQuery(server, query, { port = 43, timeoutMs = 10000, totalDe
75
86
  resolutionTimer = setTimeout(() => reject(new Error(`WHOIS request to ${server} timed out during DNS resolution`)), totalDeadlineMs);
76
87
  }),
77
88
  ]).finally(() => clearTimeout(resolutionTimer));
89
+ signal?.throwIfAborted();
78
90
  const candidates = records.slice(0, MAX_WHOIS_ADDRESSES);
79
91
  const failures = [];
80
92
  let attempts = 0;
81
93
  for (const { address } of candidates) {
94
+ signal?.throwIfAborted();
82
95
  const remainingMs = totalDeadlineMs - (now() - startedAt);
83
96
  if (remainingMs <= 0)
84
97
  break;
@@ -88,7 +101,9 @@ async function whoisQuery(server, query, { port = 43, timeoutMs = 10000, totalDe
88
101
  port,
89
102
  timeoutMs: Math.min(timeoutMs, remainingMs),
90
103
  totalDeadlineMs: remainingMs,
104
+ ...(signal ? { signal } : {}),
91
105
  });
106
+ signal?.throwIfAborted();
92
107
  try {
93
108
  onAddressSelected(address);
94
109
  }
@@ -98,6 +113,7 @@ async function whoisQuery(server, query, { port = 43, timeoutMs = 10000, totalDe
98
113
  return response;
99
114
  }
100
115
  catch (error) {
116
+ signal?.throwIfAborted();
101
117
  failures.push(errorMessage(error, 'connection failed').slice(0, 200));
102
118
  }
103
119
  }
@@ -142,7 +142,11 @@ export function normaliseRdata(type, rawValue, origin, nameMode = 'absolute') {
142
142
  const family = type === 'A' ? 4 : 6;
143
143
  if (tokens.length !== 1 || isIP(tokens[0] ?? '') !== family)
144
144
  throw new TypeError(`${type} data must contain one valid address.`);
145
- return { value: tokens[0].toLowerCase(), valueTreatment: 'normalised' };
145
+ const supplied = tokens[0];
146
+ const normalised = family === 4
147
+ ? supplied.split('.').map((part) => String(Number(part))).join('.')
148
+ : new URL(`http://[${supplied}]/`).hostname.slice(1, -1).toLowerCase();
149
+ return { value: normalised, valueTreatment: 'normalised' };
146
150
  }
147
151
  if (type === 'CNAME' || type === 'NS') {
148
152
  if (tokens.length !== 1)
@@ -152,7 +156,12 @@ export function normaliseRdata(type, rawValue, origin, nameMode = 'absolute') {
152
156
  if (type === 'MX') {
153
157
  if (tokens.length !== 2)
154
158
  throw new TypeError('MX data must contain preference and exchange.');
155
- return { value: `${integerToken(tokens[0], 0, 65_535, 'MX preference')} ${host(tokens[1], 'MX exchange')}`, valueTreatment: 'normalised' };
159
+ const preference = integerToken(tokens[0], 0, 65_535, 'MX preference');
160
+ const exchangeToken = tokens[1];
161
+ if (exchangeToken === '.' && preference !== 0)
162
+ throw new TypeError('A Null MX exchange must use preference 0.');
163
+ const exchange = exchangeToken === '.' ? '.' : host(exchangeToken, 'MX exchange');
164
+ return { value: `${preference} ${exchange}`, valueTreatment: 'normalised' };
156
165
  }
157
166
  if (type === 'CAA') {
158
167
  if (tokens.length < 3)
@@ -380,7 +389,6 @@ function parseBindZone(zoneText, initialOrigin) {
380
389
  if (tokens.length !== 2)
381
390
  throw new TypeError('$ORIGIN requires one domain name.');
382
391
  origin = domainName(tokens[1], origin, '$ORIGIN', 'master-file');
383
- lastOwner = origin;
384
392
  continue;
385
393
  }
386
394
  if (directive === '$TTL')
@@ -388,10 +396,12 @@ function parseBindZone(zoneText, initialOrigin) {
388
396
  if (directive?.startsWith('$'))
389
397
  throw new TypeError(`${directive} is deliberately unsupported.`);
390
398
  let offset = 0;
391
- const ownerToken = entry.ownerOmitted ? lastOwner : tokens[offset++];
392
- if (!ownerToken)
399
+ const ownerToken = entry.ownerOmitted ? null : tokens[offset++];
400
+ if (!entry.ownerOmitted && !ownerToken)
393
401
  throw new TypeError('Record owner is missing.');
394
- const owner = ownerName(ownerToken, origin, 'record owner', 'master-file');
402
+ const owner = entry.ownerOmitted
403
+ ? lastOwner
404
+ : ownerName(ownerToken, origin, 'record owner', 'master-file');
395
405
  lastOwner = owner;
396
406
  let ttl = null;
397
407
  if (/^\d+$/u.test(tokens[offset] ?? ''))
package/package.json CHANGED
@@ -33,9 +33,10 @@
33
33
  "bugs": {
34
34
  "url": "https://github.com/slicedearth/whoisleuth/issues"
35
35
  },
36
- "version": "2.2.0",
36
+ "version": "2.4.0",
37
37
  "dependencies": {
38
38
  "@peculiar/x509": "2.0.0",
39
+ "fflate": "0.8.3",
39
40
  "maxmind": "5.0.7",
40
41
  "parse5": "8.0.1",
41
42
  "reflect-metadata": "0.2.2",
@@ -0,0 +1,83 @@
1
+ import { MAX_SELECTED_FILES, SELECTED_FILE_MEDIA_TYPES } from "../contracts/selected-file-limits.mjs";
2
+ import { array, enumeration, exactOptional, iso, text } from "../evidence/artifact-structure.mjs";
3
+ import { readRetainedFileReference } from "../evidence/retained-file.mjs";
4
+ import { readImageDerivation } from "../evidence/image-regions.mjs";
5
+ /** Case provenance is independent of deduplicated immutable content. */
6
+ export function readCaseAttachment(value) {
7
+ const item = exactOptional(value, ['id', 'fileName', 'mediaType', 'source', 'observedAt', 'retainedAt', 'digestSha256', 'byteLength'], ['derivation'], 'Case attachment');
8
+ const id = text(item.id, 'Attachment ID', 128);
9
+ const fileName = text(item.fileName, 'Attachment filename', 240);
10
+ if (/[/\\]/u.test(fileName) || fileName === '.' || fileName === '..')
11
+ throw new TypeError('Attachment filenames cannot contain a path.');
12
+ const mediaType = enumeration(item.mediaType, SELECTED_FILE_MEDIA_TYPES, 'Attachment media type');
13
+ const source = item.source === null ? null : text(item.source, 'Attachment source', 240);
14
+ const derivation = Object.hasOwn(item, 'derivation') ? readImageDerivation(item.derivation) : undefined;
15
+ if (derivation && (mediaType !== 'image/png' || derivation.sourceAttachmentId === id))
16
+ throw new TypeError('A derived PNG must identify a different source attachment.');
17
+ iso(item.observedAt, 'Attachment observation time', true);
18
+ iso(item.retainedAt, 'Attachment retention time');
19
+ return Object.freeze({ id, fileName, mediaType, source, observedAt: item.observedAt, retainedAt: item.retainedAt,
20
+ ...readRetainedFileReference({ digestSha256: item.digestSha256, byteLength: item.byteLength }),
21
+ ...(derivation === undefined ? {} : { derivation }) });
22
+ }
23
+ export function readCaseAttachments(value) {
24
+ if (value === undefined)
25
+ return undefined;
26
+ const attachments = array(value, 'Case attachments', MAX_SELECTED_FILES).map(readCaseAttachment);
27
+ if (new Set(attachments.map(item => item.id)).size !== attachments.length)
28
+ throw new TypeError('Case attachment reference IDs must be unique.');
29
+ const lengths = new Map();
30
+ for (const attachment of attachments) {
31
+ const previous = lengths.get(attachment.digestSha256);
32
+ if (previous !== undefined && previous !== attachment.byteLength)
33
+ throw new TypeError('One retained file digest has conflicting byte lengths.');
34
+ lengths.set(attachment.digestSha256, attachment.byteLength);
35
+ }
36
+ return attachments;
37
+ }
38
+ export function mergeCaseAttachments(local, incoming) {
39
+ if (local === undefined && incoming === undefined)
40
+ return undefined;
41
+ const result = new Map((readCaseAttachments(local) ?? []).map(item => [item.id, item]));
42
+ for (const item of readCaseAttachments(incoming) ?? []) {
43
+ const existing = result.get(item.id);
44
+ if (existing && JSON.stringify(existing) !== JSON.stringify(item))
45
+ throw new TypeError('An attachment reference conflicts with retained provenance. No file or reference was overwritten.');
46
+ result.set(item.id, item);
47
+ }
48
+ return readCaseAttachments([...result.values()]);
49
+ }
50
+ export function caseAttachmentReferences(cases) {
51
+ return cases.flatMap(record => (record.attachments ?? []).map(({ digestSha256, byteLength }) => ({ digestSha256, byteLength })));
52
+ }
53
+ export function addCaseAttachments(record, attachments, updatedAt) {
54
+ iso(updatedAt, 'Case update time');
55
+ return { ...record, attachments: mergeCaseAttachments(record.attachments, attachments) ?? [], updatedAt };
56
+ }
57
+ /** A new local derivative must still refer to the observation the analyst reviewed. */
58
+ export class CaseAttachmentSourceChangedError extends TypeError {
59
+ constructor() {
60
+ super('The source image or its observation changed or was removed. Review the current source before retaining a derivative.');
61
+ this.name = 'CaseAttachmentSourceChangedError';
62
+ }
63
+ }
64
+ export function assertDerivedCaseAttachmentSource(record, input) {
65
+ const attachment = readCaseAttachment(input), derivation = attachment.derivation;
66
+ if (!derivation)
67
+ return;
68
+ const source = record.attachments?.find(candidate => candidate.id === derivation.sourceAttachmentId);
69
+ if (!source || source.mediaType !== 'image/png'
70
+ || source.digestSha256 !== derivation.source.digestSha256 || source.byteLength !== derivation.source.byteLength
71
+ || source.source !== attachment.source || source.observedAt !== attachment.observedAt) {
72
+ throw new CaseAttachmentSourceChangedError();
73
+ }
74
+ }
75
+ export function removeCaseAttachment(record, id, expected, updatedAt) {
76
+ const current = record.attachments?.find(item => item.id === id);
77
+ if (!current)
78
+ return record;
79
+ if (JSON.stringify(readCaseAttachment(current)) !== JSON.stringify(readCaseAttachment(expected)))
80
+ throw new TypeError('The attachment changed in another tab. Refresh before removing it.');
81
+ iso(updatedAt, 'Case update time');
82
+ return { ...record, attachments: record.attachments.filter(item => item.id !== id), updatedAt };
83
+ }
@@ -3,6 +3,10 @@
3
3
  import { normalizeHttpSummary } from "./http-summary.mjs";
4
4
  import { normalizeOpportunityModelVersion } from "../../lib/opportunity-scoring.mjs";
5
5
  import { normalizeRiskModelVersion } from "../../lib/risk-scoring.mjs";
6
+ import { latestObservationCohort } from "../evidence/latest-observations.mjs";
7
+ import { normalizeExplicitIsoTimestamp } from "../evidence/observation.mjs";
8
+ import { PUBLISHED_V2_3_CASE_SCHEMA_VERSION } from "../contracts/case-portability.mjs";
9
+ import { validWebObservationMode } from "../evidence/lookup-target.mjs";
6
10
  import { MAX_EVIDENCE_CHANGES, MAX_EVIDENCE_DETAIL_LENGTH, MAX_EVIDENCE_FACTORS, MAX_EVIDENCE_MUTATIONS, MAX_EVIDENCE_NAMESERVERS, MAX_EVIDENCE_SNAPSHOTS_PER_CASE, MAX_EVIDENCE_STRING_LENGTH, MAX_EVIDENCE_TITLE_LENGTH, } from "./case-record-contracts.mjs";
7
11
  import { CONCLUSIVE_AVAILABILITY, DEFAULT_EVIDENCE_SOURCE, EVIDENCE_SOURCE_RANK, EVIDENCE_SOURCE_SET, REGISTERED_LIKE, caseTimestampOrNull, hashString, isoOrNull, normalizeEvidenceHostnameForCase, objectRecord, safeId, } from "./case-record-core.mjs";
8
12
  function evidenceString(value, max = MAX_EVIDENCE_STRING_LENGTH) {
@@ -24,7 +28,10 @@ function boolOrNull(value) {
24
28
  // are deduplicated and the result is sorted deterministically (largest
25
29
  // contribution first, then label) so input order alone can never change a
26
30
  // snapshot's fingerprint and two equal factor sets in different order collapse.
27
- function normalizeFactors(value) {
31
+ function compareFactors(a, b) {
32
+ return b.points - a.points || (a.label < b.label ? -1 : a.label > b.label ? 1 : 0);
33
+ }
34
+ function normalizeFactors(value, legacyOrder = false) {
28
35
  if (!Array.isArray(value))
29
36
  return [];
30
37
  const seen = new Set();
@@ -45,7 +52,7 @@ function normalizeFactors(value) {
45
52
  seen.add(key);
46
53
  out.push({ label, points });
47
54
  }
48
- out.sort((a, b) => b.points - a.points || a.label.localeCompare(b.label));
55
+ out.sort(legacyOrder ? (a, b) => b.points - a.points || a.label.localeCompare(b.label) : compareFactors);
49
56
  return out.slice(0, MAX_EVIDENCE_FACTORS);
50
57
  }
51
58
  // Case-insensitive, terminal-dot-stripped, deduplicated and sorted so a
@@ -112,6 +119,8 @@ const DEEP_SIGNAL_FIELDS = [
112
119
  // another. Deterministic ordering here is what makes the fingerprint stable.
113
120
  const MATERIAL_FIELD_ORDER = [
114
121
  'inputHostname',
122
+ 'observationHostname',
123
+ 'webObservationMode',
115
124
  'scanDepth',
116
125
  'availability', 'confidence', 'riskModelVersion', 'riskScore', 'opportunityModelVersion', 'opportunityScore',
117
126
  'riskFactors', 'opportunityFactors',
@@ -128,7 +137,7 @@ const MATERIAL_FIELD_ORDER = [
128
137
  // The canonical, comparison-safe value of a material field. Registrar casing,
129
138
  // nameserver order, and sub-day timestamps are collapsed so they can never
130
139
  // count as a "change"; a non-conclusive availability contributes nothing.
131
- function materialValue(field, snapshot) {
140
+ function materialValue(field, snapshot, legacyOrder = false) {
132
141
  switch (field) {
133
142
  case 'availability':
134
143
  return typeof snapshot.availability === 'string' && CONCLUSIVE_AVAILABILITY.has(snapshot.availability)
@@ -147,9 +156,9 @@ function materialValue(field, snapshot) {
147
156
  case 'mutationTypes':
148
157
  return snapshot.mutationTypes;
149
158
  case 'riskFactors':
150
- return snapshot.riskFactors.map((factor) => [factor.label, factor.points]);
159
+ return (legacyOrder ? snapshot.riskFactors : [...snapshot.riskFactors].sort(compareFactors)).map((factor) => [factor.label, factor.points]);
151
160
  case 'opportunityFactors':
152
- return snapshot.opportunityFactors.map((factor) => [factor.label, factor.points]);
161
+ return (legacyOrder ? snapshot.opportunityFactors : [...snapshot.opportunityFactors].sort(compareFactors)).map((factor) => [factor.label, factor.points]);
153
162
  default:
154
163
  return snapshot[field] ?? null;
155
164
  }
@@ -165,7 +174,7 @@ function isEmptyMaterial(value) {
165
174
  }
166
175
  // Fields that describe the capture rather than assert evidence, so they never
167
176
  // on their own keep an otherwise-empty snapshot alive.
168
- const NON_EVIDENCE_MATERIAL = new Set(['inputHostname', 'scanDepth', 'confidence']);
177
+ const NON_EVIDENCE_MATERIAL = new Set(['inputHostname', 'observationHostname', 'webObservationMode', 'scanDepth', 'confidence']);
169
178
  // A snapshot with no material evidence (only timestamps/source/depth, or only a
170
179
  // bare confidence/unknown-availability) is dropped rather than added to a
171
180
  // timeline.
@@ -179,14 +188,13 @@ function hasMaterialEvidence(snapshot) {
179
188
  return false;
180
189
  }
181
190
  // Deterministic string form of the material identity, keys in fixed order.
182
- function canonicalMaterialString(snapshot) {
191
+ function canonicalMaterialString(snapshot, legacyOrder = false) {
183
192
  const canonical = {};
184
193
  for (const field of MATERIAL_FIELD_ORDER) {
185
- const value = materialValue(field, snapshot);
186
- // Preserve historical fingerprints when the new v14 observation-context
187
- // field is absent. A retained hostname is still material and therefore
188
- // separates otherwise-identical captures.
189
- if (field === 'inputHostname' && value === null)
194
+ const value = materialValue(field, snapshot, legacyOrder);
195
+ // Optional collection context does not alter historical fingerprints when
196
+ // absent; recorded context separates otherwise-identical captures.
197
+ if ((field === 'inputHostname' || field === 'observationHostname' || field === 'webObservationMode') && value === null)
190
198
  continue;
191
199
  canonical[field] = value;
192
200
  }
@@ -213,14 +221,32 @@ function buildSnapshot(raw, options) {
213
221
  if (!raw || typeof raw !== 'object')
214
222
  return null;
215
223
  const record = objectRecord(raw);
224
+ if (record.factorOrder !== undefined && record.factorOrder !== 'code-unit-v1')
225
+ return null;
226
+ const historicalSchema = options.sourceVersion !== undefined && Number(options.sourceVersion) <= PUBLISHED_V2_3_CASE_SCHEMA_VERSION;
227
+ if (historicalSchema && record.factorOrder !== undefined)
228
+ return null;
229
+ // Fieldless retained snapshots keep their original identity algorithm. New
230
+ // captures declare code-unit ordering; the declaration survives every save.
231
+ const legacyOrder = historicalSchema || (record.factorOrder === undefined && typeof record.fingerprint === 'string');
216
232
  const scanDepth = normalizeScanDepth(record.scanDepth);
217
233
  const httpSummary = normalizeHttpSummary(record);
218
234
  const acceptsProfileContext = options.sourceVersion === undefined || Number(options.sourceVersion) >= 12;
219
235
  const acceptsInputHostname = options.sourceVersion === undefined || Number(options.sourceVersion) >= 13;
236
+ const acceptsObservationHostname = options.sourceVersion === undefined || Number(options.sourceVersion) > PUBLISHED_V2_3_CASE_SCHEMA_VERSION;
237
+ const observationHostname = acceptsObservationHostname
238
+ ? normalizeEvidenceHostnameForCase(record.observationHostname, options.caseDomain)
239
+ : null;
240
+ if (acceptsObservationHostname && record.observationHostname != null && !observationHostname)
241
+ return null;
242
+ if (acceptsObservationHostname && !validWebObservationMode(record.webObservationMode))
243
+ return null;
220
244
  const fields = {
221
245
  inputHostname: acceptsInputHostname
222
246
  ? normalizeEvidenceHostnameForCase(record.inputHostname, options.caseDomain)
223
247
  : null,
248
+ ...(observationHostname ? { observationHostname } : {}),
249
+ ...(acceptsObservationHostname && scanDepth !== 'fast' && record.webObservationMode === 'selected_url' ? { webObservationMode: record.webObservationMode } : {}),
224
250
  scanDepth,
225
251
  availability: evidenceString(record.availability),
226
252
  confidence: evidenceString(record.confidence),
@@ -228,8 +254,8 @@ function buildSnapshot(raw, options) {
228
254
  riskScore: clampScore(record.riskScore),
229
255
  opportunityModelVersion: normalizeOpportunityModelVersion(record.opportunityModelVersion),
230
256
  opportunityScore: clampScore(record.opportunityScore),
231
- riskFactors: normalizeFactors(record.riskFactors),
232
- opportunityFactors: normalizeFactors(record.opportunityFactors),
257
+ riskFactors: normalizeFactors(record.riskFactors, legacyOrder),
258
+ opportunityFactors: normalizeFactors(record.opportunityFactors, legacyOrder),
233
259
  registrar: evidenceString(record.registrar),
234
260
  createdDate: lifecycleTimestamp(record.createdDate, options.sourceVersion),
235
261
  expiryDate: lifecycleTimestamp(record.expiryDate, options.sourceVersion),
@@ -317,10 +343,11 @@ function buildSnapshot(raw, options) {
317
343
  ? options.source
318
344
  : DEFAULT_EVIDENCE_SOURCE;
319
345
  const material = canonicalMaterialString(fields);
320
- const fingerprint = hashString(material);
346
+ const fingerprint = hashString(legacyOrder ? canonicalMaterialString(fields, true) : material);
321
347
  const snapshot = {
322
348
  id: `ev-${fingerprint}`,
323
349
  fingerprint,
350
+ ...(!legacyOrder ? { factorOrder: 'code-unit-v1' } : {}),
324
351
  firstCapturedAt,
325
352
  capturedAt,
326
353
  source,
@@ -356,7 +383,10 @@ function mergeDuplicateSnapshots(kept, incoming) {
356
383
  ? incoming.capturedAt
357
384
  : kept.capturedAt;
358
385
  const source = chooseSource(kept, incoming);
359
- return { ...kept, firstCapturedAt, capturedAt, source };
386
+ // Preserve an existing historical identifier when a new capture contains
387
+ // the same evidence under the newer ordering declaration.
388
+ const base = kept.factorOrder && !incoming.factorOrder ? incoming : kept;
389
+ return { ...base, firstCapturedAt, capturedAt, source };
360
390
  }
361
391
  function compareSnapshotChrono(a, b) {
362
392
  return (Date.parse(a.capturedAt) - Date.parse(b.capturedAt) ||
@@ -403,15 +433,65 @@ function assignUniqueSnapshotIds(snapshots) {
403
433
  return snapshot.id === id ? snapshot : { ...snapshot, id };
404
434
  });
405
435
  }
406
- /**
407
- * The most recent snapshot, or null. Lets UI render "the latest evidence"
408
- * without knowing the history is a bounded, deduplicated timeline.
409
- * @param {{ evidenceHistory?: CaseEvidenceSnapshot[] } | null | undefined} record
410
- * @returns {CaseEvidenceSnapshot | null}
411
- */
412
- export function latestCaseEvidence(record) {
436
+ /** Select from an admitted history without assigning order to equal or unknown capture times. */
437
+ export function currentCaseEvidence(record) {
413
438
  const history = record && Array.isArray(record.evidenceHistory) ? record.evidenceHistory : [];
414
- return history.at(-1) ?? null;
439
+ const cohort = latestObservationCohort(history, (snapshot) => snapshot.capturedAt);
440
+ const snapshot = cohort.undated.length === 0 && cohort.latest.length === 1 ? cohort.latest[0] : null;
441
+ return {
442
+ snapshot,
443
+ candidates: [...cohort.latest, ...cohort.undated],
444
+ capturedAt: cohort.observedAt,
445
+ limitation: cohort.undated.length
446
+ ? 'Retained snapshots include an unknown capture time. No single latest assessment is selected.'
447
+ : cohort.latest.length > 1
448
+ ? 'Distinct snapshots share the latest capture time. No single latest assessment is selected.'
449
+ : null,
450
+ };
451
+ }
452
+ /** The unique latest retained snapshot, or null for empty or temporally ambiguous evidence. */
453
+ export function latestCaseEvidence(record) {
454
+ return currentCaseEvidence(record).snapshot;
455
+ }
456
+ /** Chronological presentation and comparison admission shared by the browser and Case report. */
457
+ export function caseEvidenceTimeline(history) {
458
+ const dated = (history ?? []).map((snapshot) => ({
459
+ snapshot,
460
+ at: normalizeExplicitIsoTimestamp(snapshot.capturedAt),
461
+ }));
462
+ const counts = new Map();
463
+ for (const { at } of dated)
464
+ if (at)
465
+ counts.set(at, (counts.get(at) ?? 0) + 1);
466
+ const hasUndated = dated.some(({ at }) => at === null);
467
+ dated.sort((a, b) => (a.at === b.at ? 0 : a.at === null ? 1 : b.at === null ? -1 : Date.parse(a.at) - Date.parse(b.at))
468
+ || (a.snapshot.id < b.snapshot.id ? -1 : a.snapshot.id > b.snapshot.id ? 1 : 0));
469
+ return dated.map(({ snapshot, at }, index) => {
470
+ const previous = dated[index - 1];
471
+ const isBaseline = index === 0 && !hasUndated && at !== null && counts.get(at) === 1;
472
+ const orderingLimitation = !at || (previous && !previous.at)
473
+ ? 'Capture order is unknown; no temporal change is inferred.'
474
+ : counts.get(at) !== 1 || (previous && counts.get(previous.at) !== 1)
475
+ ? 'Equal-time snapshots have no unique before-and-after order; no temporal change is inferred.'
476
+ : null;
477
+ const comparable = previous && !orderingLimitation;
478
+ const changes = comparable ? compareCaseEvidence(previous.snapshot, snapshot) : [];
479
+ const incomparableReasons = orderingLimitation
480
+ ? ['other']
481
+ : comparable ? caseEvidenceIncomparableReasons(previous.snapshot, snapshot) : [];
482
+ if (comparable && !changes.length && !incomparableReasons.length
483
+ && canonicalMaterialString(previous.snapshot) !== canonicalMaterialString(snapshot))
484
+ incomparableReasons.push('other');
485
+ return {
486
+ snapshot,
487
+ isBaseline,
488
+ hasRepeatedObservation: snapshot.firstCapturedAt !== snapshot.capturedAt,
489
+ changes: changes.length ? changes : null,
490
+ hasIncomparableChange: incomparableReasons.length > 0,
491
+ incomparableReasons,
492
+ orderingLimitation,
493
+ };
494
+ });
415
495
  }
416
496
  /**
417
497
  * Returns the exact hostname from the latest retained observation when it is
@@ -433,38 +513,38 @@ export function caseLookupTarget(record) {
433
513
  // a mode change is never reported.
434
514
  // (absent) - always comparable (data available in every capture).
435
515
  const COMPARE_FIELDS = [
436
- { field: 'availability', label: 'Availability', type: 'availability' },
437
- { field: 'confidence', label: 'Confidence', type: 'token' },
438
- { field: 'riskScore', label: 'Risk score', type: 'score', depthGate: 'comparable', modelGate: 'risk', direction: 'risk' },
439
- { field: 'riskFactors', label: 'Risk factors', type: 'factors', depthGate: 'comparable', modelGate: 'risk' },
440
- { field: 'opportunityScore', label: 'Opportunity score', type: 'score', modelGate: 'opportunity' },
441
- { field: 'opportunityFactors', label: 'Opportunity factors', type: 'factors', modelGate: 'opportunity' },
442
- { field: 'registrar', label: 'Registrar', type: 'registrar' },
443
- { field: 'createdDate', label: 'Creation date', type: 'date' },
444
- { field: 'expiryDate', label: 'Expiry date', type: 'date' },
445
- { field: 'nameservers', label: 'Nameservers', type: 'set', emptyGuard: true },
446
- { field: 'hasMx', label: 'MX', type: 'bool', depthGate: 'both-deep' },
447
- { field: 'hasSpf', label: 'SPF', type: 'bool', depthGate: 'both-deep' },
448
- { field: 'hasDmarc', label: 'DMARC', type: 'bool', depthGate: 'both-deep' },
449
- { field: 'activityStatus', label: 'Website activity', type: 'token', depthGate: 'both-deep' },
450
- { field: 'websiteProbeDetail', label: 'Website check detail', type: 'text', depthGate: 'both-deep' },
451
- { field: 'pageTitle', label: 'Page title', type: 'text', depthGate: 'both-deep' },
452
- { field: 'httpEvidenceStatus', label: 'HTTP evidence status', type: 'token', depthGate: 'both-deep' },
453
- { field: 'httpFinalOrigin', label: 'Final website origin', type: 'text', depthGate: 'both-deep' },
454
- { field: 'httpResponseStatus', label: 'HTTP response status', type: 'number', depthGate: 'both-deep' },
455
- { field: 'httpTransportSecurity', label: 'Website transport', type: 'http-transport', depthGate: 'both-deep' },
456
- { field: 'httpRedirectCount', label: 'HTTP redirect count', type: 'number', depthGate: 'both-deep' },
457
- { field: 'httpCrossOriginRedirect', label: 'Cross-origin redirect', type: 'http-signal', depthGate: 'both-deep' },
458
- { field: 'httpHttpsDowngrade', label: 'HTTPS downgrade', type: 'signal', depthGate: 'both-deep' },
459
- { field: 'httpContentType', label: 'Website content type', type: 'token', depthGate: 'both-deep' },
460
- { field: 'httpSecurityHeaders', label: 'Observed security headers', type: 'set', depthGate: 'both-deep' },
461
- { field: 'faviconMatch', label: 'Official favicon match', type: 'signal', depthGate: 'both-deep' },
462
- { field: 'faviconNearMatch', label: 'Official favicon near-match', type: 'signal', depthGate: 'both-deep' },
463
- { field: 'reusesOfficialAssets', label: 'Official asset reuse', type: 'signal', depthGate: 'both-deep' },
464
- { field: 'hasPasswordField', label: 'Password form', type: 'signal', depthGate: 'both-deep' },
465
- { field: 'hasExternalFormAction', label: 'External form action', type: 'signal', depthGate: 'both-deep' },
466
- { field: 'phishingLanguageMatch', label: 'Phishing language', type: 'phishing', depthGate: 'both-deep' },
467
- { field: 'mutationTypes', label: 'Mutation types', type: 'set' },
516
+ { field: 'availability', scope: 'registration', label: 'Availability', type: 'availability' },
517
+ { field: 'confidence', scope: 'registration', label: 'Confidence', type: 'token' },
518
+ { field: 'riskScore', scope: 'web', label: 'Risk score', type: 'score', depthGate: 'comparable', modelGate: 'risk', direction: 'risk' },
519
+ { field: 'riskFactors', scope: 'web', label: 'Risk factors', type: 'factors', depthGate: 'comparable', modelGate: 'risk' },
520
+ { field: 'opportunityScore', scope: 'web', label: 'Opportunity score', type: 'score', modelGate: 'opportunity' },
521
+ { field: 'opportunityFactors', scope: 'web', label: 'Opportunity factors', type: 'factors', modelGate: 'opportunity' },
522
+ { field: 'registrar', scope: 'registration', label: 'Registrar', type: 'registrar' },
523
+ { field: 'createdDate', scope: 'registration', label: 'Creation date', type: 'date' },
524
+ { field: 'expiryDate', scope: 'registration', label: 'Expiry date', type: 'date' },
525
+ { field: 'nameservers', scope: 'registration', label: 'Nameservers', type: 'set', emptyGuard: true },
526
+ { field: 'hasMx', scope: 'hostname', label: 'MX', type: 'bool', depthGate: 'both-deep' },
527
+ { field: 'hasSpf', scope: 'hostname', label: 'SPF', type: 'bool', depthGate: 'both-deep' },
528
+ { field: 'hasDmarc', scope: 'hostname', label: 'DMARC', type: 'bool', depthGate: 'both-deep' },
529
+ { field: 'activityStatus', scope: 'web', label: 'Website activity', type: 'token', depthGate: 'both-deep' },
530
+ { field: 'websiteProbeDetail', scope: 'web', label: 'Website check detail', type: 'text', depthGate: 'both-deep' },
531
+ { field: 'pageTitle', scope: 'web', label: 'Page title', type: 'text', depthGate: 'both-deep' },
532
+ { field: 'httpEvidenceStatus', scope: 'web', label: 'HTTP evidence status', type: 'token', depthGate: 'both-deep' },
533
+ { field: 'httpFinalOrigin', scope: 'web', label: 'Final website origin', type: 'text', depthGate: 'both-deep' },
534
+ { field: 'httpResponseStatus', scope: 'web', label: 'HTTP response status', type: 'number', depthGate: 'both-deep' },
535
+ { field: 'httpTransportSecurity', scope: 'web', label: 'Website transport', type: 'http-transport', depthGate: 'both-deep' },
536
+ { field: 'httpRedirectCount', scope: 'web', label: 'HTTP redirect count', type: 'number', depthGate: 'both-deep' },
537
+ { field: 'httpCrossOriginRedirect', scope: 'web', label: 'Cross-origin redirect', type: 'http-signal', depthGate: 'both-deep' },
538
+ { field: 'httpHttpsDowngrade', scope: 'web', label: 'HTTPS downgrade', type: 'signal', depthGate: 'both-deep' },
539
+ { field: 'httpContentType', scope: 'web', label: 'Website content type', type: 'token', depthGate: 'both-deep' },
540
+ { field: 'httpSecurityHeaders', scope: 'web', label: 'Observed security headers', type: 'set', depthGate: 'both-deep' },
541
+ { field: 'faviconMatch', scope: 'web', label: 'Official favicon match', type: 'signal', depthGate: 'both-deep' },
542
+ { field: 'faviconNearMatch', scope: 'web', label: 'Official favicon near-match', type: 'signal', depthGate: 'both-deep' },
543
+ { field: 'reusesOfficialAssets', scope: 'web', label: 'Official asset reuse', type: 'signal', depthGate: 'both-deep' },
544
+ { field: 'hasPasswordField', scope: 'web', label: 'Password form', type: 'signal', depthGate: 'both-deep' },
545
+ { field: 'hasExternalFormAction', scope: 'web', label: 'External form action', type: 'signal', depthGate: 'both-deep' },
546
+ { field: 'phishingLanguageMatch', scope: 'web', label: 'Phishing language', type: 'phishing', depthGate: 'both-deep' },
547
+ { field: 'mutationTypes', scope: 'hostname', label: 'Mutation types', type: 'set' },
468
548
  ];
469
549
  function depthComparable(a, b) {
470
550
  return a === b && (a === 'fast' || a === 'deep');
@@ -495,7 +575,7 @@ export function caseEvidenceIncomparableReasons(previous, current) {
495
575
  if (!previous || !current || previous.fingerprint === current.fingerprint)
496
576
  return [];
497
577
  const reasons = [];
498
- if (previous.inputHostname !== current.inputHostname)
578
+ if (!sameObservationContext(previous, current))
499
579
  reasons.push('observation-context');
500
580
  const hasRiskEvidence = previous.riskScore !== null || current.riskScore !== null
501
581
  || previous.riskFactors.length > 0 || current.riskFactors.length > 0;
@@ -523,6 +603,16 @@ function isPresent(value) {
523
603
  return value.length > 0;
524
604
  return true;
525
605
  }
606
+ function sameObservationHostname(previous, current) {
607
+ return previous.inputHostname === current.inputHostname
608
+ && (previous.observationHostname ?? null) === (current.observationHostname ?? null);
609
+ }
610
+ function sameObservationContext(previous, current) {
611
+ // Compact Case snapshots deliberately omit URL paths and queries. A selected
612
+ // page cannot therefore establish a comparable website target here.
613
+ return sameObservationHostname(previous, current)
614
+ && previous.webObservationMode !== 'selected_url' && current.webObservationMode !== 'selected_url';
615
+ }
526
616
  function setsEqual(a, b) {
527
617
  return JSON.stringify(a) === JSON.stringify(b);
528
618
  }
@@ -648,11 +738,9 @@ function compareField(spec, before, after) {
648
738
  return { before: b, after: a, tone: 'neutral' };
649
739
  }
650
740
  case 'factors': {
651
- // Factors are already normalized (deduped + deterministically sorted), so
652
- // a set comparison ignores input order and reports a genuine change in the
653
- // score's composition even when the total is unchanged.
654
- const b = Array.isArray(before) ? before : [];
655
- const a = Array.isArray(after) ? after : [];
741
+ // Historical identity ordering is not a change in the factor set.
742
+ const b = normalizeFactors(before);
743
+ const a = normalizeFactors(after);
656
744
  if (setsEqual(b, a))
657
745
  return null;
658
746
  return { before: b, after: a, tone: 'neutral' };
@@ -681,8 +769,14 @@ export function compareCaseEvidence(previous, current) {
681
769
  const comparableDepth = depthComparable(previous.scanDepth, current.scanDepth);
682
770
  const comparableRiskModel = riskModelComparable(previous, current);
683
771
  const comparableOpportunityModel = opportunityModelComparable(previous, current);
772
+ const sameHostname = sameObservationHostname(previous, current);
773
+ const sameWebContext = sameObservationContext(previous, current);
684
774
  const changes = [];
685
775
  for (const spec of COMPARE_FIELDS) {
776
+ if (spec.scope === 'hostname' && !sameHostname)
777
+ continue;
778
+ if (spec.scope === 'web' && !sameWebContext)
779
+ continue;
686
780
  if (spec.depthGate === 'both-deep' && !bothDeep)
687
781
  continue;
688
782
  if (spec.depthGate === 'comparable' && !comparableDepth)