Skip to content
Open
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
12 changes: 11 additions & 1 deletion src/output/formatters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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) {
Expand Down Expand Up @@ -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,
Expand Down
24 changes: 17 additions & 7 deletions src/output/html-reporter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down Expand Up @@ -368,12 +370,13 @@ ${duplicatePackagesHtml}
<th>Fix available</th>
<th onclick="sortBy('severity')" class="sorted">Severity <span class="sort-arrow" style="color:var(--link)">↓</span></th>
<th onclick="sortBy('relationship')">Type <span class="sort-arrow">↕</span></th>
<th>Root</th>
Comment thread
sonukapoor marked this conversation as resolved.
<th>CVE / Advisory</th>
</tr>
</thead>
<tbody id="findings-tbody">
${findingRowsHtml}
<tr id="no-results-row" style="display:none"><td colspan="6" class="no-results">No findings match your search.</td></tr>
<tr id="no-results-row" style="display:none"><td colspan="7" class="no-results">No findings match your search.</td></tr>
</tbody>
</table>
</div>
Expand Down Expand Up @@ -580,16 +583,21 @@ export function renderFindingRow(finding: SerializedFinding, idx: number, skippe
? renderTransitiveContextCol(finding)
: "";

const rootDepsHtml = finding.rootDependencies.length > 0
? finding.rootDependencies.map(name => `<span class="root-dep">${escapeHtml(name)}</span>`).join(", ")
: `<span class="root-dep-none">-</span>`;

return ` <tr id="row-${idx}" data-skipped="${isSkipped ? '1' : '0'}" onclick="toggleRow(${idx})">
<td><span class="expand-icon" id="icon-${idx}">▶</span></td>
<td><div class="pkg-name">${escapeHtml(finding.package)}</div><div class="pkg-version">${escapeHtml(finding.version)}</div></td>
<td>${fixHtml}</td>
<td><span class="sev-badge ${escapeHtml(finding.severity)}">${escapeHtml(finding.severity)}</span></td>
<td>${renderRelBadge(finding)}</td>
<td>${rootDepsHtml}</td>
<td>${cveLinks}</td>
</tr>
<tr id="detail-${idx}" class="detail-row">
<td colspan="6">
<td colspan="7">
<div class="expanded-inner">
<div class="detail-col" style="max-width:320px">
<h4>Description</h4>
Expand Down Expand Up @@ -619,22 +627,24 @@ function renderTransitiveContextCol(finding: SerializedFinding): string {
finding.recommendedNpmTransitiveRemediation != null ||
finding.recommendedParentUpgrade != null;

const rootNames = finding.rootDependencies.join(", ");

if (hasFixAvailable) {
const parentLine = finding.primaryParent
? `<p style="font-size:12px;color:var(--text-2);margin-top:4px">Parent: ${escapeHtml(finding.primaryParent)}</p>`
const rootLine = rootNames
? `<p style="font-size:12px;color:var(--text-2);margin-top:4px">Root: ${escapeHtml(rootNames)}</p>`
: "";
return `<div class="detail-col" style="max-width:200px">
<h4>Context</h4>
<span class="tier-ok">✓ Fix available</span>
${parentLine}
${rootLine}
</div>`;
}

if (finding.primaryParent) {
if (rootNames) {
return `<div class="detail-col" style="max-width:200px">
<h4>Context</h4>
<span class="tier-warn">⚠ No safe version identified</span>
<p style="font-size:12px;color:var(--text-2);margin-top:4px">Parent: ${escapeHtml(finding.primaryParent)}</p>
<p style="font-size:12px;color:var(--text-2);margin-top:4px">Root: ${escapeHtml(rootNames)}</p>
</div>`;
}

Expand Down
5 changes: 3 additions & 2 deletions src/output/multi-folder-html-reporter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ function renderFolderSection(
: "";

const emptyRow = result.sorted.length === 0
? `<tr><td colspan="6" style="text-align:center;padding:24px;color:#8b949e">No findings</td></tr>`
? `<tr><td colspan="7" style="text-align:center;padding:24px;color:#8b949e">No findings</td></tr>`
: "";

return `
Expand Down Expand Up @@ -166,12 +166,13 @@ function renderFolderSection(
<th>Fix available</th>
<th onclick="sortBy('severity',${fi})" class="sorted">Severity <span class="sort-arrow" style="color:#58a6ff">↓</span></th>
<th onclick="sortBy('relationship',${fi})">Type <span class="sort-arrow">↕</span></th>
<th>Root</th>
<th>CVE / Advisory</th>
</tr>
</thead>
<tbody id="findings-tbody-${fi}" data-offset="${idxOffset}">
${emptyRow}${findingRowsHtml}
<tr id="no-results-row-${fi}" style="display:none"><td colspan="6" class="no-results">No findings match your search.</td></tr>
<tr id="no-results-row-${fi}" style="display:none"><td colspan="7" class="no-results">No findings match your search.</td></tr>
</tbody>
</table>
</div>
Expand Down
11 changes: 7 additions & 4 deletions src/output/printers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
countUniqueAdvisories,
formatRelationshipLabel,
formatRelLabel,
formatRootDependencySummary,
sortFindingsForOutput,
formatFixCommandWithPublishDates,
formatCooldownWarning,
Expand Down Expand Up @@ -233,7 +234,7 @@ export function printSkippedDependencies(skipped: string[]) {
}

export function printTable(findings: Finding[], threshold: SeverityLabel | null, skippedKeys?: ReadonlySet<string>) {
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) {
Expand All @@ -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(", ")
Expand All @@ -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);
Expand All @@ -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));
}
Expand Down
28 changes: 28 additions & 0 deletions src/utils/finding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,31 @@ 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.
*
* 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[] {
Comment thread
sonukapoor marked this conversation as resolved.
if (finding.relationship !== "transitive") return [];

const roots: string[] = [];
const seen = new Set<string>();
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;
}
48 changes: 46 additions & 2 deletions tests/html-reporter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -713,7 +713,7 @@ describe("renderHtmlReport", () => {
expect(html).toContain("<h4>Context</h4>");
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", () => {
Expand All @@ -731,7 +731,7 @@ describe("renderHtmlReport", () => {
expect(html).toContain("<h4>Context</h4>");
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", () => {
Expand All @@ -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", () => {
Expand Down Expand Up @@ -820,6 +838,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("<th>Root</th>");
});

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('<span class="root-dep">express</span>, <span class="root-dep">koa</span>, <span class="root-dep">fastify</span>');
});

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('<span class="root-dep-none">-</span>');
});
});

describe("dev dependency badge", () => {
it("renders 'direct · dev' badge with dev CSS class for devDependency findings", () => {
const finding = makeFinding({
Expand Down
29 changes: 29 additions & 0 deletions tests/multi-folder-html-reporter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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("<th>Root</th>");
expect(html).toContain('<span class="root-dep">express</span>');
expect(html).not.toContain('colspan="6"');
expect(html).toContain('colspan="7"');
});
});
Loading