vulnzap-mcp
Version:
Multi-ecosystem vulnerability scanning service with MCP interface for LLMs
516 lines (443 loc) • 16.6 kB
JavaScript
/**
* National Vulnerability Database (NVD) API Client
*
* This module provides functionality to fetch and process vulnerability data from the NVD.
* It includes functions for querying the CVE API and mapping the results to the Vulnzap format.
*
* Reference: https://nvd.nist.gov/developers/vulnerabilities
*/
import fetch from 'node-fetch';
import semver from 'semver';
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 = {
apiKey: process.env.NVD_API_KEY || '',
cacheDir: path.join(__dirname, 'cache'),
cacheFile: 'nvd-cache.json',
requestDelay: 5000, // 5 seconds between requests to respect rate limits
ecosystemMappings: {
'npm': ['node', 'nodejs', 'npm', 'javascript', 'js'],
'pip': ['python', 'pip', 'pypi']
},
baseUrl: 'https://services.nvd.nist.gov/rest/json/cves/2.0'
};
// Cache for NVD data
let nvdCache = new Map();
let lastFetchTime = 0;
/**
* Initialize the NVD client
* @param {Object} customConfig - Custom configuration options
*/
export async function initNvdClient(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) {
nvdCache = new Map(cacheData.entries);
lastFetchTime = cacheData.lastUpdate || 0;
console.log(`Loaded ${nvdCache.size} NVD cache entries`);
}
} catch (error) {
console.error(`Error loading NVD cache: ${error.message}`);
}
}
return { config };
}
/**
* Save the NVD 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(nvdCache.entries())
};
fs.writeFileSync(cacheFilePath, JSON.stringify(cacheData, null, 2));
console.log(`Saved ${nvdCache.size} NVD cache entries`);
} catch (error) {
console.error(`Error saving NVD cache: ${error.message}`);
}
}
/**
* Fetch vulnerability data from NVD 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.apiKey) {
console.warn('NVD API key not provided. Some features may be limited.');
}
// Check cache first
const cacheKey = `${ecosystem}:${packageName}`;
if (nvdCache.has(cacheKey)) {
const cachedData = nvdCache.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)));
}
// Get ecosystem keywords for searching
const ecosystemKeywords = config.ecosystemMappings[ecosystem] || [ecosystem];
try {
// Construct the NVD API URL with CPE filtering based on ecosystem and package name
let url = `${config.baseUrl}?keywordSearch=${encodeURIComponent(packageName)}`;
// Add additional parameters for better matching
const additionalParams = ecosystemKeywords.map(keyword =>
`cpeMatchString=cpe:2.3:a:*:${keyword}:*:*:*:*:*:*:*:*`
).join('&');
if (additionalParams) {
url += `&${additionalParams}`;
}
// Set headers including API key if available
const headers = {
'Content-Type': 'application/json',
};
if (config.apiKey) {
headers['apiKey'] = config.apiKey;
}
// Make the request
const response = await fetch(url, { headers });
lastFetchTime = Date.now();
if (!response.ok) {
throw new Error(`NVD API error: ${response.status} ${response.statusText}`);
}
const data = await response.json();
const vulnerabilities = processNvdResponse(data, ecosystem, packageName);
// Cache the result
nvdCache.set(cacheKey, {
timestamp: Date.now(),
vulnerabilities
});
// Save cache to disk
saveCache(config);
return vulnerabilities;
} catch (error) {
console.error(`Error fetching NVD data for ${packageName} (${ecosystem}): ${error.message}`);
return [];
}
}
/**
* Process the NVD API response and extract vulnerability information
*
* @param {Object} response - The NVD 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 processNvdResponse(response, ecosystem, packageName) {
if (!response.vulnerabilities || !Array.isArray(response.vulnerabilities)) {
return [];
}
const result = [];
for (const item of response.vulnerabilities) {
const cve = item.cve;
if (!cve) continue;
// Extract basic information
const id = cve.id;
const description = cve.descriptions?.find(d => d.lang === 'en')?.value || 'No description available';
const published = cve.published;
const lastModified = cve.lastModified;
// Extract severity information
let severity = 'unknown';
let cvssScore = 0;
if (cve.metrics?.cvssMetricV31) {
const metric = cve.metrics.cvssMetricV31[0];
cvssScore = metric?.cvssData?.baseScore || 0;
severity = getSeverityFromScore(cvssScore);
} else if (cve.metrics?.cvssMetricV30) {
const metric = cve.metrics.cvssMetricV30[0];
cvssScore = metric?.cvssData?.baseScore || 0;
severity = getSeverityFromScore(cvssScore);
} else if (cve.metrics?.cvssMetricV2) {
const metric = cve.metrics.cvssMetricV2[0];
cvssScore = metric?.cvssData?.baseScore || 0;
severity = getSeverityFromScore(cvssScore);
}
// Extract version information
let vulnerableVersions = '*';
let patchedVersions = '';
if (cve.configurations) {
const versionInfo = extractVersionInfoFromNvd(cve.configurations, ecosystem, packageName);
vulnerableVersions = versionInfo.vulnerableVersions || '*';
patchedVersions = versionInfo.patchedVersions || '';
}
result.push({
id: id,
ecosystem: ecosystem,
package: packageName,
vulnerable_versions: vulnerableVersions,
patched_versions: patchedVersions,
title: `${id}: Vulnerability in ${packageName}`,
description: description,
severity: severity,
cvss_score: cvssScore,
cve_id: id,
published_at: published,
last_modified: lastModified,
source: 'nvd'
});
}
return result;
}
/**
* Map CVSS score to severity level
*
* @param {number} score - CVSS base score
* @returns {string} - Severity level (critical, high, medium, low)
*/
function getSeverityFromScore(score) {
if (score >= 9.0) return 'critical';
if (score >= 7.0) return 'high';
if (score >= 4.0) return 'medium';
return 'low';
}
/**
* Extract version information from NVD configurations
*
* @param {Array} configurations - NVD configuration nodes
* @param {string} ecosystem - The package ecosystem (npm, pip)
* @param {string} packageName - The name of the package
* @returns {Object} - Object with vulnerableVersions and patchedVersions
*/
function extractVersionInfoFromNvd(configurations, ecosystem, packageName) {
const vulnerableVersionRanges = [];
const patchedVersionRanges = [];
// Process all configurations
for (const config of configurations) {
if (!config.nodes) continue;
for (const node of config.nodes) {
if (!node.cpeMatch) continue;
for (const cpeMatch of node.cpeMatch) {
const cpe = cpeMatch.criteria || '';
// Skip if not relevant to the current package/ecosystem
if (!isRelevantCpe(cpe, ecosystem, packageName)) {
continue;
}
// Extract version info from CPE
const versionInfo = extractVersionFromCpe(cpe);
if (!versionInfo) continue;
if (cpeMatch.vulnerable === true) {
// Format version range based on ecosystem
if (ecosystem === 'npm') {
if (versionInfo.version && versionInfo.version !== '*') {
const operator = versionInfo.operator || '=';
vulnerableVersionRanges.push(`${operator}${versionInfo.version}`);
}
} else {
// For pip or other ecosystems
if (versionInfo.version && versionInfo.version !== '*') {
const operator = versionInfo.operator || '==';
vulnerableVersionRanges.push(`${operator}${versionInfo.version}`);
}
}
} else {
// This is a non-vulnerable version (patched)
if (versionInfo.version && versionInfo.version !== '*') {
if (ecosystem === 'npm') {
const operator = versionInfo.operator === '<' ? '>=' : '>';
patchedVersionRanges.push(`${operator}${versionInfo.version}`);
} else {
const operator = versionInfo.operator === '<' ? '>=' : '>';
patchedVersionRanges.push(`${operator}${versionInfo.version}`);
}
}
}
}
}
}
// Convert ranges to a format suitable for the ecosystem
return {
vulnerableVersions: vulnerableVersionRanges.length > 0
? vulnerableVersionRanges.join(' || ')
: '*',
patchedVersions: patchedVersionRanges.length > 0
? patchedVersionRanges.join(' || ')
: ''
};
}
/**
* Check if a CPE is relevant to a specific package and ecosystem
*
* @param {string} cpe - The CPE string
* @param {string} ecosystem - The package ecosystem (npm, pip)
* @param {string} packageName - The name of the package
* @returns {boolean} - True if relevant
*/
function isRelevantCpe(cpe, ecosystem, packageName) {
if (!cpe) return false;
// Split CPE into components
const parts = cpe.split(':');
if (parts.length < 5) return false;
// Check if application component
if (parts[2] !== 'a') return false;
// Check package name (can be in vendor or product field)
const vendor = parts[3];
const product = parts[4];
return product === packageName || vendor === packageName;
}
/**
* Extract version information from a CPE string
*
* @param {string} cpe - The CPE string
* @returns {Object|null} - Version information or null if not found
*/
function extractVersionFromCpe(cpe) {
if (!cpe) return null;
// Split CPE into components
const parts = cpe.split(':');
if (parts.length < 6) return null;
// Version is the 5th component (index 5)
const version = parts[5];
if (version === '*') return { version: '*' };
// Check for version ranges in CPE (uncommon but possible)
if (version.startsWith('<=') || version.startsWith('>=') ||
version.startsWith('<') || version.startsWith('>') ||
version.startsWith('==')) {
const operator = version.match(/^[<>=]+/)[0];
const versionNumber = version.substring(operator.length);
return { operator, version: versionNumber };
}
return { version };
}
/**
* Search for vulnerabilities across all sources
*
* @param {string} ecosystem - The package ecosystem (npm, pip)
* @param {string} packageName - The name of the package
* @param {Object} config - Configuration options
* @returns {Promise<Array>} - Combined vulnerabilities from all sources
*/
export async function searchAllSources(ecosystem, packageName, config) {
// Fetch from NVD
const nvdVulnerabilities = await fetchPackageVulnerabilities(ecosystem, packageName, config);
return nvdVulnerabilities;
}
/**
* Check if a version is vulnerable according to NVD data
*
* @param {string} ecosystem - The package ecosystem (npm, pip)
* @param {string} packageName - The name of the package
* @param {string} packageVersion - The package version
* @param {Object} config - Configuration options
* @returns {Promise<Object>} - Vulnerability check result
*/
export async function checkNvdVulnerability(ecosystem, packageName, packageVersion, config) {
// Fetch vulnerabilities
const vulnerabilities = await fetchPackageVulnerabilities(ecosystem, packageName, config);
if (vulnerabilities.length === 0) {
return {
isVulnerable: false,
source: 'nvd',
message: `No vulnerabilities found in NVD for ${packageName}@${packageVersion} (${ecosystem})`
};
}
// Check each vulnerability
const matchingVulnerabilities = vulnerabilities.filter(vuln => {
const versionRange = vuln.vulnerable_versions;
if (!versionRange || versionRange === '*') {
// If no version is specified, consider it vulnerable
return true;
}
if (ecosystem === 'npm') {
try {
// For npm, use semver
return semver.satisfies(packageVersion, versionRange);
} catch (error) {
console.warn(`Invalid semver range for ${packageName}: ${versionRange}`);
return false;
}
} else if (ecosystem === 'pip') {
// For pip, use custom version comparison
// This is simplified and should be replaced with a proper pip version comparison
try {
return isPipVersionVulnerable(packageVersion, versionRange);
} catch (error) {
console.warn(`Error comparing pip versions for ${packageName}: ${error.message}`);
return false;
}
}
// Default case
return false;
});
if (matchingVulnerabilities.length > 0) {
return {
isVulnerable: true,
source: 'nvd',
vulnerabilities: matchingVulnerabilities,
message: `${packageName}@${packageVersion} (${ecosystem}) has ${matchingVulnerabilities.length} known vulnerabilities in NVD`
};
} else {
return {
isVulnerable: false,
source: 'nvd',
message: `No matching vulnerabilities found in NVD for ${packageName}@${packageVersion} (${ecosystem})`
};
}
}
/**
* Simple pip version comparison for NVD data
* This is a duplicate of the function in index.js to avoid circular dependencies
*/
function isPipVersionVulnerable(version, range) {
if (!version || !range) return false;
// Split the range into parts (handles multiple constraints)
const rangeParts = range.split(',');
for (const part of rangeParts) {
const operator = part.substring(0, 2);
const rangeVersion = part.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;
}