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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "muaddib-scanner",
3
- "version": "2.10.39",
3
+ "version": "2.10.40",
4
4
  "description": "Supply-chain threat detection & response for npm & PyPI/Python",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -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') || '500', 10);
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 downloadable) {
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 = downloadable.length;
258
+ const totalDownloadable = filtered.length;
247
259
  const sample = [];
248
260
 
249
261
  for (const src of sources) {
@@ -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:
@@ -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',
@@ -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 (exclude safe domains)
650
+ // 4a. DNS queries classify via network allowlist
650
651
  for (const domain of (report.network?.dns_queries || [])) {
651
- if (isSafeDomain(domain)) continue;
652
- score += 20;
653
- findings.push({ type: 'suspicious_dns', severity: 'HIGH', detail: `DNS query to non-registry domain: ${domain}`, evidence: domain });
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
- if (isSafeDomain(res.domain)) continue;
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 (exclude safe hosts, probe ports, localhost)
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
- score += 25;
669
- findings.push({ type: 'suspicious_connection', severity: 'HIGH', detail: `TCP connection to ${conn.host}:${conn.port}`, evidence: `${conn.host}:${conn.port}` });
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 — non-safe domains
689
+ // 5b. TLS connections — classify via network allowlist
673
690
  for (const tls of (report.network?.tls_connections || [])) {
674
- if (isSafeDomain(tls.domain)) continue;
675
- score += 20;
676
- findings.push({ type: 'suspicious_tls', severity: 'HIGH', detail: `TLS connection to ${tls.domain} (${tls.ip}:${tls.port})`, evidence: tls.domain });
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 to non-safe hosts
718
+ // 5d. HTTP requests classify via network allowlist
696
719
  for (const req of (report.network?.http_requests || [])) {
697
- if (isSafeDomain(req.host)) continue;
698
- score += 20;
699
- findings.push({ type: 'suspicious_http_request', severity: 'HIGH', detail: `${req.method} ${req.host}${req.path}`, evidence: `${req.method} ${req.host}${req.path}` });
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
+ };