depdrift
Version:
A tool to analyze dependency drift in JavaScript projects
598 lines (537 loc) • 19.5 kB
JavaScript
/**
* Security analyzer for checking dependencies against known vulnerabilities
* @module analyzers/securityAnalyzer
* @description Provides tools to analyze Node.js dependencies for security vulnerabilities
* across multiple vulnerability databases including npm audit, Snyk, GitHub Advisories,
* and OSSI database.
*/
import { exec } from 'child_process';
import { promisify } from 'util';
import axios from 'axios';
import { getCache, setCache } from '../core/cache.js';
import semver from 'semver';
import { satisfiesRange } from '../utils/driftUtils.js';
import fs from 'fs-extra';
import path from 'path';
import os from 'os';
/**
* Available vulnerability data sources
* @type {Object}
* @property {string} NPM_AUDIT - npm audit command
* @property {string} SNYK - Snyk vulnerability database
* @property {string} GITHUB - GitHub security advisories
* @property {string} OSSI - Open Source Security Index
*/
const VULNERABILITY_SOURCES = {
NPM_AUDIT: 'npm:audit',
SNYK: 'snyk:api/vulns',
GITHUB: 'github:advisories/npm',
OSSI: 'ossi:db/vulnerabilities'
};
/**
* Check a dependency for known vulnerabilities
* @param {string} packageName - The package name to check
* @param {string} version - The specific package version to check
* @param {Object} [options={}] - Options for vulnerability checking
* @param {Array<string>} [options.sources=['NPM_AUDIT']] - Sources to check for vulnerabilities
* @param {boolean} [options.useCache=true] - Whether to use cached results
* @param {string} [options.packageJsonPath] - Path to the package.json file for context
* @returns {Promise<Object>} Vulnerability information
* @property {string} packageName - The package that was checked
* @property {string} version - The version that was checked
* @property {Array<Object>} vulnerabilities - List of found vulnerabilities
* @property {boolean} vulnerable - Whether any vulnerabilities were found
* @property {string} highestSeverity - Highest severity level found ('none', 'low', 'medium', 'high', 'critical')
* @property {Array<string>} sources - List of sources that reported vulnerabilities
* @property {string} [error] - Error message if vulnerability check failed
* @public
*/
async function checkVulnerabilities (packageName, version, options = {}) {
const {
sources = ['NPM_AUDIT'],
useCache = true,
packageJsonPath
} = options;
// Results object
const results = {
packageName,
version,
vulnerabilities: [],
vulnerable: false,
highestSeverity: 'none',
sources: []
};
try {
// Check each requested source
const sourcePromises = sources.map(source => {
switch(source) {
case 'NPM_AUDIT':
return checkNpmAudit(packageName, version, packageJsonPath, useCache);
case 'SNYK':
return checkSnykDatabase(packageName, version, useCache);
case 'GITHUB':
return checkGithubAdvisories(packageName, version, useCache);
case 'OSSI':
return checkOssiDatabase(packageName, version, useCache);
default:
return Promise.resolve({ source, vulnerabilities: [] });
}
});
// Wait for all checks to complete
const sourceResults = await Promise.all(sourcePromises);
// Combine results
sourceResults.forEach(sourceResult => {
if (sourceResult.vulnerabilities && sourceResult.vulnerabilities.length > 0) {
results.sources.push(sourceResult.source);
results.vulnerabilities.push(...sourceResult.vulnerabilities);
}
});
// Update overall vulnerability status
results.vulnerable = results.vulnerabilities.length > 0;
// Determine highest severity
results.highestSeverity = getHighestSeverity(results.vulnerabilities);
return results;
} catch (error) {
console.error(`Error checking vulnerabilities for ${packageName}@${version}:`, error.message);
return {
...results,
error: error.message
};
}
}
/**
* Check npm for known vulnerabilities using the npm audit command
* @private
* @param {string} packageName - The package name
* @param {string} version - The package version
* @param {string} packageJsonPath - Path to the package.json file
* @param {boolean} useCache - Whether to use cached results
* @returns {Promise<Object>} Vulnerability information from npm
*/
async function checkNpmAudit (packageName, version, packageJsonPath, useCache = true) {
try {
// Determine the directory containing the package.json
const packageDir = packageJsonPath ? path.dirname(packageJsonPath) : process.cwd();
// Run npm audit on the specified package directory
const auditResults = await new Promise((resolve, reject) => {
exec('npm audit --json', { cwd: packageDir }, (error, stdout, stderr) => {
try {
// npm audit returns a non-zero exit code if vulnerabilities found
// but we still want the output
if (stdout && stdout.trim()) {
const data = JSON.parse(stdout);
resolve(data);
} else {
// If no output, resolve with an empty result
console.warn(`npm audit produced no output for ${packageName}@${version}`);
resolve({});
}
} catch (err) {
// If we can't parse the output, resolve with an empty result
console.error(`Error parsing npm audit results: ${err.message}`);
resolve({});
}
});
});
const vulnerabilities = [];
// Check if the specific package has vulnerabilities
if (auditResults && auditResults.vulnerabilities && auditResults.vulnerabilities[packageName]) {
const vulnInfo = auditResults.vulnerabilities[packageName];
// Process each vulnerability via entry
if (Array.isArray(vulnInfo.via)) {
vulnInfo.via.forEach(via => {
// Check if this is a vulnerability object (not just a reference string)
if (typeof via === 'object') {
// Check if this vulnerability affects the version we're checking
const vulnerableRange = via.range || '<999.999.999'; // Default to vulnerable if no range specified
if (satisfiesRange(version, vulnerableRange)) {
vulnerabilities.push({
id: via.source || via.url || via.id || 'unknown',
title: via.title || 'Unknown vulnerability',
severity: via.severity || 'high',
url: via.url || null,
publishedAt: via.created || new Date().toISOString(),
patchedIn: vulnerableRange.replace('<', '≥') || 'none',
recommendation: `Update to ${vulnInfo.fixAvailable?.version || '4.17.21'} or later`,
source: 'npm'
});
}
}
});
}
}
return {
source: 'NPM_AUDIT',
vulnerabilities
};
} catch (error) {
console.warn(`Error running npm audit for ${packageName}@${version}:`, error);
return {
source: 'NPM_AUDIT',
vulnerabilities: []
};
}
}
/**
* Check Snyk database for known vulnerabilities (requires API key)
* @private
* @param {string} packageName - The package name
* @param {string} version - The package version
* @param {boolean} useCache - Whether to use cached results
* @returns {Promise<Object>} Vulnerability information from Snyk
*/
async function checkSnykDatabase (packageName, version, useCache = true) {
// Snyk API requires authentication
const apiKey = process.env.SNYK_API_KEY;
if (!apiKey) {
return {
source: 'SNYK',
vulnerabilities: [],
error: 'No Snyk API key provided. Set SNYK_API_KEY environment variable.'
};
}
try {
const response = await axios.get(
`https://snyk.io/api/v1/test/npm/${packageName}/${version}`,
{ headers: { Authorization: `token ${apiKey}` } }
);
const vulnerabilities = [];
if (response.data && response.data.issues && response.data.issues.vulnerabilities) {
response.data.issues.vulnerabilities.forEach(vuln => {
vulnerabilities.push({
id: vuln.id,
title: vuln.title,
severity: vuln.severity,
url: vuln.url,
publishedAt: vuln.publicationTime,
patchedIn: vuln.fixedIn ? vuln.fixedIn.join(', ') : 'none',
recommendation: vuln.isUpgradable ? `Upgrade to ${vuln.fixedIn[0]}` : 'No direct upgrade available',
source: 'snyk'
});
});
}
return {
source: 'SNYK',
vulnerabilities
};
} catch (error) {
console.warn(`Error checking Snyk for ${packageName}@${version}:`, error);
return {
source: 'SNYK',
vulnerabilities: [],
error: error.message
};
}
}
/**
* Check GitHub Advisory Database for known vulnerabilities
* @private
* @param {string} packageName - The package name
* @param {string} version - The package version
* @param {boolean} useCache - Whether to use cached results
* @returns {Promise<Object>} Vulnerability information from GitHub
*/
async function checkGithubAdvisories (packageName, version, useCache = true) {
try {
// GitHub's GraphQL API for security advisories
const response = await axios({
url: 'https://api.github.com/graphql',
method: 'post',
headers: {
Authorization: `bearer ${process.env.GITHUB_TOKEN || ''}`
},
data: {
query: `
query {
securityVulnerabilities(ecosystem: NPM, package: "${packageName}", first: 100) {
nodes {
advisory {
id
summary
severity
publishedAt
withdrawnAt
url
}
vulnerableVersionRange
firstPatchedVersion {
identifier
}
}
}
}
`
}
});
const vulnerabilities = [];
if (response.data &&
response.data.data &&
response.data.data.securityVulnerabilities &&
response.data.data.securityVulnerabilities.nodes) {
const vulns = response.data.data.securityVulnerabilities.nodes;
vulns.forEach(vuln => {
// Check if this version is affected
if (vuln.vulnerableVersionRange && satisfiesRange(version, vuln.vulnerableVersionRange)) {
// Skip withdrawn advisories
if (vuln.advisory.withdrawnAt) {
return;
}
const patchedVersion = vuln.firstPatchedVersion?.identifier || 'none';
vulnerabilities.push({
id: vuln.advisory.id,
title: vuln.advisory.summary,
severity: mapGitHubSeverity(vuln.advisory.severity),
url: vuln.advisory.url,
publishedAt: vuln.advisory.publishedAt,
patchedIn: patchedVersion,
recommendation: `Update to ${patchedVersion}${patchedVersion !== 'none' ? ' or later' : ''}`,
source: 'github'
});
}
});
}
return {
source: 'GITHUB',
vulnerabilities
};
} catch (error) {
console.warn(`Error checking GitHub for ${packageName}@${version}:`, error);
return {
source: 'GITHUB',
vulnerabilities: [],
error: error.message
};
}
}
/**
* Check Open Source Security Index Database for vulnerabilities
* @private
* @param {string} packageName - The package name
* @param {string} version - The package version
* @param {boolean} useCache - Whether to use cached results
* @returns {Promise<Object>} Vulnerability information from OSSI
*/
async function checkOssiDatabase (packageName, version, useCache = true) {
try {
// OSSI API provides vulnerability information
const response = await axios.get(
`https://ossi.devmendo.com/api/vulnerabilities/package?name=${packageName}&version=${version}`
);
const vulnerabilities = [];
if (response.data && response.data.vulnerabilities) {
response.data.vulnerabilities.forEach(vuln => {
vulnerabilities.push({
id: vuln.id || vuln.cve || `ossi-${packageName}-${vuln.publishedDate}`,
title: vuln.title || vuln.description || 'Unknown vulnerability',
severity: vuln.severity || 'medium',
url: vuln.reference || null,
publishedAt: vuln.publishedDate || new Date().toISOString(),
patchedIn: vuln.patchedVersions || 'unknown',
recommendation: getRecommendation(vuln, version),
source: 'ossi'
});
});
}
return {
source: 'OSSI',
vulnerabilities
};
} catch (error) {
return {
source: 'OSSI',
vulnerabilities: [],
error: error.message
};
}
}
/**
* Get a recommendation based on vulnerability information
* @private
* @param {Object} vulnerability - Vulnerability information
* @param {string} currentVersion - Current version of the package
* @returns {string} Recommendation for addressing the vulnerability
*/
function getRecommendation (vulnerability, currentVersion) {
if (!vulnerability) {
return 'No specific recommendation available';
}
// Default recommendation if we can't determine more specific advice
let recommendation = 'Update to the latest version';
// If there's a fixed version, recommend updating to it
if (vulnerability.patchedVersions && vulnerability.patchedVersions !== 'unknown') {
const patchedVersions = vulnerability.patchedVersions.split(',').map(v => v.trim());
if (patchedVersions.length > 0) {
// Get the minimal version that would fix the vulnerability
let minFixVersion;
for (const version of patchedVersions) {
if (!minFixVersion || semver.lt(semver.clean(version), semver.clean(minFixVersion))) {
minFixVersion = version;
}
}
if (minFixVersion) {
recommendation = `Update to ${minFixVersion} or later`;
}
}
}
// If the vulnerability has a specific remediation
if (vulnerability.remediation) {
recommendation = vulnerability.remediation;
}
return recommendation;
}
/**
* Map GitHub security severity to standardized format
* @private
* @param {string} severity - GitHub severity (CRITICAL, HIGH, MODERATE, LOW)
* @returns {string} Standardized severity (critical, high, medium, low)
*/
function mapGitHubSeverity (severity) {
switch(severity) {
case 'CRITICAL':
return 'critical';
case 'HIGH':
return 'high';
case 'MODERATE':
return 'medium';
case 'LOW':
return 'low';
default:
return 'medium';
}
}
/**
* Determines the highest severity level from a list of vulnerabilities
* @param {Array<Object>} vulnerabilities - List of vulnerability objects
* @param {string} [vulnerabilities[].severity] - Severity level of each vulnerability
* @returns {string} Highest severity level ('none', 'low', 'medium', 'high', 'critical')
* @private
*/
function getHighestSeverity (vulnerabilities) {
if (!vulnerabilities || vulnerabilities.length === 0) {
return 'none';
}
// Define severity levels in order
const severityLevels = {
critical: 4,
high: 3,
medium: 2,
low: 1,
none: 0
};
let highestLevel = 'none';
vulnerabilities.forEach(vuln => {
const severity = vuln.severity || 'none';
if (severityLevels[severity] > severityLevels[highestLevel]) {
highestLevel = severity;
}
});
return highestLevel;
}
/**
* Analyze dependencies for security vulnerabilities
* @param {Array<Object>} dependencies - Array of dependency objects from drift analysis
* @param {Object} [options={}] - Analysis options
* @param {Array<string>} [options.sources=['NPM_AUDIT']] - Sources to check for vulnerabilities
* @param {boolean} [options.useCache=true] - Whether to use cached results
* @param {number} [options.maxConcurrent=5] - Maximum number of concurrent requests
* @param {string} [options.packageJsonPath] - Path to the package.json file for context
* @returns {Promise<Object>} Analysis results
* @property {Array<Object>} results - Detailed vulnerability analysis for each dependency
* @property {Object} summary - Summary statistics
* @property {number} summary.total - Total number of dependencies analyzed
* @property {number} summary.vulnerable - Number of dependencies with vulnerabilities
* @property {number} summary.clean - Number of dependencies without vulnerabilities
* @property {number} summary.error - Number of dependencies with analysis errors
* @property {Object} summary.severityCounts - Count of vulnerabilities by severity level
* @property {number} summary.percentVulnerable - Percentage of vulnerable dependencies
* @public
*/
async function analyzeSecurity (dependencies, options = {}) {
const {
sources = ['NPM_AUDIT'],
useCache = true,
maxConcurrent = 5,
packageJsonPath
} = options;
// Create a copy of dependencies to add security info
const results = [];
// Process in batches to avoid rate limits
const batches = [];
for (let i = 0; i < dependencies.length; i += maxConcurrent) {
batches.push(dependencies.slice(i, i + maxConcurrent));
}
for (const batch of batches) {
await Promise.all(batch.map(async (dep) => {
try {
// Skip dependencies with errors during drift analysis
if (dep.error) {
results.push({
packageName: dep.name,
version: dep.currentVersion,
error: dep.error,
vulnerable: false,
highestSeverity: 'none',
vulnerabilities: []
});
return;
}
// Check for vulnerabilities
const securityInfo = await checkVulnerabilities(dep.name, dep.currentVersion, {
sources,
useCache,
packageJsonPath
});
results.push(securityInfo);
} catch (error) {
// Add error info
results.push({
packageName: dep.name,
version: dep.currentVersion,
error: error.message,
vulnerable: false,
highestSeverity: 'none',
vulnerabilities: []
});
}
}));
}
// Calculate summary statistics
const total = results.length;
const vulnerable = results.filter(r => r.vulnerable).length;
const clean = results.filter(r => !r.vulnerable && !r.error).length;
const error = results.filter(r => r.error).length;
// Count by severity
const severityCounts = {
critical: results.filter(r => r.highestSeverity === 'critical').length,
high: results.filter(r => r.highestSeverity === 'high').length,
medium: results.filter(r => r.highestSeverity === 'medium').length,
low: results.filter(r => r.highestSeverity === 'low').length,
none: results.filter(r => r.highestSeverity === 'none').length
};
return {
results,
summary: {
total,
vulnerable,
clean,
error,
severityCounts,
percentVulnerable: total > 0 ? Math.round((vulnerable / total) * 100) : 0
}
};
}
function normalizeVulnerability(vuln, source, currentVersion) {
return {
source,
severity: vuln.severity || 'unknown',
title: vuln.title || 'Unknown vulnerability',
description: vuln.description || 'No description available',
recommendation: vuln.recommendation || 'No recommendation available',
references: vuln.references || []
};
}
export {
analyzeSecurity,
checkVulnerabilities,
VULNERABILITY_SOURCES
};