@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,209 @@
1
+ // Pure browser-local shortlist model. The browser wrapper owns persistence
2
+ // and downloads; this module owns schema migration, record normalization,
3
+ // import merging, and exact serialized-byte accounting.
4
+ import { compactWatchlistResults } from "./watchlist-history.js";
5
+ import { normalizeRiskModelVersion } from "./scoring.js";
6
+ export const SHORTLIST_SCHEMA = 'whoisleuth.shortlist';
7
+ export const SHORTLIST_SCHEMA_VERSION = 2;
8
+ export const MAX_SHORTLIST_ENTRIES = 500;
9
+ export const MAX_SHORTLIST_INPUTS = MAX_SHORTLIST_ENTRIES * 4;
10
+ export const MAX_SHORTLIST_STORE_BYTES = 1024 * 1024;
11
+ export const MAX_SHORTLIST_FACTORS = 20;
12
+ const MAX_FACTOR_LABEL_LENGTH = 200;
13
+ const MAX_TIMESTAMP_LENGTH = 64;
14
+ const CONTROL_RE = /[\x00-\x1f\x7f]/;
15
+ const EPOCH = new Date(0).toISOString();
16
+ function plainRecord(value) {
17
+ return value && typeof value === 'object' && !Array.isArray(value)
18
+ ? value
19
+ : null;
20
+ }
21
+ function isEnvelope(value) {
22
+ return Boolean(value && value.schema === SHORTLIST_SCHEMA && Array.isArray(value.entries));
23
+ }
24
+ function entryList(raw) {
25
+ if (Array.isArray(raw))
26
+ return raw;
27
+ const value = plainRecord(raw);
28
+ return isEnvelope(value) ? value.entries : null;
29
+ }
30
+ export function shortlistStoreVersion(raw) {
31
+ if (Array.isArray(raw))
32
+ return 1;
33
+ const value = plainRecord(raw);
34
+ if (!value || !isEnvelope(value))
35
+ return null;
36
+ return typeof value.version === 'number' && Number.isFinite(value.version) && value.version > 0
37
+ ? value.version
38
+ : null;
39
+ }
40
+ function score(value) {
41
+ return typeof value === 'number' && Number.isFinite(value)
42
+ ? Math.max(0, Math.min(100, Math.round(value)))
43
+ : null;
44
+ }
45
+ function timestamp(value, fallback = EPOCH) {
46
+ if (typeof value !== 'string' || value.length > MAX_TIMESTAMP_LENGTH || CONTROL_RE.test(value))
47
+ return fallback;
48
+ const parsed = Date.parse(value);
49
+ return Number.isFinite(parsed) ? new Date(parsed).toISOString() : fallback;
50
+ }
51
+ function factors(value) {
52
+ if (!Array.isArray(value))
53
+ return [];
54
+ const normalized = [];
55
+ for (const item of value.slice(0, MAX_SHORTLIST_FACTORS * 4)) {
56
+ const factor = plainRecord(item);
57
+ if (!factor || typeof factor.label !== 'string' || CONTROL_RE.test(factor.label))
58
+ continue;
59
+ const label = factor.label.slice(0, MAX_FACTOR_LABEL_LENGTH * 4).replace(/\s+/g, ' ').trim().slice(0, MAX_FACTOR_LABEL_LENGTH).trim();
60
+ const points = typeof factor.points === 'number' && Number.isFinite(factor.points)
61
+ ? Math.max(-100, Math.min(100, Math.round(factor.points)))
62
+ : null;
63
+ if (!label || points === null)
64
+ continue;
65
+ normalized.push({ label, points });
66
+ if (normalized.length >= MAX_SHORTLIST_FACTORS)
67
+ break;
68
+ }
69
+ return normalized;
70
+ }
71
+ /** Normalize one shortlist record while retaining only known compact fields. */
72
+ export function normalizeShortlistRecord(raw, options = {}) {
73
+ const value = plainRecord(raw);
74
+ if (!value)
75
+ return null;
76
+ const compact = compactWatchlistResults([value])[0];
77
+ if (!compact)
78
+ return null;
79
+ const riskScore = score(value.riskScore);
80
+ return {
81
+ ...compact,
82
+ availability: typeof compact.availability === 'string' ? compact.availability : 'unknown',
83
+ riskModelVersion: riskScore === null ? null : normalizeRiskModelVersion(value.riskModelVersion),
84
+ riskScore,
85
+ riskFactors: factors(value.riskFactors),
86
+ opportunityScore: score(value.opportunityScore),
87
+ savedAt: timestamp(value.savedAt, timestamp(options.fallbackTimestamp, EPOCH)),
88
+ };
89
+ }
90
+ /** Normalize an internal record collection or current stored envelope. */
91
+ export function normalizeShortlistStore(raw) {
92
+ const source = entryList(raw);
93
+ const byDomain = new Map();
94
+ if (source) {
95
+ for (const item of source.slice(0, MAX_SHORTLIST_INPUTS)) {
96
+ const record = normalizeShortlistRecord(item);
97
+ if (!record)
98
+ continue;
99
+ byDomain.set(record.domain, record);
100
+ if (byDomain.size >= MAX_SHORTLIST_ENTRIES)
101
+ break;
102
+ }
103
+ }
104
+ return { schema: SHORTLIST_SCHEMA, version: SHORTLIST_SCHEMA_VERSION, entries: [...byDomain.values()] };
105
+ }
106
+ function byteLength(value) {
107
+ return new TextEncoder().encode(value).byteLength;
108
+ }
109
+ export function assertShortlistStoreBudget(records) {
110
+ const store = normalizeShortlistStore(Array.isArray(records) ? records : entryList(records));
111
+ if (byteLength(JSON.stringify(store)) > MAX_SHORTLIST_STORE_BYTES) {
112
+ throw new Error('Shortlist storage is full. Export and remove entries before saving more.');
113
+ }
114
+ return store;
115
+ }
116
+ export function serializeShortlistStore(records) {
117
+ return JSON.stringify(assertShortlistStoreBudget(records));
118
+ }
119
+ /** Add, refresh, or remove an explicit bounded selection in one transaction. */
120
+ export function setShortlistSelection(localRaw, selectedRaw, selected, nowIso = new Date().toISOString()) {
121
+ const local = normalizeShortlistStore(localRaw).entries;
122
+ const byDomain = new Map(local.map((record) => [record.domain, record]));
123
+ const candidates = Array.isArray(selectedRaw) ? selectedRaw.slice(0, MAX_SHORTLIST_INPUTS) : [];
124
+ let added = 0;
125
+ let updated = 0;
126
+ let removed = 0;
127
+ let skipped = Math.max(0, (Array.isArray(selectedRaw) ? selectedRaw.length : 0) - MAX_SHORTLIST_INPUTS);
128
+ const seen = new Set();
129
+ for (const candidate of candidates) {
130
+ const record = normalizeShortlistRecord(candidate, { fallbackTimestamp: nowIso });
131
+ if (!record || seen.has(record.domain)) {
132
+ skipped += 1;
133
+ continue;
134
+ }
135
+ seen.add(record.domain);
136
+ if (selected) {
137
+ if (!byDomain.has(record.domain) && byDomain.size >= MAX_SHORTLIST_ENTRIES) {
138
+ skipped += 1;
139
+ continue;
140
+ }
141
+ if (byDomain.has(record.domain))
142
+ updated += 1;
143
+ else
144
+ added += 1;
145
+ byDomain.set(record.domain, record);
146
+ }
147
+ else if (byDomain.delete(record.domain)) {
148
+ removed += 1;
149
+ }
150
+ }
151
+ return {
152
+ entries: assertShortlistStoreBudget([...byDomain.values()]).entries,
153
+ added,
154
+ updated,
155
+ removed,
156
+ skipped,
157
+ };
158
+ }
159
+ function validateImport(raw) {
160
+ const value = plainRecord(raw);
161
+ if (!value || value.schema !== SHORTLIST_SCHEMA || !Array.isArray(value.entries)) {
162
+ throw new Error('Expected a current WHOISleuth shortlist export.');
163
+ }
164
+ }
165
+ export function mergeShortlistStores(localRaw, importedRaw) {
166
+ validateImport(importedRaw);
167
+ const importedVersion = shortlistStoreVersion(importedRaw);
168
+ if (importedVersion !== null && importedVersion > SHORTLIST_SCHEMA_VERSION) {
169
+ throw new Error(`This shortlist file uses newer schema ${importedVersion}. Update the app before importing it.`);
170
+ }
171
+ if (importedVersion !== SHORTLIST_SCHEMA_VERSION) {
172
+ throw new Error(`Expected a WHOISleuth shortlist export using schema ${SHORTLIST_SCHEMA_VERSION}.`);
173
+ }
174
+ const local = normalizeShortlistStore(localRaw).entries;
175
+ const byDomain = new Map(local.map((record) => [record.domain, record]));
176
+ const input = entryList(importedRaw) || [];
177
+ const imported = new Map();
178
+ let skipped = Math.max(0, input.length - MAX_SHORTLIST_INPUTS);
179
+ for (const item of input.slice(0, MAX_SHORTLIST_INPUTS)) {
180
+ const record = normalizeShortlistRecord(item);
181
+ if (!record) {
182
+ skipped++;
183
+ continue;
184
+ }
185
+ if (imported.has(record.domain))
186
+ skipped++;
187
+ imported.set(record.domain, record);
188
+ }
189
+ let added = 0;
190
+ let updated = 0;
191
+ for (const record of imported.values()) {
192
+ if (byDomain.has(record.domain))
193
+ updated++;
194
+ else if (byDomain.size >= MAX_SHORTLIST_ENTRIES) {
195
+ skipped++;
196
+ continue;
197
+ }
198
+ else
199
+ added++;
200
+ byDomain.set(record.domain, record);
201
+ }
202
+ return { entries: [...byDomain.values()], added, updated, skipped };
203
+ }
204
+ export function buildShortlistExport(records, nowIso = new Date().toISOString()) {
205
+ return {
206
+ ...normalizeShortlistStore(records),
207
+ exportedAt: timestamp(nowIso, new Date().toISOString()),
208
+ };
209
+ }
@@ -0,0 +1,265 @@
1
+ // Browser-safe analysis helpers shared by the Svelte tools.
2
+ import { serializeCsvCell, serializeCsvRows } from "../../../../lib/csv.mjs";
3
+ // Deliberately conservative (no +tags, no comments, no quoted local parts) -
4
+ // this only gates whether a WHOIS/RDAP-sourced string is safe to drop into a
5
+ // mailto: URI as the recipient, not a general email validator. mailto:
6
+ // treats a comma as an additional-recipient separator (RFC 6068), so a
7
+ // registrant/abuse-contact field containing e.g. "victim@x.com,cc@evil.com"
8
+ // would silently add a second recipient to the outreach/abuse draft the user
9
+ // opens - rejecting anything outside a single plain address closes that off.
10
+ const SIMPLE_EMAIL_RE = /^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/;
11
+ const MAX_ENTITY_DISPLAY_LENGTH = 300;
12
+ export function isValidEmailAddress(value) {
13
+ return typeof value === 'string' && SIMPLE_EMAIL_RE.test(value.trim());
14
+ }
15
+ // RDAP and compact availability results represent a registrar as an entity,
16
+ // while some WHOIS paths retain the historical string field. Normalize both
17
+ // forms before they reach UI summaries or browser-local evidence; blindly
18
+ // calling String() on an entity would persist "[object Object]" and hide real
19
+ // registrar changes. The backend already bounds these values, but this client
20
+ // boundary revalidates the API response before retaining it.
21
+ function plainRecord(value) {
22
+ return value && typeof value === 'object' && !Array.isArray(value)
23
+ ? value
24
+ : null;
25
+ }
26
+ export function entityDisplayName(value) {
27
+ let candidate = value;
28
+ const record = plainRecord(value);
29
+ if (record) {
30
+ candidate = record.name || record.org || record.handle;
31
+ }
32
+ if (typeof candidate !== 'string' && typeof candidate !== 'number')
33
+ return null;
34
+ const normalized = String(candidate)
35
+ .replace(/[\u0000-\u001f\u007f]/g, ' ')
36
+ .replace(/\s+/g, ' ')
37
+ .trim()
38
+ .slice(0, MAX_ENTITY_DISPLAY_LENGTH);
39
+ return normalized || null;
40
+ }
41
+ // Hamming distance (0-64) between two 16-hex perceptual dHash strings (see
42
+ // lib/perceptual-hash.mts), or null if either isn't a well-formed hash. Smaller
43
+ // = more visually similar. Shared by the brand-profile near-match check and
44
+ // bulk favicon clustering; mirrors the backend's hammingDistanceHex. The two
45
+ // implementations target separate Node and browser execution boundaries.
46
+ const HEX_HASH_RE = /^[0-9a-f]{16}$/;
47
+ export function hammingDistanceHex(a, b) {
48
+ if (typeof a !== 'string' || typeof b !== 'string')
49
+ return null;
50
+ if (!HEX_HASH_RE.test(a) || !HEX_HASH_RE.test(b))
51
+ return null;
52
+ let distance = 0;
53
+ for (let i = 0; i < 16; i += 1) {
54
+ let diff = parseInt(a.charAt(i), 16) ^ parseInt(b.charAt(i), 16);
55
+ while (diff) {
56
+ distance += diff & 1;
57
+ diff >>= 1;
58
+ }
59
+ }
60
+ return distance;
61
+ }
62
+ // A dHash carries usable structure only when its set-bit count is away from
63
+ // the degenerate extremes - a near-all-zero (or near-all-one) hash comes from
64
+ // a solid/monotonic icon and collides with every other such icon (see
65
+ // lib/perceptual-hash.mts's MIN_INFORMATIVE_BITS, mirrored here). Generation
66
+ // already rejects these, but this is re-checked at every comparison so a
67
+ // degenerate hash saved into a Brand Profile before that guard existed can't
68
+ // keep producing false near-matches.
69
+ const MIN_INFORMATIVE_HASH_BITS = 10;
70
+ export function isInformativeFaviconHash(hex) {
71
+ if (typeof hex !== 'string' || !HEX_HASH_RE.test(hex))
72
+ return false;
73
+ let bits = 0;
74
+ for (let i = 0; i < 16; i += 1) {
75
+ let n = parseInt(hex.charAt(i), 16);
76
+ while (n) {
77
+ bits += n & 1;
78
+ n >>= 1;
79
+ }
80
+ }
81
+ return bits >= MIN_INFORMATIVE_HASH_BITS && bits <= 64 - MIN_INFORMATIVE_HASH_BITS;
82
+ }
83
+ function faviconRecord(value) {
84
+ const record = plainRecord(value);
85
+ if (!record || typeof record.domain !== 'string')
86
+ return null;
87
+ const faviconHash = typeof record.faviconHash === 'string' && record.faviconHash
88
+ ? record.faviconHash
89
+ : null;
90
+ const faviconPHash = typeof record.faviconPHash === 'string' && HEX_HASH_RE.test(record.faviconPHash)
91
+ ? record.faviconPHash
92
+ : null;
93
+ return faviconHash || faviconPHash
94
+ ? { domain: record.domain, faviconHash, faviconPHash }
95
+ : null;
96
+ }
97
+ export function groupBySimilarFavicon(records, maxDistance) {
98
+ const items = Array.isArray(records)
99
+ ? records.map(faviconRecord).filter((record) => record !== null)
100
+ : [];
101
+ const parent = items.map((_, i) => i);
102
+ const find = (x) => {
103
+ let root = x;
104
+ while ((parent[root] ?? root) !== root)
105
+ root = parent[root] ?? root;
106
+ while ((parent[x] ?? x) !== root) {
107
+ const next = parent[x] ?? x;
108
+ parent[x] = root;
109
+ x = next;
110
+ }
111
+ return root;
112
+ };
113
+ const union = (a, b) => { const ra = find(a); const rb = find(b); if (ra !== rb)
114
+ parent[ra] = rb; };
115
+ // Exact-hash buckets first (cheap, and the only signal for undecodable icons).
116
+ const firstByHash = new Map();
117
+ items.forEach((r, i) => {
118
+ if (!r.faviconHash)
119
+ return;
120
+ const existing = firstByHash.get(r.faviconHash);
121
+ if (existing !== undefined)
122
+ union(i, existing);
123
+ else
124
+ firstByHash.set(r.faviconHash, i);
125
+ });
126
+ // Perceptual near-matches among records with an *informative* phash -
127
+ // degenerate hashes (solid/monotonic icons) are skipped so they don't all
128
+ // cluster together; they can still group via an exact-hash match above.
129
+ const withPhash = [];
130
+ items.forEach((r, i) => {
131
+ if (isInformativeFaviconHash(r.faviconPHash))
132
+ withPhash.push({ i, phash: r.faviconPHash });
133
+ });
134
+ for (let a = 0; a < withPhash.length; a += 1) {
135
+ for (let b = a + 1; b < withPhash.length; b += 1) {
136
+ const left = withPhash[a];
137
+ const right = withPhash[b];
138
+ if (!left || !right)
139
+ continue;
140
+ const distance = hammingDistanceHex(left.phash, right.phash);
141
+ if (distance !== null && distance <= maxDistance)
142
+ union(left.i, right.i);
143
+ }
144
+ }
145
+ const groups = new Map();
146
+ items.forEach((r, i) => {
147
+ const root = find(i);
148
+ if (!groups.has(root))
149
+ groups.set(root, []);
150
+ groups.get(root)?.push(r.domain);
151
+ });
152
+ return [...groups.values()].filter((domains) => domains.length >= 2);
153
+ }
154
+ export function toCsvValue(v) {
155
+ return serializeCsvCell(v);
156
+ }
157
+ export function rowsToCsv(rows) {
158
+ return serializeCsvRows(rows);
159
+ }
160
+ function splitDelimitedLine(line, delimiter = ',') {
161
+ const result = [];
162
+ let cur = '';
163
+ let inQuotes = false;
164
+ for (let i = 0; i < line.length; i += 1) {
165
+ const ch = line[i];
166
+ if (ch === '"') {
167
+ if (inQuotes && line[i + 1] === '"') {
168
+ cur += '"';
169
+ i += 1;
170
+ }
171
+ else {
172
+ inQuotes = !inQuotes;
173
+ }
174
+ }
175
+ else if (ch === delimiter && !inQuotes) {
176
+ result.push(cur);
177
+ cur = '';
178
+ }
179
+ else {
180
+ cur += ch;
181
+ }
182
+ }
183
+ result.push(cur);
184
+ return result.map((s) => s.trim());
185
+ }
186
+ const DOMAIN_HEADER_NAMES = ['domain', 'domain_name', 'domain name', 'hostname', 'name'];
187
+ function detectDelimiter(line) {
188
+ const candidates = [',', ';', '\t'];
189
+ let best = null;
190
+ let bestCount = 0;
191
+ for (const delimiter of candidates) {
192
+ const count = splitDelimitedLine(line, delimiter).length - 1;
193
+ if (count > bestCount) {
194
+ best = delimiter;
195
+ bestCount = count;
196
+ }
197
+ }
198
+ return best;
199
+ }
200
+ // Used only to distinguish a pasted delimiter-separated query list from a
201
+ // headerless multi-column CSV. Server-side classifyQuery remains the
202
+ // authoritative validator when the scan runs.
203
+ function looksLikeLookupToken(value) {
204
+ const token = String(value || '').trim();
205
+ if (!token || /\s/.test(token))
206
+ return false;
207
+ if (/^AS\d+$/i.test(token) || /^\d{1,3}(?:\.\d{1,3}){3}$/.test(token) || token.includes(':'))
208
+ return true;
209
+ try {
210
+ const url = new URL(/^[a-z][a-z\d+.-]*:\/\//i.test(token) ? token : `https://${token}`);
211
+ return url.hostname.includes('.');
212
+ }
213
+ catch {
214
+ return false;
215
+ }
216
+ }
217
+ export function parseDomainInput(text) {
218
+ const normalized = String(text || '').replace(/^\uFEFF/, '');
219
+ const lines = normalized.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
220
+ if (lines.length === 0)
221
+ return { entries: [], duplicates: 0, usedHeader: false };
222
+ const firstLine = lines[0] ?? '';
223
+ const delimiter = detectDelimiter(firstLine);
224
+ const firstCells = delimiter ? splitDelimitedLine(firstLine, delimiter) : [firstLine];
225
+ const header = firstCells.map((cell) => cell.toLowerCase());
226
+ const domainColIdx = header.findIndex((cell) => DOMAIN_HEADER_NAMES.includes(cell));
227
+ const usedHeader = domainColIdx !== -1;
228
+ let candidates;
229
+ if (usedHeader) {
230
+ candidates = lines.slice(1).map((line) => {
231
+ const rowDelimiter = delimiter || detectDelimiter(line);
232
+ const cells = rowDelimiter ? splitDelimitedLine(line, rowDelimiter) : [line];
233
+ return cells[domainColIdx] || '';
234
+ });
235
+ }
236
+ else {
237
+ const rows = lines.map((line) => {
238
+ const rowDelimiter = detectDelimiter(line);
239
+ return rowDelimiter ? splitDelimitedLine(line, rowDelimiter) : [line];
240
+ });
241
+ const flattened = rows.flat().map((cell) => cell.trim()).filter(Boolean);
242
+ // If every cell looks like a query, this is a pasted comma/semicolon list
243
+ // (possibly wrapped over several lines). Otherwise retain column zero so
244
+ // a headerless "domain,notes" CSV does not scan its notes as domains.
245
+ candidates = lines.length === 1 || (flattened.length > 0 && flattened.every(looksLikeLookupToken))
246
+ ? flattened
247
+ : rows.map((cells) => cells[0] || '');
248
+ }
249
+ const entries = [];
250
+ const seen = new Set();
251
+ let duplicates = 0;
252
+ for (const candidate of candidates) {
253
+ const value = String(candidate || '').trim();
254
+ if (!value)
255
+ continue;
256
+ const key = value.toLowerCase();
257
+ if (seen.has(key)) {
258
+ duplicates += 1;
259
+ continue;
260
+ }
261
+ seen.add(key);
262
+ entries.push(value);
263
+ }
264
+ return { entries, duplicates, usedHeader };
265
+ }