npm-scan-plus 1.0.0
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/.eslintrc.json +32 -0
- package/.github/CODEOWNERS +3 -0
- package/.github/workflows/ci.yml +105 -0
- package/.prettierrc +10 -0
- package/FUNDING.yml +1 -0
- package/PLAN.md +151 -0
- package/README.md +150 -0
- package/bin/npm-scan +13 -0
- package/bin/npm-scan-wrap +100 -0
- package/dist/cli/index.d.ts +18 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +299 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/lib/blocklist.d.ts +45 -0
- package/dist/lib/blocklist.d.ts.map +1 -0
- package/dist/lib/blocklist.js +256 -0
- package/dist/lib/blocklist.js.map +1 -0
- package/dist/lib/extended.js +314 -0
- package/dist/lib/extended.js.map +1 -0
- package/dist/lib/integrity.js +247 -0
- package/dist/lib/integrity.js.map +1 -0
- package/dist/lib/patterns.d.ts +76 -0
- package/dist/lib/patterns.d.ts.map +1 -0
- package/dist/lib/patterns.js +414 -0
- package/dist/lib/patterns.js.map +1 -0
- package/dist/lib/registry.d.ts +42 -0
- package/dist/lib/registry.d.ts.map +1 -0
- package/dist/lib/registry.js +157 -0
- package/dist/lib/registry.js.map +1 -0
- package/dist/lib/scanner.d.ts +43 -0
- package/dist/lib/scanner.d.ts.map +1 -0
- package/dist/lib/scanner.js +432 -0
- package/dist/lib/scanner.js.map +1 -0
- package/dist/lib/vuln.js +284 -0
- package/dist/lib/vuln.js.map +1 -0
- package/dist/types.d.ts +85 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +6 -0
- package/dist/types.js.map +1 -0
- package/jest.config.js +18 -0
- package/package.json +56 -0
- package/src/cli/index.ts +336 -0
- package/src/lib/blocklist.ts +239 -0
- package/src/lib/extended.ts +384 -0
- package/src/lib/integrity.ts +253 -0
- package/src/lib/patterns.ts +404 -0
- package/src/lib/registry.ts +146 -0
- package/src/lib/scanner.ts +447 -0
- package/src/lib/vuln.ts +321 -0
- package/src/types.ts +102 -0
- package/tests/blocklist.test.ts +89 -0
- package/tests/extended.test.ts +204 -0
- package/tests/patterns.test.ts +147 -0
- package/tests/scanner.test.ts +116 -0
- package/tests/vuln.test.ts +66 -0
- package/tsconfig.json +20 -0
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Detection patterns for malicious code, obfuscation, and suspicious behavior
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import * as fs from 'fs';
|
|
6
|
+
import * as path from 'path';
|
|
7
|
+
import type { FileThreat, ThreatType } from '../types';
|
|
8
|
+
|
|
9
|
+
// Patterns that indicate obfuscated code
|
|
10
|
+
const OBFUSCATION_PATTERNS = [
|
|
11
|
+
{
|
|
12
|
+
pattern: /eval\s*\(\s*(?:atob|fromCharCode|String\.fromCharCode)/gi,
|
|
13
|
+
type: 'obfuscation' as ThreatType,
|
|
14
|
+
severity: 'high' as const,
|
|
15
|
+
message: 'eval() with character code decoding - common obfuscation technique'
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
pattern: /eval\s*\(\s*["'`]([A-Za-z0-9+\/=]{100,})["'`]*/gi,
|
|
19
|
+
type: 'obfuscation' as ThreatType,
|
|
20
|
+
severity: 'high' as const,
|
|
21
|
+
message: 'eval() with base64-encoded string'
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
pattern: /new\s+Function\s*\(\s*(?:atob|fromCharCode)/gi,
|
|
25
|
+
type: 'obfuscation' as ThreatType,
|
|
26
|
+
severity: 'high' as const,
|
|
27
|
+
message: 'Dynamic Function with character code decoding'
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
pattern: /\\x([0-9a-fA-F]{2})/g,
|
|
31
|
+
type: 'obfuscation' as ThreatType,
|
|
32
|
+
severity: 'medium' as const,
|
|
33
|
+
message: 'Hex-encoded characters in code'
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
pattern: /\\u([0-9a-fA-F]{4})/g,
|
|
37
|
+
type: 'obfuscation' as ThreatType,
|
|
38
|
+
severity: 'medium' as const,
|
|
39
|
+
message: 'Unicode-escaped characters in code'
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
pattern: /(?:atob|btoa)\s*\([^)]*\)/g,
|
|
43
|
+
type: 'obfuscation' as ThreatType,
|
|
44
|
+
severity: 'medium' as const,
|
|
45
|
+
message: 'Base64 encoding/decoding functions detected'
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
pattern: /String\.fromCharCode\((?:\s*\d+\s*,?)+\)/g,
|
|
49
|
+
type: 'obfuscation' as ThreatType,
|
|
50
|
+
severity: 'medium' as const,
|
|
51
|
+
message: 'String.fromCharCode() to hide code'
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
pattern: /\$_\s*=\s*["'`]/g,
|
|
55
|
+
type: 'obfuscation' as ThreatType,
|
|
56
|
+
severity: 'low' as const,
|
|
57
|
+
message: 'Unusual variable naming pattern'
|
|
58
|
+
}
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
// Patterns that indicate malicious/malware behavior
|
|
62
|
+
const SUSPICIOUS_PATTERNS = [
|
|
63
|
+
{
|
|
64
|
+
pattern: /(?:process\.env|process)\s*\.\s*(?:env|argv)\s*\[.*?(?:KEY|SECRET|TOKEN|PASS|Auth|Credential)/gi,
|
|
65
|
+
type: 'suspicious_code' as ThreatType,
|
|
66
|
+
severity: 'critical' as const,
|
|
67
|
+
message: 'Accessing environment variables with sensitive keywords'
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
pattern: /fetch\s*\(\s*["'](?:https?:)?\/\/(?:[0-9]{1,3}\.){3}[0-9]{1,3}/gi,
|
|
71
|
+
type: 'suspicious_code' as ThreatType,
|
|
72
|
+
severity: 'high' as const,
|
|
73
|
+
message: 'Network request to IP address (bypasses DNS)'
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
pattern: /https?:\/\/(?:pastebin|hastebin|ipfs\.io|cloudflare|workers\.dev)\.\w+/gi,
|
|
77
|
+
type: 'suspicious_code' as ThreatType,
|
|
78
|
+
severity: 'high' as const,
|
|
79
|
+
message: 'Network request to external code hosting service'
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
pattern: /child_process\s*\.\s*(?:exec|spawn|sync)\s*\(\s*["'`]/gi,
|
|
83
|
+
type: 'suspicious_code' as ThreatType,
|
|
84
|
+
severity: 'high' as const,
|
|
85
|
+
message: 'Executing shell commands from package code'
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
pattern: /net\.connect\(|require\s*\(\s*["'](?:net|http|tls|crypto)["']\s*\)/gi,
|
|
89
|
+
type: 'suspicious_code' as ThreatType,
|
|
90
|
+
severity: 'medium' as const,
|
|
91
|
+
message: 'Network or crypto module required'
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
pattern: /require\s*\(\s*["'](?:child_process|exec|spawn)["']\s*\)/gi,
|
|
95
|
+
type: 'suspicious_code' as ThreatType,
|
|
96
|
+
severity: 'high' as const,
|
|
97
|
+
message: 'child_process module required - potential command execution'
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
pattern: /setTimeout\s*\(\s*["'`]/gi,
|
|
101
|
+
type: 'suspicious_code' as ThreatType,
|
|
102
|
+
severity: 'low' as const,
|
|
103
|
+
message: 'Delayed code execution'
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
pattern: /setInterval\s*\(\s*["'`]/gi,
|
|
107
|
+
type: 'suspicious_code' as ThreatType,
|
|
108
|
+
severity: 'low' as const,
|
|
109
|
+
message: 'Repeating scheduled code execution'
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
pattern: /\.exec\s*\(|child_process\.\s*exec/gi,
|
|
113
|
+
type: 'suspicious_code' as ThreatType,
|
|
114
|
+
severity: 'high' as const,
|
|
115
|
+
message: 'Shell command execution'
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
pattern: /fs\s*\.\s*(?:readFile|writeFile|appendFile|readFileSync|writeFileSync)\s*\(\s*(?:__dirname|process\.cwd\(\))/gi,
|
|
119
|
+
type: 'suspicious_code' as ThreatType,
|
|
120
|
+
severity: 'medium' as const,
|
|
121
|
+
message: 'File system access in package root'
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
pattern: /(?:readdirSync|readdir|readFileSync|readlinkSync)\s*\(\s*(?:\.|process\.cwd)/gi,
|
|
125
|
+
type: 'suspicious_code' as ThreatType,
|
|
126
|
+
severity: 'medium' as const,
|
|
127
|
+
message: 'Scanning directories outside package'
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
pattern: /eval\s*\(\s*process\.env/gi,
|
|
131
|
+
type: 'suspicious_code' as ThreatType,
|
|
132
|
+
severity: 'critical' as const,
|
|
133
|
+
message: 'Evaluating environment variables'
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
pattern: /require\s*\(\s*["'](?:https?|http)["']\s*\)/gi,
|
|
137
|
+
type: 'suspicious_code' as ThreatType,
|
|
138
|
+
severity: 'medium' as const,
|
|
139
|
+
message: 'HTTP/HTTPS module required'
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
pattern: /crypto\s*\.\s*createHash\s*\(\s*["'](?:sha|md5)["']\s*\)/gi,
|
|
143
|
+
type: 'suspicious_code' as ThreatType,
|
|
144
|
+
severity: 'low' as const,
|
|
145
|
+
message: 'Cryptographic hashing'
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
// Crypto mining patterns
|
|
149
|
+
pattern: /(?:stratum\+tcp|stratum\.bitcoin|cgminer|antminer|cryptonight)/gi,
|
|
150
|
+
type: 'suspicious_code' as ThreatType,
|
|
151
|
+
severity: 'critical' as const,
|
|
152
|
+
message: 'Crypto mining pool connection detected'
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
// Keylogging patterns
|
|
156
|
+
pattern: /(?:addEventListener\s*\(\s*["'](?:keydown|keypress|keyup)|onkey)/gi,
|
|
157
|
+
type: 'suspicious_code' as ThreatType,
|
|
158
|
+
severity: 'high' as const,
|
|
159
|
+
message: 'Keyboard event listener - potential keylogger'
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
pattern: /(?:key|code)\s*:\s*(?:\d{1,3}\s*,?\s*){3,}/gi,
|
|
163
|
+
type: 'suspicious_code' as ThreatType,
|
|
164
|
+
severity: 'high' as const,
|
|
165
|
+
message: 'Potential keyboard scanning code'
|
|
166
|
+
}
|
|
167
|
+
];
|
|
168
|
+
|
|
169
|
+
// Suspicious lifecycle scripts in package.json
|
|
170
|
+
const SUSPICIOUS_SCRIPTS = [
|
|
171
|
+
{
|
|
172
|
+
script: 'postinstall',
|
|
173
|
+
severity: 'high' as const,
|
|
174
|
+
message: 'postinstall script executes automatically after install'
|
|
175
|
+
},
|
|
176
|
+
{
|
|
177
|
+
script: 'preinstall',
|
|
178
|
+
severity: 'medium' as const,
|
|
179
|
+
message: 'preinstall script runs before package installs'
|
|
180
|
+
},
|
|
181
|
+
{
|
|
182
|
+
script: 'postpublish',
|
|
183
|
+
severity: 'medium' as const,
|
|
184
|
+
message: 'postpublish script runs after package is published'
|
|
185
|
+
},
|
|
186
|
+
{
|
|
187
|
+
script: 'preuninstall',
|
|
188
|
+
severity: 'low' as const,
|
|
189
|
+
message: 'preuninstall script runs before uninstall'
|
|
190
|
+
},
|
|
191
|
+
{
|
|
192
|
+
script: 'postuninstall',
|
|
193
|
+
severity: 'medium' as const,
|
|
194
|
+
message: 'postuninstall script runs after uninstall'
|
|
195
|
+
},
|
|
196
|
+
{
|
|
197
|
+
script: 'preversion',
|
|
198
|
+
severity: 'low' as const,
|
|
199
|
+
message: 'preversion script runs before version bump'
|
|
200
|
+
},
|
|
201
|
+
{
|
|
202
|
+
script: 'postversion',
|
|
203
|
+
severity: 'low' as const,
|
|
204
|
+
message: 'postversion script runs after version bump'
|
|
205
|
+
},
|
|
206
|
+
{
|
|
207
|
+
script: 'prepack',
|
|
208
|
+
severity: 'low' as const,
|
|
209
|
+
message: 'prepack script runs before packing'
|
|
210
|
+
},
|
|
211
|
+
{
|
|
212
|
+
script: 'postpack',
|
|
213
|
+
severity: 'low' as const,
|
|
214
|
+
message: 'postpack script runs after packing'
|
|
215
|
+
},
|
|
216
|
+
{
|
|
217
|
+
script: 'prepare',
|
|
218
|
+
severity: 'low' as const,
|
|
219
|
+
message: 'prepare script runs on install and publish'
|
|
220
|
+
}
|
|
221
|
+
];
|
|
222
|
+
|
|
223
|
+
// Sensitive files that should not be in packages
|
|
224
|
+
const SENSITIVE_FILES = [
|
|
225
|
+
/\.env$/,
|
|
226
|
+
/\.env\.[a-z]+$/,
|
|
227
|
+
/\.git\/config$/,
|
|
228
|
+
/\.aws\/credentials$/,
|
|
229
|
+
/\.ssh\/id_.*$/,
|
|
230
|
+
/credentials\.json$/,
|
|
231
|
+
/\.npmrc$/,
|
|
232
|
+
/\.htpasswd$/,
|
|
233
|
+
/secrets\.ya?ml$/,
|
|
234
|
+
/\.pem$/,
|
|
235
|
+
/\.key$/,
|
|
236
|
+
/\.p12$/,
|
|
237
|
+
/\.pfx$/,
|
|
238
|
+
/id_rsa/,
|
|
239
|
+
/id_dsa/,
|
|
240
|
+
/\.bash_history$/,
|
|
241
|
+
/\.zsh_history$/,
|
|
242
|
+
/\.history$/,
|
|
243
|
+
/_history$/,
|
|
244
|
+
/\.sql$/,
|
|
245
|
+
/\.db$/
|
|
246
|
+
];
|
|
247
|
+
|
|
248
|
+
// File extensions that might contain executable code
|
|
249
|
+
const CODE_EXTENSIONS = [
|
|
250
|
+
'.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx',
|
|
251
|
+
'.py', '.rb', '.php', '.pl', '.sh', '.bash',
|
|
252
|
+
'.exe', '.dll', '.so', '.dylib', '.bin',
|
|
253
|
+
'.wasm', '.class', '.jar'
|
|
254
|
+
];
|
|
255
|
+
|
|
256
|
+
export interface PatternMatch {
|
|
257
|
+
pattern: RegExp;
|
|
258
|
+
type: ThreatType;
|
|
259
|
+
severity: 'low' | 'medium' | 'high' | 'critical';
|
|
260
|
+
message: string;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Scan a single file for suspicious patterns
|
|
265
|
+
*/
|
|
266
|
+
export function scanFile(filePath: string, content: string): FileThreat[] {
|
|
267
|
+
const threats: FileThreat[] = [];
|
|
268
|
+
const fileName = path.basename(filePath);
|
|
269
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
270
|
+
|
|
271
|
+
// Skip node_modules packages themselves
|
|
272
|
+
if (filePath.includes('node_modules/')) {
|
|
273
|
+
return [];
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Check for obfuscation patterns
|
|
277
|
+
for (const { pattern, type, severity, message } of OBFUSCATION_PATTERNS) {
|
|
278
|
+
const matches = content.match(pattern);
|
|
279
|
+
if (matches) {
|
|
280
|
+
threats.push({
|
|
281
|
+
package: 'scanning',
|
|
282
|
+
file: fileName,
|
|
283
|
+
type,
|
|
284
|
+
severity,
|
|
285
|
+
message,
|
|
286
|
+
code: matches[0].substring(0, 100)
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Check for suspicious patterns (only in code files)
|
|
292
|
+
if (CODE_EXTENSIONS.includes(ext)) {
|
|
293
|
+
for (const { pattern, type, severity, message } of SUSPICIOUS_PATTERNS) {
|
|
294
|
+
const matches = content.match(pattern);
|
|
295
|
+
if (matches) {
|
|
296
|
+
threats.push({
|
|
297
|
+
package: 'scanning',
|
|
298
|
+
file: fileName,
|
|
299
|
+
type,
|
|
300
|
+
severity,
|
|
301
|
+
message,
|
|
302
|
+
code: matches[0].substring(0, 100)
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Check for sensitive files
|
|
309
|
+
for (const pattern of SENSITIVE_FILES) {
|
|
310
|
+
if (pattern.test(fileName)) {
|
|
311
|
+
threats.push({
|
|
312
|
+
package: 'scanning',
|
|
313
|
+
file: fileName,
|
|
314
|
+
type: 'suspicious_code',
|
|
315
|
+
severity: 'high',
|
|
316
|
+
message: `Sensitive file detected in package: ${fileName}`
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
return threats;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Check package.json scripts for suspicious behavior
|
|
326
|
+
*/
|
|
327
|
+
export function scanPackageJsonScripts(scripts: Record<string, string>): FileThreat[] {
|
|
328
|
+
const threats: FileThreat[] = [];
|
|
329
|
+
|
|
330
|
+
if (!scripts) return threats;
|
|
331
|
+
|
|
332
|
+
for (const [scriptName, scriptContent] of Object.entries(scripts)) {
|
|
333
|
+
const scriptDef = SUSPICIOUS_SCRIPTS.find(s => s.script === scriptName);
|
|
334
|
+
|
|
335
|
+
if (scriptDef) {
|
|
336
|
+
// Check if script content is suspicious
|
|
337
|
+
const isSuspicious = /curl|wget|npm|node|yarn|python|perl|bash|sh|\||&&|\$\(|;/.test(scriptContent) ||
|
|
338
|
+
scriptContent.length > 200;
|
|
339
|
+
|
|
340
|
+
threats.push({
|
|
341
|
+
package: 'scanning',
|
|
342
|
+
file: 'package.json',
|
|
343
|
+
type: isSuspicious ? 'suspicious_script' : 'supply_chain',
|
|
344
|
+
severity: isSuspicious ? scriptDef.severity : 'low',
|
|
345
|
+
message: `${scriptDef.message}: "${scriptName}"`,
|
|
346
|
+
code: scriptContent.substring(0, 100)
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
return threats;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Scan directory for all files
|
|
356
|
+
*/
|
|
357
|
+
export function scanDirectory(dirPath: string, extensions?: string[]): Map<string, string> {
|
|
358
|
+
const files = new Map<string, string>();
|
|
359
|
+
const codeExtensions = extensions || CODE_EXTENSIONS;
|
|
360
|
+
|
|
361
|
+
function walkDir(dir: string, basePath: string = '') {
|
|
362
|
+
try {
|
|
363
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
364
|
+
|
|
365
|
+
for (const entry of entries) {
|
|
366
|
+
const fullPath = path.join(dir, entry.name);
|
|
367
|
+
const relativePath = path.join(basePath, entry.name);
|
|
368
|
+
|
|
369
|
+
if (entry.isDirectory()) {
|
|
370
|
+
// Skip certain directories
|
|
371
|
+
if (['node_modules', '.git', 'test', '__tests__', 'coverage'].includes(entry.name)) {
|
|
372
|
+
continue;
|
|
373
|
+
}
|
|
374
|
+
walkDir(fullPath, relativePath);
|
|
375
|
+
} else if (entry.isFile()) {
|
|
376
|
+
const ext = path.extname(entry.name).toLowerCase();
|
|
377
|
+
if (codeExtensions.includes(ext)) {
|
|
378
|
+
try {
|
|
379
|
+
const content = fs.readFileSync(fullPath, 'utf-8');
|
|
380
|
+
if (content.length < 1_000_000) { // Skip files > 1MB
|
|
381
|
+
files.set(relativePath, content);
|
|
382
|
+
}
|
|
383
|
+
} catch (e) {
|
|
384
|
+
// Skip binary or unreadable files
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
} catch (e) {
|
|
390
|
+
// Skip inaccessible directories
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
walkDir(dirPath);
|
|
395
|
+
return files;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
export {
|
|
399
|
+
OBFUSCATION_PATTERNS,
|
|
400
|
+
SUSPICIOUS_PATTERNS,
|
|
401
|
+
SUSPICIOUS_SCRIPTS,
|
|
402
|
+
SENSITIVE_FILES,
|
|
403
|
+
CODE_EXTENSIONS
|
|
404
|
+
};
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* npm registry API client
|
|
3
|
+
* Fetches package metadata and tarballs from npm registry using native fetch
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import * as fs from 'fs';
|
|
7
|
+
import * as path from 'path';
|
|
8
|
+
import * as os from 'os';
|
|
9
|
+
import * as tar from 'tar';
|
|
10
|
+
import * as crypto from 'crypto';
|
|
11
|
+
|
|
12
|
+
const DEFAULT_REGISTRY = 'https://registry.npmjs.org';
|
|
13
|
+
|
|
14
|
+
export class RegistryClient {
|
|
15
|
+
private registry: string;
|
|
16
|
+
private cache: any = new Map();
|
|
17
|
+
|
|
18
|
+
constructor(options?: { registry?: string }) {
|
|
19
|
+
this.registry = options?.registry || DEFAULT_REGISTRY;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Fetch package metadata from npm registry
|
|
24
|
+
*/
|
|
25
|
+
async getPackageMetadata(packageName: string, version?: string): Promise<any> {
|
|
26
|
+
const cacheKey = `${packageName}@${version || 'latest'}`;
|
|
27
|
+
|
|
28
|
+
if (this.cache.has(cacheKey)) {
|
|
29
|
+
return this.cache.get(cacheKey);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const url = `${this.registry}/${packageName}${version ? `/${version}` : ''}`;
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
const response = await fetch(url);
|
|
36
|
+
|
|
37
|
+
if (!response.ok) {
|
|
38
|
+
if (response.status === 404) {
|
|
39
|
+
throw new Error(`Package not found: ${packageName}`);
|
|
40
|
+
}
|
|
41
|
+
throw new Error(`Failed to fetch ${packageName}: ${response.statusText}`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const data = await response.json();
|
|
45
|
+
this.cache.set(cacheKey, data);
|
|
46
|
+
return data;
|
|
47
|
+
} catch (error: any) {
|
|
48
|
+
throw new Error(`Failed to fetch ${packageName}: ${error.message}`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Download and extract package tarball to temp directory
|
|
54
|
+
*/
|
|
55
|
+
async downloadPackage(packageName: string, version?: string): Promise<{
|
|
56
|
+
tempDir: string;
|
|
57
|
+
files: string[];
|
|
58
|
+
integrity: string;
|
|
59
|
+
}> {
|
|
60
|
+
const metadata = await this.getPackageMetadata(packageName, version);
|
|
61
|
+
const dist = metadata.dist || {};
|
|
62
|
+
|
|
63
|
+
if (!dist.tarball) {
|
|
64
|
+
throw new Error(`No tarball found for ${packageName}`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const tempDir = path.join(os.tmpdir(), `npm-scan-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`);
|
|
68
|
+
fs.mkdirSync(tempDir, { recursive: true });
|
|
69
|
+
|
|
70
|
+
const tarballPath = path.join(tempDir, 'package.tgz');
|
|
71
|
+
|
|
72
|
+
// Download tarball
|
|
73
|
+
const response = await fetch(dist.tarball);
|
|
74
|
+
const arrayBuffer = await response.arrayBuffer();
|
|
75
|
+
const buffer = Buffer.from(arrayBuffer);
|
|
76
|
+
fs.writeFileSync(tarballPath, buffer);
|
|
77
|
+
|
|
78
|
+
// Calculate integrity hash (sha512)
|
|
79
|
+
const integrity = 'sha512-' + crypto.createHash('sha512').update(buffer).digest('base64');
|
|
80
|
+
|
|
81
|
+
// Extract tarball
|
|
82
|
+
const extractDir = path.join(tempDir, 'package');
|
|
83
|
+
fs.mkdirSync(extractDir, { recursive: true });
|
|
84
|
+
|
|
85
|
+
await tar.extract({
|
|
86
|
+
file: tarballPath,
|
|
87
|
+
cwd: extractDir
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// Get list of extracted files
|
|
91
|
+
const files = this.getAllFiles(extractDir);
|
|
92
|
+
|
|
93
|
+
return { tempDir, files, integrity };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Recursively get all files in directory
|
|
98
|
+
*/
|
|
99
|
+
private getAllFiles(dir: string, baseDir?: string): string[] {
|
|
100
|
+
const files: string[] = [];
|
|
101
|
+
const base = baseDir || dir;
|
|
102
|
+
|
|
103
|
+
try {
|
|
104
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
105
|
+
|
|
106
|
+
for (const entry of entries) {
|
|
107
|
+
const fullPath = path.join(dir, entry.name);
|
|
108
|
+
const relativePath = path.relative(base, fullPath);
|
|
109
|
+
|
|
110
|
+
if (entry.isDirectory()) {
|
|
111
|
+
files.push(...this.getAllFiles(fullPath, base));
|
|
112
|
+
} else if (entry.isFile()) {
|
|
113
|
+
files.push(relativePath);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
} catch (e) {
|
|
117
|
+
// Skip inaccessible
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return files;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Clean up temp directory
|
|
125
|
+
*/
|
|
126
|
+
cleanup(tempDir: string): void {
|
|
127
|
+
try {
|
|
128
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
129
|
+
} catch (e) {
|
|
130
|
+
// Ignore cleanup errors
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Get all available versions of a package
|
|
136
|
+
*/
|
|
137
|
+
async getVersions(packageName: string): Promise<string[]> {
|
|
138
|
+
const response = await fetch(`${this.registry}/${packageName}`);
|
|
139
|
+
const data: any = await response.json();
|
|
140
|
+
return Object.keys(data.versions || {}).sort();
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function createRegistryClient(options?: any): RegistryClient {
|
|
145
|
+
return new RegistryClient({ registry: options?.registry });
|
|
146
|
+
}
|