UNPKG

vulnzap-mcp

Version:

Multi-ecosystem vulnerability scanning service with MCP interface for LLMs

488 lines (415 loc) 15.7 kB
/** * GitHub Advisory Database API Client * * This module provides functionality to fetch and process vulnerability data from GitHub. * It includes functions for querying the GitHub Security Advisory API and mapping the results to the Vulnzap format. * * Reference: https://docs.github.com/en/rest/security-advisories/global-advisories */ import fetch from 'node-fetch'; import fs from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); // Configuration const DEFAULT_CONFIG = { githubToken: process.env.GITHUB_TOKEN || '', cacheDir: path.join(__dirname, 'cache'), cacheFile: 'github-advisories-cache.json', requestDelay: 1000, // 1 second between requests to respect rate limits baseUrl: 'https://api.github.com/advisories', perPage: 100, // Maximum number of results per page ecosystemMapping: { 'npm': 'npm', 'pip': 'pip', 'pypi': 'pip', 'gem': 'rubygems', 'cargo': 'rust', 'composer': 'composer', 'go': 'go', 'maven': 'maven', 'nuget': 'nuget', 'debian': 'debian', 'ubuntu': 'ubuntu', 'alpine': 'alpine', 'centos': 'centos', 'rhel': 'rhel' } }; // Cache for GitHub Advisory data let githubCache = new Map(); let lastFetchTime = 0; /** * Initialize the GitHub client * @param {Object} customConfig - Custom configuration options */ export async function initGithubClient(customConfig = {}) { const config = { ...DEFAULT_CONFIG, ...customConfig }; // Create cache directory if it doesn't exist if (!fs.existsSync(config.cacheDir)) { fs.mkdirSync(config.cacheDir, { recursive: true }); } // Load cache from disk if it exists const cacheFilePath = path.join(config.cacheDir, config.cacheFile); if (fs.existsSync(cacheFilePath)) { try { const cacheData = JSON.parse(fs.readFileSync(cacheFilePath, 'utf8')); if (cacheData && cacheData.entries) { githubCache = new Map(cacheData.entries); lastFetchTime = cacheData.lastUpdate || 0; console.log(`Loaded ${githubCache.size} GitHub Advisory cache entries`); } } catch (error) { console.error(`Error loading GitHub Advisory cache: ${error.message}`); } } return { config }; } /** * Save the GitHub cache to disk * @param {Object} config - Configuration options */ function saveCache(config) { const cacheFilePath = path.join(config.cacheDir, config.cacheFile); try { const cacheData = { lastUpdate: Date.now(), entries: Array.from(githubCache.entries()) }; fs.writeFileSync(cacheFilePath, JSON.stringify(cacheData, null, 2)); console.log(`Saved ${githubCache.size} GitHub Advisory cache entries`); } catch (error) { console.error(`Error saving GitHub Advisory cache: ${error.message}`); } } /** * Fetch vulnerability data from GitHub for a specific package * * @param {string} ecosystem - The package ecosystem (npm, pip) * @param {string} packageName - The name of the package * @param {Object} config - Configuration options * @returns {Promise<Array>} - Array of vulnerability advisories */ export async function fetchPackageVulnerabilities(ecosystem, packageName, config) { if (!config.githubToken) { console.warn('GitHub token not provided. Using public API with rate limits.'); } // Check cache first const cacheKey = `${ecosystem}:${packageName}`; if (githubCache.has(cacheKey)) { const cachedData = githubCache.get(cacheKey); // Return cached data if it exists and is less than 24 hours old if (cachedData && cachedData.timestamp > Date.now() - 86400000) { return cachedData.vulnerabilities; } } // Respect rate limits const now = Date.now(); if (now - lastFetchTime < config.requestDelay) { await new Promise(resolve => setTimeout(resolve, config.requestDelay - (now - lastFetchTime))); } try { // Map ecosystem to GitHub's ecosystem identifier const githubEcosystem = config.ecosystemMapping[ecosystem] || ecosystem; // Construct the GitHub API URL with ecosystem and package name filtering let url = `${config.baseUrl}?ecosystem=${githubEcosystem}&package=${packageName}&per_page=${config.perPage}`; // Set headers including GitHub token const headers = { 'Accept': 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28' }; if (config.githubToken) { headers['Authorization'] = `Bearer ${config.githubToken}`; } // Make the request const response = await fetch(url, { headers }); lastFetchTime = Date.now(); if (!response.ok) { throw new Error(`GitHub API error: ${response.status} ${response.statusText}`); } const data = await response.json(); const vulnerabilities = processGithubResponse(data, ecosystem, packageName); // Cache the result githubCache.set(cacheKey, { timestamp: Date.now(), vulnerabilities }); // Save cache to disk saveCache(config); return vulnerabilities; } catch (error) { console.error(`Error fetching GitHub Advisory data for ${packageName} (${ecosystem}): ${error.message}`); return []; } } /** * Process the GitHub API response and extract vulnerability information * * @param {Object} response - The GitHub API response * @param {string} ecosystem - The package ecosystem (npm, pip) * @param {string} packageName - The name of the package * @returns {Array} - Array of vulnerability advisories in Vulnzap format */ function processGithubResponse(response, ecosystem, packageName) { if (!Array.isArray(response)) { return []; } const result = []; for (const advisory of response) { if (!advisory || advisory.withdrawn_at) continue; // Skip withdrawn advisories // Find the relevant vulnerable package details const vulnerablePackage = advisory.vulnerabilities?.find(v => v.package?.ecosystem?.toLowerCase() === ecosystem.toLowerCase() && v.package?.name?.toLowerCase() === packageName.toLowerCase() ); if (!vulnerablePackage) continue; // Extract version information const vulnerableVersions = vulnerablePackage.vulnerable_version_range || '*'; const patched = vulnerablePackage.patched_versions || ''; // Map severity const severity = mapGithubSeverity(advisory.severity); result.push({ id: advisory.ghsa_id || `GHSA-${advisory.id}`, ecosystem: ecosystem, package: packageName, vulnerable_versions: vulnerableVersions, patched_versions: patched, title: advisory.summary || `Vulnerability in ${packageName}`, description: advisory.description || 'No description available', severity: severity, cvss_score: advisory.cvss?.score || 0, cve_id: advisory.cve_id || advisory.aliases?.find(a => a.startsWith('CVE-')) || 'No CVE', published_at: advisory.published_at, last_modified: advisory.updated_at, source: 'github' }); } return result; } /** * Map GitHub severity to Vulnzap severity * * @param {string} githubSeverity - GitHub severity level * @returns {string} - Vulnzap severity level (critical, high, medium, low) */ function mapGithubSeverity(githubSeverity) { if (!githubSeverity) return 'unknown'; const severity = githubSeverity.toLowerCase(); switch (severity) { case 'critical': return 'critical'; case 'high': return 'high'; case 'moderate': case 'medium': return 'medium'; case 'low': return 'low'; default: return 'unknown'; } } /** * Fetch all GitHub advisories and write to a local file * * @param {Object} config - Configuration options * @param {string} outputPath - Path to write the advisories file * @returns {Promise<number>} - Number of advisories fetched */ export async function fetchAllAdvisories(config, outputPath) { if (!config.githubToken) { console.warn('GitHub token not provided. Using public API with rate limits.'); } const headers = { 'Accept': 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28' }; if (config.githubToken) { headers['Authorization'] = `Bearer ${config.githubToken}`; } let page = 1; let hasMorePages = true; const allAdvisories = []; console.log('Fetching GitHub advisories...'); while (hasMorePages) { // Respect rate limits if (lastFetchTime > 0) { const now = Date.now(); if (now - lastFetchTime < config.requestDelay) { await new Promise(resolve => setTimeout(resolve, config.requestDelay - (now - lastFetchTime))); } } const url = `${config.baseUrl}?per_page=${config.perPage}&page=${page}`; try { console.log(`Fetching page ${page}...`); const response = await fetch(url, { headers }); lastFetchTime = Date.now(); if (!response.ok) { throw new Error(`GitHub API error: ${response.status} ${response.statusText}`); } const data = await response.json(); if (!Array.isArray(data) || data.length === 0) { hasMorePages = false; } else { allAdvisories.push(...data); page++; // Process advisories in batches if (page % 10 === 0) { console.log(`Fetched ${allAdvisories.length} advisories so far...`); } } } catch (error) { console.error(`Error fetching GitHub advisories: ${error.message}`); hasMorePages = false; } } console.log(`Fetched a total of ${allAdvisories.length} advisories`); // Process and format the advisories const processedAdvisories = []; for (const advisory of allAdvisories) { if (!advisory || advisory.withdrawn_at) continue; // Skip withdrawn advisories // Process each vulnerable package for (const vulnerability of advisory.vulnerabilities || []) { if (!vulnerability.package) continue; const ecosystem = vulnerability.package.ecosystem; const packageName = vulnerability.package.name; // Skip if missing required fields if (!ecosystem || !packageName) continue; processedAdvisories.push({ id: advisory.ghsa_id || `GHSA-${advisory.id}`, ecosystem: ecosystem, package: packageName, vulnerable_versions: vulnerability.vulnerable_version_range || '*', patched_versions: vulnerability.patched_versions || '', title: advisory.summary || `Vulnerability in ${packageName}`, description: advisory.description || 'No description available', severity: mapGithubSeverity(advisory.severity), cvss_score: advisory.cvss?.score || 0, cve_id: advisory.cve_id || advisory.aliases?.find(a => a.startsWith('CVE-')) || 'No CVE', published_at: advisory.published_at }); } } // Write the results to file try { const outputData = { advisories: processedAdvisories, last_updated: new Date().toISOString() }; fs.writeFileSync(outputPath, JSON.stringify(outputData, null, 2)); console.log(`Wrote ${processedAdvisories.length} advisories to ${outputPath}`); return processedAdvisories.length; } catch (error) { console.error(`Error writing advisories to file: ${error.message}`); return 0; } } /** * Check if a package is vulnerable according to GitHub advisories * * @param {string} ecosystem - The package ecosystem (npm, pip) * @param {string} packageName - The name of the package * @param {string} packageVersion - The version of the package * @param {Object} config - Configuration options * @returns {Promise<Object>} - Vulnerability check result */ export async function checkGithubVulnerability(ecosystem, packageName, packageVersion, config) { try { const vulnerabilities = await fetchPackageVulnerabilities(ecosystem, packageName, config); if (!vulnerabilities || vulnerabilities.length === 0) { return { isVulnerable: false, source: 'github', message: `Package ${packageName}@${packageVersion} (${ecosystem}) not found in GitHub Advisory Database` }; } // Check if the version matches any vulnerable version range const matchingVulnerabilities = vulnerabilities.filter(vuln => { // For npm, we can use semver if (ecosystem === 'npm') { const semverResult = require('semver').satisfies(packageVersion, vuln.vulnerable_versions); return semverResult; } // For pip, we use a custom implementation else if (ecosystem === 'pip') { return isPipVersionVulnerable(packageVersion, vuln.vulnerable_versions); } return false; }); if (matchingVulnerabilities.length > 0) { return { isVulnerable: true, vulnerabilities: matchingVulnerabilities, source: 'github', message: `${packageName}@${packageVersion} (${ecosystem}) has ${matchingVulnerabilities.length} known vulnerabilities in GitHub Advisory Database` }; } return { isVulnerable: false, source: 'github', message: `${packageName}@${packageVersion} (${ecosystem}) has no known vulnerabilities in GitHub Advisory Database` }; } catch (error) { console.error(`Error checking GitHub vulnerability: ${error.message}`); return { isVulnerable: false, error: error.message, source: 'github' }; } } /** * Check if a pip package version is vulnerable based on version ranges * * @param {string} version - The version to check * @param {string} range - The pip version range string * @returns {boolean} - True if vulnerable, false if not */ function isPipVersionVulnerable(version, range) { // For pip, we'll implement a simplified version comparison // This handles basic ranges like "<=2.25.0", ">=2.25.1", "<1.1.3,>=1.0" if (!version || !range) return false; // Split the range into parts (handles multiple constraints) const rangeParts = range.split(','); for (const part of rangeParts) { const trimmed = part.trim(); if (!trimmed) continue; // Extract operator and version const operator = trimmed.substring(0, 2); const rangeVersion = trimmed.substring(2); // Split versions into components const versionParts = version.split('.').map(Number); const rangeParts = rangeVersion.split('.').map(Number); // Pad arrays to equal length while (versionParts.length < rangeParts.length) versionParts.push(0); while (rangeParts.length < versionParts.length) rangeParts.push(0); // Compare version components let comparison = 0; for (let i = 0; i < versionParts.length; i++) { if (versionParts[i] > rangeParts[i]) { comparison = 1; break; } else if (versionParts[i] < rangeParts[i]) { comparison = -1; break; } } // Check if version satisfies this part of the range if (operator === '<=') { if (comparison > 0) return false; } else if (operator === '>=') { if (comparison < 0) return false; } else if (operator === '==') { if (comparison !== 0) return false; } else if (operator === '!=') { if (comparison === 0) return false; } else if (operator.startsWith('<') && !operator.includes('=')) { if (comparison >= 0) return false; } else if (operator.startsWith('>') && !operator.includes('=')) { if (comparison <= 0) return false; } } // If all range constraints are satisfied, the version is vulnerable return true; }