muaddib-scanner 2.11.113 → 2.11.115
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/audit-data/adjudication-2026-06-14.json +56 -0
- package/audit-data/fpr-baseline-2026-06-14.json +2648 -0
- package/package.json +1 -1
- package/{self-scan-v2.11.113.json → self-scan-v2.11.115.json} +18 -13
- package/src/intent-graph.js +34 -192
- package/src/pipeline/executor.js +5 -1
- package/src/pipeline/processor.js +15 -7
- package/src/scanner/ast-detectors/handle-post-walk.js +9 -2
- package/src/scanner/module-graph/annotate-sinks.js +8 -5
- package/src/scanner/module-graph/constants.js +10 -1
- package/src/scanner/module-graph/detect-cross-file.js +56 -4
- package/src/scanner/module-graph/index.js +2 -2
- package/src/scanner/module-graph/parse-utils.js +13 -1
- package/src/scoring.js +41 -0
- package/src/sdk-destination.js +328 -0
package/src/scoring.js
CHANGED
|
@@ -1029,6 +1029,28 @@ const FRAMEWORK_PROTO_RE = new RegExp(
|
|
|
1029
1029
|
'^(' + FRAMEWORK_PROTOTYPES.join('|') + ')\\.prototype\\.'
|
|
1030
1030
|
);
|
|
1031
1031
|
|
|
1032
|
+
// FPR sink-coupling (chantier 2026-06): independent exfil / remote-exec sink signals pointing at
|
|
1033
|
+
// an ANOMALOUS destination. A credential_regex_harvest signal is only a true positive when one of
|
|
1034
|
+
// these co-occurs in the package. Deliberately EXCLUDES benign network capability — a bare
|
|
1035
|
+
// fetch/http.get, remote_code_load to a first-party CDN/model host, a local server, or a native
|
|
1036
|
+
// build are NOT sinks. Dataflow-PROVEN harvest signals (intent_credential_exfil, cross_file_dataflow)
|
|
1037
|
+
// are included so a genuine read→exfil taint keeps the signal HIGH regardless of host reputation
|
|
1038
|
+
// (anti-FN floor). See _hasExfilSink + the credential_regex_harvest gate in applyFPReductions.
|
|
1039
|
+
const EXFIL_SINK_TYPES = new Set([
|
|
1040
|
+
'suspicious_domain', 'direct_ip_exfil', 'ioc_string_match', 'ioc_match',
|
|
1041
|
+
'known_malicious_package', 'pypi_malicious_package', 'shai_hulud_marker',
|
|
1042
|
+
'detached_credential_exfil', 'silent_stealth_process',
|
|
1043
|
+
'curl_pipe_shell', 'curl_env_exfil', 'reverse_shell', 'dns_exfil', 'oast_callback',
|
|
1044
|
+
'function_constructor_require', 'staged_remote_loader', 'staged_eval_decode',
|
|
1045
|
+
'fetch_decrypt_exec', 'download_exec_binary', 'self_destruct_eval',
|
|
1046
|
+
'newsletter_auto_follow', 'cross_file_dataflow', 'intent_credential_exfil',
|
|
1047
|
+
'intent_command_exfil', 'sandbox_known_exfil_domain', 'sandbox_network_after_sensitive_read'
|
|
1048
|
+
]);
|
|
1049
|
+
function _hasExfilSink(threats) {
|
|
1050
|
+
if (!Array.isArray(threats)) return false;
|
|
1051
|
+
return threats.some(t => EXFIL_SINK_TYPES.has(t.type) && t.severity !== 'LOW');
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1032
1054
|
function applyFPReductions(threats, reachableFiles, packageName, packageDeps, reachableFunctions) {
|
|
1033
1055
|
// Initialize reductions audit trail on each threat
|
|
1034
1056
|
// Store original severity before any FP reductions, so compound
|
|
@@ -1174,6 +1196,25 @@ function applyFPReductions(threats, reachableFiles, packageName, packageDeps, re
|
|
|
1174
1196
|
}
|
|
1175
1197
|
}
|
|
1176
1198
|
|
|
1199
|
+
// FPR sink-coupling gate (chantier 2026-06 — FPR-baseline-2026-06-14.md). credential_regex_harvest
|
|
1200
|
+
// is a weak signal alone: a credential-shaped regex co-located with a network call, with NO proof
|
|
1201
|
+
// the matched secret flows out and NO host-reputation check (ast.js:hasCredentialInsideRegex +
|
|
1202
|
+
// hasNetworkCallInFile). The blind FPR baseline measured 94.4% FP on it — it fires on nodemailer
|
|
1203
|
+
// SMTP code, redaction utilities in framework bundles, and SDKs that parse Authorization headers.
|
|
1204
|
+
// It is a real harvester ONLY when an independent exfil sink to an anomalous destination co-occurs
|
|
1205
|
+
// (suspicious_domain / direct_ip / ioc / detached-exfil / staged loader / curl exfil / dataflow-proven
|
|
1206
|
+
// taint ...). When no such sink is present, downgrade HIGH/CRITICAL → LOW. Runs after the dilution
|
|
1207
|
+
// floor so the floor's restored instance is also gated (the floor protects real exfil; with no sink
|
|
1208
|
+
// there is nothing to protect). No GT sample relies on credential_regex_harvest (verified).
|
|
1209
|
+
if (!_hasExfilSink(threats)) {
|
|
1210
|
+
for (const t of threats) {
|
|
1211
|
+
if (t.type === 'credential_regex_harvest' && (t.severity === 'HIGH' || t.severity === 'CRITICAL')) {
|
|
1212
|
+
t.reductions.push({ rule: 'sink_coupling', from: t.severity, to: 'LOW' });
|
|
1213
|
+
t.severity = 'LOW';
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1177
1218
|
for (const t of threats) {
|
|
1178
1219
|
|
|
1179
1220
|
// Audit v3 B3: typosquat with LOW confidence → MEDIUM
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// ============================================
|
|
4
|
+
// DESTINATION-AWARE SDK DETECTION (shared leaf module)
|
|
5
|
+
// ============================================
|
|
6
|
+
// Extracted from intent-graph.js (v2.11.x) so the same destination logic can gate
|
|
7
|
+
// every credential→network taint detector — intent coherence (intent-graph.js),
|
|
8
|
+
// dataflow (scanner/dataflow.js), cross-file flow (scanner/module-graph) and the
|
|
9
|
+
// detached/uncaught compounds (scanner/ast-detectors). No project dependencies
|
|
10
|
+
// (only the Node stdlib via callers) → safe to require from any scanner, no cycles.
|
|
11
|
+
//
|
|
12
|
+
// Curated allowlist: when an env var matching the pattern is sent to a matching domain,
|
|
13
|
+
// it is legitimate SDK usage, not credential exfiltration.
|
|
14
|
+
// Safe-by-default: unknown env vars or unknown domains remain CRITICAL.
|
|
15
|
+
const SDK_ENV_DOMAIN_MAP = [
|
|
16
|
+
{ envPattern: /^AWS_/i, domains: ['amazonaws.com', 'aws.amazon.com'] },
|
|
17
|
+
{ envPattern: /^AZURE_/i, domains: ['azure.com', 'microsoft.com'] },
|
|
18
|
+
{ envPattern: /^GOOGLE_|^GCP_/i, domains: ['googleapis.com', 'google.com'] },
|
|
19
|
+
{ envPattern: /^FIREBASE_/i, domains: ['firebase.com', 'googleapis.com'] },
|
|
20
|
+
{ envPattern: /^SALESFORCE_/i, domains: ['salesforce.com', 'force.com'] },
|
|
21
|
+
{ envPattern: /^SUPABASE_/i, domains: ['supabase.co', 'supabase.com'] },
|
|
22
|
+
{ envPattern: /^MAILGUN_/i, domains: ['mailgun.net', 'mailgun.com'] },
|
|
23
|
+
{ envPattern: /^STRIPE_/i, domains: ['stripe.com'] },
|
|
24
|
+
{ envPattern: /^TWILIO_/i, domains: ['twilio.com'] },
|
|
25
|
+
{ envPattern: /^SENDGRID_/i, domains: ['sendgrid.com', 'sendgrid.net'] },
|
|
26
|
+
{ envPattern: /^DATADOG_/i, domains: ['datadoghq.com'] },
|
|
27
|
+
{ envPattern: /^SENTRY_/i, domains: ['sentry.io'] },
|
|
28
|
+
{ envPattern: /^SLACK_/i, domains: ['slack.com'] },
|
|
29
|
+
{ envPattern: /^GITHUB_/i, domains: ['github.com', 'githubusercontent.com'] },
|
|
30
|
+
{ envPattern: /^GITLAB_/i, domains: ['gitlab.com'] },
|
|
31
|
+
{ envPattern: /^CLOUDFLARE_/i, domains: ['cloudflare.com'] },
|
|
32
|
+
{ envPattern: /^OPENAI_/i, domains: ['openai.com'] },
|
|
33
|
+
{ envPattern: /^ANTHROPIC_/i, domains: ['anthropic.com'] },
|
|
34
|
+
{ envPattern: /^MONGODB_|^MONGO_/i, domains: ['mongodb.com', 'mongodb.net'] },
|
|
35
|
+
{ envPattern: /^AUTH0_/i, domains: ['auth0.com'] },
|
|
36
|
+
{ envPattern: /^HUBSPOT_/i, domains: ['hubspot.com', 'hubapi.com'] },
|
|
37
|
+
{ envPattern: /^CONTENTFUL_/i, domains: ['contentful.com'] },
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
// Tokens stripped when extracting brand keyword from env var name
|
|
41
|
+
const ENV_NOISE_TOKENS = new Set([
|
|
42
|
+
'API', 'KEY', 'SECRET', 'TOKEN', 'PASSWORD', 'CREDENTIAL',
|
|
43
|
+
'AUTH', 'ACCESS', 'PRIVATE', 'PUBLIC', 'CLIENT', 'ID', 'URL'
|
|
44
|
+
]);
|
|
45
|
+
|
|
46
|
+
// Suspicious tunneling/proxy domains — never considered legitimate SDK destinations
|
|
47
|
+
const SUSPICIOUS_DOMAIN_PATTERNS = /ngrok|serveo|localtunnel|burpcollaborator|requestbin|pipedream|webhook\.site/i;
|
|
48
|
+
|
|
49
|
+
// URL extraction regex (matches http/https URLs in source code)
|
|
50
|
+
const URL_EXTRACT_RE = /https?:\/\/[a-zA-Z0-9\-._~:/?#[\]@!$&'()*+,;=%]+/g;
|
|
51
|
+
|
|
52
|
+
// Hostname extraction from Node.js request options: hostname: 'domain.com' or host: 'domain.com'
|
|
53
|
+
const HOSTNAME_OPTION_RE = /(?:hostname|host)\s*:\s*['"`]([a-zA-Z0-9\-._]+)['"`]/g;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Extract env var name from an intent source threat message.
|
|
57
|
+
* Messages look like: "process.env.SALESFORCE_API_KEY", "env var MAILGUN_API_KEY accessed"
|
|
58
|
+
*/
|
|
59
|
+
function extractEnvVarFromMessage(sourceThreats) {
|
|
60
|
+
for (const t of sourceThreats) {
|
|
61
|
+
if (!t.message) continue;
|
|
62
|
+
// Match process.env.VAR_NAME pattern
|
|
63
|
+
const envMatch = t.message.match(/process\.env\.([A-Z_][A-Z0-9_]*)/i);
|
|
64
|
+
if (envMatch) return envMatch[1];
|
|
65
|
+
// Match standalone VAR_NAME patterns (e.g., "SALESFORCE_API_KEY")
|
|
66
|
+
const varMatch = t.message.match(/\b([A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+)\b/);
|
|
67
|
+
if (varMatch) return varMatch[1];
|
|
68
|
+
}
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Extract brand keyword from env var name by removing noise tokens.
|
|
74
|
+
* MAILGUN_API_KEY → MAILGUN, SALESFORCE_CLIENT_SECRET → SALESFORCE
|
|
75
|
+
*/
|
|
76
|
+
function extractBrandFromEnvVar(envVarName) {
|
|
77
|
+
const parts = envVarName.toUpperCase().split('_');
|
|
78
|
+
const brandParts = parts.filter(p => !ENV_NOISE_TOKENS.has(p) && p.length > 0);
|
|
79
|
+
return brandParts.length > 0 ? brandParts[0] : null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Extract domain from a URL string.
|
|
84
|
+
* Returns the hostname (without port).
|
|
85
|
+
*/
|
|
86
|
+
function extractDomain(url) {
|
|
87
|
+
try {
|
|
88
|
+
// Capture only valid hostname characters so a path-less URL immediately followed by
|
|
89
|
+
// a quote/paren (e.g. fetch('https://api.openai.com')) does not absorb the trailing
|
|
90
|
+
// ')" into the host. Stops at /, :, ?, #, quotes, parens, etc.
|
|
91
|
+
const match = url.match(/^https?:\/\/([a-zA-Z0-9.\-]+)/i);
|
|
92
|
+
return match ? match[1].toLowerCase() : null;
|
|
93
|
+
} catch {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Check if a domain matches any of the expected SDK domains (suffix match).
|
|
100
|
+
* api.mailgun.net matches mailgun.net, sub.api.stripe.com matches stripe.com
|
|
101
|
+
*/
|
|
102
|
+
function domainMatchesSuffix(domain, expectedDomains) {
|
|
103
|
+
for (const expected of expectedDomains) {
|
|
104
|
+
if (domain === expected || domain.endsWith('.' + expected)) return true;
|
|
105
|
+
}
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Check if an env var + file content represents a legitimate SDK pattern.
|
|
111
|
+
*
|
|
112
|
+
* Returns true ONLY if:
|
|
113
|
+
* 1. The env var matches a known SDK mapping (allowlist) OR heuristic brand match
|
|
114
|
+
* 2. ALL URLs in the file point to domains matching the expected SDK
|
|
115
|
+
* 3. No suspicious tunneling/proxy domains are present
|
|
116
|
+
*
|
|
117
|
+
* @param {string} envVarName - e.g., "SALESFORCE_API_KEY"
|
|
118
|
+
* @param {string} fileContent - source code of the file
|
|
119
|
+
* @returns {boolean} true if SDK pattern (should skip intent pair)
|
|
120
|
+
*/
|
|
121
|
+
function isSDKPattern(envVarName, fileContent) {
|
|
122
|
+
// Extract domains from full URLs (https://api.stripe.com/v1/charges)
|
|
123
|
+
const urls = fileContent.match(URL_EXTRACT_RE) || [];
|
|
124
|
+
const domains = urls.map(u => extractDomain(u)).filter(Boolean);
|
|
125
|
+
|
|
126
|
+
// Also extract hostnames from Node.js request options (hostname: 'api.stripe.com')
|
|
127
|
+
let hostnameMatch;
|
|
128
|
+
const hostnameRe = new RegExp(HOSTNAME_OPTION_RE.source, 'g');
|
|
129
|
+
while ((hostnameMatch = hostnameRe.exec(fileContent)) !== null) {
|
|
130
|
+
const hostname = hostnameMatch[1].toLowerCase();
|
|
131
|
+
if (hostname && !domains.includes(hostname)) {
|
|
132
|
+
domains.push(hostname);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// No URLs found — can't confirm SDK pattern, default to suspicious
|
|
137
|
+
if (domains.length === 0) return false;
|
|
138
|
+
|
|
139
|
+
// Check for suspicious tunneling domains — immediate fail
|
|
140
|
+
for (const domain of domains) {
|
|
141
|
+
if (SUSPICIOUS_DOMAIN_PATTERNS.test(domain)) return false;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Check for raw IP addresses — immediate fail
|
|
145
|
+
for (const domain of domains) {
|
|
146
|
+
if (/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(domain)) return false;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// 1. Try curated allowlist first (strict: ALL domains must match)
|
|
150
|
+
// Curated allowlist is authoritative — no relaxation here to prevent
|
|
151
|
+
// attacker injecting a legitimate domain alongside their C2 domain.
|
|
152
|
+
for (const mapping of SDK_ENV_DOMAIN_MAP) {
|
|
153
|
+
if (mapping.envPattern.test(envVarName)) {
|
|
154
|
+
return domains.every(d => domainMatchesSuffix(d, mapping.domains));
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// R2: credential-suffixed env vars get relaxed domain matching (at least ONE match).
|
|
159
|
+
// SDKs commonly call their own API + CDN/logging/analytics domains.
|
|
160
|
+
// Safety: suspicious domains and raw IPs are already rejected above.
|
|
161
|
+
// Only applies to the heuristic fallback — curated allowlist stays strict.
|
|
162
|
+
const CREDENTIAL_SUFFIXES = ['_API_KEY', '_SECRET', '_TOKEN', '_SECRET_KEY', '_ACCESS_KEY'];
|
|
163
|
+
const upperName = envVarName.toUpperCase();
|
|
164
|
+
const hasCredentialSuffix = CREDENTIAL_SUFFIXES.some(s => upperName.endsWith(s));
|
|
165
|
+
|
|
166
|
+
// 2. Heuristic fallback: extract brand keyword and check domain labels
|
|
167
|
+
const brand = extractBrandFromEnvVar(envVarName);
|
|
168
|
+
if (!brand || brand.length < 3) return false; // Too short for reliable matching
|
|
169
|
+
|
|
170
|
+
const brandLower = brand.toLowerCase();
|
|
171
|
+
// 2a. Strict check: every domain matches brand (existing behavior)
|
|
172
|
+
// e.g., brand "ACME" matches "api.acme.com" (label "acme") but not "api.acmetech.com"
|
|
173
|
+
if (domains.every(d => {
|
|
174
|
+
const labels = d.split('.');
|
|
175
|
+
return labels.some(label => label === brandLower);
|
|
176
|
+
})) return true;
|
|
177
|
+
|
|
178
|
+
// 2b. R2 relaxed: credential suffix + at least one domain matches brand
|
|
179
|
+
if (hasCredentialSuffix && domains.some(d => {
|
|
180
|
+
const labels = d.split('.');
|
|
181
|
+
return labels.some(label => label === brandLower);
|
|
182
|
+
})) return true;
|
|
183
|
+
|
|
184
|
+
return false;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ============================================
|
|
188
|
+
// DESTINATION-BENIGNNESS GATE (env-var-independent)
|
|
189
|
+
// ============================================
|
|
190
|
+
// isSDKPattern() needs a single extractable env var + matching domain. That model
|
|
191
|
+
// breaks on (a) multi-provider files (a CLI that reads GEMINI_API_KEY *and*
|
|
192
|
+
// ANTHROPIC_API_KEY and calls both), and (b) flows whose credential source has no
|
|
193
|
+
// extractable env var (cross_file_dataflow / detached compounds). For those, judge the
|
|
194
|
+
// DESTINATIONS, not the env var: a credential→network flow is benign iff EVERY network
|
|
195
|
+
// host in scope is provably non-exfil.
|
|
196
|
+
//
|
|
197
|
+
// Benign classes (NOT attacker-spoofable):
|
|
198
|
+
// - loopback / RFC1918 private / link-local IPs, localhost, *.local (local IPC)
|
|
199
|
+
// - reserved test domains (example.com, *.test, *.invalid) (RFC 2606/6761)
|
|
200
|
+
// - curated SaaS/cloud/AI provider API domains (cannot echo a
|
|
201
|
+
// POST body back to a third party — UNLIKE paste sites / bot webhooks, deliberately
|
|
202
|
+
// EXCLUDED, see SUSPICIOUS_DOMAIN_PATTERNS + the exclusion note below)
|
|
203
|
+
// Deliberately NOT benign: package "own domain" from package.json (attacker writes it),
|
|
204
|
+
// unknown domains, public IPs, suspicious tunnels/paste hosts. Any of those ⇒ keep firing.
|
|
205
|
+
// Safe-by-default: no extractable host ⇒ NOT benign (do not suppress).
|
|
206
|
+
|
|
207
|
+
// AI providers (2025-26) absent from the env→domain map. Bot/messaging/paste channels
|
|
208
|
+
// (telegram, discord webhooks, pastebin, gist, transfer.sh, …) are intentionally absent:
|
|
209
|
+
// they CAN relay an exfil POST to the attacker, so they must keep firing.
|
|
210
|
+
const AI_PROVIDER_DOMAIN_SUFFIXES = [
|
|
211
|
+
'claude.com', 'openrouter.ai', 'deepseek.com', 'x.ai', 'mistral.ai', 'cohere.ai',
|
|
212
|
+
'cohere.com', 'huggingface.co', 'perplexity.ai', 'groq.com', 'together.ai',
|
|
213
|
+
'together.xyz', 'replicate.com', 'fireworks.ai', 'anyscale.com', 'ai21.com',
|
|
214
|
+
'voyageai.com', 'deepinfra.com',
|
|
215
|
+
];
|
|
216
|
+
|
|
217
|
+
// Flat suffix list = every domain already curated in SDK_ENV_DOMAIN_MAP + the AI extras.
|
|
218
|
+
// Derived (not duplicated) so the two stay in sync. Matched via domainMatchesSuffix, which
|
|
219
|
+
// is label-anchored: 'evilx.ai' does NOT match 'x.ai'.
|
|
220
|
+
const PROVIDER_DOMAIN_SUFFIXES = Array.from(new Set([
|
|
221
|
+
...SDK_ENV_DOMAIN_MAP.flatMap(m => m.domains),
|
|
222
|
+
...AI_PROVIDER_DOMAIN_SUFFIXES,
|
|
223
|
+
]));
|
|
224
|
+
|
|
225
|
+
function stripPort(host) {
|
|
226
|
+
let h = String(host).trim().toLowerCase();
|
|
227
|
+
// Bracketed IPv6 with optional port: [::1]:443 / [::1] → ::1
|
|
228
|
+
const br = h.match(/^\[([^\]]+)\]/);
|
|
229
|
+
if (br) return br[1];
|
|
230
|
+
// host:port for IPv4 / hostname — only when there's a single colon (bare IPv6 like
|
|
231
|
+
// ::1 has multiple colons and must NOT be truncated).
|
|
232
|
+
if ((h.match(/:/g) || []).length === 1) h = h.replace(/:\d+$/, '');
|
|
233
|
+
return h;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// loopback / RFC1918 private / link-local / localhost / reserved-test domain.
|
|
237
|
+
function isLocalOrReservedHost(host) {
|
|
238
|
+
const h = stripPort(host);
|
|
239
|
+
if (!h) return false;
|
|
240
|
+
if (h === 'localhost' || h.endsWith('.localhost') || h.endsWith('.local')) return true;
|
|
241
|
+
if (h === '::1' || h === '0:0:0:0:0:0:0:1') return true; // IPv6 loopback
|
|
242
|
+
if (h === 'example.com' || h === 'example.org' || h === 'example.net') return true;
|
|
243
|
+
if (h.endsWith('.example.com') || h.endsWith('.example.org') || h.endsWith('.example.net')) return true;
|
|
244
|
+
if (h.endsWith('.example') || h.endsWith('.test') || h.endsWith('.invalid')) return true;
|
|
245
|
+
const m = h.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
|
246
|
+
if (m) {
|
|
247
|
+
const a = +m[1], b = +m[2];
|
|
248
|
+
if (a === 127 || a === 0) return true; // loopback / this-host
|
|
249
|
+
if (a === 10) return true; // 10.0.0.0/8
|
|
250
|
+
if (a === 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12
|
|
251
|
+
if (a === 192 && b === 168) return true; // 192.168.0.0/16
|
|
252
|
+
if (a === 169 && b === 254) return true; // 169.254.0.0/16 link-local
|
|
253
|
+
return false; // any other IPv4 literal = public
|
|
254
|
+
}
|
|
255
|
+
return false;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// A public (non-loopback/private) IPv4 literal — a direct-IP exfil endpoint (ecto pattern).
|
|
259
|
+
function isPublicIpHost(host) {
|
|
260
|
+
const h = stripPort(host);
|
|
261
|
+
if (!/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(h)) return false;
|
|
262
|
+
return !isLocalOrReservedHost(h);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Extract every network host referenced in a file (URLs + Node request options).
|
|
266
|
+
function extractHostsFromContent(fileContent) {
|
|
267
|
+
if (!fileContent) return [];
|
|
268
|
+
const urls = fileContent.match(URL_EXTRACT_RE) || [];
|
|
269
|
+
const hosts = urls.map(u => extractDomain(u)).filter(Boolean);
|
|
270
|
+
let m;
|
|
271
|
+
const re = new RegExp(HOSTNAME_OPTION_RE.source, 'g');
|
|
272
|
+
while ((m = re.exec(fileContent)) !== null) {
|
|
273
|
+
const h = m[1].toLowerCase();
|
|
274
|
+
if (h && !hosts.includes(h)) hosts.push(h);
|
|
275
|
+
}
|
|
276
|
+
// Bare host literals assigned as defaults, e.g. `process.env.HOST || "127.0.0.1"` then
|
|
277
|
+
// used as `host: HOST` (common "configurable local collector" shape — the variable host
|
|
278
|
+
// isn't matched above). Capture quoted IPv4 / localhost / 0.0.0.0 literals. Safe: any
|
|
279
|
+
// co-present public IP or unknown host still fails the all-benign check downstream, so
|
|
280
|
+
// this can only RELAX a file whose every literal host is loopback/private.
|
|
281
|
+
const LITERAL_HOST_RE = /['"`](localhost|0\.0\.0\.0|(?:\d{1,3}\.){3}\d{1,3})['"`]/g;
|
|
282
|
+
while ((m = LITERAL_HOST_RE.exec(fileContent)) !== null) {
|
|
283
|
+
const h = m[1].toLowerCase();
|
|
284
|
+
if (h && !hosts.includes(h)) hosts.push(h);
|
|
285
|
+
}
|
|
286
|
+
return hosts;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Destination-benignness gate for credential→network taint flows whose env var is not
|
|
291
|
+
* (or need not be) known. Returns true ONLY if EVERY extracted host is provably non-exfil
|
|
292
|
+
* (local/reserved OR a curated provider). Any suspicious/paste host, public IP, or unknown
|
|
293
|
+
* domain ⇒ false. No hosts found ⇒ false (cannot confirm).
|
|
294
|
+
*
|
|
295
|
+
* @param {string} fileContent - source of the file containing the network sink
|
|
296
|
+
* @returns {boolean} true ⇒ first-party/local, safe to downgrade the taint flow
|
|
297
|
+
*/
|
|
298
|
+
function networkDestinationsAllBenign(fileContent) {
|
|
299
|
+
const hosts = extractHostsFromContent(fileContent);
|
|
300
|
+
if (hosts.length === 0) return false;
|
|
301
|
+
for (const h of hosts) {
|
|
302
|
+
if (SUSPICIOUS_DOMAIN_PATTERNS.test(h)) return false;
|
|
303
|
+
if (isPublicIpHost(h)) return false;
|
|
304
|
+
if (isLocalOrReservedHost(h)) continue;
|
|
305
|
+
if (PROVIDER_DOMAIN_SUFFIXES.some(s => domainMatchesSuffix(h, [s]))) continue;
|
|
306
|
+
return false; // unknown / unrecognised destination → keep firing
|
|
307
|
+
}
|
|
308
|
+
return true;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
module.exports = {
|
|
312
|
+
SDK_ENV_DOMAIN_MAP,
|
|
313
|
+
ENV_NOISE_TOKENS,
|
|
314
|
+
SUSPICIOUS_DOMAIN_PATTERNS,
|
|
315
|
+
URL_EXTRACT_RE,
|
|
316
|
+
HOSTNAME_OPTION_RE,
|
|
317
|
+
PROVIDER_DOMAIN_SUFFIXES,
|
|
318
|
+
extractEnvVarFromMessage,
|
|
319
|
+
extractBrandFromEnvVar,
|
|
320
|
+
extractDomain,
|
|
321
|
+
domainMatchesSuffix,
|
|
322
|
+
isSDKPattern,
|
|
323
|
+
stripPort,
|
|
324
|
+
isLocalOrReservedHost,
|
|
325
|
+
isPublicIpHost,
|
|
326
|
+
extractHostsFromContent,
|
|
327
|
+
networkDestinationsAllBenign,
|
|
328
|
+
};
|