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
3 changes: 2 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import {
import { countBySeverity } from "./utils/severity.js";
import { buildReportData, writeHtmlReport } from "./output/html-reporter.js";
import { writeOutputs } from "./output/write-outputs.js";
import { selectFindingsForTable } from "./output/finding-display.js";
import {
printSummary,
printActionSummary,
Expand Down Expand Up @@ -393,7 +394,7 @@ async function scanProject(params: {
const minSeverity = normalizeSeverity(params.options.minSeverity || "medium");
const tableFindings = params.options.all
? sorted
: sorted.filter(f => severityOrder[f.severity] >= severityOrder[minSeverity] || f.severity === "unknown");
: selectFindingsForTable(sorted, minSeverity);
const suggestedFixCommands = buildSuggestedFixCommandPlan(sorted, params.scanInput, { offline });

return {
Expand Down
44 changes: 44 additions & 0 deletions src/output/finding-display.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { severityOrder } from "../constants.js";
import { normalizeSeverity } from "../osv/severity.js";
import type { Finding, SeverityLabel } from "../types.js";

export function selectFindingsForTable(findings: Finding[], minSeverity: string): Finding[] {
const normalized = normalizeSeverity(minSeverity) as SeverityLabel;
return findings.filter(finding =>
severityOrder[finding.severity] >= severityOrder[normalized] || finding.severity === "unknown"
);
}

export function selectFindingsForCompact(
findings: Finding[],
options?: { urgentLimit?: number },
): Finding[] {
const urgentLimit = options?.urgentLimit ?? 3;
const urgent = findings
.filter(finding => finding.severity === "critical" || finding.severity === "high")
.slice(0, urgentLimit);
const unknownDirect = findings.filter(
finding => finding.severity === "unknown" && finding.relationship === "direct",
);

return mergeUniqueFindings(urgent, unknownDirect);
}

function mergeUniqueFindings(primary: Finding[], extra: Finding[]): Finding[] {
const result = [...primary];
const seen = new Set(result.map(findingIdentity));

for (const finding of extra) {
const id = findingIdentity(finding);
if (seen.has(id)) continue;
seen.add(id);
result.push(finding);
}

return result;
}

function findingIdentity(finding: Finding): string {
const vulnIds = finding.vulnerabilities.map(v => v.id).sort().join(",");
return `${finding.pkg.name}@${finding.pkg.version}|${finding.relationship}|${finding.severity}|${vulnIds}`;
}
8 changes: 4 additions & 4 deletions src/output/printers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
sortFindingsForOutput
} from "./formatters.js";
import { pluralize } from "../utils/string.js";
import { selectFindingsForCompact } from "./finding-display.js";

export function printSummary(findings: Finding[], packageCount: number, scanInput: ScanInput) {
if (findings.length === 0) {
Expand Down Expand Up @@ -346,10 +347,9 @@ export function printCompactOutput(
console.log(chalk.bold("📦 Vulnerabilities found"));
console.log("────────────────────────────────\n");

// Get top 3 critical/high issues plus all unknown (malicious) findings
const urgentFindings = findings
.filter(f => f.severity === "critical" || f.severity === "high" || f.severity === "unknown")
.slice(0, 3);
// Reuse shared display selection logic so compact mode does not silently
// drop direct unknown-severity findings.
const urgentFindings = selectFindingsForCompact(findings, { urgentLimit: 3 });

for (const finding of urgentFindings) {
const sevLabel = finding.severity.toUpperCase().padEnd(8);
Expand Down
44 changes: 44 additions & 0 deletions tests/output.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1470,6 +1470,50 @@ describe("output printers", () => {
expect(emptyLines).toContain("✔ Scan complete. No known vulnerabilities found.");
});

it("keeps direct unknown-severity findings visible in compact output even when urgent slots are full", () => {
const findings = [
createFinding({
pkg: { name: "critical-a", version: "1.0.0", ecosystem: "npm", paths: [["project", "critical-a"]] },
severity: "critical",
relationship: "direct",
dependencyPaths: [["project", "critical-a"]],
}),
createFinding({
pkg: { name: "critical-b", version: "1.0.0", ecosystem: "npm", paths: [["project", "critical-b"]] },
severity: "critical",
relationship: "direct",
dependencyPaths: [["project", "critical-b"]],
}),
createFinding({
pkg: { name: "high-c", version: "1.0.0", ecosystem: "npm", paths: [["project", "high-c"]] },
severity: "high",
relationship: "direct",
dependencyPaths: [["project", "high-c"]],
}),
createFinding({
pkg: { name: "fs", version: "0.0.1-security", ecosystem: "npm", paths: [["project", "fs"]] },
severity: "unknown",
relationship: "direct",
dependencyPaths: [["project", "fs"]],
firstFixedVersion: null,
recommendedParentUpgrade: undefined,
recommendedNpmTransitiveRemediation: undefined,
vulnerabilities: [{ id: "MAL-2025-21003", aliases: [], summary: "Malicious code in fs (npm)", severity: [] }],
}),
];

const lines = captureLogs(() => {
printCompactOutput(findings, createScanInputForSource("package-lock"));
});
const output = lines.join("\n");

expect(output).toContain("critical-a@1.0.0");
expect(output).toContain("critical-b@1.0.0");
expect(output).toContain("high-c@1.0.0");
expect(output).toContain("fs@0.0.1-security");
expect(output).toContain("⚠ Malicious: Remove this package from your dependencies immediately.");
});

it("renders Context column for parent-upgrade targets in urgent sections", () => {
const findings = [
createFinding({
Expand Down