@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,168 @@
|
|
|
1
|
+
// Canonical, exposure-aware target normalization shared by provider results
|
|
2
|
+
// and curated connectors. These helpers never perform network requests.
|
|
3
|
+
import { classifyQuery } from "./classify.mjs";
|
|
4
|
+
import { enumValue, exactKeys, strictBoundedString, } from "./bounded-contract-normalizers.mjs";
|
|
5
|
+
export const MAX_THREAT_INTELLIGENCE_URL_LENGTH = 2048;
|
|
6
|
+
const TARGET_EXPOSURES = Object.freeze({
|
|
7
|
+
domain: new Set(['registrable_domain']),
|
|
8
|
+
url: new Set([
|
|
9
|
+
'registrable_domain',
|
|
10
|
+
'hostname',
|
|
11
|
+
'origin',
|
|
12
|
+
'full_url',
|
|
13
|
+
]),
|
|
14
|
+
});
|
|
15
|
+
export const CURATED_CONNECTOR_ENTITY_TYPES = new Set([
|
|
16
|
+
'domain',
|
|
17
|
+
'hostname',
|
|
18
|
+
'url',
|
|
19
|
+
'ipv4',
|
|
20
|
+
'ipv6',
|
|
21
|
+
'asn',
|
|
22
|
+
'certificate',
|
|
23
|
+
]);
|
|
24
|
+
const CONNECTOR_TARGET_EXPOSURES = Object.freeze({
|
|
25
|
+
domain: new Set(['registrable_domain']),
|
|
26
|
+
hostname: new Set(['hostname']),
|
|
27
|
+
url: new Set([
|
|
28
|
+
'registrable_domain',
|
|
29
|
+
'hostname',
|
|
30
|
+
'origin',
|
|
31
|
+
'full_url',
|
|
32
|
+
]),
|
|
33
|
+
ipv4: new Set(['ip_address']),
|
|
34
|
+
ipv6: new Set(['ip_address']),
|
|
35
|
+
asn: new Set(['asn']),
|
|
36
|
+
certificate: new Set([
|
|
37
|
+
'certificate_fingerprint',
|
|
38
|
+
]),
|
|
39
|
+
});
|
|
40
|
+
function normalizeCertificateFingerprint(value) {
|
|
41
|
+
const raw = strictBoundedString(value, 128);
|
|
42
|
+
if (!raw || !/^[0-9a-f:]+$/iu.test(raw))
|
|
43
|
+
return null;
|
|
44
|
+
const canonical = raw.replace(/:/gu, '').toLowerCase();
|
|
45
|
+
return /^[0-9a-f]{64}$/u.test(canonical) ? canonical : null;
|
|
46
|
+
}
|
|
47
|
+
export function normalizeThreatIntelligenceTarget(input, exposure) {
|
|
48
|
+
exactKeys(input, new Set(['type', 'value']), 'Threat-intelligence target');
|
|
49
|
+
const type = input.type;
|
|
50
|
+
if ((type !== 'domain' && type !== 'url')
|
|
51
|
+
|| typeof exposure !== 'string'
|
|
52
|
+
|| !TARGET_EXPOSURES[type].has(exposure)) {
|
|
53
|
+
throw new TypeError('Threat-intelligence target exposure is invalid');
|
|
54
|
+
}
|
|
55
|
+
if (type === 'domain') {
|
|
56
|
+
const classified = classifyQuery(String(input.value ?? ''));
|
|
57
|
+
if (classified.type !== 'domain') {
|
|
58
|
+
throw new TypeError('Threat-intelligence domain target is invalid');
|
|
59
|
+
}
|
|
60
|
+
return Object.freeze({
|
|
61
|
+
type: 'domain',
|
|
62
|
+
value: classified.registrableDomain,
|
|
63
|
+
exposure: exposure,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
const raw = strictBoundedString(input.value, MAX_THREAT_INTELLIGENCE_URL_LENGTH);
|
|
67
|
+
let parsed;
|
|
68
|
+
try {
|
|
69
|
+
parsed = raw ? new URL(raw) : null;
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
parsed = null;
|
|
73
|
+
}
|
|
74
|
+
if (!parsed
|
|
75
|
+
|| !['http:', 'https:'].includes(parsed.protocol)
|
|
76
|
+
|| parsed.username
|
|
77
|
+
|| parsed.password) {
|
|
78
|
+
throw new TypeError('Threat-intelligence URL target is invalid');
|
|
79
|
+
}
|
|
80
|
+
const classified = classifyQuery(parsed.hostname);
|
|
81
|
+
if (classified.type !== 'domain') {
|
|
82
|
+
throw new TypeError('Threat-intelligence URL target must use a registrable domain');
|
|
83
|
+
}
|
|
84
|
+
const registrableDomain = classified.registrableDomain || classified.value;
|
|
85
|
+
const inputHostname = classified.inputHostname || parsed.hostname.toLowerCase();
|
|
86
|
+
parsed.hash = '';
|
|
87
|
+
let value = parsed.toString();
|
|
88
|
+
if (exposure === 'registrable_domain')
|
|
89
|
+
value = registrableDomain;
|
|
90
|
+
else if (exposure === 'hostname')
|
|
91
|
+
value = inputHostname;
|
|
92
|
+
else if (exposure === 'origin')
|
|
93
|
+
value = parsed.origin;
|
|
94
|
+
if (value.length > MAX_THREAT_INTELLIGENCE_URL_LENGTH) {
|
|
95
|
+
throw new TypeError('Threat-intelligence URL target exceeds the canonical length limit');
|
|
96
|
+
}
|
|
97
|
+
return Object.freeze({
|
|
98
|
+
type: 'url',
|
|
99
|
+
value,
|
|
100
|
+
exposure: exposure,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
export function normalizeCuratedConnectorTarget(input, exposure) {
|
|
104
|
+
exactKeys(input, new Set(['type', 'value']), 'Connector target');
|
|
105
|
+
const type = enumValue(input.type, CURATED_CONNECTOR_ENTITY_TYPES, 'Connector target type');
|
|
106
|
+
const normalizedExposure = enumValue(exposure, CONNECTOR_TARGET_EXPOSURES[type], 'Connector target exposure');
|
|
107
|
+
if (type === 'url') {
|
|
108
|
+
const normalized = normalizeThreatIntelligenceTarget({ type: 'url', value: input.value }, normalizedExposure);
|
|
109
|
+
return Object.freeze({
|
|
110
|
+
type,
|
|
111
|
+
value: normalized.value,
|
|
112
|
+
exposure: normalizedExposure,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
if (type === 'certificate') {
|
|
116
|
+
const value = normalizeCertificateFingerprint(input.value);
|
|
117
|
+
if (!value) {
|
|
118
|
+
throw new TypeError('Connector certificate target is invalid');
|
|
119
|
+
}
|
|
120
|
+
return Object.freeze({
|
|
121
|
+
type,
|
|
122
|
+
value,
|
|
123
|
+
exposure: normalizedExposure,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
const raw = strictBoundedString(input.value, MAX_THREAT_INTELLIGENCE_URL_LENGTH);
|
|
127
|
+
if (!raw)
|
|
128
|
+
throw new TypeError('Connector target value is invalid');
|
|
129
|
+
let classified;
|
|
130
|
+
try {
|
|
131
|
+
classified = classifyQuery(raw);
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
classified = null;
|
|
135
|
+
}
|
|
136
|
+
if (!classified)
|
|
137
|
+
throw new TypeError('Connector target value is invalid');
|
|
138
|
+
if (type === 'domain' && classified.type === 'domain') {
|
|
139
|
+
return Object.freeze({
|
|
140
|
+
type,
|
|
141
|
+
value: classified.registrableDomain,
|
|
142
|
+
exposure: normalizedExposure,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
if (type === 'hostname' && classified.type === 'domain') {
|
|
146
|
+
return Object.freeze({
|
|
147
|
+
type,
|
|
148
|
+
value: classified.inputHostname,
|
|
149
|
+
exposure: normalizedExposure,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
if ((type === 'ipv4' || type === 'ipv6')
|
|
153
|
+
&& classified.type === type) {
|
|
154
|
+
return Object.freeze({
|
|
155
|
+
type,
|
|
156
|
+
value: classified.value,
|
|
157
|
+
exposure: normalizedExposure,
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
if (type === 'asn' && classified.type === 'asn') {
|
|
161
|
+
return Object.freeze({
|
|
162
|
+
type,
|
|
163
|
+
value: classified.value,
|
|
164
|
+
exposure: normalizedExposure,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
throw new TypeError('Connector target type and value are incompatible');
|
|
168
|
+
}
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
// Optional, lookup-only malware-IOC adapter. It searches an existing public
|
|
2
|
+
// corpus for an exact registrable domain and never submits an IOC, URL, or
|
|
3
|
+
// sample. The adapter is disabled unless explicitly enabled and credentialed.
|
|
4
|
+
import { abusechAuthKey } from "./abusech-auth.mjs";
|
|
5
|
+
import { safeFetchDetailed, readTextCapped } from "./safe-fetch.mjs";
|
|
6
|
+
import { createThreatIntelligenceResult, defineThreatIntelligenceProvider, normalizeThreatIntelligenceTarget, } from "./threat-intelligence-contract.mjs";
|
|
7
|
+
const THREATFOX_SEARCH_ENDPOINT = 'https://threatfox-api.abuse.ch/api/v1/';
|
|
8
|
+
const THREATFOX_MAX_RESULTS = 20;
|
|
9
|
+
const THREATFOX_MAX_INPUT_RESULTS = 100;
|
|
10
|
+
const THREATFOX_MAX_RESPONSE_BYTES = 256 * 1024;
|
|
11
|
+
const THREATFOX_TIMEOUT_MS = 6_000;
|
|
12
|
+
const THREATFOX_DAILY_REQUESTS = 200;
|
|
13
|
+
const THREATFOX_MONTHLY_REQUESTS = 3_000;
|
|
14
|
+
const THREATFOX_PROVIDER = defineThreatIntelligenceProvider({
|
|
15
|
+
id: 'threatfox_domain_ioc',
|
|
16
|
+
label: 'ThreatFox malware IOCs',
|
|
17
|
+
capabilities: ['domain_lookup', 'indicator_search'],
|
|
18
|
+
targets: { domain: 'registrable_domain' },
|
|
19
|
+
interaction: 'lookup_only',
|
|
20
|
+
terms: {
|
|
21
|
+
reviewedAt: '2026-07-15T00:00:00.000Z',
|
|
22
|
+
termsUrl: 'https://abuse.ch/terms-of-use/',
|
|
23
|
+
privacyUrl: 'https://abuse.ch/privacy-policy/',
|
|
24
|
+
commercialUse: 'restricted',
|
|
25
|
+
attribution: 'unknown',
|
|
26
|
+
caching: 'provider_defined',
|
|
27
|
+
queryRetention: 'provider_defined',
|
|
28
|
+
redistribution: 'restricted',
|
|
29
|
+
},
|
|
30
|
+
limits: {
|
|
31
|
+
timeoutMs: THREATFOX_TIMEOUT_MS,
|
|
32
|
+
maxResponseBytes: THREATFOX_MAX_RESPONSE_BYTES,
|
|
33
|
+
cacheTtlMs: 0,
|
|
34
|
+
concurrency: 1,
|
|
35
|
+
dailyRequests: THREATFOX_DAILY_REQUESTS,
|
|
36
|
+
monthlyRequests: THREATFOX_MONTHLY_REQUESTS,
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
function isRecord(value) {
|
|
40
|
+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
41
|
+
}
|
|
42
|
+
function enabledValue(value) {
|
|
43
|
+
return ['1', 'true', 'yes', 'on'].includes(String(value ?? '').trim().toLowerCase());
|
|
44
|
+
}
|
|
45
|
+
function threatfoxConfiguration(env = process.env) {
|
|
46
|
+
const source = env && typeof env === 'object' ? env : {};
|
|
47
|
+
const enabled = enabledValue(source.WHOISLEUTH_ENABLE_THREATFOX);
|
|
48
|
+
const authKey = abusechAuthKey(source);
|
|
49
|
+
return {
|
|
50
|
+
enabled,
|
|
51
|
+
configured: enabled && authKey !== null,
|
|
52
|
+
authKey: enabled ? authKey : null,
|
|
53
|
+
reason: !enabled
|
|
54
|
+
? 'Malware-IOC intelligence is not enabled for this deployment.'
|
|
55
|
+
: authKey
|
|
56
|
+
? null
|
|
57
|
+
: 'Malware-IOC intelligence is enabled but its API credential is unavailable or malformed.',
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
function boundedText(value, maxLength) {
|
|
61
|
+
if (typeof value !== 'string' || value.length > maxLength || /[\u0000-\u001f\u007f]/u.test(value))
|
|
62
|
+
return null;
|
|
63
|
+
const normalized = value.trim().replace(/\s+/gu, ' ');
|
|
64
|
+
return normalized || null;
|
|
65
|
+
}
|
|
66
|
+
function isoTimestamp(value) {
|
|
67
|
+
const raw = boundedText(value, 64);
|
|
68
|
+
if (!raw)
|
|
69
|
+
return null;
|
|
70
|
+
const normalized = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?: UTC)?$/u.test(raw)
|
|
71
|
+
? `${raw.slice(0, 10)}T${raw.slice(11, 19)}Z`
|
|
72
|
+
: raw;
|
|
73
|
+
const timestamp = Date.parse(normalized);
|
|
74
|
+
return Number.isFinite(timestamp) ? new Date(timestamp).toISOString() : null;
|
|
75
|
+
}
|
|
76
|
+
function normalizedTags(value) {
|
|
77
|
+
if (!Array.isArray(value))
|
|
78
|
+
return [];
|
|
79
|
+
return [...new Set(value.slice(0, 40)
|
|
80
|
+
.map((item) => boundedText(item, 64)?.toLowerCase() || null)
|
|
81
|
+
.filter((item) => item !== null))]
|
|
82
|
+
.sort()
|
|
83
|
+
.slice(0, 18);
|
|
84
|
+
}
|
|
85
|
+
function normalizedConfidence(value) {
|
|
86
|
+
const score = typeof value === 'number' ? value : Number.NaN;
|
|
87
|
+
if (!Number.isFinite(score) || score < 0 || score > 100)
|
|
88
|
+
return 'unknown';
|
|
89
|
+
return score >= 80 ? 'high' : score >= 50 ? 'medium' : 'low';
|
|
90
|
+
}
|
|
91
|
+
function retryAfterSeconds(response) {
|
|
92
|
+
const value = response.headers.get('retry-after');
|
|
93
|
+
if (!value || !/^\d{1,6}$/u.test(value.trim()))
|
|
94
|
+
return null;
|
|
95
|
+
const seconds = Number(value);
|
|
96
|
+
return Number.isSafeInteger(seconds) && seconds >= 1 && seconds <= 86_400 ? seconds : null;
|
|
97
|
+
}
|
|
98
|
+
function normalizeIocFinding(value, targetDomain) {
|
|
99
|
+
if (!isRecord(value))
|
|
100
|
+
return null;
|
|
101
|
+
const id = boundedText(String(value.id ?? ''), 32);
|
|
102
|
+
const ioc = boundedText(value.ioc, 253)?.toLowerCase();
|
|
103
|
+
const iocType = boundedText(value.ioc_type, 32)?.toLowerCase();
|
|
104
|
+
const threatType = boundedText(value.threat_type, 64)?.toLowerCase();
|
|
105
|
+
const threatDescription = boundedText(value.threat_type_desc, 120);
|
|
106
|
+
const malware = boundedText(value.malware_printable, 120) || boundedText(value.malware, 120);
|
|
107
|
+
if (!id || !/^\d{1,32}$/u.test(id) || ioc !== targetDomain || iocType !== 'domain' || !threatType)
|
|
108
|
+
return null;
|
|
109
|
+
const firstObservedAt = isoTimestamp(value.first_seen);
|
|
110
|
+
const lastObservedAt = isoTimestamp(value.last_seen) || firstObservedAt;
|
|
111
|
+
const tags = normalizedTags(value.tags);
|
|
112
|
+
tags.push(`role:${threatType}`);
|
|
113
|
+
if (malware)
|
|
114
|
+
tags.push(`malware:${malware.toLowerCase()}`);
|
|
115
|
+
return {
|
|
116
|
+
id,
|
|
117
|
+
category: 'malware',
|
|
118
|
+
severity: 'unknown',
|
|
119
|
+
confidence: normalizedConfidence(value.confidence_level),
|
|
120
|
+
providerVerdict: [threatDescription || threatType.replaceAll('_', ' '), malware].filter(Boolean).join(' · '),
|
|
121
|
+
detail: `The provider associates this domain with ${threatDescription || threatType.replaceAll('_', ' ')}${malware ? ` involving ${malware}` : ''}. The record may be historical and requires analyst review.`,
|
|
122
|
+
firstObservedAt,
|
|
123
|
+
lastObservedAt,
|
|
124
|
+
referenceUrl: `https://threatfox.abuse.ch/ioc/${id}/`,
|
|
125
|
+
tags: [...new Set(tags)].sort().slice(0, 20),
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
function result(targetDomain, input, observedAt) {
|
|
129
|
+
return createThreatIntelligenceResult(THREATFOX_PROVIDER, { type: 'domain', value: targetDomain }, input, observedAt);
|
|
130
|
+
}
|
|
131
|
+
function createThreatfoxIntelligenceAdapter(dependencies = {}) {
|
|
132
|
+
const fetchDetailed = dependencies.fetchDetailed || safeFetchDetailed;
|
|
133
|
+
const readResponse = dependencies.readResponse || readTextCapped;
|
|
134
|
+
const now = dependencies.now || Date.now;
|
|
135
|
+
let activeRequests = 0;
|
|
136
|
+
let dayBucket = '';
|
|
137
|
+
let monthBucket = '';
|
|
138
|
+
let dayRequests = 0;
|
|
139
|
+
let monthRequests = 0;
|
|
140
|
+
function consumeLocalBudget(timestamp) {
|
|
141
|
+
const day = new Date(timestamp).toISOString().slice(0, 10);
|
|
142
|
+
const month = day.slice(0, 7);
|
|
143
|
+
if (day !== dayBucket) {
|
|
144
|
+
dayBucket = day;
|
|
145
|
+
dayRequests = 0;
|
|
146
|
+
}
|
|
147
|
+
if (month !== monthBucket) {
|
|
148
|
+
monthBucket = month;
|
|
149
|
+
monthRequests = 0;
|
|
150
|
+
}
|
|
151
|
+
if (dayRequests >= THREATFOX_PROVIDER.limits.dailyRequests
|
|
152
|
+
|| monthRequests >= THREATFOX_PROVIDER.limits.monthlyRequests)
|
|
153
|
+
return false;
|
|
154
|
+
dayRequests += 1;
|
|
155
|
+
monthRequests += 1;
|
|
156
|
+
return true;
|
|
157
|
+
}
|
|
158
|
+
async function lookupDomain(domain, options = {}) {
|
|
159
|
+
const observedAt = new Date(now()).toISOString();
|
|
160
|
+
let targetDomain;
|
|
161
|
+
try {
|
|
162
|
+
targetDomain = normalizeThreatIntelligenceTarget({ type: 'domain', value: domain }, 'registrable_domain').value;
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
throw new TypeError('Malware-IOC intelligence requires a valid registrable domain');
|
|
166
|
+
}
|
|
167
|
+
const configuration = threatfoxConfiguration(options.env);
|
|
168
|
+
if (!configuration.configured || !configuration.authKey) {
|
|
169
|
+
return result(targetDomain, {
|
|
170
|
+
state: configuration.enabled ? 'unavailable' : 'skipped',
|
|
171
|
+
detail: configuration.reason,
|
|
172
|
+
}, observedAt);
|
|
173
|
+
}
|
|
174
|
+
if (activeRequests >= THREATFOX_PROVIDER.limits.concurrency) {
|
|
175
|
+
return result(targetDomain, {
|
|
176
|
+
state: 'unavailable',
|
|
177
|
+
detail: 'The local malware-IOC request concurrency limit is already in use; try again shortly.',
|
|
178
|
+
}, observedAt);
|
|
179
|
+
}
|
|
180
|
+
if (!consumeLocalBudget(now())) {
|
|
181
|
+
return result(targetDomain, {
|
|
182
|
+
state: 'rate_limited',
|
|
183
|
+
detail: 'The local malware-IOC request budget has been reached for this runtime instance.',
|
|
184
|
+
}, observedAt);
|
|
185
|
+
}
|
|
186
|
+
activeRequests += 1;
|
|
187
|
+
try {
|
|
188
|
+
const body = JSON.stringify({ query: 'search_ioc', search_term: targetDomain, exact_match: true });
|
|
189
|
+
const { response } = await fetchDetailed(THREATFOX_SEARCH_ENDPOINT, {
|
|
190
|
+
method: 'POST',
|
|
191
|
+
headers: {
|
|
192
|
+
accept: 'application/json',
|
|
193
|
+
'auth-key': configuration.authKey,
|
|
194
|
+
'content-type': 'application/json',
|
|
195
|
+
'user-agent': 'WHOISleuth/1.0',
|
|
196
|
+
},
|
|
197
|
+
body,
|
|
198
|
+
signal: AbortSignal.timeout(THREATFOX_PROVIDER.limits.timeoutMs),
|
|
199
|
+
}, { maxRedirects: 0 });
|
|
200
|
+
const upstreamStatus = response.status;
|
|
201
|
+
if (upstreamStatus === 429) {
|
|
202
|
+
await response.body?.cancel().catch(() => { });
|
|
203
|
+
return result(targetDomain, {
|
|
204
|
+
state: 'rate_limited',
|
|
205
|
+
detail: 'The malware-IOC provider reported that its query quota is temporarily exhausted.',
|
|
206
|
+
upstreamStatus,
|
|
207
|
+
retryAfterSeconds: retryAfterSeconds(response),
|
|
208
|
+
}, observedAt);
|
|
209
|
+
}
|
|
210
|
+
if (upstreamStatus === 401 || upstreamStatus === 403) {
|
|
211
|
+
await response.body?.cancel().catch(() => { });
|
|
212
|
+
return result(targetDomain, {
|
|
213
|
+
state: 'unavailable',
|
|
214
|
+
detail: 'The malware-IOC provider did not accept the configured API credential.',
|
|
215
|
+
upstreamStatus,
|
|
216
|
+
}, observedAt);
|
|
217
|
+
}
|
|
218
|
+
if (upstreamStatus < 200 || upstreamStatus >= 300) {
|
|
219
|
+
await response.body?.cancel().catch(() => { });
|
|
220
|
+
return result(targetDomain, {
|
|
221
|
+
state: 'error',
|
|
222
|
+
detail: `Malware-IOC lookup failed with HTTP ${upstreamStatus}.`,
|
|
223
|
+
upstreamStatus,
|
|
224
|
+
}, observedAt);
|
|
225
|
+
}
|
|
226
|
+
const captured = await readResponse(response, THREATFOX_PROVIDER.limits.maxResponseBytes);
|
|
227
|
+
if (captured.truncated) {
|
|
228
|
+
return result(targetDomain, {
|
|
229
|
+
state: 'error',
|
|
230
|
+
detail: 'Malware-IOC lookup returned a response larger than the configured safety limit.',
|
|
231
|
+
upstreamStatus,
|
|
232
|
+
}, observedAt);
|
|
233
|
+
}
|
|
234
|
+
let parsed;
|
|
235
|
+
try {
|
|
236
|
+
parsed = JSON.parse(captured.text);
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
return result(targetDomain, {
|
|
240
|
+
state: 'error', detail: 'Malware-IOC lookup returned malformed JSON.', upstreamStatus,
|
|
241
|
+
}, observedAt);
|
|
242
|
+
}
|
|
243
|
+
if (!isRecord(parsed) || typeof parsed.query_status !== 'string') {
|
|
244
|
+
return result(targetDomain, {
|
|
245
|
+
state: 'error', detail: 'Malware-IOC lookup returned an unexpected response shape.', upstreamStatus,
|
|
246
|
+
}, observedAt);
|
|
247
|
+
}
|
|
248
|
+
const queryStatus = parsed.query_status.trim().toLowerCase();
|
|
249
|
+
if (queryStatus === 'no_result' || queryStatus === 'no_results') {
|
|
250
|
+
return result(targetDomain, {
|
|
251
|
+
state: 'not_found',
|
|
252
|
+
detail: 'The provider returned no retained malware-IOC record for this registrable domain.',
|
|
253
|
+
upstreamStatus,
|
|
254
|
+
limitations: ['The provider expires older indicators from its community API, so a neutral miss does not cover all historical activity.'],
|
|
255
|
+
}, observedAt);
|
|
256
|
+
}
|
|
257
|
+
if (['no_api_key', 'auth_key_invalid', 'user_blacklisted'].includes(queryStatus)) {
|
|
258
|
+
return result(targetDomain, {
|
|
259
|
+
state: 'unavailable', detail: 'The malware-IOC provider did not accept the configured API credential.', upstreamStatus,
|
|
260
|
+
}, observedAt);
|
|
261
|
+
}
|
|
262
|
+
if (queryStatus !== 'ok' || !Array.isArray(parsed.data)) {
|
|
263
|
+
return result(targetDomain, {
|
|
264
|
+
state: 'error', detail: 'Malware-IOC lookup returned an unexpected response shape.', upstreamStatus,
|
|
265
|
+
}, observedAt);
|
|
266
|
+
}
|
|
267
|
+
const inputRecords = parsed.data.slice(0, THREATFOX_MAX_INPUT_RESULTS);
|
|
268
|
+
const normalized = inputRecords
|
|
269
|
+
.map((item) => normalizeIocFinding(item, targetDomain))
|
|
270
|
+
.filter((item) => item !== null);
|
|
271
|
+
const findings = normalized.slice(0, THREATFOX_MAX_RESULTS);
|
|
272
|
+
const discarded = inputRecords.length - normalized.length;
|
|
273
|
+
const truncated = parsed.data.length > THREATFOX_MAX_INPUT_RESULTS || normalized.length > THREATFOX_MAX_RESULTS;
|
|
274
|
+
const partial = truncated || discarded > 0 || findings.length === 0;
|
|
275
|
+
return result(targetDomain, {
|
|
276
|
+
state: partial ? 'partial' : 'success',
|
|
277
|
+
truncated,
|
|
278
|
+
detail: findings.length
|
|
279
|
+
? `Found ${findings.length} bounded malware-IOC record${findings.length === 1 ? '' : 's'} for this domain${truncated ? '; additional provider records may exist' : ''}.`
|
|
280
|
+
: 'The provider reported a match but no returned record could be safely normalized.',
|
|
281
|
+
upstreamStatus,
|
|
282
|
+
findings,
|
|
283
|
+
limitations: [
|
|
284
|
+
'The provider retains malware-associated indicators for a limited period and does not represent complete historical coverage.',
|
|
285
|
+
'An IOC association can be historical, shared, compromised, or reassigned and requires analyst review.',
|
|
286
|
+
...(discarded > 0
|
|
287
|
+
? [`${discarded} provider record${discarded === 1 ? ' was' : 's were'} omitted because it could not be safely normalized.`]
|
|
288
|
+
: []),
|
|
289
|
+
],
|
|
290
|
+
}, observedAt);
|
|
291
|
+
}
|
|
292
|
+
catch (error) {
|
|
293
|
+
const timedOut = error instanceof Error && (error.name === 'TimeoutError' || error.name === 'AbortError');
|
|
294
|
+
return result(targetDomain, {
|
|
295
|
+
state: 'error',
|
|
296
|
+
detail: timedOut ? 'Malware-IOC lookup exceeded its bounded request deadline.' : 'Malware-IOC lookup could not be completed.',
|
|
297
|
+
}, observedAt);
|
|
298
|
+
}
|
|
299
|
+
finally {
|
|
300
|
+
activeRequests -= 1;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
return Object.freeze({ lookupDomain });
|
|
304
|
+
}
|
|
305
|
+
const defaultThreatfoxAdapter = createThreatfoxIntelligenceAdapter();
|
|
306
|
+
export { THREATFOX_PROVIDER, THREATFOX_SEARCH_ENDPOINT, THREATFOX_MAX_RESULTS, THREATFOX_MAX_RESPONSE_BYTES, THREATFOX_TIMEOUT_MS, threatfoxConfiguration, createThreatfoxIntelligenceAdapter, };
|
|
307
|
+
export const lookupThreatfoxDomain = defaultThreatfoxAdapter.lookupDomain;
|