muaddib-scanner 2.2.19 → 2.2.22

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.
@@ -1,49 +1,49 @@
1
- const fs = require('fs');
2
- const path = require('path');
3
- const { findFiles, forEachSafeFile } = require('../utils.js');
4
-
5
- const SHELL_EXCLUDED_DIRS = ['node_modules', '.git', '.muaddib-cache'];
6
-
7
- const MALICIOUS_PATTERNS = [
8
- { pattern: /curl.*\|.*sh/m, name: 'curl_pipe_shell', severity: 'HIGH' },
9
- { pattern: /wget.*&&.*chmod.*\+x/m, name: 'wget_chmod_exec', severity: 'HIGH' },
10
- { pattern: /bash\s+-i\s+>&\s+\/dev\/tcp/m, name: 'reverse_shell', severity: 'CRITICAL' },
11
- { pattern: /nc\s+-e\s+\/bin\/(ba)?sh/m, name: 'netcat_shell', severity: 'CRITICAL' },
12
- { pattern: /rm\s+-rf\s+(~\/|\$HOME|\/home)/m, name: 'home_deletion', severity: 'CRITICAL' },
13
- { pattern: /shred.*\$HOME/m, name: 'shred_home', severity: 'CRITICAL' },
14
- { pattern: /curl.*-X\s*POST.*-d/m, name: 'curl_exfiltration', severity: 'HIGH' },
15
- { pattern: /(?:cat|readFile|cp|mv|curl\s+file:\/\/|tar\s+.*|scp\s+).*\.npmrc/m, name: 'npmrc_access', severity: 'HIGH' },
16
- { pattern: /(?:cat|readFile|cp|mv|curl\s+file:\/\/|tar\s+.*|scp\s+).*\.ssh/m, name: 'ssh_access', severity: 'HIGH' },
17
- { pattern: /python\s+-c.*import\s+socket/m, name: 'python_reverse_shell', severity: 'CRITICAL' },
18
- { pattern: /perl\s+-e.*socket/m, name: 'perl_reverse_shell', severity: 'CRITICAL' },
19
- { pattern: /mkfifo.*\/dev\/tcp/m, name: 'fifo_reverse_shell', severity: 'CRITICAL' }
20
- ];
21
-
22
- async function scanShellScripts(targetPath) {
23
- const threats = [];
24
-
25
- // Cherche les fichiers shell
26
- const files = findFiles(targetPath, { extensions: ['.sh', '.bash', '.zsh', '.command'], excludedDirs: SHELL_EXCLUDED_DIRS });
27
-
28
- forEachSafeFile(files, (file, content) => {
29
- // Strip comment lines to avoid false positives on documentation
30
- const activeContent = content.split('\n')
31
- .filter(line => !line.trimStart().startsWith('#'))
32
- .join('\n');
33
-
34
- for (const { pattern, name, severity } of MALICIOUS_PATTERNS) {
35
- if (pattern.test(activeContent)) {
36
- threats.push({
37
- type: name,
38
- severity: severity,
39
- message: `Pattern malveillant "${name}" detecte.`,
40
- file: path.relative(targetPath, file)
41
- });
42
- }
43
- }
44
- });
45
-
46
- return threats;
47
- }
48
-
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const { findFiles, forEachSafeFile } = require('../utils.js');
4
+
5
+ const SHELL_EXCLUDED_DIRS = ['node_modules', '.git', '.muaddib-cache'];
6
+
7
+ const MALICIOUS_PATTERNS = [
8
+ { pattern: /curl.*\|.*sh/m, name: 'curl_pipe_shell', severity: 'HIGH' },
9
+ { pattern: /wget.*&&.*chmod.*\+x/m, name: 'wget_chmod_exec', severity: 'HIGH' },
10
+ { pattern: /bash\s+-i\s+>&\s+\/dev\/tcp/m, name: 'reverse_shell', severity: 'CRITICAL' },
11
+ { pattern: /nc\s+-e\s+\/bin\/(ba)?sh/m, name: 'netcat_shell', severity: 'CRITICAL' },
12
+ { pattern: /rm\s+-rf\s+(~\/|\$HOME|\/home)/m, name: 'home_deletion', severity: 'CRITICAL' },
13
+ { pattern: /shred.*\$HOME/m, name: 'shred_home', severity: 'CRITICAL' },
14
+ { pattern: /curl.*-X\s*POST.*-d/m, name: 'curl_exfiltration', severity: 'HIGH' },
15
+ { pattern: /(?:cat|readFile|cp|mv|curl\s+file:\/\/|tar\s+.*|scp\s+).*\.npmrc/m, name: 'npmrc_access', severity: 'HIGH' },
16
+ { pattern: /(?:cat|readFile|cp|mv|curl\s+file:\/\/|tar\s+.*|scp\s+).*\.ssh/m, name: 'ssh_access', severity: 'HIGH' },
17
+ { pattern: /python\s+-c.*import\s+socket/m, name: 'python_reverse_shell', severity: 'CRITICAL' },
18
+ { pattern: /perl\s+-e.*socket/m, name: 'perl_reverse_shell', severity: 'CRITICAL' },
19
+ { pattern: /mkfifo.*\/dev\/tcp/m, name: 'fifo_reverse_shell', severity: 'CRITICAL' }
20
+ ];
21
+
22
+ async function scanShellScripts(targetPath) {
23
+ const threats = [];
24
+
25
+ // Cherche les fichiers shell
26
+ const files = findFiles(targetPath, { extensions: ['.sh', '.bash', '.zsh', '.command'], excludedDirs: SHELL_EXCLUDED_DIRS });
27
+
28
+ forEachSafeFile(files, (file, content) => {
29
+ // Strip comment lines to avoid false positives on documentation
30
+ const activeContent = content.split(/\r?\n/)
31
+ .filter(line => !line.trimStart().startsWith('#'))
32
+ .join('\n');
33
+
34
+ for (const { pattern, name, severity } of MALICIOUS_PATTERNS) {
35
+ if (pattern.test(activeContent)) {
36
+ threats.push({
37
+ type: name,
38
+ severity: severity,
39
+ message: `Pattern malveillant "${name}" detecte.`,
40
+ file: path.relative(targetPath, file)
41
+ });
42
+ }
43
+ }
44
+ });
45
+
46
+ return threats;
47
+ }
48
+
49
49
  module.exports = { scanShellScripts };
