@slicedearth/whoisleuth-cli 1.32.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 (189) hide show
  1. package/LICENSE +661 -0
  2. package/LICENSES/Retire.js-Apache-2.0.txt +199 -0
  3. package/NOTICE +76 -0
  4. package/README.md +84 -0
  5. package/TRADEMARKS.md +41 -0
  6. package/bin/whoisleuth.mjs +35 -0
  7. package/cli/archive-inspect.mjs +198 -0
  8. package/cli/arguments.mjs +1022 -0
  9. package/cli/artifact-verify.mjs +210 -0
  10. package/cli/bulk-checkpoint.mjs +218 -0
  11. package/cli/bulk-command-runner.mjs +109 -0
  12. package/cli/bulk-output.mjs +112 -0
  13. package/cli/bulk.mjs +133 -0
  14. package/cli/compare.mjs +238 -0
  15. package/cli/completion.mjs +175 -0
  16. package/cli/csv.mjs +1 -0
  17. package/cli/discover.mjs +50 -0
  18. package/cli/discovery-command-runner.mjs +36 -0
  19. package/cli/discovery-observation-snapshot.mjs +315 -0
  20. package/cli/discovery-scan-command-runner.mjs +141 -0
  21. package/cli/discovery-scan.mjs +325 -0
  22. package/cli/discovery-snapshot.mjs +133 -0
  23. package/cli/discovery-workflow.mjs +73 -0
  24. package/cli/doctor.mjs +137 -0
  25. package/cli/errors.mjs +21 -0
  26. package/cli/evidence-command-arguments.mjs +145 -0
  27. package/cli/evidence-command-runner.mjs +106 -0
  28. package/cli/evidence-signing.mjs +215 -0
  29. package/cli/exit-codes.mjs +9 -0
  30. package/cli/export-evidence.mjs +28 -0
  31. package/cli/formatters/evidence-report.mjs +3 -0
  32. package/cli/formatters/html.mjs +69 -0
  33. package/cli/formatters/json.mjs +135 -0
  34. package/cli/formatters/markdown.mjs +3 -0
  35. package/cli/formatters/terminal.mjs +658 -0
  36. package/cli/http.mjs +27 -0
  37. package/cli/lookup-command-runner.mjs +87 -0
  38. package/cli/lookup-diff.mjs +171 -0
  39. package/cli/mail-review.mjs +204 -0
  40. package/cli/manual.mjs +34 -0
  41. package/cli/network-command-runner.mjs +88 -0
  42. package/cli/output-file.mjs +97 -0
  43. package/cli/page-compare.mjs +123 -0
  44. package/cli/posture.mjs +20 -0
  45. package/cli/progress-events.mjs +53 -0
  46. package/cli/progress.mjs +56 -0
  47. package/cli/registry-access.mjs +3 -0
  48. package/cli/registry-support.mjs +120 -0
  49. package/cli/risk-calibration.mjs +294 -0
  50. package/cli/runner-types.mjs +1 -0
  51. package/cli/runner.mjs +726 -0
  52. package/cli/saved-lookup.mjs +87 -0
  53. package/cli/source-reliability.mjs +727 -0
  54. package/cli/strict-exit.mjs +55 -0
  55. package/cli/terminal-presentation.mjs +147 -0
  56. package/docs/cli.md +827 -0
  57. package/frontend/src/lib/analysis/acquisition-decision-packet.js +93 -0
  58. package/frontend/src/lib/analysis/acquisition-due-diligence.js +391 -0
  59. package/frontend/src/lib/analysis/artifact-integrity.js +21 -0
  60. package/frontend/src/lib/analysis/brand-profile-model.js +421 -0
  61. package/frontend/src/lib/analysis/bulk-domain-comparison.js +246 -0
  62. package/frontend/src/lib/analysis/bulk-mail-exposure.js +192 -0
  63. package/frontend/src/lib/analysis/bulk-retry-plan.js +86 -0
  64. package/frontend/src/lib/analysis/bulk-review-export.js +62 -0
  65. package/frontend/src/lib/analysis/bulk-review-model.js +266 -0
  66. package/frontend/src/lib/analysis/bulk-session-model.js +515 -0
  67. package/frontend/src/lib/analysis/bulk-sort.js +49 -0
  68. package/frontend/src/lib/analysis/campaign-model.js +265 -0
  69. package/frontend/src/lib/analysis/case-evidence-model.js +642 -0
  70. package/frontend/src/lib/analysis/case-migration-model.js +248 -0
  71. package/frontend/src/lib/analysis/case-model.js +5 -0
  72. package/frontend/src/lib/analysis/case-record-contracts.js +44 -0
  73. package/frontend/src/lib/analysis/case-record-core.js +232 -0
  74. package/frontend/src/lib/analysis/case-record-model.js +7 -0
  75. package/frontend/src/lib/analysis/case-record-operations.js +224 -0
  76. package/frontend/src/lib/analysis/case-response-model.js +640 -0
  77. package/frontend/src/lib/analysis/case-response-packet.js +573 -0
  78. package/frontend/src/lib/analysis/case-storage-model.js +93 -0
  79. package/frontend/src/lib/analysis/detection-rule-model.js +306 -0
  80. package/frontend/src/lib/analysis/dns-record-normalization.js +11 -0
  81. package/frontend/src/lib/analysis/http-summary.js +165 -0
  82. package/frontend/src/lib/analysis/investigation-guide.js +500 -0
  83. package/frontend/src/lib/analysis/investigation-template-model.js +144 -0
  84. package/frontend/src/lib/analysis/page-baseline.js +272 -0
  85. package/frontend/src/lib/analysis/page-similarity.js +179 -0
  86. package/frontend/src/lib/analysis/relationship-evidence.js +265 -0
  87. package/frontend/src/lib/analysis/relationship-observation-model.js +423 -0
  88. package/frontend/src/lib/analysis/scoring.js +135 -0
  89. package/frontend/src/lib/analysis/shortlist-model.js +209 -0
  90. package/frontend/src/lib/analysis/utils.js +265 -0
  91. package/frontend/src/lib/analysis/watchlist-history.js +643 -0
  92. package/frontend/src/lib/analysis/watchlist-store.js +127 -0
  93. package/frontend/src/lib/analysis/website-snapshot-model.js +302 -0
  94. package/frontend/src/lib/analysis/workspace-archive-crypto.js +282 -0
  95. package/frontend/src/lib/analysis/workspace-archive.js +472 -0
  96. package/lib/abort.mjs +26 -0
  97. package/lib/abusech-auth.mjs +19 -0
  98. package/lib/availability.mjs +716 -0
  99. package/lib/bounded-contract-normalizers.mjs +61 -0
  100. package/lib/browser-library-profile.mjs +310 -0
  101. package/lib/bulk-comparison-evidence.mjs +83 -0
  102. package/lib/classify.mjs +93 -0
  103. package/lib/client-behavior-profile.mjs +83 -0
  104. package/lib/credential-surface-profile.mjs +66 -0
  105. package/lib/csv.mjs +23 -0
  106. package/lib/ct-query.mjs +27 -0
  107. package/lib/ct-search.mjs +393 -0
  108. package/lib/distributed-operation-budget.mjs +461 -0
  109. package/lib/dns-delegation-health.mjs +344 -0
  110. package/lib/dns-intelligence.mjs +739 -0
  111. package/lib/dns-mx.mjs +24 -0
  112. package/lib/dns-resolver-selection.mjs +33 -0
  113. package/lib/domain-posture-analysis.mjs +254 -0
  114. package/lib/domain-posture-parsers.mjs +358 -0
  115. package/lib/domain-posture.mjs +745 -0
  116. package/lib/error-detail.mjs +10 -0
  117. package/lib/evidence-export.mjs +431 -0
  118. package/lib/evidence-report-markdown.mjs +71 -0
  119. package/lib/evidence-report.mjs +391 -0
  120. package/lib/external-intelligence-risk.mjs +108 -0
  121. package/lib/favicon.mjs +146 -0
  122. package/lib/feature-policy.mjs +70 -0
  123. package/lib/generated/retire-browser-catalog.mjs +8724 -0
  124. package/lib/generated/unicode-confusables-17.mjs +93 -0
  125. package/lib/hostname.mjs +38 -0
  126. package/lib/html-signals.mjs +680 -0
  127. package/lib/http-intelligence.mjs +232 -0
  128. package/lib/idn-confusable-policy.mjs +86 -0
  129. package/lib/idn-confusables.mjs +465 -0
  130. package/lib/lookup-cache.mjs +130 -0
  131. package/lib/lookup-progress.mjs +291 -0
  132. package/lib/lookup-source-progress.mjs +109 -0
  133. package/lib/lookup-threat-provider-inventory.mjs +13 -0
  134. package/lib/lookup.mjs +592 -0
  135. package/lib/observation.mjs +114 -0
  136. package/lib/observed-network-context.mjs +355 -0
  137. package/lib/operation-budget.mjs +489 -0
  138. package/lib/page-fingerprints.mjs +481 -0
  139. package/lib/page-role-profile.mjs +117 -0
  140. package/lib/perceptual-hash.mjs +451 -0
  141. package/lib/rdap-attempts.mjs +34 -0
  142. package/lib/rdap-bootstrap.mjs +173 -0
  143. package/lib/rdap-capabilities.mjs +292 -0
  144. package/lib/rdap-client.mjs +125 -0
  145. package/lib/rdap-normalization.mjs +691 -0
  146. package/lib/rdap-registrar.mjs +196 -0
  147. package/lib/rdap-transport.mjs +39 -0
  148. package/lib/rdap-types.mjs +1 -0
  149. package/lib/rdap-validation.mjs +94 -0
  150. package/lib/rdap.mjs +16 -0
  151. package/lib/registry-access.mjs +32 -0
  152. package/lib/registry-capabilities.mjs +117 -0
  153. package/lib/registry-capability-catalogue.mjs +1206 -0
  154. package/lib/registry-comparison.mjs +318 -0
  155. package/lib/registry-dates.mjs +190 -0
  156. package/lib/registry-insights.mjs +325 -0
  157. package/lib/response-policy.mjs +256 -0
  158. package/lib/risk-scoring.mjs +164 -0
  159. package/lib/safe-fetch.mjs +446 -0
  160. package/lib/security-txt.mjs +359 -0
  161. package/lib/service-binding-dns.mjs +608 -0
  162. package/lib/sslbl-certificates.generated.mjs +143 -0
  163. package/lib/sslbl-intelligence.mjs +241 -0
  164. package/lib/static-html-analysis.mjs +340 -0
  165. package/lib/structured-data-identity.mjs +323 -0
  166. package/lib/threat-intelligence-contract.mjs +5 -0
  167. package/lib/threat-intelligence-definition-registry.mjs +34 -0
  168. package/lib/threat-intelligence-fixtures.mjs +82 -0
  169. package/lib/threat-intelligence-manifests.mjs +512 -0
  170. package/lib/threat-intelligence-runtime.mjs +662 -0
  171. package/lib/threat-intelligence-targets.mjs +168 -0
  172. package/lib/threat-intelligence-types.mjs +3 -0
  173. package/lib/threatfox-intelligence.mjs +307 -0
  174. package/lib/tls-intelligence.mjs +814 -0
  175. package/lib/typosquat-generator.mjs +884 -0
  176. package/lib/urlhaus-intelligence.mjs +361 -0
  177. package/lib/urlscan-intelligence.mjs +349 -0
  178. package/lib/website-security-posture.mjs +325 -0
  179. package/lib/website-technology.mjs +437 -0
  180. package/lib/whois-authority.mjs +98 -0
  181. package/lib/whois-chain.mjs +261 -0
  182. package/lib/whois-contacts.mjs +163 -0
  183. package/lib/whois-contracts.mjs +3 -0
  184. package/lib/whois-normalization.mjs +27 -0
  185. package/lib/whois-transport.mjs +100 -0
  186. package/lib/whois-values.mjs +92 -0
  187. package/lib/whois.mjs +1187 -0
  188. package/package.json +55 -0
  189. package/third-party-notices.txt +5571 -0
