@slicedearth/whoisleuth-cli 1.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (189) hide show
  1. package/LICENSE +661 -0
  2. package/LICENSES/Retire.js-Apache-2.0.txt +199 -0
  3. package/NOTICE +76 -0
  4. package/README.md +84 -0
  5. package/TRADEMARKS.md +41 -0
  6. package/bin/whoisleuth.mjs +35 -0
  7. package/cli/archive-inspect.mjs +198 -0
  8. package/cli/arguments.mjs +1022 -0
  9. package/cli/artifact-verify.mjs +210 -0
  10. package/cli/bulk-checkpoint.mjs +218 -0
  11. package/cli/bulk-command-runner.mjs +109 -0
  12. package/cli/bulk-output.mjs +112 -0
  13. package/cli/bulk.mjs +133 -0
  14. package/cli/compare.mjs +238 -0
  15. package/cli/completion.mjs +175 -0
  16. package/cli/csv.mjs +1 -0
  17. package/cli/discover.mjs +50 -0
  18. package/cli/discovery-command-runner.mjs +36 -0
  19. package/cli/discovery-observation-snapshot.mjs +315 -0
  20. package/cli/discovery-scan-command-runner.mjs +141 -0
  21. package/cli/discovery-scan.mjs +325 -0
  22. package/cli/discovery-snapshot.mjs +133 -0
  23. package/cli/discovery-workflow.mjs +73 -0
  24. package/cli/doctor.mjs +137 -0
  25. package/cli/errors.mjs +21 -0
  26. package/cli/evidence-command-arguments.mjs +145 -0
  27. package/cli/evidence-command-runner.mjs +106 -0
  28. package/cli/evidence-signing.mjs +215 -0
  29. package/cli/exit-codes.mjs +9 -0
  30. package/cli/export-evidence.mjs +28 -0
  31. package/cli/formatters/evidence-report.mjs +3 -0
  32. package/cli/formatters/html.mjs +69 -0
  33. package/cli/formatters/json.mjs +135 -0
  34. package/cli/formatters/markdown.mjs +3 -0
  35. package/cli/formatters/terminal.mjs +658 -0
  36. package/cli/http.mjs +27 -0
  37. package/cli/lookup-command-runner.mjs +87 -0
  38. package/cli/lookup-diff.mjs +171 -0
  39. package/cli/mail-review.mjs +204 -0
  40. package/cli/manual.mjs +34 -0
  41. package/cli/network-command-runner.mjs +88 -0
  42. package/cli/output-file.mjs +97 -0
  43. package/cli/page-compare.mjs +123 -0
  44. package/cli/posture.mjs +20 -0
  45. package/cli/progress-events.mjs +53 -0
  46. package/cli/progress.mjs +56 -0
  47. package/cli/registry-access.mjs +3 -0
  48. package/cli/registry-support.mjs +120 -0
  49. package/cli/risk-calibration.mjs +294 -0
  50. package/cli/runner-types.mjs +1 -0
  51. package/cli/runner.mjs +726 -0
  52. package/cli/saved-lookup.mjs +87 -0
  53. package/cli/source-reliability.mjs +727 -0
  54. package/cli/strict-exit.mjs +55 -0
  55. package/cli/terminal-presentation.mjs +147 -0
  56. package/docs/cli.md +827 -0
  57. package/frontend/src/lib/analysis/acquisition-decision-packet.js +93 -0
  58. package/frontend/src/lib/analysis/acquisition-due-diligence.js +391 -0
  59. package/frontend/src/lib/analysis/artifact-integrity.js +21 -0
  60. package/frontend/src/lib/analysis/brand-profile-model.js +421 -0
  61. package/frontend/src/lib/analysis/bulk-domain-comparison.js +246 -0
  62. package/frontend/src/lib/analysis/bulk-mail-exposure.js +192 -0
  63. package/frontend/src/lib/analysis/bulk-retry-plan.js +86 -0
  64. package/frontend/src/lib/analysis/bulk-review-export.js +62 -0
  65. package/frontend/src/lib/analysis/bulk-review-model.js +266 -0
  66. package/frontend/src/lib/analysis/bulk-session-model.js +515 -0
  67. package/frontend/src/lib/analysis/bulk-sort.js +49 -0
  68. package/frontend/src/lib/analysis/campaign-model.js +265 -0
  69. package/frontend/src/lib/analysis/case-evidence-model.js +642 -0
  70. package/frontend/src/lib/analysis/case-migration-model.js +248 -0
  71. package/frontend/src/lib/analysis/case-model.js +5 -0
  72. package/frontend/src/lib/analysis/case-record-contracts.js +44 -0
  73. package/frontend/src/lib/analysis/case-record-core.js +232 -0
  74. package/frontend/src/lib/analysis/case-record-model.js +7 -0
  75. package/frontend/src/lib/analysis/case-record-operations.js +224 -0
  76. package/frontend/src/lib/analysis/case-response-model.js +640 -0
  77. package/frontend/src/lib/analysis/case-response-packet.js +573 -0
  78. package/frontend/src/lib/analysis/case-storage-model.js +93 -0
  79. package/frontend/src/lib/analysis/detection-rule-model.js +306 -0
  80. package/frontend/src/lib/analysis/dns-record-normalization.js +11 -0
  81. package/frontend/src/lib/analysis/http-summary.js +165 -0
  82. package/frontend/src/lib/analysis/investigation-guide.js +500 -0
  83. package/frontend/src/lib/analysis/investigation-template-model.js +144 -0
  84. package/frontend/src/lib/analysis/page-baseline.js +272 -0
  85. package/frontend/src/lib/analysis/page-similarity.js +179 -0
  86. package/frontend/src/lib/analysis/relationship-evidence.js +265 -0
  87. package/frontend/src/lib/analysis/relationship-observation-model.js +423 -0
  88. package/frontend/src/lib/analysis/scoring.js +135 -0
  89. package/frontend/src/lib/analysis/shortlist-model.js +209 -0
  90. package/frontend/src/lib/analysis/utils.js +265 -0
  91. package/frontend/src/lib/analysis/watchlist-history.js +643 -0
  92. package/frontend/src/lib/analysis/watchlist-store.js +127 -0
  93. package/frontend/src/lib/analysis/website-snapshot-model.js +302 -0
  94. package/frontend/src/lib/analysis/workspace-archive-crypto.js +282 -0
  95. package/frontend/src/lib/analysis/workspace-archive.js +472 -0
  96. package/lib/abort.mjs +26 -0
  97. package/lib/abusech-auth.mjs +19 -0
  98. package/lib/availability.mjs +716 -0
  99. package/lib/bounded-contract-normalizers.mjs +61 -0
  100. package/lib/browser-library-profile.mjs +310 -0
  101. package/lib/bulk-comparison-evidence.mjs +83 -0
  102. package/lib/classify.mjs +93 -0
  103. package/lib/client-behavior-profile.mjs +83 -0
  104. package/lib/credential-surface-profile.mjs +66 -0
  105. package/lib/csv.mjs +23 -0
  106. package/lib/ct-query.mjs +27 -0
  107. package/lib/ct-search.mjs +393 -0
  108. package/lib/distributed-operation-budget.mjs +461 -0
  109. package/lib/dns-delegation-health.mjs +344 -0
  110. package/lib/dns-intelligence.mjs +739 -0
  111. package/lib/dns-mx.mjs +24 -0
  112. package/lib/dns-resolver-selection.mjs +33 -0
  113. package/lib/domain-posture-analysis.mjs +254 -0
  114. package/lib/domain-posture-parsers.mjs +358 -0
  115. package/lib/domain-posture.mjs +745 -0
  116. package/lib/error-detail.mjs +10 -0
  117. package/lib/evidence-export.mjs +431 -0
  118. package/lib/evidence-report-markdown.mjs +71 -0
  119. package/lib/evidence-report.mjs +391 -0
  120. package/lib/external-intelligence-risk.mjs +108 -0
  121. package/lib/favicon.mjs +146 -0
  122. package/lib/feature-policy.mjs +70 -0
  123. package/lib/generated/retire-browser-catalog.mjs +8724 -0
  124. package/lib/generated/unicode-confusables-17.mjs +93 -0
  125. package/lib/hostname.mjs +38 -0
  126. package/lib/html-signals.mjs +680 -0
  127. package/lib/http-intelligence.mjs +232 -0
  128. package/lib/idn-confusable-policy.mjs +86 -0
  129. package/lib/idn-confusables.mjs +465 -0
  130. package/lib/lookup-cache.mjs +130 -0
  131. package/lib/lookup-progress.mjs +291 -0
  132. package/lib/lookup-source-progress.mjs +109 -0
  133. package/lib/lookup-threat-provider-inventory.mjs +13 -0
  134. package/lib/lookup.mjs +592 -0
  135. package/lib/observation.mjs +114 -0
  136. package/lib/observed-network-context.mjs +355 -0
  137. package/lib/operation-budget.mjs +489 -0
  138. package/lib/page-fingerprints.mjs +481 -0
  139. package/lib/page-role-profile.mjs +117 -0
  140. package/lib/perceptual-hash.mjs +451 -0
  141. package/lib/rdap-attempts.mjs +34 -0
  142. package/lib/rdap-bootstrap.mjs +173 -0
  143. package/lib/rdap-capabilities.mjs +292 -0
  144. package/lib/rdap-client.mjs +125 -0
  145. package/lib/rdap-normalization.mjs +691 -0
  146. package/lib/rdap-registrar.mjs +196 -0
  147. package/lib/rdap-transport.mjs +39 -0
  148. package/lib/rdap-types.mjs +1 -0
  149. package/lib/rdap-validation.mjs +94 -0
  150. package/lib/rdap.mjs +16 -0
  151. package/lib/registry-access.mjs +32 -0
  152. package/lib/registry-capabilities.mjs +117 -0
  153. package/lib/registry-capability-catalogue.mjs +1206 -0
  154. package/lib/registry-comparison.mjs +318 -0
  155. package/lib/registry-dates.mjs +190 -0
  156. package/lib/registry-insights.mjs +325 -0
  157. package/lib/response-policy.mjs +256 -0
  158. package/lib/risk-scoring.mjs +164 -0
  159. package/lib/safe-fetch.mjs +446 -0
  160. package/lib/security-txt.mjs +359 -0
  161. package/lib/service-binding-dns.mjs +608 -0
  162. package/lib/sslbl-certificates.generated.mjs +143 -0
  163. package/lib/sslbl-intelligence.mjs +241 -0
  164. package/lib/static-html-analysis.mjs +340 -0
  165. package/lib/structured-data-identity.mjs +323 -0
  166. package/lib/threat-intelligence-contract.mjs +5 -0
  167. package/lib/threat-intelligence-definition-registry.mjs +34 -0
  168. package/lib/threat-intelligence-fixtures.mjs +82 -0
  169. package/lib/threat-intelligence-manifests.mjs +512 -0
  170. package/lib/threat-intelligence-runtime.mjs +662 -0
  171. package/lib/threat-intelligence-targets.mjs +168 -0
  172. package/lib/threat-intelligence-types.mjs +3 -0
  173. package/lib/threatfox-intelligence.mjs +307 -0
  174. package/lib/tls-intelligence.mjs +814 -0
  175. package/lib/typosquat-generator.mjs +884 -0
  176. package/lib/urlhaus-intelligence.mjs +361 -0
  177. package/lib/urlscan-intelligence.mjs +349 -0
  178. package/lib/website-security-posture.mjs +325 -0
  179. package/lib/website-technology.mjs +437 -0
  180. package/lib/whois-authority.mjs +98 -0
  181. package/lib/whois-chain.mjs +261 -0
  182. package/lib/whois-contacts.mjs +163 -0
  183. package/lib/whois-contracts.mjs +3 -0
  184. package/lib/whois-normalization.mjs +27 -0
  185. package/lib/whois-transport.mjs +100 -0
  186. package/lib/whois-values.mjs +92 -0
  187. package/lib/whois.mjs +1187 -0
  188. package/package.json +55 -0
  189. package/third-party-notices.txt +5571 -0
