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,447 @@
1
+ /**
2
+ * Core scanner module
3
+ * Pre and post-install security scanning for npm packages
4
+ */
5
+
6
+ import * as fs from 'fs';
7
+ import * as path from 'path';
8
+ import { RegistryClient, createRegistryClient } from './registry';
9
+ import { blocklistManager, TYPOSQUATTING_PATTERNS } from './blocklist';
10
+ import { scanFile, scanDirectory, scanPackageJsonScripts, CODE_EXTENSIONS } from './patterns';
11
+ import { VulnerabilityChecker, createVulnerabilityChecker } from './vuln';
12
+ import {
13
+ analyzeLicense,
14
+ checkMaintainerTrust,
15
+ validateRepository,
16
+ analyzeDependencies,
17
+ analyzeFileStructure
18
+ } from './extended';
19
+ import { fullIntegrityCheck } from './integrity';
20
+ import type {
21
+ PackageMetadata,
22
+ ScanResult,
23
+ Threat,
24
+ ThreatType,
25
+ ScanOptions,
26
+ PostInstallScanResult,
27
+ FileThreat
28
+ } from '../types';
29
+
30
+ /**
31
+ * Main scanner class
32
+ */
33
+ export class Scanner {
34
+ private registry: RegistryClient;
35
+ private options: ScanOptions;
36
+ private vulnChecker: VulnerabilityChecker;
37
+
38
+ constructor(options: ScanOptions = {}) {
39
+ this.options = options;
40
+ this.registry = createRegistryClient({ registry: options.registry });
41
+ this.vulnChecker = createVulnerabilityChecker(process.env.GITHUB_TOKEN);
42
+ }
43
+
44
+ /**
45
+ * Pre-install scan: Check a package before installation
46
+ */
47
+ async preInstallScan(packageName: string, version?: string): Promise<ScanResult> {
48
+ const threats: Threat[] = [];
49
+ let score = 0;
50
+
51
+ // 1. Check blocklist
52
+ const blocklistEntry = blocklistManager.isBlocklisted(packageName);
53
+ if (blocklistEntry) {
54
+ threats.push({
55
+ type: 'blocklisted',
56
+ severity: blocklistEntry.severity,
57
+ message: `Package is blocklisted: ${blocklistEntry.reason}`,
58
+ details: `Source: ${blocklistEntry.source || 'internal'}`
59
+ });
60
+ score += blocklistEntry.severity === 'critical' ? 100 : 75;
61
+ }
62
+
63
+ // 2. Check typosquatting
64
+ const typosquatMatches = blocklistManager.detectTyposquatting(packageName);
65
+ if (typosquatMatches.length > 0) {
66
+ threats.push({
67
+ type: 'typosquatting',
68
+ severity: 'high',
69
+ message: `Possible typosquatting of "${typosquatMatches.join(', ')}"`,
70
+ details: 'This package name is similar to popular packages - may be attempting to confuse users'
71
+ });
72
+ score += 60;
73
+ }
74
+
75
+ // 3. Fetch metadata
76
+ let rawMetadata: any;
77
+ try {
78
+ rawMetadata = await this.registry.getPackageMetadata(packageName, version);
79
+ } catch (e: any) {
80
+ threats.push({
81
+ type: 'supply_chain',
82
+ severity: 'medium',
83
+ message: `Could not fetch package metadata: ${e.message}`
84
+ });
85
+ return {
86
+ packageName,
87
+ version: version || 'unknown',
88
+ status: 'warning',
89
+ threats,
90
+ score: Math.min(score, 100)
91
+ };
92
+ }
93
+
94
+ const metadata: PackageMetadata = {
95
+ name: rawMetadata.name,
96
+ version: rawMetadata['dist-tags']?.latest || version || 'unknown',
97
+ description: rawMetadata.description,
98
+ publisher: rawMetadata.publisher,
99
+ maintainers: rawMetadata.maintainers,
100
+ repository: rawMetadata.repository,
101
+ homepage: rawMetadata.homepage,
102
+ license: rawMetadata.license
103
+ };
104
+
105
+ // Get latest version data
106
+ const latestVersion = rawMetadata.versions?.[metadata.version] || {};
107
+ metadata.dependencies = latestVersion.dependencies;
108
+ metadata.devDependencies = latestVersion.devDependencies;
109
+ metadata.peerDependencies = latestVersion.peerDependencies;
110
+ metadata.scripts = latestVersion.scripts;
111
+ metadata.time = rawMetadata.time;
112
+
113
+ // 4. Check for suspicious scripts
114
+ if (metadata.scripts) {
115
+ const suspiciousScripts = ['postinstall', 'preinstall', 'postpublish', 'preuninstall', 'postuninstall'];
116
+ for (const script of suspiciousScripts) {
117
+ if (metadata.scripts?.[script]) {
118
+ const isComplex = metadata.scripts?.[script].length > 100 ||
119
+ /curl|wget|npm\|pipe|\$\(|\||\&\&/.test(metadata.scripts?.[script] || '');
120
+
121
+ threats.push({
122
+ type: 'suspicious_script',
123
+ severity: isComplex ? 'high' : 'medium',
124
+ message: `Package has "${script}" script that runs automatically`,
125
+ details: metadata.scripts?.[script].substring(0, 100)
126
+ });
127
+ score += isComplex ? 40 : 20;
128
+ }
129
+ }
130
+ }
131
+
132
+ // 5. Check publisher reputation
133
+ if (!metadata.publisher && !metadata.maintainers?.length) {
134
+ threats.push({
135
+ type: 'unknown_publisher',
136
+ severity: 'medium',
137
+ message: 'Package has no verified publisher information'
138
+ });
139
+ score += 15;
140
+ }
141
+
142
+ // 6. Check version age
143
+ if (metadata.time?.created) {
144
+ const created = new Date(metadata.time.created);
145
+ const daysSince = (Date.now() - created.getTime()) / (1000 * 60 * 60 * 24);
146
+ if (daysSince < 7 && !metadata.maintainers?.length) {
147
+ threats.push({
148
+ type: 'supply_chain',
149
+ severity: 'medium',
150
+ message: `Package was published less than ${Math.floor(daysSince)} days ago with no maintainers`
151
+ });
152
+ score += 20;
153
+ }
154
+ }
155
+
156
+ // 7. Check dependencies
157
+ if (metadata.dependencies) {
158
+ const depCount = Object.keys(metadata.dependencies).length;
159
+ if (depCount > 100) {
160
+ threats.push({
161
+ type: 'supply_chain',
162
+ severity: 'low',
163
+ message: `Package has ${depCount} dependencies - large attack surface`
164
+ });
165
+ score += 10;
166
+ }
167
+
168
+ // Check for dependency confusion
169
+ for (const dep of Object.keys(metadata.dependencies)) {
170
+ if (dep.startsWith('@') && !dep.includes('/')) {
171
+ threats.push({
172
+ type: 'dependency_confusion',
173
+ severity: 'medium',
174
+ message: `Scoped dependency "@${dep}" may be internal package confusion`
175
+ });
176
+ score += 15;
177
+ break;
178
+ }
179
+ }
180
+ }
181
+
182
+ // 8. Check vulnerability databases (OSV, GitHub Advisory, npm Audit)
183
+ if (this.options.checkVulnerabilities !== false) {
184
+ try {
185
+ const vulnResult = await this.vulnChecker.checkPackage(packageName, version || metadata.version);
186
+
187
+ for (const vuln of vulnResult.vulnerabilities) {
188
+ if (vuln.id.startsWith('CVE-') || vuln.id.startsWith('GHSA-')) {
189
+ threats.push({
190
+ type: 'vulnerability',
191
+ severity: vuln.severity,
192
+ message: `${vuln.source.toUpperCase()} vulnerability: ${vuln.id}`,
193
+ details: vuln.summary || vuln.details,
194
+ location: vuln.patched_versions ? `Fixed in: ${vuln.patched_versions}` : undefined
195
+ });
196
+
197
+ if (vuln.severity === 'critical') score += 50;
198
+ else if (vuln.severity === 'high') score += 30;
199
+ else if (vuln.severity === 'medium') score += 15;
200
+ else score += 5;
201
+ }
202
+ }
203
+ } catch (e) {
204
+ // Vulnerability check failed, continue without
205
+ }
206
+ }
207
+
208
+ // 9. License analysis
209
+ const licenseAnalysis = analyzeLicense(metadata.license);
210
+ if (licenseAnalysis.risk === 'high') {
211
+ threats.push({
212
+ type: 'supply_chain',
213
+ severity: 'medium',
214
+ message: `License risk: ${licenseAnalysis.details}`,
215
+ details: metadata.license
216
+ });
217
+ score += 20;
218
+ } else if (licenseAnalysis.risk === 'critical') {
219
+ threats.push({
220
+ type: 'supply_chain',
221
+ severity: 'high',
222
+ message: `License: ${licenseAnalysis.details}`,
223
+ details: metadata.license
224
+ });
225
+ score += 40;
226
+ }
227
+
228
+ // 10. Maintainer trust check
229
+ const mainterTrust = checkMaintainerTrust(metadata.maintainers, metadata.publisher);
230
+ if (!mainterTrust.isTrusted && mainterTrust.score < 30) {
231
+ threats.push({
232
+ type: 'unknown_publisher',
233
+ severity: 'medium',
234
+ message: `Maintainer trust: ${mainterTrust.details}`
235
+ });
236
+ score += 15;
237
+ }
238
+
239
+ // 11. Repository validation
240
+ if (metadata.repository) {
241
+ const repoCheck = await validateRepository(metadata.repository.url, packageName);
242
+ if (!repoCheck.valid) {
243
+ for (const issue of repoCheck.issues) {
244
+ threats.push({
245
+ type: 'supply_chain',
246
+ severity: 'medium',
247
+ message: `Repository issue: ${issue}`,
248
+ details: repoCheck.details
249
+ });
250
+ score += 10;
251
+ }
252
+ }
253
+ }
254
+
255
+ // 12. Deprecated dependency check
256
+ const depAnalysis = analyzeDependencies(metadata.dependencies, metadata.devDependencies);
257
+ if (depAnalysis.outdatedCount > 0) {
258
+ threats.push({
259
+ type: 'supply_chain',
260
+ severity: 'low',
261
+ message: `${depAnalysis.outdatedCount} deprecated/outdated dependencies`,
262
+ details: depAnalysis.details.slice(0, 3).join('; ')
263
+ });
264
+ score += depAnalysis.outdatedCount * 2;
265
+ }
266
+
267
+ // 13. Package integrity check
268
+ const integrityChecked = await fullIntegrityCheck(packageName, version || metadata.version);
269
+ const integrity = integrityChecked.integrity;
270
+ const sizeInfo = integrityChecked.size;
271
+ const tarballInfo = integrityChecked.tarball;
272
+
273
+ if (!integrity.valid) {
274
+ threats.push({
275
+ type: 'supply_chain',
276
+ severity: 'high',
277
+ message: `Integrity check failed: ${integrity.details}`,
278
+ details: `Expected: ${integrity.expectedHash?.substring(0, 20)}...`
279
+ });
280
+ score += 50;
281
+ }
282
+ if (sizeInfo.suspicious) {
283
+ threats.push({
284
+ type: 'supply_chain',
285
+ severity: 'medium',
286
+ message: sizeInfo.details
287
+ });
288
+ score += 20;
289
+ }
290
+ if (tarballInfo.hasNativeCode) {
291
+ // Native code is usually fine for big packages, but warn
292
+ threats.push({
293
+ type: 'supply_chain',
294
+ severity: 'low',
295
+ message: 'Package contains native code'
296
+ });
297
+ score += 5;
298
+ }
299
+
300
+ // Determine status
301
+ const status = this.determineStatus(threats, score);
302
+
303
+ return {
304
+ packageName,
305
+ version: metadata?.version || version || 'unknown',
306
+ status,
307
+ threats,
308
+ metadata,
309
+ score
310
+ };
311
+ }
312
+
313
+ /**
314
+ * Post-install scan: Scan downloaded packages
315
+ */
316
+ async postInstallScan(folderPath?: string): Promise<PostInstallScanResult> {
317
+ const startTime = Date.now();
318
+ const scanFolder = folderPath || path.join(process.cwd(), 'node_modules');
319
+ const allThreats: FileThreat[] = [];
320
+ let scannedPackages = 0;
321
+
322
+ if (!fs.existsSync(scanFolder)) {
323
+ throw new Error(`node_modules folder not found: ${scanFolder}`);
324
+ }
325
+
326
+ // Find all packages
327
+ const packages = this.findPackages(scanFolder);
328
+
329
+ for (const pkg of packages) {
330
+ scannedPackages++;
331
+
332
+ // Scan package directory
333
+ if (fs.existsSync(pkg)) {
334
+ const files = scanDirectory(pkg);
335
+
336
+ for (const [filePath, content] of files) {
337
+ const fileThreats = scanFile(filePath, content);
338
+ allThreats.push(...fileThreats.map(t => ({
339
+ ...t,
340
+ package: pkg.split(path.sep).slice(-2).join(path.sep)
341
+ })));
342
+ }
343
+
344
+ // Check package.json
345
+ const packageJsonPath = path.join(pkg, 'package.json');
346
+ if (fs.existsSync(packageJsonPath)) {
347
+ try {
348
+ const pkgJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
349
+ if (pkgJson.scripts) {
350
+ const scriptThreats = scanPackageJsonScripts(pkgJson.scripts);
351
+ allThreats.push(...scriptThreats.map(t => ({
352
+ ...t,
353
+ package: pkg.split(path.sep).slice(-2).join(path.sep)
354
+ })));
355
+ }
356
+ } catch (e) {
357
+ // Skip invalid package.json
358
+ }
359
+ }
360
+ }
361
+ }
362
+
363
+ return {
364
+ scannedPackages,
365
+ threats: allThreats,
366
+ duration: Date.now() - startTime
367
+ };
368
+ }
369
+
370
+ /**
371
+ * Full scan: Pre-scan, then install, then post-scan
372
+ */
373
+ async fullScan(packageName: string, version?: string, folderPath?: string): Promise<{
374
+ pre: ScanResult;
375
+ post: PostInstallScanResult;
376
+ }> {
377
+ const pre = await this.preInstallScan(packageName, version);
378
+
379
+ if (pre.status === 'blocked') {
380
+ return { pre, post: { scannedPackages: 0, threats: [], duration: 0 } };
381
+ }
382
+
383
+ // Run post-install scan if folder provided
384
+ let post: PostInstallScanResult = { scannedPackages: 0, threats: [], duration: 0 };
385
+ if (folderPath) {
386
+ post = await this.postInstallScan(folderPath);
387
+ }
388
+
389
+ return { pre, post };
390
+ }
391
+
392
+ /**
393
+ * Find all packages in node_modules
394
+ */
395
+ private findPackages(nodeModulesPath: string): string[] {
396
+ const packages: string[] = [];
397
+
398
+ function findPkgs(dir: string, depth: number = 0) {
399
+ if (depth > 3) return; // Limit depth
400
+
401
+ try {
402
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
403
+
404
+ for (const entry of entries) {
405
+ if (entry.isDirectory()) {
406
+ const fullPath = path.join(dir, entry.name);
407
+
408
+ // Skip scoped packages at root level
409
+ if (entry.name.startsWith('@')) {
410
+ findPkgs(fullPath, depth + 1);
411
+ } else if (fs.existsSync(path.join(fullPath, 'package.json'))) {
412
+ packages.push(fullPath);
413
+ }
414
+ }
415
+ }
416
+ } catch (e) {
417
+ // Skip inaccessible
418
+ }
419
+ }
420
+
421
+ findPkgs(nodeModulesPath);
422
+ return packages;
423
+ }
424
+
425
+ /**
426
+ * Determine overall status from threats and score
427
+ */
428
+ private determineStatus(threats: Threat[], score: number): 'safe' | 'warning' | 'danger' | 'blocked' {
429
+ if (threats.some(t => t.type === 'blocklisted')) {
430
+ return 'blocked';
431
+ }
432
+ if (score >= 60 || threats.some(t => t.severity === 'critical')) {
433
+ return 'danger';
434
+ }
435
+ if (score >= 30 || threats.some(t => t.severity === 'high')) {
436
+ return 'warning';
437
+ }
438
+ return 'safe';
439
+ }
440
+ }
441
+
442
+ export function createScanner(options?: ScanOptions): Scanner {
443
+ return new Scanner(options);
444
+ }
445
+
446
+ // Export for CLI
447
+ export default { Scanner, createScanner };