packmate
Version:
Your smart and friendly interactive assistant for dependency updates, security advisories, and cleanup.
276 lines (248 loc) • 8.64 kB
JavaScript
/**
* Security Vulnerability Checker Module
* 패키지 보안 취약성 검사 및 분류
*/
import { execSync } from 'child_process';
import fs from 'fs';
import path from 'path';
import process from 'process';
// 취약성 심각도 분류
export const SEVERITY_LEVELS = {
CRITICAL: 'critical',
HIGH: 'high',
MODERATE: 'moderate',
LOW: 'low',
INFO: 'info'
};
// 취약성 상세 정보 구조
export class Vulnerability {
constructor({
packageName,
severity,
title,
description,
vulnerableVersions,
patchedVersions,
cve,
advisoryUrl,
source = 'npm',
direct = false,
fixAvailable = null
}) {
this.packageName = packageName;
this.severity = severity;
this.title = title;
this.description = description;
this.vulnerableVersions = vulnerableVersions;
this.patchedVersions = patchedVersions;
this.cve = cve;
this.advisoryUrl = advisoryUrl;
this.source = source;
this.direct = direct;
this.fixAvailable = fixAvailable;
this.detectedAt = new Date().toISOString();
}
}
function getDeclaredPackageNames() {
try {
const pkgPath = path.resolve(process.cwd(), 'package.json');
const pkgJson = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
return new Set([
...Object.keys(pkgJson.dependencies || {}),
...Object.keys(pkgJson.devDependencies || {}),
...Object.keys(pkgJson.optionalDependencies || {}),
...Object.keys(pkgJson.peerDependencies || {}),
]);
} catch {
return new Set();
}
}
/**
* npm audit API를 통한 취약성 검사
*/
export async function checkNpmAudit() {
try {
// npm audit --json 실행하여 JSON 결과 획득
const auditResult = execSync('npm audit --json', {
encoding: 'utf-8',
stdio: 'pipe',
shell: true,
cwd: process.cwd()
});
const auditData = JSON.parse(auditResult);
return parseNpmAuditResult(auditData);
} catch (error) {
// npm audit는 취약성이 발견되면 non-zero exit code 반환
// 이는 정상적인 동작이므로 JSON 파싱을 시도
try {
const auditData = JSON.parse(error.stdout || error.message);
return parseNpmAuditResult(auditData);
} catch (parseError) {
console.warn(`[Security] npm audit failed: ${parseError.message}`);
return [];
}
}
}
/**
* npm audit 결과를 표준화된 Vulnerability 객체로 변환
*/
function parseNpmAuditResult(auditData) {
const vulnerabilities = [];
const declaredPackages = getDeclaredPackageNames();
// npm audit v7+ 형식 (advisories)
if (auditData.advisories) {
for (const [advisoryId, advisory] of Object.entries(auditData.advisories)) {
const vulnerability = new Vulnerability({
packageName: advisory.module_name,
severity: mapNpmSeverity(advisory.severity),
title: advisory.title,
description: advisory.overview,
vulnerableVersions: advisory.vulnerable_versions,
patchedVersions: advisory.patched_versions,
cve: advisory.cves?.[0] || null,
advisoryUrl: advisory.url,
source: 'npm-audit',
direct: declaredPackages.has(advisory.module_name)
});
vulnerabilities.push(vulnerability);
}
}
// npm audit v8+ 형식 (vulnerabilities)
if (auditData.vulnerabilities) {
for (const [packageName, vulnInfo] of Object.entries(auditData.vulnerabilities)) {
if (vulnInfo.via && Array.isArray(vulnInfo.via)) {
vulnInfo.via.forEach(via => {
if (typeof via === 'object') {
const vulnerability = new Vulnerability({
packageName: packageName,
severity: mapNpmSeverity(via.severity),
title: via.title,
description: via.description || 'No description available',
vulnerableVersions: via.range || 'Unknown',
patchedVersions: 'See advisory',
cve: via.cve?.join(', ') || null,
advisoryUrl: via.url,
source: 'npm-audit',
direct: declaredPackages.has(packageName),
fixAvailable: vulnInfo.fixAvailable || null
});
vulnerabilities.push(vulnerability);
}
});
}
}
}
return vulnerabilities;
}
/**
* npm audit severity를 표준 severity로 매핑
*/
function mapNpmSeverity(npmSeverity) {
const mapping = {
'critical': SEVERITY_LEVELS.CRITICAL,
'high': SEVERITY_LEVELS.HIGH,
'moderate': SEVERITY_LEVELS.MODERATE,
'low': SEVERITY_LEVELS.LOW,
'info': SEVERITY_LEVELS.INFO
};
return mapping[npmSeverity] || SEVERITY_LEVELS.LOW;
}
/**
* 패키지별 취약성 그룹화
*/
export function groupVulnerabilitiesByPackage(vulnerabilities) {
const grouped = {};
vulnerabilities.forEach(vuln => {
if (!grouped[vuln.packageName]) {
grouped[vuln.packageName] = [];
}
grouped[vuln.packageName].push(vuln);
});
return grouped;
}
/**
* 심각도별 취약성 분류
*/
export function classifyBySeverity(vulnerabilities) {
const classified = {
[SEVERITY_LEVELS.CRITICAL]: [],
[SEVERITY_LEVELS.HIGH]: [],
[SEVERITY_LEVELS.MODERATE]: [],
[SEVERITY_LEVELS.LOW]: [],
[SEVERITY_LEVELS.INFO]: []
};
vulnerabilities.forEach(vuln => {
if (classified[vuln.severity]) {
classified[vuln.severity].push(vuln);
} else {
classified[SEVERITY_LEVELS.LOW].push(vuln);
}
});
return classified;
}
/**
* 메인 보안 검사 함수
*/
export async function checkVulnerabilities() {
try {
// 현재는 npm audit만 지원, 향후 확장 예정
const npmVulns = await checkNpmAudit();
const classified = classifyBySeverity(npmVulns);
const grouped = groupVulnerabilitiesByPackage(npmVulns);
const direct = npmVulns.filter((vuln) => vuln.direct);
const transitive = npmVulns.filter((vuln) => !vuln.direct);
return {
vulnerabilities: npmVulns,
classified: classified,
grouped: grouped,
summary: {
total: npmVulns.length,
direct: direct.length,
transitive: transitive.length,
critical: classified[SEVERITY_LEVELS.CRITICAL].length,
high: classified[SEVERITY_LEVELS.HIGH].length,
moderate: classified[SEVERITY_LEVELS.MODERATE].length,
low: classified[SEVERITY_LEVELS.LOW].length,
info: classified[SEVERITY_LEVELS.INFO].length
}
};
} catch (error) {
console.error('❌ Security scan failed:', error.message);
return {
vulnerabilities: [],
classified: {},
grouped: {},
summary: { total: 0, direct: 0, transitive: 0, critical: 0, high: 0, moderate: 0, low: 0, info: 0 }
};
}
}
/**
* 보안 검사 결과를 사용자 친화적 형태로 포맷
*/
export function formatSecuritySummary(securityResults) {
const { summary } = securityResults;
if (summary.total === 0) {
return '✅ No vulnerabilities found!';
}
let output = `Security: ${summary.total} advisories`;
if (summary.direct !== undefined && summary.transitive !== undefined) {
output += ` (${summary.direct} direct, ${summary.transitive} transitive)`;
}
output += '\n';
if (summary.critical > 0) {
output += `Critical: ${summary.critical}\n`;
}
if (summary.high > 0) {
output += `High: ${summary.high}\n`;
}
if (summary.moderate > 0) {
output += `Moderate: ${summary.moderate}\n`;
}
if (summary.low > 0) {
output += `Low: ${summary.low}\n`;
}
if (summary.info > 0) {
output += `Info: ${summary.info}\n`;
}
return output.trim();
}