@@ -0,0 +1,472 @@
1
+ // Pure, bounded workspace archive composition. Existing typed store models remain the
2
+ // authority for normalization and merge semantics; this module only packages
3
+ // their portable contracts, verifies integrity, and previews conflicts.
4
+ import { buildCaseExport, CASE_SCHEMA_VERSION, enforceStoreBudget, mergeCases, } from "./case-model.js";
5
+ import { assertBrandProfileStoreBudget, BRAND_PROFILE_SCHEMA, BRAND_PROFILE_SCHEMA_VERSION, buildBrandProfileExport, mergeBrandProfiles, } from "./brand-profile-model.js";
6
+ import { assertCampaignStoreBudget, buildCampaignExport, CAMPAIGN_SCHEMA, CAMPAIGN_SCHEMA_VERSION, mergeCampaigns, } from "./campaign-model.js";
7
+ import { assertWatchlistStoreBudget, buildWatchlistExport, mergeWatchlistStores, WATCHLIST_SCHEMA, WATCHLIST_SCHEMA_VERSION, } from "./watchlist-store.js";
8
+ import { assertShortlistStoreBudget, buildShortlistExport, mergeShortlistStores, SHORTLIST_SCHEMA, SHORTLIST_SCHEMA_VERSION, } from "./shortlist-model.js";
9
+ import { assertDetectionRuleStoreBudget, buildDetectionRuleExport, DETECTION_RULE_SCHEMA, DETECTION_RULE_SCHEMA_VERSION, mergeDetectionRules, } from "./detection-rule-model.js";
10
+ import { buildRelationshipObservationExport, mergeRelationshipObservations, RELATIONSHIP_OBSERVATION_SCHEMA, RELATIONSHIP_OBSERVATION_SCHEMA_VERSION, } from "./relationship-observation-model.js";
11
+ import { BULK_SESSION_SCHEMA, BULK_SESSION_SCHEMA_VERSION, buildBulkSessionExport, enforceBulkSessionStoreBudget, mergeBulkSessions, } from "./bulk-session-model.js";
12
+ import { WEBSITE_SNAPSHOT_SCHEMA, WEBSITE_SNAPSHOT_SCHEMA_VERSION, buildWebsiteSnapshotExport, mergeWebsiteSnapshots, } from "./website-snapshot-model.js";
13
+ import { INVESTIGATION_TEMPLATE_SCHEMA, INVESTIGATION_TEMPLATE_VERSION, buildInvestigationTemplateExport, mergeInvestigationTemplates, } from "./investigation-template-model.js";
14
+ import { BULK_REVIEW_SCHEMA, BULK_REVIEW_SCHEMA_VERSION, buildBulkReviewExport, mergeBulkReviewStores, } from "./bulk-review-model.js";
15
+ export const WORKSPACE_ARCHIVE_SCHEMA = 'whoisleuth.workspace-archive';
16
+ export const WORKSPACE_ARCHIVE_VERSION = 5;
17
+ export const SUPPORTED_WORKSPACE_ARCHIVE_VERSIONS = [1, 2, 3, 4, WORKSPACE_ARCHIVE_VERSION];
18
+ export const WORKSPACE_SETTINGS_SCHEMA = 'whoisleuth.workspace-settings';
19
+ export const WORKSPACE_SETTINGS_VERSION = 1;
20
+ export const MAX_WORKSPACE_ARCHIVE_BYTES = 10 * 1024 * 1024;
21
+ export const MAX_WORKSPACE_ARCHIVE_SECTION_BYTES = 5 * 1024 * 1024;
22
+ export const MAX_WORKSPACE_ARCHIVE_SECTIONS = 12;
23
+ export const WORKSPACE_ARCHIVE_SECTION_IDS = [
24
+ 'cases',
25
+ 'campaigns',
26
+ 'brandProfiles',
27
+ 'watchlists',
28
+ 'shortlist',
29
+ 'detectionRules',
30
+ 'relationshipObservations',
31
+ 'bulkSessions',
32
+ 'websiteSnapshots',
33
+ 'investigationTemplates',
34
+ 'bulkReview',
35
+ 'settings',
36
+ ];
37
+ export function isSupportedWorkspaceArchiveVersion(value) {
38
+ return typeof value === 'number'
39
+ && Number.isSafeInteger(value)
40
+ && SUPPORTED_WORKSPACE_ARCHIVE_VERSIONS.some((version) => version === value);
41
+ }
42
+ const CONTROL_RE = /[\x00-\x1f\x7f]/;
43
+ const SAFE_ID_RE = /^[A-Za-z0-9_-]{1,128}$/;
44
+ const CHECKSUM_RE = /^sha256:[a-f0-9]{64}$/;
45
+ function record(value) {
46
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
47
+ }
48
+ function byteLength(value) {
49
+ return new TextEncoder().encode(value).byteLength;
50
+ }
51
+ function serialize(value, message = 'The workspace archive contains data that cannot be serialized.') {
52
+ try {
53
+ const serialized = JSON.stringify(value);
54
+ if (typeof serialized !== 'string')
55
+ throw new Error(message);
56
+ return serialized;
57
+ }
58
+ catch {
59
+ throw new Error(message);
60
+ }
61
+ }
62
+ function clone(value) {
63
+ return JSON.parse(serialize(value));
64
+ }
65
+ function timestamp(value, fallback = null) {
66
+ if (typeof value !== 'string' || value.length > 64 || CONTROL_RE.test(value))
67
+ return fallback;
68
+ const parsed = Date.parse(value);
69
+ return Number.isFinite(parsed) ? new Date(parsed).toISOString() : fallback;
70
+ }
71
+ function boundedText(value, maximum = 300) {
72
+ if (typeof value !== 'string' || value.length > maximum * 4 || CONTROL_RE.test(value))
73
+ return '';
74
+ return value.replace(/\s+/g, ' ').trim().slice(0, maximum).trim();
75
+ }
76
+ function normalizeTheme(value) {
77
+ return value === 'dark' || value === 'light' ? value : 'system';
78
+ }
79
+ function canonicalize(value) {
80
+ if (Array.isArray(value))
81
+ return value.map(canonicalize);
82
+ const item = record(value);
83
+ if (!item)
84
+ return value;
85
+ return Object.fromEntries(Object.keys(item).sort().map((key) => [key, canonicalize(item[key])]));
86
+ }
87
+ function canonicalString(value) {
88
+ try {
89
+ return JSON.stringify(canonicalize(value));
90
+ }
91
+ catch {
92
+ throw new Error('A workspace archive section could not be canonicalized safely.');
93
+ }
94
+ }
95
+ async function checksum(value, cryptoProvider = globalThis.crypto) {
96
+ if (!cryptoProvider?.subtle?.digest) {
97
+ throw new Error('Workspace archive checksums are unavailable in this browser.');
98
+ }
99
+ const digest = await cryptoProvider.subtle.digest('SHA-256', new TextEncoder().encode(canonicalString(value)));
100
+ return `sha256:${[...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, '0')).join('')}`;
101
+ }
102
+ function settingsDocument(input) {
103
+ const profiles = Array.isArray(input.brandProfiles) ? input.brandProfiles : [];
104
+ const requestedProfileId = typeof input.settings?.activeProfileId === 'string' && SAFE_ID_RE.test(input.settings.activeProfileId)
105
+ ? input.settings.activeProfileId
106
+ : '';
107
+ const activeProfileId = profiles.some((profile) => profile?.id === requestedProfileId)
108
+ ? requestedProfileId
109
+ : '';
110
+ return {
111
+ schema: WORKSPACE_SETTINGS_SCHEMA,
112
+ version: WORKSPACE_SETTINGS_VERSION,
113
+ activeProfileId,
114
+ theme: normalizeTheme(input.settings?.theme),
115
+ };
116
+ }
117
+ function workspaceArchiveSections(input, now) {
118
+ return {
119
+ cases: buildCaseExport(input.cases, now),
120
+ campaigns: buildCampaignExport(input.campaigns, now),
121
+ brandProfiles: buildBrandProfileExport(input.brandProfiles, now),
122
+ watchlists: buildWatchlistExport(input.watchlists, now),
123
+ shortlist: buildShortlistExport(input.shortlist, now),
124
+ detectionRules: buildDetectionRuleExport(input.detectionRules, now),
125
+ relationshipObservations: buildRelationshipObservationExport(input.relationshipObservations, now),
126
+ bulkSessions: buildBulkSessionExport(input.bulkSessions, now),
127
+ websiteSnapshots: buildWebsiteSnapshotExport(input.websiteSnapshots, now),
128
+ investigationTemplates: buildInvestigationTemplateExport(input.investigationTemplates, now),
129
+ bulkReview: buildBulkReviewExport(input.bulkReview),
130
+ settings: settingsDocument(input),
131
+ };
132
+ }
133
+ function arrayCount(data, key) {
134
+ const value = record(data);
135
+ return value && Array.isArray(value[key]) ? value[key].length : 0;
136
+ }
137
+ function objectCount(data, key) {
138
+ const value = record(data);
139
+ const nested = value ? record(value[key]) : null;
140
+ return nested ? Object.keys(nested).length : 0;
141
+ }
142
+ const SECTION_DEFINITIONS = [
143
+ {
144
+ id: 'cases', label: 'Cases', schema: null, version: CASE_SCHEMA_VERSION,
145
+ count: (data) => arrayCount(data, 'cases'),
146
+ merge: (local, data) => {
147
+ const result = mergeCases(local.cases, data);
148
+ const bounded = enforceStoreBudget(result.cases);
149
+ return { ...result, cases: bounded.cases, pruned: bounded.pruned };
150
+ },
151
+ },
152
+ {
153
+ id: 'campaigns', label: 'Campaigns', schema: CAMPAIGN_SCHEMA, version: CAMPAIGN_SCHEMA_VERSION,
154
+ count: (data) => arrayCount(data, 'campaigns'),
155
+ merge: (local, data) => {
156
+ const result = mergeCampaigns(local.campaigns, data);
157
+ return { ...result, campaigns: assertCampaignStoreBudget(result.campaigns).campaigns };
158
+ },
159
+ },
160
+ {
161
+ id: 'brandProfiles', label: 'Brand profiles', schema: BRAND_PROFILE_SCHEMA, version: BRAND_PROFILE_SCHEMA_VERSION,
162
+ count: (data) => arrayCount(data, 'profiles'),
163
+ merge: (local, data, now) => {
164
+ const result = mergeBrandProfiles(local.brandProfiles, data, { nowIso: now });
165
+ return { ...result, profiles: assertBrandProfileStoreBudget(result.profiles).profiles };
166
+ },
167
+ },
168
+ {
169
+ id: 'watchlists', label: 'Watchlists', schema: WATCHLIST_SCHEMA, version: WATCHLIST_SCHEMA_VERSION,
170
+ count: (data) => objectCount(data, 'watchlists'),
171
+ merge: (local, data) => {
172
+ const result = mergeWatchlistStores(local.watchlists, data);
173
+ return { ...result, watchlists: assertWatchlistStoreBudget(result.watchlists).watchlists };
174
+ },
175
+ },
176
+ {
177
+ id: 'shortlist', label: 'Shortlist', schema: SHORTLIST_SCHEMA, version: SHORTLIST_SCHEMA_VERSION,
178
+ count: (data) => arrayCount(data, 'entries'),
179
+ merge: (local, data) => {
180
+ const result = mergeShortlistStores(local.shortlist, data);
181
+ return { ...result, entries: assertShortlistStoreBudget(result.entries).entries };
182
+ },
183
+ },
184
+ {
185
+ id: 'detectionRules', label: 'Detection rules', schema: DETECTION_RULE_SCHEMA, version: DETECTION_RULE_SCHEMA_VERSION,
186
+ count: (data) => arrayCount(data, 'rules'),
187
+ merge: (local, data) => {
188
+ const result = mergeDetectionRules(local.detectionRules, data);
189
+ return { ...result, rules: assertDetectionRuleStoreBudget(result.rules).rules };
190
+ },
191
+ },
192
+ {
193
+ id: 'relationshipObservations',
194
+ label: 'Retained relationship observations',
195
+ schema: RELATIONSHIP_OBSERVATION_SCHEMA,
196
+ version: RELATIONSHIP_OBSERVATION_SCHEMA_VERSION,
197
+ count: (data) => arrayCount(data, 'observations'),
198
+ merge: (local, data) => mergeRelationshipObservations(local.relationshipObservations, data),
199
+ },
200
+ {
201
+ id: 'bulkSessions',
202
+ label: 'Saved Bulk sessions',
203
+ schema: BULK_SESSION_SCHEMA,
204
+ version: BULK_SESSION_SCHEMA_VERSION,
205
+ count: (data) => arrayCount(data, 'sessions'),
206
+ merge: (local, data) => {
207
+ const result = mergeBulkSessions(local.bulkSessions, data);
208
+ enforceBulkSessionStoreBudget(result.sessions);
209
+ return result;
210
+ },
211
+ },
212
+ {
213
+ id: 'websiteSnapshots',
214
+ label: 'Website profile snapshots',
215
+ schema: WEBSITE_SNAPSHOT_SCHEMA,
216
+ version: WEBSITE_SNAPSHOT_SCHEMA_VERSION,
217
+ count: (data) => arrayCount(data, 'snapshots'),
218
+ merge: (local, data) => mergeWebsiteSnapshots(local.websiteSnapshots, data),
219
+ },
220
+ {
221
+ id: 'investigationTemplates',
222
+ label: 'Investigation templates',
223
+ schema: INVESTIGATION_TEMPLATE_SCHEMA,
224
+ version: INVESTIGATION_TEMPLATE_VERSION,
225
+ count: (data) => arrayCount(data, 'templates'),
226
+ merge: (local, data) => mergeInvestigationTemplates(local.investigationTemplates, data),
227
+ },
228
+ {
229
+ id: 'bulkReview',
230
+ label: 'Bulk saved views and review queue',
231
+ schema: BULK_REVIEW_SCHEMA,
232
+ version: BULK_REVIEW_SCHEMA_VERSION,
233
+ count: (data) => arrayCount(data, 'presets') + arrayCount(data, 'rows'),
234
+ merge: (local, data) => mergeBulkReviewStores(local.bulkReview, data),
235
+ },
236
+ {
237
+ id: 'settings', label: 'Workspace settings', schema: WORKSPACE_SETTINGS_SCHEMA, version: WORKSPACE_SETTINGS_VERSION,
238
+ count: () => 1,
239
+ merge: null,
240
+ },
241
+ ];
242
+ const DEFINITION_BY_ID = new Map(SECTION_DEFINITIONS.map((definition) => [definition.id, definition]));
243
+ const SECTION_ORDER = new Map(WORKSPACE_ARCHIVE_SECTION_IDS.map((id, index) => [id, index]));
244
+ function canonicalSectionOrder(left, right) {
245
+ const leftIndex = SECTION_ORDER.get(left.id) ?? Number.MAX_SAFE_INTEGER;
246
+ const rightIndex = SECTION_ORDER.get(right.id) ?? Number.MAX_SAFE_INTEGER;
247
+ return leftIndex - rightIndex;
248
+ }
249
+ function normalizedInput(input) {
250
+ const value = record(input) || {};
251
+ return {
252
+ cases: Array.isArray(value.cases) ? value.cases : [],
253
+ campaigns: Array.isArray(value.campaigns) ? value.campaigns : [],
254
+ brandProfiles: Array.isArray(value.brandProfiles) ? value.brandProfiles : [],
255
+ watchlists: record(value.watchlists) || {},
256
+ shortlist: Array.isArray(value.shortlist) ? value.shortlist : [],
257
+ detectionRules: Array.isArray(value.detectionRules) ? value.detectionRules : [],
258
+ relationshipObservations: Array.isArray(value.relationshipObservations) ? value.relationshipObservations : [],
259
+ bulkSessions: Array.isArray(value.bulkSessions) ? value.bulkSessions : [],
260
+ websiteSnapshots: Array.isArray(value.websiteSnapshots) ? value.websiteSnapshots : [],
261
+ investigationTemplates: Array.isArray(value.investigationTemplates) ? value.investigationTemplates : [],
262
+ bulkReview: record(value.bulkReview) || {},
263
+ settings: record(value.settings) || {},
264
+ };
265
+ }
266
+ function ensureArchiveBudget(value) {
267
+ const serialized = serialize(value);
268
+ const bytes = byteLength(serialized);
269
+ if (bytes > MAX_WORKSPACE_ARCHIVE_BYTES) {
270
+ throw new Error('Workspace archives are limited to 10 MiB. Export smaller collections separately before trying again.');
271
+ }
272
+ return { serialized, bytes };
273
+ }
274
+ /** Build one deterministic, unencrypted archive from normalized local stores. */
275
+ export async function buildWorkspaceArchive(input, options = {}) {
276
+ const now = timestamp(options.generatedAt) || new Date().toISOString();
277
+ const source = normalizedInput(input);
278
+ const sections = workspaceArchiveSections(source, now);
279
+ const manifestSections = [];
280
+ let totalRecords = 0;
281
+ for (const definition of SECTION_DEFINITIONS) {
282
+ const data = sections[definition.id];
283
+ const sectionBytes = byteLength(serialize(data));
284
+ if (sectionBytes > MAX_WORKSPACE_ARCHIVE_SECTION_BYTES) {
285
+ throw new Error(`${definition.label} exceeds the 5 MiB workspace archive section limit.`);
286
+ }
287
+ const recordCount = definition.count(data);
288
+ manifestSections.push({
289
+ id: definition.id,
290
+ schema: definition.schema,
291
+ version: definition.version,
292
+ recordCount,
293
+ bytes: sectionBytes,
294
+ checksum: await checksum(data, options.cryptoProvider),
295
+ });
296
+ totalRecords += recordCount;
297
+ }
298
+ const archive = {
299
+ schema: WORKSPACE_ARCHIVE_SCHEMA,
300
+ version: WORKSPACE_ARCHIVE_VERSION,
301
+ generatedAt: now,
302
+ manifest: {
303
+ sectionCount: manifestSections.length,
304
+ totalRecords,
305
+ sections: manifestSections,
306
+ },
307
+ sections,
308
+ limitations: [
309
+ 'This archive contains only the bounded browser-local workspace sections listed in its manifest.',
310
+ 'It excludes login sessions, passwords, API credentials, hosted-monitor encryption keys, raw upstream payloads, tab state, and unrelated browser storage.',
311
+ 'Import is a reviewed non-destructive merge. Existing records can be updated according to their section-specific versioned merge rules but are not deleted by absence from the archive.',
312
+ ],
313
+ };
314
+ ensureArchiveBudget(archive);
315
+ return archive;
316
+ }
317
+ function manifestEntry(raw) {
318
+ const value = record(raw);
319
+ if (!value)
320
+ return null;
321
+ const id = boundedText(value.id, 40);
322
+ const schema = value.schema === null ? null : boundedText(value.schema, 100);
323
+ const version = typeof value.version === 'number' && Number.isSafeInteger(value.version) && value.version > 0 && value.version <= 1000 ? value.version : null;
324
+ const recordCount = typeof value.recordCount === 'number' && Number.isSafeInteger(value.recordCount) && value.recordCount >= 0 && value.recordCount <= 10000 ? value.recordCount : null;
325
+ const bytes = typeof value.bytes === 'number' && Number.isSafeInteger(value.bytes) && value.bytes >= 0 && value.bytes <= MAX_WORKSPACE_ARCHIVE_SECTION_BYTES ? value.bytes : null;
326
+ const checksumValue = typeof value.checksum === 'string' && CHECKSUM_RE.test(value.checksum) ? value.checksum : null;
327
+ return id && version !== null && recordCount !== null && bytes !== null && checksumValue
328
+ ? { id, schema, version, recordCount, bytes, checksum: checksumValue }
329
+ : null;
330
+ }
331
+ /** Validate structure, section byte counts, and checksums without applying data. */
332
+ export async function readWorkspaceArchive(raw, options = {}) {
333
+ const value = record(raw);
334
+ if (!value || value.schema !== WORKSPACE_ARCHIVE_SCHEMA) {
335
+ throw new Error('This file is not a WHOISleuth workspace archive.');
336
+ }
337
+ if (!isSupportedWorkspaceArchiveVersion(value.version)) {
338
+ if (typeof value.version === 'number' && Number.isSafeInteger(value.version) && value.version > WORKSPACE_ARCHIVE_VERSION) {
339
+ throw new Error(`This workspace archive uses newer schema ${value.version}. Update the app before importing it.`);
340
+ }
341
+ throw new Error(`Expected workspace archive schema 1, 2, 3, 4, or ${WORKSPACE_ARCHIVE_VERSION}.`);
342
+ }
343
+ const { bytes } = ensureArchiveBudget(value);
344
+ const manifest = record(value.manifest);
345
+ const sectionValues = record(value.sections);
346
+ if (!manifest || !sectionValues || !Array.isArray(manifest.sections)) {
347
+ throw new Error('The workspace archive manifest is missing or malformed.');
348
+ }
349
+ if (manifest.sections.length > MAX_WORKSPACE_ARCHIVE_SECTIONS) {
350
+ throw new Error(`Workspace archives are limited to ${MAX_WORKSPACE_ARCHIVE_SECTIONS} sections.`);
351
+ }
352
+ const seen = new Set();
353
+ const sections = [];
354
+ let totalRecords = 0;
355
+ for (const rawEntry of manifest.sections) {
356
+ const entry = manifestEntry(rawEntry);
357
+ if (!entry || seen.has(entry.id) || !Object.prototype.hasOwnProperty.call(sectionValues, entry.id)) {
358
+ throw new Error('The workspace archive manifest contains an invalid, duplicate, or missing section.');
359
+ }
360
+ seen.add(entry.id);
361
+ const data = sectionValues[entry.id];
362
+ const actualBytes = byteLength(serialize(data));
363
+ if (actualBytes !== entry.bytes)
364
+ throw new Error(`${entry.id} failed its archive byte-count check.`);
365
+ if (await checksum(data, options.cryptoProvider) !== entry.checksum) {
366
+ throw new Error(`${entry.id} failed its archive checksum check.`);
367
+ }
368
+ const definition = DEFINITION_BY_ID.get(entry.id);
369
+ let status = 'ready';
370
+ let reason = '';
371
+ if (!definition) {
372
+ status = 'unsupported';
373
+ reason = 'This app does not recognize the archive section.';
374
+ }
375
+ else if (entry.version !== definition.version || entry.schema !== definition.schema) {
376
+ status = 'unsupported';
377
+ reason = entry.version > definition.version
378
+ ? `This section uses newer schema ${entry.version}.`
379
+ : 'This section uses an unsupported schema contract.';
380
+ }
381
+ else if (definition.count(data) !== entry.recordCount) {
382
+ throw new Error(`${entry.id} does not match its manifest record count.`);
383
+ }
384
+ sections.push({ ...entry, label: definition?.label || entry.id, status, reason, data: clone(data) });
385
+ totalRecords += entry.recordCount;
386
+ }
387
+ if (Object.keys(sectionValues).some((id) => !seen.has(id))) {
388
+ throw new Error('The workspace archive contains data that is not declared in its manifest.');
389
+ }
390
+ if (manifest.sectionCount !== sections.length || manifest.totalRecords !== totalRecords) {
391
+ throw new Error('The workspace archive manifest totals do not match its sections.');
392
+ }
393
+ sections.sort(canonicalSectionOrder);
394
+ return {
395
+ schema: WORKSPACE_ARCHIVE_SCHEMA,
396
+ version: WORKSPACE_ARCHIVE_VERSION,
397
+ generatedAt: timestamp(value.generatedAt),
398
+ bytes,
399
+ sections,
400
+ limitations: Array.isArray(value.limitations)
401
+ ? value.limitations.map((item) => boundedText(item, 400)).filter(Boolean).slice(0, 8)
402
+ : [],
403
+ };
404
+ }
405
+ function settingsPreview(data, local, mergedProfiles) {
406
+ const value = record(data) || {};
407
+ const requestedProfileId = typeof value.activeProfileId === 'string' ? value.activeProfileId : '';
408
+ const activeProfileId = SAFE_ID_RE.test(requestedProfileId) ? requestedProfileId : '';
409
+ const theme = normalizeTheme(value.theme);
410
+ let skipped = 0;
411
+ if (activeProfileId && !mergedProfiles.some((profile) => profile?.id === activeProfileId))
412
+ skipped++;
413
+ const updated = Number(theme !== normalizeTheme(local.settings?.theme)
414
+ || (Boolean(activeProfileId) && activeProfileId !== local.settings?.activeProfileId && skipped === 0));
415
+ return { added: 0, updated, skipped, settings: { activeProfileId: skipped ? '' : activeProfileId, theme } };
416
+ }
417
+ /** Preview section-specific non-destructive merge outcomes without writing. */
418
+ export async function previewWorkspaceArchive(raw, localInput, options = {}) {
419
+ const archive = await readWorkspaceArchive(raw, options);
420
+ const local = normalizedInput(localInput);
421
+ const results = [];
422
+ let mergedProfiles = local.brandProfiles;
423
+ for (const section of archive.sections) {
424
+ if (section.status !== 'ready') {
425
+ results.push({ ...section, added: 0, updated: 0, skipped: section.recordCount, selected: false });
426
+ continue;
427
+ }
428
+ const definition = DEFINITION_BY_ID.get(section.id);
429
+ if (!definition)
430
+ continue;
431
+ try {
432
+ const result = definition.id === 'settings'
433
+ ? settingsPreview(section.data, local, mergedProfiles)
434
+ : definition.merge
435
+ ? definition.merge(local, section.data, archive.generatedAt)
436
+ : { added: 0, updated: 0, skipped: section.recordCount };
437
+ if (definition.id === 'brandProfiles' && result.profiles)
438
+ mergedProfiles = result.profiles;
439
+ results.push({
440
+ ...section,
441
+ status: 'ready',
442
+ reason: '',
443
+ added: result.added ?? 0,
444
+ updated: result.updated ?? 0,
445
+ skipped: result.skipped ?? 0,
446
+ pruned: result.pruned ?? 0,
447
+ selected: true,
448
+ normalizedSettings: result.settings || null,
449
+ });
450
+ }
451
+ catch (cause) {
452
+ const reason = cause instanceof Error ? cause.message : 'This section could not be previewed.';
453
+ const status = /newer schema|expected .* export|not a .* export/i.test(reason) ? 'unsupported' : 'blocked';
454
+ results.push({
455
+ ...section,
456
+ status,
457
+ reason,
458
+ added: 0,
459
+ updated: 0,
460
+ skipped: section.recordCount,
461
+ pruned: 0,
462
+ selected: false,
463
+ });
464
+ }
465
+ }
466
+ return {
467
+ ...archive,
468
+ sections: results,
469
+ readyCount: results.filter((section) => section.status === 'ready').length,
470
+ unsupportedCount: results.filter((section) => section.status !== 'ready').length,
471
+ };
472
+ }
package/lib/abort.mjs ADDED
@@ -0,0 +1,26 @@
1
+ function abortReason(signal) {
2
+ return signal.reason || new DOMException('Aborted', 'AbortError');
3
+ }
4
+ function abortable(operation, signal) {
5
+ if (!signal)
6
+ return Promise.resolve().then(operation);
7
+ if (signal.aborted)
8
+ return Promise.reject(abortReason(signal));
9
+ return new Promise((resolve, reject) => {
10
+ const aborted = () => reject(abortReason(signal));
11
+ signal.addEventListener('abort', aborted, { once: true });
12
+ Promise.resolve()
13
+ .then(operation)
14
+ .then(resolve, reject)
15
+ .finally(() => signal.removeEventListener('abort', aborted));
16
+ });
17
+ }
18
+ function withTimeout(operation, timeoutMs, message = 'Operation timed out.') {
19
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1) {
20
+ return Promise.reject(new TypeError('Timeout must be a positive integer.'));
21
+ }
22
+ const controller = new AbortController();
23
+ const timer = setTimeout(() => controller.abort(new Error(message)), timeoutMs);
24
+ return abortable(operation, controller.signal).finally(() => clearTimeout(timer));
25
+ }
26
+ export { abortable, withTimeout };
@@ -0,0 +1,19 @@
1
+ // Shared validation for abuse.ch Auth-Keys. The credential is accepted from a
2
+ // provider-neutral variable first while retaining the original URLhaus name
3
+ // for compatible deployments. Callers must send it only to a fixed abuse.ch
4
+ // HTTPS endpoint and must never include it in returned configuration data.
5
+ const MAX_ABUSECH_AUTH_KEY_LENGTH = 256;
6
+ function normalizeAbusechAuthKey(value) {
7
+ if (typeof value !== 'string'
8
+ || value.length < 8
9
+ || value.length > MAX_ABUSECH_AUTH_KEY_LENGTH
10
+ || /[\u0000-\u0020\u007f]/u.test(value))
11
+ return null;
12
+ return value;
13
+ }
14
+ function abusechAuthKey(env = process.env) {
15
+ const source = env && typeof env === 'object' ? env : {};
16
+ return normalizeAbusechAuthKey(source.ABUSECH_AUTH_KEY)
17
+ || normalizeAbusechAuthKey(source.URLHAUS_AUTH_KEY);
18
+ }
19
+ export { MAX_ABUSECH_AUTH_KEY_LENGTH, normalizeAbusechAuthKey, abusechAuthKey, };