@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,361 @@
1
+ // Optional, lookup-only malware-host adapter. It queries an existing public
2
+ // intelligence corpus and never submits a URL, hostname, or sample. The
3
+ // adapter is disabled unless the deployment explicitly enables it and supplies
4
+ // an Auth-Key; credentials are sent only in a header to the fixed HTTPS host
5
+ // lookup endpoint.
6
+ import { classifyQuery } from "./classify.mjs";
7
+ import { abusechAuthKey } from "./abusech-auth.mjs";
8
+ import { safeFetchDetailed, readTextCapped } from "./safe-fetch.mjs";
9
+ import { createThreatIntelligenceResult, defineThreatIntelligenceProvider, normalizeThreatIntelligenceTarget, } from "./threat-intelligence-contract.mjs";
10
+ const URLHAUS_HOST_ENDPOINT = 'https://urlhaus-api.abuse.ch/v1/host/';
11
+ const URLHAUS_MAX_RESULTS = 20;
12
+ const URLHAUS_MAX_INPUT_RESULTS = 100;
13
+ const URLHAUS_MAX_RESPONSE_BYTES = 256 * 1024;
14
+ const URLHAUS_TIMEOUT_MS = 6_000;
15
+ // These are conservative process-local guardrails below the provider's
16
+ // unspecified fair-use query-volume ceiling. Provider-side account limits
17
+ // remain authoritative, and serverless cold starts can reset local counters.
18
+ const URLHAUS_DAILY_REQUESTS = 200;
19
+ const URLHAUS_MONTHLY_REQUESTS = 3_000;
20
+ const MAX_URL_ID_LENGTH = 24;
21
+ const URLHAUS_PROVIDER = defineThreatIntelligenceProvider({
22
+ id: 'urlhaus_host',
23
+ label: 'URLhaus malware-host records',
24
+ capabilities: ['domain_lookup', 'indicator_search'],
25
+ targets: { domain: 'registrable_domain' },
26
+ interaction: 'lookup_only',
27
+ terms: {
28
+ reviewedAt: '2026-07-15T00:00:00.000Z',
29
+ termsUrl: 'https://abuse.ch/terms-of-use/',
30
+ privacyUrl: 'https://abuse.ch/privacy-policy/',
31
+ commercialUse: 'restricted',
32
+ attribution: 'unknown',
33
+ caching: 'provider_defined',
34
+ queryRetention: 'provider_defined',
35
+ redistribution: 'restricted',
36
+ },
37
+ limits: {
38
+ timeoutMs: URLHAUS_TIMEOUT_MS,
39
+ maxResponseBytes: URLHAUS_MAX_RESPONSE_BYTES,
40
+ cacheTtlMs: 0,
41
+ concurrency: 1,
42
+ dailyRequests: URLHAUS_DAILY_REQUESTS,
43
+ monthlyRequests: URLHAUS_MONTHLY_REQUESTS,
44
+ },
45
+ });
46
+ function isRecord(value) {
47
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
48
+ }
49
+ function enabledValue(value) {
50
+ return ['1', 'true', 'yes', 'on'].includes(String(value ?? '').trim().toLowerCase());
51
+ }
52
+ function urlhausConfiguration(env = process.env) {
53
+ const source = env && typeof env === 'object' ? env : {};
54
+ const enabled = enabledValue(source.WHOISLEUTH_ENABLE_URLHAUS);
55
+ const authKey = abusechAuthKey(source);
56
+ return {
57
+ enabled,
58
+ configured: enabled && authKey !== null,
59
+ authKey: enabled ? authKey : null,
60
+ reason: !enabled
61
+ ? 'Malware-host intelligence is not enabled for this deployment.'
62
+ : authKey
63
+ ? null
64
+ : 'Malware-host intelligence is enabled but its API credential is unavailable or malformed.',
65
+ };
66
+ }
67
+ function boundedText(value, maxLength) {
68
+ if (typeof value !== 'string' || /[\u0000-\u001f\u007f]/u.test(value))
69
+ return null;
70
+ const normalized = value.trim().replace(/\s+/gu, ' ');
71
+ return normalized ? normalized.slice(0, maxLength) : null;
72
+ }
73
+ function isoTimestamp(value) {
74
+ if (typeof value !== 'string' || value.length > 64 || /[\u0000-\u001f\u007f]/u.test(value))
75
+ return null;
76
+ const trimmed = value.trim();
77
+ const normalized = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?: UTC)?$/u.test(trimmed)
78
+ ? `${trimmed.slice(0, 10)}T${trimmed.slice(11, 19)}Z`
79
+ : trimmed;
80
+ const timestamp = Date.parse(normalized);
81
+ return Number.isFinite(timestamp) ? new Date(timestamp).toISOString() : null;
82
+ }
83
+ function retryAfterSeconds(response) {
84
+ const value = response.headers.get('retry-after');
85
+ if (!value || !/^\d{1,6}$/u.test(value.trim()))
86
+ return null;
87
+ const seconds = Number(value);
88
+ return Number.isSafeInteger(seconds) && seconds >= 1 && seconds <= 86_400 ? seconds : null;
89
+ }
90
+ function exactDomainFromUrl(value) {
91
+ const raw = boundedText(value, 2_048);
92
+ if (!raw)
93
+ return null;
94
+ try {
95
+ const parsed = new URL(raw);
96
+ if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password)
97
+ return null;
98
+ const classified = classifyQuery(parsed.hostname);
99
+ return classified.type === 'domain' ? classified.inputHostname : null;
100
+ }
101
+ catch {
102
+ return null;
103
+ }
104
+ }
105
+ function exactDomain(value) {
106
+ const raw = boundedText(value, 253);
107
+ if (!raw)
108
+ return null;
109
+ try {
110
+ const classified = classifyQuery(raw);
111
+ return classified.type === 'domain' ? classified.inputHostname : null;
112
+ }
113
+ catch {
114
+ return null;
115
+ }
116
+ }
117
+ function normalizedTags(value) {
118
+ if (!Array.isArray(value))
119
+ return [];
120
+ return [...new Set(value
121
+ .slice(0, 40)
122
+ .map((item) => boundedText(item, 64)?.toLowerCase() || null)
123
+ .filter((item) => item !== null))]
124
+ .sort()
125
+ .slice(0, 18);
126
+ }
127
+ function normalizedUrlCount(value) {
128
+ if (typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 && value <= 1_000_000)
129
+ return value;
130
+ if (typeof value !== 'string' || !/^\d{1,7}$/u.test(value.trim()))
131
+ return null;
132
+ const count = Number(value);
133
+ return Number.isSafeInteger(count) && count <= 1_000_000 ? count : null;
134
+ }
135
+ function normalizeHostFinding(value, targetDomain) {
136
+ if (!isRecord(value))
137
+ return null;
138
+ const id = boundedText(value.id, MAX_URL_ID_LENGTH);
139
+ const status = boundedText(value.url_status, 16)?.toLowerCase();
140
+ const threat = boundedText(value.threat, 64)?.toLowerCase();
141
+ if (!id
142
+ || !/^\d{1,24}$/u.test(id)
143
+ || !status
144
+ || !['online', 'offline', 'unknown'].includes(status)
145
+ || threat !== 'malware_download'
146
+ || exactDomainFromUrl(value.url) !== targetDomain)
147
+ return null;
148
+ const observedAt = isoTimestamp(value.date_added);
149
+ const tags = normalizedTags(value.tags);
150
+ tags.push(`url-status:${status}`);
151
+ return {
152
+ id,
153
+ category: 'malware',
154
+ // The provider classifies URL purpose and online state, but does not
155
+ // publish a portable severity or confidence scale for the host record.
156
+ severity: 'unknown',
157
+ confidence: 'medium',
158
+ providerVerdict: `malware distribution · ${status}`,
159
+ detail: `The provider labels an archived malware-distribution URL on this host as ${status}. A host may have been compromised and the record may be historical.`,
160
+ firstObservedAt: observedAt,
161
+ lastObservedAt: observedAt,
162
+ referenceUrl: `https://urlhaus.abuse.ch/url/${id}/`,
163
+ tags,
164
+ };
165
+ }
166
+ function result(targetDomain, input, observedAt) {
167
+ return createThreatIntelligenceResult(URLHAUS_PROVIDER, { type: 'domain', value: targetDomain }, input, observedAt);
168
+ }
169
+ function createUrlhausIntelligenceAdapter(dependencies = {}) {
170
+ const fetchDetailed = dependencies.fetchDetailed || safeFetchDetailed;
171
+ const readResponse = dependencies.readResponse || readTextCapped;
172
+ const now = dependencies.now || Date.now;
173
+ let activeRequests = 0;
174
+ let dayBucket = '';
175
+ let monthBucket = '';
176
+ let dayRequests = 0;
177
+ let monthRequests = 0;
178
+ function consumeLocalBudget(timestamp) {
179
+ const date = new Date(timestamp);
180
+ const nextDay = date.toISOString().slice(0, 10);
181
+ const nextMonth = nextDay.slice(0, 7);
182
+ if (nextDay !== dayBucket) {
183
+ dayBucket = nextDay;
184
+ dayRequests = 0;
185
+ }
186
+ if (nextMonth !== monthBucket) {
187
+ monthBucket = nextMonth;
188
+ monthRequests = 0;
189
+ }
190
+ if (dayRequests >= URLHAUS_PROVIDER.limits.dailyRequests
191
+ || monthRequests >= URLHAUS_PROVIDER.limits.monthlyRequests)
192
+ return false;
193
+ dayRequests += 1;
194
+ monthRequests += 1;
195
+ return true;
196
+ }
197
+ async function lookupDomain(domain, options = {}) {
198
+ const observedAt = new Date(now()).toISOString();
199
+ let targetDomain;
200
+ try {
201
+ targetDomain = normalizeThreatIntelligenceTarget({ type: 'domain', value: domain }, 'registrable_domain').value;
202
+ }
203
+ catch {
204
+ throw new TypeError('Malware-host intelligence requires a valid registrable domain');
205
+ }
206
+ const configuration = urlhausConfiguration(options.env);
207
+ if (!configuration.configured || !configuration.authKey) {
208
+ return result(targetDomain, {
209
+ state: configuration.enabled ? 'unavailable' : 'skipped',
210
+ detail: configuration.reason,
211
+ }, observedAt);
212
+ }
213
+ if (activeRequests >= URLHAUS_PROVIDER.limits.concurrency) {
214
+ return result(targetDomain, {
215
+ state: 'unavailable',
216
+ detail: 'The local malware-host request concurrency limit is already in use; try again shortly.',
217
+ }, observedAt);
218
+ }
219
+ if (!consumeLocalBudget(now())) {
220
+ return result(targetDomain, {
221
+ state: 'rate_limited',
222
+ detail: 'The local malware-host request budget has been reached for this runtime instance.',
223
+ }, observedAt);
224
+ }
225
+ activeRequests += 1;
226
+ try {
227
+ const body = new URLSearchParams({ host: targetDomain }).toString();
228
+ const { response } = await fetchDetailed(URLHAUS_HOST_ENDPOINT, {
229
+ method: 'POST',
230
+ headers: {
231
+ accept: 'application/json',
232
+ 'auth-key': configuration.authKey,
233
+ 'content-type': 'application/x-www-form-urlencoded;charset=UTF-8',
234
+ 'user-agent': 'WHOISleuth/1.0',
235
+ },
236
+ body,
237
+ signal: AbortSignal.timeout(URLHAUS_PROVIDER.limits.timeoutMs),
238
+ }, { maxRedirects: 0 });
239
+ const upstreamStatus = response.status;
240
+ if (upstreamStatus === 429) {
241
+ await response.body?.cancel().catch(() => { });
242
+ return result(targetDomain, {
243
+ state: 'rate_limited',
244
+ detail: 'The malware-host provider reported that its query quota is temporarily exhausted.',
245
+ upstreamStatus,
246
+ retryAfterSeconds: retryAfterSeconds(response),
247
+ }, observedAt);
248
+ }
249
+ if (upstreamStatus === 401 || upstreamStatus === 403) {
250
+ await response.body?.cancel().catch(() => { });
251
+ return result(targetDomain, {
252
+ state: 'unavailable',
253
+ detail: 'The malware-host provider did not accept the configured API credential.',
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: `Malware-host lookup failed with HTTP ${upstreamStatus}.`,
262
+ upstreamStatus,
263
+ }, observedAt);
264
+ }
265
+ const captured = await readResponse(response, URLHAUS_PROVIDER.limits.maxResponseBytes);
266
+ if (captured.truncated) {
267
+ return result(targetDomain, {
268
+ state: 'error',
269
+ detail: 'Malware-host lookup 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: 'Malware-host lookup returned malformed JSON.',
281
+ upstreamStatus,
282
+ }, observedAt);
283
+ }
284
+ if (!isRecord(parsed) || typeof parsed.query_status !== 'string') {
285
+ return result(targetDomain, {
286
+ state: 'error',
287
+ detail: 'Malware-host lookup returned an unexpected response shape.',
288
+ upstreamStatus,
289
+ }, observedAt);
290
+ }
291
+ const queryStatus = parsed.query_status.trim().toLowerCase();
292
+ if (queryStatus === 'no_results') {
293
+ return result(targetDomain, {
294
+ state: 'not_found',
295
+ detail: 'The provider returned no malware-distribution record for this registrable domain.',
296
+ upstreamStatus,
297
+ }, observedAt);
298
+ }
299
+ if (['no_api_key', 'auth_key_invalid', 'user_blacklisted'].includes(queryStatus)) {
300
+ return result(targetDomain, {
301
+ state: 'unavailable',
302
+ detail: 'The malware-host provider did not accept the configured API credential.',
303
+ upstreamStatus,
304
+ }, observedAt);
305
+ }
306
+ if (queryStatus !== 'ok'
307
+ || exactDomain(parsed.host) !== targetDomain
308
+ || !Array.isArray(parsed.urls)) {
309
+ return result(targetDomain, {
310
+ state: 'error',
311
+ detail: 'Malware-host lookup returned an invalid or mismatched host response.',
312
+ upstreamStatus,
313
+ }, observedAt);
314
+ }
315
+ const inputRecords = parsed.urls.slice(0, URLHAUS_MAX_INPUT_RESULTS);
316
+ const normalized = inputRecords
317
+ .map((item) => normalizeHostFinding(item, targetDomain))
318
+ .filter((item) => item !== null);
319
+ const findings = normalized.slice(0, URLHAUS_MAX_RESULTS);
320
+ const discarded = inputRecords.length - normalized.length;
321
+ const urlCount = normalizedUrlCount(parsed.url_count);
322
+ const truncated = parsed.urls.length > URLHAUS_MAX_INPUT_RESULTS
323
+ || normalized.length > URLHAUS_MAX_RESULTS
324
+ || (urlCount !== null && urlCount > parsed.urls.length);
325
+ const partial = truncated || discarded > 0 || findings.length === 0;
326
+ return result(targetDomain, {
327
+ state: partial ? 'partial' : 'success',
328
+ truncated,
329
+ detail: findings.length
330
+ ? `Found ${findings.length} bounded malware-distribution record${findings.length === 1 ? '' : 's'} for this host${truncated ? '; additional provider records may exist' : ''}.`
331
+ : 'The provider reported this host but no returned record could be safely normalized.',
332
+ upstreamStatus,
333
+ findings,
334
+ limitations: [
335
+ 'The provider tracks malware-distribution URLs, not phishing pages generally.',
336
+ 'A listed host may have been compromised, cleaned, or reassigned; record status and timestamps require analyst review.',
337
+ ...(discarded > 0
338
+ ? [`${discarded} provider record${discarded === 1 ? ' was' : 's were'} omitted because it could not be safely normalized.`]
339
+ : []),
340
+ ],
341
+ }, observedAt);
342
+ }
343
+ catch (error) {
344
+ const timedOut = error instanceof Error
345
+ && (error.name === 'TimeoutError' || error.name === 'AbortError');
346
+ return result(targetDomain, {
347
+ state: 'error',
348
+ detail: timedOut
349
+ ? 'Malware-host lookup exceeded its bounded request deadline.'
350
+ : 'Malware-host lookup could not be completed.',
351
+ }, observedAt);
352
+ }
353
+ finally {
354
+ activeRequests -= 1;
355
+ }
356
+ }
357
+ return Object.freeze({ lookupDomain });
358
+ }
359
+ const defaultUrlhausAdapter = createUrlhausIntelligenceAdapter();
360
+ export { URLHAUS_PROVIDER, URLHAUS_HOST_ENDPOINT, URLHAUS_MAX_RESULTS, URLHAUS_MAX_RESPONSE_BYTES, URLHAUS_TIMEOUT_MS, urlhausConfiguration, createUrlhausIntelligenceAdapter, };
361
+ export const lookupUrlhausDomain = defaultUrlhausAdapter.lookupDomain;