Skip to content

Instantly share code, notes, and snippets.

@stoft
Last active September 9, 2025 10:24
Show Gist options
  • Save stoft/4a67949150021a5dec2b52a96783b734 to your computer and use it in GitHub Desktop.
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
#!/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