muaddib-scanner 2.10.39 → 2.10.40
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/package.json +1 -1
- package/scripts/ossf-benchmark.js +15 -3
- package/src/monitor/classify.js +1 -0
- package/src/response/playbooks.js +9 -0
- package/src/rules/index.js +23 -0
- package/src/sandbox/index.js +46 -17
- package/src/sandbox/network-allowlist.js +162 -0
package/package.json
CHANGED
|
@@ -33,7 +33,7 @@ const SCAN_TIMEOUT_MS = 30000;
|
|
|
33
33
|
const SAFE_PKG_RE = /^(@[\w._-]+\/)?[\w._-]+$/;
|
|
34
34
|
|
|
35
35
|
// --- CLI args ---
|
|
36
|
-
const SAMPLE_SIZE = parseInt(process.argv.find((a, i) => process.argv[i - 1] === '--sample') || '
|
|
36
|
+
const SAMPLE_SIZE = parseInt(process.argv.find((a, i) => process.argv[i - 1] === '--sample') || '5000', 10);
|
|
37
37
|
const SEED = parseInt(process.argv.find((a, i) => process.argv[i - 1] === '--seed') || '42', 10);
|
|
38
38
|
const REFRESH = process.argv.includes('--refresh');
|
|
39
39
|
|
|
@@ -220,9 +220,21 @@ function stratifySample(index, sampleSize, seed) {
|
|
|
220
220
|
const downloadable = index.filter(e => e.version !== '*' && SAFE_PKG_RE.test(e.name));
|
|
221
221
|
console.log(' Downloadable (non-wildcard, valid name): ' + downloadable.length);
|
|
222
222
|
|
|
223
|
+
// Filter out spam packages (SEO junk uploaded to npm — never available, waste time)
|
|
224
|
+
const SPAM_WORDS = /\b(watch|movie|free|generator|download|stream|online|full|episode|subtitle)\b/i;
|
|
225
|
+
const filtered = downloadable.filter(function(e) {
|
|
226
|
+
if (e.name.length > 100) return false;
|
|
227
|
+
if (/\s/.test(e.name)) return false;
|
|
228
|
+
if (SPAM_WORDS.test(e.name)) return false;
|
|
229
|
+
return true;
|
|
230
|
+
});
|
|
231
|
+
const spamRemoved = downloadable.length - filtered.length;
|
|
232
|
+
if (spamRemoved > 0) console.log(' Spam filtered: ' + spamRemoved + ' entries removed');
|
|
233
|
+
console.log(' After spam filter: ' + filtered.length);
|
|
234
|
+
|
|
223
235
|
// Group by source
|
|
224
236
|
const bySource = {};
|
|
225
|
-
for (const entry of
|
|
237
|
+
for (const entry of filtered) {
|
|
226
238
|
const src = entry.source || 'unknown';
|
|
227
239
|
if (!bySource[src]) bySource[src] = [];
|
|
228
240
|
bySource[src].push(entry);
|
|
@@ -243,7 +255,7 @@ function stratifySample(index, sampleSize, seed) {
|
|
|
243
255
|
|
|
244
256
|
// Proportional allocation per source
|
|
245
257
|
const sources = Object.keys(bySource);
|
|
246
|
-
const totalDownloadable =
|
|
258
|
+
const totalDownloadable = filtered.length;
|
|
247
259
|
const sample = [];
|
|
248
260
|
|
|
249
261
|
for (const src of sources) {
|
package/src/monitor/classify.js
CHANGED
|
@@ -49,6 +49,7 @@ const HIGH_CONFIDENCE_MALICE_TYPES = new Set([
|
|
|
49
49
|
'cross_file_dataflow', // proven taint cross-modules
|
|
50
50
|
'canary_exfiltration', // canary sandbox exfiltrated
|
|
51
51
|
'sandbox_network_after_sensitive_read', // compound sandbox detection
|
|
52
|
+
'sandbox_known_exfil_domain', // known exfil/C2 domain contacted during install
|
|
52
53
|
'detached_credential_exfil', // detached process + credential exfil (DPRK/Lazarus)
|
|
53
54
|
'node_modules_write', // writeFile to node_modules/ (worm propagation)
|
|
54
55
|
'npm_publish_worm', // exec("npm publish") (worm propagation)
|
|
@@ -243,6 +243,15 @@ const PLAYBOOKS = {
|
|
|
243
243
|
'Acces a des variables d\'environnement sensibles detecte via monkey-patching runtime (TOKEN, SECRET, KEY, PASSWORD). ' +
|
|
244
244
|
'Verifier si le package a une raison legitime d\'acceder a ces variables. Revoquer les credentials si necessaire.',
|
|
245
245
|
|
|
246
|
+
sandbox_network_outlier:
|
|
247
|
+
'Package contacte un domaine/IP hors allowlist pendant l\'installation. Seulement 0.027% des packages font du DNS hors infrastructure npm. ' +
|
|
248
|
+
'Verifier le domaine contacte. Si aucune raison legitime (CDN de binaires, service declare en dep), considerer comme suspect.',
|
|
249
|
+
|
|
250
|
+
sandbox_known_exfil_domain:
|
|
251
|
+
'CRITIQUE: Package contacte un domaine d\'exfiltration/C2 connu (OAST, webhook.site, infrastructure de campagne). ' +
|
|
252
|
+
'Taux de faux positif quasi-nul. Actions: 1. NE PAS installer. 2. Signaler au registry. ' +
|
|
253
|
+
'3. Si deja installe, isoler la machine et regenerer TOUS les secrets.',
|
|
254
|
+
|
|
246
255
|
high_entropy_string:
|
|
247
256
|
'Chaine a haute entropie detectee. Verifier si c\'est du base64, hex, ou un payload chiffre. Analyser le contexte d\'utilisation.',
|
|
248
257
|
js_obfuscation_pattern:
|
package/src/rules/index.js
CHANGED
|
@@ -1011,6 +1011,29 @@ const RULES = {
|
|
|
1011
1011
|
mitre: 'T1552.001'
|
|
1012
1012
|
},
|
|
1013
1013
|
|
|
1014
|
+
// Sandbox network outlier detections
|
|
1015
|
+
sandbox_network_outlier: {
|
|
1016
|
+
id: 'MUADDIB-SANDBOX-015',
|
|
1017
|
+
name: 'Sandbox: Network Outlier',
|
|
1018
|
+
severity: 'HIGH',
|
|
1019
|
+
confidence: 'medium',
|
|
1020
|
+
description: 'Package contacts a non-registry domain/IP during install. Only 0.027% of packages make DNS queries outside npm infrastructure — this is a high-precision outlier signal.',
|
|
1021
|
+
references: ['https://attack.mitre.org/techniques/T1071/001/'],
|
|
1022
|
+
mitre: 'T1071.001'
|
|
1023
|
+
},
|
|
1024
|
+
sandbox_known_exfil_domain: {
|
|
1025
|
+
id: 'MUADDIB-SANDBOX-016',
|
|
1026
|
+
name: 'Sandbox: Known Exfiltration Domain',
|
|
1027
|
+
severity: 'CRITICAL',
|
|
1028
|
+
confidence: 'high',
|
|
1029
|
+
description: 'Package contacts a known exfiltration/C2 domain during install (OAST, webhook sinks, campaign infrastructure). Near-zero false positive rate.',
|
|
1030
|
+
references: [
|
|
1031
|
+
'https://attack.mitre.org/techniques/T1041/',
|
|
1032
|
+
'https://attack.mitre.org/techniques/T1071/001/'
|
|
1033
|
+
],
|
|
1034
|
+
mitre: 'T1041'
|
|
1035
|
+
},
|
|
1036
|
+
|
|
1014
1037
|
// Entropy detections
|
|
1015
1038
|
high_entropy_string: {
|
|
1016
1039
|
id: 'MUADDIB-ENTROPY-001',
|
package/src/sandbox/index.js
CHANGED
|
@@ -15,6 +15,7 @@ const {
|
|
|
15
15
|
|
|
16
16
|
const { NPM_PACKAGE_REGEX } = require('../shared/constants.js');
|
|
17
17
|
const { analyzePreloadLog } = require('./analyzer.js');
|
|
18
|
+
const { classifyDomain } = require('./network-allowlist.js');
|
|
18
19
|
|
|
19
20
|
const DOCKER_IMAGE = 'muaddib-sandbox';
|
|
20
21
|
const CONTAINER_TIMEOUT = 120000; // 120 seconds
|
|
@@ -646,34 +647,56 @@ function scoreFindings(report) {
|
|
|
646
647
|
}
|
|
647
648
|
}
|
|
648
649
|
|
|
649
|
-
// 4a. DNS queries
|
|
650
|
+
// 4a. DNS queries — classify via network allowlist
|
|
650
651
|
for (const domain of (report.network?.dns_queries || [])) {
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
652
|
+
const cls = classifyDomain(domain);
|
|
653
|
+
if (cls === 'safe') continue;
|
|
654
|
+
if (cls === 'blacklisted') {
|
|
655
|
+
score += 50;
|
|
656
|
+
findings.push({ type: 'sandbox_known_exfil_domain', severity: 'CRITICAL', detail: `DNS query to known exfiltration domain: ${domain}`, evidence: domain });
|
|
657
|
+
} else if (cls === 'tunnel') {
|
|
658
|
+
score += 30;
|
|
659
|
+
findings.push({ type: 'sandbox_network_outlier', severity: 'HIGH', detail: `DNS query to tunnel/proxy domain: ${domain}`, evidence: domain });
|
|
660
|
+
} else {
|
|
661
|
+
score += 20;
|
|
662
|
+
findings.push({ type: 'sandbox_network_outlier', severity: 'HIGH', detail: `DNS query to non-registry domain: ${domain}`, evidence: domain });
|
|
663
|
+
}
|
|
654
664
|
}
|
|
655
665
|
|
|
656
666
|
// 4b. DNS resolutions — extra detail
|
|
657
667
|
for (const res of (report.network?.dns_resolutions || [])) {
|
|
658
|
-
|
|
668
|
+
const cls = classifyDomain(res.domain);
|
|
669
|
+
if (cls === 'safe') continue;
|
|
659
670
|
// Already scored in 4a via dns_queries, but flag the resolution for reporting
|
|
660
671
|
findings.push({ type: 'dns_resolution', severity: 'INFO', detail: `${res.domain} → ${res.ip}`, evidence: `${res.domain}:${res.ip}` });
|
|
661
672
|
}
|
|
662
673
|
|
|
663
|
-
// 5a. TCP connections
|
|
674
|
+
// 5a. TCP connections — classify via network allowlist
|
|
664
675
|
for (const conn of (report.network?.http_connections || [])) {
|
|
665
|
-
if (isSafeHost(conn.host)) continue;
|
|
666
676
|
if (SAFE_IPS.includes(conn.host)) continue;
|
|
667
677
|
if (PROBE_PORTS.includes(conn.port)) continue;
|
|
668
|
-
|
|
669
|
-
|
|
678
|
+
const cls = classifyDomain(conn.host);
|
|
679
|
+
if (cls === 'safe') continue;
|
|
680
|
+
if (cls === 'blacklisted') {
|
|
681
|
+
score += 50;
|
|
682
|
+
findings.push({ type: 'sandbox_known_exfil_domain', severity: 'CRITICAL', detail: `TCP connection to known exfiltration host: ${conn.host}:${conn.port}`, evidence: `${conn.host}:${conn.port}` });
|
|
683
|
+
} else {
|
|
684
|
+
score += 25;
|
|
685
|
+
findings.push({ type: 'suspicious_connection', severity: 'HIGH', detail: `TCP connection to ${conn.host}:${conn.port}`, evidence: `${conn.host}:${conn.port}` });
|
|
686
|
+
}
|
|
670
687
|
}
|
|
671
688
|
|
|
672
|
-
// 5b. TLS connections —
|
|
689
|
+
// 5b. TLS connections — classify via network allowlist
|
|
673
690
|
for (const tls of (report.network?.tls_connections || [])) {
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
691
|
+
const cls = classifyDomain(tls.domain);
|
|
692
|
+
if (cls === 'safe') continue;
|
|
693
|
+
if (cls === 'blacklisted') {
|
|
694
|
+
score += 50;
|
|
695
|
+
findings.push({ type: 'sandbox_known_exfil_domain', severity: 'CRITICAL', detail: `TLS to known exfiltration domain: ${tls.domain} (${tls.ip}:${tls.port})`, evidence: tls.domain });
|
|
696
|
+
} else {
|
|
697
|
+
score += 20;
|
|
698
|
+
findings.push({ type: 'suspicious_tls', severity: 'HIGH', detail: `TLS connection to ${tls.domain} (${tls.ip}:${tls.port})`, evidence: tls.domain });
|
|
699
|
+
}
|
|
677
700
|
}
|
|
678
701
|
|
|
679
702
|
// 5c. HTTP exfiltration detection — scan body snippets for sensitive data
|
|
@@ -692,11 +715,17 @@ function scoreFindings(report) {
|
|
|
692
715
|
}
|
|
693
716
|
}
|
|
694
717
|
|
|
695
|
-
// 5d. HTTP requests
|
|
718
|
+
// 5d. HTTP requests — classify via network allowlist
|
|
696
719
|
for (const req of (report.network?.http_requests || [])) {
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
720
|
+
const cls = classifyDomain(req.host);
|
|
721
|
+
if (cls === 'safe') continue;
|
|
722
|
+
if (cls === 'blacklisted') {
|
|
723
|
+
score += 50;
|
|
724
|
+
findings.push({ type: 'sandbox_known_exfil_domain', severity: 'CRITICAL', detail: `HTTP request to known exfiltration host: ${req.method} ${req.host}${req.path}`, evidence: `${req.method} ${req.host}${req.path}` });
|
|
725
|
+
} else {
|
|
726
|
+
score += 20;
|
|
727
|
+
findings.push({ type: 'suspicious_http_request', severity: 'HIGH', detail: `${req.method} ${req.host}${req.path}`, evidence: `${req.method} ${req.host}${req.path}` });
|
|
728
|
+
}
|
|
700
729
|
}
|
|
701
730
|
|
|
702
731
|
// 5e. Blocked connections (strict mode)
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// ── Network Allowlist & Blacklist for Sandbox Analysis ──
|
|
4
|
+
//
|
|
5
|
+
// Classifies domains/IPs contacted during npm install into three categories:
|
|
6
|
+
// - safe: legitimate install-time traffic (registries, CDNs, GitHub)
|
|
7
|
+
// - blacklisted: known exfiltration/C2 infrastructure (OAST, webhook sinks, campaign IPs)
|
|
8
|
+
// - unknown: everything else — potential outlier requiring investigation
|
|
9
|
+
//
|
|
10
|
+
// Threat model: SafeDep found only 0.027% of 3M+ packages make DNS queries to
|
|
11
|
+
// non-npm domains during install. Network outliers are the highest-precision
|
|
12
|
+
// signal available for detecting supply chain attacks at install time.
|
|
13
|
+
|
|
14
|
+
// ── Safe domains: legitimate traffic during npm install ──
|
|
15
|
+
// These domains are expected during normal package installation.
|
|
16
|
+
// Subdomains are matched (e.g., foo.github.com matches github.com).
|
|
17
|
+
const SAFE_INSTALL_DOMAINS = [
|
|
18
|
+
// npm registry
|
|
19
|
+
'registry.npmjs.org',
|
|
20
|
+
'npmjs.com',
|
|
21
|
+
'npmjs.org',
|
|
22
|
+
// yarn registry
|
|
23
|
+
'registry.yarnpkg.com',
|
|
24
|
+
'yarnpkg.com',
|
|
25
|
+
// GitHub (source tarballs, git deps)
|
|
26
|
+
'github.com',
|
|
27
|
+
'api.github.com',
|
|
28
|
+
'objects.githubusercontent.com',
|
|
29
|
+
'raw.githubusercontent.com',
|
|
30
|
+
'codeload.github.com',
|
|
31
|
+
'github.githubassets.com',
|
|
32
|
+
// CDNs (native binary downloads via node-gyp, prebuild)
|
|
33
|
+
'cdn.jsdelivr.net',
|
|
34
|
+
'unpkg.com',
|
|
35
|
+
'cdnjs.cloudflare.com',
|
|
36
|
+
'cloudflare.com',
|
|
37
|
+
// AWS S3 (prebuild binaries: sharp, canvas, sqlite3, etc.)
|
|
38
|
+
'amazonaws.com',
|
|
39
|
+
// Google (googleapis client, protobuf downloads)
|
|
40
|
+
'googleapis.com',
|
|
41
|
+
'storage.googleapis.com',
|
|
42
|
+
// Node.js (node-gyp headers)
|
|
43
|
+
'nodejs.org',
|
|
44
|
+
// GitLab (git deps)
|
|
45
|
+
'gitlab.com',
|
|
46
|
+
// Bitbucket (git deps)
|
|
47
|
+
'bitbucket.org'
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
// ── Known exfiltration / C2 domains ──
|
|
51
|
+
// Any contact during install is near-certain malicious (quasi-zero FP).
|
|
52
|
+
// Sources: OAST tooling, known campaign C2, webhook sink services.
|
|
53
|
+
const KNOWN_EXFIL_DOMAINS = [
|
|
54
|
+
// OAST / Interactsh / BurpSuite
|
|
55
|
+
'oastify.com',
|
|
56
|
+
'oast.fun',
|
|
57
|
+
'oast.me',
|
|
58
|
+
'oast.live',
|
|
59
|
+
'oast.online',
|
|
60
|
+
'oast.site',
|
|
61
|
+
'burpcollaborator.net',
|
|
62
|
+
'interact.sh',
|
|
63
|
+
// Webhook sink services
|
|
64
|
+
'webhook.site',
|
|
65
|
+
'pipedream.net',
|
|
66
|
+
'requestbin.com',
|
|
67
|
+
'hookbin.com',
|
|
68
|
+
'canarytokens.com',
|
|
69
|
+
// GlassWorm C2 IPs (mars 2026, 433+ packages)
|
|
70
|
+
'217.69.3.218',
|
|
71
|
+
'217.69.3.152',
|
|
72
|
+
'199.247.10.166',
|
|
73
|
+
'199.247.13.106',
|
|
74
|
+
'140.82.52.31',
|
|
75
|
+
'45.32.150.251',
|
|
76
|
+
// TeamPCP / CanisterWorm C2 (mars 2026)
|
|
77
|
+
'icp0.io',
|
|
78
|
+
'raw.icp0.io',
|
|
79
|
+
'ic0.app',
|
|
80
|
+
'hackmoltrepeat.com',
|
|
81
|
+
'recv.hackmoltrepeat.com',
|
|
82
|
+
'scan.aquasecurtiy.org', // Trivy exfil C2 (typosquat of aquasecurity)
|
|
83
|
+
'api.telegram.org', // Telegram bot exfiltration
|
|
84
|
+
'checkmarx.zone',
|
|
85
|
+
'45.148.10.212',
|
|
86
|
+
'83.142.209.11'
|
|
87
|
+
];
|
|
88
|
+
|
|
89
|
+
// ── Regex patterns for wildcard exfil domains ──
|
|
90
|
+
// Matches subdomains of OAST/exfil infrastructure.
|
|
91
|
+
const KNOWN_EXFIL_PATTERNS = [
|
|
92
|
+
/\.oast\.(online|site|live|fun|me)$/i,
|
|
93
|
+
/\.oastify\.com$/i,
|
|
94
|
+
/\.burpcollaborator\.net$/i,
|
|
95
|
+
/\.interact\.sh$/i,
|
|
96
|
+
/\.webhook\.site$/i,
|
|
97
|
+
/\.pipedream\.net$/i,
|
|
98
|
+
/\.requestbin\.com$/i
|
|
99
|
+
];
|
|
100
|
+
|
|
101
|
+
// ── Suspicious tunnel/proxy domains (not blacklisted, but escalate unknown → suspicious) ──
|
|
102
|
+
const TUNNEL_DOMAINS = [
|
|
103
|
+
'ngrok.io',
|
|
104
|
+
'ngrok-free.app',
|
|
105
|
+
'serveo.net',
|
|
106
|
+
'localhost.run',
|
|
107
|
+
'loca.lt',
|
|
108
|
+
'trycloudflare.com'
|
|
109
|
+
];
|
|
110
|
+
|
|
111
|
+
// Parse MUADDIB_SANDBOX_NETWORK_ALLOWLIST env var (comma-separated domains)
|
|
112
|
+
function getCustomAllowlist() {
|
|
113
|
+
const envVal = process.env.MUADDIB_SANDBOX_NETWORK_ALLOWLIST;
|
|
114
|
+
if (!envVal) return [];
|
|
115
|
+
return envVal.split(',')
|
|
116
|
+
.map(d => d.trim().toLowerCase())
|
|
117
|
+
.filter(d => d.length > 0 && d.length < 256);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Classify a domain/IP contacted during sandbox install.
|
|
122
|
+
*
|
|
123
|
+
* @param {string} domain - Domain name or IP address
|
|
124
|
+
* @returns {'safe'|'blacklisted'|'tunnel'|'unknown'} classification
|
|
125
|
+
*/
|
|
126
|
+
function classifyDomain(domain) {
|
|
127
|
+
if (!domain || typeof domain !== 'string') return 'unknown';
|
|
128
|
+
const d = domain.toLowerCase().trim();
|
|
129
|
+
if (d.length === 0) return 'unknown';
|
|
130
|
+
|
|
131
|
+
// Check safe domains (exact or subdomain match)
|
|
132
|
+
const allSafe = SAFE_INSTALL_DOMAINS.concat(getCustomAllowlist());
|
|
133
|
+
for (const safe of allSafe) {
|
|
134
|
+
if (d === safe || d.endsWith('.' + safe)) return 'safe';
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Check blacklisted domains (exact match)
|
|
138
|
+
for (const exfil of KNOWN_EXFIL_DOMAINS) {
|
|
139
|
+
if (d === exfil || d.endsWith('.' + exfil)) return 'blacklisted';
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Check blacklisted patterns (regex — catches subdomains like abc123.oast.online)
|
|
143
|
+
for (const pat of KNOWN_EXFIL_PATTERNS) {
|
|
144
|
+
if (pat.test(d)) return 'blacklisted';
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Check tunnel domains
|
|
148
|
+
for (const tunnel of TUNNEL_DOMAINS) {
|
|
149
|
+
if (d === tunnel || d.endsWith('.' + tunnel)) return 'tunnel';
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return 'unknown';
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
module.exports = {
|
|
156
|
+
SAFE_INSTALL_DOMAINS,
|
|
157
|
+
KNOWN_EXFIL_DOMAINS,
|
|
158
|
+
KNOWN_EXFIL_PATTERNS,
|
|
159
|
+
TUNNEL_DOMAINS,
|
|
160
|
+
classifyDomain,
|
|
161
|
+
getCustomAllowlist
|
|
162
|
+
};
|