From 58a4a2c35af82316f59f440315aedef781688f2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A1bio=20Englert=20Moutinho?= Date: Fri, 31 Jul 2026 23:35:54 -0400 Subject: [PATCH 1/3] feat: add Root column showing the top-level dependency behind each finding Vulnerable transitive dependencies gave no indication of which direct dependency pulled them in. The terminal table and JSON output now show a compact "name +N" summary, while the HTML report lists every root dependency in full. --- src/output/formatters.ts | 12 ++++- src/output/html-reporter.ts | 12 ++++- src/output/printers.ts | 11 +++-- src/utils/finding.ts | 20 ++++++++ tests/html-reporter.test.ts | 26 +++++++++++ tests/output.test.ts | 92 ++++++++++++++++++++++++++++++++++++- 6 files changed, 165 insertions(+), 8 deletions(-) diff --git a/src/output/formatters.ts b/src/output/formatters.ts index 6750dbbc..488198e1 100644 --- a/src/output/formatters.ts +++ b/src/output/formatters.ts @@ -11,7 +11,7 @@ import { } from "../constants.js"; import { loadCache } from "../osv/cache.js"; import { inferSeverity } from "../osv/severity.js"; -import { getPrimaryParent } from "../utils/finding.js"; +import { getPrimaryParent, getRootDependencies } from "../utils/finding.js"; import { calculatePathCoverage, formatDependencyPath } from "../utils/path-coverage.js"; import { pluralize } from "../utils/string.js"; import { hasMaliciousAdvisory } from "../utils/vuln.js"; @@ -27,6 +27,15 @@ export function formatRelLabel(finding: { relationship: string; pkg: { dev?: boo return finding.pkg.dev === true ? `${base} · dev` : base; } +// Compact form for the terminal table, where column width is limited: shows +// the first root dependency plus a count of additional ones (e.g. "app +2"). +export function formatRootDependencySummary(finding: Finding): string { + const roots = getRootDependencies(finding); + if (roots.length === 0) return "-"; + if (roots.length === 1) return roots[0]; + return `${roots[0]} +${roots.length - 1}`; +} + export function formatAdvisorySourceLine(sourceLabel: string): string { const match = sourceLabel.match(/^(.*) \((.*)\)$/); if (!match) { @@ -288,6 +297,7 @@ export function serializeFinding(finding: Finding, plan?: SuggestedFixCommandPla recommendedAction: getRecommendedAction(finding), runnableFixCommand: plan ? findSuggestedCommandForFinding(plan, finding) : null, primaryParent: getPrimaryParent(finding), + rootDependencies: getRootDependencies(finding), recommendedParentUpgrade: finding.recommendedParentUpgrade, recommendedNpmTransitiveRemediation: finding.recommendedNpmTransitiveRemediation ?? null, cves: finding.cveAliases, diff --git a/src/output/html-reporter.ts b/src/output/html-reporter.ts index 0b9944ab..04eb0106 100644 --- a/src/output/html-reporter.ts +++ b/src/output/html-reporter.ts @@ -180,6 +180,8 @@ button.header-link:hover{color:var(--link);border-color:var(--link)} .rel-badge.transitive{color:#e3b341;background:#e3b34122} .rel-badge.unknown{color:#8b949e;background:#8b949e22} .rel-badge.dev{color:#a371f7;background:#6e40c922;border:1px solid #6e40c955} +.root-dep{font-size:11px;font-family:monospace;color:var(--text)} +.root-dep-none{font-size:11px;color:var(--text-3)} .cve-link{font-size:11px;color:var(--link);font-family:monospace;text-decoration:none;border-bottom:1px dotted var(--link)} .cve-link:hover{color:var(--link-h)} .fix-hint{font-size:11px;color:var(--green);font-family:monospace} @@ -368,12 +370,13 @@ ${duplicatePackagesHtml} Fix available Severity Type + Root CVE / Advisory ${findingRowsHtml} - No findings match your search. + No findings match your search. @@ -580,16 +583,21 @@ export function renderFindingRow(finding: SerializedFinding, idx: number, skippe ? renderTransitiveContextCol(finding) : ""; + const rootDepsHtml = finding.rootDependencies.length > 0 + ? finding.rootDependencies.map(name => `${escapeHtml(name)}`).join(", ") + : `-`; + return `
${escapeHtml(finding.package)}
${escapeHtml(finding.version)}
${fixHtml} ${escapeHtml(finding.severity)} ${renderRelBadge(finding)} + ${rootDepsHtml} ${cveLinks} - +

Description

diff --git a/src/output/printers.ts b/src/output/printers.ts index 3fc78a2a..0c11a333 100644 --- a/src/output/printers.ts +++ b/src/output/printers.ts @@ -7,6 +7,7 @@ import { countUniqueAdvisories, formatRelationshipLabel, formatRelLabel, + formatRootDependencySummary, sortFindingsForOutput, formatFixCommandWithPublishDates, formatCooldownWarning, @@ -233,7 +234,7 @@ export function printSkippedDependencies(skipped: string[]) { } export function printTable(findings: Finding[], threshold: SeverityLabel | null, skippedKeys?: ReadonlySet) { - const headers = ["Package", "Version", "Severity", "Type", "Usage", "Fixed", "IDs"]; + const headers = ["Package", "Version", "Severity", "Type", "Root", "Usage", "Fixed", "IDs"]; const rawRows = findings.map(f => { let usageText = "n/a"; if (f.usage) { @@ -260,6 +261,7 @@ export function printTable(findings: Finding[], threshold: SeverityLabel | null, f.pkg.version, f.severity, formatRelLabel(f), + formatRootDependencySummary(f), usageText, fixedDisplay, f.vulnerabilities.map(v => v.id).join(", ") @@ -283,7 +285,7 @@ export function printTable(findings: Finding[], threshold: SeverityLabel | null, console.log(line("├", "┼", "┤")); for (const row of rawRows) { - let usageDecorated = String(row[4]); + let usageDecorated = String(row[5]); if (usageDecorated.includes("file(s)")) usageDecorated = chalk.red(usageDecorated); else if (usageDecorated.includes("unused")) usageDecorated = chalk.green(usageDecorated); else usageDecorated = chalk.gray(usageDecorated); @@ -293,9 +295,10 @@ export function printTable(findings: Finding[], threshold: SeverityLabel | null, row[1], formatSeverityLabel(String(row[2])), formatRelationshipLabel(String(row[3])), + row[4], usageDecorated, - row[5], - row[6] + row[6], + row[7] ]; console.log(renderRow(decorated, widths)); } diff --git a/src/utils/finding.ts b/src/utils/finding.ts index 18d8562c..4a245eb3 100644 --- a/src/utils/finding.ts +++ b/src/utils/finding.ts @@ -5,3 +5,23 @@ export function getPrimaryParent(finding: Finding): string | null { if (!firstPath || firstPath.length < 3) return null; return firstPath[1] ?? null; } + +/** + * Unique root (top-level) dependencies that pull in this package, derived from + * every known dependencyPaths chain. Paths of length < 3 (direct dependencies, + * where the package itself is path[1]) are excluded — there is no meaningful + * root distinct from the package. + */ +export function getRootDependencies(finding: Finding): string[] { + const roots: string[] = []; + const seen = new Set(); + for (const path of finding.dependencyPaths ?? []) { + if (path.length < 3) continue; + const root = path[1]; + if (root && !seen.has(root)) { + seen.add(root); + roots.push(root); + } + } + return roots; +} diff --git a/tests/html-reporter.test.ts b/tests/html-reporter.test.ts index 475e6044..0f057945 100644 --- a/tests/html-reporter.test.ts +++ b/tests/html-reporter.test.ts @@ -820,6 +820,32 @@ describe("renderHtmlReport", () => { expect(html).not.toContain("⚠ No fix"); }); + describe("Root column", () => { + it("adds a Root header to the findings table", () => { + const html = renderHtmlReport(buildReportData(BASE_PARAMS)); + expect(html).toContain("Root"); + }); + + it("lists every root dependency name (not truncated) for a transitive finding with multiple roots", () => { + const finding = makeFinding({ + relationship: "transitive", + dependencyPaths: [ + ["my-app", "express", "lodash"], + ["my-app", "koa", "lodash"], + ["my-app", "fastify", "lodash"], + ], + }); + const html = renderHtmlReport(buildReportData({ ...BASE_PARAMS, findings: [finding] })); + expect(html).toContain('express, koa, fastify'); + }); + + it("shows a dash for a direct dependency with no identifiable root", () => { + const finding = makeFinding({ relationship: "direct", dependencyPaths: [["my-app", "lodash"]] }); + const html = renderHtmlReport(buildReportData({ ...BASE_PARAMS, findings: [finding] })); + expect(html).toContain('-'); + }); + }); + describe("dev dependency badge", () => { it("renders 'direct · dev' badge with dev CSS class for devDependency findings", () => { const finding = makeFinding({ diff --git a/tests/output.test.ts b/tests/output.test.ts index 754c1405..081968d2 100644 --- a/tests/output.test.ts +++ b/tests/output.test.ts @@ -2,7 +2,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { jest } from "@jest/globals"; -import { getPrimaryParent } from "../src/utils/finding.js"; +import { getPrimaryParent, getRootDependencies } from "../src/utils/finding.js"; import { countUniqueAdvisories, getRecommendedAction, @@ -14,6 +14,7 @@ import { summarizeNextAction, summarizeRisk, formatRelLabel, + formatRootDependencySummary, formatHintLines, formatFixCommandWithPublishDates, formatFixVersionPublishDate, @@ -195,6 +196,53 @@ describe("output formatters", () => { expect(getPrimaryParent(noPath)).toBeNull(); }); + it("getRootDependencies returns the unique root dependency for a single path", () => { + const finding = createFinding({ dependencyPaths: [["project", "app", "lodash"]] }); + expect(getRootDependencies(finding)).toEqual(["app"]); + }); + + it("getRootDependencies returns unique roots in first-seen order across multiple paths", () => { + const finding = createFinding({ + dependencyPaths: [ + ["project", "express", "app", "lodash"], + ["project", "koa", "lodash"], + ["project", "express", "other", "lodash"], + ], + }); + expect(getRootDependencies(finding)).toEqual(["express", "koa"]); + }); + + it("getRootDependencies excludes degenerate (direct-dependency) paths", () => { + const finding = createFinding({ dependencyPaths: [["project", "lodash"]] }); + expect(getRootDependencies(finding)).toEqual([]); + }); + + it("getRootDependencies returns an empty array for empty paths", () => { + const finding = createFinding({ dependencyPaths: [] }); + expect(getRootDependencies(finding)).toEqual([]); + }); + + it("formatRootDependencySummary shows the name alone for a single root", () => { + const finding = createFinding({ dependencyPaths: [["project", "app", "lodash"]] }); + expect(formatRootDependencySummary(finding)).toBe("app"); + }); + + it("formatRootDependencySummary shows the first root plus a count for multiple roots", () => { + const finding = createFinding({ + dependencyPaths: [ + ["project", "express", "lodash"], + ["project", "koa", "lodash"], + ["project", "fastify", "lodash"], + ], + }); + expect(formatRootDependencySummary(finding)).toBe("express +2"); + }); + + it("formatRootDependencySummary shows a dash when no root is identifiable", () => { + const finding = createFinding({ dependencyPaths: [] }); + expect(formatRootDependencySummary(finding)).toBe("-"); + }); + it("derives the primary parent and recommendation text from findings", () => { const finding = createFinding(); @@ -215,6 +263,7 @@ describe("output formatters", () => { relationship: "transitive", firstFixedVersion: "4.17.21", primaryParent: "app", + rootDependencies: ["app"], cves: ["CVE-2026-0001"], }); expect(serialized.vulnerabilities[0]).toMatchObject({ @@ -1216,6 +1265,47 @@ describe("output printers", () => { expect(lines.join("\n")).toContain("✖ Scan complete. 1 vulnerability found (1 critical, 0 high)."); }); + it("shows a Root column with the sole root name for a single-path transitive finding", () => { + const finding = createFinding(); + + const lines = captureLogs(() => { + printTable([finding], null); + }); + + const output = lines.join("\n"); + expect(output).toContain("Root"); + expect(output).toContain("app"); + }); + + it("shows the first root plus a count in the Root column when multiple roots pull in the same finding", () => { + const finding = createFinding({ + dependencyPaths: [ + ["project", "express", "lodash"], + ["project", "koa", "lodash"], + ], + }); + + const lines = captureLogs(() => { + printTable([finding], null); + }); + + expect(lines.join("\n")).toContain("express +1"); + }); + + it("shows a dash in the Root column for a direct dependency", () => { + const finding = createFinding({ + relationship: "direct", + dependencyPaths: [["project", "lodash"]], + }); + + const lines = captureLogs(() => { + printTable([finding], null); + }); + + const rows = stripAnsi(lines.join("\n")).split("\n").filter(line => line.includes("lodash")); + expect(rows[0]).toContain("-"); + }); + it("shows ⚠ no fix in the Fixed column when firstFixedVersion is null", () => { const finding = createFinding({ firstFixedVersion: null, From 56f8a69201a28691fdd1531f555d4884ee6a90f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A1bio=20Englert=20Moutinho?= Date: Sat, 1 Aug 2026 15:00:39 -0400 Subject: [PATCH 2/3] fix: gate root dependency lookup on relationship and align Root/Parent labeling getRootDependencies unioned roots across every dependencyPaths entry without checking finding.relationship, so a package classified "direct" (but also reachable via another root's transitive chain, deduped into the same PackageRef) could still surface a contradictory root. Gate it on relationship === "transitive", matching the convention already used by parent-upgrade.ts, transitive-chain-resolver.ts, and npm-transitive-resolution.ts. Also rename the HTML detail view's "Parent:" label to "Root:" so it matches the new column header instead of using two terms for the same concept. Addresses review feedback on #920. --- src/output/html-reporter.ts | 8 ++++---- src/utils/finding.ts | 8 ++++++++ tests/html-reporter.test.ts | 4 ++-- tests/output.test.ts | 27 +++++++++++++++++++++++++++ 4 files changed, 41 insertions(+), 6 deletions(-) diff --git a/src/output/html-reporter.ts b/src/output/html-reporter.ts index 04eb0106..3506979c 100644 --- a/src/output/html-reporter.ts +++ b/src/output/html-reporter.ts @@ -628,13 +628,13 @@ function renderTransitiveContextCol(finding: SerializedFinding): string { finding.recommendedParentUpgrade != null; if (hasFixAvailable) { - const parentLine = finding.primaryParent - ? `

Parent: ${escapeHtml(finding.primaryParent)}

` + const rootLine = finding.primaryParent + ? `

Root: ${escapeHtml(finding.primaryParent)}

` : ""; return `

Context

✓ Fix available - ${parentLine} + ${rootLine}
`; } @@ -642,7 +642,7 @@ function renderTransitiveContextCol(finding: SerializedFinding): string { return `

Context

⚠ No safe version identified -

Parent: ${escapeHtml(finding.primaryParent)}

+

Root: ${escapeHtml(finding.primaryParent)}

`; } diff --git a/src/utils/finding.ts b/src/utils/finding.ts index 4a245eb3..691c457e 100644 --- a/src/utils/finding.ts +++ b/src/utils/finding.ts @@ -11,8 +11,16 @@ export function getPrimaryParent(finding: Finding): string | null { * every known dependencyPaths chain. Paths of length < 3 (direct dependencies, * where the package itself is path[1]) are excluded — there is no meaningful * root distinct from the package. + * + * Gated on relationship === "transitive": a package can be a direct dependency + * while also being reachable via another root's transitive chain (same + * resolved version, deduped into one PackageRef). classifyRelationship marks + * that case "direct", so treat it the same way here rather than surfacing a + * root that contradicts the Type column. */ export function getRootDependencies(finding: Finding): string[] { + if (finding.relationship !== "transitive") return []; + const roots: string[] = []; const seen = new Set(); for (const path of finding.dependencyPaths ?? []) { diff --git a/tests/html-reporter.test.ts b/tests/html-reporter.test.ts index 0f057945..d85c3a23 100644 --- a/tests/html-reporter.test.ts +++ b/tests/html-reporter.test.ts @@ -713,7 +713,7 @@ describe("renderHtmlReport", () => { expect(html).toContain("

Context

"); expect(html).toContain("tier-ok"); expect(html).toContain("✓ Fix available"); - expect(html).toContain("Parent: express"); + expect(html).toContain("Root: express"); }); it("shows ⚠ No safe version badge when parent is known but no fix is available", () => { @@ -731,7 +731,7 @@ describe("renderHtmlReport", () => { expect(html).toContain("

Context

"); expect(html).toContain("tier-warn"); expect(html).toContain("⚠ No safe version identified"); - expect(html).toContain("Parent: nest-core"); + expect(html).toContain("Root: nest-core"); }); it("shows ✕ No parent badge when no parent is identifiable from the dependency path", () => { diff --git a/tests/output.test.ts b/tests/output.test.ts index 081968d2..070dd86c 100644 --- a/tests/output.test.ts +++ b/tests/output.test.ts @@ -217,6 +217,22 @@ describe("output formatters", () => { expect(getRootDependencies(finding)).toEqual([]); }); + it("getRootDependencies returns no roots when the package is classified direct, even if a transitive path also resolves to it", () => { + // Same resolved version reachable both as a direct dependency and via + // another root's chain (e.g. commander pinned directly, but also a dep of + // express) dedupes into one PackageRef with both paths attached. + // classifyRelationship marks this "direct", so the root should not + // contradict that by naming "express" as a root dependency. + const finding = createFinding({ + relationship: "direct", + dependencyPaths: [ + ["project", "commander"], + ["project", "express", "commander"], + ], + }); + expect(getRootDependencies(finding)).toEqual([]); + }); + it("getRootDependencies returns an empty array for empty paths", () => { const finding = createFinding({ dependencyPaths: [] }); expect(getRootDependencies(finding)).toEqual([]); @@ -243,6 +259,17 @@ describe("output formatters", () => { expect(formatRootDependencySummary(finding)).toBe("-"); }); + it("formatRootDependencySummary shows a dash for a direct dependency that also has a transitive path", () => { + const finding = createFinding({ + relationship: "direct", + dependencyPaths: [ + ["project", "commander"], + ["project", "express", "commander"], + ], + }); + expect(formatRootDependencySummary(finding)).toBe("-"); + }); + it("derives the primary parent and recommendation text from findings", () => { const finding = createFinding(); From 449354a1082690a69ba0dd4fa5b6e4bab522a5a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A1bio=20Englert=20Moutinho?= Date: Tue, 11 Aug 2026 20:55:31 -0400 Subject: [PATCH 3/3] fix: add Root column to multi-folder HTML report and align detail-row roots renderFindingRow is shared between the single-folder and multi-folder HTML reports. The multi-folder report kept its own 6-column table header and colspans, so the new Root shifted its columns out of alignment. Add the matching Root and bump both colspans to 7. Also update the expanded detail row's Context column to list every root dependency (finding.rootDependencies) instead of only the first path's root (finding.primaryParent), so it no longer disagrees with the "name +N" summary shown in the column when multiple roots pull in the same finding. Addresses further review feedback on #920. --- src/output/html-reporter.ts | 10 ++++---- src/output/multi-folder-html-reporter.ts | 5 ++-- tests/html-reporter.test.ts | 18 +++++++++++++++ tests/multi-folder-html-reporter.test.ts | 29 ++++++++++++++++++++++++ 4 files changed, 56 insertions(+), 6 deletions(-) diff --git a/src/output/html-reporter.ts b/src/output/html-reporter.ts index 3506979c..5166a903 100644 --- a/src/output/html-reporter.ts +++ b/src/output/html-reporter.ts @@ -627,9 +627,11 @@ function renderTransitiveContextCol(finding: SerializedFinding): string { finding.recommendedNpmTransitiveRemediation != null || finding.recommendedParentUpgrade != null; + const rootNames = finding.rootDependencies.join(", "); + if (hasFixAvailable) { - const rootLine = finding.primaryParent - ? `

Root: ${escapeHtml(finding.primaryParent)}

` + const rootLine = rootNames + ? `

Root: ${escapeHtml(rootNames)}

` : ""; return `

Context

@@ -638,11 +640,11 @@ function renderTransitiveContextCol(finding: SerializedFinding): string {
`; } - if (finding.primaryParent) { + if (rootNames) { return `

Context

⚠ No safe version identified -

Root: ${escapeHtml(finding.primaryParent)}

+

Root: ${escapeHtml(rootNames)}

`; } diff --git a/src/output/multi-folder-html-reporter.ts b/src/output/multi-folder-html-reporter.ts index d322af7d..470bfb02 100644 --- a/src/output/multi-folder-html-reporter.ts +++ b/src/output/multi-folder-html-reporter.ts @@ -128,7 +128,7 @@ function renderFolderSection( : ""; const emptyRow = result.sorted.length === 0 - ? `No findings` + ? `No findings` : ""; return ` @@ -166,12 +166,13 @@ function renderFolderSection( Fix available Severity Type + Root CVE / Advisory ${emptyRow}${findingRowsHtml} - No findings match your search. + No findings match your search.
diff --git a/tests/html-reporter.test.ts b/tests/html-reporter.test.ts index d85c3a23..73b620c0 100644 --- a/tests/html-reporter.test.ts +++ b/tests/html-reporter.test.ts @@ -749,6 +749,24 @@ describe("renderHtmlReport", () => { expect(html).toContain("✕ No parent identified"); expect(html).toContain("npm ls lodash"); }); + + it("lists every root dependency (not just the first) in the Context column when multiple roots pull in the same finding", () => { + const finding = makeFinding({ + pkg: { name: "qs", version: "6.5.2", ecosystem: "npm" }, + relationship: "transitive", + dependencyPaths: [ + ["project", "express", "qs"], + ["project", "koa", "qs"], + ], + firstFixedVersion: "6.11.0", + }); + + const html = renderHtmlReport( + buildReportData({ ...BASE_PARAMS, findings: [finding], suggestedFixCommands: null }), + ); + + expect(html).toContain("Root: express, koa"); + }); }); it("shows ⚠ No fix in the fix column when no fixed version is available", () => { diff --git a/tests/multi-folder-html-reporter.test.ts b/tests/multi-folder-html-reporter.test.ts index 81bcd038..43612f25 100644 --- a/tests/multi-folder-html-reporter.test.ts +++ b/tests/multi-folder-html-reporter.test.ts @@ -302,4 +302,33 @@ describe("writeMultiFolderHtmlReport", () => { expect(html).toMatch(/Override hygiene/i); expect(html).toMatch(/No override hygiene findings/i); }); + + it("adds a Root header and column so cell count matches the header, keeping colspans in sync", async () => { + const transitiveFinding: Finding = { + pkg: { name: "qs", version: "6.5.2", ecosystem: "npm" }, + vulnerabilities: [{ id: "GHSA-yyy", aliases: ["CVE-2021-1234"], summary: "test" }], + severity: "high", + cveAliases: ["CVE-2021-1234"], + dependencyPaths: [["project", "express", "qs"]], + relationship: "transitive", + firstFixedVersion: "6.11.0", + }; + + await writeMultiFolderHtmlReport({ + outputDir, + results: [ + makeResult("packages/a", { sorted: [transitiveFinding] }), + makeResult("packages/b", { sorted: [] }), + ], + projectPath: "/project", + cliVersion: "1.27.0", + autoOpen: false, + }); + + const html = fs.readFileSync(path.join(outputDir, "index.html"), "utf8"); + expect(html).toContain("Root"); + expect(html).toContain('express'); + expect(html).not.toContain('colspan="6"'); + expect(html).toContain('colspan="7"'); + }); });