@@ -1,171 +1,181 @@
1
- const https = require('https');
2
- const fs = require('fs');
3
- const path = require('path');
4
- const { execFileSync } = require('child_process');
5
- const { MAX_TARBALL_SIZE, DOWNLOAD_TIMEOUT } = require('./constants.js');
6
-
7
- // Allowed redirect domains for tarball downloads (SSRF protection)
8
- const ALLOWED_DOWNLOAD_DOMAINS = [
9
- 'registry.npmjs.org',
10
- 'registry.yarnpkg.com',
11
- 'pypi.org',
12
- 'files.pythonhosted.org'
13
- ];
14
-
15
- // Private IP ranges — block redirects to internal networks
16
- const PRIVATE_IP_PATTERNS = [
17
- /^127\./,
18
- /^10\./,
19
- /^172\.(1[6-9]|2[0-9]|3[0-1])\./,
20
- /^192\.168\./,
21
- /^0\./,
22
- /^169\.254\./,
23
- /^::1$/,
24
- /^::ffff:127\./,
25
- /^fc00:/,
26
- /^fe80:/
27
- ];
28
-
29
- /**
30
- * Validates that a redirect URL is allowed (SSRF protection).
31
- * Only HTTPS to whitelisted domains is permitted.
32
- * @param {string} redirectUrl - The redirect target URL
33
- * @returns {{allowed: boolean, error?: string}}
34
- */
35
- function isAllowedDownloadRedirect(redirectUrl) {
36
- try {
37
- const urlObj = new URL(redirectUrl);
38
- if (urlObj.protocol !== 'https:') {
39
- return { allowed: false, error: `Redirect blocked: non-HTTPS protocol ${urlObj.protocol}` };
40
- }
41
- const hostname = urlObj.hostname.toLowerCase();
42
- // Block private IP addresses
43
- if (PRIVATE_IP_PATTERNS.some(p => p.test(hostname))) {
44
- return { allowed: false, error: `Redirect blocked: private IP ${hostname}` };
45
- }
46
- const domainAllowed = ALLOWED_DOWNLOAD_DOMAINS.some(domain =>
47
- hostname === domain || hostname.endsWith('.' + domain)
48
- );
49
- if (!domainAllowed) {
50
- return { allowed: false, error: `Redirect blocked: domain ${hostname} not in allowlist` };
51
- }
52
- return { allowed: true };
53
- } catch {
54
- return { allowed: false, error: `Redirect blocked: invalid URL ${redirectUrl}` };
55
- }
56
- }
57
-
58
- /**
59
- * Download a file from HTTPS URL to disk, with SSRF-safe redirect handling.
60
- * @param {string} url - Source URL (must be HTTPS)
61
- * @param {string} destPath - Local file path to write to
62
- * @param {number} [timeoutMs] - Download timeout in ms (default: DOWNLOAD_TIMEOUT)
63
- * @returns {Promise<number>} Number of bytes downloaded
64
- */
65
- function downloadToFile(url, destPath, timeoutMs = DOWNLOAD_TIMEOUT) {
66
- return new Promise((resolve, reject) => {
67
- const doRequest = (requestUrl) => {
68
- const req = https.get(requestUrl, { timeout: timeoutMs }, (res) => {
69
- if (res.statusCode === 301 || res.statusCode === 302) {
70
- res.resume();
71
- const location = res.headers.location;
72
- if (!location) return reject(new Error(`Redirect without Location for ${requestUrl}`));
73
- // Resolve relative redirects against the request URL
74
- const absoluteLocation = new URL(location, requestUrl).href;
75
- const check = isAllowedDownloadRedirect(absoluteLocation);
76
- if (!check.allowed) {
77
- return reject(new Error(check.error));
78
- }
79
- return doRequest(absoluteLocation);
80
- }
81
- if (res.statusCode < 200 || res.statusCode >= 300) {
82
- res.resume();
83
- return reject(new Error(`HTTP ${res.statusCode} for ${requestUrl}`));
84
- }
85
- const contentLength = parseInt(res.headers['content-length'], 10);
86
- if (contentLength && contentLength > MAX_TARBALL_SIZE) {
87
- res.resume();
88
- return reject(new Error(`Package too large: ${contentLength} bytes (max ${MAX_TARBALL_SIZE})`));
89
- }
90
- const fileStream = fs.createWriteStream(destPath);
91
- let downloadedBytes = 0;
92
- res.on('data', (chunk) => {
93
- downloadedBytes += chunk.length;
94
- if (downloadedBytes > MAX_TARBALL_SIZE) {
95
- res.destroy();
96
- fileStream.destroy();
97
- try { fs.unlinkSync(destPath); } catch {}
98
- reject(new Error(`Package too large: ${downloadedBytes}+ bytes (max ${MAX_TARBALL_SIZE})`));
99
- }
100
- });
101
- res.pipe(fileStream);
102
- fileStream.on('finish', () => resolve(downloadedBytes));
103
- fileStream.on('error', (err) => {
104
- try { fs.unlinkSync(destPath); } catch {}
105
- reject(err);
106
- });
107
- res.on('error', (err) => {
108
- fileStream.destroy();
109
- try { fs.unlinkSync(destPath); } catch {}
110
- reject(err);
111
- });
112
- });
113
- req.on('error', reject);
114
- req.on('timeout', () => {
115
- req.destroy();
116
- reject(new Error(`Timeout downloading ${requestUrl}`));
117
- });
118
- };
119
- doRequest(url);
120
- });
121
- }
122
-
123
- /**
124
- * Extract a .tar.gz to a directory. Returns the package root.
125
- * Uses execFileSync (no shell) to prevent command injection.
126
- * @param {string} tgzPath - Path to the .tar.gz file
127
- * @param {string} destDir - Destination directory
128
- * @returns {string} Path to extracted package root
129
- */
130
- function extractTarGz(tgzPath, destDir) {
131
- // Use cwd + relative paths so C: never appears in tar arguments
132
- // (GNU tar treats C: as remote host, bsdtar doesn't support --force-local)
133
- const tgzDir = path.dirname(path.resolve(tgzPath));
134
- const tgzName = path.basename(tgzPath);
135
- const relDest = path.relative(tgzDir, path.resolve(destDir)) || '.';
136
- execFileSync('tar', ['xzf', tgzName, '-C', relDest], { cwd: tgzDir, timeout: 60_000, stdio: 'pipe' });
137
- // npm tarballs extract into a package/ subdirectory; detect it
138
- const packageSubdir = path.join(destDir, 'package');
139
- if (fs.existsSync(packageSubdir) && fs.statSync(packageSubdir).isDirectory()) {
140
- return packageSubdir;
141
- }
142
- // Otherwise return destDir itself (PyPI sdists vary)
143
- const entries = fs.readdirSync(destDir);
144
- if (entries.length === 1) {
145
- const single = path.join(destDir, entries[0]);
146
- if (fs.statSync(single).isDirectory()) return single;
147
- }
148
- return destDir;
149
- }
150
-
151
- /**
152
- * Sanitize a package name for use in temporary directory names.
153
- * Removes path traversal sequences, slashes, and @ symbols.
154
- * @param {string} packageName - Raw package name
155
- * @returns {string} Safe string for directory names
156
- */
157
- function sanitizePackageName(packageName) {
158
- return packageName
159
- .replace(/\.\./g, '')
160
- .replace(/\//g, '_')
161
- .replace(/@/g, '');
162
- }
163
-
164
- module.exports = {
165
- downloadToFile,
166
- extractTarGz,
167
- sanitizePackageName,
168
- isAllowedDownloadRedirect,
169
- ALLOWED_DOWNLOAD_DOMAINS,
170
- PRIVATE_IP_PATTERNS
171
- };
1
+ const https = require('https');
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+ const { execFileSync } = require('child_process');
5
+ const { MAX_TARBALL_SIZE, DOWNLOAD_TIMEOUT } = require('./constants.js');
6
+
7
+ // Allowed redirect domains for tarball downloads (SSRF protection)
8
+ const ALLOWED_DOWNLOAD_DOMAINS = [
9
+ 'registry.npmjs.org',
10
+ 'registry.yarnpkg.com',
11
+ 'pypi.org',
12
+ 'files.pythonhosted.org'
13
+ ];
14
+
15
+ // Private IP ranges — block redirects to internal networks
16
+ const PRIVATE_IP_PATTERNS = [
17
+ /^127\./,
18
+ /^10\./,
19
+ /^172\.(1[6-9]|2[0-9]|3[0-1])\./,
20
+ /^192\.168\./,
21
+ /^0\./,
22
+ /^169\.254\./,
23
+ /^::1$/,
24
+ /^::ffff:127\./,
25
+ /^fc00:/,
26
+ /^fe80:/
27
+ ];
28
+
29
+ /**
30
+ * Validates that a redirect URL is allowed (SSRF protection).
31
+ * Only HTTPS to whitelisted domains is permitted.
32
+ * @param {string} redirectUrl - The redirect target URL
33
+ * @returns {{allowed: boolean, error?: string}}
34
+ */
35
+ function isAllowedDownloadRedirect(redirectUrl) {
36
+ try {
37
+ const urlObj = new URL(redirectUrl);
38
+ if (urlObj.protocol !== 'https:') {
39
+ return { allowed: false, error: `Redirect blocked: non-HTTPS protocol ${urlObj.protocol}` };
40
+ }
41
+ const hostname = urlObj.hostname.toLowerCase();
42
+ // Block private IP addresses
43
+ if (PRIVATE_IP_PATTERNS.some(p => p.test(hostname))) {
44
+ return { allowed: false, error: `Redirect blocked: private IP ${hostname}` };
45
+ }
46
+ const domainAllowed = ALLOWED_DOWNLOAD_DOMAINS.some(domain =>
47
+ hostname === domain || hostname.endsWith('.' + domain)
48
+ );
49
+ if (!domainAllowed) {
50
+ return { allowed: false, error: `Redirect blocked: domain ${hostname} not in allowlist` };
51
+ }
52
+ return { allowed: true };
53
+ } catch {
54
+ return { allowed: false, error: `Redirect blocked: invalid URL ${redirectUrl}` };
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Download a file from HTTPS URL to disk, with SSRF-safe redirect handling.
60
+ * @param {string} url - Source URL (must be HTTPS)
61
+ * @param {string} destPath - Local file path to write to
62
+ * @param {number} [timeoutMs] - Download timeout in ms (default: DOWNLOAD_TIMEOUT)
63
+ * @returns {Promise<number>} Number of bytes downloaded
64
+ */
65
+ function downloadToFile(url, destPath, timeoutMs = DOWNLOAD_TIMEOUT) {
66
+ return new Promise((resolve, reject) => {
67
+ const doRequest = (requestUrl) => {
68
+ const req = https.get(requestUrl, { timeout: timeoutMs }, (res) => {
69
+ if (res.statusCode === 301 || res.statusCode === 302) {
70
+ res.resume();
71
+ const location = res.headers.location;
72
+ if (!location) return reject(new Error(`Redirect without Location for ${requestUrl}`));
73
+ // Resolve relative redirects against the request URL
74
+ const absoluteLocation = new URL(location, requestUrl).href;
75
+ const check = isAllowedDownloadRedirect(absoluteLocation);
76
+ if (!check.allowed) {
77
+ return reject(new Error(check.error));
78
+ }
79
+ return doRequest(absoluteLocation);
80
+ }
81
+ if (res.statusCode < 200 || res.statusCode >= 300) {
82
+ res.resume();
83
+ return reject(new Error(`HTTP ${res.statusCode} for ${requestUrl}`));
84
+ }
85
+ const contentLength = parseInt(res.headers['content-length'], 10);
86
+ if (contentLength && contentLength > MAX_TARBALL_SIZE) {
87
+ res.resume();
88
+ return reject(new Error(`Package too large: ${contentLength} bytes (max ${MAX_TARBALL_SIZE})`));
89
+ }
90
+ const fileStream = fs.createWriteStream(destPath);
91
+ let downloadedBytes = 0;
92
+ res.on('data', (chunk) => {
93
+ downloadedBytes += chunk.length;
94
+ if (downloadedBytes > MAX_TARBALL_SIZE) {
95
+ res.destroy();
96
+ fileStream.destroy();
97
+ try { fs.unlinkSync(destPath); } catch {}
98
+ reject(new Error(`Package too large: ${downloadedBytes}+ bytes (max ${MAX_TARBALL_SIZE})`));
99
+ }
100
+ });
101
+ res.pipe(fileStream);
102
+ fileStream.on('finish', () => resolve(downloadedBytes));
103
+ fileStream.on('error', (err) => {
104
+ try { fs.unlinkSync(destPath); } catch {}
105
+ reject(err);
106
+ });
107
+ res.on('error', (err) => {
108
+ fileStream.destroy();
109
+ try { fs.unlinkSync(destPath); } catch {}
110
+ reject(err);
111
+ });
112
+ });
113
+ req.on('error', reject);
114
+ req.on('timeout', () => {
115
+ req.destroy();
116
+ reject(new Error(`Timeout downloading ${requestUrl}`));
117
+ });
118
+ };
119
+ doRequest(url);
120
+ });
121
+ }
122
+
123
+ /**
124
+ * Extract a .tar.gz to a directory. Returns the package root.
125
+ * Uses execFileSync (no shell) to prevent command injection.
126
+ * @param {string} tgzPath - Path to the .tar.gz file
127
+ * @param {string} destDir - Destination directory
128
+ * @returns {string} Path to extracted package root
129
+ */
130
+ function extractTarGz(tgzPath, destDir) {
131
+ // Use cwd + relative paths so C: never appears in tar arguments
132
+ // (GNU tar treats C: as remote host, bsdtar doesn't support --force-local)
133
+ const tgzDir = path.dirname(path.resolve(tgzPath));
134
+ const tgzName = path.basename(tgzPath);
135
+ const relDest = path.relative(tgzDir, path.resolve(destDir)) || '.';
136
+ execFileSync('tar', ['xzf', tgzName, '-C', relDest], { cwd: tgzDir, timeout: 60_000, stdio: 'pipe' });
137
+ // npm tarballs extract into a package/ subdirectory; detect it
138
+ const packageSubdir = path.join(destDir, 'package');
139
+ try {
140
+ const stat = fs.lstatSync(packageSubdir);
141
+ if (!stat.isSymbolicLink() && stat.isDirectory()) {
142
+ return packageSubdir;
143
+ }
144
+ } catch {
145
+ // packageSubdir doesn't exist or is a broken symlink — continue
146
+ }
147
+ // Otherwise return destDir itself (PyPI sdists vary)
148
+ const entries = fs.readdirSync(destDir);
149
+ if (entries.length === 1) {
150
+ const single = path.join(destDir, entries[0]);
151
+ try {
152
+ const stat = fs.lstatSync(single);
153
+ if (!stat.isSymbolicLink() && stat.isDirectory()) return single;
154
+ } catch {
155
+ // broken symlink or permission denied skip
156
+ }
157
+ }
158
+ return destDir;
159
+ }
160
+
161
+ /**
162
+ * Sanitize a package name for use in temporary directory names.
163
+ * Removes path traversal sequences, slashes, and @ symbols.
164
+ * @param {string} packageName - Raw package name
165
+ * @returns {string} Safe string for directory names
166
+ */
167
+ function sanitizePackageName(packageName) {
168
+ return packageName
169
+ .replace(/\.\./g, '')
170
+ .replace(/\//g, '_')
171
+ .replace(/@/g, '');
172
+ }
173
+
174
+ module.exports = {
175
+ downloadToFile,
176
+ extractTarGz,
177
+ sanitizePackageName,
178
+ isAllowedDownloadRedirect,
179
+ ALLOWED_DOWNLOAD_DOMAINS,
180
+ PRIVATE_IP_PATTERNS
181
+ };