@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,265 @@
|
|
|
1
|
+
// Pure, framework-neutral browser-local campaign model. Campaigns deliberately
|
|
2
|
+
// reference normalized case domains rather than copying case evidence, notes,
|
|
3
|
+
// or mutable case ids. The browser wrapper owns persistence and downloads.
|
|
4
|
+
import { normalizeDomain } from "./case-model.js";
|
|
5
|
+
export const CAMPAIGN_SCHEMA = 'whoisleuth.campaigns';
|
|
6
|
+
export const CAMPAIGN_SCHEMA_VERSION = 1;
|
|
7
|
+
export const MAX_CAMPAIGNS = 50;
|
|
8
|
+
export const MAX_CAMPAIGN_DOMAINS = 50;
|
|
9
|
+
export const MAX_CAMPAIGN_NAME_LENGTH = 100;
|
|
10
|
+
export const MAX_CAMPAIGN_DESCRIPTION_LENGTH = 1000;
|
|
11
|
+
export const MAX_CAMPAIGN_IMPORT_BYTES = 2 * 1024 * 1024;
|
|
12
|
+
export const MAX_CAMPAIGN_INPUT_RECORDS = 500;
|
|
13
|
+
// Cases and watchlists share the same per-origin browser quota. Keep this
|
|
14
|
+
// collection smaller than the case store and fail before a browser quota error.
|
|
15
|
+
export const MAX_CAMPAIGN_STORE_BYTES = 512 * 1024;
|
|
16
|
+
const SAFE_ID_RE = /^[A-Za-z0-9_-]{1,64}$/;
|
|
17
|
+
function plainRecord(value) {
|
|
18
|
+
return value && typeof value === 'object' && !Array.isArray(value)
|
|
19
|
+
? value
|
|
20
|
+
: null;
|
|
21
|
+
}
|
|
22
|
+
function hashString(value) {
|
|
23
|
+
let hash = 2166136261 >>> 0;
|
|
24
|
+
for (let index = 0; index < value.length; index++) {
|
|
25
|
+
hash ^= value.charCodeAt(index);
|
|
26
|
+
hash = Math.imul(hash, 16777619) >>> 0;
|
|
27
|
+
}
|
|
28
|
+
return hash.toString(36);
|
|
29
|
+
}
|
|
30
|
+
function safeId(value) {
|
|
31
|
+
return typeof value === 'string' && SAFE_ID_RE.test(value) ? value : null;
|
|
32
|
+
}
|
|
33
|
+
function makeId() {
|
|
34
|
+
if (typeof globalThis.crypto?.randomUUID === 'function')
|
|
35
|
+
return globalThis.crypto.randomUUID();
|
|
36
|
+
return `campaign-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
37
|
+
}
|
|
38
|
+
function isoOrNull(value) {
|
|
39
|
+
if (typeof value !== 'string' || value.length > 64 || /[\x00-\x1f\x7f]/.test(value))
|
|
40
|
+
return null;
|
|
41
|
+
const parsed = Date.parse(value);
|
|
42
|
+
return Number.isFinite(parsed) ? new Date(parsed).toISOString() : null;
|
|
43
|
+
}
|
|
44
|
+
function normalizeName(value) {
|
|
45
|
+
return String(value == null ? '' : value).replace(/\s+/g, ' ').trim().slice(0, MAX_CAMPAIGN_NAME_LENGTH).trim();
|
|
46
|
+
}
|
|
47
|
+
function normalizeDescription(value) {
|
|
48
|
+
return String(value == null ? '' : value)
|
|
49
|
+
.replace(/\r\n?/g, '\n')
|
|
50
|
+
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '')
|
|
51
|
+
.trim()
|
|
52
|
+
.slice(0, MAX_CAMPAIGN_DESCRIPTION_LENGTH)
|
|
53
|
+
.trim();
|
|
54
|
+
}
|
|
55
|
+
export function normalizeCampaignDomains(value) {
|
|
56
|
+
if (!Array.isArray(value))
|
|
57
|
+
return [];
|
|
58
|
+
const domains = new Set();
|
|
59
|
+
for (const item of value.slice(0, MAX_CAMPAIGN_DOMAINS * 4)) {
|
|
60
|
+
const domain = normalizeDomain(item);
|
|
61
|
+
if (domain)
|
|
62
|
+
domains.add(domain);
|
|
63
|
+
if (domains.size >= MAX_CAMPAIGN_DOMAINS)
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
66
|
+
return [...domains].sort();
|
|
67
|
+
}
|
|
68
|
+
/** Normalize one record, or return null when it has no usable name. */
|
|
69
|
+
export function normalizeCampaign(raw, fallbackNow = new Date().toISOString()) {
|
|
70
|
+
const record = plainRecord(raw) || {};
|
|
71
|
+
const name = normalizeName(record.name);
|
|
72
|
+
if (!name)
|
|
73
|
+
return null;
|
|
74
|
+
const fallback = isoOrNull(fallbackNow) || new Date().toISOString();
|
|
75
|
+
const createdAt = isoOrNull(record.createdAt) || fallback;
|
|
76
|
+
const updatedAt = isoOrNull(record.updatedAt) || createdAt;
|
|
77
|
+
return {
|
|
78
|
+
id: safeId(record.id) || `campaign-${hashString(`${name.toLowerCase()}|${createdAt}`)}`,
|
|
79
|
+
name,
|
|
80
|
+
description: normalizeDescription(record.description),
|
|
81
|
+
domains: normalizeCampaignDomains(record.domains),
|
|
82
|
+
createdAt,
|
|
83
|
+
updatedAt,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
function asCampaignList(raw) {
|
|
87
|
+
if (Array.isArray(raw))
|
|
88
|
+
return raw;
|
|
89
|
+
const value = plainRecord(raw);
|
|
90
|
+
if (value && Array.isArray(value.campaigns))
|
|
91
|
+
return value.campaigns;
|
|
92
|
+
return [];
|
|
93
|
+
}
|
|
94
|
+
export function campaignStoreVersion(raw) {
|
|
95
|
+
const value = plainRecord(raw);
|
|
96
|
+
return value && typeof value.version === 'number' && Number.isFinite(value.version) ? value.version : null;
|
|
97
|
+
}
|
|
98
|
+
/** Recover a deterministic, bounded store from parsed browser data. */
|
|
99
|
+
export function normalizeCampaignStore(raw) {
|
|
100
|
+
const now = new Date().toISOString();
|
|
101
|
+
const byId = new Map();
|
|
102
|
+
for (const item of asCampaignList(raw).slice(0, MAX_CAMPAIGN_INPUT_RECORDS)) {
|
|
103
|
+
const campaign = normalizeCampaign(item, now);
|
|
104
|
+
if (!campaign)
|
|
105
|
+
continue;
|
|
106
|
+
const existing = byId.get(campaign.id);
|
|
107
|
+
const campaignKey = `${campaign.name}\u0000${campaign.description}\u0000${campaign.domains.join('\u0000')}\u0000${campaign.createdAt}`;
|
|
108
|
+
const existingKey = existing ? `${existing.name}\u0000${existing.description}\u0000${existing.domains.join('\u0000')}\u0000${existing.createdAt}` : '';
|
|
109
|
+
if (!existing
|
|
110
|
+
|| Date.parse(campaign.updatedAt) > Date.parse(existing.updatedAt)
|
|
111
|
+
|| (campaign.updatedAt === existing.updatedAt && campaignKey.localeCompare(existingKey) > 0)) {
|
|
112
|
+
byId.set(campaign.id, campaign);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
const used = new Set();
|
|
116
|
+
const campaigns = [...byId.values()]
|
|
117
|
+
.sort((left, right) => Date.parse(right.updatedAt) - Date.parse(left.updatedAt)
|
|
118
|
+
|| left.name.localeCompare(right.name)
|
|
119
|
+
|| left.id.localeCompare(right.id))
|
|
120
|
+
.slice(0, MAX_CAMPAIGNS)
|
|
121
|
+
.map((campaign) => {
|
|
122
|
+
let id = campaign.id;
|
|
123
|
+
if (used.has(id)) {
|
|
124
|
+
const base = `campaign-${hashString(`${campaign.name}|${campaign.createdAt}`)}`;
|
|
125
|
+
id = base;
|
|
126
|
+
let suffix = 2;
|
|
127
|
+
while (used.has(id))
|
|
128
|
+
id = `${base}-${suffix++}`;
|
|
129
|
+
}
|
|
130
|
+
used.add(id);
|
|
131
|
+
return { ...campaign, id };
|
|
132
|
+
});
|
|
133
|
+
return { version: CAMPAIGN_SCHEMA_VERSION, campaigns };
|
|
134
|
+
}
|
|
135
|
+
export function createCampaign(campaigns, input, nowIso = new Date().toISOString()) {
|
|
136
|
+
if (campaigns.length >= MAX_CAMPAIGNS)
|
|
137
|
+
throw new Error(`Campaigns are limited to ${MAX_CAMPAIGNS}. Delete or export one first.`);
|
|
138
|
+
const name = normalizeName(input?.name);
|
|
139
|
+
if (!name)
|
|
140
|
+
throw new Error('A campaign name is required.');
|
|
141
|
+
const now = isoOrNull(nowIso) || new Date().toISOString();
|
|
142
|
+
const record = {
|
|
143
|
+
id: makeId(),
|
|
144
|
+
name,
|
|
145
|
+
description: normalizeDescription(input?.description),
|
|
146
|
+
domains: normalizeCampaignDomains(input?.domains),
|
|
147
|
+
createdAt: now,
|
|
148
|
+
updatedAt: now,
|
|
149
|
+
};
|
|
150
|
+
return { campaigns: [record, ...campaigns], record };
|
|
151
|
+
}
|
|
152
|
+
export function updateCampaign(campaigns, id, patch, nowIso = new Date().toISOString()) {
|
|
153
|
+
const index = campaigns.findIndex((campaign) => campaign.id === id);
|
|
154
|
+
if (index < 0)
|
|
155
|
+
throw new Error('That campaign no longer exists.');
|
|
156
|
+
const current = campaigns[index];
|
|
157
|
+
if (!current)
|
|
158
|
+
throw new Error('That campaign no longer exists.');
|
|
159
|
+
const name = patch.name === undefined ? current.name : normalizeName(patch.name);
|
|
160
|
+
if (!name)
|
|
161
|
+
throw new Error('A campaign name is required.');
|
|
162
|
+
const record = {
|
|
163
|
+
...current,
|
|
164
|
+
name,
|
|
165
|
+
description: patch.description === undefined
|
|
166
|
+
? current.description
|
|
167
|
+
: normalizeDescription(patch.description),
|
|
168
|
+
domains: patch.domains === undefined ? current.domains : normalizeCampaignDomains(patch.domains),
|
|
169
|
+
updatedAt: isoOrNull(nowIso) || new Date().toISOString(),
|
|
170
|
+
};
|
|
171
|
+
const next = [...campaigns];
|
|
172
|
+
next[index] = record;
|
|
173
|
+
return { campaigns: next, record };
|
|
174
|
+
}
|
|
175
|
+
export function addCampaignDomain(campaigns, id, domain, nowIso) {
|
|
176
|
+
const record = campaigns.find((campaign) => campaign.id === id);
|
|
177
|
+
if (!record)
|
|
178
|
+
throw new Error('That campaign no longer exists.');
|
|
179
|
+
const normalized = normalizeDomain(domain);
|
|
180
|
+
if (!normalized)
|
|
181
|
+
throw new Error('A valid case domain is required.');
|
|
182
|
+
if (record.domains.includes(normalized))
|
|
183
|
+
return { campaigns, record, added: false };
|
|
184
|
+
if (record.domains.length >= MAX_CAMPAIGN_DOMAINS) {
|
|
185
|
+
throw new Error(`Each campaign is limited to ${MAX_CAMPAIGN_DOMAINS} case domains.`);
|
|
186
|
+
}
|
|
187
|
+
const result = updateCampaign(campaigns, id, { domains: [...record.domains, normalized] }, nowIso);
|
|
188
|
+
return { ...result, added: true };
|
|
189
|
+
}
|
|
190
|
+
export function removeCampaignDomain(campaigns, id, domain, nowIso) {
|
|
191
|
+
const record = campaigns.find((campaign) => campaign.id === id);
|
|
192
|
+
if (!record)
|
|
193
|
+
throw new Error('That campaign no longer exists.');
|
|
194
|
+
const normalized = normalizeDomain(domain);
|
|
195
|
+
return updateCampaign(campaigns, id, { domains: record.domains.filter((item) => item !== normalized) }, nowIso);
|
|
196
|
+
}
|
|
197
|
+
function mergeCampaign(local, imported) {
|
|
198
|
+
const importedNewer = Date.parse(imported.updatedAt) > Date.parse(local.updatedAt);
|
|
199
|
+
return {
|
|
200
|
+
...local,
|
|
201
|
+
name: importedNewer ? imported.name : local.name,
|
|
202
|
+
description: importedNewer ? imported.description : local.description,
|
|
203
|
+
domains: normalizeCampaignDomains([...local.domains, ...imported.domains]),
|
|
204
|
+
createdAt: Date.parse(imported.createdAt) < Date.parse(local.createdAt) ? imported.createdAt : local.createdAt,
|
|
205
|
+
updatedAt: importedNewer ? imported.updatedAt : local.updatedAt,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
/** Non-destructively merge a portable export into local campaigns by id. */
|
|
209
|
+
export function mergeCampaigns(localRaw, importedRaw) {
|
|
210
|
+
const importedEnvelope = plainRecord(importedRaw);
|
|
211
|
+
if (importedEnvelope && typeof importedEnvelope.schema === 'string' && importedEnvelope.schema !== CAMPAIGN_SCHEMA) {
|
|
212
|
+
throw new Error('This JSON file is not a WHOISleuth campaign export.');
|
|
213
|
+
}
|
|
214
|
+
const version = campaignStoreVersion(importedRaw);
|
|
215
|
+
if (version !== null && version > CAMPAIGN_SCHEMA_VERSION) {
|
|
216
|
+
throw new Error(`This campaign file uses newer schema ${version}. Update the app before importing it.`);
|
|
217
|
+
}
|
|
218
|
+
const local = normalizeCampaignStore(localRaw).campaigns;
|
|
219
|
+
const byId = new Map(local.map((campaign) => [campaign.id, campaign]));
|
|
220
|
+
let added = 0;
|
|
221
|
+
let updated = 0;
|
|
222
|
+
const importedList = asCampaignList(importedRaw);
|
|
223
|
+
let skipped = Math.max(0, importedList.length - MAX_CAMPAIGN_INPUT_RECORDS);
|
|
224
|
+
for (const raw of importedList.slice(0, MAX_CAMPAIGN_INPUT_RECORDS)) {
|
|
225
|
+
const imported = normalizeCampaign(raw, '1970-01-01T00:00:00.000Z');
|
|
226
|
+
if (!imported) {
|
|
227
|
+
skipped++;
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
const existing = byId.get(imported.id);
|
|
231
|
+
if (existing) {
|
|
232
|
+
byId.set(imported.id, mergeCampaign(existing, imported));
|
|
233
|
+
updated++;
|
|
234
|
+
}
|
|
235
|
+
else if (byId.size < MAX_CAMPAIGNS) {
|
|
236
|
+
byId.set(imported.id, imported);
|
|
237
|
+
added++;
|
|
238
|
+
}
|
|
239
|
+
else
|
|
240
|
+
skipped++;
|
|
241
|
+
}
|
|
242
|
+
return { ...normalizeCampaignStore([...byId.values()]), added, updated, skipped };
|
|
243
|
+
}
|
|
244
|
+
export function serializeCampaignStore(campaigns) {
|
|
245
|
+
return JSON.stringify(normalizeCampaignStore(campaigns));
|
|
246
|
+
}
|
|
247
|
+
function byteLength(value) {
|
|
248
|
+
return new TextEncoder().encode(value).byteLength;
|
|
249
|
+
}
|
|
250
|
+
export function assertCampaignStoreBudget(campaigns) {
|
|
251
|
+
const store = normalizeCampaignStore(campaigns);
|
|
252
|
+
if (byteLength(JSON.stringify(store)) > MAX_CAMPAIGN_STORE_BYTES) {
|
|
253
|
+
throw new Error('Campaign storage is full. Remove case domains or export and delete a campaign before saving.');
|
|
254
|
+
}
|
|
255
|
+
return store;
|
|
256
|
+
}
|
|
257
|
+
export function buildCampaignExport(campaigns, nowIso = new Date().toISOString()) {
|
|
258
|
+
return {
|
|
259
|
+
schema: CAMPAIGN_SCHEMA,
|
|
260
|
+
version: CAMPAIGN_SCHEMA_VERSION,
|
|
261
|
+
exportedAt: isoOrNull(nowIso) || new Date().toISOString(),
|
|
262
|
+
campaigns: normalizeCampaignStore(campaigns).campaigns,
|
|
263
|
+
limitations: 'Campaigns contain browser-local labels and domain membership only. They do not prove common ownership, coordination, intent, or maliciousness.',
|
|
264
|
+
};
|
|
265
|
+
}
|