diff --git a/scripts/check-security.mjs b/scripts/check-security.mjs index ac78c0bd5a..a05af8531e 100644 --- a/scripts/check-security.mjs +++ b/scripts/check-security.mjs @@ -24,554 +24,412 @@ * 1. Fetches known malicious packages from multiple sources: * - OSV (Open Source Vulnerabilities) - Google's aggregated database * - GitHub Advisory Database - GitHub's security advisories + * - Meterian CLI (optional) - additional vulnerability coverage * 2. Checks both package.json and package-lock.json * 3. Blocks installation if a compromised package is found * * Cache: Results are cached locally for 24 hours to avoid slowing down installs. */ -import { readFileSync, writeFileSync, existsSync, mkdirSync, rmSync, readdirSync } from 'fs'; -import { join, dirname } from 'path'; -import { fileURLToPath } from 'url'; -import { spawnSync } from 'child_process'; +import { readFileSync, writeFileSync, mkdirSync, rmSync, statSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { fetchMalwareData as fetchFromOSV } from './security-providers/osv-provider.mjs'; +import { fetchMalwareData as fetchFromGitHub } from './security-providers/github-provider.mjs'; +import { checkVulnerabilities as checkWithMeterian } from './security-providers/meterian-provider.mjs'; +import { FALLBACK_BLOCKED_PACKAGES } from './security-providers/fallback-list.mjs'; const __dirname = dirname(fileURLToPath(import.meta.url)); const ROOT_DIR = join(__dirname, '..'); const CACHE_DIR = join(ROOT_DIR, 'node_modules', '.cache', 'security-check'); const CACHE_FILE = join(CACHE_DIR, 'blocked-packages.json'); -const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours - -// API endpoints -const OSV_API = 'https://api.osv.dev/v1/query'; -const OSV_BATCH_API = 'https://api.osv.dev/v1/querybatch'; -const GITHUB_ADVISORY_API = 'https://api.github.com/advisories'; - -// Meterian CLI (bundled with VSCode extension, also available via npx) -const METERIAN_CLI = '@meterian/cli'; - -// Filter for supply chain attacks (malware, compromised packages) -// These are the most dangerous - not just vulnerabilities but intentionally malicious -const MALWARE_KEYWORDS = [ - 'malware', - 'malicious', - 'compromised', - 'supply chain', - 'backdoor', - 'cryptominer', - 'credential stealing', - 'data exfiltration', - 'typosquat' -]; +const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // ============================================================================ -// FALLBACK LIST - Used when OSV is unreachable -// These are confirmed supply chain attacks (not regular CVEs) -// ============================================================================ -const FALLBACK_BLOCKED_PACKAGES = { - '@solana/web3.js': { - versions: ['1.95.6', '1.95.7'], - reason: 'Compromised - steals private keys (Dec 2024)' - }, - '@lottiefiles/lottie-player': { - versions: ['2.0.5', '2.0.6', '2.0.7'], - reason: 'Compromised - crypto wallet drainer (Oct 2024)' - }, - 'node-ipc': { - versions: ['9.2.2', '10.1.1', '10.1.2', '10.1.3', '11.0.0', '11.1.0'], - reason: 'Protestware - overwrites files (Mar 2022)' - }, - 'ua-parser-js': { - versions: ['0.7.29', '0.8.0', '1.0.0'], - reason: 'Compromised - cryptominer and password stealer (Oct 2021)' - }, - 'coa': { - versions: ['2.0.3', '2.0.4', '2.1.1', '2.1.3', '3.0.1', '3.1.3'], - reason: 'Compromised - password stealer (Nov 2021)' - }, - 'rc': { - versions: ['1.2.9', '1.3.9', '2.3.9'], - reason: 'Compromised - exfiltrates environment variables (Nov 2021)' - }, - 'colors': { - versions: ['1.4.1', '1.4.44-liberty-2'], - reason: 'Sabotage - infinite loop (Jan 2022)' - }, - 'faker': { - versions: ['6.6.6'], - reason: 'Sabotage - no functionality (Jan 2022)' - }, - 'event-stream': { - versions: ['3.3.6'], - reason: 'Compromised - bitcoin wallet theft (Nov 2018)' - } -}; - -// ============================================================================ -// DATABASE FETCHING +// FILE UTILITIES // ============================================================================ -function isMalwareRelated(text) { - const lowerText = text.toLowerCase(); - return MALWARE_KEYWORDS.some(keyword => lowerText.includes(keyword.toLowerCase())); -} - -function extractVersionsFromOSV(vuln) { - const versions = []; - for (const affected of vuln.affected || []) { - for (const range of affected.ranges || []) { - for (const event of range.events || []) { - if (event.introduced && event.introduced !== '0') { - versions.push(event.introduced); - } - } - } - if (affected.versions) { - versions.push(...affected.versions); - } - } - return [...new Set(versions)]; -} - -async function fetchFromOSV(projectDependencies) { - console.log(' šŸ“” Fetching from OSV (Google)...'); - const results = {}; - - if (!projectDependencies || projectDependencies.length === 0) { - console.log(' ⚠ OSV: No dependencies to check'); - return results; - } - +function readJsonFile(filePath) { try { - // Query OSV for the project's actual dependencies (in batches of 1000) - const batchSize = 1000; - for (let i = 0; i < projectDependencies.length; i += batchSize) { - const batch = projectDependencies.slice(i, i + batchSize); - const response = await fetch(OSV_BATCH_API, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - queries: batch.map(dep => ({ - package: { name: dep.name, ecosystem: 'npm' }, - version: dep.version - })) - }), - signal: AbortSignal.timeout(30000) - }); - - if (response.ok) { - const data = await response.json(); - for (const result of data.results || []) { - for (const vuln of result.vulns || []) { - const summary = [vuln.summary || '', vuln.details || ''].join(' '); - if (isMalwareRelated(summary)) { - const pkgName = vuln.affected?.[0]?.package?.name; - if (pkgName) { - const versions = extractVersionsFromOSV(vuln); - if (versions.length > 0) { - if (!results[pkgName]) { - results[pkgName] = { versions: [], reason: '' }; - } - results[pkgName].versions.push(...versions); - results[pkgName].reason = vuln.summary || 'Malware detected by OSV'; - results[pkgName].source = 'OSV'; - } - } - } - } - } - } - } - console.log(` āœ“ OSV: Found ${Object.keys(results).length} malware entries`); - } catch (e) { - console.log(` ⚠ OSV: Could not fetch (${e.message})`); + return JSON.parse(readFileSync(filePath, 'utf8')); + } catch { + return null; } - - return results; } -function findMeterianCli() { - const homeDir = process.env.HOME || process.env.USERPROFILE; - - // Check VSCode extensions first (most likely for local dev) - const extensionsDir = join(homeDir, '.vscode', 'extensions'); - if (existsSync(extensionsDir)) { - try { - const extensions = readdirSync(extensionsDir) - .filter(d => /^meterian\.meterian-heidi-\d+\.\d+\.\d+$/.test(d)) - .sort() - .reverse(); // Latest version first - - for (const ext of extensions) { - const cliPath = join(extensionsDir, ext, 'packages', 'meterian-cli'); - if (existsSync(cliPath)) { - return cliPath; - } - } - } catch { - // Ignore errors reading extensions dir - } - } - - // Other possible locations - const locations = [ - // Local node_modules - join(ROOT_DIR, 'node_modules', '@meterian', 'cli'), - // Global npm (macOS/Linux) - join(homeDir, '.npm-global', 'lib', 'node_modules', '@meterian', 'cli'), - // Global npm (alternative) - '/usr/local/lib/node_modules/@meterian/cli' - ]; - - for (const loc of locations) { - if (existsSync(loc)) { - return loc; - } - } - - return null; -} - -function isMeterianAvailable() { - return findMeterianCli() !== null; -} - -async function checkWithMeterian(dependencies) { - // Skip if Meterian CLI is not available - const cliPath = findMeterianCli(); - if (!cliPath) { - console.log(' ā­ļø Meterian: Not installed, skipping'); - console.log(' Install VSCode extension "Meterian Security" or run: npm i -g @meterian/cli'); - return { vulnerable: [], source: 'Meterian' }; - } - - console.log(' šŸ“” Checking with Meterian CLI...'); - +function writeJsonFile(filePath, data) { try { - // Format dependencies for Meterian CLI - const input = dependencies.map(dep => ({ - language: 'nodejs', - name: dep.name, - version: dep.version + writeFileSync(filePath, JSON.stringify(data, null, 2)); + return true; + } catch { + return false; + } +} + +function directoryExists(path) { + try { + return statSync(path).isDirectory(); + } catch { + return false; + } +} + +function ensureDirectory(path) { + try { + mkdirSync(path, { recursive: true }); + return true; + } catch { + return false; + } +} + +function removeDirectory(path) { + try { + rmSync(path, { recursive: true, force: true }); + return true; + } catch { + return false; + } +} + +// ============================================================================ +// DEPENDENCY EXTRACTION +// ============================================================================ + +function extractDependenciesFromLockfile(lockfilePath) { + const lockfile = readJsonFile(lockfilePath); + + if (!lockfile?.packages) { + return []; + } + + return Object.entries(lockfile.packages) + .filter(([path, info]) => path && path !== '' && info.version) + .map(([path, info]) => ({ + name: path.replace(/^node_modules\//, '').replace(/^.*node_modules\//, ''), + version: info.version })); - - // Run Meterian CLI check using the found path - // Use process.execPath to avoid PATH-based attacks - const cliScript = join(cliPath, 'src', 'cli.js'); - const result = spawnSync(process.execPath, [cliScript, 'check'], { - input: JSON.stringify(input), - encoding: 'utf-8', - timeout: 60000, // 60 second timeout - maxBuffer: 10 * 1024 * 1024 - }); - - if (result.error) { - console.log(` ⚠ Meterian: ${result.error.message}`); - return { vulnerable: [], source: 'Meterian' }; - } - - if (result.status !== 0 && !result.stdout) { - console.log(` ⚠ Meterian: CLI returned status ${result.status}`); - return { vulnerable: [], source: 'Meterian' }; - } - - const output = JSON.parse(result.stdout); - console.log(` āœ“ Meterian: Found ${output.vulnerable?.length || 0} vulnerable packages`); - - return { - vulnerable: output.vulnerable || [], - summary: output.summary, - source: 'Meterian' - }; - } catch (e) { - console.log(` ⚠ Meterian: ${e.message}`); - return { vulnerable: [], source: 'Meterian' }; - } } -async function fetchFromGitHubAdvisory() { - console.log(' šŸ“” Fetching from GitHub Advisory...'); - const results = {}; +function extractDependenciesFromPackageJson(packageJsonPath, existingDependencies) { + const packageJson = readJsonFile(packageJsonPath); - try { - // GitHub Advisory API - query for npm malware - // We search for advisories with malware-related keywords - const response = await fetch( - `${GITHUB_ADVISORY_API}?ecosystem=npm&type=malware&per_page=100`, - { - headers: { - 'Accept': 'application/vnd.github+json', - 'X-GitHub-Api-Version': '2022-11-28' - }, - signal: AbortSignal.timeout(15000) - } - ); - - if (response.ok) { - const advisories = await response.json(); - for (const advisory of advisories) { - const summary = [advisory.summary || '', advisory.description || ''].join(' '); - - // Check each vulnerable package in this advisory - for (const vuln of advisory.vulnerabilities || []) { - const pkgName = vuln.package?.name; - if (pkgName && vuln.package?.ecosystem === 'npm') { - if (!results[pkgName]) { - results[pkgName] = { versions: [], versionRanges: [], reason: '' }; - } - - if (vuln.vulnerable_version_range) { - // Store the full version range for later matching - results[pkgName].versionRanges.push(vuln.vulnerable_version_range); - - // Also extract exact versions where possible - const exactMatch = vuln.vulnerable_version_range.match(/^=\s*(.+)$/); - if (exactMatch) { - results[pkgName].versions.push(exactMatch[1].trim()); - } - } - - results[pkgName].reason = advisory.summary || 'Malware detected by GitHub Advisory'; - results[pkgName].source = 'GitHub'; - } - } - } - console.log(` āœ“ GitHub: Found ${Object.keys(results).length} malware entries`); - } else if (response.status === 403) { - console.log(' ⚠ GitHub: Rate limited (will use cached/fallback data)'); - } - } catch (e) { - console.log(` ⚠ GitHub: Could not fetch (${e.message})`); + if (!packageJson) { + return []; } - return results; + const allDeps = { + ...packageJson.dependencies, + ...packageJson.devDependencies + }; + + return Object.entries(allDeps) + .filter(([, versionSpec]) => typeof versionSpec === 'string' && !versionSpec.startsWith('file:')) + .map(([name, versionSpec]) => ({ + name, + version: versionSpec.replace(/^[\^~>=<]*/, '').split(' ')[0] + })) + .filter(dep => dep.version && !existingDependencies.some( + existing => existing.name === dep.name && existing.version === dep.version + )); } function getProjectDependencies() { - const dependencies = []; - const lockfilePath = join(ROOT_DIR, 'package-lock.json'); + const lockfileDeps = extractDependenciesFromLockfile(join(ROOT_DIR, 'package-lock.json')); + const packageJsonDeps = extractDependenciesFromPackageJson(join(ROOT_DIR, 'package.json'), lockfileDeps); - if (existsSync(lockfilePath)) { - try { - const lockfile = JSON.parse(readFileSync(lockfilePath, 'utf8')); - if (lockfile.packages) { - for (const [path, info] of Object.entries(lockfile.packages)) { - if (!path || path === '' || !info.version) continue; - const name = path.replace(/^node_modules\//, '').replace(/^.*node_modules\//, ''); - dependencies.push({ name, version: info.version }); - } - } - } catch { - // Ignore parse errors - } - } - - // Also check package.json for dependencies not yet in lockfile - const packageJsonPath = join(ROOT_DIR, 'package.json'); - if (existsSync(packageJsonPath)) { - try { - const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')); - const allDeps = { - ...packageJson.dependencies, - ...packageJson.devDependencies - }; - for (const [name, versionSpec] of Object.entries(allDeps)) { - if (typeof versionSpec === 'string' && !versionSpec.startsWith('file:')) { - const version = versionSpec.replace(/^[\^~>=<]*/, '').split(' ')[0]; - if (version && !dependencies.some(d => d.name === name && d.version === version)) { - dependencies.push({ name, version }); - } - } - } - } catch { - // Ignore parse errors - } - } - - return dependencies; + return [...lockfileDeps, ...packageJsonDeps]; } -async function fetchMalwareList(projectDependencies) { - // Check cache first - if (existsSync(CACHE_FILE)) { - try { - const cache = JSON.parse(readFileSync(CACHE_FILE, 'utf8')); - if (Date.now() - cache.timestamp < CACHE_TTL_MS) { - console.log(' Using cached security database...'); - console.log(` (${cache.sources?.join(', ') || 'fallback'} - cached ${Math.round((Date.now() - cache.timestamp) / 60000)} min ago)`); - return { packages: cache.packages, sources: cache.sources || ['fallback'] }; - } - } catch { - // Cache corrupted, will refetch +// ============================================================================ +// CACHE MANAGEMENT +// ============================================================================ + +function readCache() { + const cache = readJsonFile(CACHE_FILE); + + if (!cache) { + return null; + } + + const isExpired = Date.now() - cache.timestamp >= CACHE_TTL_MS; + return isExpired ? null : cache; +} + +function writeCache(packages, sources) { + ensureDirectory(CACHE_DIR); + + writeJsonFile(CACHE_FILE, { + timestamp: Date.now(), + sources, + packages + }); +} + +// ============================================================================ +// MALWARE DATABASE AGGREGATION +// ============================================================================ + +function mergeResults(target, source) { + for (const [name, data] of Object.entries(source)) { + if (target[name]) { + target[name].versions = [...new Set([...target[name].versions, ...(data.versions || [])])]; + target[name].versionRanges = [...new Set([ + ...(target[name].versionRanges || []), + ...(data.versionRanges || []) + ])]; + } else { + target[name] = data; } } +} + +async function fetchMalwareDatabase(projectDependencies) { + const cache = readCache(); + + if (cache) { + console.log(' Using cached security database...'); + console.log(` (${cache.sources?.join(', ') || 'fallback'} - cached ${Math.round((Date.now() - cache.timestamp) / 60000)} min ago)`); + return { packages: cache.packages, sources: cache.sources || ['fallback'] }; + } console.log(' Fetching latest security databases...\n'); - // Start with fallback list const packages = { ...FALLBACK_BLOCKED_PACKAGES }; const sources = ['fallback']; - // Fetch from OSV using the project's actual dependencies const osvResults = await fetchFromOSV(projectDependencies); if (Object.keys(osvResults).length > 0) { sources.push('OSV'); - for (const [name, data] of Object.entries(osvResults)) { - if (packages[name]) { - // Merge versions - packages[name].versions = [...new Set([...packages[name].versions, ...data.versions])]; - } else { - packages[name] = data; - } - } + mergeResults(packages, osvResults); } - // Fetch from GitHub Advisory (returns all known npm malware, limited to first page) - const ghResults = await fetchFromGitHubAdvisory(); - if (Object.keys(ghResults).length > 0) { + const githubResults = await fetchFromGitHub(); + if (Object.keys(githubResults).length > 0) { sources.push('GitHub'); - for (const [name, data] of Object.entries(ghResults)) { - if (packages[name]) { - // Merge versions - packages[name].versions = [...new Set([...packages[name].versions, ...(data.versions || [])])]; - packages[name].versionRanges = [...new Set([...(packages[name].versionRanges || []), ...(data.versionRanges || [])])]; - } else { - packages[name] = data; - } - } + mergeResults(packages, githubResults); } - console.log(''); - - // Cache results - try { - if (!existsSync(CACHE_DIR)) { - mkdirSync(CACHE_DIR, { recursive: true }); - } - writeFileSync(CACHE_FILE, JSON.stringify({ - timestamp: Date.now(), - sources, - packages - }, null, 2)); - } catch { - // Cache write failed, continue anyway - } + writeCache(packages, sources); return { packages, sources }; } // ============================================================================ -// PACKAGE CHECKING +// VERSION CHECKING // ============================================================================ -function readJsonFile(filePath) { - if (!existsSync(filePath)) { - return null; - } - try { - return JSON.parse(readFileSync(filePath, 'utf8')); - } catch (e) { - console.error(` Failed to parse ${filePath}: ${e.message}`); - return null; - } -} +function compareVersions(versionA, versionB) { + const partsA = versionA.replace(/^v/, '').split('.').map(part => parseInt(part, 10) || 0); + const partsB = versionB.replace(/^v/, '').split('.').map(part => parseInt(part, 10) || 0); + const maxLength = Math.max(partsA.length, partsB.length); -function compareVersions(a, b) { - const partsA = a.replace(/^v/, '').split('.').map(p => parseInt(p, 10) || 0); - const partsB = b.replace(/^v/, '').split('.').map(p => parseInt(p, 10) || 0); - const len = Math.max(partsA.length, partsB.length); - for (let i = 0; i < len; i++) { - const numA = partsA[i] || 0; - const numB = partsB[i] || 0; - if (numA > numB) return 1; - if (numA < numB) return -1; + let result = 0; + + for (let index = 0; index < maxLength && result === 0; index++) { + const numA = partsA[index] || 0; + const numB = partsB[index] || 0; + result = Math.sign(numA - numB); } - return 0; + + return result; } function matchesVersionRange(version, range) { - // Parse version range like ">= 1.0.0, < 1.0.1" or "= 1.95.6" - const conditions = range.split(',').map(c => c.trim()); + const conditions = range.split(',').map(condition => condition.trim()); for (const condition of conditions) { const match = condition.match(/^(>=|<=|>|<|=)?\s*(\S+)$/); if (!match) continue; const [, operator = '=', targetVersion] = match; - const cmp = compareVersions(version, targetVersion.trim()); + const comparison = compareVersions(version, targetVersion.trim()); - switch (operator) { - case '=': - if (cmp !== 0) return false; - break; - case '>': - if (cmp <= 0) return false; - break; - case '>=': - if (cmp < 0) return false; - break; - case '<': - if (cmp >= 0) return false; - break; - case '<=': - if (cmp > 0) return false; - break; + const operatorChecks = { + '=': comparison === 0, + '>': comparison > 0, + '>=': comparison >= 0, + '<': comparison < 0, + '<=': comparison <= 0 + }; + + if (!operatorChecks[operator]) { + return false; } } + return true; } -function checkVersion(version, blockedEntry) { - // Check exact version matches +function isVersionBlocked(version, blockedEntry) { if (blockedEntry.versions?.some(blocked => blocked === version)) { return true; } - // Check version ranges (from GitHub Advisory) + if (blockedEntry.versionRanges?.some(range => matchesVersionRange(version, range))) { return true; } + return false; } -function checkDependencies(deps, blockedPackages, source) { - const violations = []; - if (!deps) return violations; +// ============================================================================ +// VIOLATION DETECTION +// ============================================================================ - for (const [name, versionSpec] of Object.entries(deps)) { - if (blockedPackages[name]) { - const version = versionSpec.replace(/^[\^~>=<]*/, '').split(' ')[0]; - if (checkVersion(version, blockedPackages[name])) { - violations.push({ - package: name, - version, - reason: blockedPackages[name].reason, - source - }); - } - } - } - return violations; +function checkDependenciesForViolations(dependencies, blockedPackages, source) { + if (!dependencies) return []; + + return Object.entries(dependencies) + .filter(([name]) => blockedPackages[name]) + .map(([name, versionSpec]) => ({ + name, + version: versionSpec.replace(/^[\^~>=<]*/, '').split(' ')[0], + blockedEntry: blockedPackages[name] + })) + .filter(({ version, blockedEntry }) => isVersionBlocked(version, blockedEntry)) + .map(({ name, version, blockedEntry }) => ({ + package: name, + version, + reason: blockedEntry.reason, + source + })); } -function checkLockfileDependencies(packages, blockedPackages) { - const violations = []; - if (!packages) return violations; +function checkLockfileForViolations(packages, blockedPackages) { + if (!packages) return []; - for (const [path, info] of Object.entries(packages)) { - if (!path || path === '') continue; - const name = path.replace(/^node_modules\//, '').replace(/^.*node_modules\//, ''); + return Object.entries(packages) + .filter(([path]) => path && path !== '') + .map(([path, info]) => ({ + name: path.replace(/^node_modules\//, '').replace(/^.*node_modules\//, ''), + version: info.version + })) + .filter(({ name, version }) => blockedPackages[name] && version && isVersionBlocked(version, blockedPackages[name])) + .map(({ name, version }) => ({ + package: name, + version, + reason: blockedPackages[name].reason, + source: 'package-lock.json (transitive)' + })); +} - if (blockedPackages[name] && info.version) { - if (checkVersion(info.version, blockedPackages[name])) { - violations.push({ - package: name, - version: info.version, - reason: blockedPackages[name].reason, - source: 'package-lock.json (transitive)' - }); - } - } +function deduplicateViolations(violations) { + return violations.filter((violation, index, array) => + array.findIndex(other => + other.package === violation.package && other.version === violation.version + ) === index + ); +} + +// ============================================================================ +// METERIAN INTEGRATION +// ============================================================================ + +function prepareDependenciesForMeterian(lockfilePackages) { + return Object.entries(lockfilePackages) + .filter(([path, info]) => path && info.version) + .map(([path, info]) => ({ + name: path.replace(/^node_modules\//, '').replace(/^.*node_modules\//, ''), + version: info.version + })) + .slice(0, 500); +} + +function formatMeterianFindings(meterianResult) { + return (meterianResult.vulnerable || []).map(vulnerability => ({ + package: vulnerability.name, + version: vulnerability.version, + reason: `${vulnerability.severity}: ${vulnerability.id}${vulnerability.safeVersions?.length ? ` (safe: ${vulnerability.safeVersions[0]})` : ''}`, + source: 'Meterian' + })); +} + +function handleMeterianFindings(findings, shouldBlock) { + if (shouldBlock) { + return findings; } - return violations; + + if (findings.length > 0) { + console.log('\nāš ļø Meterian found vulnerabilities (informational, not blocking):'); + for (const finding of findings) { + console.log(` ${finding.package}@${finding.version} - ${finding.reason}`); + } + console.log(' Set ADF_METERIAN_BLOCK=1 to block on these findings.\n'); + } + + return []; +} + +// ============================================================================ +// REPORTING +// ============================================================================ + +function reportViolations(violations) { + console.error('\n' + '='.repeat(70)); + console.error('🚨 SECURITY ALERT: MALICIOUS PACKAGES DETECTED 🚨'); + console.error('='.repeat(70) + '\n'); + + console.error('The following packages are known to be COMPROMISED:\n'); + + for (const violation of violations) { + console.error(` šŸ“¦ ${violation.package}@${violation.version}`); + console.error(` āš ļø ${violation.reason}`); + console.error(` šŸ“ Found in: ${violation.source}\n`); + } + + console.error('='.repeat(70)); + console.error('ā›” INSTALLATION BLOCKED FOR YOUR SECURITY'); + console.error('='.repeat(70) + '\n'); + + console.error('These packages contain malicious code that may:'); + console.error(' • Steal credentials and environment variables'); + console.error(' • Install cryptominers or backdoors'); + console.error(' • Exfiltrate sensitive data\n'); +} + +function handleNodeModulesDeletion() { + const skipDeletion = process.env.ADF_SECURITY_KEEP_NODE_MODULES === '1' || + process.env.ADF_SECURITY_KEEP_NODE_MODULES === 'true'; + const nodeModulesPath = join(ROOT_DIR, 'node_modules'); + + if (!directoryExists(nodeModulesPath)) { + return; + } + + if (skipDeletion) { + console.error('āš ļø Skipping node_modules deletion (ADF_SECURITY_KEEP_NODE_MODULES=1)\n'); + return; + } + + console.error('šŸ—‘ļø Removing node_modules to prevent use of compromised packages...'); + console.error(' (Set ADF_SECURITY_KEEP_NODE_MODULES=1 to skip deletion)\n'); + + if (removeDirectory(nodeModulesPath)) { + console.error('āœ… node_modules deleted successfully.\n'); + } else { + console.error('āš ļø Could not delete node_modules. Please delete it manually.\n'); + } +} + +function reportRequiredActions() { + console.error('šŸ“‹ REQUIRED ACTIONS:'); + console.error(' 1. Review your package.json for the affected packages'); + console.error(' 2. Update to safe versions or remove the packages'); + console.error(' 3. Delete package-lock.json and regenerate it'); + console.error(' 4. Run npm install again\n'); + + console.error('šŸ“š More information:'); + console.error(' • https://osv.dev'); + console.error(' • https://socket.dev/npm/advisories'); + console.error(' • https://github.com/advisories\n'); +} + +function reportSuccess(sources, packageCount) { + const sourceList = sources.join(' + '); + console.log(`āœ… Security check passed (checked against ${sourceList}, ${packageCount} blocked packages)`); + console.log('='.repeat(70) + '\n'); } // ============================================================================ @@ -584,129 +442,46 @@ async function main() { console.log('='.repeat(70)); console.log('Scanning for known supply chain attacks and compromised packages...\n'); - // Get project dependencies and fetch blocked packages list const projectDependencies = getProjectDependencies(); - const { packages: blockedPackages, sources } = await fetchMalwareList(projectDependencies); + const { packages: blockedPackages, sources } = await fetchMalwareDatabase(projectDependencies); const violations = []; - // Check package.json const packageJson = readJsonFile(join(ROOT_DIR, 'package.json')); if (packageJson) { - violations.push(...checkDependencies(packageJson.dependencies, blockedPackages, 'package.json (dependencies)')); - violations.push(...checkDependencies(packageJson.devDependencies, blockedPackages, 'package.json (devDependencies)')); + violations.push(...checkDependenciesForViolations(packageJson.dependencies, blockedPackages, 'package.json (dependencies)')); + violations.push(...checkDependenciesForViolations(packageJson.devDependencies, blockedPackages, 'package.json (devDependencies)')); } - // Check package-lock.json const lockfile = readJsonFile(join(ROOT_DIR, 'package-lock.json')); if (lockfile?.packages) { - violations.push(...checkLockfileDependencies(lockfile.packages, blockedPackages)); + violations.push(...checkLockfileForViolations(lockfile.packages, blockedPackages)); } - // Check with Meterian CLI for additional vulnerability coverage (informational by default) - // Set ADF_METERIAN_BLOCK=1 to make Meterian findings block installation - const meterianFindings = []; if (lockfile?.packages) { - console.log(''); - const deps = Object.entries(lockfile.packages) - .filter(([path, info]) => path && info.version) - .map(([path, info]) => ({ - name: path.replace(/^node_modules\//, '').replace(/^.*node_modules\//, ''), - version: info.version - })) - .slice(0, 500); // Limit to avoid timeout + const meterianDeps = prepareDependenciesForMeterian(lockfile.packages); + const meterianResult = await checkWithMeterian(meterianDeps, ROOT_DIR); + const meterianFindings = formatMeterianFindings(meterianResult); - const meterianResult = await checkWithMeterian(deps); - for (const vuln of meterianResult.vulnerable || []) { - meterianFindings.push({ - package: vuln.name, - version: vuln.version, - reason: `${vuln.severity}: ${vuln.id}${vuln.safeVersions?.length ? ` (safe: ${vuln.safeVersions[0]})` : ''}`, - source: 'Meterian' - }); - } + const shouldBlockOnMeterian = process.env.ADF_METERIAN_BLOCK === '1' || + process.env.ADF_METERIAN_BLOCK === 'true'; + violations.push(...handleMeterianFindings(meterianFindings, shouldBlockOnMeterian)); } - // Meterian findings are informational unless ADF_METERIAN_BLOCK is set - const blockOnMeterian = process.env.ADF_METERIAN_BLOCK === '1' || process.env.ADF_METERIAN_BLOCK === 'true'; - if (blockOnMeterian) { - violations.push(...meterianFindings); - } else if (meterianFindings.length > 0) { - console.log('\nāš ļø Meterian found vulnerabilities (informational, not blocking):'); - for (const v of meterianFindings) { - console.log(` ${v.package}@${v.version} - ${v.reason}`); - } - console.log(' Set ADF_METERIAN_BLOCK=1 to block on these findings.\n'); - } - - // Deduplicate - const uniqueViolations = violations.filter((v, i, arr) => - arr.findIndex(x => x.package === v.package && x.version === v.version) === i - ); + const uniqueViolations = deduplicateViolations(violations); if (uniqueViolations.length > 0) { - console.error('\n' + '='.repeat(70)); - console.error('🚨 SECURITY ALERT: MALICIOUS PACKAGES DETECTED 🚨'); - console.error('='.repeat(70) + '\n'); - - console.error('The following packages are known to be COMPROMISED:\n'); - - for (const v of uniqueViolations) { - console.error(` šŸ“¦ ${v.package}@${v.version}`); - console.error(` āš ļø ${v.reason}`); - console.error(` šŸ“ Found in: ${v.source}\n`); - } - - console.error('='.repeat(70)); - console.error('ā›” INSTALLATION BLOCKED FOR YOUR SECURITY'); - console.error('='.repeat(70) + '\n'); - - console.error('These packages contain malicious code that may:'); - console.error(' • Steal credentials and environment variables'); - console.error(' • Install cryptominers or backdoors'); - console.error(' • Exfiltrate sensitive data\n'); - - // Delete node_modules to prevent using compromised packages - // Set ADF_SECURITY_KEEP_NODE_MODULES=1 to skip deletion (e.g., in CI for caching) - const skipDeletion = process.env.ADF_SECURITY_KEEP_NODE_MODULES === '1' || process.env.ADF_SECURITY_KEEP_NODE_MODULES === 'true'; - const nodeModulesPath = join(ROOT_DIR, 'node_modules'); - if (existsSync(nodeModulesPath) && !skipDeletion) { - console.error('šŸ—‘ļø Removing node_modules to prevent use of compromised packages...'); - console.error(' (Set ADF_SECURITY_KEEP_NODE_MODULES=1 to skip deletion)\n'); - try { - rmSync(nodeModulesPath, { recursive: true, force: true }); - console.error('āœ… node_modules deleted successfully.\n'); - } catch (e) { - console.error(`āš ļø Could not delete node_modules: ${e.message}`); - console.error(' Please delete it manually before proceeding.\n'); - } - } else if (skipDeletion) { - console.error('āš ļø Skipping node_modules deletion (ADF_SECURITY_KEEP_NODE_MODULES=1)\n'); - } - - console.error('šŸ“‹ REQUIRED ACTIONS:'); - console.error(' 1. Review your package.json for the affected packages'); - console.error(' 2. Update to safe versions or remove the packages'); - console.error(' 3. Delete package-lock.json and regenerate it'); - console.error(' 4. Run npm install again\n'); - - console.error('šŸ“š More information:'); - console.error(' • https://osv.dev'); - console.error(' • https://socket.dev/npm/advisories'); - console.error(' • https://github.com/advisories\n'); - + reportViolations(uniqueViolations); + handleNodeModulesDeletion(); + reportRequiredActions(); process.exit(1); } - const packageCount = Object.keys(blockedPackages).length; - const sourceList = sources.join(' + '); - console.log(`āœ… Security check passed (checked against ${sourceList}, ${packageCount} blocked packages)`); - console.log('='.repeat(70) + '\n'); + reportSuccess(sources, Object.keys(blockedPackages).length); process.exit(0); } -main().catch(err => { - console.error('Security check failed:', err.message); - // Don't block install if script itself fails +main().catch(error => { + console.error('Security check failed:', error.message); process.exit(0); }); diff --git a/scripts/postinstall-security.mjs b/scripts/postinstall-security.mjs index ccbecd5bb9..b90296da47 100644 --- a/scripts/postinstall-security.mjs +++ b/scripts/postinstall-security.mjs @@ -30,23 +30,19 @@ * 3. Sets up husky */ -import { execSync } from 'child_process'; -import { existsSync } from 'fs'; -import { join, dirname } from 'path'; -import { fileURLToPath } from 'url'; +import { execSync } from 'node:child_process'; +import { statSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; const __dirname = dirname(fileURLToPath(import.meta.url)); const ROOT_DIR = join(__dirname, '..'); -// Use npm_execpath from environment (set by npm during lifecycle scripts) -// Falls back to 'npm' if not available (e.g., running script directly) const NPM_PATH = process.env.npm_execpath || 'npm'; const NPX_CMD = NPM_PATH.endsWith('npm-cli.js') ? `"${process.execPath}" "${NPM_PATH.replace('npm-cli.js', 'npx-cli.js')}"` : 'npx'; -// Packages that are trusted to run postinstall/install scripts -// These typically need to compile native bindings or setup tooling const TRUSTED_PACKAGES = [ 'esbuild', 'sharp', @@ -66,7 +62,6 @@ const TRUSTED_PACKAGES = [ 'core-js-pure' ]; -// Scoped packages that need rebuild (full package names) const TRUSTED_SCOPED_PACKAGES = [ '@esbuild/darwin-arm64', '@esbuild/darwin-x64', @@ -81,6 +76,14 @@ const TRUSTED_SCOPED_PACKAGES = [ '@swc/core' ]; +function directoryExists(path) { + try { + return statSync(path).isDirectory(); + } catch { + return false; + } +} + function run(command, options = {}) { try { execSync(command, { @@ -96,28 +99,59 @@ function run(command, options = {}) { function getInstalledTrustedPackages() { const nodeModulesPath = join(ROOT_DIR, 'node_modules'); - if (!existsSync(nodeModulesPath)) return []; - const installed = []; - - // Check non-scoped packages - for (const pkg of TRUSTED_PACKAGES) { - const pkgPath = join(nodeModulesPath, pkg); - if (existsSync(pkgPath)) { - installed.push(pkg); - } + if (!directoryExists(nodeModulesPath)) { + return []; } - // Check scoped packages - for (const pkg of TRUSTED_SCOPED_PACKAGES) { - const [scope, name] = pkg.split('/'); - const pkgPath = join(nodeModulesPath, scope, name); - if (existsSync(pkgPath)) { - installed.push(pkg); - } + const nonScopedInstalled = TRUSTED_PACKAGES + .filter(pkg => directoryExists(join(nodeModulesPath, pkg))); + + const scopedInstalled = TRUSTED_SCOPED_PACKAGES + .filter(pkg => { + const [scope, name] = pkg.split('/'); + return directoryExists(join(nodeModulesPath, scope, name)); + }); + + return [...new Set([...nonScopedInstalled, ...scopedInstalled])]; +} + +function runSecurityCheck() { + console.log('Step 1/3: Running security check...\n'); + const securityCheckPath = join(__dirname, 'check-security.mjs'); + const securityPassed = run(`"${process.execPath}" "${securityCheckPath}"`); + + if (!securityPassed) { + console.error('\nāŒ Security check failed - installation aborted\n'); + process.exit(1); + } +} + +function rebuildTrustedPackages() { + console.log('\nStep 2/3: Rebuilding trusted packages...\n'); + const trustedInstalled = getInstalledTrustedPackages(); + + if (!trustedInstalled.length) { + console.log('No trusted packages require rebuilding.\n'); + return; } - return [...new Set(installed)]; + console.log('Trusted packages to rebuild:'); + for (const pkg of trustedInstalled) { + console.log(` āœ“ ${pkg}`); + } + + const npmCmd = NPM_PATH === 'npm' ? 'npm' : `"${process.execPath}" "${NPM_PATH}"`; + run(`${npmCmd} rebuild --ignore-scripts=false ${trustedInstalled.join(' ')}`); +} + +function setupHusky() { + console.log('\nStep 3/3: Setting up husky...\n'); + const huskyPath = join(ROOT_DIR, 'node_modules', 'husky'); + + if (directoryExists(huskyPath)) { + run(`${NPX_CMD} husky`); + } } async function main() { @@ -125,45 +159,16 @@ async function main() { console.log('šŸ”’ ADF POST-INSTALL SECURITY'); console.log('='.repeat(70) + '\n'); - // Step 1: Run security check - console.log('Step 1/3: Running security check...\n'); - const securityCheckPath = join(__dirname, 'check-security.mjs'); - - // Run security check as subprocess using process.execPath to avoid PATH-based attacks - const securityPassed = run(`"${process.execPath}" "${securityCheckPath}"`); - if (!securityPassed) { - console.error('\nāŒ Security check failed - installation aborted\n'); - process.exit(1); - } - - // Step 2: Rebuild trusted packages - console.log('\nStep 2/3: Rebuilding trusted packages...\n'); - const trustedInstalled = getInstalledTrustedPackages(); - - if (trustedInstalled.length > 0) { - console.log('Trusted packages to rebuild:'); - trustedInstalled.forEach(pkg => console.log(` āœ“ ${pkg}`)); - console.log(''); - - const npmCmd = NPM_PATH === 'npm' ? 'npm' : `"${process.execPath}" "${NPM_PATH}"`; - run(`${npmCmd} rebuild --ignore-scripts=false ${trustedInstalled.join(' ')}`); - } else { - console.log('No trusted packages require rebuilding.\n'); - } - - // Step 3: Setup husky - console.log('Step 3/3: Setting up husky...\n'); - const huskyPath = join(ROOT_DIR, 'node_modules', 'husky'); - if (existsSync(huskyPath)) { - run(`${NPX_CMD} husky`); - } + runSecurityCheck(); + rebuildTrustedPackages(); + setupHusky(); console.log('='.repeat(70)); console.log('āœ… Post-install security complete'); console.log('='.repeat(70) + '\n'); } -main().catch(err => { - console.error('Post-install failed:', err.message); +main().catch(error => { + console.error('Post-install failed:', error.message); process.exit(1); }); diff --git a/scripts/preinstall-check.mjs b/scripts/preinstall-check.mjs index 78828029d3..db7edfb87f 100644 --- a/scripts/preinstall-check.mjs +++ b/scripts/preinstall-check.mjs @@ -28,16 +28,15 @@ * (e.g., nx migrate) before the lockfile is updated. */ -import { readFileSync, existsSync, mkdirSync, writeFileSync } from 'fs'; -import { join, dirname } from 'path'; -import { fileURLToPath } from 'url'; +import { readFileSync, mkdirSync, writeFileSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; const __dirname = dirname(fileURLToPath(import.meta.url)); const ROOT_DIR = join(__dirname, '..'); const CACHE_DIR = join(ROOT_DIR, 'node_modules', '.cache', 'security-check'); -const CACHE_TTL = 24 * 60 * 60 * 1000; // 24 hours +const CACHE_TTL = 24 * 60 * 60 * 1000; -// Known supply chain attack packages (fallback if APIs fail) const KNOWN_MALICIOUS = new Set([ 'event-stream@3.3.6', 'flatmap-stream@0.1.1', @@ -52,47 +51,85 @@ const KNOWN_MALICIOUS = new Set([ '@primevue/themes@4.3.0', '@nicolo-ribaudo/chokidar-2@2.1.8-no-fsevents.3' ]); -function readCache() { - const cachePath = join(CACHE_DIR, 'threats.json'); - if (!existsSync(cachePath)) return null; +// ============================================================================ +// FILE UTILITIES +// ============================================================================ + +function readJsonFile(filePath) { try { - const data = JSON.parse(readFileSync(cachePath, 'utf8')); - if (Date.now() - data.timestamp < CACHE_TTL) { - return { - threats: new Set(data.threats), - ranges: data.ranges || [] - }; - } - } catch { /* ignore */ } - return null; + return JSON.parse(readFileSync(filePath, 'utf8')); + } catch { + return null; + } +} + +function writeJsonFile(filePath, data) { + try { + writeFileSync(filePath, JSON.stringify(data)); + return true; + } catch { + return false; + } +} + +function ensureDirectory(path) { + try { + mkdirSync(path, { recursive: true }); + return true; + } catch { + return false; + } +} + +// ============================================================================ +// CACHE MANAGEMENT +// ============================================================================ + +function readCache() { + const data = readJsonFile(join(CACHE_DIR, 'threats.json')); + + if (!data || Date.now() - data.timestamp >= CACHE_TTL) { + return null; + } + + return { + threats: new Set(data.threats), + ranges: data.ranges || [] + }; } function writeCache(threats, ranges) { - try { - if (!existsSync(CACHE_DIR)) { - mkdirSync(CACHE_DIR, { recursive: true }); - } - writeFileSync(join(CACHE_DIR, 'threats.json'), JSON.stringify({ - timestamp: Date.now(), - threats: [...threats], - ranges: ranges - })); - } catch { /* ignore */ } + ensureDirectory(CACHE_DIR); + writeJsonFile(join(CACHE_DIR, 'threats.json'), { + timestamp: Date.now(), + threats: [...threats], + ranges + }); +} + +// ============================================================================ +// SECURITY PROVIDERS +// ============================================================================ + +function splitIntoBatches(items, batchSize) { + const batches = []; + for (let index = 0; index < items.length; index += batchSize) { + batches.push(items.slice(index, index + batchSize)); + } + return batches; } async function fetchOSV(projectDependencies) { - // Query OSV batch API for the project's actual dependencies - if (!projectDependencies || projectDependencies.length === 0) { + if (!projectDependencies?.length) { return new Set(); } const malicious = new Set(); try { - // Process in batches of 1000 - const batchSize = 1000; - for (let i = 0; i < projectDependencies.length; i += batchSize) { - const batch = projectDependencies.slice(i, i + batchSize); + const batches = splitIntoBatches(projectDependencies, 1000); + + for (const batch of batches) { const response = await fetch('https://api.osv.dev/v1/querybatch', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -111,18 +148,14 @@ async function fetchOSV(projectDependencies) { for (const result of data.results || []) { for (const vuln of result.vulns || []) { const summary = [vuln.summary || '', vuln.details || ''].join(' ').toLowerCase(); - const isMalware = summary.includes('malware') || - summary.includes('malicious') || - summary.includes('compromised') || - summary.includes('supply chain') || - summary.includes('backdoor'); + const isMalware = ['malware', 'malicious', 'compromised', 'supply chain', 'backdoor'] + .some(keyword => summary.includes(keyword)); if (isMalware && vuln.affected) { for (const affected of vuln.affected) { if (affected.package?.ecosystem === 'npm' && affected.package?.name) { - const versions = affected.versions || []; - for (const v of versions) { - malicious.add(`${affected.package.name}@${v}`); + for (const version of affected.versions || []) { + malicious.add(`${affected.package.name}@${version}`); } } } @@ -131,7 +164,7 @@ async function fetchOSV(projectDependencies) { } } } catch { - // Ignore errors, return what we have + // Ignore errors } return malicious; @@ -143,126 +176,143 @@ async function fetchGitHubAdvisory() { headers: { 'Accept': 'application/vnd.github+json' }, signal: AbortSignal.timeout(10000) }); - if (!response.ok) return new Set(); + + if (!response.ok) { + return new Set(); + } const advisories = await response.json(); const malicious = new Set(); for (const advisory of advisories) { - if (advisory.vulnerabilities) { - for (const vuln of advisory.vulnerabilities) { - if (vuln.package?.ecosystem === 'npm' && vuln.package?.name) { - // GitHub uses version ranges, we'll mark the package name - // and check ranges in the scan - if (vuln.vulnerable_version_range) { - malicious.add(`${vuln.package.name}:${vuln.vulnerable_version_range}`); - } - } + for (const vuln of advisory.vulnerabilities || []) { + if (vuln.package?.ecosystem === 'npm' && vuln.package?.name && vuln.vulnerable_version_range) { + malicious.add(`${vuln.package.name}:${vuln.vulnerable_version_range}`); } } } + return malicious; } catch { return new Set(); } } +// ============================================================================ +// VERSION COMPARISON +// ============================================================================ + +function compareVersions(versionA, versionB) { + const partsA = versionA.split('.').map(Number); + const partsB = versionB.split('.').map(Number); + const maxLength = Math.max(partsA.length, partsB.length); + + let result = 0; + + for (let index = 0; index < maxLength && result === 0; index++) { + const numA = partsA[index] || 0; + const numB = partsB[index] || 0; + result = Math.sign(numA - numB); + } + + return result; +} + function parseVersionRange(range, version) { - // Simple version range parser for GitHub advisory format - // Handles: "= 1.0.0", "< 1.0.0", "<= 1.0.0", "> 1.0.0", ">= 1.0.0" if (!range || !version) return false; - const parts = range.split(',').map(p => p.trim()); + const parts = range.split(',').map(part => part.trim()); + for (const part of parts) { const match = part.match(/^([<>=]+)\s*(\S+)$/); + if (!match) { if (part === version) return true; continue; } - const [, op, rangeVer] = match; - const cmp = compareVersions(version, rangeVer); - if (op === '=' && cmp !== 0) return false; - if (op === '<' && cmp >= 0) return false; - if (op === '<=' && cmp > 0) return false; - if (op === '>' && cmp <= 0) return false; - if (op === '>=' && cmp < 0) return false; + const [, operator, rangeVersion] = match; + const comparison = compareVersions(version, rangeVersion); + + const checks = { + '=': comparison === 0, + '<': comparison < 0, + '<=': comparison <= 0, + '>': comparison > 0, + '>=': comparison >= 0 + }; + + if (!checks[operator]) return false; } + return true; } -function compareVersions(a, b) { - const pa = a.split('.').map(Number); - const pb = b.split('.').map(Number); - for (let i = 0; i < Math.max(pa.length, pb.length); i++) { - const na = pa[i] || 0; - const nb = pb[i] || 0; - if (na > nb) return 1; - if (na < nb) return -1; - } - return 0; -} +// ============================================================================ +// PACKAGE EXTRACTION +// ============================================================================ function extractVersionNumber(versionSpec) { if (!versionSpec) return null; - // Remove ^, ~, >=, <=, >, <, = prefixes - // Use atomic pattern to prevent backtracking: match version then optional prerelease + const match = versionSpec.match(/(\d+)\.(\d+)\.(\d+)(?:-([a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*))?/); return match ? match[0] : null; } function getPackagesFromPackageJson() { - const packageJsonPath = join(ROOT_DIR, 'package.json'); - if (!existsSync(packageJsonPath)) return []; + const packageJson = readJsonFile(join(ROOT_DIR, 'package.json')); - const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')); - const packages = []; + if (!packageJson) { + return []; + } const depTypes = ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies']; - for (const depType of depTypes) { - const deps = packageJson[depType] || {}; - for (const [name, versionSpec] of Object.entries(deps)) { - // Skip file: and link: dependencies - if (typeof versionSpec === 'string' && !versionSpec.startsWith('file:') && !versionSpec.startsWith('link:')) { - const version = extractVersionNumber(versionSpec); - if (version) { - packages.push({ name, version, source: 'package.json' }); - } - } - } - } - return packages; + return depTypes + .flatMap(depType => Object.entries(packageJson[depType] || {})) + .filter(([, versionSpec]) => + typeof versionSpec === 'string' && + !versionSpec.startsWith('file:') && + !versionSpec.startsWith('link:') + ) + .map(([name, versionSpec]) => ({ + name, + version: extractVersionNumber(versionSpec), + source: 'package.json' + })) + .filter(pkg => pkg.version); } function getPackagesFromLockfile() { - const lockfilePath = join(ROOT_DIR, 'package-lock.json'); - if (!existsSync(lockfilePath)) { + const lockfile = readJsonFile(join(ROOT_DIR, 'package-lock.json')); + + if (!lockfile) { return []; } - const lockfile = JSON.parse(readFileSync(lockfilePath, 'utf8')); const packages = []; - // npm v2+ lockfile format if (lockfile.packages) { for (const [path, info] of Object.entries(lockfile.packages)) { - if (!path || path === '') continue; // skip root - const name = path.replace(/^node_modules\//, '').replace(/\/node_modules\//g, '/'); - if (info.version) { - packages.push({ name, version: info.version, source: 'lockfile' }); - } + if (!path || path === '' || !info.version) continue; + + packages.push({ + name: path.replace(/^node_modules\//, '').replace(/\/node_modules\//g, '/'), + version: info.version, + source: 'lockfile' + }); } } - // npm v1 lockfile format if (lockfile.dependencies) { const extractDeps = (deps, prefix = '') => { for (const [name, info] of Object.entries(deps)) { const fullName = prefix ? `${prefix}/${name}` : name; + if (info.version) { packages.push({ name: fullName, version: info.version, source: 'lockfile' }); } + if (info.dependencies) { extractDeps(info.dependencies, fullName); } @@ -278,11 +328,12 @@ function getAllPackages() { const packageJsonPkgs = getPackagesFromPackageJson(); const lockfilePkgs = getPackagesFromLockfile(); - // Combine and dedupe (lockfile takes precedence for same name) const seen = new Map(); + for (const pkg of lockfilePkgs) { seen.set(`${pkg.name}@${pkg.version}`, pkg); } + for (const pkg of packageJsonPkgs) { const key = `${pkg.name}@${pkg.version}`; if (!seen.has(key)) { @@ -293,96 +344,110 @@ function getAllPackages() { return [...seen.values()]; } -async function main() { - console.log('\nšŸ”’ ADF SECURITY CHECK'); - console.log('='.repeat(70)); - console.log('Scanning for known supply chain attacks and compromised packages...\n'); +// ============================================================================ +// VIOLATION DETECTION +// ============================================================================ - // Get all packages from both package.json and lockfile - const packages = getAllPackages(); - if (packages.length === 0) { - console.log(' āš ļø No packages found to check'); - console.log('='.repeat(70) + '\n'); - process.exit(0); - } - - const fromPackageJson = packages.filter(p => p.source === 'package.json').length; - const fromLockfile = packages.filter(p => p.source === 'lockfile').length; - console.log(` Checking ${packages.length} packages (${fromPackageJson} from package.json, ${fromLockfile} from lockfile)\n`); - - // Try to use cache first - const cache = readCache(); - let threats; - let ghRanges; - - if (!cache) { - console.log(' Fetching latest security databases...\n'); - - const [osvThreats, ghThreats] = await Promise.all([ - fetchOSV(packages).then(r => { console.log(` šŸ“” OSV: ${r.size} malware entries`); return r; }), - fetchGitHubAdvisory().then(r => { console.log(` šŸ“” GitHub Advisory: ${r.size} malware entries`); return r; }) - ]); - - threats = new Set([...KNOWN_MALICIOUS, ...osvThreats]); - ghRanges = [...ghThreats]; - - // Cache the results including ranges - writeCache(threats, ghRanges); - } else { - threats = cache.threats; - ghRanges = cache.ranges; - console.log(` Using cached security database (${threats.size} exact + ${ghRanges.length} ranges)\n`); - } - - // Check packages against exact matches and ranges - const found = []; +function findViolations(packages, threats, ghRanges) { + const violations = []; for (const pkg of packages) { - const exact = `${pkg.name}@${pkg.version}`; + const exactKey = `${pkg.name}@${pkg.version}`; - // Check exact match - if (threats.has(exact)) { - found.push({ ...pkg, source: 'exact match' }); + if (threats.has(exactKey)) { + violations.push({ ...pkg, detectionSource: 'exact match' }); continue; } - // Check GitHub Advisory ranges for (const entry of ghRanges) { const [name, range] = entry.split(':'); if (pkg.name === name && parseVersionRange(range, pkg.version)) { - found.push({ ...pkg, source: 'GitHub Advisory' }); + violations.push({ ...pkg, detectionSource: 'GitHub Advisory' }); break; } } } - if (found.length > 0) { - console.log('\n' + '!'.repeat(70)); - console.log('🚨 MALICIOUS PACKAGES DETECTED - BLOCKING INSTALLATION'); - console.log('!'.repeat(70) + '\n'); + return violations; +} - for (const pkg of found) { - console.log(` āŒ ${pkg.name}@${pkg.version} (${pkg.source})`); - } +// ============================================================================ +// REPORTING +// ============================================================================ - console.log('\nThese packages are known to contain malware or malicious code.'); - console.log('Installation has been blocked to protect your system.\n'); - console.log('Actions:'); - console.log(' 1. Remove these packages from package.json'); - console.log(' 2. Find safe alternatives'); - console.log(' 3. Run npm install again\n'); - console.log('='.repeat(70) + '\n'); +function reportViolations(violations) { + console.log('\n' + '!'.repeat(70)); + console.log('🚨 MALICIOUS PACKAGES DETECTED - BLOCKING INSTALLATION'); + console.log('!'.repeat(70) + '\n'); - process.exit(1); + for (const pkg of violations) { + console.log(` āŒ ${pkg.name}@${pkg.version} (${pkg.detectionSource})`); } - console.log(`\nāœ… Security check passed (${threats.size} exact + ${ghRanges.length} ranges checked)`) - + console.log('\nThese packages are known to contain malware or malicious code.'); + console.log('Installation has been blocked to protect your system.\n'); + console.log('Actions:'); + console.log(' 1. Remove these packages from package.json'); + console.log(' 2. Find safe alternatives'); + console.log(' 3. Run npm install again\n'); console.log('='.repeat(70) + '\n'); } -main().catch(err => { - console.error('Security check error:', err.message); - // Don't block on errors - allow install to proceed +// ============================================================================ +// MAIN +// ============================================================================ + +async function main() { + console.log('\nšŸ”’ ADF SECURITY CHECK'); + console.log('='.repeat(70)); + console.log('Scanning for known supply chain attacks and compromised packages...\n'); + + const packages = getAllPackages(); + + if (!packages.length) { + console.log(' āš ļø No packages found to check'); + console.log('='.repeat(70) + '\n'); + process.exit(0); + } + + const fromPackageJson = packages.filter(pkg => pkg.source === 'package.json').length; + const fromLockfile = packages.filter(pkg => pkg.source === 'lockfile').length; + console.log(` Checking ${packages.length} packages (${fromPackageJson} from package.json, ${fromLockfile} from lockfile)\n`); + + const cache = readCache(); + let threats; + let ghRanges; + + if (cache) { + threats = cache.threats; + ghRanges = cache.ranges; + console.log(` Using cached security database (${threats.size} exact + ${ghRanges.length} ranges)\n`); + } else { + console.log(' Fetching latest security databases...\n'); + + const [osvThreats, ghThreats] = await Promise.all([ + fetchOSV(packages).then(result => { console.log(` šŸ“” OSV: ${result.size} malware entries`); return result; }), + fetchGitHubAdvisory().then(result => { console.log(` šŸ“” GitHub Advisory: ${result.size} malware entries`); return result; }) + ]); + + threats = new Set([...KNOWN_MALICIOUS, ...osvThreats]); + ghRanges = [...ghThreats]; + + writeCache(threats, ghRanges); + } + + const violations = findViolations(packages, threats, ghRanges); + + if (violations.length) { + reportViolations(violations); + process.exit(1); + } + + console.log(`\nāœ… Security check passed (${threats.size} exact + ${ghRanges.length} ranges checked)`); + console.log('='.repeat(70) + '\n'); +} + +main().catch(error => { + console.error('Security check error:', error.message); process.exit(0); }); diff --git a/scripts/security-providers/fallback-list.mjs b/scripts/security-providers/fallback-list.mjs new file mode 100644 index 0000000000..70e01292a8 --- /dev/null +++ b/scripts/security-providers/fallback-list.mjs @@ -0,0 +1,60 @@ +/*! + * @license + * Copyright Ā© 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Fallback list of known malicious packages. + * Used when external APIs (OSV, GitHub) are unreachable. + * These are confirmed supply chain attacks (not regular CVEs). + */ +export const FALLBACK_BLOCKED_PACKAGES = { + '@solana/web3.js': { + versions: ['1.95.6', '1.95.7'], + reason: 'Compromised - steals private keys (Dec 2024)' + }, + '@lottiefiles/lottie-player': { + versions: ['2.0.5', '2.0.6', '2.0.7'], + reason: 'Compromised - crypto wallet drainer (Oct 2024)' + }, + 'node-ipc': { + versions: ['9.2.2', '10.1.1', '10.1.2', '10.1.3', '11.0.0', '11.1.0'], + reason: 'Protestware - overwrites files (Mar 2022)' + }, + 'ua-parser-js': { + versions: ['0.7.29', '0.8.0', '1.0.0'], + reason: 'Compromised - cryptominer and password stealer (Oct 2021)' + }, + 'coa': { + versions: ['2.0.3', '2.0.4', '2.1.1', '2.1.3', '3.0.1', '3.1.3'], + reason: 'Compromised - password stealer (Nov 2021)' + }, + 'rc': { + versions: ['1.2.9', '1.3.9', '2.3.9'], + reason: 'Compromised - exfiltrates environment variables (Nov 2021)' + }, + 'colors': { + versions: ['1.4.1', '1.4.44-liberty-2'], + reason: 'Sabotage - infinite loop (Jan 2022)' + }, + 'faker': { + versions: ['6.6.6'], + reason: 'Sabotage - no functionality (Jan 2022)' + }, + 'event-stream': { + versions: ['3.3.6'], + reason: 'Compromised - bitcoin wallet theft (Nov 2018)' + } +}; diff --git a/scripts/security-providers/github-provider.mjs b/scripts/security-providers/github-provider.mjs new file mode 100644 index 0000000000..a550b12888 --- /dev/null +++ b/scripts/security-providers/github-provider.mjs @@ -0,0 +1,91 @@ +/*! + * @license + * Copyright Ā© 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const GITHUB_ADVISORY_API = 'https://api.github.com/advisories'; +const REQUEST_TIMEOUT_MS = 15000; + +function extractExactVersion(versionRange) { + const exactMatch = versionRange.match(/^=\s*(.+)$/); + return exactMatch ? exactMatch[1].trim() : null; +} + +function processAdvisoryVulnerability(vulnerability, advisory, registry) { + const packageName = vulnerability.package?.name; + + if (!packageName || vulnerability.package?.ecosystem !== 'npm') { + return; + } + + if (!registry[packageName]) { + registry[packageName] = { versions: [], versionRanges: [], reason: '', source: 'GitHub' }; + } + + if (vulnerability.vulnerable_version_range) { + registry[packageName].versionRanges.push(vulnerability.vulnerable_version_range); + + const exactVersion = extractExactVersion(vulnerability.vulnerable_version_range); + if (exactVersion) { + registry[packageName].versions.push(exactVersion); + } + } + + registry[packageName].reason = advisory.summary || 'Malware detected by GitHub Advisory'; +} + +function processAdvisory(advisory, registry) { + const vulnerabilities = advisory.vulnerabilities || []; + + for (const vulnerability of vulnerabilities) { + processAdvisoryVulnerability(vulnerability, advisory, registry); + } +} + +export async function fetchMalwareData() { + console.log(' šŸ“” Fetching from GitHub Advisory...'); + const malwareRegistry = {}; + + try { + const response = await fetch( + `${GITHUB_ADVISORY_API}?ecosystem=npm&type=malware&per_page=100`, + { + headers: { + 'Accept': 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28' + }, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) + } + ); + + if (response.ok) { + const advisories = await response.json(); + + for (const advisory of advisories) { + processAdvisory(advisory, malwareRegistry); + } + + console.log(` āœ“ GitHub: Found ${Object.keys(malwareRegistry).length} malware entries`); + } else if (response.status === 403) { + console.log(' ⚠ GitHub: Rate limited (will use cached/fallback data)'); + } else { + console.log(` ⚠ GitHub: Unexpected status ${response.status}`); + } + } catch (error) { + console.log(` ⚠ GitHub: Could not fetch (${error.message})`); + } + + return malwareRegistry; +} diff --git a/scripts/security-providers/meterian-provider.mjs b/scripts/security-providers/meterian-provider.mjs new file mode 100644 index 0000000000..1874c55458 --- /dev/null +++ b/scripts/security-providers/meterian-provider.mjs @@ -0,0 +1,142 @@ +/*! + * @license + * Copyright Ā© 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { readdirSync, statSync } from 'node:fs'; +import { join } from 'node:path'; +import { spawnSync } from 'node:child_process'; + +const CLI_TIMEOUT_MS = 60000; +const MAX_BUFFER_SIZE = 10 * 1024 * 1024; + +function isDirectory(path) { + try { + return statSync(path).isDirectory(); + } catch { + return false; + } +} + +function listDirectories(path) { + try { + return readdirSync(path); + } catch { + return []; + } +} + +function findVSCodeExtensionCli(homeDir) { + const extensionsDir = join(homeDir, '.vscode', 'extensions'); + const extensions = listDirectories(extensionsDir) + .filter(dir => /^meterian\.meterian-heidi-\d+\.\d+\.\d+$/.test(dir)) + .sort() + .reverse(); + + for (const extension of extensions) { + const cliPath = join(extensionsDir, extension, 'packages', 'meterian-cli'); + if (isDirectory(cliPath)) { + return cliPath; + } + } + + return null; +} + +function findGlobalCli(homeDir, rootDir) { + const possibleLocations = [ + join(rootDir, 'node_modules', '@meterian', 'cli'), + join(homeDir, '.npm-global', 'lib', 'node_modules', '@meterian', 'cli'), + '/usr/local/lib/node_modules/@meterian/cli' + ]; + + for (const location of possibleLocations) { + if (isDirectory(location)) { + return location; + } + } + + return null; +} + +export function findCliPath(rootDir) { + const homeDir = process.env.HOME || process.env.USERPROFILE; + + return findVSCodeExtensionCli(homeDir) || findGlobalCli(homeDir, rootDir); +} + +export function isAvailable(rootDir) { + return findCliPath(rootDir) !== null; +} + +function formatDependenciesForCli(dependencies) { + return dependencies.map(dependency => ({ + language: 'nodejs', + name: dependency.name, + version: dependency.version + })); +} + +function runCliCheck(cliPath, input) { + const cliScript = join(cliPath, 'src', 'cli.js'); + + return spawnSync(process.execPath, [cliScript, 'check'], { + input: JSON.stringify(input), + encoding: 'utf-8', + timeout: CLI_TIMEOUT_MS, + maxBuffer: MAX_BUFFER_SIZE + }); +} + +function parseCliOutput(result) { + if (result.error) { + throw new Error(result.error.message); + } + + if (result.status !== 0 && !result.stdout) { + throw new Error(`CLI returned status ${result.status}`); + } + + return JSON.parse(result.stdout); +} + +export async function checkVulnerabilities(dependencies, rootDir) { + const cliPath = findCliPath(rootDir); + + if (!cliPath) { + console.log(' ā­ļø Meterian: Not installed, skipping'); + console.log(' Install VSCode extension "Meterian Security" or run: npm i -g @meterian/cli'); + return { vulnerable: [], source: 'Meterian' }; + } + + console.log(' šŸ“” Checking with Meterian CLI...'); + + try { + const formattedInput = formatDependenciesForCli(dependencies); + const result = runCliCheck(cliPath, formattedInput); + const output = parseCliOutput(result); + + console.log(` āœ“ Meterian: Found ${output.vulnerable?.length || 0} vulnerable packages`); + + return { + vulnerable: output.vulnerable || [], + summary: output.summary, + source: 'Meterian' + }; + } catch (error) { + console.log(` ⚠ Meterian: ${error.message}`); + return { vulnerable: [], source: 'Meterian' }; + } +} diff --git a/scripts/security-providers/osv-provider.mjs b/scripts/security-providers/osv-provider.mjs new file mode 100644 index 0000000000..b4192e91fa --- /dev/null +++ b/scripts/security-providers/osv-provider.mjs @@ -0,0 +1,151 @@ +/*! + * @license + * Copyright Ā© 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const OSV_BATCH_API = 'https://api.osv.dev/v1/querybatch'; +const REQUEST_TIMEOUT_MS = 30000; +const BATCH_SIZE = 1000; + +const MALWARE_KEYWORDS = [ + 'malware', + 'malicious', + 'compromised', + 'supply chain', + 'backdoor', + 'cryptominer', + 'credential stealing', + 'data exfiltration', + 'typosquat' +]; + +function isMalwareRelated(text) { + const lowerText = text.toLowerCase(); + return MALWARE_KEYWORDS.some(keyword => lowerText.includes(keyword.toLowerCase())); +} + +function extractVersionsFromVulnerability(vulnerability) { + const affectedEntries = vulnerability.affected || []; + + const introducedVersions = affectedEntries + .flatMap(entry => entry.ranges || []) + .flatMap(range => range.events || []) + .filter(event => event.introduced && event.introduced !== '0') + .map(event => event.introduced); + + const explicitVersions = affectedEntries.flatMap(entry => entry.versions || []); + + return [...new Set([...introducedVersions, ...explicitVersions])]; +} + +function splitIntoBatches(items, batchSize) { + const batches = []; + for (let index = 0; index < items.length; index += batchSize) { + batches.push(items.slice(index, index + batchSize)); + } + return batches; +} + +function buildQueryPayload(dependencies) { + return { + queries: dependencies.map(dependency => ({ + package: { name: dependency.name, ecosystem: 'npm' }, + version: dependency.version + })) + }; +} + +async function queryBatch(dependencies) { + const response = await fetch(OSV_BATCH_API, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(buildQueryPayload(dependencies)), + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) + }); + + if (!response.ok) { + return []; + } + + const data = await response.json(); + return data.results || []; +} + +function extractMalwareInfo(vulnerability) { + const summary = [vulnerability.summary || '', vulnerability.details || ''].join(' '); + + if (!isMalwareRelated(summary)) { + return null; + } + + const packageName = vulnerability.affected?.[0]?.package?.name; + if (!packageName) { + return null; + } + + const versions = extractVersionsFromVulnerability(vulnerability); + if (versions.length === 0) { + return null; + } + + return { + packageName, + versions, + reason: vulnerability.summary || 'Malware detected by OSV' + }; +} + +function processBatchResults(batchResults, malwareRegistry) { + for (const result of batchResults) { + const vulnerabilities = result.vulns || []; + + for (const vulnerability of vulnerabilities) { + const malwareInfo = extractMalwareInfo(vulnerability); + + if (malwareInfo) { + if (!malwareRegistry[malwareInfo.packageName]) { + malwareRegistry[malwareInfo.packageName] = { versions: [], reason: '', source: 'OSV' }; + } + malwareRegistry[malwareInfo.packageName].versions.push(...malwareInfo.versions); + malwareRegistry[malwareInfo.packageName].reason = malwareInfo.reason; + } + } + } +} + +export async function fetchMalwareData(projectDependencies) { + console.log(' šŸ“” Fetching from OSV (Google)...'); + const malwareRegistry = {}; + + if (!projectDependencies || projectDependencies.length === 0) { + console.log(' ⚠ OSV: No dependencies to check'); + return malwareRegistry; + } + + try { + const dependencyBatches = splitIntoBatches(projectDependencies, BATCH_SIZE); + + for (const batch of dependencyBatches) { + const batchResults = await queryBatch(batch); + processBatchResults(batchResults, malwareRegistry); + } + + console.log(` āœ“ OSV: Found ${Object.keys(malwareRegistry).length} malware entries`); + } catch (error) { + console.log(` ⚠ OSV: Could not fetch (${error.message})`); + } + + return malwareRegistry; +}