diff --git a/README.md b/README.md index 18e9a96..f282bfc 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,7 @@ cve-lite /path/to/project --osv-url https://security.company.internal/osv CVE Lite CLI produces a clean, summary-first console view by default, designed for fast triage before release. -For deeper investigation, running with `--verbose` provides full details, including dependency paths, a complete fix plan, and a detailed table view. +For deeper investigation, running with `--verbose` provides full details, including dependency paths, parent upgrade guidance for transitive issues when available, a complete fix plan, and a detailed table view. That is the core idea: install it, point it at your project, and immediately get a practical fix plan instead of a wall of raw advisories. @@ -244,6 +244,7 @@ Instead of only showing advisory IDs, the CLI reports: - fixed-version hint when available - advisory IDs - dependency path hints +- recommended parent upgrade for transitive issues when available By default, the CLI now presents a cleaner summary-first view, with `--verbose` available for the full detailed output. @@ -262,7 +263,7 @@ CVE Lite CLI organizes likely remediation work into a practical sequence, such a ### 5. Parent package hints for transitive issues -For transitive vulnerabilities, the tool can point to the likely parent dependency to review. That makes the output more actionable than simply saying a nested package is vulnerable. +For transitive vulnerabilities, CVE Lite CLI can show the dependency path and, when it can determine one reliably, recommend the parent package upgrade to make. That makes the output more actionable than simply saying a nested package is vulnerable. ### 6. JSON output diff --git a/package-lock.json b/package-lock.json index 56f8b34..e38202b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "cve-lite-cli", - "version": "1.0.5", + "version": "1.0.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cve-lite-cli", - "version": "1.0.5", + "version": "1.0.6", "license": "MIT", "dependencies": { "yaml": "^2.7.1", diff --git a/package.json b/package.json index 7d186d8..5040107 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cve-lite-cli", - "version": "1.0.5", + "version": "1.0.6", "description": "Developer-friendly CLI for scanning JS/TS projects for dependency vulnerabilities using local lockfiles and OSV", "type": "module", "bin": { diff --git a/src/output/formatters.ts b/src/output/formatters.ts index 000ed0f..77e389c 100644 --- a/src/output/formatters.ts +++ b/src/output/formatters.ts @@ -1,15 +1,16 @@ -import type { Finding, ScanInput } from "../types.js"; +import type { Finding } from "../types.js"; import { chalk } from "../utils/chalk.js"; import { severityOrder } from "../constants.js"; import { loadCache } from "../osv/cache.js"; import { inferSeverity } from "../osv/severity.js"; export function formatSeverityLabel(severity: string): string { - if (severity === "critical") return chalk.redBright(severity); - if (severity === "high") return chalk.red(severity); - if (severity === "medium") return chalk.yellow(severity); - if (severity === "low") return chalk.blueBright(severity); - if (severity === "unknown") return chalk.magenta(severity); + const lower = severity.toLowerCase(); + if (lower === "critical") return chalk.redBright(severity); + if (lower === "high") return chalk.red(severity); + if (lower === "medium") return chalk.yellow(severity); + if (lower === "low") return chalk.blueBright(severity); + if (lower === "unknown") return chalk.magenta(severity); return severity; } @@ -33,6 +34,10 @@ export function getRecommendedAction(finding: Finding): string { return `Review and upgrade ${finding.pkg.name} directly in this project.`; } + if (finding.recommendedParentUpgrade) { + return `Upgrade ${finding.recommendedParentUpgrade.package} from ${finding.recommendedParentUpgrade.currentVersion} to ${finding.recommendedParentUpgrade.targetVersion} to stop pulling in vulnerable ${finding.pkg.name}.`; + } + const parent = getPrimaryParent(finding); if (parent && finding.firstFixedVersion) { return `Review ${parent}; aim for a version that resolves ${finding.pkg.name} to ${finding.firstFixedVersion}+`; @@ -53,6 +58,9 @@ export function summarizeRisk(finding: Finding): string { if (finding.severity === "high" && finding.relationship === "direct") { return "High-severity direct dependency. A direct upgrade is likely the fastest path."; } + if (finding.relationship === "transitive" && finding.recommendedParentUpgrade) { + return `Transitive issue. A specific parent upgrade target was found for ${finding.recommendedParentUpgrade.package}.`; + } if (finding.relationship === "transitive" && finding.firstFixedVersion) { return `Transitive issue. Look for a parent dependency upgrade that pulls in ${finding.firstFixedVersion}+`; } @@ -72,6 +80,9 @@ export function summarizeNextAction(finding: Finding): string { if (finding.relationship === "direct") { return `Review and upgrade ${finding.pkg.name} directly in this project.`; } + if (finding.recommendedParentUpgrade) { + return `Upgrade ${finding.recommendedParentUpgrade.package} ${finding.recommendedParentUpgrade.currentVersion} -> ${finding.recommendedParentUpgrade.targetVersion}.`; + } if (finding.firstFixedVersion) { return `Upgrade the parent dependency chain so it resolves ${finding.pkg.name} to ${finding.firstFixedVersion}+`; } @@ -87,14 +98,15 @@ export function serializeFinding(finding: Finding) { firstFixedVersion: finding.firstFixedVersion, recommendedAction: getRecommendedAction(finding), primaryParent: getPrimaryParent(finding), + recommendedParentUpgrade: finding.recommendedParentUpgrade, cves: finding.cveAliases, dependencyPaths: finding.dependencyPaths, vulnerabilities: finding.vulnerabilities.map(v => ({ id: v.id, aliases: v.aliases ?? [], summary: v.summary ?? "", - severity: inferSeverity(v) - })) + severity: inferSeverity(v), + })), }; } @@ -103,15 +115,33 @@ export function printCacheSummary(cacheDirOverride?: string, options?: { json?: const cache = loadCache(cacheDirOverride); const advisoryCount = Object.entries(cache.entries).filter(([, value]) => Boolean(value)).length; - const negativeCount = Object.entries(cache.entries).filter(([, value]) => value === null).length; + const emptyCount = Object.entries(cache.entries).filter(([, value]) => value === null).length; + const totalCount = advisoryCount + emptyCount; - logInfo(`Cache summary: ${advisoryCount} advisory record${advisoryCount === 1 ? "" : "s"} cached, ${negativeCount} negative entr${negativeCount === 1 ? "y" : "ies"}`, options); + if (totalCount === 0) return; + + console.log( + chalk.gray(`Cache: ${advisoryCount} advisory detail record${advisoryCount === 1 ? "" : "s"}`) + + (emptyCount > 0 + ? chalk.gray(`, ${emptyCount} empty lookup${emptyCount === 1 ? "" : "s"}`) + : ""), + ); } export function logInfo(message: string, options?: { json?: boolean }) { - if (!options?.json) console.log(chalk.cyan(`• ${message}`)); + if (options?.json) return; + console.log(chalk.gray(message)); } export function logWarn(message: string, options?: { json?: boolean }) { - if (!options?.json) console.log(chalk.yellow(`! ${message}`)); + if (options?.json) return; + console.log(chalk.yellow(message)); } + +export function sortFindingsForOutput(findings: Finding[]): Finding[] { + return [...findings].sort((a, b) => { + const sevDelta = severityOrder[b.severity] - severityOrder[a.severity]; + if (sevDelta !== 0) return sevDelta; + return a.pkg.name.localeCompare(b.pkg.name); + }); +} \ No newline at end of file diff --git a/src/output/printers.ts b/src/output/printers.ts index e87328a..06d2013 100644 --- a/src/output/printers.ts +++ b/src/output/printers.ts @@ -1,4 +1,3 @@ -import path from "node:path"; import type { Finding, ScanInput, SeverityLabel } from "../types.js"; import { chalk, stripAnsi } from "../utils/chalk.js"; import { severityOrder } from "../constants.js"; @@ -8,7 +7,8 @@ import { getPrimaryParent, getRecommendedAction, summarizeRisk, - summarizeNextAction + summarizeNextAction, + sortFindingsForOutput } from "./formatters.js"; export function printSummary(findings: Finding[], packageCount: number, scanInput: ScanInput) { @@ -80,6 +80,11 @@ export function printPriorityFixes(findings: Finding[]) { console.log(` Risk: ${summarizeRisk(finding)}`); const parent = getPrimaryParent(finding); if (parent) console.log(` Parent: ${parent}`); + if (finding.recommendedParentUpgrade) { + console.log( + ` Target: ${finding.recommendedParentUpgrade.package} ${finding.recommendedParentUpgrade.currentVersion} -> ${finding.recommendedParentUpgrade.targetVersion}`, + ); + } console.log(` Next: ${summarizeNextAction(finding)}`); } } @@ -231,6 +236,12 @@ export function printPathHints(findings: Finding[]) { for (const example of examples) { console.log(` ${chalk.gray(example)}`); } + + if (finding.recommendedParentUpgrade) { + console.log( + ` Recommended parent upgrade: ${chalk.bold.cyan(finding.recommendedParentUpgrade.package)} ${finding.recommendedParentUpgrade.currentVersion} -> ${finding.recommendedParentUpgrade.targetVersion}`, + ); + } } } @@ -298,15 +309,22 @@ export function printCompactOutput(findings: Finding[]) { for (const finding of urgentFindings) { const sevLabel = finding.severity.toUpperCase().padEnd(8); - const typeLabel = finding.relationship === "direct" ? "Direct dependency" : - finding.relationship === "transitive" ? "Transitive dependency" : "Unknown dependency"; + const typeLabel = finding.relationship === "direct" + ? "Direct dependency" + : finding.relationship === "transitive" + ? "Transitive dependency" + : "Unknown dependency"; console.log(`${formatSeverityLabel(sevLabel)} ${chalk.whiteBright(finding.pkg.name)}@${finding.pkg.version}`); console.log(` ${typeLabel}`); - if (finding.firstFixedVersion) { - const action = finding.relationship === "direct" - ? `upgrade to ${finding.firstFixedVersion}` + if (finding.recommendedParentUpgrade) { + console.log( + ` ${chalk.gray(`Fix: upgrade ${finding.recommendedParentUpgrade.package} to ${finding.recommendedParentUpgrade.targetVersion}`)}`, + ); + } else if (finding.firstFixedVersion) { + const action = finding.relationship === "direct" + ? `upgrade to ${finding.firstFixedVersion}` : `upgrade parent chain to resolve ${finding.firstFixedVersion}+`; console.log(` ${chalk.gray(`Fix: ${action}`)}`); } else { @@ -329,6 +347,10 @@ export function printCompactOutput(findings: Finding[]) { console.log(`Upgrade ${chalk.whiteBright(topFinding.pkg.name)} → ${topFinding.firstFixedVersion}`); } else if (topFinding.relationship === "direct") { console.log(`Upgrade ${chalk.whiteBright(topFinding.pkg.name)} to latest safe version`); + } else if (topFinding.recommendedParentUpgrade) { + console.log( + `Upgrade ${chalk.whiteBright(topFinding.recommendedParentUpgrade.package)} ${topFinding.recommendedParentUpgrade.currentVersion} → ${topFinding.recommendedParentUpgrade.targetVersion}`, + ); } else { const parent = getPrimaryParent(topFinding); if (parent) { @@ -356,38 +378,37 @@ export function printCompactOutput(findings: Finding[]) { const direct = findings.filter(f => f.relationship === "direct").length; const transitive = findings.filter(f => f.relationship === "transitive").length; -const parts: string[] = []; -if (counts.critical > 0) parts.push(chalk.redBright(`${counts.critical} critical`)); -if (counts.high > 0) parts.push(chalk.magenta(`${counts.high} high`)); -if (counts.medium > 0) parts.push(chalk.yellow(`${counts.medium} medium`)); -if (counts.low > 0) parts.push(chalk.green(`${counts.low} low`)); -if (counts.unknown > 0) parts.push(chalk.gray(`${counts.unknown} unknown`)); - -console.log(`${chalk.whiteBright(String(findings.length))} vulnerable packages`); -console.log(parts.join(chalk.gray(" · "))); -console.log( - `${chalk.cyan(String(direct))} ${chalk.white("direct")}` + - `${chalk.gray(" · ")}` + - `${chalk.cyan(String(transitive))} ${chalk.white("transitive")}` -); -console.log(""); - -// Footer -const urgentCount = counts.critical + counts.high; -if (urgentCount > 0) { - console.log( - chalk.redBright( - `✖ Scan complete. ${urgentCount} urgent issue${urgentCount === 1 ? "" : "s"} found.` - ) - ); -} else { + const parts: string[] = []; + if (counts.critical > 0) parts.push(chalk.redBright(`${counts.critical} critical`)); + if (counts.high > 0) parts.push(chalk.magenta(`${counts.high} high`)); + if (counts.medium > 0) parts.push(chalk.yellow(`${counts.medium} medium`)); + if (counts.low > 0) parts.push(chalk.green(`${counts.low} low`)); + if (counts.unknown > 0) parts.push(chalk.gray(`${counts.unknown} unknown`)); + + console.log(`${chalk.whiteBright(String(findings.length))} vulnerable packages`); + console.log(parts.join(chalk.gray(" · "))); console.log( - chalk.yellow( - `▲ Scan complete. ${findings.length} issue${findings.length === 1 ? "" : "s"} found.` - ) + `${chalk.cyan(String(direct))} ${chalk.white("direct")}` + + `${chalk.gray(" · ")}` + + `${chalk.cyan(String(transitive))} ${chalk.white("transitive")}` ); -} -console.log(chalk.gray(`Run with ${chalk.whiteBright("--verbose")} for fix plan, paths, and full table.`)); -console.log(""); -} + console.log(""); + // Footer + const urgentCount = counts.critical + counts.high; + if (urgentCount > 0) { + console.log( + chalk.redBright( + `✖ Scan complete. ${urgentCount} urgent issue${urgentCount === 1 ? "" : "s"} found.` + ) + ); + } else { + console.log( + chalk.yellow( + `▲ Scan complete. ${findings.length} issue${findings.length === 1 ? "" : "s"} found.` + ) + ); + } + console.log(chalk.gray(`Run with ${chalk.whiteBright("--verbose")} for fix plan, paths, and full table.`)); + console.log(""); +} \ No newline at end of file diff --git a/src/remediation/parent-upgrade.ts b/src/remediation/parent-upgrade.ts new file mode 100644 index 0000000..207ae0a --- /dev/null +++ b/src/remediation/parent-upgrade.ts @@ -0,0 +1,295 @@ +import type { Finding, PackageRef, RecommendedParentUpgrade } from "../types.js"; +import { compareVersions, looksLikeVersion } from "../utils/version.js"; + +type Packument = { + versions?: Record; + optionalDependencies?: Record; + }>; +}; + +const packumentCache = new Map(); +const DEFAULT_NPM_REGISTRY_URL = "https://registry.npmjs.org"; + +export async function resolveRecommendedParentUpgrade( + finding: Finding, + packages: PackageRef[], +): Promise { + if (finding.relationship !== "transitive") return null; + + const viaPath = getBestPath(finding); + if (!viaPath || viaPath.length < 3) return null; + + const directParentName = viaPath[1]; + const immediateParentName = viaPath[viaPath.length - 2]; + const vulnerableName = finding.pkg.name; + + const directParent = findDirectDependency(packages, directParentName); + if (!directParent) return null; + + // Common reliable case: + // project -> sanitize-html -> lodash + if (viaPath.length === 3) { + return findUpgradeForExactDirectChild({ + directParentName, + directParentVersion: directParent.version, + vulnerableName, + vulnerableInstalledVersion: finding.pkg.version, + vulnerableFixedVersion: finding.firstFixedVersion, + viaPath, + }); + } + + // Best-effort fallback for deeper chains. + return findUpgradeForImmediateIntermediate({ + directParentName, + directParentVersion: directParent.version, + immediateParentName, + immediateParentInstalledVersion: findPackageVersion( + packages, + immediateParentName, + viaPath.slice(0, -1), + ) ?? "", + vulnerableName, + viaPath, + }); +} + +function getBestPath(finding: Finding): string[] | null { + const paths = finding.dependencyPaths ?? []; + if (paths.length === 0) return null; + return [...paths].sort((a, b) => a.length - b.length)[0] ?? null; +} + +function findDirectDependency(packages: PackageRef[], name: string): PackageRef | null { + for (const pkg of packages) { + if (pkg.name !== name) continue; + const paths = pkg.paths ?? []; + if (paths.some(path => path.length === 2 && path[1] === name)) { + return pkg; + } + } + return null; +} + +function findPackageVersion( + packages: PackageRef[], + name: string, + pathPrefix: string[], +): string | null { + for (const pkg of packages) { + if (pkg.name !== name) continue; + const paths = pkg.paths ?? []; + if (paths.some(path => startsWithPath(path, pathPrefix))) { + return pkg.version; + } + } + return null; +} + +function startsWithPath(path: string[], prefix: string[]): boolean { + if (prefix.length > path.length) return false; + for (let i = 0; i < prefix.length; i++) { + if (path[i] !== prefix[i]) return false; + } + return true; +} + +type ExactDirectChildArgs = { + directParentName: string; + directParentVersion: string; + vulnerableName: string; + vulnerableInstalledVersion: string; + vulnerableFixedVersion: string | null; + viaPath: string[]; +}; + +async function findUpgradeForExactDirectChild( + args: ExactDirectChildArgs, +): Promise { + const packument = await fetchPackument(args.directParentName); + const versions = Object.keys(packument?.versions ?? {}) + .filter(looksLikeVersion) + .filter(version => compareVersions(version, args.directParentVersion) > 0) + .sort(compareVersions); + + for (const version of versions) { + const manifest = packument?.versions?.[version]; + const depRange = + manifest?.dependencies?.[args.vulnerableName] ?? + manifest?.optionalDependencies?.[args.vulnerableName]; + + if (!depRange) continue; + + const stillAllowsInstalled = versionSatisfiesRange( + args.vulnerableInstalledVersion, + depRange, + ); + const allowsFixed = args.vulnerableFixedVersion + ? versionSatisfiesRange(args.vulnerableFixedVersion, depRange) + : true; + + if (!stillAllowsInstalled && allowsFixed) { + return { + package: args.directParentName, + currentVersion: args.directParentVersion, + targetVersion: version, + viaPath: args.viaPath, + vulnerablePackage: args.vulnerableName, + confidence: "exact-direct-child", + reason: `${args.directParentName}@${version} no longer allows ${args.vulnerableName}@${args.vulnerableInstalledVersion}${args.vulnerableFixedVersion ? ` and allows ${args.vulnerableFixedVersion}+` : ""}`, + }; + } + } + + return null; +} + +type ImmediateIntermediateArgs = { + directParentName: string; + directParentVersion: string; + immediateParentName: string; + immediateParentInstalledVersion: string; + vulnerableName: string; + viaPath: string[]; +}; + +async function findUpgradeForImmediateIntermediate( + args: ImmediateIntermediateArgs, +): Promise { + if (!args.immediateParentInstalledVersion || !looksLikeVersion(args.immediateParentInstalledVersion)) { + return null; + } + + const packument = await fetchPackument(args.directParentName); + const versions = Object.keys(packument?.versions ?? {}) + .filter(looksLikeVersion) + .filter(version => compareVersions(version, args.directParentVersion) > 0) + .sort(compareVersions); + + for (const version of versions) { + const manifest = packument?.versions?.[version]; + const depRange = + manifest?.dependencies?.[args.immediateParentName] ?? + manifest?.optionalDependencies?.[args.immediateParentName]; + + if (!depRange) continue; + + const stillAllowsImmediateParentInstalled = versionSatisfiesRange( + args.immediateParentInstalledVersion, + depRange, + ); + + if (!stillAllowsImmediateParentInstalled) { + return { + package: args.directParentName, + currentVersion: args.directParentVersion, + targetVersion: version, + viaPath: args.viaPath, + vulnerablePackage: args.vulnerableName, + confidence: "best-effort", + reason: `${args.directParentName}@${version} no longer allows ${args.immediateParentName}@${args.immediateParentInstalledVersion} in the current path`, + }; + } + } + + return null; +} + +async function fetchPackument(packageName: string): Promise { + if (packumentCache.has(packageName)) { + return packumentCache.get(packageName) ?? null; + } + + const url = `${DEFAULT_NPM_REGISTRY_URL}/${encodeURIComponent(packageName) + .replace(/%40/g, "@") + .replace(/%2F/g, "/")}`; + + const response = await fetch(url); + if (!response.ok) { + packumentCache.set(packageName, null); + return null; + } + + const json = (await response.json()) as Packument; + packumentCache.set(packageName, json); + return json; +} + +function versionSatisfiesRange(version: string, rawRange: string): boolean { + const range = rawRange.trim(); + if (!range) return false; + if (range === "*" || range === "latest") return true; + + const orParts = range.split("||").map(part => part.trim()).filter(Boolean); + return orParts.some(part => satisfiesAndRange(version, part)); +} + +function satisfiesAndRange(version: string, range: string): boolean { + const normalized = normalizeRange(range); + if (!normalized) return false; + + const tokens = normalized.split(/\s+/).filter(Boolean); + return tokens.every(token => satisfiesComparator(version, token)); +} + +function normalizeRange(range: string): string | null { + const trimmed = range.trim(); + if (!trimmed) return null; + + if (looksLikeVersion(trimmed)) { + return `=${trimmed}`; + } + + if (trimmed.startsWith("^")) { + const base = trimmed.slice(1); + if (!looksLikeVersion(base)) return null; + const [major, minor, patch] = parseCoreVersion(base); + if (major > 0) return `>=${base} <${major + 1}.0.0`; + if (minor > 0) return `>=${base} <0.${minor + 1}.0`; + return `>=${base} <0.0.${patch + 1}`; + } + + if (trimmed.startsWith("~")) { + const base = trimmed.slice(1); + if (!looksLikeVersion(base)) return null; + const [major, minor] = parseCoreVersion(base); + return `>=${base} <${major}.${minor + 1}.0`; + } + + return trimmed; +} + +function satisfiesComparator(version: string, comparator: string): boolean { + const token = comparator.trim(); + if (!token) return true; + + const match = token.match(/^(<=|>=|<|>|=)?\s*([0-9]+\.[0-9]+\.[0-9]+(?:[-+][^\s]+)?)$/); + if (!match) return false; + + const operator = match[1] ?? "="; + const target = match[2]; + const cmp = compareVersions(version, target); + + switch (operator) { + case "<": + return cmp < 0; + case "<=": + return cmp <= 0; + case ">": + return cmp > 0; + case ">=": + return cmp >= 0; + case "=": + return cmp === 0; + default: + return false; + } +} + +function parseCoreVersion(version: string): [number, number, number] { + const [major, minor, patch] = version + .split(".") + .map(part => Number(part.replace(/[^0-9].*$/, ""))); + return [major || 0, minor || 0, patch || 0]; +} \ No newline at end of file diff --git a/src/scanner.ts b/src/scanner.ts index d8bdeb0..7b97778 100644 --- a/src/scanner.ts +++ b/src/scanner.ts @@ -6,6 +6,7 @@ import { maxSeverity } from "./osv/severity.js"; import { createSpinner } from "./output/spinner.js"; import { OsvAdvisorySource } from "./advisory/osv-advisory-source.js"; import { AdvisorySource } from "./advisory/advisory-source.js"; +import { resolveRecommendedParentUpgrade } from "./remediation/parent-upgrade.js"; export function createAdvisorySource(options?: { osvUrl?: string }): AdvisorySource { return new OsvAdvisorySource(options?.osvUrl); @@ -84,14 +85,14 @@ export async function scanPackages( saveCache(cache, cacheDirOverride); } - return results.map(result => { + const findings: Finding[] = results.map(result => { const vulnerabilities = result.vulnIds .map(id => vulnMap.get(id)) .filter((v): v is OsvVuln => Boolean(v)); const severity = maxSeverity(vulnerabilities); const cveAliases = unique( - vulnerabilities.flatMap(v => (v.aliases ?? []).filter(a => a.startsWith("CVE-"))) + vulnerabilities.flatMap(v => (v.aliases ?? []).filter(a => a.startsWith("CVE-"))), ); const dependencyPaths = result.pkg.paths ?? []; const relationship = classifyRelationship(dependencyPaths); @@ -104,9 +105,21 @@ export async function scanPackages( cveAliases, dependencyPaths, relationship, - firstFixedVersion + firstFixedVersion, + recommendedParentUpgrade: undefined, }; }); + + for (const finding of findings) { + if (finding.relationship !== "transitive" || offline) continue; + try { + finding.recommendedParentUpgrade = await resolveRecommendedParentUpgrade(finding, packages); + } catch { + finding.recommendedParentUpgrade = undefined; + } + } + + return findings; } catch (error) { spinner.fail("Scan failed"); throw error; @@ -143,7 +156,7 @@ export function buildCoverageNotes(scanInput: ScanInput, offline: boolean): stri "This MVP checks package versions against OSV advisories. It does not prove exploitability or runtime reachability.", "Installed node_modules contents are not verified in this scan.", "Container images, binaries, secrets, and IaC files are not scanned.", - "Monorepo workspace boundaries are only partially modeled in this version." + "Monorepo workspace boundaries are only partially modeled in this version.", ]; if (scanInput.mode === "manifest-fallback") { diff --git a/src/types.ts b/src/types.ts index 7e53747..d640857 100644 --- a/src/types.ts +++ b/src/types.ts @@ -50,6 +50,16 @@ export type OsvVuln = { }>; }; +export type RecommendedParentUpgrade = { + package: string; + currentVersion: string; + targetVersion: string; + viaPath: string[]; + vulnerablePackage: string; + confidence: "exact-direct-child" | "best-effort"; + reason: string; +}; + export type Finding = { pkg: PackageRef; vulnerabilities: OsvVuln[]; @@ -58,6 +68,7 @@ export type Finding = { dependencyPaths: string[][]; relationship: "direct" | "transitive" | "unknown"; firstFixedVersion: string | null; + recommendedParentUpgrade?: RecommendedParentUpgrade | null; }; export type CacheFile = { @@ -86,4 +97,4 @@ export type ParsedOptions = { minSeverity?: string; help?: boolean; osvUrl?: string; -}; +}; \ No newline at end of file