@slicedearth/whoisleuth-cli 1.45.0 → 2.0.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/DISCLOSURE +7 -3
- package/NOTICE +5 -4
- package/README.md +74 -124
- package/SECURITY.md +6 -3
- package/bin/whoisleuth.mjs +7 -4
- package/cli/archive-inspect.mjs +28 -10
- package/cli/arguments.mjs +450 -173
- package/cli/artifact-structure.mjs +2507 -0
- package/cli/artifact-verify.mjs +286 -64
- package/cli/bounded-json.mjs +1 -0
- package/cli/bulk-checkpoint.mjs +35 -23
- package/cli/bulk-command-runner.mjs +2 -5
- package/cli/bulk.mjs +9 -2
- package/cli/case-pack.mjs +435 -46
- package/cli/ci-report.mjs +6 -1
- package/cli/collection-preflight.mjs +2 -2
- package/cli/command-catalogue.mjs +12 -3
- package/cli/command-reference.mjs +948 -106
- package/cli/completion.mjs +504 -218
- package/cli/config-profile.mjs +13 -3
- package/cli/ct-event-intake.mjs +32 -2
- package/cli/discover.mjs +7 -1
- package/cli/discovery-observation-snapshot.mjs +24 -28
- package/cli/discovery-scan-command-runner.mjs +13 -14
- package/cli/discovery-scan.mjs +17 -3
- package/cli/discovery-snapshot.mjs +15 -7
- package/cli/discovery-workflow.mjs +2 -3
- package/cli/doctor.mjs +2 -2
- package/cli/domain-control-monitor.mjs +215 -36
- package/cli/domain-control-observations.mjs +34 -17
- package/cli/errors.mjs +8 -3
- package/cli/evidence-command-runner.mjs +17 -11
- package/cli/evidence-signing.mjs +68 -50
- package/cli/export-evidence.mjs +11 -4
- package/cli/external-observation-mapping.mjs +3 -3
- package/cli/formatters/html.mjs +3 -3
- package/cli/formatters/json.mjs +20 -32
- package/cli/formatters/terminal.mjs +586 -22
- package/cli/input.mjs +62 -0
- package/cli/interactive-launcher.mjs +226 -0
- package/cli/interchange-report.mjs +226 -0
- package/cli/investigation-manifest.mjs +16 -17
- package/cli/investigation-plan.mjs +167 -16
- package/cli/investigation-run.mjs +80 -24
- package/cli/local-mmdb-review.mjs +45 -14
- package/cli/lookalike-calibration.mjs +3 -1
- package/cli/lookup-brief.mjs +13 -8
- package/cli/lookup-browser.mjs +844 -0
- package/cli/lookup-command-runner.mjs +96 -33
- package/cli/lookup-diff.mjs +11 -2
- package/cli/lookup-plan.mjs +1 -0
- package/cli/mail-review.mjs +206 -94
- package/cli/manual.mjs +5 -5
- package/cli/network-command-runner.mjs +78 -0
- package/cli/offline-evidence-review.mjs +45 -14
- package/cli/open-asset-model-bridge.mjs +1 -1
- package/cli/output-file.mjs +70 -14
- package/cli/page-compare.mjs +18 -10
- package/cli/progress-events.mjs +2 -1
- package/cli/progress.mjs +3 -3
- package/cli/registry-cohort.mjs +343 -61
- package/cli/registry-doctor.mjs +67 -28
- package/cli/registry-support.mjs +2 -1
- package/cli/retained-artifact-diff.mjs +375 -0
- package/cli/risk-calibration.mjs +89 -40
- package/cli/runner.mjs +322 -246
- package/cli/saved-lookup.mjs +439 -49
- package/cli/sharing-review.mjs +18 -7
- package/cli/source-reliability.mjs +75 -36
- package/cli/terminal-presentation.mjs +43 -18
- package/docs/cli-reference.md +125 -1334
- package/docs/cli.md +87 -236
- package/frontend/src/lib/analysis/domain-control-manifest-core.js +3 -0
- package/frontend/src/lib/analysis/domain-control-records.js +3 -0
- package/lib/application-version.mjs +3 -0
- package/lib/availability.mjs +72 -52
- package/lib/bounded-contract-normalizers.mjs +2 -4
- package/lib/bounded-file.mjs +91 -4
- package/lib/bounded-json.mjs +287 -0
- package/lib/browser-library-profile.mjs +1 -1
- package/lib/certificate-extension-profile.mjs +2 -1
- package/lib/classify.mjs +56 -5
- package/lib/client-behavior-profile.mjs +1 -1
- package/lib/credential-surface-profile.mjs +1 -1
- package/lib/cryptographic-assurance.mjs +173 -0
- package/lib/ct-query.mjs +3 -3
- package/lib/ct-search.mjs +120 -64
- package/lib/distributed-operation-budget.mjs +4 -2
- package/lib/dns-delegation-health.mjs +9 -6
- package/lib/dns-intelligence.mjs +4 -3
- package/lib/dns-resolver-selection.mjs +6 -4
- package/lib/dnssec-chain-validation.mjs +1052 -0
- package/lib/dnssec-evidence-validation.mjs +23 -10
- package/lib/domain-assurance.mjs +12 -12
- package/lib/domain-change-packet.mjs +7 -5
- package/lib/domain-control-flight-recorder.mjs +442 -78
- package/lib/domain-control-manifest.mjs +314 -171
- package/lib/domain-posture-analysis.mjs +5 -2
- package/lib/domain-posture.mjs +7 -4
- package/lib/encrypted-dns-contract.mjs +2 -3
- package/lib/evidence-export.mjs +1243 -88
- package/lib/evidence-report-markdown.mjs +3 -0
- package/lib/evidence-report.mjs +5 -0
- package/lib/external-intelligence-risk.mjs +26 -10
- package/lib/favicon.mjs +8 -5
- package/lib/feature-policy.mjs +1 -1
- package/lib/homepage-metadata-contract.mjs +290 -0
- package/lib/hostname.mjs +1 -1
- package/lib/html-signals.mjs +89 -5
- package/lib/http-evidence-bounds.mjs +3 -0
- package/lib/http-intelligence.mjs +332 -6
- package/lib/interchange-fidelity-registry.mjs +124 -0
- package/lib/local-geoip-evidence.mjs +2 -1
- package/lib/lookup-cache.mjs +6 -4
- package/lib/lookup-network-evidence-bounds.mjs +9 -0
- package/lib/lookup.mjs +27 -4
- package/lib/observed-network-context.mjs +1 -1
- package/lib/outbound-identity.mjs +2 -3
- package/lib/page-fingerprints.mjs +2 -2
- package/lib/page-role-profile.mjs +1 -1
- package/lib/perceptual-hash.mjs +12 -4
- package/lib/portable-generator.mjs +29 -0
- package/lib/provider-policy-admission.mjs +11 -4
- package/lib/rdap-bootstrap.mjs +76 -16
- package/lib/rdap-client.mjs +20 -12
- package/lib/rdap-normalization.mjs +324 -80
- package/lib/rdap-registrar.mjs +5 -1
- package/lib/rdap-transport.mjs +2 -2
- package/lib/rdap-validation.mjs +65 -14
- package/lib/rdap.mjs +2 -2
- package/lib/registrable-domain.mjs +36 -0
- package/lib/registry-capabilities.mjs +3 -0
- package/lib/registry-capability-catalogue.mjs +12 -2
- package/lib/registry-comparison.mjs +28 -11
- package/lib/registry-insights.mjs +7 -0
- package/lib/response-policy.mjs +27 -4
- package/lib/risk-calibration-summary.mjs +340 -0
- package/lib/risk-scoring.mjs +4 -3
- package/lib/rpki-evidence.mjs +9 -8
- package/lib/safe-fetch.mjs +121 -50
- package/lib/security-txt.mjs +27 -4
- package/lib/service-binding-dns.mjs +6 -1
- package/lib/smtp-transport-review.mjs +787 -0
- package/lib/sslbl-certificates.generated.mjs +136 -134
- package/lib/sslbl-intelligence.mjs +6 -8
- package/lib/static-html-analysis.mjs +301 -4
- package/lib/structured-data-identity.mjs +51 -19
- package/lib/threat-intelligence-fixtures.mjs +2 -4
- package/lib/threat-intelligence-manifests.mjs +2 -7
- package/lib/threat-intelligence-runtime.mjs +5 -15
- package/lib/threat-intelligence-types.mjs +21 -1
- package/lib/threatfox-intelligence.mjs +7 -3
- package/lib/tls-intelligence.mjs +15 -9
- package/lib/tlsa-evidence.mjs +37 -7
- package/lib/urlhaus-intelligence.mjs +4 -2
- package/lib/urlscan-intelligence.mjs +6 -5
- package/lib/website-security-posture.mjs +1 -1
- package/lib/website-technology.mjs +1 -1
- package/lib/whois-authority.mjs +43 -8
- package/lib/whois-chain.mjs +25 -3
- package/lib/whois-normalization.mjs +3 -1
- package/lib/whois-transport.mjs +12 -3
- package/lib/zone-intent-review.mjs +1 -1
- package/package.json +12 -4
- package/packages/cases/case-brand-profile-references.mjs +85 -0
- package/{frontend/src/lib/analysis/case-evidence-model.js → packages/cases/case-evidence-model.mjs} +43 -14
- package/packages/cases/case-investigation-branch-model.mjs +132 -0
- package/packages/cases/case-migration-model.mjs +476 -0
- package/{frontend/src/lib/analysis/case-model.js → packages/cases/case-model.mjs} +3 -3
- package/{frontend/src/lib/analysis/case-record-contracts.js → packages/cases/case-record-contracts.mjs} +3 -19
- package/{frontend/src/lib/analysis/case-record-core.js → packages/cases/case-record-core.mjs} +58 -31
- package/packages/cases/case-record-model.mjs +9 -0
- package/{frontend/src/lib/analysis/case-record-operations.js → packages/cases/case-record-operations.mjs} +106 -33
- package/{frontend/src/lib/analysis/case-report.js → packages/cases/case-report.mjs} +143 -16
- package/packages/cases/case-response-model.mjs +1604 -0
- package/packages/cases/case-response-packet.mjs +1312 -0
- package/{frontend/src/lib/analysis/case-storage-model.js → packages/cases/case-storage-model.mjs} +2 -2
- package/{frontend/src/lib/analysis/http-summary.js → packages/cases/http-summary.mjs} +1 -1
- package/packages/cases/opaque-reference-id.mjs +9 -0
- package/packages/comparison/comparison-ledger-bulk.mjs +366 -0
- package/packages/comparison/comparison-ledger-contract.mjs +241 -0
- package/packages/comparison/comparison-ledger-json.mjs +80 -0
- package/packages/comparison/comparison-ledger-serialization.mjs +52 -0
- package/packages/comparison/favicon-similarity.mjs +78 -0
- package/{frontend/src/lib/analysis/page-similarity.js → packages/comparison/page-similarity.mjs} +4 -3
- package/{frontend/src/lib/analysis/relationship-evidence.js → packages/comparison/relationship-evidence.mjs} +6 -7
- package/packages/contracts/analyst-review-state-contract.mjs +61 -0
- package/packages/contracts/analyst-review-state.mjs +50 -0
- package/packages/contracts/case-portability.mjs +1310 -0
- package/packages/contracts/cli-command-catalogue.mjs +2 -0
- package/packages/contracts/cli-lookup.mjs +394 -0
- package/packages/contracts/domain-control-flight-recorder.mjs +461 -0
- package/packages/contracts/domain-control-manifest.mjs +1027 -0
- package/packages/contracts/domain-control-monitor.mjs +310 -0
- package/packages/contracts/domain-control-review.mjs +828 -0
- package/packages/contracts/domain-name.mjs +4 -0
- package/packages/contracts/external-observation-interchange.mjs +224 -0
- package/packages/contracts/extracted-domain-lifecycle.mjs +218 -0
- package/packages/contracts/investigation-portability.mjs +331 -0
- package/packages/contracts/offline-comparison.mjs +160 -0
- package/packages/contracts/risk-calibration.mjs +1288 -0
- package/packages/contracts/schema-compatibility.mjs +71 -0
- package/packages/contracts/schema-lifecycle.mjs +1702 -0
- package/packages/contracts/sslbl-snapshot.mjs +17 -0
- package/packages/contracts/workspace-portability.mjs +1347 -0
- package/packages/evidence/artifact-integrity.mjs +65 -0
- package/packages/evidence/decision-fact.mjs +735 -0
- package/packages/evidence/domain-control-runtime.mjs +547 -0
- package/packages/evidence/domain-name.mjs +41 -0
- package/packages/evidence/observation.mjs +384 -0
- package/{frontend/src/lib/analysis/external-findings-import.js → packages/interchange/external-findings-import.mjs} +86 -20
- package/packages/interchange/retained-text.mjs +8 -0
- package/{frontend/src/lib/analysis/acquisition-decision-packet.js → packages/investigation/acquisition-decision-packet.mjs} +11 -8
- package/{frontend/src/lib/analysis/bulk-domain-comparison.js → packages/investigation/bulk-domain-comparison.mjs} +28 -9
- package/{frontend/src/lib/analysis/bulk-mail-exposure.js → packages/investigation/bulk-mail-exposure.mjs} +67 -18
- package/{frontend/src/lib/analysis/bulk-review-export.js → packages/investigation/bulk-review-export.mjs} +10 -9
- package/packages/investigation/bulk-review-policy.mjs +1 -0
- package/{frontend/src/lib/analysis/investigation-capsule.js → packages/investigation/investigation-capsule.mjs} +50 -18
- package/packages/investigation/lookup-artefact-inputs.mjs +1 -0
- package/{frontend/src/lib/analysis/lookup-asset-graph.js → packages/investigation/lookup-asset-graph.mjs} +4 -2
- package/packages/investigation/lookup-claim-passport.mjs +232 -0
- package/packages/investigation/lookup-claim-readiness.mjs +287 -0
- package/packages/investigation/lookup-investigation-brief.mjs +172 -0
- package/packages/monitoring/analyst-review-state.mjs +680 -0
- package/{frontend/src/lib/analysis/brand-profile-model.js → packages/workspace/brand-profile-model.mjs} +97 -50
- package/{frontend/src/lib/analysis/bulk-review-model.js → packages/workspace/bulk-review-model.mjs} +15 -15
- package/{frontend/src/lib/analysis/bulk-session-model.js → packages/workspace/bulk-session-model.mjs} +179 -39
- package/{frontend/src/lib/analysis/bulk-sort.js → packages/workspace/bulk-sort.mjs} +12 -5
- package/{frontend/src/lib/analysis/campaign-model.js → packages/workspace/campaign-model.mjs} +20 -22
- package/{frontend/src/lib/analysis/detection-rule-model.js → packages/workspace/detection-rule-model.mjs} +15 -17
- package/packages/workspace/hostile-input.mjs +172 -0
- package/{frontend/src/lib/analysis/investigation-guide.js → packages/workspace/investigation-guide.mjs} +51 -46
- package/{frontend/src/lib/analysis/investigation-template-model.js → packages/workspace/investigation-template-model.mjs} +15 -15
- package/{frontend/src/lib/analysis/page-baseline.js → packages/workspace/page-baseline.mjs} +6 -13
- package/{frontend/src/lib/analysis/relationship-observation-model.js → packages/workspace/relationship-observation-model.mjs} +13 -17
- package/{frontend/src/lib/analysis/shortlist-model.js → packages/workspace/shortlist-model.mjs} +20 -18
- package/{frontend/src/lib/analysis/watchlist-history.js → packages/workspace/watchlist-history.mjs} +9 -16
- package/{frontend/src/lib/analysis/watchlist-store.js → packages/workspace/watchlist-store.mjs} +24 -17
- package/{frontend/src/lib/analysis/website-snapshot-model.js → packages/workspace/website-snapshot-model.mjs} +18 -12
- package/{frontend/src/lib/analysis/workspace-archive-crypto.js → packages/workspace/workspace-archive-crypto.mjs} +49 -19
- package/{frontend/src/lib/analysis/workspace-archive.js → packages/workspace/workspace-archive.mjs} +195 -84
- package/third-party-notices.txt +112 -5834
- package/frontend/src/lib/analysis/artifact-integrity.js +0 -21
- package/frontend/src/lib/analysis/bulk-retry-plan.js +0 -79
- package/frontend/src/lib/analysis/bulk-source-coverage.js +0 -76
- package/frontend/src/lib/analysis/case-migration-model.js +0 -253
- package/frontend/src/lib/analysis/case-record-model.js +0 -7
- package/frontend/src/lib/analysis/case-response-model.js +0 -669
- package/frontend/src/lib/analysis/case-response-packet.js +0 -573
- package/frontend/src/lib/analysis/lookup-decision-support.js +0 -487
- package/frontend/src/lib/analysis/lookup-investigation-brief.js +0 -153
- package/frontend/src/lib/analysis/lookup-presentation.js +0 -58
- package/frontend/src/lib/analysis/lookup-response.js +0 -2
- package/frontend/src/lib/analysis/lookup-source-refresh.js +0 -277
- package/frontend/src/lib/analysis/lookup-summary-model.js +0 -398
- package/frontend/src/lib/analysis/scoring.js +0 -55
- package/frontend/src/lib/analysis/utils.js +0 -232
- package/frontend/src/lib/analysis/visualization-bounds.js +0 -26
- package/frontend/src/lib/analysis/visualization-force-graph.js +0 -352
- package/frontend/src/lib/analysis/visualization-matrix.js +0 -88
- package/frontend/src/lib/analysis/visualization-models.js +0 -369
- package/frontend/src/lib/analysis/visualization-time-series.js +0 -144
- package/lib/bounded-json-response.mjs +0 -157
- package/lib/lookup-response-contract.mjs +0 -346
- package/lib/observation.mjs +0 -114
- /package/{frontend/src/lib/analysis/acquisition-due-diligence.js → packages/investigation/acquisition-due-diligence.mjs} +0 -0
- /package/{frontend/src/lib/analysis/evidence-coverage-ledger.js → packages/investigation/evidence-coverage-ledger.mjs} +0 -0
- /package/{frontend/src/lib/analysis/dns-record-normalization.js → packages/workspace/dns-record-normalization.mjs} +0 -0
|
@@ -1,398 +0,0 @@
|
|
|
1
|
-
import { fmtAge, fmtExpiresIn, formatActivityCell, formatPrivacyCell, } from "./scoring.js";
|
|
2
|
-
const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f]/gu;
|
|
3
|
-
const MAX_SIGNALS = 12;
|
|
4
|
-
const MAX_PROVENANCE_ITEMS = 8;
|
|
5
|
-
const MAX_PROVENANCE_TEXT = 320;
|
|
6
|
-
function record(value) {
|
|
7
|
-
return value && typeof value === 'object' && !Array.isArray(value)
|
|
8
|
-
? value
|
|
9
|
-
: {};
|
|
10
|
-
}
|
|
11
|
-
function boundedText(value, maximum = 320) {
|
|
12
|
-
return String(value ?? '')
|
|
13
|
-
.replace(CONTROL_CHARACTERS, ' ')
|
|
14
|
-
.replace(/\s+/gu, ' ')
|
|
15
|
-
.trim()
|
|
16
|
-
.slice(0, maximum);
|
|
17
|
-
}
|
|
18
|
-
function display(value) {
|
|
19
|
-
if (value === null || value === undefined || value === '')
|
|
20
|
-
return '—';
|
|
21
|
-
if (Array.isArray(value)) {
|
|
22
|
-
return value.slice(0, 16).map((item) => boundedText(item, 120)).filter(Boolean).join(', ') || '—';
|
|
23
|
-
}
|
|
24
|
-
if (typeof value === 'object') {
|
|
25
|
-
const item = record(value);
|
|
26
|
-
return display(item.name || item.org || item.handle || item.domain);
|
|
27
|
-
}
|
|
28
|
-
return boundedText(value) || '—';
|
|
29
|
-
}
|
|
30
|
-
function textOrNull(value) {
|
|
31
|
-
const valueText = typeof value === 'string' ? boundedText(value) : '';
|
|
32
|
-
return valueText || null;
|
|
33
|
-
}
|
|
34
|
-
function records(value) {
|
|
35
|
-
return Array.isArray(value)
|
|
36
|
-
? value.slice(0, 24).map(record).filter((item) => Object.keys(item).length > 0)
|
|
37
|
-
: [];
|
|
38
|
-
}
|
|
39
|
-
function textList(value, maximum = MAX_PROVENANCE_ITEMS) {
|
|
40
|
-
if (!Array.isArray(value))
|
|
41
|
-
return [];
|
|
42
|
-
const output = [];
|
|
43
|
-
const seen = new Set();
|
|
44
|
-
for (const item of value) {
|
|
45
|
-
const text = boundedText(item, MAX_PROVENANCE_TEXT);
|
|
46
|
-
if (!text || seen.has(text))
|
|
47
|
-
continue;
|
|
48
|
-
output.push(text);
|
|
49
|
-
seen.add(text);
|
|
50
|
-
if (output.length >= maximum)
|
|
51
|
-
break;
|
|
52
|
-
}
|
|
53
|
-
return output;
|
|
54
|
-
}
|
|
55
|
-
function formatDate(value) {
|
|
56
|
-
if (!value)
|
|
57
|
-
return '—';
|
|
58
|
-
const parsed = new Date(String(value));
|
|
59
|
-
return Number.isNaN(parsed.getTime()) ? boundedText(value) || '—' : parsed.toLocaleString();
|
|
60
|
-
}
|
|
61
|
-
function statusLabel(value) {
|
|
62
|
-
return boundedText(value, 64).replaceAll('_', ' ') || 'unknown';
|
|
63
|
-
}
|
|
64
|
-
function diagnosticDetail(value) {
|
|
65
|
-
const source = record(value);
|
|
66
|
-
const attempts = Array.isArray(source.attempts)
|
|
67
|
-
? source.attempts.slice(0, 8).map((attempt) => (statusLabel(record(attempt).outcome))).join(' → ')
|
|
68
|
-
: '';
|
|
69
|
-
const parts = [
|
|
70
|
-
boundedText(source.endpoint, 2_048),
|
|
71
|
-
source.transportSecurity === 'http' ? 'transport: cleartext HTTP' : '',
|
|
72
|
-
typeof source.httpStatus === 'number' ? `HTTP ${source.httpStatus}` : '',
|
|
73
|
-
attempts ? `attempts: ${attempts}` : '',
|
|
74
|
-
source.resultState ? `result: ${boundedText(source.resultState, 80)}` : '',
|
|
75
|
-
boundedText(source.errorCode, 120),
|
|
76
|
-
source.authoritativeHop ? `authoritative: ${display(source.authoritativeHop)}` : '',
|
|
77
|
-
source.failedHop ? `failed: ${display(source.failedHop)}` : '',
|
|
78
|
-
source.fetchedAt ? `fetched ${formatDate(source.fetchedAt)}` : '',
|
|
79
|
-
source.queriedAt ? `queried ${formatDate(source.queriedAt)}` : '',
|
|
80
|
-
].filter(Boolean);
|
|
81
|
-
return boundedText(parts.join(' · '), 2_400) || 'No additional source detail';
|
|
82
|
-
}
|
|
83
|
-
function sourceLabel(value) {
|
|
84
|
-
return {
|
|
85
|
-
dns: 'Authoritative DNS delegation',
|
|
86
|
-
rdap: 'Registry RDAP',
|
|
87
|
-
whois: 'WHOIS',
|
|
88
|
-
}[boundedText(value, 32)] || 'No conclusive registration source';
|
|
89
|
-
}
|
|
90
|
-
function sourceObservation(source, diagnostics, fallback) {
|
|
91
|
-
const sourceId = boundedText(source, 32);
|
|
92
|
-
const diagnostic = sourceId === 'dns'
|
|
93
|
-
? record(diagnostics.availability)
|
|
94
|
-
: record(diagnostics[sourceId]);
|
|
95
|
-
return formatDate(diagnostic.fetchedAt
|
|
96
|
-
|| diagnostic.queriedAt
|
|
97
|
-
|| diagnostic.observedAt
|
|
98
|
-
|| fallback);
|
|
99
|
-
}
|
|
100
|
-
function sourceCompleteness(source, diagnostics) {
|
|
101
|
-
const sourceId = boundedText(source, 32);
|
|
102
|
-
const diagnostic = sourceId === 'dns'
|
|
103
|
-
? record(diagnostics.availability)
|
|
104
|
-
: record(diagnostics[sourceId]);
|
|
105
|
-
return statusLabel(diagnostic.status);
|
|
106
|
-
}
|
|
107
|
-
function registryLimitations(availability, rdapParsed, whoisParsed) {
|
|
108
|
-
const output = [
|
|
109
|
-
...textList(availability.limitations),
|
|
110
|
-
...textList(whoisParsed.limitations),
|
|
111
|
-
];
|
|
112
|
-
if (rdapParsed.serverTruncated === true) {
|
|
113
|
-
output.push('The registry declared that the RDAP response omitted some data.');
|
|
114
|
-
output.push(...textList(rdapParsed.serverTruncationReasons));
|
|
115
|
-
}
|
|
116
|
-
return textList(output);
|
|
117
|
-
}
|
|
118
|
-
function comparisonConflicts(value, labels) {
|
|
119
|
-
const accepted = new Set(labels);
|
|
120
|
-
return records(record(value).fields)
|
|
121
|
-
.filter((field) => accepted.has(boundedText(field.label, 120)))
|
|
122
|
-
.filter((field) => boundedText(field.status, 64) === 'conflict')
|
|
123
|
-
.map((field) => boundedText(field.assessment
|
|
124
|
-
|| `${display(field.rdapDisplay || field.registryDisplay)} differs from ${display(field.whoisDisplay || field.registrarDisplay)}`, MAX_PROVENANCE_TEXT))
|
|
125
|
-
.filter(Boolean)
|
|
126
|
-
.slice(0, MAX_PROVENANCE_ITEMS);
|
|
127
|
-
}
|
|
128
|
-
function factProvenance(input) {
|
|
129
|
-
return {
|
|
130
|
-
sources: [sourceLabel(input.source)],
|
|
131
|
-
observedAt: sourceObservation(input.source, input.diagnostics, input.observedAt),
|
|
132
|
-
fieldFamilies: input.fields.map((item) => boundedText(item, 160)).filter(Boolean),
|
|
133
|
-
normalization: boundedText(input.normalization, MAX_PROVENANCE_TEXT),
|
|
134
|
-
completeness: sourceCompleteness(input.source, input.diagnostics),
|
|
135
|
-
limitations: textList(input.limitations),
|
|
136
|
-
conflicts: textList(input.conflicts),
|
|
137
|
-
decisionImpact: boundedText(input.decisionImpact, MAX_PROVENANCE_TEXT),
|
|
138
|
-
};
|
|
139
|
-
}
|
|
140
|
-
function pushSignal(signals, signal) {
|
|
141
|
-
if (signal && signals.length < MAX_SIGNALS)
|
|
142
|
-
signals.push(signal);
|
|
143
|
-
}
|
|
144
|
-
function buildSignals(availability, profileSignals, idnAnalysis) {
|
|
145
|
-
const signals = [];
|
|
146
|
-
const trusted = boundedText(profileSignals.trusted, 80);
|
|
147
|
-
if (trusted)
|
|
148
|
-
pushSignal(signals, { label: `Trusted ${trusted}`, tone: 'good' });
|
|
149
|
-
if (profileSignals.faviconMatch === true) {
|
|
150
|
-
pushSignal(signals, { label: 'Favicon match', tone: 'danger' });
|
|
151
|
-
}
|
|
152
|
-
else if (profileSignals.faviconNearMatch === true) {
|
|
153
|
-
pushSignal(signals, { label: 'Favicon near-match', tone: 'warn' });
|
|
154
|
-
}
|
|
155
|
-
if (profileSignals.reusesOfficialAssets === true) {
|
|
156
|
-
pushSignal(signals, { label: 'Reuses official assets', tone: 'danger' });
|
|
157
|
-
}
|
|
158
|
-
if (availability.hasPasswordField === true) {
|
|
159
|
-
pushSignal(signals, { label: 'Password field', tone: 'warn' });
|
|
160
|
-
}
|
|
161
|
-
const phishingLanguage = textOrNull(availability.phishingLanguageMatch);
|
|
162
|
-
if (phishingLanguage) {
|
|
163
|
-
pushSignal(signals, {
|
|
164
|
-
label: 'Phishing language',
|
|
165
|
-
tone: 'danger',
|
|
166
|
-
detail: phishingLanguage,
|
|
167
|
-
});
|
|
168
|
-
}
|
|
169
|
-
if (idnAnalysis.mixedScript === true) {
|
|
170
|
-
pushSignal(signals, {
|
|
171
|
-
label: 'Mixed-script IDN',
|
|
172
|
-
tone: 'warn',
|
|
173
|
-
detail: 'The Unicode label combines writing scripts.',
|
|
174
|
-
});
|
|
175
|
-
}
|
|
176
|
-
if (Array.isArray(idnAnalysis.referenceMatches) && idnAnalysis.referenceMatches.length > 0) {
|
|
177
|
-
pushSignal(signals, {
|
|
178
|
-
label: 'Official-domain skeleton match',
|
|
179
|
-
tone: 'warn',
|
|
180
|
-
detail: 'A bounded visual skeleton matches an official domain in the active brand profile.',
|
|
181
|
-
});
|
|
182
|
-
}
|
|
183
|
-
const domainAgeDays = typeof availability.domainAgeDays === 'number'
|
|
184
|
-
? availability.domainAgeDays
|
|
185
|
-
: null;
|
|
186
|
-
const age = fmtAge(domainAgeDays);
|
|
187
|
-
if (age)
|
|
188
|
-
pushSignal(signals, { label: age, tone: 'neutral' });
|
|
189
|
-
const expiresInDays = typeof availability.expiresInDays === 'number'
|
|
190
|
-
? availability.expiresInDays
|
|
191
|
-
: null;
|
|
192
|
-
const expiry = fmtExpiresIn(expiresInDays);
|
|
193
|
-
if (expiry && expiresInDays !== null) {
|
|
194
|
-
pushSignal(signals, {
|
|
195
|
-
label: expiry,
|
|
196
|
-
tone: expiresInDays <= 60 ? 'warn' : 'neutral',
|
|
197
|
-
});
|
|
198
|
-
}
|
|
199
|
-
if (typeof availability.privacyProtected === 'boolean') {
|
|
200
|
-
pushSignal(signals, {
|
|
201
|
-
label: formatPrivacyCell(availability.privacyProtected),
|
|
202
|
-
tone: availability.privacyProtected ? 'warn' : 'good',
|
|
203
|
-
});
|
|
204
|
-
}
|
|
205
|
-
const activityStatus = textOrNull(availability.activityStatus);
|
|
206
|
-
if (activityStatus) {
|
|
207
|
-
const detail = textOrNull(availability.websiteProbeDetail);
|
|
208
|
-
pushSignal(signals, {
|
|
209
|
-
label: formatActivityCell(activityStatus, availability.hasMx === true, availability.hasSpf === true, availability.hasDmarc === true),
|
|
210
|
-
tone: activityStatus === 'active'
|
|
211
|
-
? 'good'
|
|
212
|
-
: activityStatus === 'parked'
|
|
213
|
-
? 'warn'
|
|
214
|
-
: 'neutral',
|
|
215
|
-
...(detail ? { detail } : {}),
|
|
216
|
-
});
|
|
217
|
-
}
|
|
218
|
-
return signals;
|
|
219
|
-
}
|
|
220
|
-
function diagnosticAttempts(value) {
|
|
221
|
-
return records(value).slice(0, 8).map((attempt) => ({
|
|
222
|
-
endpoint: boundedText(attempt.endpoint, 2_048) || 'Endpoint not recorded',
|
|
223
|
-
outcome: statusLabel(attempt.outcome),
|
|
224
|
-
detail: boundedText(typeof attempt.status === 'number'
|
|
225
|
-
? `HTTP ${attempt.status}${attempt.detail ? ` · ${boundedText(attempt.detail, 240)}` : ''}`
|
|
226
|
-
: attempt.detail, MAX_PROVENANCE_TEXT) || 'No additional attempt detail',
|
|
227
|
-
}));
|
|
228
|
-
}
|
|
229
|
-
function buildDiagnostics(diagnostics, rdapParsed, whoisParsed, registrarRdap) {
|
|
230
|
-
const sources = ['rdap', 'whois', 'availability'].map((source) => {
|
|
231
|
-
const item = record(diagnostics[source]);
|
|
232
|
-
const status = boundedText(item.status, 64);
|
|
233
|
-
const isRdap = source === 'rdap';
|
|
234
|
-
const isWhois = source === 'whois';
|
|
235
|
-
return {
|
|
236
|
-
source,
|
|
237
|
-
status,
|
|
238
|
-
label: statusLabel(status),
|
|
239
|
-
detail: diagnosticDetail(item),
|
|
240
|
-
endpoint: boundedText(item.endpoint, 2_048),
|
|
241
|
-
route: isRdap
|
|
242
|
-
? 'The endpoint was selected through IANA RDAP bootstrap discovery and bounded endpoint failover.'
|
|
243
|
-
: isWhois
|
|
244
|
-
? 'The response follows the bounded WHOIS registry and referral chain shown below.'
|
|
245
|
-
: 'This derived state applies the authority-aware availability rules to the collected registration evidence.',
|
|
246
|
-
observedAt: formatDate(item.fetchedAt || item.queriedAt || item.observedAt),
|
|
247
|
-
conformance: isRdap ? textList(rdapParsed.conformance) : [],
|
|
248
|
-
limitations: isRdap
|
|
249
|
-
? registryLimitations({}, rdapParsed, {})
|
|
250
|
-
: isWhois
|
|
251
|
-
? textList(whoisParsed.limitations)
|
|
252
|
-
: [],
|
|
253
|
-
attempts: diagnosticAttempts(item.attempts),
|
|
254
|
-
};
|
|
255
|
-
});
|
|
256
|
-
if (registrarRdap.status) {
|
|
257
|
-
const registrarDiagnostic = record(record(diagnostics.rdap).registrar);
|
|
258
|
-
const status = boundedText(registrarRdap.status || registrarDiagnostic.status, 64);
|
|
259
|
-
sources.push({
|
|
260
|
-
source: 'registrar RDAP',
|
|
261
|
-
status,
|
|
262
|
-
label: statusLabel(status),
|
|
263
|
-
detail: diagnosticDetail(registrarDiagnostic),
|
|
264
|
-
endpoint: boundedText(registrarRdap.endpoint || registrarDiagnostic.endpoint, 2_048),
|
|
265
|
-
route: 'This is a separately attributed registrar publication discovered from the registry RDAP response.',
|
|
266
|
-
observedAt: formatDate(registrarRdap.fetchedAt || registrarDiagnostic.fetchedAt),
|
|
267
|
-
conformance: textList(record(registrarRdap.parsed).conformance),
|
|
268
|
-
limitations: textList(registrarRdap.limitations),
|
|
269
|
-
attempts: diagnosticAttempts(registrarRdap.attempt ? [registrarRdap.attempt] : registrarDiagnostic.attempts),
|
|
270
|
-
});
|
|
271
|
-
}
|
|
272
|
-
const reverseDns = record(diagnostics.reverseDns);
|
|
273
|
-
if (reverseDns.status) {
|
|
274
|
-
const status = boundedText(reverseDns.status, 64);
|
|
275
|
-
sources.push({
|
|
276
|
-
source: 'reverse DNS',
|
|
277
|
-
status,
|
|
278
|
-
label: statusLabel(status),
|
|
279
|
-
detail: diagnosticDetail(reverseDns),
|
|
280
|
-
endpoint: '',
|
|
281
|
-
route: 'PTR evidence is non-authoritative context published by the address operator.',
|
|
282
|
-
observedAt: formatDate(reverseDns.observedAt),
|
|
283
|
-
conformance: [],
|
|
284
|
-
limitations: [],
|
|
285
|
-
attempts: [],
|
|
286
|
-
});
|
|
287
|
-
}
|
|
288
|
-
return sources;
|
|
289
|
-
}
|
|
290
|
-
export function buildLookupSummaryModel(input) {
|
|
291
|
-
const availability = record(input.availability);
|
|
292
|
-
const rdapParsed = record(input.rdapParsed);
|
|
293
|
-
const whoisParsed = record(input.whoisParsed);
|
|
294
|
-
const diagnostics = record(input.diagnostics);
|
|
295
|
-
const source = availability.source;
|
|
296
|
-
const limitations = registryLimitations(availability, rdapParsed, whoisParsed);
|
|
297
|
-
const registryComparison = input.registryComparison;
|
|
298
|
-
const registrarPublicationComparison = input.registrarPublicationComparison;
|
|
299
|
-
return {
|
|
300
|
-
signals: buildSignals(availability, record(input.profileSignals), record(input.idnAnalysis)),
|
|
301
|
-
facts: [
|
|
302
|
-
{
|
|
303
|
-
label: 'Registration',
|
|
304
|
-
value: display(availability.state || whoisParsed.registrationStatus),
|
|
305
|
-
detail: `${display(availability.confidence)} confidence`,
|
|
306
|
-
provenance: factProvenance({
|
|
307
|
-
source,
|
|
308
|
-
diagnostics,
|
|
309
|
-
observedAt: input.resultObservedAt,
|
|
310
|
-
fields: ['RDAP object state', 'WHOIS registration status', 'authoritative DNS delegation'],
|
|
311
|
-
normalization: 'Authority-aware registration evidence is normalised into a stable availability state while inconclusive sources remain unknown.',
|
|
312
|
-
limitations,
|
|
313
|
-
conflicts: comparisonConflicts(registryComparison, ['Registration status']),
|
|
314
|
-
decisionImpact: 'This is the authority-aware input to the availability result. This inspector does not recalculate or override that decision.',
|
|
315
|
-
}),
|
|
316
|
-
},
|
|
317
|
-
{
|
|
318
|
-
label: 'Registrar',
|
|
319
|
-
value: display(availability.registrar || rdapParsed.registrar || whoisParsed.registrar),
|
|
320
|
-
detail: display(whoisParsed.registrarUrl),
|
|
321
|
-
provenance: factProvenance({
|
|
322
|
-
source,
|
|
323
|
-
diagnostics,
|
|
324
|
-
observedAt: input.resultObservedAt,
|
|
325
|
-
fields: ['RDAP registrar entity', 'WHOIS Registrar', 'registrar RDAP registrar entity'],
|
|
326
|
-
normalization: 'Structured registrar identities are reduced to a bounded display name while each registry, registrar, and WHOIS publication remains separate.',
|
|
327
|
-
limitations,
|
|
328
|
-
conflicts: [
|
|
329
|
-
...comparisonConflicts(registryComparison, ['Registrar', 'Registrar IANA ID']),
|
|
330
|
-
...comparisonConflicts(registrarPublicationComparison, ['Registrar', 'Registrar IANA ID']),
|
|
331
|
-
],
|
|
332
|
-
decisionImpact: 'Registrar identity is investigative and contact context. It does not decide whether the domain exists or establish ownership.',
|
|
333
|
-
}),
|
|
334
|
-
},
|
|
335
|
-
{
|
|
336
|
-
label: 'Created',
|
|
337
|
-
value: formatDate(input.createdDate),
|
|
338
|
-
detail: fmtAge(typeof availability.domainAgeDays === 'number' ? availability.domainAgeDays : null) || 'Registry lifecycle date',
|
|
339
|
-
provenance: factProvenance({
|
|
340
|
-
source,
|
|
341
|
-
diagnostics,
|
|
342
|
-
observedAt: input.resultObservedAt,
|
|
343
|
-
fields: ['RDAP registration event or lifecycle', 'WHOIS creation-date family'],
|
|
344
|
-
normalization: 'Recognised lifecycle dates are parsed into an ISO-compatible instant when possible; the original source publication remains authoritative.',
|
|
345
|
-
limitations,
|
|
346
|
-
conflicts: comparisonConflicts(registryComparison, ['Created']),
|
|
347
|
-
decisionImpact: 'Domain age may contribute to an explainable score factor. It does not prove intent, ownership, or continuous operation.',
|
|
348
|
-
}),
|
|
349
|
-
},
|
|
350
|
-
{
|
|
351
|
-
label: 'Expires',
|
|
352
|
-
value: formatDate(input.expiresDate),
|
|
353
|
-
detail: fmtExpiresIn(typeof availability.expiresInDays === 'number' ? availability.expiresInDays : null) || 'Registry lifecycle date',
|
|
354
|
-
provenance: factProvenance({
|
|
355
|
-
source,
|
|
356
|
-
diagnostics,
|
|
357
|
-
observedAt: input.resultObservedAt,
|
|
358
|
-
fields: ['RDAP expiration event or lifecycle', 'WHOIS expiry-date family'],
|
|
359
|
-
normalization: 'Recognised expiry dates are parsed into an ISO-compatible instant when possible without predicting deletion or release.',
|
|
360
|
-
limitations,
|
|
361
|
-
conflicts: comparisonConflicts(registryComparison, ['Expires']),
|
|
362
|
-
decisionImpact: 'Expiry proximity may contribute to an explainable opportunity factor. It does not predict renewal, deletion, release, price, or eligibility.',
|
|
363
|
-
}),
|
|
364
|
-
},
|
|
365
|
-
{
|
|
366
|
-
label: 'Updated',
|
|
367
|
-
value: formatDate(input.updatedDate),
|
|
368
|
-
detail: 'Most recent registry change',
|
|
369
|
-
provenance: factProvenance({
|
|
370
|
-
source,
|
|
371
|
-
diagnostics,
|
|
372
|
-
observedAt: input.resultObservedAt,
|
|
373
|
-
fields: ['RDAP last-changed event or lifecycle', 'WHOIS updated-date family'],
|
|
374
|
-
normalization: 'Recognised update dates are parsed into an ISO-compatible instant when possible and remain a source-reported lifecycle observation.',
|
|
375
|
-
limitations,
|
|
376
|
-
conflicts: comparisonConflicts(registryComparison, ['Updated']),
|
|
377
|
-
decisionImpact: 'The update date is contextual evidence only and does not establish what changed or whether a website is active.',
|
|
378
|
-
}),
|
|
379
|
-
},
|
|
380
|
-
{
|
|
381
|
-
label: 'Website',
|
|
382
|
-
value: display(availability.activityStatus),
|
|
383
|
-
detail: display(availability.websiteProbeDetail),
|
|
384
|
-
provenance: {
|
|
385
|
-
sources: ['Bounded homepage and favicon observation'],
|
|
386
|
-
observedAt: formatDate(input.resultObservedAt),
|
|
387
|
-
fieldFamilies: ['HTTP response', 'redirect chain', 'bounded homepage body', 'favicon response'],
|
|
388
|
-
normalization: 'Observed responses are classified into active, parked, inactive, or unknown context without executing page scripts.',
|
|
389
|
-
completeness: availability.deepScanComplete === false ? 'partial' : 'complete',
|
|
390
|
-
limitations: textList(record(availability.http).limitations),
|
|
391
|
-
conflicts: [],
|
|
392
|
-
decisionImpact: 'Website activity can inform explainable analysis. A failed or missing response never implies inactivity, safety, or domain availability.',
|
|
393
|
-
},
|
|
394
|
-
},
|
|
395
|
-
],
|
|
396
|
-
diagnostics: buildDiagnostics(diagnostics, rdapParsed, whoisParsed, record(input.registrarRdap)),
|
|
397
|
-
};
|
|
398
|
-
}
|
|
@@ -1,55 +0,0 @@
|
|
|
1
|
-
export { RISK_MODEL_VERSION, RISK_REVIEW_THRESHOLD, buildRiskScoreSensitivity, computeRiskScore, explainRiskScore, formatScoreBreakdown, normalizeRiskModelVersion, riskTone, } from "../../../../lib/risk-scoring.mjs";
|
|
2
|
-
export { OPPORTUNITY_MODEL_VERSION, OPPORTUNITY_REVIEW_THRESHOLD, computeOpportunityScore, explainOpportunityScore, normalizeOpportunityModelVersion, opportunityTone, } from "../../../../lib/opportunity-scoring.mjs";
|
|
3
|
-
export function fmtAge(value) {
|
|
4
|
-
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0)
|
|
5
|
-
return null;
|
|
6
|
-
const days = Math.round(value);
|
|
7
|
-
if (days < 60)
|
|
8
|
-
return `${days}d old`;
|
|
9
|
-
const years = days / 365.25;
|
|
10
|
-
return years < 1 ? `${Math.round(days / 30)}mo old` : `${years.toFixed(1)}y old`;
|
|
11
|
-
}
|
|
12
|
-
export function fmtExpiresIn(value) {
|
|
13
|
-
if (typeof value !== 'number' || !Number.isFinite(value))
|
|
14
|
-
return null;
|
|
15
|
-
const days = Math.round(value);
|
|
16
|
-
return days < 0 ? `expired ${Math.abs(days)}d ago` : `expires in ${days}d`;
|
|
17
|
-
}
|
|
18
|
-
export const ACTIVITY_LABELS = {
|
|
19
|
-
active: 'Active site',
|
|
20
|
-
parked: 'Parked / for-sale page',
|
|
21
|
-
unreachable: 'Website check inconclusive',
|
|
22
|
-
// Kept only so older saved watchlist snapshots remain readable. New scans
|
|
23
|
-
// use `unreachable`, because a fetch failure never proves that no site exists.
|
|
24
|
-
no_site: 'No site reported (legacy)',
|
|
25
|
-
};
|
|
26
|
-
export function formatPrivacyCell(v) {
|
|
27
|
-
if (v === true)
|
|
28
|
-
return 'Privacy protected';
|
|
29
|
-
if (v === false)
|
|
30
|
-
return 'Public registrant data';
|
|
31
|
-
return '—';
|
|
32
|
-
}
|
|
33
|
-
export function formatActivityCell(v, hasMx, hasSpf, hasDmarc) {
|
|
34
|
-
const label = typeof v === 'string' && v in ACTIVITY_LABELS
|
|
35
|
-
? ACTIVITY_LABELS[v]
|
|
36
|
-
: '—';
|
|
37
|
-
const mailParts = [];
|
|
38
|
-
if (hasMx === true)
|
|
39
|
-
mailParts.push('MX');
|
|
40
|
-
if (hasSpf === true)
|
|
41
|
-
mailParts.push('SPF');
|
|
42
|
-
if (hasDmarc === true)
|
|
43
|
-
mailParts.push('DMARC');
|
|
44
|
-
return mailParts.length ? `${label} · ${mailParts.join('+')}` : label;
|
|
45
|
-
}
|
|
46
|
-
export function scoreTone(score) {
|
|
47
|
-
// Kept as a compatibility name for existing UI callers.
|
|
48
|
-
if (score === null)
|
|
49
|
-
return 'neutral';
|
|
50
|
-
if (score >= 70)
|
|
51
|
-
return 'good';
|
|
52
|
-
if (score >= 40)
|
|
53
|
-
return 'neutral';
|
|
54
|
-
return 'warn';
|
|
55
|
-
}
|
|
@@ -1,232 +0,0 @@
|
|
|
1
|
-
// Browser-safe analysis helpers shared by the Svelte tools.
|
|
2
|
-
import { serializeCsvCell, serializeCsvRows } from "../../../../lib/csv.mjs";
|
|
3
|
-
import { hammingDistanceHex, isInformativePerceptualHash, isPerceptualHash, } from "../../../../lib/perceptual-hash-comparison.mjs";
|
|
4
|
-
// Deliberately conservative (no +tags, no comments, no quoted local parts) -
|
|
5
|
-
// this only gates whether a WHOIS/RDAP-sourced string is safe to drop into a
|
|
6
|
-
// mailto: URI as the recipient, not a general email validator. mailto:
|
|
7
|
-
// treats a comma as an additional-recipient separator (RFC 6068), so a
|
|
8
|
-
// registrant/abuse-contact field containing e.g. "person@example.test,cc@hostile.example"
|
|
9
|
-
// would silently add a second recipient to the outreach/abuse draft the user
|
|
10
|
-
// opens - rejecting anything outside a single plain address closes that off.
|
|
11
|
-
const SIMPLE_EMAIL_RE = /^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/;
|
|
12
|
-
const MAX_ENTITY_DISPLAY_LENGTH = 300;
|
|
13
|
-
export function isValidEmailAddress(value) {
|
|
14
|
-
return typeof value === 'string' && SIMPLE_EMAIL_RE.test(value.trim());
|
|
15
|
-
}
|
|
16
|
-
// RDAP and compact availability results represent a registrar as an entity,
|
|
17
|
-
// while some WHOIS paths retain the historical string field. Normalize both
|
|
18
|
-
// forms before they reach UI summaries or browser-local evidence; blindly
|
|
19
|
-
// calling String() on an entity would persist "[object Object]" and hide real
|
|
20
|
-
// registrar changes. The backend already bounds these values, but this client
|
|
21
|
-
// boundary revalidates the API response before retaining it.
|
|
22
|
-
function plainRecord(value) {
|
|
23
|
-
return value && typeof value === 'object' && !Array.isArray(value)
|
|
24
|
-
? value
|
|
25
|
-
: null;
|
|
26
|
-
}
|
|
27
|
-
export function entityDisplayName(value) {
|
|
28
|
-
let candidate = value;
|
|
29
|
-
const record = plainRecord(value);
|
|
30
|
-
if (record) {
|
|
31
|
-
candidate = record.name || record.org || record.handle;
|
|
32
|
-
}
|
|
33
|
-
if (typeof candidate !== 'string' && typeof candidate !== 'number')
|
|
34
|
-
return null;
|
|
35
|
-
const normalized = String(candidate)
|
|
36
|
-
.replace(/[\u0000-\u001f\u007f]/g, ' ')
|
|
37
|
-
.replace(/\s+/g, ' ')
|
|
38
|
-
.trim()
|
|
39
|
-
.slice(0, MAX_ENTITY_DISPLAY_LENGTH);
|
|
40
|
-
return normalized || null;
|
|
41
|
-
}
|
|
42
|
-
export { hammingDistanceHex };
|
|
43
|
-
// A dHash carries usable structure only when its set-bit count is away from
|
|
44
|
-
// the degenerate extremes. The shared browser-safe contract applies the same
|
|
45
|
-
// threshold during generation and comparison, including for older retained
|
|
46
|
-
// profile values.
|
|
47
|
-
export function isInformativeFaviconHash(hex) {
|
|
48
|
-
return isInformativePerceptualHash(hex);
|
|
49
|
-
}
|
|
50
|
-
function faviconRecord(value) {
|
|
51
|
-
const record = plainRecord(value);
|
|
52
|
-
if (!record || typeof record.domain !== 'string')
|
|
53
|
-
return null;
|
|
54
|
-
const faviconHash = typeof record.faviconHash === 'string' && record.faviconHash
|
|
55
|
-
? record.faviconHash
|
|
56
|
-
: null;
|
|
57
|
-
const faviconPHash = isPerceptualHash(record.faviconPHash)
|
|
58
|
-
? record.faviconPHash
|
|
59
|
-
: null;
|
|
60
|
-
return faviconHash || faviconPHash
|
|
61
|
-
? { domain: record.domain, faviconHash, faviconPHash }
|
|
62
|
-
: null;
|
|
63
|
-
}
|
|
64
|
-
export function groupBySimilarFavicon(records, maxDistance) {
|
|
65
|
-
const items = Array.isArray(records)
|
|
66
|
-
? records.map(faviconRecord).filter((record) => record !== null)
|
|
67
|
-
: [];
|
|
68
|
-
const parent = items.map((_, i) => i);
|
|
69
|
-
const find = (x) => {
|
|
70
|
-
let root = x;
|
|
71
|
-
while ((parent[root] ?? root) !== root)
|
|
72
|
-
root = parent[root] ?? root;
|
|
73
|
-
while ((parent[x] ?? x) !== root) {
|
|
74
|
-
const next = parent[x] ?? x;
|
|
75
|
-
parent[x] = root;
|
|
76
|
-
x = next;
|
|
77
|
-
}
|
|
78
|
-
return root;
|
|
79
|
-
};
|
|
80
|
-
const union = (a, b) => { const ra = find(a); const rb = find(b); if (ra !== rb)
|
|
81
|
-
parent[ra] = rb; };
|
|
82
|
-
// Exact-hash buckets first (cheap, and the only signal for undecodable icons).
|
|
83
|
-
const firstByHash = new Map();
|
|
84
|
-
items.forEach((r, i) => {
|
|
85
|
-
if (!r.faviconHash)
|
|
86
|
-
return;
|
|
87
|
-
const existing = firstByHash.get(r.faviconHash);
|
|
88
|
-
if (existing !== undefined)
|
|
89
|
-
union(i, existing);
|
|
90
|
-
else
|
|
91
|
-
firstByHash.set(r.faviconHash, i);
|
|
92
|
-
});
|
|
93
|
-
// Perceptual near-matches among records with an *informative* phash -
|
|
94
|
-
// degenerate hashes (solid/monotonic icons) are skipped so they don't all
|
|
95
|
-
// cluster together; they can still group via an exact-hash match above.
|
|
96
|
-
const withPhash = [];
|
|
97
|
-
items.forEach((r, i) => {
|
|
98
|
-
if (isInformativeFaviconHash(r.faviconPHash))
|
|
99
|
-
withPhash.push({ i, phash: r.faviconPHash });
|
|
100
|
-
});
|
|
101
|
-
for (let a = 0; a < withPhash.length; a += 1) {
|
|
102
|
-
for (let b = a + 1; b < withPhash.length; b += 1) {
|
|
103
|
-
const left = withPhash[a];
|
|
104
|
-
const right = withPhash[b];
|
|
105
|
-
if (!left || !right)
|
|
106
|
-
continue;
|
|
107
|
-
const distance = hammingDistanceHex(left.phash, right.phash);
|
|
108
|
-
if (distance !== null && distance <= maxDistance)
|
|
109
|
-
union(left.i, right.i);
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
const groups = new Map();
|
|
113
|
-
items.forEach((r, i) => {
|
|
114
|
-
const root = find(i);
|
|
115
|
-
if (!groups.has(root))
|
|
116
|
-
groups.set(root, []);
|
|
117
|
-
groups.get(root)?.push(r.domain);
|
|
118
|
-
});
|
|
119
|
-
return [...groups.values()].filter((domains) => domains.length >= 2);
|
|
120
|
-
}
|
|
121
|
-
export function toCsvValue(v) {
|
|
122
|
-
return serializeCsvCell(v);
|
|
123
|
-
}
|
|
124
|
-
export function rowsToCsv(rows) {
|
|
125
|
-
return serializeCsvRows(rows);
|
|
126
|
-
}
|
|
127
|
-
function splitDelimitedLine(line, delimiter = ',') {
|
|
128
|
-
const result = [];
|
|
129
|
-
let cur = '';
|
|
130
|
-
let inQuotes = false;
|
|
131
|
-
for (let i = 0; i < line.length; i += 1) {
|
|
132
|
-
const ch = line[i];
|
|
133
|
-
if (ch === '"') {
|
|
134
|
-
if (inQuotes && line[i + 1] === '"') {
|
|
135
|
-
cur += '"';
|
|
136
|
-
i += 1;
|
|
137
|
-
}
|
|
138
|
-
else {
|
|
139
|
-
inQuotes = !inQuotes;
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
else if (ch === delimiter && !inQuotes) {
|
|
143
|
-
result.push(cur);
|
|
144
|
-
cur = '';
|
|
145
|
-
}
|
|
146
|
-
else {
|
|
147
|
-
cur += ch;
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
result.push(cur);
|
|
151
|
-
return result.map((s) => s.trim());
|
|
152
|
-
}
|
|
153
|
-
const DOMAIN_HEADER_NAMES = ['domain', 'domain_name', 'domain name', 'hostname', 'name'];
|
|
154
|
-
function detectDelimiter(line) {
|
|
155
|
-
const candidates = [',', ';', '\t'];
|
|
156
|
-
let best = null;
|
|
157
|
-
let bestCount = 0;
|
|
158
|
-
for (const delimiter of candidates) {
|
|
159
|
-
const count = splitDelimitedLine(line, delimiter).length - 1;
|
|
160
|
-
if (count > bestCount) {
|
|
161
|
-
best = delimiter;
|
|
162
|
-
bestCount = count;
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
return best;
|
|
166
|
-
}
|
|
167
|
-
// Used only to distinguish a pasted delimiter-separated query list from a
|
|
168
|
-
// headerless multi-column CSV. Server-side classifyQuery remains the
|
|
169
|
-
// authoritative validator when the scan runs.
|
|
170
|
-
function looksLikeLookupToken(value) {
|
|
171
|
-
const token = String(value || '').trim();
|
|
172
|
-
if (!token || /\s/.test(token))
|
|
173
|
-
return false;
|
|
174
|
-
if (/^AS\d+$/i.test(token) || /^\d{1,3}(?:\.\d{1,3}){3}$/.test(token) || token.includes(':'))
|
|
175
|
-
return true;
|
|
176
|
-
try {
|
|
177
|
-
const url = new URL(/^[a-z][a-z\d+.-]*:\/\//i.test(token) ? token : `https://${token}`);
|
|
178
|
-
return url.hostname.includes('.');
|
|
179
|
-
}
|
|
180
|
-
catch {
|
|
181
|
-
return false;
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
export function parseDomainInput(text) {
|
|
185
|
-
const normalized = String(text || '').replace(/^\uFEFF/, '');
|
|
186
|
-
const lines = normalized.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
|
187
|
-
if (lines.length === 0)
|
|
188
|
-
return { entries: [], duplicates: 0, usedHeader: false };
|
|
189
|
-
const firstLine = lines[0] ?? '';
|
|
190
|
-
const delimiter = detectDelimiter(firstLine);
|
|
191
|
-
const firstCells = delimiter ? splitDelimitedLine(firstLine, delimiter) : [firstLine];
|
|
192
|
-
const header = firstCells.map((cell) => cell.toLowerCase());
|
|
193
|
-
const domainColIdx = header.findIndex((cell) => DOMAIN_HEADER_NAMES.includes(cell));
|
|
194
|
-
const usedHeader = domainColIdx !== -1;
|
|
195
|
-
let candidates;
|
|
196
|
-
if (usedHeader) {
|
|
197
|
-
candidates = lines.slice(1).map((line) => {
|
|
198
|
-
const rowDelimiter = delimiter || detectDelimiter(line);
|
|
199
|
-
const cells = rowDelimiter ? splitDelimitedLine(line, rowDelimiter) : [line];
|
|
200
|
-
return cells[domainColIdx] || '';
|
|
201
|
-
});
|
|
202
|
-
}
|
|
203
|
-
else {
|
|
204
|
-
const rows = lines.map((line) => {
|
|
205
|
-
const rowDelimiter = detectDelimiter(line);
|
|
206
|
-
return rowDelimiter ? splitDelimitedLine(line, rowDelimiter) : [line];
|
|
207
|
-
});
|
|
208
|
-
const flattened = rows.flat().map((cell) => cell.trim()).filter(Boolean);
|
|
209
|
-
// If every cell looks like a query, this is a pasted comma/semicolon list
|
|
210
|
-
// (possibly wrapped over several lines). Otherwise retain column zero so
|
|
211
|
-
// a headerless "domain,notes" CSV does not scan its notes as domains.
|
|
212
|
-
candidates = lines.length === 1 || (flattened.length > 0 && flattened.every(looksLikeLookupToken))
|
|
213
|
-
? flattened
|
|
214
|
-
: rows.map((cells) => cells[0] || '');
|
|
215
|
-
}
|
|
216
|
-
const entries = [];
|
|
217
|
-
const seen = new Set();
|
|
218
|
-
let duplicates = 0;
|
|
219
|
-
for (const candidate of candidates) {
|
|
220
|
-
const value = String(candidate || '').trim();
|
|
221
|
-
if (!value)
|
|
222
|
-
continue;
|
|
223
|
-
const key = value.toLowerCase();
|
|
224
|
-
if (seen.has(key)) {
|
|
225
|
-
duplicates += 1;
|
|
226
|
-
continue;
|
|
227
|
-
}
|
|
228
|
-
seen.add(key);
|
|
229
|
-
entries.push(value);
|
|
230
|
-
}
|
|
231
|
-
return { entries, duplicates, usedHeader };
|
|
232
|
-
}
|