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.
Files changed (56) hide show
  1. package/.eslintrc.json +32 -0
  2. package/.github/CODEOWNERS +3 -0
  3. package/.github/workflows/ci.yml +105 -0
  4. package/.prettierrc +10 -0
  5. package/FUNDING.yml +1 -0
  6. package/PLAN.md +151 -0
  7. package/README.md +150 -0
  8. package/bin/npm-scan +13 -0
  9. package/bin/npm-scan-wrap +100 -0
  10. package/dist/cli/index.d.ts +18 -0
  11. package/dist/cli/index.d.ts.map +1 -0
  12. package/dist/cli/index.js +299 -0
  13. package/dist/cli/index.js.map +1 -0
  14. package/dist/lib/blocklist.d.ts +45 -0
  15. package/dist/lib/blocklist.d.ts.map +1 -0
  16. package/dist/lib/blocklist.js +256 -0
  17. package/dist/lib/blocklist.js.map +1 -0
  18. package/dist/lib/extended.js +314 -0
  19. package/dist/lib/extended.js.map +1 -0
  20. package/dist/lib/integrity.js +247 -0
  21. package/dist/lib/integrity.js.map +1 -0
  22. package/dist/lib/patterns.d.ts +76 -0
  23. package/dist/lib/patterns.d.ts.map +1 -0
  24. package/dist/lib/patterns.js +414 -0
  25. package/dist/lib/patterns.js.map +1 -0
  26. package/dist/lib/registry.d.ts +42 -0
  27. package/dist/lib/registry.d.ts.map +1 -0
  28. package/dist/lib/registry.js +157 -0
  29. package/dist/lib/registry.js.map +1 -0
  30. package/dist/lib/scanner.d.ts +43 -0
  31. package/dist/lib/scanner.d.ts.map +1 -0
  32. package/dist/lib/scanner.js +432 -0
  33. package/dist/lib/scanner.js.map +1 -0
  34. package/dist/lib/vuln.js +284 -0
  35. package/dist/lib/vuln.js.map +1 -0
  36. package/dist/types.d.ts +85 -0
  37. package/dist/types.d.ts.map +1 -0
  38. package/dist/types.js +6 -0
  39. package/dist/types.js.map +1 -0
  40. package/jest.config.js +18 -0
  41. package/package.json +56 -0
  42. package/src/cli/index.ts +336 -0
  43. package/src/lib/blocklist.ts +239 -0
  44. package/src/lib/extended.ts +384 -0
  45. package/src/lib/integrity.ts +253 -0
  46. package/src/lib/patterns.ts +404 -0
  47. package/src/lib/registry.ts +146 -0
  48. package/src/lib/scanner.ts +447 -0
  49. package/src/lib/vuln.ts +321 -0
  50. package/src/types.ts +102 -0
  51. package/tests/blocklist.test.ts +89 -0
  52. package/tests/extended.test.ts +204 -0
  53. package/tests/patterns.test.ts +147 -0
  54. package/tests/scanner.test.ts +116 -0
  55. package/tests/vuln.test.ts +66 -0
  56. 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
+ }