agent-security-scanner-mcp 4.3.0 → 4.4.1
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 +4 -1
- package/src/semantic-analyzer.js +1293 -0
- package/src/semantic-integration.js +301 -0
- package/src/tools/scan-project.js +28 -6
- package/src/utils/github-clone.js +227 -0
- package/src/utils/npm-download.js +265 -0
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* npm-download.js
|
|
5
|
+
*
|
|
6
|
+
* Safe npm package downloading WITHOUT code execution
|
|
7
|
+
*
|
|
8
|
+
* Security Features:
|
|
9
|
+
* - Uses npm pack instead of npm install (no lifecycle scripts)
|
|
10
|
+
* - Extracts tarball manually without running any code
|
|
11
|
+
* - Size limits to prevent tarball bombs
|
|
12
|
+
* - Path traversal protection
|
|
13
|
+
* - Timeout protection
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { exec } from 'child_process';
|
|
17
|
+
import { promisify } from 'util';
|
|
18
|
+
import fs from 'fs/promises';
|
|
19
|
+
import { createReadStream, existsSync } from 'fs';
|
|
20
|
+
import path from 'path';
|
|
21
|
+
import * as tar from 'tar';
|
|
22
|
+
|
|
23
|
+
const execAsync = promisify(exec);
|
|
24
|
+
|
|
25
|
+
const MAX_PACKAGE_SIZE_MB = 200; // 200MB limit
|
|
26
|
+
const PACK_TIMEOUT_MS = 60000; // 1 minute
|
|
27
|
+
const MAX_EXTRACTED_SIZE_MB = 500; // 500MB limit after extraction
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Download npm package tarball using npm pack (no scripts executed)
|
|
31
|
+
* @param {string} packageName - npm package name with optional version
|
|
32
|
+
* @param {string} destDir - Destination directory
|
|
33
|
+
* @param {object} options - Options
|
|
34
|
+
* @returns {Promise<{success: boolean, tarballPath?: string, error?: string}>}
|
|
35
|
+
*/
|
|
36
|
+
export async function downloadNpmPackage(packageName, destDir, options = {}) {
|
|
37
|
+
const {
|
|
38
|
+
timeout = PACK_TIMEOUT_MS,
|
|
39
|
+
} = options;
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
// Create destination directory
|
|
43
|
+
await fs.mkdir(destDir, { recursive: true });
|
|
44
|
+
|
|
45
|
+
// npm pack downloads the tarball WITHOUT running any scripts
|
|
46
|
+
const packCmd = `cd "${destDir}" && npm pack ${packageName} --dry-run=false 2>&1`;
|
|
47
|
+
|
|
48
|
+
const { stdout, stderr } = await execAsync(packCmd, {
|
|
49
|
+
timeout,
|
|
50
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// Find the downloaded tarball (npm pack outputs the filename)
|
|
54
|
+
const lines = stdout.split('\n');
|
|
55
|
+
const tarballLine = lines.find(line => line.endsWith('.tgz'));
|
|
56
|
+
|
|
57
|
+
if (!tarballLine) {
|
|
58
|
+
return {
|
|
59
|
+
success: false,
|
|
60
|
+
error: 'npm pack did not produce a tarball',
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const tarballPath = path.join(destDir, tarballLine.trim());
|
|
65
|
+
|
|
66
|
+
// Check tarball size
|
|
67
|
+
const stats = await fs.stat(tarballPath);
|
|
68
|
+
const sizeMB = stats.size / (1024 * 1024);
|
|
69
|
+
|
|
70
|
+
if (sizeMB > MAX_PACKAGE_SIZE_MB) {
|
|
71
|
+
await fs.unlink(tarballPath);
|
|
72
|
+
return {
|
|
73
|
+
success: false,
|
|
74
|
+
error: `Package too large: ${sizeMB.toFixed(1)}MB > ${MAX_PACKAGE_SIZE_MB}MB limit`,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
success: true,
|
|
80
|
+
tarballPath,
|
|
81
|
+
size: sizeMB,
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
} catch (error) {
|
|
85
|
+
return {
|
|
86
|
+
success: false,
|
|
87
|
+
error: error.message,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Extract npm tarball with security checks
|
|
94
|
+
* @param {string} tarballPath - Path to tarball
|
|
95
|
+
* @param {string} extractDir - Directory to extract to
|
|
96
|
+
* @returns {Promise<{success: boolean, path?: string, error?: string}>}
|
|
97
|
+
*/
|
|
98
|
+
export async function extractNpmTarball(tarballPath, extractDir) {
|
|
99
|
+
try {
|
|
100
|
+
// Create extraction directory
|
|
101
|
+
await fs.mkdir(extractDir, { recursive: true });
|
|
102
|
+
|
|
103
|
+
// Extract tarball with security options
|
|
104
|
+
await tar.x({
|
|
105
|
+
file: tarballPath,
|
|
106
|
+
cwd: extractDir,
|
|
107
|
+
strip: 1, // Remove 'package/' prefix from npm tarballs
|
|
108
|
+
|
|
109
|
+
// Security options
|
|
110
|
+
filter: (path, entry) => {
|
|
111
|
+
// Block path traversal attempts
|
|
112
|
+
if (path.includes('..')) {
|
|
113
|
+
console.warn(`Blocked path traversal attempt: ${path}`);
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Block absolute paths
|
|
118
|
+
if (path.startsWith('/')) {
|
|
119
|
+
console.warn(`Blocked absolute path: ${path}`);
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Block symlinks (potential security risk)
|
|
124
|
+
if (entry.type === 'SymbolicLink') {
|
|
125
|
+
console.warn(`Blocked symlink: ${path}`);
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Block files larger than 50MB individually
|
|
130
|
+
if (entry.size > 50 * 1024 * 1024) {
|
|
131
|
+
console.warn(`Blocked large file: ${path} (${entry.size} bytes)`);
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return true;
|
|
136
|
+
},
|
|
137
|
+
|
|
138
|
+
// Prevent archive bombs
|
|
139
|
+
maxReadSize: MAX_EXTRACTED_SIZE_MB * 1024 * 1024,
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// Check total extracted size
|
|
143
|
+
const sizeCmd = `du -sm "${extractDir}" | cut -f1`;
|
|
144
|
+
const { stdout } = await execAsync(sizeCmd);
|
|
145
|
+
const sizeMB = parseInt(stdout.trim(), 10);
|
|
146
|
+
|
|
147
|
+
if (sizeMB > MAX_EXTRACTED_SIZE_MB) {
|
|
148
|
+
// Delete oversized extraction
|
|
149
|
+
await fs.rm(extractDir, { recursive: true, force: true });
|
|
150
|
+
return {
|
|
151
|
+
success: false,
|
|
152
|
+
error: `Extracted size too large: ${sizeMB}MB > ${MAX_EXTRACTED_SIZE_MB}MB limit`,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
success: true,
|
|
158
|
+
path: extractDir,
|
|
159
|
+
size: sizeMB,
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
} catch (error) {
|
|
163
|
+
return {
|
|
164
|
+
success: false,
|
|
165
|
+
error: error.message,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Full workflow: download + extract + cleanup tarball
|
|
172
|
+
* @param {string} packageName - npm package name
|
|
173
|
+
* @param {string} workDir - Working directory
|
|
174
|
+
* @returns {Promise<{success: boolean, sourcePath?: string, error?: string}>}
|
|
175
|
+
*/
|
|
176
|
+
export async function downloadAndExtract(packageName, workDir) {
|
|
177
|
+
const tempDownloadDir = path.join(workDir, 'temp-downloads');
|
|
178
|
+
const extractDir = path.join(workDir, 'extracted-source');
|
|
179
|
+
|
|
180
|
+
try {
|
|
181
|
+
// Step 1: Download tarball
|
|
182
|
+
const downloadResult = await downloadNpmPackage(packageName, tempDownloadDir);
|
|
183
|
+
|
|
184
|
+
if (!downloadResult.success) {
|
|
185
|
+
return downloadResult;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Step 2: Extract tarball
|
|
189
|
+
const extractResult = await extractNpmTarball(downloadResult.tarballPath, extractDir);
|
|
190
|
+
|
|
191
|
+
// Step 3: Clean up tarball and download directory
|
|
192
|
+
await fs.rm(tempDownloadDir, { recursive: true, force: true });
|
|
193
|
+
|
|
194
|
+
if (!extractResult.success) {
|
|
195
|
+
// Clean up extracted files on failure
|
|
196
|
+
await fs.rm(extractDir, { recursive: true, force: true });
|
|
197
|
+
return extractResult;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return {
|
|
201
|
+
success: true,
|
|
202
|
+
sourcePath: extractDir,
|
|
203
|
+
packageSize: downloadResult.size,
|
|
204
|
+
extractedSize: extractResult.size,
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
} catch (error) {
|
|
208
|
+
// Clean up everything on error
|
|
209
|
+
await fs.rm(tempDownloadDir, { recursive: true, force: true }).catch(() => {});
|
|
210
|
+
await fs.rm(extractDir, { recursive: true, force: true }).catch(() => {});
|
|
211
|
+
|
|
212
|
+
return {
|
|
213
|
+
success: false,
|
|
214
|
+
error: error.message,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Check if an npm package exists (without downloading)
|
|
221
|
+
* @param {string} packageName - Package name to check
|
|
222
|
+
* @returns {Promise<boolean>}
|
|
223
|
+
*/
|
|
224
|
+
export async function npmPackageExists(packageName) {
|
|
225
|
+
try {
|
|
226
|
+
// Use npm view to check if package exists
|
|
227
|
+
const viewCmd = `npm view ${packageName} name 2>&1`;
|
|
228
|
+
const { stdout } = await execAsync(viewCmd, { timeout: 10000 });
|
|
229
|
+
|
|
230
|
+
return stdout.trim().length > 0;
|
|
231
|
+
} catch {
|
|
232
|
+
return false;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Get package.json metadata without downloading full package
|
|
238
|
+
* @param {string} packageName - Package name
|
|
239
|
+
* @returns {Promise<{success: boolean, metadata?: object, error?: string}>}
|
|
240
|
+
*/
|
|
241
|
+
export async function getPackageMetadata(packageName) {
|
|
242
|
+
try {
|
|
243
|
+
const viewCmd = `npm view ${packageName} --json`;
|
|
244
|
+
const { stdout } = await execAsync(viewCmd, { timeout: 10000 });
|
|
245
|
+
|
|
246
|
+
const metadata = JSON.parse(stdout);
|
|
247
|
+
|
|
248
|
+
return {
|
|
249
|
+
success: true,
|
|
250
|
+
metadata: {
|
|
251
|
+
name: metadata.name,
|
|
252
|
+
version: metadata.version,
|
|
253
|
+
description: metadata.description,
|
|
254
|
+
homepage: metadata.homepage,
|
|
255
|
+
repository: metadata.repository,
|
|
256
|
+
dependencies: metadata.dependencies || {},
|
|
257
|
+
},
|
|
258
|
+
};
|
|
259
|
+
} catch (error) {
|
|
260
|
+
return {
|
|
261
|
+
success: false,
|
|
262
|
+
error: error.message,
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
}
|