Last active
September 9, 2025 10:24
-
-
Save stoft/4a67949150021a5dec2b52a96783b734 to your computer and use it in GitHub Desktop.
Script to check for npm-debug-and-chalk-packages-compromised supply chain attack
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env node | |
| /** | |
| * Advanced script to check if a repository is affected by compromised npm packages | |
| * Based on the security incident reported on September 8, 2025 | |
| * Reference: https://www.aikido.dev/blog/npm-debug-and-chalk-packages-compromised | |
| * | |
| * This script checks both package-lock.json and package.json files | |
| * | |
| * Disclaimer: generated by A.I. Not tested thoroughly. | |
| */ | |
| const fs = require('fs') | |
| const path = require('path') | |
| // Compromised packages and their malicious versions | |
| const COMPROMISED_PACKAGES = { | |
| backslash: '0.2.1', | |
| 'chalk-template': '1.1.1', | |
| 'supports-hyperlinks': '4.1.1', | |
| 'has-ansi': '6.0.1', | |
| 'simple-swizzle': '0.2.3', | |
| 'color-string': '2.1.1', | |
| 'error-ex': '1.3.3', | |
| 'color-name': '2.0.1', | |
| 'is-arrayish': '0.3.3', | |
| 'slice-ansi': '7.1.1', | |
| 'color-convert': '3.1.1', | |
| 'wrap-ansi': '9.0.1', | |
| 'ansi-regex': '6.2.1', | |
| 'supports-color': '10.2.1', | |
| 'strip-ansi': '7.1.1', | |
| chalk: '5.6.1', | |
| debug: '4.4.2', | |
| 'ansi-styles': '6.2.2', | |
| } | |
| // Additional suspicious package found later | |
| const ADDITIONAL_COMPROMISED = { | |
| 'proto-tinker-wc': '0.1.87', | |
| } | |
| // Combine all compromised packages | |
| const ALL_COMPROMISED_PACKAGES = { ...COMPROMISED_PACKAGES, ...ADDITIONAL_COMPROMISED } | |
| /** | |
| * Parse JSON file safely | |
| */ | |
| function parseJsonFile(filePath) { | |
| try { | |
| if (!fs.existsSync(filePath)) { | |
| return null | |
| } | |
| const content = fs.readFileSync(filePath, 'utf8') | |
| return JSON.parse(content) | |
| } catch (error) { | |
| console.error(`Error reading ${filePath}: ${error.message}`) | |
| return null | |
| } | |
| } | |
| /** | |
| * Recursively find all packages in dependencies tree from package-lock.json | |
| */ | |
| function findPackagesInDependencies(dependencies, foundPackages = {}) { | |
| if (!dependencies) return foundPackages | |
| for (const [packageName, packageInfo] of Object.entries(dependencies)) { | |
| if (packageInfo.version) { | |
| foundPackages[packageName] = packageInfo.version | |
| } | |
| // Recursively check nested dependencies | |
| if (packageInfo.dependencies) { | |
| findPackagesInDependencies(packageInfo.dependencies, foundPackages) | |
| } | |
| } | |
| return foundPackages | |
| } | |
| /** | |
| * Extract packages from package-lock.json (handles both old and new formats) | |
| */ | |
| function extractPackagesFromPackageLock(packageLock) { | |
| // Map of packageName -> Set of versions | |
| const foundPackages = {} | |
| // Handle new format (lockfileVersion 3+) - uses "packages" object | |
| if (packageLock.packages) { | |
| for (const [packagePath, packageInfo] of Object.entries(packageLock.packages)) { | |
| // Skip the root package (empty key) | |
| if (packagePath === '') continue | |
| // Extract the actual package name for nested paths, e.g.: | |
| // "node_modules/chalk" -> "chalk" | |
| // "node_modules/boxen/node_modules/chalk" -> "chalk" | |
| // "node_modules/@scope/pkg" -> "@scope/pkg" | |
| // "node_modules/boxen/node_modules/@scope/pkg" -> "@scope/pkg" | |
| const lastSegment = packagePath.split('node_modules/').pop() | |
| if (!lastSegment) continue | |
| const lastParts = lastSegment.split('/') | |
| const packageName = lastSegment.startsWith('@') ? lastParts.slice(0, 2).join('/') : lastParts[0] | |
| if (packageInfo.version && packageName) { | |
| if (!foundPackages[packageName]) foundPackages[packageName] = new Set() | |
| foundPackages[packageName].add(packageInfo.version) | |
| } | |
| } | |
| } | |
| // Handle old format (lockfileVersion 1-2) - uses "dependencies" object | |
| if (packageLock.dependencies) { | |
| const oldFormatPackages = findPackagesInDependencies(packageLock.dependencies) | |
| for (const [name, version] of Object.entries(oldFormatPackages)) { | |
| if (!foundPackages[name]) foundPackages[name] = new Set() | |
| foundPackages[name].add(version) | |
| } | |
| } | |
| return foundPackages | |
| } | |
| /** | |
| * Extract packages from package.json dependencies | |
| */ | |
| function extractPackagesFromPackageJson(packageJson) { | |
| const packages = {} | |
| const dependencySections = ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'] | |
| dependencySections.forEach((section) => { | |
| if (packageJson[section]) { | |
| Object.entries(packageJson[section]).forEach(([name, version]) => { | |
| // Extract version number from version strings like "^4.1.2" or "~4.1.2" | |
| const cleanVersion = version.replace(/^[\^~>=<]/, '') | |
| packages[name] = cleanVersion | |
| }) | |
| } | |
| }) | |
| return packages | |
| } | |
| /** | |
| * Check if a package version matches any compromised version (handles version ranges) | |
| */ | |
| function isVersionCompromised(packageName, version) { | |
| const compromisedVersion = ALL_COMPROMISED_PACKAGES[packageName] | |
| if (!compromisedVersion) return false | |
| // Handle exact version matches | |
| if (version === compromisedVersion) return true | |
| // Handle version ranges (e.g., "^4.3.0" should match "4.3.0") | |
| const cleanVersion = version.replace(/^[\^~>=<]/, '') | |
| return cleanVersion === compromisedVersion | |
| } | |
| /** | |
| * Check npm cache for compromised packages | |
| * Note: This is a simplified implementation. A full cache check would require | |
| * parsing npm's complex cache structure and metadata files. | |
| */ | |
| function checkNpmCache() { | |
| const os = require('os') | |
| const npmCachePath = path.join(os.homedir(), '.npm') | |
| if (!fs.existsSync(npmCachePath)) { | |
| return { found: false, message: 'npm cache directory not found' } | |
| } | |
| try { | |
| // Basic cache directory validation | |
| const cacheEntries = fs.readdirSync(npmCachePath) | |
| const hasCacheContent = cacheEntries.some((entry) => entry.startsWith('_cacache') || entry.includes('cache')) | |
| if (hasCacheContent) { | |
| return { | |
| found: false, | |
| message: 'npm cache exists - consider running "npm cache clean --force" if compromised packages were found', | |
| } | |
| } else { | |
| return { found: false, message: 'npm cache appears to be empty' } | |
| } | |
| } catch (error) { | |
| return { found: false, message: `Error checking npm cache: ${error.message}` } | |
| } | |
| } | |
| /** | |
| * Generate remediation commands | |
| */ | |
| function generateRemediationCommands() { | |
| return [ | |
| 'rm -rf node_modules', | |
| 'rm package-lock.json', | |
| 'npm cache clean --force', | |
| 'npm install', | |
| 'npm audit', | |
| 'npm audit fix', | |
| ] | |
| } | |
| /** | |
| * Main function to check for compromised packages | |
| */ | |
| function checkCompromisedPackages(repoPath = '.') { | |
| const packageLockPath = path.join(repoPath, 'package-lock.json') | |
| const packageJsonPath = path.join(repoPath, 'package.json') | |
| console.log('π Advanced Compromised Package Checker') | |
| console.log('='.repeat(50)) | |
| console.log('Reference: https://www.aikido.dev/blog/npm-debug-and-chalk-packages-compromised\n') | |
| // Check package-lock.json | |
| let packageLock = null | |
| let packageJson = null | |
| let allPackages = {} | |
| if (fs.existsSync(packageLockPath)) { | |
| console.log('π¦ Checking package-lock.json...') | |
| packageLock = parseJsonFile(packageLockPath) | |
| if (packageLock) { | |
| allPackages = extractPackagesFromPackageLock(packageLock) | |
| console.log(` Found ${Object.keys(allPackages).length} packages in lock file`) | |
| } | |
| } else { | |
| console.log('β οΈ package-lock.json not found') | |
| } | |
| // Check package.json | |
| if (fs.existsSync(packageJsonPath)) { | |
| console.log('π Checking package.json...') | |
| packageJson = parseJsonFile(packageJsonPath) | |
| if (packageJson) { | |
| const packageJsonPackages = extractPackagesFromPackageJson(packageJson) | |
| console.log(` Found ${Object.keys(packageJsonPackages).length} direct dependencies`) | |
| // Merge with lock file packages (package.json versions are less precise) | |
| for (const [name, version] of Object.entries(packageJsonPackages)) { | |
| if (!allPackages[name]) { | |
| allPackages[name] = new Set([version]) | |
| } else if (allPackages[name] instanceof Set) { | |
| allPackages[name].add(version) | |
| } else { | |
| // Convert existing string version to Set | |
| const existingVersion = allPackages[name] | |
| allPackages[name] = new Set([existingVersion, version]) | |
| } | |
| } | |
| } | |
| } else { | |
| console.log('β οΈ package.json not found') | |
| } | |
| if (Object.keys(allPackages).length === 0) { | |
| console.log('β No packages found to check') | |
| process.exit(1) | |
| } | |
| // Check for compromised packages | |
| const compromisedFound = [] | |
| const suspiciousPackages = [] | |
| const safePackages = [] | |
| for (const [packageName, versionsOrVersion] of Object.entries(allPackages)) { | |
| const versions = versionsOrVersion instanceof Set ? Array.from(versionsOrVersion) : [versionsOrVersion] | |
| for (const version of versions) { | |
| if (isVersionCompromised(packageName, version)) { | |
| compromisedFound.push({ name: packageName, version }) | |
| } else if (ALL_COMPROMISED_PACKAGES[packageName]) { | |
| // Package exists in compromised list but different version | |
| safePackages.push({ | |
| name: packageName, | |
| version, | |
| compromisedVersion: ALL_COMPROMISED_PACKAGES[packageName], | |
| }) | |
| } else if (packageName.includes('tinker') || packageName.includes('proto')) { | |
| // Additional suspicious packages | |
| suspiciousPackages.push({ name: packageName, version }) | |
| } | |
| } | |
| } | |
| // Check npm cache | |
| console.log('\nποΈ Checking npm cache...') | |
| const cacheCheck = checkNpmCache() | |
| console.log(` ${cacheCheck.message}`) | |
| // Display results | |
| console.log('\nπ RESULTS:') | |
| console.log('='.repeat(50)) | |
| if (compromisedFound.length > 0) { | |
| console.log('\nπ¨ COMPROMISED PACKAGES FOUND:') | |
| compromisedFound.forEach((pkg) => { | |
| console.log(`β ${pkg.name}@${pkg.version} - COMPROMISED VERSION`) | |
| }) | |
| console.log('\nβ οΈ IMMEDIATE ACTION REQUIRED:') | |
| console.log('The following commands will help clean your environment:') | |
| generateRemediationCommands().forEach((cmd) => { | |
| console.log(` ${cmd}`) | |
| }) | |
| console.log('\nπ Additional Security Steps:') | |
| console.log('1. Review any crypto/web3 transactions made recently') | |
| console.log('2. Check browser wallet extensions for suspicious activity') | |
| console.log('3. Consider rotating any API keys or tokens') | |
| console.log('4. Run a full security audit of your system') | |
| } else { | |
| console.log('β No compromised packages found in your dependencies') | |
| } | |
| if (suspiciousPackages.length > 0) { | |
| console.log('\nβ οΈ Suspicious packages found:') | |
| suspiciousPackages.forEach((pkg) => { | |
| console.log(`π ${pkg.name}@${pkg.version} - Review recommended`) | |
| }) | |
| } | |
| if (safePackages.length > 0) { | |
| console.log('\nπ Related packages with safe versions:') | |
| safePackages.forEach((pkg) => { | |
| console.log(`β ${pkg.name}@${pkg.version} (compromised version: ${pkg.compromisedVersion})`) | |
| }) | |
| } | |
| console.log('\nπ Summary:') | |
| console.log(`Total packages checked: ${Object.keys(allPackages).length}`) | |
| console.log(`Compromised packages found: ${compromisedFound.length}`) | |
| console.log(`Suspicious packages: ${suspiciousPackages.length}`) | |
| console.log(`Related safe packages: ${safePackages.length}`) | |
| if (compromisedFound.length > 0) { | |
| console.log('\nπ For more information:') | |
| console.log('https://www.aikido.dev/blog/npm-debug-and-chalk-packages-compromised') | |
| process.exit(1) | |
| } else { | |
| console.log('\nπ Your repository appears to be safe from this specific attack') | |
| console.log('\nπ‘ Recommendations:') | |
| console.log('- Keep your dependencies updated regularly') | |
| console.log('- Use npm audit to check for known vulnerabilities') | |
| console.log('- Consider using tools like Aikido SafeChain for ongoing protection') | |
| process.exit(0) | |
| } | |
| } | |
| // Handle command line arguments | |
| const args = process.argv.slice(2) | |
| const repoPath = args[0] || '.' | |
| // Show help if requested | |
| if (args.includes('--help') || args.includes('-h')) { | |
| console.log('Usage: node check-compromised-packages-advanced.js [directory] [options]') | |
| console.log('') | |
| console.log('Options:') | |
| console.log(' directory Path to the repository to check (default: current directory)') | |
| console.log(' --help, -h Show this help message') | |
| console.log(' --test Test mode: simulate compromised packages for testing') | |
| console.log('') | |
| console.log('This script checks for compromised npm packages from the September 8, 2025 incident.') | |
| process.exit(0) | |
| } | |
| // Test mode - simulate compromised packages | |
| if (args.includes('--test')) { | |
| console.log('π§ͺ TEST MODE: Simulating compromised packages for testing...') | |
| // Override the compromised packages list for testing | |
| ALL_COMPROMISED_PACKAGES['ansi-styles'] = '4.3.0' | |
| ALL_COMPROMISED_PACKAGES['chalk'] = '4.1.2' // This should be found in your package.json | |
| } | |
| // Run the check | |
| checkCompromisedPackages(repoPath) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment