@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,643 @@
1
+ // Pure watchlist history/diff logic. The latest full bulk records remain in
2
+ // the watchlist entry for rescanning, while history stores compact changes
3
+ // against a last-known baseline so browser-local storage does not grow by one complete
4
+ // result set on every check.
5
+ import { explainRiskScore, normalizeRiskModelVersion } from "./scoring.js";
6
+ import { HTTP_SECURITY_HEADER_TOKENS, normalizeHttpSummary } from "./http-summary.js";
7
+ import { normalizeDomain } from "./case-model.js";
8
+ export const MAX_WATCHLIST_HISTORY_EVENTS = 12;
9
+ export const MAX_WATCHLIST_CHANGES_PER_EVENT = 500;
10
+ export const MAX_WATCHLIST_DOMAINS = 2000;
11
+ export const MAX_WATCHLIST_INPUT_RECORDS = MAX_WATCHLIST_DOMAINS * 2;
12
+ export const MAX_WATCHLIST_NAMESERVERS = 12;
13
+ export const MAX_WATCHLIST_MUTATION_TYPES = 30;
14
+ export const MAX_WATCHLIST_HISTORY_DOMAIN_OPTIONS = MAX_WATCHLIST_DOMAINS;
15
+ const MAX_TEXT_LENGTH = 300;
16
+ const MAX_TITLE_LENGTH = 200;
17
+ const MAX_MUTATION_TYPE_LENGTH = 60;
18
+ const MAX_TIMESTAMP_LENGTH = 64;
19
+ const CONTROL_RE = /[\x00-\x1f\x7f]/;
20
+ const CONCLUSIVE_AVAILABILITY = new Set(['available', 'registered', 'for_sale', 'expiring']);
21
+ const DEEP_FIELDS = new Set([
22
+ 'hasMx',
23
+ 'hasNullMx',
24
+ 'hasSpf',
25
+ 'hasDmarc',
26
+ 'activityStatus',
27
+ 'pageTitle',
28
+ 'httpSummaryVersion',
29
+ 'httpEvidenceStatus',
30
+ 'httpFinalOrigin',
31
+ 'httpResponseStatus',
32
+ 'httpTransportSecurity',
33
+ 'httpRedirectCount',
34
+ 'httpCrossOriginRedirect',
35
+ 'httpHttpsDowngrade',
36
+ 'httpContentType',
37
+ 'httpSecurityHeaders',
38
+ 'faviconHash',
39
+ 'faviconMatch',
40
+ 'faviconNearMatch',
41
+ 'hasPasswordField',
42
+ 'phishingLanguageMatch',
43
+ 'reusesOfficialAssets',
44
+ 'riskModelVersion',
45
+ 'riskScore',
46
+ ]);
47
+ const FIELD_LABELS = {
48
+ availability: 'Availability',
49
+ registrarName: 'Registrar',
50
+ nameservers: 'Nameservers',
51
+ createdDate: 'Creation date',
52
+ expiryDate: 'Expiry date',
53
+ privacyProtected: 'WHOIS privacy',
54
+ hasNullMx: 'Null MX',
55
+ hasMx: 'MX',
56
+ hasSpf: 'SPF',
57
+ hasDmarc: 'DMARC',
58
+ activityStatus: 'Website activity',
59
+ pageTitle: 'Page title',
60
+ httpEvidenceStatus: 'HTTP evidence status',
61
+ httpFinalOrigin: 'Final website origin',
62
+ httpResponseStatus: 'HTTP response status',
63
+ httpTransportSecurity: 'Website transport',
64
+ httpRedirectCount: 'HTTP redirect count',
65
+ httpCrossOriginRedirect: 'Cross-origin redirect',
66
+ httpHttpsDowngrade: 'HTTPS downgrade',
67
+ httpContentType: 'Website content type',
68
+ httpSecurityHeaders: 'Observed security headers',
69
+ faviconHash: 'Favicon',
70
+ faviconMatch: 'Official favicon match',
71
+ faviconNearMatch: 'Official favicon near-match',
72
+ hasPasswordField: 'Password form',
73
+ phishingLanguageMatch: 'Phishing language',
74
+ reusesOfficialAssets: 'Official asset reuse',
75
+ riskScore: 'Risk score',
76
+ };
77
+ const HISTORY_CATEGORY_FIELDS = {
78
+ registration: new Set(['availability', 'registrarName', 'createdDate', 'expiryDate', 'privacyProtected']),
79
+ delegation: new Set(['nameservers']),
80
+ mail: new Set(['hasMx', 'hasSpf', 'hasDmarc']),
81
+ web: new Set([
82
+ 'activityStatus',
83
+ 'httpEvidenceStatus',
84
+ 'httpFinalOrigin',
85
+ 'httpResponseStatus',
86
+ 'httpTransportSecurity',
87
+ 'httpRedirectCount',
88
+ 'httpCrossOriginRedirect',
89
+ 'httpHttpsDowngrade',
90
+ 'httpContentType',
91
+ 'httpSecurityHeaders',
92
+ ]),
93
+ identity: new Set([
94
+ 'pageTitle',
95
+ 'faviconHash',
96
+ 'faviconMatch',
97
+ 'faviconNearMatch',
98
+ 'hasPasswordField',
99
+ 'phishingLanguageMatch',
100
+ 'reusesOfficialAssets',
101
+ ]),
102
+ risk: new Set(['riskScore']),
103
+ };
104
+ export const WATCHLIST_HISTORY_CATEGORIES = Object.freeze([
105
+ Object.freeze({ key: 'registration', label: 'Registration' }),
106
+ Object.freeze({ key: 'delegation', label: 'Delegation' }),
107
+ Object.freeze({ key: 'mail', label: 'Mail' }),
108
+ Object.freeze({ key: 'web', label: 'Web' }),
109
+ Object.freeze({ key: 'identity', label: 'Identity' }),
110
+ Object.freeze({ key: 'risk', label: 'Risk' }),
111
+ ]);
112
+ const MAX_CHANGE_COUNT = MAX_WATCHLIST_DOMAINS * Object.keys(FIELD_LABELS).length;
113
+ // The schema version must travel with retained summaries so every storage
114
+ // boundary can revalidate them, but it is implementation metadata rather than
115
+ // an analyst-facing observation and must never generate a history event.
116
+ const BASELINE_FIELDS = ['httpSummaryVersion', 'riskModelVersion', ...Object.keys(FIELD_LABELS)];
117
+ const AVAILABILITY_LABELS = {
118
+ available: 'Available',
119
+ registered: 'Registered',
120
+ for_sale: 'For sale',
121
+ expiring: 'Expiring',
122
+ unknown: 'Unknown',
123
+ error: 'Lookup failed',
124
+ };
125
+ const ACTIVITY_LABELS = {
126
+ active: 'Active site',
127
+ parked: 'Parked',
128
+ unreachable: 'Website check inconclusive',
129
+ no_site: 'No site reported (legacy)',
130
+ };
131
+ const AVAILABILITY_VALUES = new Set(Object.keys(AVAILABILITY_LABELS));
132
+ const ACTIVITY_VALUES = new Set(Object.keys(ACTIVITY_LABELS));
133
+ const CHANGE_KINDS = new Set(['new_registration', 'released', 'availability_changed', 'risk_signal_added', 'mail_activated', 'high_risk', 'infrastructure_changed', 'field_changed']);
134
+ const HTTP_HEADER_VALUES = new Set(HTTP_SECURITY_HEADER_TOKENS);
135
+ function hasString(set, value) {
136
+ return typeof value === 'string' && set.has(value);
137
+ }
138
+ function plainRecord(value) {
139
+ return value && typeof value === 'object' && !Array.isArray(value)
140
+ ? value
141
+ : null;
142
+ }
143
+ function boundedText(value, maximum = MAX_TEXT_LENGTH, allowNull = true) {
144
+ if (value == null && allowNull)
145
+ return null;
146
+ if (typeof value !== 'string' || CONTROL_RE.test(value))
147
+ return allowNull ? null : '';
148
+ const normalized = value.slice(0, maximum * 4).replace(/\s+/g, ' ').trim().slice(0, maximum).trim();
149
+ return normalized || (allowNull ? null : '');
150
+ }
151
+ function isoTimestamp(value, fallback = new Date(0).toISOString()) {
152
+ if (typeof value !== 'string' || value.length > MAX_TIMESTAMP_LENGTH || CONTROL_RE.test(value))
153
+ return fallback;
154
+ const parsed = Date.parse(value);
155
+ return Number.isFinite(parsed) ? new Date(parsed).toISOString() : fallback;
156
+ }
157
+ function boundedInteger(value, maximum, fallback = 0) {
158
+ return Number.isSafeInteger(value) && Number(value) >= 0 && Number(value) <= maximum
159
+ ? Number(value)
160
+ : fallback;
161
+ }
162
+ function normalizeDateValue(value) {
163
+ if (typeof value !== 'string' || value.length > MAX_TIMESTAMP_LENGTH || CONTROL_RE.test(value))
164
+ return null;
165
+ const parsed = Date.parse(value);
166
+ return Number.isFinite(parsed) ? new Date(parsed).toISOString().slice(0, 10) : null;
167
+ }
168
+ function inferredScanDepth(record) {
169
+ if (record.scanDepth === 'deep' || record.scanDepth === 'fast')
170
+ return record.scanDepth;
171
+ return ['hasMx', 'hasSpf', 'hasDmarc', 'activityStatus', 'faviconHash', 'pageTitle', 'httpResponseStatus']
172
+ .some((key) => record[key] !== null && record[key] !== undefined)
173
+ ? 'deep'
174
+ : 'fast';
175
+ }
176
+ function normalizeNameservers(value) {
177
+ const values = Array.isArray(value) ? value : String(value || '').slice(0, 4096).split(';');
178
+ const normalized = new Set();
179
+ for (const item of values.slice(0, MAX_WATCHLIST_NAMESERVERS * 4)) {
180
+ const nameserver = normalizeDomain(item);
181
+ if (nameserver)
182
+ normalized.add(nameserver);
183
+ if (normalized.size >= MAX_WATCHLIST_NAMESERVERS)
184
+ break;
185
+ }
186
+ return [...normalized].sort();
187
+ }
188
+ function normalizeRegistrar(value) {
189
+ return String(value || '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
190
+ }
191
+ function normalizeDate(value) {
192
+ return normalizeDateValue(value);
193
+ }
194
+ function normalizeMutationTypes(value) {
195
+ if (!Array.isArray(value))
196
+ return [];
197
+ const types = new Set();
198
+ for (const item of value.slice(0, MAX_WATCHLIST_MUTATION_TYPES * 4)) {
199
+ const type = (boundedText(item, MAX_MUTATION_TYPE_LENGTH, false) || '').toLowerCase();
200
+ if (type)
201
+ types.add(type);
202
+ if (types.size >= MAX_WATCHLIST_MUTATION_TYPES)
203
+ break;
204
+ }
205
+ return [...types].sort();
206
+ }
207
+ function compactRecord(value) {
208
+ const record = plainRecord(value);
209
+ if (!record)
210
+ return null;
211
+ const domain = normalizeDomain(record.domain);
212
+ if (!domain)
213
+ return null;
214
+ const scanDepth = inferredScanDepth(record);
215
+ const httpSummary = normalizeHttpSummary(record) || {};
216
+ const providedRiskScore = typeof record.riskScore === 'number' && Number.isFinite(record.riskScore)
217
+ ? Math.max(0, Math.min(100, Math.round(record.riskScore)))
218
+ : null;
219
+ const computedRisk = scanDepth === 'deep' && providedRiskScore === null ? explainRiskScore(record) : null;
220
+ const riskScore = scanDepth === 'deep' ? (providedRiskScore ?? computedRisk?.score ?? null) : null;
221
+ const riskModelVersion = riskScore === null
222
+ ? null
223
+ : providedRiskScore !== null
224
+ ? normalizeRiskModelVersion(record.riskModelVersion)
225
+ : computedRisk?.modelVersion ?? null;
226
+ return {
227
+ domain,
228
+ scanDepth,
229
+ availability: hasString(AVAILABILITY_VALUES, record.availability) ? record.availability : null,
230
+ registrarName: boundedText(record.registrarName),
231
+ nameservers: normalizeNameservers(record.nameservers),
232
+ createdDate: normalizeDateValue(record.createdDate),
233
+ expiryDate: normalizeDateValue(record.expiryDate),
234
+ privacyProtected: typeof record.privacyProtected === 'boolean' ? record.privacyProtected : null,
235
+ hasMx: typeof record.hasMx === 'boolean' ? record.hasMx : null,
236
+ hasSpf: typeof record.hasSpf === 'boolean' ? record.hasSpf : null,
237
+ hasDmarc: typeof record.hasDmarc === 'boolean' ? record.hasDmarc : null,
238
+ activityStatus: hasString(ACTIVITY_VALUES, record.activityStatus) ? record.activityStatus : null,
239
+ pageTitle: boundedText(record.pageTitle, MAX_TITLE_LENGTH),
240
+ ...httpSummary,
241
+ faviconHash: boundedText(record.faviconHash, 128),
242
+ faviconMatch: typeof record.faviconMatch === 'boolean' ? record.faviconMatch : null,
243
+ faviconNearMatch: typeof record.faviconNearMatch === 'boolean' ? record.faviconNearMatch : null,
244
+ hasPasswordField: typeof record.hasPasswordField === 'boolean' ? record.hasPasswordField : null,
245
+ phishingLanguageMatch: boundedText(record.phishingLanguageMatch, MAX_TITLE_LENGTH),
246
+ reusesOfficialAssets: typeof record.reusesOfficialAssets === 'boolean' ? record.reusesOfficialAssets : null,
247
+ riskModelVersion,
248
+ riskScore,
249
+ mutationTypes: normalizeMutationTypes(record.mutationTypes),
250
+ };
251
+ }
252
+ /** @param {object[]} results */
253
+ export function compactWatchlistResults(results) {
254
+ if (!Array.isArray(results))
255
+ return [];
256
+ const byDomain = new Map();
257
+ for (const item of results.slice(0, MAX_WATCHLIST_INPUT_RECORDS)) {
258
+ const record = compactRecord(item);
259
+ if (!record || byDomain.has(record.domain))
260
+ continue;
261
+ byDomain.set(record.domain, record);
262
+ if (byDomain.size >= MAX_WATCHLIST_DOMAINS)
263
+ break;
264
+ }
265
+ return [...byDomain.values()];
266
+ }
267
+ function isComparable(field, value, record) {
268
+ if (DEEP_FIELDS.has(field) && record.scanDepth !== 'deep')
269
+ return false;
270
+ if (field === 'availability')
271
+ return hasString(CONCLUSIVE_AVAILABILITY, value);
272
+ if (field === 'nameservers')
273
+ return Array.isArray(value) && value.length > 0;
274
+ if (field === 'httpSecurityHeaders')
275
+ return Array.isArray(value);
276
+ if (['httpSummaryVersion', 'riskModelVersion', 'httpResponseStatus', 'httpRedirectCount'].includes(field))
277
+ return Number.isInteger(value);
278
+ if (['registrarName', 'createdDate', 'expiryDate', 'activityStatus', 'httpEvidenceStatus', 'httpFinalOrigin', 'httpTransportSecurity', 'httpContentType'].includes(field))
279
+ return Boolean(value);
280
+ if (['pageTitle', 'faviconHash', 'phishingLanguageMatch'].includes(field))
281
+ return value === null || typeof value === 'string';
282
+ if (field === 'riskScore')
283
+ return typeof value === 'number';
284
+ return typeof value === 'boolean';
285
+ }
286
+ function valuesEqual(field, before, after) {
287
+ if (field === 'registrarName')
288
+ return normalizeRegistrar(before) === normalizeRegistrar(after);
289
+ if (field === 'nameservers')
290
+ return JSON.stringify(normalizeNameservers(before)) === JSON.stringify(normalizeNameservers(after));
291
+ if (field === 'httpSecurityHeaders')
292
+ return JSON.stringify(before) === JSON.stringify(after);
293
+ if (field === 'createdDate' || field === 'expiryDate')
294
+ return normalizeDate(before) === normalizeDate(after);
295
+ return before === after;
296
+ }
297
+ function classifyChange(field, before, after) {
298
+ if (field === 'availability') {
299
+ if (before === 'available' && typeof after === 'string' && ['registered', 'for_sale', 'expiring'].includes(after)) {
300
+ return { kind: 'new_registration', tone: 'danger' };
301
+ }
302
+ if (typeof before === 'string' && ['registered', 'for_sale', 'expiring'].includes(before) && after === 'available') {
303
+ return { kind: 'released', tone: 'good' };
304
+ }
305
+ return { kind: 'availability_changed', tone: 'warn' };
306
+ }
307
+ if (['faviconMatch', 'faviconNearMatch', 'hasPasswordField', 'reusesOfficialAssets'].includes(field) && before === false && after === true) {
308
+ return { kind: 'risk_signal_added', tone: 'danger' };
309
+ }
310
+ if (field === 'phishingLanguageMatch' && !before && after)
311
+ return { kind: 'risk_signal_added', tone: 'danger' };
312
+ if (field === 'hasMx' && before === false && after === true)
313
+ return { kind: 'mail_activated', tone: 'warn' };
314
+ if (field === 'riskScore' && typeof before === 'number' && typeof after === 'number'
315
+ && before < 70 && after >= 70)
316
+ return { kind: 'high_risk', tone: 'danger' };
317
+ if (field === 'httpHttpsDowngrade' && before === false && after === true)
318
+ return { kind: 'risk_signal_added', tone: 'danger' };
319
+ if (field === 'httpCrossOriginRedirect' && before === false && after === true)
320
+ return { kind: 'field_changed', tone: 'warn' };
321
+ if (field === 'httpTransportSecurity' && before === 'https' && after === 'http')
322
+ return { kind: 'risk_signal_added', tone: 'danger' };
323
+ if (field === 'httpFinalOrigin')
324
+ return { kind: 'infrastructure_changed', tone: 'warn' };
325
+ if (['registrarName', 'nameservers', 'faviconHash'].includes(field))
326
+ return { kind: 'infrastructure_changed', tone: 'warn' };
327
+ return { kind: 'field_changed', tone: 'neutral' };
328
+ }
329
+ /**
330
+ * @param {object[]} baseline
331
+ * @param {object[]} current
332
+ * @param {Set<string>} [ignoredDomains]
333
+ */
334
+ export function diffWatchlistBaseline(baseline, current, ignoredDomains = new Set()) {
335
+ const previousByDomain = new Map(baseline.map((record) => [record.domain, record]));
336
+ const changes = [];
337
+ for (const next of current) {
338
+ const previous = previousByDomain.get(next.domain);
339
+ if (!previous || ignoredDomains.has(next.domain))
340
+ continue;
341
+ for (const field of Object.keys(FIELD_LABELS)) {
342
+ const before = previous[field];
343
+ const after = next[field];
344
+ if (!isComparable(field, before, previous) || !isComparable(field, after, next))
345
+ continue;
346
+ if (field === 'riskScore' && (!Number.isInteger(previous.riskModelVersion)
347
+ || previous.riskModelVersion !== next.riskModelVersion))
348
+ continue;
349
+ if (valuesEqual(field, before, after))
350
+ continue;
351
+ changes.push({ domain: next.domain, field, before, after, ...classifyChange(field, before, after) });
352
+ }
353
+ }
354
+ return changes;
355
+ }
356
+ // Rebuilds the last-known baseline from the current snapshot's membership,
357
+ // carrying each retained domain's prior deep-scan fields forward (so a fast
358
+ // rescan doesn't erase evidence a fast scan can't re-observe). Domains absent
359
+ // from the new snapshot are dropped rather than accumulated - otherwise
360
+ // reusing a watchlist name with a changing candidate set would grow the
361
+ // baseline without bound and compare reintroduced domains against stale state.
362
+ /** @param {object[]} baseline @param {object[]} current */
363
+ export function mergeWatchlistBaseline(baseline, current) {
364
+ const previousByDomain = new Map(baseline.map((record) => [record.domain, record]));
365
+ return current.map((next) => {
366
+ const previous = previousByDomain.get(next.domain);
367
+ const updated = {
368
+ ...(previous || {}),
369
+ domain: next.domain,
370
+ scanDepth: previous?.scanDepth === 'deep' || next.scanDepth === 'deep' ? 'deep' : 'fast',
371
+ };
372
+ for (const field of BASELINE_FIELDS) {
373
+ if (isComparable(field, next[field], next))
374
+ updated[field] = next[field];
375
+ }
376
+ // Preserve an explicit null beside legacy scores. Absence and null both
377
+ // mean "unversioned" to the comparator, but retaining the key makes the
378
+ // stored/exported contract unambiguous and prevents consumers from having
379
+ // to infer why a score was deliberately excluded from comparison.
380
+ if (next.scanDepth === 'deep' && typeof next.riskScore === 'number') {
381
+ updated.riskModelVersion = normalizeRiskModelVersion(next.riskModelVersion);
382
+ }
383
+ return updated;
384
+ });
385
+ }
386
+ function sanitizeStoredChange(value) {
387
+ const change = plainRecord(value);
388
+ if (!change || typeof change.field !== 'string' || !FIELD_LABELS[change.field])
389
+ return null;
390
+ const domain = normalizeDomain(change.domain);
391
+ if (!domain)
392
+ return null;
393
+ const before = normalizeStoredFieldValue(change.field, change.before);
394
+ const after = normalizeStoredFieldValue(change.field, change.after);
395
+ if (before === undefined || after === undefined)
396
+ return null;
397
+ return {
398
+ domain,
399
+ field: change.field,
400
+ before,
401
+ after,
402
+ kind: typeof change.kind === 'string' && CHANGE_KINDS.has(change.kind) ? change.kind : 'field_changed',
403
+ tone: typeof change.tone === 'string' && ['danger', 'warn', 'good', 'neutral'].includes(change.tone)
404
+ ? change.tone
405
+ : 'neutral',
406
+ };
407
+ }
408
+ function normalizedOrigin(value) {
409
+ if (typeof value !== 'string' || value.length > 4096 || CONTROL_RE.test(value))
410
+ return undefined;
411
+ try {
412
+ const url = new URL(value);
413
+ return ['http:', 'https:'].includes(url.protocol) && !url.username && !url.password && url.origin.length <= 300
414
+ ? url.origin
415
+ : undefined;
416
+ }
417
+ catch {
418
+ return undefined;
419
+ }
420
+ }
421
+ function normalizeStoredFieldValue(field, value) {
422
+ if (field === 'availability')
423
+ return hasString(AVAILABILITY_VALUES, value) ? value : undefined;
424
+ if (field === 'nameservers')
425
+ return Array.isArray(value) ? normalizeNameservers(value) : undefined;
426
+ if (field === 'httpSecurityHeaders') {
427
+ return Array.isArray(value)
428
+ ? [...new Set(value.slice(0, 20).filter((item) => hasString(HTTP_HEADER_VALUES, item)))].sort()
429
+ : undefined;
430
+ }
431
+ if (field === 'createdDate' || field === 'expiryDate')
432
+ return normalizeDateValue(value) ?? undefined;
433
+ if (field === 'activityStatus')
434
+ return hasString(ACTIVITY_VALUES, value) ? value : undefined;
435
+ if (field === 'httpEvidenceStatus')
436
+ return value === 'success' || value === 'partial' ? value : undefined;
437
+ if (field === 'httpFinalOrigin')
438
+ return normalizedOrigin(value);
439
+ if (field === 'httpResponseStatus')
440
+ return Number.isInteger(value) && Number(value) >= 100 && Number(value) <= 599 ? value : undefined;
441
+ if (field === 'httpRedirectCount')
442
+ return Number.isInteger(value) && Number(value) >= 0 && Number(value) <= 5 ? value : undefined;
443
+ if (field === 'riskScore')
444
+ return typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= 100 ? Math.round(value) : undefined;
445
+ if (field === 'httpTransportSecurity')
446
+ return value === 'http' || value === 'https' ? value : undefined;
447
+ if (field === 'httpContentType') {
448
+ const type = boundedText(value, 100);
449
+ return type && /^[a-z0-9!#$&^_.+-]+\/[a-z0-9!#$&^_.+-]+$/i.test(type) ? type.toLowerCase() : undefined;
450
+ }
451
+ if (field === 'registrarName')
452
+ return boundedText(value) ?? undefined;
453
+ if (['pageTitle', 'faviconHash', 'phishingLanguageMatch'].includes(field)) {
454
+ if (value === null)
455
+ return null;
456
+ return boundedText(value, field === 'pageTitle' || field === 'phishingLanguageMatch' ? MAX_TITLE_LENGTH : 128) ?? undefined;
457
+ }
458
+ return typeof value === 'boolean' ? value : undefined;
459
+ }
460
+ function initialHistoryEvent(entry, baseline) {
461
+ return {
462
+ checkedAt: entry.updatedAt || new Date(0).toISOString(),
463
+ mode: 'saved',
464
+ resultCount: Array.isArray(entry.results) ? entry.results.length : baseline.length,
465
+ conclusiveCount: baseline.filter((record) => hasString(CONCLUSIVE_AVAILABILITY, record.availability)).length,
466
+ changeCount: 0,
467
+ omittedChanges: 0,
468
+ changes: [],
469
+ };
470
+ }
471
+ /** @param {object} entry */
472
+ export function normalizeWatchlistEntry(entry) {
473
+ const input = plainRecord(entry) || {};
474
+ const rawResults = Array.isArray(input.results) ? input.results : [];
475
+ const results = compactWatchlistResults(rawResults);
476
+ const compactResults = results;
477
+ const importedBaseline = Array.isArray(input.baseline) ? compactWatchlistResults(input.baseline) : [];
478
+ const baseline = mergeWatchlistBaseline(importedBaseline, compactResults);
479
+ const history = Array.isArray(input.history)
480
+ ? input.history.slice(-MAX_WATCHLIST_HISTORY_EVENTS * 4).map((value) => {
481
+ const event = plainRecord(value) || {};
482
+ const changes = Array.isArray(event.changes)
483
+ ? event.changes.slice(0, MAX_WATCHLIST_CHANGES_PER_EVENT * 4)
484
+ .map(sanitizeStoredChange)
485
+ .filter((change) => change !== null)
486
+ .slice(0, MAX_WATCHLIST_CHANGES_PER_EVENT)
487
+ : [];
488
+ const rawMode = event.mode;
489
+ return {
490
+ checkedAt: isoTimestamp(event.checkedAt),
491
+ mode: typeof rawMode === 'string' && ['fast', 'deep', 'saved'].includes(rawMode)
492
+ ? rawMode
493
+ : 'saved',
494
+ resultCount: boundedInteger(event.resultCount, MAX_WATCHLIST_DOMAINS, results.length),
495
+ conclusiveCount: boundedInteger(event.conclusiveCount, MAX_WATCHLIST_DOMAINS),
496
+ changeCount: boundedInteger(event.changeCount, MAX_CHANGE_COUNT, changes.length),
497
+ omittedChanges: boundedInteger(event.omittedChanges, MAX_CHANGE_COUNT),
498
+ changes,
499
+ };
500
+ }).slice(-MAX_WATCHLIST_HISTORY_EVENTS)
501
+ : [];
502
+ const normalized = {
503
+ updatedAt: isoTimestamp(input.updatedAt),
504
+ results,
505
+ baseline,
506
+ history,
507
+ };
508
+ if (normalized.history.length === 0)
509
+ normalized.history.push(initialHistoryEvent(normalized, baseline));
510
+ return normalized;
511
+ }
512
+ /**
513
+ * @param {object | null} existingEntry
514
+ * @param {object[]} results
515
+ * @param {{ checkedAt?: string, mode?: string, ignoredDomains?: Set<string> }} [options]
516
+ */
517
+ export function appendWatchlistScan(existingEntry, results, options = {}) {
518
+ const checkedAt = isoTimestamp(options.checkedAt, new Date().toISOString());
519
+ const mode = typeof options.mode === 'string' && ['fast', 'deep', 'saved'].includes(options.mode)
520
+ ? options.mode
521
+ : 'saved';
522
+ const previous = existingEntry ? normalizeWatchlistEntry(existingEntry) : null;
523
+ const current = compactWatchlistResults(results);
524
+ const changes = previous
525
+ ? diffWatchlistBaseline(previous.baseline, current, options.ignoredDomains || new Set())
526
+ : [];
527
+ const storedChanges = changes.slice(0, MAX_WATCHLIST_CHANGES_PER_EVENT);
528
+ const event = {
529
+ checkedAt,
530
+ mode,
531
+ resultCount: current.length,
532
+ conclusiveCount: current.filter((record) => hasString(CONCLUSIVE_AVAILABILITY, record.availability)).length,
533
+ changeCount: changes.length,
534
+ omittedChanges: Math.max(0, changes.length - storedChanges.length),
535
+ changes: storedChanges,
536
+ };
537
+ const history = [...(previous?.history || []), event].slice(-MAX_WATCHLIST_HISTORY_EVENTS);
538
+ return {
539
+ entry: {
540
+ updatedAt: checkedAt,
541
+ results: current,
542
+ baseline: mergeWatchlistBaseline(previous?.baseline || [], current),
543
+ history,
544
+ },
545
+ changes,
546
+ };
547
+ }
548
+ export function watchlistFieldLabel(field) {
549
+ return FIELD_LABELS[field] || field;
550
+ }
551
+ export function formatWatchlistValue(field, value) {
552
+ if (field === 'availability')
553
+ return (typeof value === 'string' ? AVAILABILITY_LABELS[value] : '') || String(value ?? 'None');
554
+ if (field === 'activityStatus')
555
+ return (typeof value === 'string' ? ACTIVITY_LABELS[value] : '') || String(value ?? 'None');
556
+ if (field === 'nameservers')
557
+ return Array.isArray(value) && value.length ? value.join(', ') : 'None';
558
+ if (field === 'faviconHash')
559
+ return value ? `${String(value).slice(0, 12)}…` : 'None';
560
+ if (typeof value === 'boolean')
561
+ return value ? 'Yes' : 'No';
562
+ if (value === null || value === undefined || value === '')
563
+ return 'None';
564
+ return String(value);
565
+ }
566
+ export function watchlistHistoryCategory(field) {
567
+ for (const category of WATCHLIST_HISTORY_CATEGORIES) {
568
+ if (HISTORY_CATEGORY_FIELDS[category.key]?.has(field))
569
+ return category.key;
570
+ }
571
+ return null;
572
+ }
573
+ /**
574
+ * Produces a bounded, deterministic domain list for the history focus control.
575
+ * Current retained domains are prioritised before domains that appear only in
576
+ * older change events, because the latter may have left the current watchlist.
577
+ *
578
+ * @param {object | null | undefined} entry
579
+ */
580
+ export function watchlistHistoryDomains(entry) {
581
+ const normalized = normalizeWatchlistEntry(entry || {});
582
+ const current = new Set();
583
+ const historical = new Set();
584
+ for (const record of [...normalized.results, ...normalized.baseline]) {
585
+ if (record?.domain)
586
+ current.add(record.domain);
587
+ }
588
+ for (const event of normalized.history) {
589
+ for (const change of event.changes) {
590
+ if (change.domain && !current.has(change.domain))
591
+ historical.add(change.domain);
592
+ }
593
+ }
594
+ const domains = [...current].sort().concat([...historical].sort()).slice(0, MAX_WATCHLIST_HISTORY_DOMAIN_OPTIONS);
595
+ return {
596
+ domains,
597
+ omittedDomains: Math.max(0, current.size + historical.size - domains.length),
598
+ };
599
+ }
600
+ /**
601
+ * Builds a read-only view over one domain's retained watchlist changes. The
602
+ * coverage window and scan modes describe the watchlist, not proof that the
603
+ * selected domain was included in every retained check.
604
+ *
605
+ * @param {object | null | undefined} entry
606
+ * @param {unknown} domain
607
+ */
608
+ export function projectWatchlistDomainHistory(entry, domain) {
609
+ const normalizedDomain = normalizeDomain(domain);
610
+ if (!normalizedDomain)
611
+ return null;
612
+ const normalized = normalizeWatchlistEntry(entry || {});
613
+ const events = [];
614
+ let materialChangeCount = 0;
615
+ let omittedChanges = 0;
616
+ for (const event of normalized.history) {
617
+ omittedChanges += event.omittedChanges;
618
+ const matching = event.changes.filter((change) => change.domain === normalizedDomain);
619
+ if (!matching.length)
620
+ continue;
621
+ materialChangeCount += matching.length;
622
+ const groups = [];
623
+ for (const category of WATCHLIST_HISTORY_CATEGORIES) {
624
+ const changes = matching.filter((change) => watchlistHistoryCategory(change.field) === category.key);
625
+ if (changes.length)
626
+ groups.push({ ...category, changes });
627
+ }
628
+ const uncategorized = matching.filter((change) => watchlistHistoryCategory(change.field) === null);
629
+ if (uncategorized.length)
630
+ groups.push({ key: 'other', label: 'Other', changes: uncategorized });
631
+ events.push({ checkedAt: event.checkedAt, mode: event.mode, groups });
632
+ }
633
+ return {
634
+ domain: normalizedDomain,
635
+ retainedWatchlistChecks: normalized.history.length,
636
+ watchlistFirstCheckedAt: normalized.history[0]?.checkedAt || null,
637
+ watchlistLastCheckedAt: normalized.history.at(-1)?.checkedAt || null,
638
+ scanModes: ['saved', 'fast', 'deep'].filter((mode) => normalized.history.some((event) => event.mode === mode)),
639
+ materialChangeCount,
640
+ omittedChanges,
641
+ events,
642
+ };
643
+ }