@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.
- package/LICENSE +661 -0
- package/LICENSES/Retire.js-Apache-2.0.txt +199 -0
- package/NOTICE +76 -0
- package/README.md +84 -0
- package/TRADEMARKS.md +41 -0
- package/bin/whoisleuth.mjs +35 -0
- package/cli/archive-inspect.mjs +198 -0
- package/cli/arguments.mjs +1022 -0
- package/cli/artifact-verify.mjs +210 -0
- package/cli/bulk-checkpoint.mjs +218 -0
- package/cli/bulk-command-runner.mjs +109 -0
- package/cli/bulk-output.mjs +112 -0
- package/cli/bulk.mjs +133 -0
- package/cli/compare.mjs +238 -0
- package/cli/completion.mjs +175 -0
- package/cli/csv.mjs +1 -0
- package/cli/discover.mjs +50 -0
- package/cli/discovery-command-runner.mjs +36 -0
- package/cli/discovery-observation-snapshot.mjs +315 -0
- package/cli/discovery-scan-command-runner.mjs +141 -0
- package/cli/discovery-scan.mjs +325 -0
- package/cli/discovery-snapshot.mjs +133 -0
- package/cli/discovery-workflow.mjs +73 -0
- package/cli/doctor.mjs +137 -0
- package/cli/errors.mjs +21 -0
- package/cli/evidence-command-arguments.mjs +145 -0
- package/cli/evidence-command-runner.mjs +106 -0
- package/cli/evidence-signing.mjs +215 -0
- package/cli/exit-codes.mjs +9 -0
- package/cli/export-evidence.mjs +28 -0
- package/cli/formatters/evidence-report.mjs +3 -0
- package/cli/formatters/html.mjs +69 -0
- package/cli/formatters/json.mjs +135 -0
- package/cli/formatters/markdown.mjs +3 -0
- package/cli/formatters/terminal.mjs +658 -0
- package/cli/http.mjs +27 -0
- package/cli/lookup-command-runner.mjs +87 -0
- package/cli/lookup-diff.mjs +171 -0
- package/cli/mail-review.mjs +204 -0
- package/cli/manual.mjs +34 -0
- package/cli/network-command-runner.mjs +88 -0
- package/cli/output-file.mjs +97 -0
- package/cli/page-compare.mjs +123 -0
- package/cli/posture.mjs +20 -0
- package/cli/progress-events.mjs +53 -0
- package/cli/progress.mjs +56 -0
- package/cli/registry-access.mjs +3 -0
- package/cli/registry-support.mjs +120 -0
- package/cli/risk-calibration.mjs +294 -0
- package/cli/runner-types.mjs +1 -0
- package/cli/runner.mjs +726 -0
- package/cli/saved-lookup.mjs +87 -0
- package/cli/source-reliability.mjs +727 -0
- package/cli/strict-exit.mjs +55 -0
- package/cli/terminal-presentation.mjs +147 -0
- package/docs/cli.md +827 -0
- package/frontend/src/lib/analysis/acquisition-decision-packet.js +93 -0
- package/frontend/src/lib/analysis/acquisition-due-diligence.js +391 -0
- package/frontend/src/lib/analysis/artifact-integrity.js +21 -0
- package/frontend/src/lib/analysis/brand-profile-model.js +421 -0
- package/frontend/src/lib/analysis/bulk-domain-comparison.js +246 -0
- package/frontend/src/lib/analysis/bulk-mail-exposure.js +192 -0
- package/frontend/src/lib/analysis/bulk-retry-plan.js +86 -0
- package/frontend/src/lib/analysis/bulk-review-export.js +62 -0
- package/frontend/src/lib/analysis/bulk-review-model.js +266 -0
- package/frontend/src/lib/analysis/bulk-session-model.js +515 -0
- package/frontend/src/lib/analysis/bulk-sort.js +49 -0
- package/frontend/src/lib/analysis/campaign-model.js +265 -0
- package/frontend/src/lib/analysis/case-evidence-model.js +642 -0
- package/frontend/src/lib/analysis/case-migration-model.js +248 -0
- package/frontend/src/lib/analysis/case-model.js +5 -0
- package/frontend/src/lib/analysis/case-record-contracts.js +44 -0
- package/frontend/src/lib/analysis/case-record-core.js +232 -0
- package/frontend/src/lib/analysis/case-record-model.js +7 -0
- package/frontend/src/lib/analysis/case-record-operations.js +224 -0
- package/frontend/src/lib/analysis/case-response-model.js +640 -0
- package/frontend/src/lib/analysis/case-response-packet.js +573 -0
- package/frontend/src/lib/analysis/case-storage-model.js +93 -0
- package/frontend/src/lib/analysis/detection-rule-model.js +306 -0
- package/frontend/src/lib/analysis/dns-record-normalization.js +11 -0
- package/frontend/src/lib/analysis/http-summary.js +165 -0
- package/frontend/src/lib/analysis/investigation-guide.js +500 -0
- package/frontend/src/lib/analysis/investigation-template-model.js +144 -0
- package/frontend/src/lib/analysis/page-baseline.js +272 -0
- package/frontend/src/lib/analysis/page-similarity.js +179 -0
- package/frontend/src/lib/analysis/relationship-evidence.js +265 -0
- package/frontend/src/lib/analysis/relationship-observation-model.js +423 -0
- package/frontend/src/lib/analysis/scoring.js +135 -0
- package/frontend/src/lib/analysis/shortlist-model.js +209 -0
- package/frontend/src/lib/analysis/utils.js +265 -0
- package/frontend/src/lib/analysis/watchlist-history.js +643 -0
- package/frontend/src/lib/analysis/watchlist-store.js +127 -0
- package/frontend/src/lib/analysis/website-snapshot-model.js +302 -0
- package/frontend/src/lib/analysis/workspace-archive-crypto.js +282 -0
- package/frontend/src/lib/analysis/workspace-archive.js +472 -0
- package/lib/abort.mjs +26 -0
- package/lib/abusech-auth.mjs +19 -0
- package/lib/availability.mjs +716 -0
- package/lib/bounded-contract-normalizers.mjs +61 -0
- package/lib/browser-library-profile.mjs +310 -0
- package/lib/bulk-comparison-evidence.mjs +83 -0
- package/lib/classify.mjs +93 -0
- package/lib/client-behavior-profile.mjs +83 -0
- package/lib/credential-surface-profile.mjs +66 -0
- package/lib/csv.mjs +23 -0
- package/lib/ct-query.mjs +27 -0
- package/lib/ct-search.mjs +393 -0
- package/lib/distributed-operation-budget.mjs +461 -0
- package/lib/dns-delegation-health.mjs +344 -0
- package/lib/dns-intelligence.mjs +739 -0
- package/lib/dns-mx.mjs +24 -0
- package/lib/dns-resolver-selection.mjs +33 -0
- package/lib/domain-posture-analysis.mjs +254 -0
- package/lib/domain-posture-parsers.mjs +358 -0
- package/lib/domain-posture.mjs +745 -0
- package/lib/error-detail.mjs +10 -0
- package/lib/evidence-export.mjs +431 -0
- package/lib/evidence-report-markdown.mjs +71 -0
- package/lib/evidence-report.mjs +391 -0
- package/lib/external-intelligence-risk.mjs +108 -0
- package/lib/favicon.mjs +146 -0
- package/lib/feature-policy.mjs +70 -0
- package/lib/generated/retire-browser-catalog.mjs +8724 -0
- package/lib/generated/unicode-confusables-17.mjs +93 -0
- package/lib/hostname.mjs +38 -0
- package/lib/html-signals.mjs +680 -0
- package/lib/http-intelligence.mjs +232 -0
- package/lib/idn-confusable-policy.mjs +86 -0
- package/lib/idn-confusables.mjs +465 -0
- package/lib/lookup-cache.mjs +130 -0
- package/lib/lookup-progress.mjs +291 -0
- package/lib/lookup-source-progress.mjs +109 -0
- package/lib/lookup-threat-provider-inventory.mjs +13 -0
- package/lib/lookup.mjs +592 -0
- package/lib/observation.mjs +114 -0
- package/lib/observed-network-context.mjs +355 -0
- package/lib/operation-budget.mjs +489 -0
- package/lib/page-fingerprints.mjs +481 -0
- package/lib/page-role-profile.mjs +117 -0
- package/lib/perceptual-hash.mjs +451 -0
- package/lib/rdap-attempts.mjs +34 -0
- package/lib/rdap-bootstrap.mjs +173 -0
- package/lib/rdap-capabilities.mjs +292 -0
- package/lib/rdap-client.mjs +125 -0
- package/lib/rdap-normalization.mjs +691 -0
- package/lib/rdap-registrar.mjs +196 -0
- package/lib/rdap-transport.mjs +39 -0
- package/lib/rdap-types.mjs +1 -0
- package/lib/rdap-validation.mjs +94 -0
- package/lib/rdap.mjs +16 -0
- package/lib/registry-access.mjs +32 -0
- package/lib/registry-capabilities.mjs +117 -0
- package/lib/registry-capability-catalogue.mjs +1206 -0
- package/lib/registry-comparison.mjs +318 -0
- package/lib/registry-dates.mjs +190 -0
- package/lib/registry-insights.mjs +325 -0
- package/lib/response-policy.mjs +256 -0
- package/lib/risk-scoring.mjs +164 -0
- package/lib/safe-fetch.mjs +446 -0
- package/lib/security-txt.mjs +359 -0
- package/lib/service-binding-dns.mjs +608 -0
- package/lib/sslbl-certificates.generated.mjs +143 -0
- package/lib/sslbl-intelligence.mjs +241 -0
- package/lib/static-html-analysis.mjs +340 -0
- package/lib/structured-data-identity.mjs +323 -0
- package/lib/threat-intelligence-contract.mjs +5 -0
- package/lib/threat-intelligence-definition-registry.mjs +34 -0
- package/lib/threat-intelligence-fixtures.mjs +82 -0
- package/lib/threat-intelligence-manifests.mjs +512 -0
- package/lib/threat-intelligence-runtime.mjs +662 -0
- package/lib/threat-intelligence-targets.mjs +168 -0
- package/lib/threat-intelligence-types.mjs +3 -0
- package/lib/threatfox-intelligence.mjs +307 -0
- package/lib/tls-intelligence.mjs +814 -0
- package/lib/typosquat-generator.mjs +884 -0
- package/lib/urlhaus-intelligence.mjs +361 -0
- package/lib/urlscan-intelligence.mjs +349 -0
- package/lib/website-security-posture.mjs +325 -0
- package/lib/website-technology.mjs +437 -0
- package/lib/whois-authority.mjs +98 -0
- package/lib/whois-chain.mjs +261 -0
- package/lib/whois-contacts.mjs +163 -0
- package/lib/whois-contracts.mjs +3 -0
- package/lib/whois-normalization.mjs +27 -0
- package/lib/whois-transport.mjs +100 -0
- package/lib/whois-values.mjs +92 -0
- package/lib/whois.mjs +1187 -0
- package/package.json +55 -0
- 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
|
+
}
|