UNPKG

super-confused

Version:

Super-confused is a next-gen dependency confusion analysis tool that works on local or remote package manifests or SBOMs.

779 lines (665 loc) 29.1 kB
#!/usr/bin/env node const fs = require('fs'); const path = require('path'); const https = require('https'); const { promisify } = require('util'); const readFile = promisify(fs.readFile); const readdir = promisify(fs.readdir); const stat = promisify(fs.stat); // ANSI color codes const colors = { red: '\x1b[31m', reset: '\x1b[0m' }; function colorize(text, color) { return colors[color] + text + colors.reset; } class SuperConfused { constructor(jsonMode = false) { this.results = []; this.jsonMode = jsonMode; this.supportedFiles = { 'package.json': this.scanPackageJson, 'requirements.txt': this.scanRequirementsTxt, 'pyproject.toml': this.scanPyprojectToml, 'go.mod': this.scanGoMod, 'go.sum': this.scanGoSum, 'Cargo.toml': this.scanCargoToml, 'pom.xml': this.scanPomXml, 'build.gradle': this.scanGradle, 'composer.json': this.scanComposerJson, 'Gemfile': this.scanGemfile, 'yarn.lock': this.scanYarnLock, 'package-lock.json': this.scanPackageLock, 'bom.json': this.scanSbom, 'sbom.json': this.scanSbom, 'bom.xml': this.scanSbomXml, 'sbom.xml': this.scanSbomXml }; } async scan(targetPath) { if (!this.jsonMode) { console.log(`Scanning ${targetPath} for dependency confusion opportunities...`); } if (targetPath.startsWith('http://') || targetPath.startsWith('https://')) { await this.scanUrl(targetPath); } else { const isDirectory = (await stat(targetPath)).isDirectory(); if (isDirectory) { await this.scanDirectory(targetPath); } else { await this.scanFile(targetPath); } } this.printResults(); return this.results; } async scanUrl(url) { try { // Convert GitHub blob URLs to raw URLs let rawUrl = url; if (url.includes('github.com') && url.includes('/blob/')) { rawUrl = url.replace('github.com', 'raw.githubusercontent.com').replace('/blob/', '/'); } else if (url.includes('gitlab.com') && url.includes('/blob/')) { rawUrl = url.replace('/blob/', '/raw/'); } const content = await this.fetchUrl(rawUrl); const fileName = this.getFileNameFromUrl(url); if (this.supportedFiles[fileName]) { const scanner = this.supportedFiles[fileName].bind(this); await scanner(url, content); } else { if (!this.jsonMode) { console.error(`Unsupported file type: ${fileName}`); } } } catch (error) { if (!this.jsonMode) { console.error(`Error fetching URL ${url}: ${error.message}`); } } } getFileNameFromUrl(url) { const pathname = new URL(url).pathname; const fileName = pathname.split('/').pop(); return fileName; } fetchUrl(url) { return new Promise((resolve, reject) => { const request = https.get(url, { timeout: 10000 }, (response) => { if (response.statusCode === 200) { let data = ''; response.on('data', chunk => { data += chunk; }); response.on('end', () => { resolve(data); }); } else if (response.statusCode === 302 || response.statusCode === 301) { // Handle redirects this.fetchUrl(response.headers.location).then(resolve).catch(reject); } else { reject(new Error(`HTTP ${response.statusCode}`)); } }); request.on('error', reject); request.on('timeout', () => { request.destroy(); reject(new Error('Request timeout')); }); }); } async scanDirectory(dirPath) { try { const entries = await readdir(dirPath); for (const entry of entries) { const fullPath = path.join(dirPath, entry); const stats = await stat(fullPath); if (stats.isDirectory() && !entry.startsWith('.') && entry !== 'node_modules') { await this.scanDirectory(fullPath); } else if (stats.isFile() && this.supportedFiles[entry]) { await this.scanFile(fullPath); } } } catch (error) { if (!this.jsonMode) { console.error(`Error scanning directory ${dirPath}: ${error.message}`); } } } async scanFile(filePath) { const fileName = path.basename(filePath); if (!this.supportedFiles[fileName]) { return; } try { const content = await readFile(filePath, 'utf8'); const scanner = this.supportedFiles[fileName].bind(this); await scanner(filePath, content); } catch (error) { if (!this.jsonMode) { console.error(`Error reading ${filePath}: ${error.message}`); } } } async scanPackageJson(filePath, content) { try { const packageData = JSON.parse(content); const dependencies = { ...packageData.dependencies, ...packageData.devDependencies, ...packageData.peerDependencies, ...packageData.optionalDependencies }; for (const [name, version] of Object.entries(dependencies || {})) { if (this.isPotentiallyVulnerable(name)) { const exists = await this.checkNpmPackageExists(name); this.addResult(filePath, 'npm', name, version, exists); } } } catch (error) { if (!this.jsonMode) { console.error(`Error parsing package.json: ${error.message}`); } } } async scanPackageLock(filePath, content) { try { const lockData = JSON.parse(content); const dependencies = lockData.dependencies || {}; for (const [name, info] of Object.entries(dependencies)) { if (this.isPotentiallyVulnerable(name)) { const exists = await this.checkNpmPackageExists(name); this.addResult(filePath, 'npm', name, info.version, exists); } } } catch (error) { if (!this.jsonMode) { console.error(`Error parsing package-lock.json: ${error.message}`); } } } async scanYarnLock(filePath, content) { const lines = content.split('\n'); const packages = new Set(); for (const line of lines) { const match = line.match(/^"?([^@\s]+)@/); if (match && this.isPotentiallyVulnerable(match[1])) { packages.add(match[1]); } } for (const packageName of packages) { const exists = await this.checkNpmPackageExists(packageName); this.addResult(filePath, 'npm', packageName, 'unknown', exists); } } async scanRequirementsTxt(filePath, content) { const lines = content.split('\n'); for (const line of lines) { const trimmed = line.trim(); if (trimmed && !trimmed.startsWith('#')) { const packageMatch = trimmed.match(/^([a-zA-Z0-9\-_\.]+)/); if (packageMatch && this.isPotentiallyVulnerable(packageMatch[1])) { const exists = await this.checkPyPiPackageExists(packageMatch[1]); this.addResult(filePath, 'pypi', packageMatch[1], 'unknown', exists); } } } } async scanPyprojectToml(filePath, content) { const lines = content.split('\n'); let inDependencies = false; let inOptionalDependencies = false; for (const line of lines) { const trimmed = line.trim(); // Check for dependencies section if (trimmed === 'dependencies = [') { inDependencies = true; inOptionalDependencies = false; continue; } // Check for optional-dependencies section if (trimmed.includes('optional-dependencies') && trimmed.includes('[')) { inOptionalDependencies = true; inDependencies = false; continue; } // Check for end of section if (trimmed === ']' && (inDependencies || inOptionalDependencies)) { inDependencies = false; inOptionalDependencies = false; continue; } // Parse dependencies if ((inDependencies || inOptionalDependencies) && trimmed.includes('"')) { // Match patterns like "requests>=2.25.1" or "flask==2.0.0" const depMatch = trimmed.match(/"([a-zA-Z0-9\-_\.]+)/); if (depMatch && this.isPotentiallyVulnerable(depMatch[1])) { const exists = await this.checkPyPiPackageExists(depMatch[1]); this.addResult(filePath, 'pypi', depMatch[1], 'unknown', exists); } } // Also check for [project] dependencies format if (trimmed.startsWith('dependencies') && trimmed.includes('=') && trimmed.includes('[')) { // Handle single-line dependencies array const depsMatch = trimmed.match(/dependencies\s*=\s*\[(.*)\]/); if (depsMatch) { const depsString = depsMatch[1]; const deps = depsString.split(','); for (const dep of deps) { const cleanDep = dep.trim().replace(/['"]/g, ''); const packageMatch = cleanDep.match(/^([a-zA-Z0-9\-_\.]+)/); if (packageMatch && this.isPotentiallyVulnerable(packageMatch[1])) { const exists = await this.checkPyPiPackageExists(packageMatch[1]); this.addResult(filePath, 'pypi', packageMatch[1], 'unknown', exists); } } } } } } async scanSbom(filePath, content) { try { const sbomData = JSON.parse(content); // Detect SBOM format and extract components if (sbomData.bomFormat === 'CycloneDX') { await this.scanCycloneDx(filePath, sbomData); } else if (sbomData.spdxVersion) { await this.scanSpdx(filePath, sbomData); } else { // Try to auto-detect based on structure if (sbomData.components) { await this.scanCycloneDx(filePath, sbomData); } else if (sbomData.packages) { await this.scanSpdx(filePath, sbomData); } } } catch (error) { if (!this.jsonMode) { console.error(`Error parsing SBOM file: ${error.message}`); } } } async scanCycloneDx(filePath, sbomData) { const components = sbomData.components || []; for (const component of components) { if (component.name && component.type === 'library') { const packageName = component.name; const version = component.version || 'unknown'; const ecosystem = this.detectEcosystemFromPurl(component.purl) || 'unknown'; if (this.isPotentiallyVulnerable(packageName)) { let exists = 'unknown'; // Check package existence based on ecosystem if (ecosystem === 'npm') { exists = await this.checkNpmPackageExists(packageName); } else if (ecosystem === 'pypi') { exists = await this.checkPyPiPackageExists(packageName); } else if (ecosystem === 'cargo') { exists = await this.checkCratesIoPackageExists(packageName); } else if (ecosystem === 'packagist') { exists = await this.checkPackagistPackageExists(packageName); } else if (ecosystem === 'gem') { exists = await this.checkRubyGemsPackageExists(packageName); } this.addResult(filePath, ecosystem, packageName, version, exists); } } } } async scanSpdx(filePath, sbomData) { const packages = sbomData.packages || []; for (const pkg of packages) { if (pkg.name && pkg.name !== sbomData.name) { const packageName = pkg.name; const version = pkg.versionInfo || 'unknown'; const ecosystem = this.detectEcosystemFromSpdx(pkg) || 'unknown'; if (this.isPotentiallyVulnerable(packageName)) { let exists = 'unknown'; // Check package existence based on ecosystem if (ecosystem === 'npm') { exists = await this.checkNpmPackageExists(packageName); } else if (ecosystem === 'pypi') { exists = await this.checkPyPiPackageExists(packageName); } else if (ecosystem === 'cargo') { exists = await this.checkCratesIoPackageExists(packageName); } else if (ecosystem === 'packagist') { exists = await this.checkPackagistPackageExists(packageName); } else if (ecosystem === 'gem') { exists = await this.checkRubyGemsPackageExists(packageName); } this.addResult(filePath, ecosystem, packageName, version, exists); } } } } async scanSbomXml(filePath, content) { // Basic XML parsing for CycloneDX XML format const componentRegex = /<component[^>]*type="library"[^>]*>[\s\S]*?<name>([^<]+)<\/name>[\s\S]*?(?:<version>([^<]+)<\/version>)?[\s\S]*?(?:<purl>([^<]+)<\/purl>)?[\s\S]*?<\/component>/g; let match; while ((match = componentRegex.exec(content)) !== null) { const packageName = match[1]; const version = match[2] || 'unknown'; const purl = match[3]; const ecosystem = this.detectEcosystemFromPurl(purl) || 'unknown'; if (this.isPotentiallyVulnerable(packageName)) { let exists = 'unknown'; // Check package existence based on ecosystem if (ecosystem === 'npm') { exists = await this.checkNpmPackageExists(packageName); } else if (ecosystem === 'pypi') { exists = await this.checkPyPiPackageExists(packageName); } else if (ecosystem === 'cargo') { exists = await this.checkCratesIoPackageExists(packageName); } else if (ecosystem === 'packagist') { exists = await this.checkPackagistPackageExists(packageName); } else if (ecosystem === 'gem') { exists = await this.checkRubyGemsPackageExists(packageName); } this.addResult(filePath, ecosystem, packageName, version, exists); } } } detectEcosystemFromPurl(purl) { if (!purl) return null; if (purl.startsWith('pkg:npm/')) return 'npm'; if (purl.startsWith('pkg:pypi/')) return 'pypi'; if (purl.startsWith('pkg:cargo/')) return 'cargo'; if (purl.startsWith('pkg:composer/')) return 'packagist'; if (purl.startsWith('pkg:gem/')) return 'gem'; if (purl.startsWith('pkg:maven/')) return 'maven'; if (purl.startsWith('pkg:golang/')) return 'go'; return null; } detectEcosystemFromSpdx(pkg) { // Try to detect ecosystem from SPDX package info const downloadLocation = pkg.downloadLocation || ''; const packageFileName = pkg.packageFileName || ''; const homepage = pkg.homepage || ''; if (downloadLocation.includes('npmjs.org') || packageFileName.includes('.tgz')) { return 'npm'; } if (downloadLocation.includes('pypi.org') || downloadLocation.includes('files.pythonhosted.org')) { return 'pypi'; } if (downloadLocation.includes('crates.io')) { return 'cargo'; } if (downloadLocation.includes('packagist.org')) { return 'packagist'; } if (downloadLocation.includes('rubygems.org')) { return 'gem'; } if (downloadLocation.includes('maven') || packageFileName.includes('.jar')) { return 'maven'; } return null; } async scanGoMod(filePath, content) { const lines = content.split('\n'); for (const line of lines) { const trimmed = line.trim(); const requireMatch = trimmed.match(/^\s*([^\s]+)\s+v/); if (requireMatch) { const moduleName = requireMatch[1]; if (this.isPotentiallyVulnerable(moduleName)) { this.addResult(filePath, 'go', moduleName, 'unknown', 'unknown'); } } } } async scanGoSum(filePath, content) { const lines = content.split('\n'); const modules = new Set(); for (const line of lines) { const parts = line.split(' '); if (parts.length >= 2) { const modulePath = parts[0].split('/v')[0]; if (this.isPotentiallyVulnerable(modulePath)) { modules.add(modulePath); } } } for (const moduleName of modules) { this.addResult(filePath, 'go', moduleName, 'unknown', 'unknown'); } } async scanCargoToml(filePath, content) { const lines = content.split('\n'); let inDependencies = false; for (const line of lines) { const trimmed = line.trim(); if (trimmed === '[dependencies]' || trimmed === '[dev-dependencies]') { inDependencies = true; continue; } if (trimmed.startsWith('[') && trimmed !== '[dependencies]' && trimmed !== '[dev-dependencies]') { inDependencies = false; continue; } if (inDependencies && trimmed.includes('=')) { const packageMatch = trimmed.match(/^([a-zA-Z0-9\-_]+)\s*=/); if (packageMatch && this.isPotentiallyVulnerable(packageMatch[1])) { const exists = await this.checkCratesIoPackageExists(packageMatch[1]); this.addResult(filePath, 'crates.io', packageMatch[1], 'unknown', exists); } } } } async scanComposerJson(filePath, content) { try { const composerData = JSON.parse(content); const dependencies = { ...composerData.require, ...composerData['require-dev'] }; for (const [name, version] of Object.entries(dependencies || {})) { if (name !== 'php' && this.isPotentiallyVulnerable(name)) { const exists = await this.checkPackagistPackageExists(name); this.addResult(filePath, 'packagist', name, version, exists); } } } catch (error) { if (!this.jsonMode) { console.error(`Error parsing composer.json: ${error.message}`); } } } async scanGemfile(filePath, content) { const lines = content.split('\n'); for (const line of lines) { const trimmed = line.trim(); const gemMatch = trimmed.match(/gem\s+['"]([^'"]+)['"]/); if (gemMatch && this.isPotentiallyVulnerable(gemMatch[1])) { const exists = await this.checkRubyGemsPackageExists(gemMatch[1]); this.addResult(filePath, 'rubygems', gemMatch[1], 'unknown', exists); } } } async scanPomXml(filePath, content) { const dependencyRegex = /<groupId>([^<]+)<\/groupId>\s*<artifactId>([^<]+)<\/artifactId>/g; let match; while ((match = dependencyRegex.exec(content)) !== null) { const groupId = match[1]; const artifactId = match[2]; const fullName = `${groupId}:${artifactId}`; if (this.isPotentiallyVulnerable(artifactId)) { this.addResult(filePath, 'maven', fullName, 'unknown', 'unknown'); } } } async scanGradle(filePath, content) { const lines = content.split('\n'); for (const line of lines) { const trimmed = line.trim(); const depMatch = trimmed.match(/(?:implementation|compile|api|testImplementation)\s+['"]([^'"]+)['"]/); if (depMatch) { const parts = depMatch[1].split(':'); if (parts.length >= 2) { const artifactId = parts[1]; if (this.isPotentiallyVulnerable(artifactId)) { this.addResult(filePath, 'maven', depMatch[1], 'unknown', 'unknown'); } } } } } isPotentiallyVulnerable(packageName) { if (packageName.includes('://') || packageName.startsWith('git+')) { return false; } // For scoped packages, we still want to check them, but with different logic if (packageName.startsWith('@')) { // Only check scoped packages that look suspicious const scopedSuspiciousPatterns = [ /^@[a-z]+\/[a-z]{3,8}$/, // short package names in scope /^@[a-z]+\/(lib|utils?|helper|common|core|base|tools?|sdk)$/i, // generic names /^@[a-z]+\/(test|demo|example|sample)[-_]?/i, // test/demo packages ]; return scopedSuspiciousPatterns.some(pattern => pattern.test(packageName)); } const suspiciousPatterns = [ /^[a-z]+[-_][a-z]+$/, /^[a-z]{3,8}$/, /^(lib|utils?|helper|common|core|base|tools?|sdk)$/i, /^(test|demo|example|sample)[-_]?/i, ]; const wellKnownPackages = [ 'react', 'vue', 'angular', 'lodash', 'express', 'axios', 'moment', 'jquery', 'bootstrap', 'webpack', 'babel', 'eslint', 'jest', 'mocha', 'typescript', 'commander', 'chalk', 'inquirer', 'yargs', 'fs-extra', 'rimraf', 'glob', 'mkdirp', 'debug', 'semver', 'uuid', 'cors', 'dotenv', 'nodemon', 'concurrently', 'cross-env', 'husky', 'lint-staged' ]; if (wellKnownPackages.includes(packageName.toLowerCase())) { return false; } return suspiciousPatterns.some(pattern => pattern.test(packageName)) || packageName.length <= 4; } async checkNpmPackageExists(packageName) { const encodedPackageName = packageName.startsWith('@') ? packageName.replace('@', '%40') : packageName; return this.makeHttpRequest(`https://registry.npmjs.org/${encodedPackageName}`) .then(() => true) .catch(() => false); } async checkPyPiPackageExists(packageName) { return this.makeHttpRequest(`https://pypi.org/pypi/${packageName}/json`) .then(() => true) .catch(() => false); } async checkCratesIoPackageExists(packageName) { return this.makeHttpRequest(`https://crates.io/api/v1/crates/${packageName}`) .then(() => true) .catch(() => false); } async checkPackagistPackageExists(packageName) { return this.makeHttpRequest(`https://packagist.org/packages/${packageName}.json`) .then(() => true) .catch(() => false); } async checkRubyGemsPackageExists(packageName) { return this.makeHttpRequest(`https://rubygems.org/api/v1/gems/${packageName}.json`) .then(() => true) .catch(() => false); } makeHttpRequest(url) { return new Promise((resolve, reject) => { const request = https.get(url, { timeout: 5000 }, (response) => { if (response.statusCode === 200) { resolve(response); } else { reject(new Error(`Status: ${response.statusCode}`)); } }); request.on('error', reject); request.on('timeout', () => { request.destroy(); reject(new Error('Request timeout')); }); }); } addResult(filePath, ecosystem, packageName, version, exists) { const risk = exists === false ? 'HIGH' : exists === true ? 'LOW' : 'UNKNOWN'; this.results.push({ file: filePath, ecosystem, package: packageName, version, exists, risk }); } printResults() { if (this.jsonMode) { const vulnerabilities = this.results .filter(r => r.risk === 'HIGH' || r.risk === 'UNKNOWN'); if (vulnerabilities.length > 0) { const packages = vulnerabilities.map(result => ({ [result.package]: result.version })); const output = { "name": "super-confused", "description": "Identify dependeny confusion in your source code", "author": "6mile", "dependency-confused-packages": packages }; console.log(JSON.stringify(output, null, 2)); } return; } const vulnerabilities = this.results.filter(r => r.risk === 'HIGH' || r.risk === 'UNKNOWN'); if (vulnerabilities.length === 0) { console.log('No Dependency Confusion Opportunities found.'); return; } vulnerabilities.forEach(result => { console.log(''); console.log('DEPENDENCY CONFUSION OPPORTUNITY!'); console.log(`${result.package} (${result.ecosystem}) in ${result.file}`); console.log(`Version: ${result.version}`); }); } } async function main() { const args = process.argv.slice(2); let jsonMode = false; let targetPath; if (args.includes('--json')) { jsonMode = true; targetPath = args.find(arg => arg !== '--json'); } else { targetPath = args[0]; } if (!targetPath) { console.log('Usage: super-confused [--json] <path|url>'); console.log(''); console.log('Examples:'); console.log(' super-confused ./package.json'); console.log(' super-confused --json ./my-project'); console.log(' super-confused https://github.com/user/repo/blob/main/package.json'); console.log(' super-confused .'); process.exit(1); } if (!targetPath.startsWith('http://') && !targetPath.startsWith('https://') && !fs.existsSync(targetPath)) { if (!jsonMode) { console.error(`Path does not exist: ${targetPath}`); } process.exit(1); } const scanner = new SuperConfused(jsonMode); try { await scanner.scan(targetPath); } catch (error) { if (!jsonMode) { console.error(`Scan failed: ${error.message}`); } process.exit(1); } } module.exports = SuperConfused; if (require.main === module) { main().catch(console.error); }