@@ -0,0 +1,642 @@
1
+ // Pure, framework-neutral analyst-case records, evidence histories, bounded
2
+ // record normalization, and analyst updates.
3
+ import { normalizeHttpSummary } from "./http-summary.js";
4
+ import { normalizeRiskModelVersion } from "./scoring.js";
5
+ import { CASE_DISPOSITIONS, CASE_IMPORT_VERSIONS, CASE_SCHEMA_VERSION, CASE_SOURCES, CASE_STATUSES, DEFAULT_DISPOSITION, DEFAULT_SOURCE, DEFAULT_STATUS, EVIDENCE_SOURCES, MAX_CASES, MAX_CASE_IMPORT_BYTES, MAX_CASE_STORE_BYTES, MAX_DOMAIN_LENGTH, 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, MAX_NOTES_PER_CASE, MAX_NOTE_LENGTH, MAX_TAGS_PER_CASE, MAX_TAG_LENGTH, } from "./case-record-contracts.js";
6
+ import { CONCLUSIVE_AVAILABILITY, DEFAULT_EVIDENCE_SOURCE, EVIDENCE_SOURCE_RANK, EVIDENCE_SOURCE_SET, REGISTERED_LIKE, hashString, isoOrNull, objectRecord, safeId, } from "./case-record-core.js";
7
+ function evidenceString(value, max = MAX_EVIDENCE_STRING_LENGTH) {
8
+ if (value == null)
9
+ return null;
10
+ const text = String(value).trim().slice(0, max);
11
+ return text || null;
12
+ }
13
+ function clampScore(value) {
14
+ return typeof value === 'number' && Number.isFinite(value)
15
+ ? Math.max(0, Math.min(100, Math.round(value)))
16
+ : null;
17
+ }
18
+ function boolOrNull(value) {
19
+ return typeof value === 'boolean' ? value : null;
20
+ }
21
+ // Accepts both the display factor shape ({ label, delta }) emitted by the
22
+ // scoring module and the stored snapshot shape ({ label, points }). Exact pairs
23
+ // are deduplicated and the result is sorted deterministically (largest
24
+ // contribution first, then label) so input order alone can never change a
25
+ // snapshot's fingerprint and two equal factor sets in different order collapse.
26
+ function normalizeFactors(value) {
27
+ if (!Array.isArray(value))
28
+ return [];
29
+ const seen = new Set();
30
+ const out = [];
31
+ for (const raw of value) {
32
+ if (!raw || typeof raw !== 'object')
33
+ continue;
34
+ const item = objectRecord(raw);
35
+ const label = evidenceString(item.label);
36
+ const source = item.points ?? item.delta;
37
+ const rounded = typeof source === 'number' && Number.isFinite(source) ? Math.round(source) : null;
38
+ if (label === null || rounded === null)
39
+ continue;
40
+ const points = rounded === 0 ? 0 : rounded; // collapse -0 to 0
41
+ const key = `${label}\u0000${points}`;
42
+ if (seen.has(key))
43
+ continue;
44
+ seen.add(key);
45
+ out.push({ label, points });
46
+ }
47
+ out.sort((a, b) => b.points - a.points || a.label.localeCompare(b.label));
48
+ return out.slice(0, MAX_EVIDENCE_FACTORS);
49
+ }
50
+ // Case-insensitive, terminal-dot-stripped, deduplicated and sorted so a
51
+ // nameserver set has one canonical form regardless of source casing/order.
52
+ function normalizeNameserverList(value) {
53
+ const values = Array.isArray(value)
54
+ ? value
55
+ : typeof value === 'string'
56
+ ? value.split(/[;\s]+/)
57
+ : [];
58
+ const seen = new Set();
59
+ for (const raw of values) {
60
+ const ns = String(raw == null ? '' : raw).trim().toLowerCase().replace(/\.$/, '').slice(0, MAX_EVIDENCE_STRING_LENGTH);
61
+ if (ns)
62
+ seen.add(ns);
63
+ }
64
+ return [...seen].sort().slice(0, MAX_EVIDENCE_NAMESERVERS);
65
+ }
66
+ function normalizeMutationList(value) {
67
+ if (!Array.isArray(value))
68
+ return [];
69
+ const seen = new Set();
70
+ for (const raw of value) {
71
+ const token = String(raw == null ? '' : raw).trim().slice(0, MAX_EVIDENCE_STRING_LENGTH);
72
+ if (token)
73
+ seen.add(token);
74
+ }
75
+ return [...seen].sort().slice(0, MAX_EVIDENCE_MUTATIONS);
76
+ }
77
+ function registrarKey(value) {
78
+ return String(value == null ? '' : value).toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
79
+ }
80
+ function dayOf(value) {
81
+ const iso = isoOrNull(value);
82
+ return iso ? iso.slice(0, 10) : null;
83
+ }
84
+ // Capture completeness is recorded explicitly, never inferred from whether a
85
+ // boolean happens to be false. 'fast' captures skip the DNS/site/HTML probes;
86
+ // 'deep' evaluates them; 'unknown' is for migrated/imported evidence whose depth
87
+ // we cannot trust.
88
+ const EVIDENCE_SCAN_DEPTHS = new Set(['fast', 'deep']);
89
+ function normalizeScanDepth(value) {
90
+ return typeof value === 'string' && EVIDENCE_SCAN_DEPTHS.has(value) ? value : 'unknown';
91
+ }
92
+ // Signals only a deep scan evaluates. On a 'fast' capture these are forced to
93
+ // null (an unevaluated field, not an observed `false`) so a later comparison
94
+ // cannot mistake "not scanned" for "signal removed".
95
+ const DEEP_SIGNAL_FIELDS = [
96
+ 'hasMx', 'hasSpf', 'hasDmarc', 'activityStatus', 'pageTitle', 'websiteProbeDetail',
97
+ 'httpSummaryVersion', 'httpEvidenceStatus', 'httpFinalOrigin', 'httpResponseStatus', 'httpTransportSecurity', 'httpRedirectCount',
98
+ 'httpCrossOriginRedirect', 'httpHttpsDowngrade', 'httpContentType', 'httpSecurityHeaders',
99
+ 'faviconMatch', 'faviconNearMatch', 'reusesOfficialAssets', 'hasPasswordField', 'hasExternalFormAction', 'phishingLanguageMatch',
100
+ ];
101
+ // Ordered list of the fields that make up a snapshot's *material* identity -
102
+ // everything except capture timestamps, snapshot id and source. `scanDepth` is
103
+ // included so captures of differing completeness can never be confused for one
104
+ // another. Deterministic ordering here is what makes the fingerprint stable.
105
+ const MATERIAL_FIELD_ORDER = [
106
+ 'scanDepth',
107
+ 'availability', 'confidence', 'riskModelVersion', 'riskScore', 'opportunityScore',
108
+ 'riskFactors', 'opportunityFactors',
109
+ 'registrar', 'createdDate', 'expiryDate', 'nameservers',
110
+ 'hasMx', 'hasSpf', 'hasDmarc',
111
+ 'activityStatus', 'websiteProbeDetail', 'pageTitle',
112
+ 'httpSummaryVersion', 'httpEvidenceStatus', 'httpFinalOrigin', 'httpResponseStatus', 'httpTransportSecurity', 'httpRedirectCount',
113
+ 'httpCrossOriginRedirect', 'httpHttpsDowngrade', 'httpContentType', 'httpSecurityHeaders',
114
+ 'faviconMatch', 'faviconNearMatch', 'reusesOfficialAssets', 'hasPasswordField', 'hasExternalFormAction', 'phishingLanguageMatch',
115
+ 'mutationTypes',
116
+ ];
117
+ // The canonical, comparison-safe value of a material field. Registrar casing,
118
+ // nameserver order, and sub-day timestamps are collapsed so they can never
119
+ // count as a "change"; a non-conclusive availability contributes nothing.
120
+ function materialValue(field, snapshot) {
121
+ switch (field) {
122
+ case 'availability':
123
+ return typeof snapshot.availability === 'string' && CONCLUSIVE_AVAILABILITY.has(snapshot.availability)
124
+ ? snapshot.availability
125
+ : null;
126
+ case 'registrar':
127
+ return registrarKey(snapshot.registrar) || null;
128
+ case 'createdDate':
129
+ return dayOf(snapshot.createdDate);
130
+ case 'expiryDate':
131
+ return dayOf(snapshot.expiryDate);
132
+ case 'nameservers':
133
+ return snapshot.nameservers;
134
+ case 'httpSecurityHeaders':
135
+ return snapshot.httpSecurityHeaders;
136
+ case 'mutationTypes':
137
+ return snapshot.mutationTypes;
138
+ case 'riskFactors':
139
+ return snapshot.riskFactors.map((factor) => [factor.label, factor.points]);
140
+ case 'opportunityFactors':
141
+ return snapshot.opportunityFactors.map((factor) => [factor.label, factor.points]);
142
+ default:
143
+ return snapshot[field] ?? null;
144
+ }
145
+ }
146
+ function isEmptyMaterial(value) {
147
+ if (value === null || value === undefined)
148
+ return true;
149
+ if (Array.isArray(value))
150
+ return value.length === 0;
151
+ if (typeof value === 'string')
152
+ return value.trim() === '';
153
+ return false; // finite numbers and booleans (incl. `false`) are material
154
+ }
155
+ // Fields that describe the capture rather than assert evidence, so they never
156
+ // on their own keep an otherwise-empty snapshot alive.
157
+ const NON_EVIDENCE_MATERIAL = new Set(['scanDepth', 'confidence']);
158
+ // A snapshot with no material evidence (only timestamps/source/depth, or only a
159
+ // bare confidence/unknown-availability) is dropped rather than added to a
160
+ // timeline.
161
+ function hasMaterialEvidence(snapshot) {
162
+ for (const field of MATERIAL_FIELD_ORDER) {
163
+ if (NON_EVIDENCE_MATERIAL.has(field))
164
+ continue;
165
+ if (!isEmptyMaterial(materialValue(field, snapshot)))
166
+ return true;
167
+ }
168
+ return false;
169
+ }
170
+ // Deterministic string form of the material identity, keys in fixed order.
171
+ function canonicalMaterialString(snapshot) {
172
+ const canonical = {};
173
+ for (const field of MATERIAL_FIELD_ORDER)
174
+ canonical[field] = materialValue(field, snapshot);
175
+ return JSON.stringify(canonical);
176
+ }
177
+ /**
178
+ * Normalizes one arbitrary value into a bounded evidence snapshot, or null when
179
+ * it carries no material evidence or cannot be placed in time.
180
+ *
181
+ * `capturedAt` comes from the value itself when valid, else `options.fallback`.
182
+ * Callers that represent a genuine "now" capture (Lookup/Bulk/local migration)
183
+ * pass a real fallback; the import path deliberately passes an older case
184
+ * timestamp (never "now") so malformed imported evidence can't appear newest.
185
+ * @param {unknown} raw
186
+ * @param {{ source?: string, fallback?: string | null }} [options]
187
+ * @returns {CaseEvidenceSnapshot | null}
188
+ */
189
+ export function normalizeSnapshot(raw, options = {}) {
190
+ const built = buildSnapshot(raw, options);
191
+ return built ? built.snapshot : null;
192
+ }
193
+ /** @returns {{ snapshot: CaseEvidenceSnapshot, material: string } | null} */
194
+ function buildSnapshot(raw, options) {
195
+ if (!raw || typeof raw !== 'object')
196
+ return null;
197
+ const record = objectRecord(raw);
198
+ const scanDepth = normalizeScanDepth(record.scanDepth);
199
+ const httpSummary = normalizeHttpSummary(record);
200
+ const fields = {
201
+ scanDepth,
202
+ availability: evidenceString(record.availability),
203
+ confidence: evidenceString(record.confidence),
204
+ riskModelVersion: normalizeRiskModelVersion(record.riskModelVersion),
205
+ riskScore: clampScore(record.riskScore),
206
+ opportunityScore: clampScore(record.opportunityScore),
207
+ riskFactors: normalizeFactors(record.riskFactors),
208
+ opportunityFactors: normalizeFactors(record.opportunityFactors),
209
+ registrar: evidenceString(record.registrar),
210
+ createdDate: isoOrNull(record.createdDate),
211
+ expiryDate: isoOrNull(record.expiryDate),
212
+ nameservers: normalizeNameserverList(record.nameservers),
213
+ hasMx: boolOrNull(record.hasMx),
214
+ hasSpf: boolOrNull(record.hasSpf),
215
+ hasDmarc: boolOrNull(record.hasDmarc),
216
+ activityStatus: evidenceString(record.activityStatus),
217
+ websiteProbeDetail: evidenceString(record.websiteProbeDetail, MAX_EVIDENCE_DETAIL_LENGTH),
218
+ pageTitle: evidenceString(record.pageTitle, MAX_EVIDENCE_TITLE_LENGTH),
219
+ httpSummaryVersion: httpSummary?.httpSummaryVersion ?? null,
220
+ httpEvidenceStatus: httpSummary?.httpEvidenceStatus ?? null,
221
+ httpFinalOrigin: httpSummary?.httpFinalOrigin ?? null,
222
+ httpResponseStatus: httpSummary?.httpResponseStatus ?? null,
223
+ httpTransportSecurity: httpSummary?.httpTransportSecurity ?? null,
224
+ httpRedirectCount: httpSummary?.httpRedirectCount ?? null,
225
+ httpCrossOriginRedirect: httpSummary?.httpCrossOriginRedirect ?? null,
226
+ httpHttpsDowngrade: httpSummary?.httpHttpsDowngrade ?? null,
227
+ httpContentType: httpSummary?.httpContentType ?? null,
228
+ httpSecurityHeaders: httpSummary?.httpSecurityHeaders ?? null,
229
+ faviconMatch: boolOrNull(record.faviconMatch),
230
+ faviconNearMatch: boolOrNull(record.faviconNearMatch),
231
+ reusesOfficialAssets: boolOrNull(record.reusesOfficialAssets),
232
+ hasPasswordField: boolOrNull(record.hasPasswordField),
233
+ hasExternalFormAction: boolOrNull(record.hasExternalFormAction),
234
+ phishingLanguageMatch: evidenceString(record.phishingLanguageMatch),
235
+ mutationTypes: normalizeMutationList(record.mutationTypes),
236
+ };
237
+ // A version without an actual risk assessment is orphaned metadata. Drop it
238
+ // so it cannot make otherwise-identical evidence look materially different.
239
+ if (fields.riskScore === null && fields.riskFactors.length === 0)
240
+ fields.riskModelVersion = null;
241
+ // A fast capture never evaluates the deep signals, so any value supplied for
242
+ // them (e.g. a profile's default `false`) is discarded as unevaluated.
243
+ if (scanDepth === 'fast') {
244
+ Object.assign(fields, {
245
+ hasMx: null,
246
+ hasSpf: null,
247
+ hasDmarc: null,
248
+ activityStatus: null,
249
+ websiteProbeDetail: null,
250
+ pageTitle: null,
251
+ httpSummaryVersion: null,
252
+ httpEvidenceStatus: null,
253
+ httpFinalOrigin: null,
254
+ httpResponseStatus: null,
255
+ httpTransportSecurity: null,
256
+ httpRedirectCount: null,
257
+ httpCrossOriginRedirect: null,
258
+ httpHttpsDowngrade: null,
259
+ httpContentType: null,
260
+ httpSecurityHeaders: null,
261
+ faviconMatch: null,
262
+ faviconNearMatch: null,
263
+ reusesOfficialAssets: null,
264
+ hasPasswordField: null,
265
+ hasExternalFormAction: null,
266
+ phishingLanguageMatch: null,
267
+ });
268
+ }
269
+ if (!hasMaterialEvidence(fields))
270
+ return null;
271
+ const capturedAt = isoOrNull(record.capturedAt) || options.fallback || null;
272
+ if (!capturedAt)
273
+ return null; // an evidence entry with no placeable time is skipped
274
+ let firstCapturedAt = isoOrNull(record.firstCapturedAt) || capturedAt;
275
+ if (Date.parse(firstCapturedAt) > Date.parse(capturedAt))
276
+ firstCapturedAt = capturedAt;
277
+ const source = typeof record.source === 'string' && EVIDENCE_SOURCE_SET.has(record.source)
278
+ ? record.source
279
+ : typeof options.source === 'string' && EVIDENCE_SOURCE_SET.has(options.source)
280
+ ? options.source
281
+ : DEFAULT_EVIDENCE_SOURCE;
282
+ const material = canonicalMaterialString(fields);
283
+ const fingerprint = hashString(material);
284
+ const snapshot = {
285
+ id: `ev-${fingerprint}`,
286
+ fingerprint,
287
+ firstCapturedAt,
288
+ capturedAt,
289
+ source,
290
+ ...fields,
291
+ };
292
+ return { snapshot, material };
293
+ }
294
+ function sourceRank(source) {
295
+ return EVIDENCE_SOURCE_RANK[source] ?? 0;
296
+ }
297
+ // Deterministic winner between two sources for the same material evidence.
298
+ // Higher rank wins; on a rank tie (e.g. lookup vs bulk) the source tied to the
299
+ // later observation wins; if those also tie, the lexically-smaller source is
300
+ // chosen so the result never depends on input order.
301
+ function chooseSource(kept, incoming) {
302
+ const rankKept = sourceRank(kept.source);
303
+ const rankIncoming = sourceRank(incoming.source);
304
+ if (rankIncoming !== rankKept)
305
+ return rankIncoming > rankKept ? incoming.source : kept.source;
306
+ const timeKept = Date.parse(kept.capturedAt);
307
+ const timeIncoming = Date.parse(incoming.capturedAt);
308
+ if (timeIncoming !== timeKept)
309
+ return timeIncoming > timeKept ? incoming.source : kept.source;
310
+ return kept.source <= incoming.source ? kept.source : incoming.source;
311
+ }
312
+ // Two materially identical captures collapse into one timeline entry: earliest
313
+ // first-seen, latest observed time, and a deterministically-chosen source.
314
+ function mergeDuplicateSnapshots(kept, incoming) {
315
+ const firstCapturedAt = Date.parse(incoming.firstCapturedAt) < Date.parse(kept.firstCapturedAt)
316
+ ? incoming.firstCapturedAt
317
+ : kept.firstCapturedAt;
318
+ const capturedAt = Date.parse(incoming.capturedAt) > Date.parse(kept.capturedAt)
319
+ ? incoming.capturedAt
320
+ : kept.capturedAt;
321
+ const source = chooseSource(kept, incoming);
322
+ return { ...kept, firstCapturedAt, capturedAt, source };
323
+ }
324
+ function compareSnapshotChrono(a, b) {
325
+ return (Date.parse(a.capturedAt) - Date.parse(b.capturedAt) ||
326
+ Date.parse(a.firstCapturedAt) - Date.parse(b.firstCapturedAt) ||
327
+ a.fingerprint.localeCompare(b.fingerprint));
328
+ }
329
+ /**
330
+ * Normalizes a list of arbitrary values into a bounded, chronological,
331
+ * material-deduplicated evidence history. Identical material collapses to one
332
+ * entry (regardless of differing ids or timestamps); the newest distinct
333
+ * snapshots are retained up to the per-case bound; ids are made unique within
334
+ * the case.
335
+ * @param {unknown} rawList
336
+ * @param {{ source?: string, fallback?: string | null }} [options]
337
+ * @returns {CaseEvidenceSnapshot[]}
338
+ */
339
+ export function normalizeEvidenceHistory(rawList, options = {}) {
340
+ const list = Array.isArray(rawList) ? rawList : [];
341
+ const byMaterial = new Map();
342
+ for (const raw of list) {
343
+ const built = buildSnapshot(raw, options);
344
+ if (!built)
345
+ continue;
346
+ const existing = byMaterial.get(built.material);
347
+ // Verify full material equality, not just the short fingerprint, so a hash
348
+ // collision can never merge two genuinely different snapshots.
349
+ byMaterial.set(built.material, existing ? mergeDuplicateSnapshots(existing, built.snapshot) : built.snapshot);
350
+ }
351
+ const ordered = [...byMaterial.values()].sort(compareSnapshotChrono);
352
+ const kept = ordered.slice(Math.max(0, ordered.length - MAX_EVIDENCE_SNAPSHOTS_PER_CASE));
353
+ return assignUniqueSnapshotIds(kept);
354
+ }
355
+ function assignUniqueSnapshotIds(snapshots) {
356
+ const used = new Set();
357
+ return snapshots.map((snapshot) => {
358
+ let id = safeId(snapshot.id) || `ev-${snapshot.fingerprint}`;
359
+ if (used.has(id)) {
360
+ const base = id;
361
+ let suffix = 2;
362
+ while (used.has(id))
363
+ id = `${base}-${suffix++}`;
364
+ }
365
+ used.add(id);
366
+ return snapshot.id === id ? snapshot : { ...snapshot, id };
367
+ });
368
+ }
369
+ /**
370
+ * The most recent snapshot, or null. Lets UI render "the latest evidence"
371
+ * without knowing the history is a bounded, deduplicated timeline.
372
+ * @param {{ evidenceHistory?: CaseEvidenceSnapshot[] } | null | undefined} record
373
+ * @returns {CaseEvidenceSnapshot | null}
374
+ */
375
+ export function latestCaseEvidence(record) {
376
+ const history = record && Array.isArray(record.evidenceHistory) ? record.evidenceHistory : [];
377
+ return history.at(-1) ?? null;
378
+ }
379
+ // ---------------------------------------------------------------------------
380
+ // Material-change comparison (pure; no Svelte, DOM, or persistence access)
381
+ // ---------------------------------------------------------------------------
382
+ // `depthGate` decides when a field may be compared:
383
+ // 'both-deep' - only when both snapshots are explicitly deep (a shallower
384
+ // capture can neither add nor remove a deep-only signal).
385
+ // 'comparable' - only when the two depths are equal and meaningful
386
+ // (fast->fast or deep->deep), so a risk delta caused solely by
387
+ // a mode change is never reported.
388
+ // (absent) - always comparable (data available in every capture).
389
+ const COMPARE_FIELDS = [
390
+ { field: 'availability', label: 'Availability', type: 'availability' },
391
+ { field: 'confidence', label: 'Confidence', type: 'token' },
392
+ { field: 'riskScore', label: 'Risk score', type: 'score', depthGate: 'comparable', modelGate: 'risk', direction: 'risk' },
393
+ { field: 'riskFactors', label: 'Risk factors', type: 'factors', depthGate: 'comparable', modelGate: 'risk' },
394
+ { field: 'opportunityScore', label: 'Opportunity score', type: 'score' },
395
+ { field: 'opportunityFactors', label: 'Opportunity factors', type: 'factors' },
396
+ { field: 'registrar', label: 'Registrar', type: 'registrar' },
397
+ { field: 'createdDate', label: 'Creation date', type: 'date' },
398
+ { field: 'expiryDate', label: 'Expiry date', type: 'date' },
399
+ { field: 'nameservers', label: 'Nameservers', type: 'set', emptyGuard: true },
400
+ { field: 'hasMx', label: 'MX', type: 'bool', depthGate: 'both-deep' },
401
+ { field: 'hasSpf', label: 'SPF', type: 'bool', depthGate: 'both-deep' },
402
+ { field: 'hasDmarc', label: 'DMARC', type: 'bool', depthGate: 'both-deep' },
403
+ { field: 'activityStatus', label: 'Website activity', type: 'token', depthGate: 'both-deep' },
404
+ { field: 'websiteProbeDetail', label: 'Website check detail', type: 'text', depthGate: 'both-deep' },
405
+ { field: 'pageTitle', label: 'Page title', type: 'text', depthGate: 'both-deep' },
406
+ { field: 'httpEvidenceStatus', label: 'HTTP evidence status', type: 'token', depthGate: 'both-deep' },
407
+ { field: 'httpFinalOrigin', label: 'Final website origin', type: 'text', depthGate: 'both-deep' },
408
+ { field: 'httpResponseStatus', label: 'HTTP response status', type: 'number', depthGate: 'both-deep' },
409
+ { field: 'httpTransportSecurity', label: 'Website transport', type: 'http-transport', depthGate: 'both-deep' },
410
+ { field: 'httpRedirectCount', label: 'HTTP redirect count', type: 'number', depthGate: 'both-deep' },
411
+ { field: 'httpCrossOriginRedirect', label: 'Cross-origin redirect', type: 'http-signal', depthGate: 'both-deep' },
412
+ { field: 'httpHttpsDowngrade', label: 'HTTPS downgrade', type: 'signal', depthGate: 'both-deep' },
413
+ { field: 'httpContentType', label: 'Website content type', type: 'token', depthGate: 'both-deep' },
414
+ { field: 'httpSecurityHeaders', label: 'Observed security headers', type: 'set', depthGate: 'both-deep' },
415
+ { field: 'faviconMatch', label: 'Official favicon match', type: 'signal', depthGate: 'both-deep' },
416
+ { field: 'faviconNearMatch', label: 'Official favicon near-match', type: 'signal', depthGate: 'both-deep' },
417
+ { field: 'reusesOfficialAssets', label: 'Official asset reuse', type: 'signal', depthGate: 'both-deep' },
418
+ { field: 'hasPasswordField', label: 'Password form', type: 'signal', depthGate: 'both-deep' },
419
+ { field: 'hasExternalFormAction', label: 'External form action', type: 'signal', depthGate: 'both-deep' },
420
+ { field: 'phishingLanguageMatch', label: 'Phishing language', type: 'phishing', depthGate: 'both-deep' },
421
+ { field: 'mutationTypes', label: 'Mutation types', type: 'set' },
422
+ ];
423
+ function depthComparable(a, b) {
424
+ return a === b && (a === 'fast' || a === 'deep');
425
+ }
426
+ function riskModelComparable(previous, current) {
427
+ const before = normalizeRiskModelVersion(previous?.riskModelVersion);
428
+ const after = normalizeRiskModelVersion(current?.riskModelVersion);
429
+ return before !== null && before === after;
430
+ }
431
+ function valuesMateriallyEqual(field, previous, current) {
432
+ return JSON.stringify(materialValue(field, previous)) === JSON.stringify(materialValue(field, current));
433
+ }
434
+ /**
435
+ * Explains material fields that comparison gates deliberately suppress. The
436
+ * reasons are stable machine values for UI/report wording; they are not risk
437
+ * findings. A model-version mismatch remains visible even when another field
438
+ * in the same observation produced an ordinary material change.
439
+ * @param {CaseEvidenceSnapshot | null | undefined} previous
440
+ * @param {CaseEvidenceSnapshot | null | undefined} current
441
+ * @returns {Array<'scan-depth' | 'risk-model'>}
442
+ */
443
+ export function caseEvidenceIncomparableReasons(previous, current) {
444
+ if (!previous || !current || previous.fingerprint === current.fingerprint)
445
+ return [];
446
+ const reasons = [];
447
+ const hasRiskEvidence = previous.riskScore !== null || current.riskScore !== null
448
+ || previous.riskFactors.length > 0 || current.riskFactors.length > 0;
449
+ if (hasRiskEvidence && !riskModelComparable(previous, current))
450
+ reasons.push('risk-model');
451
+ if (!depthComparable(previous.scanDepth, current.scanDepth)) {
452
+ const deepOnlyChanged = DEEP_SIGNAL_FIELDS.some((field) => !valuesMateriallyEqual(field, previous, current));
453
+ const comparableRiskChanged = riskModelComparable(previous, current)
454
+ && (!valuesMateriallyEqual('riskScore', previous, current) || !valuesMateriallyEqual('riskFactors', previous, current));
455
+ if (deepOnlyChanged || comparableRiskChanged)
456
+ reasons.push('scan-depth');
457
+ }
458
+ return reasons;
459
+ }
460
+ function isPresent(value) {
461
+ if (value === null || value === undefined)
462
+ return false;
463
+ if (typeof value === 'string')
464
+ return value.trim() !== '';
465
+ if (Array.isArray(value))
466
+ return value.length > 0;
467
+ return true;
468
+ }
469
+ function setsEqual(a, b) {
470
+ return JSON.stringify(a) === JSON.stringify(b);
471
+ }
472
+ function compareField(spec, before, after) {
473
+ switch (spec.type) {
474
+ case 'score': {
475
+ const b = clampScore(before);
476
+ const a = clampScore(after);
477
+ if (b === a)
478
+ return null;
479
+ let tone = 'neutral';
480
+ if (spec.direction === 'risk') {
481
+ if (b !== null && a !== null)
482
+ tone = a > b ? (a >= 70 ? 'danger' : 'warn') : 'good';
483
+ else if (a !== null && b === null)
484
+ tone = a >= 70 ? 'danger' : 'warn';
485
+ }
486
+ return { before: b, after: a, tone };
487
+ }
488
+ case 'availability': {
489
+ // Only compare two conclusive states; unknown/error can't prove a change.
490
+ if (typeof before !== 'string' || typeof after !== 'string'
491
+ || !CONCLUSIVE_AVAILABILITY.has(before) || !CONCLUSIVE_AVAILABILITY.has(after))
492
+ return null;
493
+ if (before === after)
494
+ return null;
495
+ let tone = 'warn';
496
+ if (before === 'available' && REGISTERED_LIKE.has(after))
497
+ tone = 'danger';
498
+ else if (REGISTERED_LIKE.has(before) && after === 'available')
499
+ tone = 'good';
500
+ return { before, after, tone };
501
+ }
502
+ case 'registrar': {
503
+ if (registrarKey(before) === registrarKey(after))
504
+ return null;
505
+ if (!isPresent(before) && !isPresent(after))
506
+ return null;
507
+ return { before: before ?? null, after: after ?? null, tone: 'warn' };
508
+ }
509
+ case 'date': {
510
+ if (dayOf(before) === dayOf(after))
511
+ return null;
512
+ if (!isPresent(before) && !isPresent(after))
513
+ return null;
514
+ return { before: before ?? null, after: after ?? null, tone: 'warn' };
515
+ }
516
+ case 'set': {
517
+ const normalizeSet = spec.field === 'nameservers'
518
+ ? normalizeNameserverList
519
+ : spec.field === 'httpSecurityHeaders'
520
+ ? (value) => Array.isArray(value) ? [...value].sort() : []
521
+ : normalizeMutationList;
522
+ const b = normalizeSet(before);
523
+ const a = normalizeSet(after);
524
+ if (setsEqual(b, a))
525
+ return null;
526
+ // An emptied set for a field we can't always observe isn't a removal.
527
+ if (spec.emptyGuard && a.length === 0)
528
+ return null;
529
+ return { before: b, after: a, tone: 'warn' };
530
+ }
531
+ case 'bool': {
532
+ if (before === null || before === undefined || after === null || after === undefined)
533
+ return null;
534
+ if (before === after)
535
+ return null;
536
+ const tone = spec.field === 'hasMx' && before === false && after === true ? 'warn' : 'neutral';
537
+ return { before, after, tone };
538
+ }
539
+ case 'number': {
540
+ const b = Number.isInteger(before) ? before : null;
541
+ const a = Number.isInteger(after) ? after : null;
542
+ if (b === a || b === null || a === null)
543
+ return null;
544
+ return { before: b, after: a, tone: 'neutral' };
545
+ }
546
+ case 'http-transport': {
547
+ if (!isPresent(before) || !isPresent(after) || before === after)
548
+ return null;
549
+ return { before, after, tone: after === 'http' ? 'danger' : after === 'https' ? 'good' : 'neutral' };
550
+ }
551
+ case 'http-signal': {
552
+ if (typeof before !== 'boolean' || typeof after !== 'boolean' || before === after)
553
+ return null;
554
+ return { before, after, tone: after ? 'warn' : 'good' };
555
+ }
556
+ case 'signal': {
557
+ if (before === null || before === undefined || after === null || after === undefined)
558
+ return null;
559
+ if (before === after)
560
+ return null;
561
+ const tone = before === false && after === true ? 'danger' : before === true && after === false ? 'good' : 'neutral';
562
+ return { before, after, tone };
563
+ }
564
+ case 'phishing': {
565
+ const b = isPresent(before);
566
+ const a = isPresent(after);
567
+ if (!b && !a)
568
+ return null;
569
+ if ((before ?? null) === (after ?? null))
570
+ return null;
571
+ const tone = !b && a ? 'danger' : b && !a ? 'good' : 'warn';
572
+ return { before: before ?? null, after: after ?? null, tone };
573
+ }
574
+ case 'token': {
575
+ const b = before ?? null;
576
+ const a = after ?? null;
577
+ if (b === a)
578
+ return null;
579
+ if (!isPresent(b) && !isPresent(a))
580
+ return null;
581
+ const tone = spec.field === 'activityStatus' && a === 'active' ? 'warn' : 'neutral';
582
+ return { before: b, after: a, tone };
583
+ }
584
+ case 'text': {
585
+ const b = before ?? null;
586
+ const a = after ?? null;
587
+ if ((b || '') === (a || ''))
588
+ return null;
589
+ if (!isPresent(b) && !isPresent(a))
590
+ return null;
591
+ return { before: b, after: a, tone: 'neutral' };
592
+ }
593
+ case 'factors': {
594
+ // Factors are already normalized (deduped + deterministically sorted), so
595
+ // a set comparison ignores input order and reports a genuine change in the
596
+ // score's composition even when the total is unchanged.
597
+ const b = Array.isArray(before) ? before : [];
598
+ const a = Array.isArray(after) ? after : [];
599
+ if (setsEqual(b, a))
600
+ return null;
601
+ return { before: b, after: a, tone: 'neutral' };
602
+ }
603
+ default:
604
+ return null;
605
+ }
606
+ }
607
+ /**
608
+ * Diffs two normalized snapshots into a bounded, stably-ordered list of
609
+ * material changes. Timestamps, source, id and fingerprint are ignored;
610
+ * nameservers/mutations/factors compare as sets; casing/order-only differences
611
+ * never produce a change. Capture depth is honoured explicitly: deep-only
612
+ * signals are only compared when both snapshots are deep. Risk-score and
613
+ * factor changes additionally require matching explicit model versions and
614
+ * equal meaningful depths, so formula upgrades and scan-mode differences are
615
+ * never reported as changes in the observed domain.
616
+ * @param {CaseEvidenceSnapshot | null | undefined} previous
617
+ * @param {CaseEvidenceSnapshot | null | undefined} current
618
+ * @returns {Array<{ field: string, label: string, before: unknown, after: unknown, tone: string }>}
619
+ */
620
+ export function compareCaseEvidence(previous, current) {
621
+ if (!previous || !current)
622
+ return [];
623
+ const bothDeep = previous.scanDepth === 'deep' && current.scanDepth === 'deep';
624
+ const comparableDepth = depthComparable(previous.scanDepth, current.scanDepth);
625
+ const comparableRiskModel = riskModelComparable(previous, current);
626
+ const changes = [];
627
+ for (const spec of COMPARE_FIELDS) {
628
+ if (spec.depthGate === 'both-deep' && !bothDeep)
629
+ continue;
630
+ if (spec.depthGate === 'comparable' && !comparableDepth)
631
+ continue;
632
+ if (spec.modelGate === 'risk' && !comparableRiskModel)
633
+ continue;
634
+ const result = compareField(spec, previous[spec.field], current[spec.field]);
635
+ if (result) {
636
+ changes.push({ field: spec.field, label: spec.label, before: result.before, after: result.after, tone: result.tone });
637
+ if (changes.length >= MAX_EVIDENCE_CHANGES)
638
+ break;
639
+ }
640
+ }
641
+ return changes;
642
+ }