am-i-secure
Version:
A CLI tool to detect malicious npm packages in your project dependencies
269 lines (230 loc) • 10.3 kB
JavaScript
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 };