UNPKG

am-i-secure

Version:

A CLI tool to detect malicious npm packages in your project dependencies

269 lines (230 loc) 10.3 kB
const path = require('path'); const { detectLockFiles, parseLockFile } = require('./lock-parsers'); const { scanNodeModules } = require('./node-modules-scanner'); const { isMalicious } = require('./malicious-packages'); class Scanner { constructor(projectDir, logger, options = {}) { this.projectDir = projectDir; this.logger = logger; this.recursive = options.recursive || false; } /** * Perform a comprehensive scan of the project * @returns {Object} - Scan results with findings and summary */ async scan() { const results = { findings: [], summary: { lockFilesScanned: 0, nodeModulesScanned: false, totalPackagesChecked: 0, maliciousPackagesFound: 0 } }; try { // Scan lock files this.logger.verbose(`Detecting lock files${this.recursive ? ' (recursive)' : ''}...`); const lockFiles = detectLockFiles(this.projectDir, this.recursive); results.summary.lockFilesScanned = lockFiles.length; if (lockFiles.length === 0) { this.logger.warn('No lock files found. Scanning node_modules only.'); } const checkedPackages = new Set(); // Track checked packages to avoid duplicates // Process each lock file for (const lockFile of lockFiles) { this.logger.verbose(`Parsing ${path.basename(lockFile)}...`); await this.scanLockFile(lockFile, results, checkedPackages); } // Scan node_modules directory this.logger.verbose(`Scanning node_modules directory${this.recursive ? ' (recursive)' : ''}...`); await this.scanNodeModulesDirectory(results, checkedPackages); results.summary.totalPackagesChecked = checkedPackages.size; results.summary.maliciousPackagesFound = results.findings.length; // Show helpful tip if nothing was found and not using recursive mode if (results.summary.totalPackagesChecked === 0 && !this.recursive) { // Check if there are subdirectories that might contain projects const hasSubProjects = this.detectPotentialSubProjects(); if (hasSubProjects) { this.logger.info('💡 Tip: Found subdirectories that may contain projects. Use --recursive flag to scan them'); } else { this.logger.info('💡 Tip: Use --recursive flag to scan subdirectories for lock files and node_modules'); } } this.logger.verbose('Scan completed'); return results; } catch (error) { throw new Error(`Scan failed: ${error.message}`); } } /** * Scan a specific lock file for malicious packages * @param {string} lockFilePath - Path to the lock file * @param {Object} results - Results object to update * @param {Set} checkedPackages - Set of already checked packages */ async scanLockFile(lockFilePath, results, checkedPackages) { try { const packages = parseLockFile(lockFilePath); const lockFileName = path.basename(lockFilePath); for (const pkg of packages) { const packageKey = `${pkg.name}@${pkg.version}`; // Skip if already checked if (checkedPackages.has(packageKey)) { continue; } checkedPackages.add(packageKey); if (isMalicious(pkg.name, pkg.version)) { const finding = { packageName: pkg.name, version: pkg.version, source: lockFileName, filePath: lockFilePath, introducedBy: this.determineIntroducingPackage(pkg), isDirect: pkg.isDirect, dependencyPath: pkg.dependencyPath }; results.findings.push(finding); this.logger.verbose(`Found malicious package: ${pkg.name}@${pkg.version} in ${lockFileName}`); } } } catch (error) { this.logger.warn(`Failed to parse ${path.basename(lockFilePath)}: ${error.message}`); } } /** * Scan node_modules directory for malicious packages * @param {Object} results - Results object to update * @param {Set} checkedPackages - Set of already checked packages */ async scanNodeModulesDirectory(results, checkedPackages) { try { const packages = scanNodeModules(this.projectDir, this.recursive); if (packages.length > 0) { results.summary.nodeModulesScanned = true; for (const pkg of packages) { const packageKey = `${pkg.name}@${pkg.version}`; // Skip if already checked if (checkedPackages.has(packageKey)) { continue; } checkedPackages.add(packageKey); if (isMalicious(pkg.name, pkg.version)) { const finding = { packageName: pkg.name, version: pkg.version, source: 'node_modules', filePath: pkg.packageJsonPath, introducedBy: pkg.parentPackage, isDirect: pkg.isDirect, dependencyPath: this.getRelativePath(pkg.path) }; // Check if this finding already exists from lock file scan const existingFinding = results.findings.find(f => f.packageName === finding.packageName && f.version === finding.version ); if (!existingFinding) { results.findings.push(finding); this.logger.verbose(`Found malicious package: ${pkg.name}@${pkg.version} in node_modules`); } } } } } catch (error) { this.logger.warn(`Failed to scan node_modules: ${error.message}`); } } /** * Determine which package introduced a dependency * @param {Object} pkg - Package object * @returns {string|null} - Name of the introducing package or null */ determineIntroducingPackage(pkg) { if (pkg.isDirect) { return null; // Direct dependency } // Extract from dependency path if (pkg.dependencyPath && typeof pkg.dependencyPath === 'string') { if (pkg.dependencyPath.includes(' > ')) { // Yarn/npm format: "parent > child" const parts = pkg.dependencyPath.split(' > '); return parts[parts.length - 2]; // Parent of the current package } else if (pkg.dependencyPath.includes('/')) { // Extract from path-like structure const parts = pkg.dependencyPath.split('/'); const nodeModulesIndex = parts.lastIndexOf('node_modules'); if (nodeModulesIndex > 0) { const parentPart = parts[nodeModulesIndex - 1]; return parentPart !== 'node_modules' ? parentPart : null; } } } return pkg.parentPackage || null; } /** * Get relative path from project directory * @param {string} absolutePath - Absolute path * @returns {string} - Relative path */ getRelativePath(absolutePath) { return path.relative(this.projectDir, absolutePath); } /** * Detect if there are subdirectories that might contain projects * @returns {boolean} - True if potential sub-projects found */ detectPotentialSubProjects() { try { const fs = require('fs'); const entries = fs.readdirSync(this.projectDir, { withFileTypes: true }); for (const entry of entries) { if (entry.isDirectory() && !entry.name.startsWith('.') && entry.name !== 'node_modules') { const subDir = path.join(this.projectDir, entry.name); // Check if subdirectory contains package.json or lock files const lockFiles = ['package.json', 'package-lock.json', 'yarn.lock', 'pnpm-lock.yaml']; for (const lockFile of lockFiles) { if (fs.existsSync(path.join(subDir, lockFile))) { return true; } } // Check if subdirectory has node_modules if (fs.existsSync(path.join(subDir, 'node_modules'))) { return true; } } } return false; } catch (error) { return false; } } /** * Get scan statistics * @param {Object} results - Scan results * @returns {Object} - Statistics object */ getStatistics(results) { const stats = { totalFindings: results.findings.length, findingsBySource: {}, findingsByPackage: {}, directDependencies: 0, transitiveDependencies: 0 }; results.findings.forEach(finding => { // Count by source stats.findingsBySource[finding.source] = (stats.findingsBySource[finding.source] || 0) + 1; // Count by package stats.findingsByPackage[finding.packageName] = (stats.findingsByPackage[finding.packageName] || 0) + 1; // Count direct vs transitive if (finding.isDirect) { stats.directDependencies++; } else { stats.transitiveDependencies++; } }); return stats; } } module.exports = { Scanner };