UNPKG

is-my-code-pwned

Version:

Advanced security scanner for detecting malicious npm packages and analyzing vulnerability risks in Node.js projects

873 lines (763 loc) 30.1 kB
const fs = require('fs'); const path = require('path'); const { execSync } = require('child_process'); class RiskAnalyzer { constructor(database, logger) { this.db = database; this.logger = logger; } analyzeVulnerabilityRisks(targetPath) { const risks = []; this.logger.info('Analyzing vulnerability risks'); // Análisis exhaustivo de todos los archivos de configuración this.analyzePackageJson(targetPath, risks); this.analyzeLockFiles(targetPath, risks); this.analyzeNpmConfig(targetPath, risks); this.analyzeEnvironment(risks); this.analyzePackageManagerVersions(risks); this.analyzeRegistryConfiguration(risks); this.checkForUnsafeConfigurations(targetPath, risks); return risks; } analyzePackageJson(targetPath, risks) { const packageJsonPath = path.join(targetPath, 'package.json'); if (!fs.existsSync(packageJsonPath)) { risks.push({ type: 'no-package-json', severity: 'medium', message: 'No package.json found - not a Node.js project or missing configuration', recommendation: 'Ensure this is a Node.js project with proper package.json' }); return; } try { const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); // Verificar dependencias con rangos peligrosos this.checkDependencyRanges(packageJson.dependencies, 'dependencies', risks); this.checkDependencyRanges(packageJson.devDependencies, 'devDependencies', risks); this.checkDependencyRanges(packageJson.peerDependencies, 'peerDependencies', risks); this.checkDependencyRanges(packageJson.optionalDependencies, 'optionalDependencies', risks); // Verificar scripts peligrosos this.analyzePackageScripts(packageJson.scripts, risks); // Verificar configuración de engine this.analyzeEngineRequirements(packageJson.engines, risks); // Verificar repositorio y autor this.analyzePackageMetadata(packageJson, risks); } catch (error) { risks.push({ type: 'invalid-package-json', severity: 'high', message: `Invalid package.json: ${error.message}`, recommendation: 'Fix package.json syntax errors' }); } } checkDependencyRanges(deps, depType, risks) { if (!deps) return; for (const [packageName, version] of Object.entries(deps)) { // Verificar si es un paquete malicioso conocido if (this.db.getMaliciousVersion(packageName)) { const maliciousVersion = this.db.getMaliciousVersion(packageName); if (this.versionRangeCouldInclude(version, maliciousVersion)) { risks.push({ type: 'vulnerable-range', severity: 'critical', message: `${packageName} uses range "${version}" which could install malicious version ${maliciousVersion}`, package: packageName, currentRange: version, maliciousVersion: maliciousVersion, dependencyType: depType, recommendation: `Pin to safe version (not ${maliciousVersion}): "${packageName}": "SAFE_VERSION"` }); } } // Verificar rangos muy amplios if (this.isVeryLooseRange(version)) { risks.push({ type: 'loose-dependency', severity: version === '*' ? 'high' : 'medium', message: `${packageName} uses very loose version "${version}"`, package: packageName, version: version, dependencyType: depType, recommendation: 'Pin to specific version to avoid unexpected updates' }); } // Verificar uso de carets y tildes que permiten actualizaciones automáticas if (this.allowsAutomaticUpdates(version)) { risks.push({ type: 'automatic-updates-allowed', severity: 'high', message: `${packageName} uses range "${version}" allowing automatic updates`, package: packageName, version: version, dependencyType: depType, recommendation: 'Consider pinning to exact version to prevent supply chain attacks: "' + packageName + '": "' + version.replace(/^[\^~]/, '') + '"' }); } // Verificar protocolos de fuente sospechosos this.checkSuspiciousSourceProtocol(packageName, version, depType, risks); } } versionRangeCouldInclude(range, targetVersion) { // Si no es un rango, verificar coincidencia exacta if (!this.isVersionRange(range)) { return range === targetVersion; } // Verificar si el rango específico podría incluir la versión objetivo return this.rangeIncludesVersion(range, targetVersion); } isVersionRange(version) { return version.includes('^') || version.includes('~') || version === '*' || version.includes('x') || version.includes('latest') || version.includes('>') || version.includes('<') || version.includes('>=') || version.includes('<=') || version.includes('||'); } rangeIncludesVersion(range, targetVersion) { try { // Manejar casos específicos de rangos comunes if (range === '*' || range === 'latest') { return true; // Incluye cualquier versión } // Parsear versión objetivo const target = this.parseVersion(targetVersion); if (!target) return true; // Si no se puede parsear, asumir riesgo // Manejar caret (^1.2.3 permite 1.x.x pero no 2.x.x) if (range.startsWith('^')) { const baseVersion = range.substring(1); const base = this.parseVersion(baseVersion); if (!base) return true; // Si no se puede parsear, asumir riesgo // Caret permite cambios compatibles: mismo major version return target.major === base.major && (target.major > 0 ? (target.minor > base.minor || (target.minor === base.minor && target.patch >= base.patch)) : // Para 0.x.x, caret es más restrictivo (base.minor === 0 ? (target.minor === base.minor && target.patch >= base.patch) : (target.minor === base.minor && target.patch >= base.patch))); } // Manejar tilde (~1.2.3 permite 1.2.x pero no 1.3.x) if (range.startsWith('~')) { const baseVersion = range.substring(1); const base = this.parseVersion(baseVersion); if (!base) return true; // Si no se puede parsear, asumir riesgo // Tilde permite cambios de patch: mismo major.minor return target.major === base.major && target.minor === base.minor && target.patch >= base.patch; } // Manejar rangos con operadores if (range.includes('>=') || range.includes('<=') || range.includes('>') || range.includes('<')) { // Para rangos complejos, asumir que podría incluir la versión return true; } // Manejar rangos con x (1.2.x, 1.x.x) if (range.includes('x') || range.includes('X')) { const parts = range.split('.'); const targetParts = targetVersion.split('.'); for (let i = 0; i < parts.length; i++) { if (parts[i] === 'x' || parts[i] === 'X') { continue; // x coincide con cualquier valor } if (parts[i] !== targetParts[i]) { return false; } } return true; } // Si llegamos aquí y sigue siendo un rango, asumir riesgo return this.isVersionRange(range); } catch (error) { // En caso de error, asumir que el rango podría incluir la versión return true; } } parseVersion(version) { try { // Limpiar prefijos y sufijos comunes const cleaned = version.replace(/^[v=]/, '').split('-')[0].split('+')[0]; const parts = cleaned.split('.'); if (parts.length < 3) { // Añadir partes faltantes como 0 while (parts.length < 3) { parts.push('0'); } } return { major: parseInt(parts[0]) || 0, minor: parseInt(parts[1]) || 0, patch: parseInt(parts[2]) || 0 }; } catch (error) { return null; } } isVeryLooseRange(version) { return version === '*' || version.includes('x') || version === 'latest' || version === '>0.0.0' || /^\^0\./.test(version) || // ^0.x es muy peligroso /^~0\./.test(version); // ~0.x también } allowsAutomaticUpdates(version) { // Detectar carets (^) y tildes (~) que permiten actualizaciones automáticas return version.startsWith('^') || version.startsWith('~'); } checkSuspiciousSourceProtocol(packageName, version, depType, risks) { // Verificar fuentes sospechosas const suspiciousPatterns = [ 'git+http://', // HTTP no seguro 'file://', // Archivos locales 'ftp://', // FTP 'git+ssh://', // SSH sin verificación ]; for (const pattern of suspiciousPatterns) { if (version.includes(pattern)) { risks.push({ type: 'suspicious-source', severity: 'high', message: `${packageName} uses suspicious source: ${version}`, package: packageName, source: version, dependencyType: depType, recommendation: 'Use official npm registry or verify source security' }); } } } analyzePackageScripts(scripts, risks) { if (!scripts) return; const dangerousCommands = [ 'rm -rf', 'sudo ', 'curl | sh', 'wget | sh', 'eval(', '$((', 'chmod +x', '> /dev/', 'dd if=', 'format ', 'fdisk' ]; for (const [scriptName, command] of Object.entries(scripts)) { for (const dangerous of dangerousCommands) { if (command.includes(dangerous)) { risks.push({ type: 'dangerous-script', severity: 'critical', message: `Script "${scriptName}" contains potentially dangerous command: ${dangerous}`, script: scriptName, command: command, recommendation: 'Review and verify script safety before execution' }); } } // Verificar descarga de archivos externos if (command.includes('http://') || (command.includes('curl') && command.includes('http'))) { risks.push({ type: 'external-download-script', severity: 'high', message: `Script "${scriptName}" downloads external content over HTTP`, script: scriptName, recommendation: 'Use HTTPS and verify downloaded content integrity' }); } } } analyzeEngineRequirements(engines, risks) { if (!engines) return; if (engines.node) { try { const currentNodeVersion = process.version; if (engines.node.includes('<') && !engines.node.includes('>=')) { risks.push({ type: 'outdated-node-requirement', severity: 'medium', message: `Package requires old Node.js version: ${engines.node}`, requirement: engines.node, current: currentNodeVersion, recommendation: 'Consider updating to support newer Node.js versions' }); } } catch (error) { this.logger.debug('Could not analyze Node version requirement'); } } } analyzePackageMetadata(packageJson, risks) { // Verificar repositorio if (!packageJson.repository) { risks.push({ type: 'no-repository', severity: 'low', message: 'Package has no repository information', recommendation: 'Verify package legitimacy through other means' }); } // Verificar autor if (!packageJson.author && !packageJson.contributors) { risks.push({ type: 'no-author', severity: 'low', message: 'Package has no author information', recommendation: 'Be cautious with anonymous packages' }); } // Verificar homepage sospechosa if (packageJson.homepage) { if (packageJson.homepage.includes('bit.ly') || packageJson.homepage.includes('tinyurl') || !packageJson.homepage.startsWith('https://')) { risks.push({ type: 'suspicious-homepage', severity: 'medium', message: `Suspicious homepage URL: ${packageJson.homepage}`, url: packageJson.homepage, recommendation: 'Verify package legitimacy' }); } } } analyzeLockFiles(targetPath, risks) { const lockFiles = [ { file: 'package-lock.json', type: 'npm' }, { file: 'yarn.lock', type: 'yarn' }, { file: 'pnpm-lock.yaml', type: 'pnpm' }, { file: 'npm-shrinkwrap.json', type: 'npm-shrinkwrap' } ]; const existingLockFiles = lockFiles.filter(({ file }) => fs.existsSync(path.join(targetPath, file)) ); if (existingLockFiles.length === 0) { risks.push({ type: 'no-lock-file', severity: 'critical', message: 'No lock file found - versions are not pinned, exposing to supply chain attacks', recommendation: 'Run npm install, yarn install, or pnpm install to generate lock file' }); } else if (existingLockFiles.length > 1) { risks.push({ type: 'multiple-lock-files', severity: 'high', message: `Multiple lock files found: ${existingLockFiles.map(f => f.file).join(', ')}`, files: existingLockFiles.map(f => f.file), recommendation: 'Use only one package manager to avoid conflicts' }); } // Analizar contenido de lock files for (const { file, type } of existingLockFiles) { this.analyzeLockFileContent(path.join(targetPath, file), type, risks); } } analyzeLockFileContent(lockFilePath, type, risks) { try { const content = fs.readFileSync(lockFilePath, 'utf8'); // Verificar registros sospechosos en lock files - solo si no son registros configurados legítimamente const suspiciousPatterns = [ 'http://registry', // HTTP no seguro 'localhost:', // Registro local '127.0.0.1:', // Registro local '192.168.', // Red interna '10.', // Red interna ]; // Obtener registros configurados legítimamente const configuredRegistries = this.getConfiguredRegistries(); for (const pattern of suspiciousPatterns) { if (content.includes(pattern)) { // Extraer la URL completa del registro const registryMatch = content.match(new RegExp(`https?://[^"'\\s]+`)); const fullRegistry = registryMatch ? registryMatch[0] : pattern; // Solo marcar como sospechoso si no está en la configuración de npm if (!configuredRegistries.some(reg => fullRegistry.includes(reg))) { risks.push({ type: 'suspicious-registry-in-lock', severity: 'high', message: `Lock file contains unconfigured registry: ${fullRegistry}`, lockFile: path.basename(lockFilePath), registry: fullRegistry, recommendation: 'Verify this registry is intended and properly configured' }); } else { // Si está configurado, solo advertir si es HTTP if (fullRegistry.startsWith('http://')) { risks.push({ type: 'insecure-configured-registry', severity: 'medium', message: `Configured registry uses HTTP: ${fullRegistry}`, lockFile: path.basename(lockFilePath), registry: fullRegistry, recommendation: 'Switch to HTTPS version of your private registry' }); } } } } // Verificar integridad if (type === 'npm' && !content.includes('"integrity":')) { risks.push({ type: 'no-integrity-hashes', severity: 'high', message: 'npm lock file lacks integrity hashes', lockFile: path.basename(lockFilePath), recommendation: 'Update npm and regenerate lock file for integrity protection' }); } } catch (error) { risks.push({ type: 'invalid-lock-file', severity: 'high', message: `Cannot read or parse lock file: ${error.message}`, lockFile: path.basename(lockFilePath), recommendation: 'Regenerate lock file' }); } } getConfiguredRegistries() { const registries = []; try { // Obtener registro por defecto const defaultRegistry = execSync('npm config get registry', { encoding: 'utf8', stderr: 'ignore' }).trim(); if (defaultRegistry) registries.push(defaultRegistry); // Obtener registros con scope const configList = execSync('npm config list', { encoding: 'utf8', stderr: 'ignore' }); const scopedMatches = configList.match(/@[^:]+:registry\s*=\s*([^\s]+)/g); if (scopedMatches) { for (const match of scopedMatches) { const registry = match.split('=')[1].trim(); registries.push(registry); } } } catch (error) { this.logger.debug('Could not get configured registries'); } return registries; } analyzeNpmConfig(targetPath, risks) { // Verificar .npmrc local (esto SÍ es importante) const npmrcPath = path.join(targetPath, '.npmrc'); if (fs.existsSync(npmrcPath)) { this.analyzeNpmrcFile(npmrcPath, 'project', risks, targetPath); } // Solo verificar .npmrc global para ciertas configuraciones peligrosas, NO para tokens try { const globalNpmrc = path.join(require('os').homedir(), '.npmrc'); if (fs.existsSync(globalNpmrc)) { this.analyzeGlobalNpmrcFile(globalNpmrc, risks); } } catch (error) { this.logger.debug('Could not check global .npmrc'); } } analyzeNpmrcFile(npmrcPath, scope, risks, projectPath) { try { const content = fs.readFileSync(npmrcPath, 'utf8'); const lines = content.split('\n').filter(line => line.trim() && !line.startsWith('#')); for (const line of lines) { // Verificar registros no seguros if (line.includes('registry=http://')) { risks.push({ type: 'insecure-registry', severity: 'critical', message: `${scope} .npmrc uses insecure HTTP registry`, file: npmrcPath, line: line.trim(), recommendation: 'Use HTTPS registry only' }); } // Verificar configuraciones peligrosas if (line.includes('ignore-scripts=false')) { risks.push({ type: 'scripts-enabled', severity: 'high', message: `${scope} .npmrc explicitly enables package scripts`, file: npmrcPath, recommendation: 'Consider disabling scripts for security' }); } if (line.includes('audit=false') || line.includes('audit-level=none')) { risks.push({ type: 'audit-disabled', severity: 'medium', message: `${scope} .npmrc disables security auditing`, file: npmrcPath, recommendation: 'Enable npm audit for security checks' }); } // SOLO verificar tokens en .npmrc del proyecto (no global) if (line.includes('_authToken=') && !line.includes('${') && scope === 'project') { const isIgnored = this.isFileIgnoredByGit(npmrcPath); risks.push({ type: 'exposed-auth-token', severity: 'critical', message: `Project .npmrc contains exposed authentication token`, file: npmrcPath, gitIgnored: isIgnored, recommendation: isIgnored ? 'Token is git-ignored but still visible in filesystem - use environment variables' : 'URGENT: Token will be committed to git! Use environment variables and add .npmrc to .gitignore' }); } } } catch (error) { this.logger.warning(`Could not analyze .npmrc: ${error.message}`, { path: npmrcPath }); } } analyzeGlobalNpmrcFile(globalNpmrcPath, risks) { try { const content = fs.readFileSync(globalNpmrcPath, 'utf8'); const lines = content.split('\n').filter(line => line.trim() && !line.startsWith('#')); for (const line of lines) { // Solo verificar configuraciones que afectan a todos los proyectos if (line.includes('registry=http://')) { risks.push({ type: 'insecure-global-registry', severity: 'high', message: 'Global npm config uses insecure HTTP registry', file: globalNpmrcPath, line: line.trim(), recommendation: 'Change global registry to HTTPS: npm config set registry https://registry.npmjs.org/' }); } // TLS deshabilitado globalmente es muy peligroso if (line.includes('strict-ssl=false')) { risks.push({ type: 'global-tls-disabled', severity: 'critical', message: 'Global npm config disables SSL verification', file: globalNpmrcPath, recommendation: 'Enable SSL: npm config set strict-ssl true' }); } // NO reportar tokens globales - es normal tenerlos } } catch (error) { this.logger.debug(`Could not analyze global .npmrc: ${error.message}`); } } analyzeEnvironment(risks) { // Verificar variables de entorno peligrosas const dangerousEnvVars = [ 'NPM_CONFIG_REGISTRY', 'npm_config_registry', 'YARN_REGISTRY', 'NODE_TLS_REJECT_UNAUTHORIZED' ]; for (const envVar of dangerousEnvVars) { if (process.env[envVar]) { const value = process.env[envVar]; if (envVar.includes('REGISTRY') && value.startsWith('http://')) { risks.push({ type: 'insecure-registry-env', severity: 'critical', message: `Environment variable ${envVar} uses insecure HTTP registry`, variable: envVar, value: value, recommendation: 'Use HTTPS registry or remove environment override' }); } if (envVar === 'NODE_TLS_REJECT_UNAUTHORIZED' && value === '0') { risks.push({ type: 'tls-disabled', severity: 'critical', message: 'TLS certificate verification is disabled', variable: envVar, recommendation: 'Remove NODE_TLS_REJECT_UNAUTHORIZED=0 for security' }); } } } } analyzePackageManagerVersions(risks) { const packageManagers = [ { cmd: 'npm --version', name: 'npm', minSecureVersion: '8.0.0' }, { cmd: 'yarn --version', name: 'yarn', minSecureVersion: '1.22.0' }, { cmd: 'pnpm --version', name: 'pnpm', minSecureVersion: '7.0.0' } ]; for (const pm of packageManagers) { try { const version = execSync(pm.cmd, { encoding: 'utf8', stderr: 'ignore' }).trim(); if (this.isVersionOutdated(version, pm.minSecureVersion)) { risks.push({ type: 'outdated-package-manager', severity: 'medium', message: `${pm.name} v${version} is outdated (recommend v${pm.minSecureVersion}+)`, packageManager: pm.name, currentVersion: version, recommendedVersion: pm.minSecureVersion, recommendation: `Update ${pm.name} to latest version for security fixes` }); } } catch (error) { this.logger.debug(`Could not check ${pm.name} version`); } } } analyzeRegistryConfiguration(risks) { try { // Verificar configuración actual del registro const registry = execSync('npm config get registry', { encoding: 'utf8', stderr: 'ignore' }).trim(); if (registry && registry.startsWith('http://')) { risks.push({ type: 'insecure-default-registry', severity: 'critical', message: `Default npm registry uses insecure HTTP: ${registry}`, registry: registry, recommendation: 'Configure secure HTTPS registry: npm config set registry https://registry.npmjs.org/' }); } // Verificar registros con scope configurados const scopedRegistries = execSync('npm config list', { encoding: 'utf8', stderr: 'ignore' }); const lines = scopedRegistries.split('\n'); for (const line of lines) { if (line.includes(':registry = http://')) { risks.push({ type: 'insecure-scoped-registry', severity: 'high', message: `Scoped registry uses insecure HTTP: ${line.trim()}`, config: line.trim(), recommendation: 'Configure scoped registries to use HTTPS' }); } } } catch (error) { this.logger.debug('Could not check registry configuration'); } } checkForUnsafeConfigurations(targetPath, risks) { // Verificar archivos de configuración adicionales que podrían ser peligrosos const configFiles = [ '.yarnrc', '.yarnrc.yml', '.pnpmfile.cjs', 'pnpm-workspace.yaml', 'lerna.json' ]; for (const configFile of configFiles) { const filePath = path.join(targetPath, configFile); if (fs.existsSync(filePath)) { this.analyzeConfigFile(filePath, risks); } } } analyzeConfigFile(filePath, risks) { try { const content = fs.readFileSync(filePath, 'utf8'); const fileName = path.basename(filePath); // Verificar URLs HTTP en archivos de configuración if (content.includes('http://')) { risks.push({ type: 'insecure-config-url', severity: 'high', message: `Configuration file ${fileName} contains insecure HTTP URLs`, file: filePath, recommendation: 'Replace HTTP URLs with HTTPS equivalents' }); } // Verificar configuraciones específicas por tipo de archivo if (fileName.includes('.yarnrc')) { this.analyzeYarnrcContent(content, filePath, risks); } } catch (error) { this.logger.debug(`Could not analyze config file: ${filePath}`); } } analyzeYarnrcContent(content, filePath, risks) { if (content.includes('ignore-scripts false')) { risks.push({ type: 'yarn-scripts-enabled', severity: 'high', message: 'Yarn configuration enables package scripts', file: filePath, recommendation: 'Consider setting ignore-scripts true for security' }); } if (content.includes('disable-self-update-check true')) { risks.push({ type: 'yarn-update-disabled', severity: 'low', message: 'Yarn self-update check is disabled', file: filePath, recommendation: 'Enable update checks to stay current with security fixes' }); } } isVersionOutdated(current, minimum) { try { const currentParts = current.split('.').map(Number); const minimumParts = minimum.split('.').map(Number); for (let i = 0; i < Math.max(currentParts.length, minimumParts.length); i++) { const currentPart = currentParts[i] || 0; const minimumPart = minimumParts[i] || 0; if (currentPart < minimumPart) return true; if (currentPart > minimumPart) return false; } return false; } catch (error) { return false; } } isFileIgnoredByGit(filePath) { try { const projectRoot = this.findProjectRoot(filePath); const gitignorePath = path.join(projectRoot, '.gitignore'); if (!fs.existsSync(gitignorePath)) { return false; // No .gitignore means file is not ignored } const gitignoreContent = fs.readFileSync(gitignorePath, 'utf8'); const relativePath = path.relative(projectRoot, filePath); // Simple gitignore pattern matching (basic implementation) const patterns = gitignoreContent .split('\n') .map(line => line.trim()) .filter(line => line && !line.startsWith('#')); for (const pattern of patterns) { if (this.matchesGitignorePattern(relativePath, pattern)) { return true; } } return false; } catch (error) { this.logger.debug(`Could not check gitignore status for ${filePath}: ${error.message}`); return false; } } findProjectRoot(startPath) { let currentPath = path.dirname(startPath); while (currentPath !== path.dirname(currentPath)) { if (fs.existsSync(path.join(currentPath, 'package.json')) || fs.existsSync(path.join(currentPath, '.git'))) { return currentPath; } currentPath = path.dirname(currentPath); } return path.dirname(startPath); // Fallback to file's directory } matchesGitignorePattern(filePath, pattern) { // Basic gitignore pattern matching if (pattern === filePath) return true; if (pattern === path.basename(filePath)) return true; // Handle wildcards (very basic) if (pattern.includes('*')) { const regexPattern = pattern.replace(/\*/g, '.*'); const regex = new RegExp(`^${regexPattern}$`); return regex.test(filePath) || regex.test(path.basename(filePath)); } // Handle directory patterns if (pattern.endsWith('/')) { return filePath.startsWith(pattern) || filePath.includes(`/${pattern.slice(0, -1)}/`); } return false; } } module.exports = RiskAnalyzer;