@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,515 @@
|
|
|
1
|
+
// Pure, bounded persistence model for analyst-saved Bulk investigations.
|
|
2
|
+
// Sessions retain compact derived rows and source states, never raw registry,
|
|
3
|
+
// WHOIS, HTTP, TLS, page, or provider payloads.
|
|
4
|
+
import { normalizeDomain } from "./case-model.js";
|
|
5
|
+
import { normalizeCaaCritical } from "./dns-record-normalization.js";
|
|
6
|
+
export const BULK_SESSION_SCHEMA = 'whoisleuth.bulk-sessions';
|
|
7
|
+
export const BULK_SESSION_SCHEMA_VERSION = 2;
|
|
8
|
+
export const SUPPORTED_BULK_SESSION_SCHEMA_VERSIONS = Object.freeze([1, BULK_SESSION_SCHEMA_VERSION]);
|
|
9
|
+
export const MAX_BULK_SESSIONS = 10;
|
|
10
|
+
export const MAX_BULK_SESSION_ROWS = 2_000;
|
|
11
|
+
export const MAX_BULK_SESSION_STORE_BYTES = 4 * 1024 * 1024;
|
|
12
|
+
export const MAX_BULK_SESSION_NAME_LENGTH = 100;
|
|
13
|
+
export const MAX_BULK_SESSION_TEXT_LENGTH = 500;
|
|
14
|
+
export const MAX_BULK_SESSION_ARRAY_VALUES = 100;
|
|
15
|
+
export const MAX_BULK_SESSION_SOURCES = 12;
|
|
16
|
+
const CONTROL_RE = /[\x00-\x1f\x7f]/u;
|
|
17
|
+
const SAFE_ID_RE = /^[A-Za-z0-9_-]{1,128}$/u;
|
|
18
|
+
const DIGEST_RE = /^sha256:[a-f0-9]{64}$/u;
|
|
19
|
+
const HASH_64_RE = /^[a-f0-9]{64}$/iu;
|
|
20
|
+
const PHASH_RE = /^[a-f0-9]{16}$/iu;
|
|
21
|
+
const SOURCE_RE = /^[a-z][a-z0-9_-]{0,39}$/u;
|
|
22
|
+
const TECHNOLOGY_ID_RE = /^[a-z0-9][a-z0-9_-]{0,79}$/u;
|
|
23
|
+
const TRACKING_IDENTIFIER_RE = /^[a-z-]{1,40}:[A-Z0-9-]{1,64}$/u;
|
|
24
|
+
const SESSION_STATES = new Set(['complete', 'partial', 'cancelled']);
|
|
25
|
+
const RESULT_STATES = new Set(['complete', 'error']);
|
|
26
|
+
const MODES = new Set(['fast', 'deep']);
|
|
27
|
+
const SOURCE_STATES = new Set([
|
|
28
|
+
'complete',
|
|
29
|
+
'partial',
|
|
30
|
+
'skipped',
|
|
31
|
+
'unavailable',
|
|
32
|
+
'unsupported',
|
|
33
|
+
'not_found',
|
|
34
|
+
'error',
|
|
35
|
+
]);
|
|
36
|
+
const TRUST_STATES = new Set(['official', 'partner', 'allowlisted']);
|
|
37
|
+
const COMPARISON_STATES = new Set(['error', 'not_found', 'partial', 'success', 'unavailable']);
|
|
38
|
+
function record(value) {
|
|
39
|
+
return value && typeof value === 'object' && !Array.isArray(value)
|
|
40
|
+
? value
|
|
41
|
+
: null;
|
|
42
|
+
}
|
|
43
|
+
function boundedText(value, maximum = MAX_BULK_SESSION_TEXT_LENGTH) {
|
|
44
|
+
return typeof value === 'string' && value.length <= maximum && !CONTROL_RE.test(value)
|
|
45
|
+
? value.trim()
|
|
46
|
+
: '';
|
|
47
|
+
}
|
|
48
|
+
function timestamp(value, fallback = null) {
|
|
49
|
+
const text = boundedText(value, 64);
|
|
50
|
+
return text && Number.isFinite(Date.parse(text)) ? new Date(text).toISOString() : fallback;
|
|
51
|
+
}
|
|
52
|
+
function nullableBoolean(value) {
|
|
53
|
+
return typeof value === 'boolean' ? value : null;
|
|
54
|
+
}
|
|
55
|
+
function nullableScore(value) {
|
|
56
|
+
return typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= 100
|
|
57
|
+
? Math.round(value)
|
|
58
|
+
: null;
|
|
59
|
+
}
|
|
60
|
+
function nullableVersion(value) {
|
|
61
|
+
return typeof value === 'number' && Number.isSafeInteger(value) && value > 0 && value <= 10_000
|
|
62
|
+
? value
|
|
63
|
+
: null;
|
|
64
|
+
}
|
|
65
|
+
function boundedStrings(value, maximum = MAX_BULK_SESSION_ARRAY_VALUES) {
|
|
66
|
+
if (!Array.isArray(value))
|
|
67
|
+
return [];
|
|
68
|
+
const output = new Set();
|
|
69
|
+
for (const candidate of value.slice(0, maximum * 4)) {
|
|
70
|
+
const text = boundedText(candidate, 253).toLowerCase().replace(/\.$/u, '');
|
|
71
|
+
if (text)
|
|
72
|
+
output.add(text);
|
|
73
|
+
if (output.size >= maximum)
|
|
74
|
+
break;
|
|
75
|
+
}
|
|
76
|
+
return [...output];
|
|
77
|
+
}
|
|
78
|
+
function boundedTrackingIdentifiers(value) {
|
|
79
|
+
if (!Array.isArray(value))
|
|
80
|
+
return [];
|
|
81
|
+
const output = new Set();
|
|
82
|
+
for (const candidate of value.slice(0, 120)) {
|
|
83
|
+
const text = boundedText(candidate, 105);
|
|
84
|
+
if (TRACKING_IDENTIFIER_RE.test(text))
|
|
85
|
+
output.add(text);
|
|
86
|
+
if (output.size >= 30)
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
return [...output];
|
|
90
|
+
}
|
|
91
|
+
function domainList(value) {
|
|
92
|
+
if (!Array.isArray(value))
|
|
93
|
+
return [];
|
|
94
|
+
const output = new Set();
|
|
95
|
+
for (const candidate of value.slice(0, MAX_BULK_SESSION_ROWS * 2)) {
|
|
96
|
+
const normalized = typeof candidate === 'string' ? normalizeDomain(candidate) : '';
|
|
97
|
+
if (normalized)
|
|
98
|
+
output.add(normalized);
|
|
99
|
+
if (output.size >= MAX_BULK_SESSION_ROWS)
|
|
100
|
+
break;
|
|
101
|
+
}
|
|
102
|
+
return [...output];
|
|
103
|
+
}
|
|
104
|
+
function normalizeSourceCoverage(value) {
|
|
105
|
+
if (!Array.isArray(value))
|
|
106
|
+
return [];
|
|
107
|
+
const output = [];
|
|
108
|
+
const seen = new Set();
|
|
109
|
+
for (const candidate of value.slice(0, MAX_BULK_SESSION_SOURCES * 4)) {
|
|
110
|
+
const item = record(candidate);
|
|
111
|
+
const source = boundedText(item?.source, 40);
|
|
112
|
+
const state = boundedText(item?.state, 20);
|
|
113
|
+
if (!SOURCE_RE.test(source) || !SOURCE_STATES.has(state) || seen.has(source))
|
|
114
|
+
continue;
|
|
115
|
+
seen.add(source);
|
|
116
|
+
output.push({ source, state: state });
|
|
117
|
+
if (output.length >= MAX_BULK_SESSION_SOURCES)
|
|
118
|
+
break;
|
|
119
|
+
}
|
|
120
|
+
return output;
|
|
121
|
+
}
|
|
122
|
+
function normalizeRiskFactors(value) {
|
|
123
|
+
if (!Array.isArray(value))
|
|
124
|
+
return [];
|
|
125
|
+
return value.slice(0, 40).flatMap((candidate) => {
|
|
126
|
+
const item = record(candidate);
|
|
127
|
+
const label = boundedText(item?.label, 160);
|
|
128
|
+
const points = item?.points;
|
|
129
|
+
return label && typeof points === 'number' && Number.isFinite(points) && Math.abs(points) <= 100
|
|
130
|
+
? [{ label, points: Math.round(points) }]
|
|
131
|
+
: [];
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
function nullableHash(value, expression) {
|
|
135
|
+
return typeof value === 'string' && expression.test(value) ? value.toLowerCase() : null;
|
|
136
|
+
}
|
|
137
|
+
function normalizeRelationship(value) {
|
|
138
|
+
const item = record(value);
|
|
139
|
+
return {
|
|
140
|
+
version: 2,
|
|
141
|
+
nameservers: boundedStrings(item?.nameservers, 20),
|
|
142
|
+
ipAddresses: boundedStrings(item?.ipAddresses, 50),
|
|
143
|
+
trackingIdentifiers: boundedTrackingIdentifiers(item?.trackingIdentifiers),
|
|
144
|
+
officialAssetHosts: boundedStrings(item?.officialAssetHosts, 30),
|
|
145
|
+
faviconHash: nullableHash(item?.faviconHash, HASH_64_RE),
|
|
146
|
+
faviconPHash: nullableHash(item?.faviconPHash, PHASH_RE),
|
|
147
|
+
certificateFingerprint: nullableHash(item?.certificateFingerprint, HASH_64_RE),
|
|
148
|
+
truncated: item?.truncated === true,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
function normalizeDns(value) {
|
|
152
|
+
const item = record(value);
|
|
153
|
+
const records = record(item?.records);
|
|
154
|
+
if (!item || !records)
|
|
155
|
+
return null;
|
|
156
|
+
const caa = Array.isArray(records.caa)
|
|
157
|
+
? records.caa.slice(0, 100).flatMap((candidate) => {
|
|
158
|
+
const caaRecord = record(candidate);
|
|
159
|
+
const tag = boundedText(caaRecord?.tag, 64);
|
|
160
|
+
const recordValue = boundedText(caaRecord?.value, 500);
|
|
161
|
+
const critical = normalizeCaaCritical(caaRecord?.critical);
|
|
162
|
+
return tag && recordValue && critical !== null
|
|
163
|
+
? [{ critical, tag, value: recordValue }]
|
|
164
|
+
: [];
|
|
165
|
+
})
|
|
166
|
+
: [];
|
|
167
|
+
return {
|
|
168
|
+
status: boundedText(item.status, 40) || null,
|
|
169
|
+
records: {
|
|
170
|
+
a: boundedStrings(records.a),
|
|
171
|
+
aaaa: boundedStrings(records.aaaa),
|
|
172
|
+
cname: boundedStrings(records.cname),
|
|
173
|
+
caa,
|
|
174
|
+
},
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
export function normalizeBulkComparisonEvidence(value) {
|
|
178
|
+
const item = record(value);
|
|
179
|
+
const technology = record(item?.technology);
|
|
180
|
+
const tls = record(item?.tls);
|
|
181
|
+
if (!item || item.version !== 1 || !technology || !tls)
|
|
182
|
+
return null;
|
|
183
|
+
const technologyState = boundedText(technology.state, 40);
|
|
184
|
+
const tlsState = boundedText(tls.state, 40);
|
|
185
|
+
if (!COMPARISON_STATES.has(technologyState) || !COMPARISON_STATES.has(tlsState))
|
|
186
|
+
return null;
|
|
187
|
+
const ids = Array.isArray(technology.ids)
|
|
188
|
+
? [...new Set(technology.ids
|
|
189
|
+
.slice(0, 48)
|
|
190
|
+
.map((candidate) => boundedText(candidate, 80).toLowerCase())
|
|
191
|
+
.filter((candidate) => TECHNOLOGY_ID_RE.test(candidate)))]
|
|
192
|
+
.sort()
|
|
193
|
+
.slice(0, 12)
|
|
194
|
+
: [];
|
|
195
|
+
const technologyUsable = ['success', 'partial'].includes(technologyState);
|
|
196
|
+
const tlsUsable = ['success', 'partial'].includes(tlsState);
|
|
197
|
+
return {
|
|
198
|
+
version: 1,
|
|
199
|
+
technology: {
|
|
200
|
+
state: technologyState,
|
|
201
|
+
ids: technologyUsable ? ids : [],
|
|
202
|
+
truncated: technologyUsable && technology.truncated === true,
|
|
203
|
+
},
|
|
204
|
+
tls: {
|
|
205
|
+
state: tlsState,
|
|
206
|
+
issuerLabel: tlsUsable ? boundedText(tls.issuerLabel, 240) || null : null,
|
|
207
|
+
spkiSha256: tlsUsable ? nullableHash(tls.spkiSha256, HASH_64_RE) : null,
|
|
208
|
+
},
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
export function normalizeBulkSessionResult(value) {
|
|
212
|
+
const item = record(value);
|
|
213
|
+
const domain = normalizeDomain(item?.domain);
|
|
214
|
+
const status = boundedText(item?.status, 20);
|
|
215
|
+
const scanDepth = boundedText(item?.scanDepth, 10);
|
|
216
|
+
if (!item || !domain || !RESULT_STATES.has(status) || !MODES.has(scanDepth))
|
|
217
|
+
return null;
|
|
218
|
+
const trusted = boundedText(item.trusted, 20);
|
|
219
|
+
return {
|
|
220
|
+
domain,
|
|
221
|
+
status: status,
|
|
222
|
+
availability: boundedText(item.availability, 40) || 'unknown',
|
|
223
|
+
confidence: boundedText(item.confidence, 40) || 'unknown',
|
|
224
|
+
registrar: boundedText(item.registrar, 300) || '—',
|
|
225
|
+
activity: boundedText(item.activity, 300) || '—',
|
|
226
|
+
risk: nullableScore(item.risk),
|
|
227
|
+
opportunity: nullableScore(item.opportunity),
|
|
228
|
+
mutationTypes: boundedStrings(item.mutationTypes, 40),
|
|
229
|
+
trusted: TRUST_STATES.has(trusted) ? trusted : null,
|
|
230
|
+
error: boundedText(item.error),
|
|
231
|
+
scanDepth: scanDepth,
|
|
232
|
+
createdDate: boundedText(item.createdDate, 64) || null,
|
|
233
|
+
expiryDate: boundedText(item.expiryDate, 64) || null,
|
|
234
|
+
nameservers: boundedStrings(item.nameservers, 20),
|
|
235
|
+
hasMx: nullableBoolean(item.hasMx),
|
|
236
|
+
hasNullMx: nullableBoolean(item.hasNullMx),
|
|
237
|
+
hasSpf: nullableBoolean(item.hasSpf),
|
|
238
|
+
hasDmarc: nullableBoolean(item.hasDmarc),
|
|
239
|
+
activityStatus: boundedText(item.activityStatus, 40) || null,
|
|
240
|
+
pageTitle: boundedText(item.pageTitle, 300) || null,
|
|
241
|
+
faviconHash: nullableHash(item.faviconHash, HASH_64_RE),
|
|
242
|
+
faviconPHash: nullableHash(item.faviconPHash, PHASH_RE),
|
|
243
|
+
faviconMatch: item.faviconMatch === true,
|
|
244
|
+
faviconNearMatch: item.faviconNearMatch === true,
|
|
245
|
+
reusesOfficialAssets: item.reusesOfficialAssets === true,
|
|
246
|
+
hasPasswordField: item.hasPasswordField === true,
|
|
247
|
+
hasExternalFormAction: nullableBoolean(item.hasExternalFormAction),
|
|
248
|
+
phishingLanguageMatch: boundedText(item.phishingLanguageMatch, 300) || null,
|
|
249
|
+
riskModelVersion: nullableVersion(item.riskModelVersion),
|
|
250
|
+
riskFactors: normalizeRiskFactors(item.riskFactors),
|
|
251
|
+
dns: normalizeDns(item.dns),
|
|
252
|
+
dnssec: boundedText(item.dnssec, 40) || null,
|
|
253
|
+
comparisonEvidence: normalizeBulkComparisonEvidence(item.comparisonEvidence),
|
|
254
|
+
relationship: normalizeRelationship(item.relationship),
|
|
255
|
+
sourceCoverage: normalizeSourceCoverage(item.sourceCoverage),
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
export function normalizeBulkSession(value) {
|
|
259
|
+
const item = record(value);
|
|
260
|
+
const id = boundedText(item?.id, 128);
|
|
261
|
+
const name = boundedText(item?.name, MAX_BULK_SESSION_NAME_LENGTH);
|
|
262
|
+
const mode = boundedText(item?.mode, 10);
|
|
263
|
+
const state = boundedText(item?.state, 20);
|
|
264
|
+
const inputDigest = boundedText(item?.inputDigest, 80);
|
|
265
|
+
const startedAt = timestamp(item?.startedAt);
|
|
266
|
+
const updatedAt = timestamp(item?.updatedAt);
|
|
267
|
+
const completedAt = item?.completedAt === null ? null : timestamp(item?.completedAt);
|
|
268
|
+
if (!item
|
|
269
|
+
|| !SAFE_ID_RE.test(id)
|
|
270
|
+
|| !name
|
|
271
|
+
|| !MODES.has(mode)
|
|
272
|
+
|| !SESSION_STATES.has(state)
|
|
273
|
+
|| !DIGEST_RE.test(inputDigest)
|
|
274
|
+
|| !startedAt
|
|
275
|
+
|| !updatedAt)
|
|
276
|
+
return null;
|
|
277
|
+
const domains = domainList(item.domains);
|
|
278
|
+
if (!domains.length)
|
|
279
|
+
return null;
|
|
280
|
+
const allowed = new Set(domains);
|
|
281
|
+
const results = [];
|
|
282
|
+
const seen = new Set();
|
|
283
|
+
for (const candidate of Array.isArray(item.results) ? item.results.slice(0, MAX_BULK_SESSION_ROWS * 2) : []) {
|
|
284
|
+
const result = normalizeBulkSessionResult(candidate);
|
|
285
|
+
if (!result || !allowed.has(result.domain) || seen.has(result.domain))
|
|
286
|
+
continue;
|
|
287
|
+
seen.add(result.domain);
|
|
288
|
+
results.push(result);
|
|
289
|
+
if (results.length >= MAX_BULK_SESSION_ROWS)
|
|
290
|
+
break;
|
|
291
|
+
}
|
|
292
|
+
return {
|
|
293
|
+
id,
|
|
294
|
+
name,
|
|
295
|
+
mode: mode,
|
|
296
|
+
state: state,
|
|
297
|
+
inputDigest,
|
|
298
|
+
domains,
|
|
299
|
+
results,
|
|
300
|
+
startedAt,
|
|
301
|
+
updatedAt,
|
|
302
|
+
completedAt,
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
export function bulkSessionStoreVersion(raw) {
|
|
306
|
+
const value = record(raw);
|
|
307
|
+
return typeof value?.version === 'number' && Number.isSafeInteger(value.version) && value.version > 0
|
|
308
|
+
? value.version
|
|
309
|
+
: null;
|
|
310
|
+
}
|
|
311
|
+
export function normalizeBulkSessionStore(raw) {
|
|
312
|
+
const value = record(raw);
|
|
313
|
+
const candidates = Array.isArray(raw)
|
|
314
|
+
? raw
|
|
315
|
+
: value?.schema === BULK_SESSION_SCHEMA
|
|
316
|
+
&& SUPPORTED_BULK_SESSION_SCHEMA_VERSIONS.includes(Number(value.version))
|
|
317
|
+
&& Array.isArray(value.sessions)
|
|
318
|
+
? value.sessions
|
|
319
|
+
: [];
|
|
320
|
+
const byId = new Map();
|
|
321
|
+
for (const candidate of candidates.slice(0, MAX_BULK_SESSIONS * 4)) {
|
|
322
|
+
const session = normalizeBulkSession(candidate);
|
|
323
|
+
if (!session)
|
|
324
|
+
continue;
|
|
325
|
+
const existing = byId.get(session.id);
|
|
326
|
+
if (!existing || existing.updatedAt < session.updatedAt)
|
|
327
|
+
byId.set(session.id, session);
|
|
328
|
+
}
|
|
329
|
+
return {
|
|
330
|
+
schema: BULK_SESSION_SCHEMA,
|
|
331
|
+
version: BULK_SESSION_SCHEMA_VERSION,
|
|
332
|
+
sessions: [...byId.values()]
|
|
333
|
+
.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt) || left.id.localeCompare(right.id))
|
|
334
|
+
.slice(0, MAX_BULK_SESSIONS),
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
export function serializeBulkSessionStore(raw) {
|
|
338
|
+
return JSON.stringify(normalizeBulkSessionStore(raw));
|
|
339
|
+
}
|
|
340
|
+
function byteLength(value) {
|
|
341
|
+
return new TextEncoder().encode(value).byteLength;
|
|
342
|
+
}
|
|
343
|
+
export function enforceBulkSessionStoreBudget(raw) {
|
|
344
|
+
const store = normalizeBulkSessionStore(raw);
|
|
345
|
+
let pruned = 0;
|
|
346
|
+
while (byteLength(JSON.stringify(store)) > MAX_BULK_SESSION_STORE_BYTES && store.sessions.length > 1) {
|
|
347
|
+
store.sessions.pop();
|
|
348
|
+
pruned += 1;
|
|
349
|
+
}
|
|
350
|
+
if (byteLength(JSON.stringify(store)) > MAX_BULK_SESSION_STORE_BYTES) {
|
|
351
|
+
throw new Error('This Bulk session exceeds the 4 MiB browser-local session limit.');
|
|
352
|
+
}
|
|
353
|
+
return { store, pruned };
|
|
354
|
+
}
|
|
355
|
+
export function upsertBulkSession(raw, sessionRaw) {
|
|
356
|
+
const session = normalizeBulkSession(sessionRaw);
|
|
357
|
+
if (!session)
|
|
358
|
+
throw new Error('The Bulk session is incomplete or invalid.');
|
|
359
|
+
const current = normalizeBulkSessionStore(raw).sessions;
|
|
360
|
+
const index = current.findIndex((candidate) => candidate.id === session.id);
|
|
361
|
+
const next = index >= 0
|
|
362
|
+
? current.map((candidate, candidateIndex) => candidateIndex === index ? session : candidate)
|
|
363
|
+
: [session, ...current];
|
|
364
|
+
const bounded = enforceBulkSessionStoreBudget({
|
|
365
|
+
schema: BULK_SESSION_SCHEMA,
|
|
366
|
+
version: BULK_SESSION_SCHEMA_VERSION,
|
|
367
|
+
sessions: next,
|
|
368
|
+
});
|
|
369
|
+
return {
|
|
370
|
+
sessions: bounded.store.sessions,
|
|
371
|
+
session,
|
|
372
|
+
added: index < 0,
|
|
373
|
+
pruned: bounded.pruned,
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
export function deleteBulkSession(raw, idRaw) {
|
|
377
|
+
const id = boundedText(idRaw, 128);
|
|
378
|
+
return normalizeBulkSessionStore({
|
|
379
|
+
schema: BULK_SESSION_SCHEMA,
|
|
380
|
+
version: BULK_SESSION_SCHEMA_VERSION,
|
|
381
|
+
sessions: normalizeBulkSessionStore(raw).sessions.filter((session) => session.id !== id),
|
|
382
|
+
}).sessions;
|
|
383
|
+
}
|
|
384
|
+
function sourceStateMap(value) {
|
|
385
|
+
return new Map(value.sourceCoverage.map((item) => [item.source, item.state]));
|
|
386
|
+
}
|
|
387
|
+
function resultChanges(previous, current) {
|
|
388
|
+
const changes = [];
|
|
389
|
+
if (previous.availability !== current.availability)
|
|
390
|
+
changes.push(`Registration: ${previous.availability} → ${current.availability}`);
|
|
391
|
+
if (previous.risk !== current.risk)
|
|
392
|
+
changes.push(`Risk: ${previous.risk ?? 'unavailable'} → ${current.risk ?? 'unavailable'}`);
|
|
393
|
+
if (previous.registrar !== current.registrar)
|
|
394
|
+
changes.push(`Registrar: ${previous.registrar} → ${current.registrar}`);
|
|
395
|
+
if (previous.activity !== current.activity)
|
|
396
|
+
changes.push(`Website: ${previous.activity} → ${current.activity}`);
|
|
397
|
+
const previousComparison = previous.comparisonEvidence;
|
|
398
|
+
const currentComparison = current.comparisonEvidence;
|
|
399
|
+
if (JSON.stringify(previousComparison?.technology.ids ?? null)
|
|
400
|
+
!== JSON.stringify(currentComparison?.technology.ids ?? null)) {
|
|
401
|
+
changes.push(`Technology IDs: ${previousComparison?.technology.ids.join(', ') || 'not recorded'} → ${currentComparison?.technology.ids.join(', ') || 'not recorded'}`);
|
|
402
|
+
}
|
|
403
|
+
if ((previousComparison?.tls.issuerLabel ?? null) !== (currentComparison?.tls.issuerLabel ?? null)) {
|
|
404
|
+
changes.push(`TLS issuer: ${previousComparison?.tls.issuerLabel || 'not recorded'} → ${currentComparison?.tls.issuerLabel || 'not recorded'}`);
|
|
405
|
+
}
|
|
406
|
+
if ((previousComparison?.tls.spkiSha256 ?? null) !== (currentComparison?.tls.spkiSha256 ?? null)) {
|
|
407
|
+
changes.push(`TLS public key: ${previousComparison?.tls.spkiSha256 || 'not recorded'} → ${currentComparison?.tls.spkiSha256 || 'not recorded'}`);
|
|
408
|
+
}
|
|
409
|
+
const previousSources = sourceStateMap(previous);
|
|
410
|
+
const currentSources = sourceStateMap(current);
|
|
411
|
+
for (const source of [...new Set([...previousSources.keys(), ...currentSources.keys()])].sort()) {
|
|
412
|
+
const before = previousSources.get(source) ?? 'not recorded';
|
|
413
|
+
const after = currentSources.get(source) ?? 'not recorded';
|
|
414
|
+
if (before !== after)
|
|
415
|
+
changes.push(`${source} source: ${before} → ${after}`);
|
|
416
|
+
}
|
|
417
|
+
return changes.slice(0, 20);
|
|
418
|
+
}
|
|
419
|
+
export function compareBulkSessions(baselineRaw, currentRaw) {
|
|
420
|
+
const baseline = normalizeBulkSession(baselineRaw);
|
|
421
|
+
const current = normalizeBulkSession(currentRaw);
|
|
422
|
+
if (!baseline || !current || baseline.id === current.id)
|
|
423
|
+
return null;
|
|
424
|
+
const before = new Map(baseline.results.map((result) => [result.domain, result]));
|
|
425
|
+
const after = new Map(current.results.map((result) => [result.domain, result]));
|
|
426
|
+
const rows = [];
|
|
427
|
+
let added = 0;
|
|
428
|
+
let removed = 0;
|
|
429
|
+
let unchanged = 0;
|
|
430
|
+
for (const domain of [...new Set([...before.keys(), ...after.keys()])].sort()) {
|
|
431
|
+
const previous = before.get(domain);
|
|
432
|
+
const next = after.get(domain);
|
|
433
|
+
if (!previous) {
|
|
434
|
+
added += 1;
|
|
435
|
+
rows.push({ domain, changes: ['Added to the later settled result set.'] });
|
|
436
|
+
}
|
|
437
|
+
else if (!next) {
|
|
438
|
+
removed += 1;
|
|
439
|
+
rows.push({ domain, changes: ['Absent from the later settled result set.'] });
|
|
440
|
+
}
|
|
441
|
+
else {
|
|
442
|
+
const changes = resultChanges(previous, next);
|
|
443
|
+
if (changes.length)
|
|
444
|
+
rows.push({ domain, changes });
|
|
445
|
+
else
|
|
446
|
+
unchanged += 1;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
return {
|
|
450
|
+
baselineId: baseline.id,
|
|
451
|
+
currentId: current.id,
|
|
452
|
+
added,
|
|
453
|
+
removed,
|
|
454
|
+
changed: rows.length - added - removed,
|
|
455
|
+
unchanged,
|
|
456
|
+
rows: rows.slice(0, MAX_BULK_SESSION_ROWS),
|
|
457
|
+
limitations: [
|
|
458
|
+
'This compares compact saved observations, not historical provider data or raw source payloads.',
|
|
459
|
+
'A source-state change may reflect collection availability rather than a change to the domain.',
|
|
460
|
+
'A missing row means it was not completed in that saved session; it does not establish domain removal.',
|
|
461
|
+
],
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
export function buildBulkSessionExport(sessions, generatedAt = new Date().toISOString()) {
|
|
465
|
+
const store = enforceBulkSessionStoreBudget(sessions).store;
|
|
466
|
+
return {
|
|
467
|
+
schema: BULK_SESSION_SCHEMA,
|
|
468
|
+
version: BULK_SESSION_SCHEMA_VERSION,
|
|
469
|
+
generatedAt: timestamp(generatedAt, new Date().toISOString()),
|
|
470
|
+
sessions: store.sessions,
|
|
471
|
+
limitations: [
|
|
472
|
+
'Compact Bulk results and source states only; raw source payloads and contact records are excluded.',
|
|
473
|
+
'Import is non-destructive and does not resume network collection automatically.',
|
|
474
|
+
],
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
export function mergeBulkSessions(localRaw, importedRaw) {
|
|
478
|
+
const imported = record(importedRaw);
|
|
479
|
+
if (!imported || imported.schema !== BULK_SESSION_SCHEMA || !Array.isArray(imported.sessions)) {
|
|
480
|
+
throw new Error('This file is not a WHOISleuth Bulk session export.');
|
|
481
|
+
}
|
|
482
|
+
if (!SUPPORTED_BULK_SESSION_SCHEMA_VERSIONS.includes(Number(imported.version))) {
|
|
483
|
+
if (typeof imported.version === 'number' && imported.version > BULK_SESSION_SCHEMA_VERSION) {
|
|
484
|
+
throw new Error(`This Bulk session export uses newer schema ${imported.version}. Update the app before importing it.`);
|
|
485
|
+
}
|
|
486
|
+
throw new Error(`Expected Bulk session schema ${BULK_SESSION_SCHEMA_VERSION}.`);
|
|
487
|
+
}
|
|
488
|
+
let sessions = normalizeBulkSessionStore(localRaw).sessions;
|
|
489
|
+
let added = 0;
|
|
490
|
+
let updated = 0;
|
|
491
|
+
let skipped = 0;
|
|
492
|
+
for (const candidate of imported.sessions.slice(0, MAX_BULK_SESSIONS * 4)) {
|
|
493
|
+
const session = normalizeBulkSession(candidate);
|
|
494
|
+
if (!session) {
|
|
495
|
+
skipped += 1;
|
|
496
|
+
continue;
|
|
497
|
+
}
|
|
498
|
+
const existing = sessions.find((item) => item.id === session.id);
|
|
499
|
+
if (!existing)
|
|
500
|
+
added += 1;
|
|
501
|
+
else if (existing.updatedAt !== session.updatedAt)
|
|
502
|
+
updated += 1;
|
|
503
|
+
else {
|
|
504
|
+
skipped += 1;
|
|
505
|
+
continue;
|
|
506
|
+
}
|
|
507
|
+
sessions = upsertBulkSession(sessions, session).sessions;
|
|
508
|
+
}
|
|
509
|
+
const bounded = enforceBulkSessionStoreBudget({
|
|
510
|
+
schema: BULK_SESSION_SCHEMA,
|
|
511
|
+
version: BULK_SESSION_SCHEMA_VERSION,
|
|
512
|
+
sessions,
|
|
513
|
+
});
|
|
514
|
+
return { sessions: bounded.store.sessions, added, updated, skipped, pruned: bounded.pruned };
|
|
515
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
const CONFIDENCE_RANK = {
|
|
2
|
+
low: 0,
|
|
3
|
+
medium: 1,
|
|
4
|
+
high: 2,
|
|
5
|
+
};
|
|
6
|
+
function boundedText(value) {
|
|
7
|
+
return String(value ?? '').replace(/[\u0000-\u001f\u007f]/gu, ' ').trim().slice(0, 300);
|
|
8
|
+
}
|
|
9
|
+
function textValue(value) {
|
|
10
|
+
const text = boundedText(value);
|
|
11
|
+
return { missing: !text || text === '—', value: text };
|
|
12
|
+
}
|
|
13
|
+
function compareText(left, right) {
|
|
14
|
+
const leftText = boundedText(left);
|
|
15
|
+
const rightText = boundedText(right);
|
|
16
|
+
return leftText.localeCompare(rightText, 'en', { numeric: true, sensitivity: 'base' });
|
|
17
|
+
}
|
|
18
|
+
function sortValue(row, key) {
|
|
19
|
+
if (key === 'risk' || key === 'opportunity') {
|
|
20
|
+
return { missing: !Number.isFinite(row[key]), value: Number(row[key] ?? 0) };
|
|
21
|
+
}
|
|
22
|
+
if (key === 'confidence') {
|
|
23
|
+
const confidence = boundedText(row.confidence).toLowerCase();
|
|
24
|
+
const rank = CONFIDENCE_RANK[confidence];
|
|
25
|
+
return { missing: rank === undefined, value: rank ?? confidence };
|
|
26
|
+
}
|
|
27
|
+
if (key === 'mutation')
|
|
28
|
+
return textValue(row.mutationTypes.join(' '));
|
|
29
|
+
return textValue(row[key]);
|
|
30
|
+
}
|
|
31
|
+
export function defaultBulkSortDirection(key) {
|
|
32
|
+
return key === 'risk' || key === 'opportunity' || key === 'confidence' ? -1 : 1;
|
|
33
|
+
}
|
|
34
|
+
export function sortBulkResults(rows, key, direction) {
|
|
35
|
+
return [...rows].sort((left, right) => {
|
|
36
|
+
const leftValue = sortValue(left, key);
|
|
37
|
+
const rightValue = sortValue(right, key);
|
|
38
|
+
// Missing values always remain last instead of jumping to the top when
|
|
39
|
+
// the requested direction changes.
|
|
40
|
+
if (leftValue.missing !== rightValue.missing)
|
|
41
|
+
return leftValue.missing ? 1 : -1;
|
|
42
|
+
const comparison = typeof leftValue.value === 'number' && typeof rightValue.value === 'number'
|
|
43
|
+
? leftValue.value - rightValue.value
|
|
44
|
+
: compareText(leftValue.value, rightValue.value);
|
|
45
|
+
if (comparison !== 0)
|
|
46
|
+
return comparison * direction;
|
|
47
|
+
return compareText(left.domain, right.domain);
|
|
48
|
+
});
|
|
49
|
+
}
|