@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,349 @@
1
+ // Optional, lookup-only URLscan Search API adapter. The adapter searches
2
+ // existing public scan history and never submits a target for scanning. It is
3
+ // disabled unless the deployment explicitly enables it and supplies an API
4
+ // key; credentials are sent only in a header to the fixed HTTPS endpoint.
5
+ import { classifyQuery } from "./classify.mjs";
6
+ import { safeFetchDetailed, readTextCapped } from "./safe-fetch.mjs";
7
+ import { createThreatIntelligenceResult, defineThreatIntelligenceProvider, normalizeThreatIntelligenceTarget, } from "./threat-intelligence-contract.mjs";
8
+ const URLSCAN_SEARCH_ENDPOINT = 'https://urlscan.io/api/v1/search/';
9
+ const URLSCAN_SEARCH_DAYS = 90;
10
+ const URLSCAN_MAX_RESULTS = 20;
11
+ const URLSCAN_MAX_RESPONSE_BYTES = 256 * 1024;
12
+ const URLSCAN_TIMEOUT_MS = 6_000;
13
+ const URLSCAN_DAILY_REQUESTS = 1_000;
14
+ const URLSCAN_MONTHLY_REQUESTS = 30_000;
15
+ const MAX_API_KEY_LENGTH = 256;
16
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu;
17
+ const URLSCAN_PROVIDER = defineThreatIntelligenceProvider({
18
+ id: 'urlscan_search',
19
+ label: 'URLscan archived verdicts',
20
+ capabilities: ['domain_lookup', 'indicator_search'],
21
+ targets: { domain: 'registrable_domain' },
22
+ interaction: 'lookup_only',
23
+ terms: {
24
+ reviewedAt: '2026-07-15T00:00:00.000Z',
25
+ termsUrl: 'https://urlscan.io/terms/',
26
+ privacyUrl: 'https://urlscan.io/privacy/',
27
+ commercialUse: 'restricted',
28
+ attribution: 'unknown',
29
+ caching: 'prohibited',
30
+ queryRetention: 'provider_defined',
31
+ redistribution: 'restricted',
32
+ },
33
+ limits: {
34
+ timeoutMs: URLSCAN_TIMEOUT_MS,
35
+ maxResponseBytes: URLSCAN_MAX_RESPONSE_BYTES,
36
+ cacheTtlMs: 0,
37
+ concurrency: 1,
38
+ dailyRequests: URLSCAN_DAILY_REQUESTS,
39
+ monthlyRequests: URLSCAN_MONTHLY_REQUESTS,
40
+ },
41
+ });
42
+ function isRecord(value) {
43
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
44
+ }
45
+ function enabledValue(value) {
46
+ return ['1', 'true', 'yes', 'on'].includes(String(value ?? '').trim().toLowerCase());
47
+ }
48
+ function normalizedApiKey(value) {
49
+ if (typeof value !== 'string'
50
+ || value.length < 8
51
+ || value.length > MAX_API_KEY_LENGTH
52
+ || /[\u0000-\u0020\u007f]/u.test(value))
53
+ return null;
54
+ return value;
55
+ }
56
+ function urlscanConfiguration(env = process.env) {
57
+ const source = env && typeof env === 'object' ? env : {};
58
+ const enabled = enabledValue(source.WHOISLEUTH_ENABLE_URLSCAN);
59
+ const apiKey = normalizedApiKey(source.URLSCAN_API_KEY);
60
+ return {
61
+ enabled,
62
+ configured: enabled && apiKey !== null,
63
+ apiKey: enabled ? apiKey : null,
64
+ reason: !enabled
65
+ ? 'Archived URLscan verdict search is not enabled for this deployment.'
66
+ : apiKey
67
+ ? null
68
+ : 'Archived URLscan verdict search is enabled but its API credential is unavailable or malformed.',
69
+ };
70
+ }
71
+ function boundedText(value, maxLength) {
72
+ if (typeof value !== 'string' || /[\u0000-\u001f\u007f]/u.test(value))
73
+ return null;
74
+ const normalized = value.trim().replace(/\s+/gu, ' ');
75
+ return normalized ? normalized.slice(0, maxLength) : null;
76
+ }
77
+ function isoTimestamp(value) {
78
+ if (typeof value !== 'string' || value.length > 64 || /[\u0000-\u001f\u007f]/u.test(value))
79
+ return null;
80
+ const timestamp = Date.parse(value);
81
+ return Number.isFinite(timestamp) ? new Date(timestamp).toISOString() : null;
82
+ }
83
+ function retryAfterSeconds(response) {
84
+ for (const name of ['retry-after', 'x-rate-limit-reset-after']) {
85
+ const value = response.headers.get(name);
86
+ if (!value || !/^\d{1,6}$/u.test(value.trim()))
87
+ continue;
88
+ const seconds = Number(value);
89
+ if (Number.isSafeInteger(seconds) && seconds >= 1 && seconds <= 86_400)
90
+ return seconds;
91
+ }
92
+ return null;
93
+ }
94
+ function targetDomainFromUrl(value) {
95
+ const raw = boundedText(value, 2_048);
96
+ if (!raw)
97
+ return null;
98
+ try {
99
+ const parsed = new URL(raw);
100
+ if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password)
101
+ return null;
102
+ const classified = classifyQuery(parsed.hostname);
103
+ return classified.type === 'domain' ? classified.registrableDomain : null;
104
+ }
105
+ catch {
106
+ return null;
107
+ }
108
+ }
109
+ function normalizedCategories(value) {
110
+ if (!Array.isArray(value))
111
+ return [];
112
+ return [...new Set(value
113
+ .slice(0, 40)
114
+ .map((item) => boundedText(item, 64)?.toLowerCase() || null)
115
+ .filter((item) => item !== null))]
116
+ .sort()
117
+ .slice(0, 20);
118
+ }
119
+ function findingCategory(tags) {
120
+ if (tags.some((tag) => tag.includes('phish')))
121
+ return 'phishing';
122
+ if (tags.some((tag) => tag.includes('malware')))
123
+ return 'malware';
124
+ if (tags.some((tag) => tag.includes('spam')))
125
+ return 'spam';
126
+ if (tags.some((tag) => tag.includes('abuse')))
127
+ return 'abuse';
128
+ return 'suspicious';
129
+ }
130
+ function normalizeSearchFinding(value, targetDomain) {
131
+ if (!isRecord(value))
132
+ return null;
133
+ const task = isRecord(value.task) ? value.task : {};
134
+ const page = isRecord(value.page) ? value.page : {};
135
+ const verdicts = isRecord(value.verdicts) ? value.verdicts : {};
136
+ const id = boundedText(value._id, 80) || boundedText(task.uuid, 80);
137
+ if (!id || !UUID_RE.test(id) || targetDomainFromUrl(task.url) !== targetDomain)
138
+ return null;
139
+ // URLscan restricts verdict-filtered Search API queries to paid plans. The
140
+ // free-compatible query therefore retrieves recent domain scans and checks
141
+ // their returned verdict locally. False or omitted verdicts are valid
142
+ // neutral records, not malformed provider data.
143
+ if (verdicts.malicious !== true)
144
+ return undefined;
145
+ const categories = normalizedCategories(verdicts.categories);
146
+ const observedAt = isoTimestamp(task.time) || isoTimestamp(value.date);
147
+ const title = boundedText(page.title, 180);
148
+ return {
149
+ id,
150
+ category: findingCategory(categories),
151
+ // The search verdict is binary and does not publish a portable severity
152
+ // scale, so keep severity unknown instead of inventing one locally.
153
+ severity: 'unknown',
154
+ confidence: 'medium',
155
+ providerVerdict: 'malicious verdict match',
156
+ detail: title ? `Archived scan page title: ${title}` : 'An archived scan matched the provider malicious-verdict query.',
157
+ firstObservedAt: observedAt,
158
+ lastObservedAt: observedAt,
159
+ referenceUrl: `https://urlscan.io/result/${id}/`,
160
+ tags: categories,
161
+ };
162
+ }
163
+ function searchUrl(targetDomain) {
164
+ const url = new URL(URLSCAN_SEARCH_ENDPOINT);
165
+ url.searchParams.set('q', `task.apexDomain:${targetDomain} AND date:>now-${URLSCAN_SEARCH_DAYS}d`);
166
+ url.searchParams.set('size', String(URLSCAN_MAX_RESULTS));
167
+ return url.toString();
168
+ }
169
+ function result(targetDomain, input, observedAt) {
170
+ return createThreatIntelligenceResult(URLSCAN_PROVIDER, { type: 'domain', value: targetDomain }, input, observedAt);
171
+ }
172
+ function createUrlscanIntelligenceAdapter(dependencies = {}) {
173
+ const fetchDetailed = dependencies.fetchDetailed || safeFetchDetailed;
174
+ const readResponse = dependencies.readResponse || readTextCapped;
175
+ const now = dependencies.now || Date.now;
176
+ let activeRequests = 0;
177
+ let dayBucket = '';
178
+ let monthBucket = '';
179
+ let dayRequests = 0;
180
+ let monthRequests = 0;
181
+ function consumeLocalBudget(timestamp) {
182
+ const date = new Date(timestamp);
183
+ const nextDay = date.toISOString().slice(0, 10);
184
+ const nextMonth = nextDay.slice(0, 7);
185
+ if (nextDay !== dayBucket) {
186
+ dayBucket = nextDay;
187
+ dayRequests = 0;
188
+ }
189
+ if (nextMonth !== monthBucket) {
190
+ monthBucket = nextMonth;
191
+ monthRequests = 0;
192
+ }
193
+ if (dayRequests >= URLSCAN_DAILY_REQUESTS || monthRequests >= URLSCAN_MONTHLY_REQUESTS)
194
+ return false;
195
+ dayRequests += 1;
196
+ monthRequests += 1;
197
+ return true;
198
+ }
199
+ async function lookupDomain(domain, options = {}) {
200
+ const observedAt = new Date(now()).toISOString();
201
+ let targetDomain;
202
+ try {
203
+ targetDomain = normalizeThreatIntelligenceTarget({ type: 'domain', value: domain }, 'registrable_domain').value;
204
+ }
205
+ catch {
206
+ throw new TypeError('URLscan intelligence requires a valid registrable domain');
207
+ }
208
+ const configuration = urlscanConfiguration(options.env);
209
+ if (!configuration.configured || !configuration.apiKey) {
210
+ return result(targetDomain, {
211
+ state: configuration.enabled ? 'unavailable' : 'skipped',
212
+ detail: configuration.reason,
213
+ }, observedAt);
214
+ }
215
+ if (activeRequests >= URLSCAN_PROVIDER.limits.concurrency) {
216
+ return result(targetDomain, {
217
+ state: 'unavailable',
218
+ detail: 'The local URLscan request concurrency limit is already in use; try again shortly.',
219
+ }, observedAt);
220
+ }
221
+ if (!consumeLocalBudget(now())) {
222
+ return result(targetDomain, {
223
+ state: 'rate_limited',
224
+ detail: 'The local URLscan request budget has been reached for this runtime instance.',
225
+ }, observedAt);
226
+ }
227
+ activeRequests += 1;
228
+ try {
229
+ const { response } = await fetchDetailed(searchUrl(targetDomain), {
230
+ headers: {
231
+ accept: 'application/json',
232
+ 'api-key': configuration.apiKey,
233
+ 'user-agent': 'WHOISleuth/1.0',
234
+ },
235
+ signal: AbortSignal.timeout(URLSCAN_PROVIDER.limits.timeoutMs),
236
+ }, { maxRedirects: 0 });
237
+ const upstreamStatus = response.status;
238
+ if (upstreamStatus === 429) {
239
+ await response.body?.cancel().catch(() => { });
240
+ return result(targetDomain, {
241
+ state: 'rate_limited',
242
+ detail: 'URLscan reported that its search quota is temporarily exhausted.',
243
+ upstreamStatus,
244
+ retryAfterSeconds: retryAfterSeconds(response),
245
+ }, observedAt);
246
+ }
247
+ if (upstreamStatus === 401 || upstreamStatus === 403) {
248
+ await response.body?.cancel().catch(() => { });
249
+ return result(targetDomain, {
250
+ state: 'unavailable',
251
+ detail: upstreamStatus === 401
252
+ ? 'URLscan did not accept the configured search credential.'
253
+ : 'URLscan denied the search; the credential or account plan may not permit this request.',
254
+ upstreamStatus,
255
+ }, observedAt);
256
+ }
257
+ if (upstreamStatus < 200 || upstreamStatus >= 300) {
258
+ await response.body?.cancel().catch(() => { });
259
+ return result(targetDomain, {
260
+ state: 'error',
261
+ detail: `URLscan search failed with HTTP ${upstreamStatus}.`,
262
+ upstreamStatus,
263
+ }, observedAt);
264
+ }
265
+ const captured = await readResponse(response, URLSCAN_PROVIDER.limits.maxResponseBytes);
266
+ if (captured.truncated) {
267
+ return result(targetDomain, {
268
+ state: 'error',
269
+ detail: 'URLscan search returned a response larger than the configured safety limit.',
270
+ upstreamStatus,
271
+ }, observedAt);
272
+ }
273
+ let parsed;
274
+ try {
275
+ parsed = JSON.parse(captured.text);
276
+ }
277
+ catch {
278
+ return result(targetDomain, {
279
+ state: 'error',
280
+ detail: 'URLscan search returned malformed JSON.',
281
+ upstreamStatus,
282
+ }, observedAt);
283
+ }
284
+ if (!isRecord(parsed) || !Array.isArray(parsed.results)) {
285
+ return result(targetDomain, {
286
+ state: 'error',
287
+ detail: 'URLscan search returned an unexpected response shape.',
288
+ upstreamStatus,
289
+ }, observedAt);
290
+ }
291
+ const rawResults = parsed.results.slice(0, URLSCAN_MAX_RESULTS);
292
+ const normalized = rawResults.map((item) => normalizeSearchFinding(item, targetDomain));
293
+ const findings = normalized
294
+ .filter((item) => Boolean(item));
295
+ const overLimit = parsed.results.length > URLSCAN_MAX_RESULTS || parsed.has_more === true;
296
+ const invalid = normalized.filter((item) => item === null).length;
297
+ const reviewed = rawResults.length - invalid;
298
+ if (!findings.length && parsed.results.length === 0) {
299
+ return result(targetDomain, {
300
+ state: 'not_found',
301
+ detail: `No archived malicious-verdict match was returned for the last ${URLSCAN_SEARCH_DAYS} days.`,
302
+ upstreamStatus,
303
+ }, observedAt);
304
+ }
305
+ return result(targetDomain, {
306
+ state: overLimit || invalid > 0
307
+ ? 'partial'
308
+ : findings.length
309
+ ? 'success'
310
+ : 'not_found',
311
+ truncated: overLimit,
312
+ detail: findings.length
313
+ ? overLimit
314
+ ? `Found ${findings.length} archived malicious-verdict match${findings.length === 1 ? '' : 'es'} among the newest ${reviewed} bounded scan${reviewed === 1 ? '' : 's'}; older scans may exist.`
315
+ : invalid > 0
316
+ ? `Found ${findings.length} archived malicious-verdict match${findings.length === 1 ? '' : 'es'}; ${invalid} provider record${invalid === 1 ? ' was' : 's were'} omitted because it could not be safely normalized.`
317
+ : `Found ${findings.length} archived malicious-verdict match${findings.length === 1 ? '' : 'es'} among ${reviewed} recent scan${reviewed === 1 ? '' : 's'}.`
318
+ : overLimit
319
+ ? `No malicious verdict was found among the newest ${reviewed} bounded scan${reviewed === 1 ? '' : 's'}; older scans may exist.`
320
+ : invalid > 0
321
+ ? `No malicious verdict was found among ${reviewed} normalized recent scan${reviewed === 1 ? '' : 's'}; ${invalid} provider record${invalid === 1 ? ' was' : 's were'} omitted.`
322
+ : `No malicious verdict was found among ${reviewed} recent scan${reviewed === 1 ? '' : 's'} in the last ${URLSCAN_SEARCH_DAYS} days.`,
323
+ upstreamStatus,
324
+ findings,
325
+ limitations: [
326
+ 'Search covers archived scans and provider verdicts only; it does not submit or rescan the domain.',
327
+ `Search reviews only the newest ${URLSCAN_MAX_RESULTS} returned scans from the last ${URLSCAN_SEARCH_DAYS} days and filters their verdicts locally; older malicious scans may be missed.`,
328
+ ],
329
+ }, observedAt);
330
+ }
331
+ catch (error) {
332
+ const timedOut = error instanceof Error
333
+ && (error.name === 'TimeoutError' || error.name === 'AbortError');
334
+ return result(targetDomain, {
335
+ state: 'error',
336
+ detail: timedOut
337
+ ? 'URLscan search exceeded its bounded request deadline.'
338
+ : 'URLscan search could not be completed.',
339
+ }, observedAt);
340
+ }
341
+ finally {
342
+ activeRequests -= 1;
343
+ }
344
+ }
345
+ return Object.freeze({ lookupDomain });
346
+ }
347
+ const defaultUrlscanAdapter = createUrlscanIntelligenceAdapter();
348
+ export { URLSCAN_PROVIDER, URLSCAN_SEARCH_ENDPOINT, URLSCAN_SEARCH_DAYS, URLSCAN_MAX_RESULTS, URLSCAN_MAX_RESPONSE_BYTES, URLSCAN_TIMEOUT_MS, urlscanConfiguration, createUrlscanIntelligenceAdapter, };
349
+ export const lookupUrlscanDomain = defaultUrlscanAdapter.lookupDomain;