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.
- package/.gitattributes +18 -0
- package/README.fr.md +819 -825
- package/README.md +822 -822
- package/bin/muaddib.js +868 -849
- package/docker/Dockerfile +19 -18
- package/docker/sandbox-runner.sh +313 -292
- package/package.json +61 -61
- package/src/commands/evaluate.js +484 -484
- package/src/diff.js +411 -411
- package/src/hooks-init.js +264 -258
- package/src/index.js +439 -437
- package/src/ioc/scraper.js +1293 -1293
- package/src/monitor.js +1817 -1764
- package/src/sandbox.js +631 -620
- package/src/scanner/ast-detectors.js +946 -933
- package/src/scanner/ast.js +96 -96
- package/src/scanner/github-actions.js +96 -96
- package/src/scanner/module-graph.js +2 -2
- package/src/scanner/obfuscation.js +128 -128
- package/src/scanner/shell.js +48 -48
- package/src/shared/download.js +181 -171
- package/src/webhook.js +413 -353
package/src/scanner/shell.js
CHANGED
|
@@ -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(
|
|
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 };
|
package/src/shared/download.js
CHANGED
|
@@ -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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
return
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
+
};
|