UNPKG

react-rsc-vuln-scanner

Version:

CLI tool to scan projects for React Server Components vulnerabilities (CVE-2025-55182, CVE-2025-55184, CVE-2025-55183)

504 lines (434 loc) 18 kB
#!/usr/bin/env node const fs = require('node:fs'); const path = require('node:path'); // React Server Components Security Vulnerabilities // CVE-2025-55182 - RCE (CRITICAL, CVSS 10.0) - https://react.dev/blog/2025/12/03/critical-security-vulnerability-in-react-server-components // CVE-2025-55184 - Denial of Service (HIGH, CVSS 7.5) - https://react.dev/blog/2025/12/11/denial-of-service-and-source-code-exposure-in-react-server-components // CVE-2025-55183 - Source Code Exposure (MEDIUM, CVSS 5.3) - https://react.dev/blog/2025/12/11/denial-of-service-and-source-code-exposure-in-react-server-components // All CVEs tracked with their affected versions const CVE_DATABASE = { 'CVE-2025-55182': { name: 'Remote Code Execution (React2Shell)', severity: 'CRITICAL', cvss: 10.0, vulnerable: ['19.0.0', '19.1.0', '19.1.1', '19.2.0'], fixed: ['19.0.1', '19.1.2', '19.2.1', '19.0.2', '19.1.3', '19.2.2'], url: 'https://react.dev/blog/2025/12/03/critical-security-vulnerability-in-react-server-components', }, 'CVE-2025-55184': { name: 'Denial of Service', severity: 'HIGH', cvss: 7.5, vulnerable: ['19.0.0', '19.0.1', '19.1.0', '19.1.1', '19.1.2', '19.2.0', '19.2.1'], fixed: ['19.0.2', '19.1.3', '19.2.2'], url: 'https://react.dev/blog/2025/12/11/denial-of-service-and-source-code-exposure-in-react-server-components', }, 'CVE-2025-55183': { name: 'Source Code Exposure', severity: 'MEDIUM', cvss: 5.3, vulnerable: ['19.0.0', '19.0.1', '19.1.0', '19.1.1', '19.1.2', '19.2.0', '19.2.1'], fixed: ['19.0.2', '19.1.3', '19.2.2'], url: 'https://react.dev/blog/2025/12/11/denial-of-service-and-source-code-exposure-in-react-server-components', }, }; // Packages affected by all CVEs const VULNERABLE_PACKAGES = { 'react-server-dom-webpack': { allVulnerable: ['19.0.0', '19.0.1', '19.1.0', '19.1.1', '19.1.2', '19.2.0', '19.2.1'], fixed: ['19.0.2', '19.1.3', '19.2.2'], }, 'react-server-dom-parcel': { allVulnerable: ['19.0.0', '19.0.1', '19.1.0', '19.1.1', '19.1.2', '19.2.0', '19.2.1'], fixed: ['19.0.2', '19.1.3', '19.2.2'], }, 'react-server-dom-turbopack': { allVulnerable: ['19.0.0', '19.0.1', '19.1.0', '19.1.1', '19.1.2', '19.2.0', '19.2.1'], fixed: ['19.0.2', '19.1.3', '19.2.2'], }, }; // Frameworks that may include vulnerable packages // Next.js fixed versions from: https://vercel.com/kb/bulletin/security-bulletin-cve-2025-55184-and-cve-2025-55183 const AFFECTED_FRAMEWORKS = { 'next': { description: 'Next.js - check if using App Router (Pages Router not affected)', fixedVersions: { '13.x': '14.2.35 (upgrade required)', '14.x': '14.2.35', '15.0.x': '15.0.7', '15.1.x': '15.1.11', '15.2.x': '15.2.8', '15.3.x': '15.3.8', '15.4.x': '15.4.10', '15.5.x': '15.5.9', '16.0.x': '16.0.10', }, }, 'react-router': { description: 'React Router - check if using unstable RSC APIs' }, 'waku': { description: 'Waku framework' }, '@parcel/rsc': { description: 'Parcel RSC plugin' }, '@vitejs/plugin-rsc': { description: 'Vite RSC plugin' }, 'rwsdk': { description: 'Redwood SDK' }, }; const COLORS = { reset: '\x1b[0m', red: '\x1b[31m', green: '\x1b[32m', yellow: '\x1b[33m', blue: '\x1b[34m', magenta: '\x1b[35m', cyan: '\x1b[36m', white: '\x1b[37m', bold: '\x1b[1m', dim: '\x1b[2m', }; function colorize(text, color) { return `${COLORS[color]}${text}${COLORS.reset}`; } function parseVersion(version) { if (!version) return null; // Remove common prefixes like ^, ~, >=, etc. const cleaned = version.replace(/^[\^~>=<]+/, '').trim(); // Handle versions like "19.0.0-rc.1" by taking just the main version const match = cleaned.match(/^(\d+)\.(\d+)\.(\d+)/); if (match) { return { major: parseInt(match[1], 10), minor: parseInt(match[2], 10), patch: parseInt(match[3], 10), raw: cleaned, }; } return null; } function getAffectedCVEs(version) { const parsed = parseVersion(version); if (!parsed) return { vulnerable: false, cves: [], reason: 'Could not parse version' }; const affectedCVEs = []; for (const [cveId, cveInfo] of Object.entries(CVE_DATABASE)) { for (const vulnVersion of cveInfo.vulnerable) { const vulnParsed = parseVersion(vulnVersion); if (!vulnParsed) continue; if ( parsed.major === vulnParsed.major && parsed.minor === vulnParsed.minor && parsed.patch === vulnParsed.patch ) { affectedCVEs.push({ id: cveId, ...cveInfo, }); break; } } } // Check if it's a potentially vulnerable range (19.x before latest fixes) if (affectedCVEs.length === 0 && parsed.major === 19) { // All versions 19.0.x before 19.0.2, 19.1.x before 19.1.3, 19.2.x before 19.2.2 are vulnerable if (parsed.minor === 0 && parsed.patch < 2) { return { vulnerable: true, cves: Object.entries(CVE_DATABASE).map(([id, info]) => ({ id, ...info })), reason: '19.0.x before 19.0.2' }; } if (parsed.minor === 1 && parsed.patch < 3) { return { vulnerable: true, cves: Object.entries(CVE_DATABASE).map(([id, info]) => ({ id, ...info })), reason: '19.1.x before 19.1.3' }; } if (parsed.minor === 2 && parsed.patch < 2) { return { vulnerable: true, cves: Object.entries(CVE_DATABASE).map(([id, info]) => ({ id, ...info })), reason: '19.2.x before 19.2.2' }; } } return { vulnerable: affectedCVEs.length > 0, cves: affectedCVEs }; } function checkDependency(pkgName, version, findings) { // Check for vulnerable packages if (VULNERABLE_PACKAGES[pkgName]) { const pkgInfo = VULNERABLE_PACKAGES[pkgName]; const vulnCheck = getAffectedCVEs(version); findings.vulnerablePackages.push({ name: pkgName, version, ...vulnCheck, fixedVersions: pkgInfo.fixed, }); } // Check for affected frameworks if (AFFECTED_FRAMEWORKS[pkgName]) { const frameworkInfo = AFFECTED_FRAMEWORKS[pkgName]; findings.affectedFrameworks.push({ name: pkgName, version: version, description: frameworkInfo.description, fixedVersions: frameworkInfo.fixedVersions, }); } } function findManifestFiles(directory, results = [], scannedProjects = []) { const SKIP_DIRS = ['node_modules', '.git', '.next', 'dist', 'build', '.cache', 'coverage']; const MANIFESTS = ['package.json', 'bun.lock']; let entries; try { entries = fs.readdirSync(directory, { withFileTypes: true }); } catch (err) { return { results, scannedProjects }; } for (const entry of entries) { const fullPath = path.join(directory, entry.name); if (entry.isDirectory()) { if (!SKIP_DIRS.includes(entry.name)) { findManifestFiles(fullPath, results, scannedProjects); } } else if (MANIFESTS.includes(entry.name)) { results.push(fullPath); // Track unique project directories const dir = path.dirname(fullPath); if (!scannedProjects.includes(dir)) { scannedProjects.push(dir); } } } return { results, scannedProjects }; } function analyzePackageJson(packageJsonPath) { let packageData; try { const content = fs.readFileSync(packageJsonPath, 'utf8'); packageData = JSON.parse(content); } catch { return null; } const projectName = packageData.name || path.basename(path.dirname(packageJsonPath)); const findings = { fileType: 'package.json', projectName, projectPath: path.dirname(packageJsonPath), filePath: packageJsonPath, vulnerablePackages: [], affectedFrameworks: [], hasLockFile: false, }; // Check for lock files const projectDir = path.dirname(packageJsonPath); findings.hasLockFile = fs.existsSync(path.join(projectDir, 'package-lock.json')) || fs.existsSync(path.join(projectDir, 'yarn.lock')) || fs.existsSync(path.join(projectDir, 'pnpm-lock.yaml')) || fs.existsSync(path.join(projectDir, 'bun.lock')); const allDeps = { ...packageData.dependencies, ...packageData.devDependencies, ...packageData.peerDependencies, }; for (const [pkgName, version] of Object.entries(allDeps)) { checkDependency(pkgName, version, findings); } return findings; } function fromBunLockfileToJson(content) { return content .replaceAll("\r", "\n") .replaceAll("\n\n", "\n") .replaceAll(" ", "") .replaceAll(",\n}", "}") .replaceAll(",\n]", "]") .replaceAll(",}", "}") .replaceAll(",]", "]"); } function analyzeBunLock(lockPath) { const content = fromBunLockfileToJson(fs.readFileSync(lockPath, 'utf8')); let lockData; try { lockData = JSON.parse(content); } catch { return null; } const findings = { fileType: 'bun.lock', projectName: lockData.name || path.basename(path.dirname(lockPath)), projectPath: path.dirname(lockPath), filePath: lockPath, vulnerablePackages: [], affectedFrameworks: [], }; if (lockData.packages) { for (const entry of Object.values(lockData.packages)) { if (!Array.isArray(entry) || entry.length === 0) continue; // Index 0 contains the full specifier e.g. "@babel/parser@7.28.5" const fullSpec = entry[0]; if (typeof fullSpec !== 'string') continue; // Extract name and version // Find the *last* '@' to split name and version (handles scoped packages @foo/bar@1.0.0) const lastAtIndex = fullSpec.lastIndexOf('@'); if (lastAtIndex === -1) continue; const pkgName = fullSpec.substring(0, lastAtIndex); const version = fullSpec.substring(lastAtIndex + 1); checkDependency(pkgName, version, findings); } } return findings; } function printBanner() { console.log('\n' + colorize('═'.repeat(70), 'cyan')); console.log(colorize(' React Server Components Vulnerability Scanner', 'bold')); console.log(colorize('═'.repeat(70), 'cyan')); console.log(colorize(' Checking for 3 CVEs:', 'white')); console.log(colorize(' • CVE-2025-55182 - RCE (CRITICAL, CVSS 10.0)', 'red')); console.log(colorize(' • CVE-2025-55184 - Denial of Service (HIGH, CVSS 7.5)', 'yellow')); console.log(colorize(' • CVE-2025-55183 - Source Code Exposure (MEDIUM, CVSS 5.3)', 'yellow')); console.log(colorize('─'.repeat(70), 'dim')); console.log(colorize(' References:', 'dim')); console.log(colorize(' https://react.dev/blog/2025/12/03/critical-security-vulnerability-in-react-server-components', 'dim')); console.log(colorize(' https://react.dev/blog/2025/12/11/denial-of-service-and-source-code-exposure-in-react-server-components', 'dim')); console.log(); } function printResults(scanDirectory, scannedProjects, vulnerableProjects, frameworkProjects) { console.log(colorize('\n📂 Scan Directory:', 'bold'), scanDirectory); console.log(colorize('📊 Total Projects Scanned:', 'bold'), scannedProjects.length); console.log(); // List all scanned projects console.log(colorize('─'.repeat(70), 'dim')); console.log(colorize('📋 All Scanned Projects:', 'cyan')); console.log(colorize('─'.repeat(70), 'dim')); for (const project of scannedProjects) { const relativePath = path.relative(scanDirectory, project) || '.'; console.log(` ${colorize('•', 'dim')} ${relativePath}`); } if (vulnerableProjects.length === 0) { console.log(colorize('\n\n ✅ No directly vulnerable packages found!', 'green')); } else { console.log(colorize('\n'+'─'.repeat(70), 'dim')); console.log(colorize('🚨 VULNERABLE PACKAGES FOUND:', 'red')); console.log(colorize('─'.repeat(70), 'dim')); for (const project of vulnerableProjects) { console.log(); console.log(colorize(` 📁 ${project.projectName}`, 'bold')); console.log(colorize(` Path: ${project.projectPath}`, 'dim')); console.log(colorize(` Source: ${project.fileType}`, 'blue')); for (const pkg of project.vulnerablePackages) { const status = pkg.vulnerable ? colorize('VULNERABLE', 'red') : colorize('OK', 'green'); console.log(` ${colorize('•', 'red')} ${pkg.name}@${pkg.version} [${status}]`); if (pkg.vulnerable && pkg.cves && pkg.cves.length > 0) { for (const cve of pkg.cves) { const severityColor = cve.severity === 'CRITICAL' ? 'red' : cve.severity === 'HIGH' ? 'yellow' : 'white'; console.log(colorize(` ⚠️ ${cve.id}: ${cve.name} (${cve.severity}, CVSS ${cve.cvss})`, severityColor)); } console.log(colorize(` 🔧 Upgrade to: ${pkg.fixedVersions.join(' or ')}`, 'green')); } } } } if (frameworkProjects.length === 0) { console.log(colorize('\n\n ✅ No affected frameworks found!', 'green')); } else { console.log(colorize('\n'+'─'.repeat(70), 'dim')); console.log(colorize('⚠️ PROJECTS WITH POTENTIALLY AFFECTED FRAMEWORKS:', 'yellow')); console.log(colorize('─'.repeat(70), 'dim')); for (const project of frameworkProjects) { console.log(); console.log(colorize(` 📁 ${project.projectName}`, 'bold')); console.log(colorize(` Path: ${project.projectPath}`, 'dim')); console.log(colorize(` Source: ${project.fileType}`, 'blue')); for (const framework of project.affectedFrameworks) { console.log(` ${colorize('•', 'yellow')} ${framework.name}@${framework.version}`); console.log(colorize(` ${framework.description}`, 'dim')); if (framework.fixedVersions) { console.log(colorize(` Check fixed versions for your release line`, 'yellow')); } } } } // Summary console.log(colorize('\n'+'═'.repeat(70), 'cyan')); console.log(colorize('📈 SUMMARY', 'bold')); console.log(colorize('═'.repeat(70), 'cyan')); console.log(` Total projects scanned: ${scannedProjects.length}`); console.log(` Projects with vulnerable packages: ${colorize(vulnerableProjects.length.toString(), vulnerableProjects.length > 0 ? 'red' : 'green')}`); console.log(` Projects with affected frameworks: ${colorize(frameworkProjects.length.toString(), frameworkProjects.length > 0 ? 'yellow' : 'green')}`); if (vulnerableProjects.length > 0 || frameworkProjects.length > 0) { console.log(colorize('\n⚠️ ACTION REQUIRED:', 'red')); console.log(' 1. Update vulnerable packages immediately to 19.0.2, 19.1.3, or 19.2.2'); console.log(' 2. Check node_modules for transitive dependencies'); console.log(' 3. Run `npm ls react-server-dom-webpack` to check nested deps'); console.log(' 4. References:'); console.log(' - https://react.dev/blog/2025/12/03/critical-security-vulnerability-in-react-server-components'); console.log(' - https://react.dev/blog/2025/12/11/denial-of-service-and-source-code-exposure-in-react-server-components'); } else { console.log(colorize('\n✅ No immediate action required based on package.json and bun.lock (package-lock.json and other coming soon) analysis.', 'green')); console.log(colorize(' Note: Also check node_modules for transitive dependencies.', 'dim')); } console.log(); } function main() { const args = process.argv.slice(2); if (args.length === 0 || args.includes('--help') || args.includes('-h')) { console.log(` ${colorize('Usage:', 'bold')} node scan-react-rsc-vuln.js <absolute-path-to-scan> ${colorize('Description:', 'bold')} Scans a directory recursively for Node.js projects that may be affected by React Server Components security vulnerabilities: • CVE-2025-55182 - Remote Code Execution (CRITICAL, CVSS 10.0) • CVE-2025-55184 - Denial of Service (HIGH, CVSS 7.5) • CVE-2025-55183 - Source Code Exposure (MEDIUM, CVSS 5.3) ${colorize('Example:', 'bold')} node scan-react-rsc-vuln.js /Users/username/projects ${colorize('Checks for:', 'bold')} - react-server-dom-webpack (vulnerable: 19.0.0-19.2.1, fixed: 19.0.2, 19.1.3, 19.2.2) - react-server-dom-parcel (vulnerable: 19.0.0-19.2.1, fixed: 19.0.2, 19.1.3, 19.2.2) - react-server-dom-turbopack (vulnerable: 19.0.0-19.2.1, fixed: 19.0.2, 19.1.3, 19.2.2) - Affected frameworks: next, react-router, waku, @parcel/rsc, @vitejs/plugin-rsc, rwsdk `); process.exit(0); } const scanDirectory = args[0]; // Validate path if (!path.isAbsolute(scanDirectory)) { console.error(colorize('Error: Please provide an absolute path.', 'red')); console.error(` Received: ${scanDirectory}`); process.exit(1); } if (!fs.existsSync(scanDirectory)) { console.error(colorize('Error: Directory does not exist.', 'red')); console.error(` Path: ${scanDirectory}`); process.exit(1); } if (!fs.statSync(scanDirectory).isDirectory()) { console.error(colorize('Error: Path is not a directory.', 'red')); console.error(` Path: ${scanDirectory}`); process.exit(1); } printBanner(); console.log(colorize('🔍 Scanning for package.json and bun.lock files...', 'cyan')); const { results: manifestFiles, scannedProjects } = findManifestFiles(scanDirectory); const vulnerableProjects = []; const frameworkProjects = []; for (const manifestPath of manifestFiles) { const fileName = path.basename(manifestPath); let analysis = null; if (fileName === 'package.json') { analysis = analyzePackageJson(manifestPath); } else if (fileName === 'bun.lock') { analysis = analyzeBunLock(manifestPath); } if (!analysis) continue; if (analysis.vulnerablePackages.length > 0) { vulnerableProjects.push(analysis); } if (analysis.affectedFrameworks.length > 0) { frameworkProjects.push(analysis); } } printResults(scanDirectory, scannedProjects, vulnerableProjects, frameworkProjects); } main();