|
#!/usr/bin/env node |
|
|
|
// Node.js rewrite of check-npm-cache.sh |
|
// Original script source: https://gist.github.com/joeskeen/202fe9f6d7a2f624097962507c5ab681 |
|
|
|
const fs = require('fs'); |
|
const path = require('path'); |
|
const { execSync } = require('child_process'); |
|
const os = require('os'); |
|
|
|
// if no arguments, print usage |
|
if (process.argv.length === 2) { |
|
console.error('Usage: node scan-npm-deps.js <packages-csv>'); |
|
console.error(); |
|
console.error(' This script scans all subdirectories and global npm caches'); |
|
console.error(' for compromised NPM packages'); |
|
console.error(); |
|
console.error(' The file <packages-csv> must have the following format:'); |
|
console.error(' <package>,<version>'); |
|
console.error(' <package>,<version>'); |
|
console.error(' ...'); |
|
console.error(); |
|
console.error(' Each line contains a single package and version'); |
|
console.error(); |
|
console.error(' Example:'); |
|
console.error(' eslint-config-crowdstrike-node,4.0.3'); |
|
console.error(' @art-ws/common,2.0.22'); |
|
console.error(' @art-ws/common,2.0.28'); |
|
process.exit(1); |
|
} |
|
|
|
const findings = []; |
|
|
|
function printFindings() { |
|
// Summary |
|
console.log(''); |
|
|
|
if (findings.length === 0) { |
|
console.log('✅ No compromised packages found.'); |
|
} else { |
|
console.log('❌ Compromised packages found:'); |
|
console.log(''); |
|
|
|
const grouped = {}; |
|
const remediation = {}; |
|
|
|
// Group findings by remediation directory |
|
for (const line of findings) { |
|
const match = line.match(/in (.+)$/); |
|
if (!match) continue; |
|
|
|
let filePath = match[1]; |
|
let dir = path.dirname(filePath); |
|
|
|
// Trim path before node_modules if present |
|
if (dir.includes('/node_modules/')) { |
|
dir = dir.substring(0, dir.indexOf('/node_modules')); |
|
} else if (dir.endsWith('/node_modules')) { |
|
dir = dir.substring(0, dir.length - '/node_modules'.length); |
|
} |
|
|
|
// Walk up to find the remediation root |
|
let currentDir = dir; |
|
while (currentDir !== '/' && currentDir !== '.') { |
|
if (fileExists(path.join(currentDir, 'package-lock.json'))) { |
|
remediation[currentDir] = 'npm'; |
|
break; |
|
} else if (fileExists(path.join(currentDir, 'yarn.lock'))) { |
|
remediation[currentDir] = 'yarn'; |
|
break; |
|
} else if (fileExists(path.join(currentDir, 'pnpm-lock.yaml'))) { |
|
remediation[currentDir] = 'pnpm'; |
|
break; |
|
} |
|
currentDir = path.dirname(currentDir); |
|
} |
|
|
|
if (!grouped[dir]) { |
|
grouped[dir] = []; |
|
} |
|
grouped[dir].push(line); |
|
} |
|
|
|
// Print grouped findings |
|
for (const [dir, lines] of Object.entries(grouped)) { |
|
console.log(`📁 ${dir}`); |
|
for (const line of lines) { |
|
console.log(` ${line}`); |
|
} |
|
console.log(''); |
|
} |
|
|
|
// console.log('🛠️ Suggested Remediation Commands:'); |
|
// console.log(); |
|
// for (const [dir, tool] of Object.entries(remediation)) { |
|
// console.log(`💡 cd "${dir}" && rm -rf node_modules ${tool}-lock.yaml yarn.lock package-lock.json && ${tool} install`); |
|
// } |
|
} |
|
} |
|
|
|
console.log('🔍 Scanning for compromised NPM packages'); |
|
console.log(); |
|
console.log('📋 Reading package list...'); |
|
|
|
const compromisedPackages = fs.readFileSync(process.argv[2], 'utf8'); |
|
// Parse compromised packages into array |
|
const compromised = []; |
|
compromisedPackages.split('\n').forEach(line => { |
|
line = line.trim(); |
|
if (line && !line.startsWith('#')) { |
|
compromised.push(line); |
|
} |
|
}); |
|
console.log(` ${compromised.length} packages`); |
|
console.log(); |
|
|
|
// Utility functions |
|
function fileExists(filePath) { |
|
try { |
|
return fs.existsSync(filePath); |
|
} catch (error) { |
|
return false; |
|
} |
|
} |
|
|
|
function readFileSync(filePath) { |
|
try { |
|
return fs.readFileSync(filePath, 'utf8'); |
|
} catch (error) { |
|
return ''; |
|
} |
|
} |
|
|
|
function findFiles(dir, patterns, maxDepth = Infinity, matchDirectories = false) { |
|
const results = []; |
|
|
|
function traverse(currentDir, depth) { |
|
if (depth > maxDepth) return; |
|
|
|
try { |
|
const entries = fs.readdirSync(currentDir, { withFileTypes: true }); |
|
|
|
for (const entry of entries) { |
|
const fullPath = path.join(currentDir, entry.name); |
|
|
|
if (entry.isDirectory()) { |
|
if (matchDirectories) { |
|
results.push(fullPath); |
|
} |
|
traverse(fullPath, depth + 1); |
|
} else if (entry.isFile()) { |
|
for (const pattern of patterns) { |
|
if (entry.name.match(pattern)) { |
|
results.push(fullPath); |
|
break; |
|
} |
|
} |
|
} |
|
} |
|
} catch (error) { |
|
// Skip directories we can't read |
|
} |
|
} |
|
|
|
if (fileExists(dir)) { |
|
traverse(dir, 0); |
|
} |
|
|
|
return results; |
|
} |
|
|
|
function scanFile(filePath) { |
|
const content = readFileSync(filePath); |
|
if (!content) return; |
|
|
|
for (const item of compromised) { |
|
const [pkg, version] = item.split(','); |
|
if (content.includes(pkg) && content.includes(version)) { |
|
findings.push(`Found ${pkg}@${version} in ${filePath}`); |
|
} |
|
} |
|
} |
|
|
|
function scanLockfileResolved(filePath) { |
|
const content = readFileSync(filePath); |
|
if (!content) return; |
|
|
|
for (const item of compromised) { |
|
const [pkg, version] = item.split(','); |
|
// Match resolved tarball URLs for compromised versions |
|
// get package local name, i.e. after the last / |
|
const localName = pkg.split('/').pop(); |
|
const pattern = `${pkg}/-/${localName}-${version}.tgz`; |
|
if (content.includes(pattern)) { |
|
findings.push(`Resolved ${pkg}@${version} in ${filePath}`); |
|
} |
|
} |
|
} |
|
|
|
function scanProjectFiles() { |
|
const lockfilePatterns = [/^package-lock\.json$/, /^yarn\.lock$/, /^pnpm-lock\.yaml$/]; |
|
const lockfiles = findFiles('.', lockfilePatterns); |
|
console.log(` ${lockfiles.length} lockfiles`); |
|
|
|
let count = 0; |
|
for (const file of lockfiles) { |
|
scanLockfileResolved(file); |
|
count++; |
|
if (count % 100 === 0) { |
|
console.log(` ...scanned ${count} files`); |
|
} |
|
} |
|
} |
|
|
|
async function scanNpmCache(cacheDir) { |
|
if (!fileExists(cacheDir)) return; |
|
|
|
// traverse cacheDir recursively |
|
const files = findFiles(cacheDir, [/.*/], Infinity); |
|
console.log(` ${files.length} files to check in ${cacheDir}`); |
|
|
|
let startTime = Date.now(); |
|
let count = 0; |
|
for (const file of files) { |
|
const content = readFileSync(file); |
|
for (const item of compromised) { |
|
const [pkg, version] = item.split(','); |
|
const searchPattern = `${pkg}@${version}`; |
|
|
|
if (content.includes(searchPattern)) { |
|
findings.push(`Found ${pkg}@${version} in cached file: ${file}`); |
|
} |
|
} |
|
count++; |
|
// print update every 5 seconds |
|
if (Date.now() - startTime > 5000) { |
|
console.log(` ...checked ${count}/${files.length} files in ${cacheDir}`); |
|
startTime = Date.now(); |
|
} |
|
} |
|
} |
|
|
|
async function scanNvmVersions() { |
|
const nvmDir = process.env.NVM_DIR || path.join(os.homedir(), '.nvm'); |
|
if (!fileExists(nvmDir)) return; |
|
|
|
console.log(); |
|
console.log('🧠 Scanning NVM-managed Node versions...'); |
|
|
|
const nodeVersionsDir = path.join(nvmDir, 'versions', 'node'); |
|
if (!fileExists(nodeVersionsDir)) return; |
|
|
|
// for each dir in nodeVersionsDir, scan the npm cache |
|
const dirs = findFiles(nodeVersionsDir, [/.*/], 0, true); |
|
console.log(` ${dirs.length} node versions`); |
|
for (const dir of dirs) { |
|
await scanNpmCache(dir); |
|
} |
|
} |
|
|
|
function scanDockerfiles() { |
|
console.log(); |
|
console.log('🐳 Scanning Dockerfiles...'); |
|
|
|
const dockerfiles = findFiles('.', [/^Dockerfile/i]); |
|
console.log(` ${dockerfiles.length} Dockerfiles`); |
|
|
|
let startTime = Date.now(); |
|
let count = 0; |
|
|
|
for (const file of dockerfiles) { |
|
scanFile(file); |
|
count++; |
|
// print update every 5 seconds |
|
if (Date.now() - startTime > 5000) { |
|
console.log(` ...scanned ${count}/${dockerfiles.length} Dockerfiles`); |
|
startTime = Date.now(); |
|
} |
|
} |
|
} |
|
|
|
function scanCiConfigs() { |
|
console.log(); |
|
console.log('⚙️ Scanning CI/CD config files...'); |
|
|
|
const patterns = [ |
|
/^Jenkinsfile/, |
|
/\.ya?ml$/ |
|
]; |
|
|
|
const ciFiles = findFiles('.', patterns).filter(file => { |
|
const relativePath = path.relative('.', file); |
|
return !relativePath.includes('/node_modules/') && |
|
(relativePath.includes('.github/') || |
|
relativePath.includes('.gitlab/') || |
|
relativePath.includes('.circleci/') || |
|
relativePath.includes('Jenkinsfile')); |
|
}); |
|
|
|
console.log(` ${ciFiles.length} CI config files`); |
|
|
|
let startTime = Date.now(); |
|
let count = 0; |
|
for (const file of ciFiles) { |
|
scanFile(file); |
|
count++; |
|
// print update every 5 seconds |
|
if (Date.now() - startTime > 5000) { |
|
console.log(` ...scanned ${count}/${ciFiles.length} CI config files`); |
|
startTime = Date.now(); |
|
} |
|
} |
|
} |
|
|
|
function scanVendoredDirs() { |
|
console.log(); |
|
console.log('📁 Scanning vendored folders...'); |
|
|
|
const vendorDirs = ['vendor', 'third_party', 'static', 'assets']; |
|
const patterns = [/\.js$/, /\.json$/, /\.tgz$/]; |
|
|
|
for (const dir of vendorDirs) { |
|
if (!fileExists(dir)) continue; |
|
|
|
const files = findFiles(dir, patterns); |
|
for (const file of files) { |
|
scanFile(file); |
|
} |
|
} |
|
} |
|
|
|
// Main scanning logic |
|
async function main() { |
|
console.log('🔒 Scanning project lockfiles and package.json...'); |
|
scanProjectFiles(); |
|
|
|
scanDockerfiles(); |
|
scanCiConfigs(); |
|
scanVendoredDirs(); |
|
|
|
// Global caches |
|
console.log(); |
|
console.log('📦 Scanning global npm caches...'); |
|
const homeDir = os.homedir(); |
|
|
|
const npmCache = path.join(homeDir, '.npm', '_cacache'); |
|
if (fileExists(npmCache)) { |
|
await scanNpmCache(npmCache); |
|
} |
|
|
|
const npmPackages = path.join(homeDir, '.npm-packages'); |
|
if (fileExists(npmPackages)) { |
|
await scanNpmCache(npmPackages); |
|
} |
|
|
|
// Yarn global cache |
|
console.log(); |
|
console.log('📦 Scanning Yarn global cache...'); |
|
try { |
|
const yarnCache = execSync('yarn cache dir 2>/dev/null || echo ""', { encoding: 'utf8' }).trim(); |
|
if (yarnCache && fileExists(yarnCache)) { |
|
await scanNpmCache(yarnCache); |
|
} |
|
} catch (error) { |
|
// Yarn not available |
|
} |
|
|
|
// pnpm global store |
|
console.log(); |
|
console.log('📦 Scanning pnpm global store...'); |
|
try { |
|
const pnpmCache = execSync('pnpm store path 2>/dev/null || echo ""', { encoding: 'utf8' }).trim(); |
|
if (pnpmCache && fileExists(pnpmCache)) { |
|
await scanNpmCache(pnpmCache); |
|
} |
|
} catch (error) { |
|
// pnpm not available |
|
} |
|
|
|
await scanNvmVersions(); |
|
|
|
printFindings(); |
|
} |
|
|
|
// Run the main function |
|
main().catch(console.error); |
Thanks for this! Small bug, I got a false positive from:
supports-color@5.0.1(believing it'scolor@5.0.1)