Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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.

Expand All @@ -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

Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
54 changes: 42 additions & 12 deletions src/output/formatters.ts
Original file line number Diff line number Diff line change
@@ -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;
}

Expand All @@ -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}+`;
Expand All @@ -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}+`;
}
Expand All @@ -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}+`;
}
Expand All @@ -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),
})),
};
}

Expand All @@ -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);
});
}
99 changes: 60 additions & 39 deletions src/output/printers.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -8,7 +7,8 @@ import {
getPrimaryParent,
getRecommendedAction,
summarizeRisk,
summarizeNextAction
summarizeNextAction,
sortFindingsForOutput
} from "./formatters.js";

export function printSummary(findings: Finding[], packageCount: number, scanInput: ScanInput) {
Expand Down Expand Up @@ -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)}`);
}
}
Expand Down Expand Up @@ -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}`,
);
}
}
}

Expand Down Expand Up @@ -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 {
Expand All @@ -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) {
Expand Down Expand Up @@ -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("");
}
Loading
Loading