From 3c946986ff6d29f56fb942052f2442cd7b5a1cbb Mon Sep 17 00:00:00 2001 From: Juha Litola Date: Tue, 28 Apr 2026 18:17:37 +0300 Subject: [PATCH] fix(pkg): use version-aware vulnerability advisories Query affected advisories from the backend so safe versions report historical advisories separately from active risk. Update CLI and MCP output to preserve version-specific metadata while keeping terminal wording focused on inspected-version impact. --- src/commands/pkg/vulns.test.ts | 4 +- src/commands/pkg/vulns.ts | 4 +- .../package-intelligence-service.test.ts | 292 ++++++++++++++---- src/services/package-intelligence-service.ts | 189 ++++++++++-- src/services/test-helpers.ts | 34 +- .../package-vulnerabilities-response.test.ts | 122 ++++++-- .../package-vulnerabilities-response.ts | 162 ++++++++-- .../package-vulnerabilities-parity.test.ts | 12 +- src/tools/package-vulnerabilities.ts | 3 +- 9 files changed, 674 insertions(+), 148 deletions(-) diff --git a/src/commands/pkg/vulns.test.ts b/src/commands/pkg/vulns.test.ts index 35611eb3..21a492fd 100644 --- a/src/commands/pkg/vulns.test.ts +++ b/src/commands/pkg/vulns.test.ts @@ -40,9 +40,9 @@ describe("pkgVulnsAction", () => { const combined = writes.join(""); expect(combined).toContain("express @ 4.18.0 · npm"); - expect(combined).toContain("6 known vulnerabilities · latest affected"); + expect(combined).toContain("6 vulnerabilities affect this version"); expect(combined).toContain("MALWARE"); - expect(combined).toContain("Upgrade to 4.18.2."); + expect(combined).toContain("Fix version: 4.18.2."); writeSpy.mockRestore(); }); diff --git a/src/commands/pkg/vulns.ts b/src/commands/pkg/vulns.ts index e8afaf83..9e809b56 100644 --- a/src/commands/pkg/vulns.ts +++ b/src/commands/pkg/vulns.ts @@ -138,8 +138,8 @@ function formatVulnsTerminalError(mapped: MappedError): string { } const PKG_VULNS_DESCRIPTION = `Show known vulnerabilities for a package. Lists CVE / OSV advisories -with severity, affected version ranges, fix versions, and suggested -upgrade paths. Malicious-package advisories are flagged prominently. +with severity, affected version ranges, and fix versions. +Malicious-package advisories are flagged prominently. Package spec: :[@]. Supported registries: npm, pypi, hex, crates. Omit @ to check the latest release. diff --git a/src/services/package-intelligence-service.test.ts b/src/services/package-intelligence-service.test.ts index 327db766..e0eadbcb 100644 --- a/src/services/package-intelligence-service.test.ts +++ b/src/services/package-intelligence-service.test.ts @@ -512,37 +512,52 @@ const VULNS_HAPPY_BODY = { packageVulnerabilities: { package: { name: "express", registry: "NPM", version: "4.18.0" }, security: { - vulnerabilityCount: 2, + affectedVulnerabilityCount: 2, + nonAffectingVulnerabilityCount: 3, + allVulnerabilityCount: 5, currentVersionAffected: true, upgradePaths: ["4.18.2"], - vulnerabilities: [ - { - osvId: "GHSA-xxxx-xxxx-xxxx", - summary: "Open redirect", - severityScore: 7.5, - severityType: "CVSS_V3", - affectedVersionRanges: [">= 4.0.0, < 4.18.2"], - fixedInVersions: ["4.18.2"], - publishedAt: "2024-06-01T00:00:00Z", - modifiedAt: null, - withdrawnAt: null, - aliases: ["CVE-2024-1234"], - isMalicious: false, - }, - { - osvId: "GHSA-mmmm-mmmm-mmmm", - summary: "Malicious impersonator", - severityScore: null, - severityType: null, - affectedVersionRanges: [">= 4.17.0, < 4.18.1"], - fixedInVersions: [], - publishedAt: "2024-07-10T00:00:00Z", - modifiedAt: null, - withdrawnAt: null, - aliases: [], - isMalicious: true, - }, - ], + advisories: { + entries: [ + { + osvId: "GHSA-xxxx-xxxx-xxxx", + summary: "Open redirect", + severityScore: 7.5, + severityType: "CVSS_V3", + affectedVersionRanges: [">= 4.0.0, < 4.18.2"], + affectedVersionRangesCount: 1, + affectedVersionRangesTruncated: false, + fixedInVersions: ["4.18.2"], + publishedAt: "2024-06-01T00:00:00Z", + modifiedAt: null, + withdrawnAt: null, + aliases: ["CVE-2024-1234"], + isMalicious: false, + affectsInspectedVersion: true, + matchedAffectedVersionRanges: [">= 4.0.0, < 4.18.2"], + duplicateIds: [], + }, + { + osvId: "GHSA-mmmm-mmmm-mmmm", + summary: "Malicious impersonator", + severityScore: null, + severityType: null, + affectedVersionRanges: [">= 4.17.0, < 4.18.1"], + affectedVersionRangesCount: 1, + affectedVersionRangesTruncated: false, + fixedInVersions: [], + publishedAt: "2024-07-10T00:00:00Z", + modifiedAt: null, + withdrawnAt: null, + aliases: [], + isMalicious: true, + affectsInspectedVersion: true, + matchedAffectedVersionRanges: [">= 4.17.0, < 4.18.1"], + duplicateIds: [], + }, + ], + pageInfo: { hasNextPage: false, endCursor: null, totalCount: 2 }, + }, }, }, }, @@ -575,12 +590,20 @@ describe("PackageIntelligenceServiceImpl.packageVulnerabilities", () => { expect(result.package.name).toBe("express"); expect(result.package.version).toBe("4.18.0"); - expect(result.security?.vulnerabilityCount).toBe(2); + expect(result.security?.affectedVulnerabilityCount).toBe(2); + expect(result.security?.nonAffectingVulnerabilityCount).toBe(3); + expect(result.security?.allVulnerabilityCount).toBe(5); expect(result.security?.upgradePaths).toEqual(["4.18.2"]); expect(result.security?.vulnerabilities?.[0]?.osvId).toBe( "GHSA-xxxx-xxxx-xxxx", ); expect(result.security?.vulnerabilities?.[1]?.isMalicious).toBe(true); + expect(result.security?.vulnerabilities?.[0]?.affectsInspectedVersion).toBe( + true, + ); + expect( + result.security?.vulnerabilities?.[0]?.matchedAffectedVersionRanges, + ).toEqual([">= 4.0.0, < 4.18.2"]); }); it("sends packageVulnerabilities query with wire variables", async () => { @@ -608,12 +631,15 @@ describe("PackageIntelligenceServiceImpl.packageVulnerabilities", () => { expect(parsed.query).toContain( "packageVulnerabilities(\n registry: $registry", ); + expect(parsed.query).toContain("advisories(scope: AFFECTED"); + expect(parsed.query).not.toContain("vulnerabilityCount"); expect(parsed.variables).toEqual({ registry: "NPM", name: "express", version: "4.18.0", minSeverity: 7.0, includeWithdrawn: true, + after: null, }); }); @@ -671,10 +697,15 @@ describe("PackageIntelligenceServiceImpl.packageVulnerabilities", () => { packageVulnerabilities: { package: { name: "express", registry: "NPM", version: "4.18.2" }, security: { - vulnerabilityCount: 0, + affectedVulnerabilityCount: 0, + nonAffectingVulnerabilityCount: 2, + allVulnerabilityCount: 2, currentVersionAffected: false, upgradePaths: [], - vulnerabilities: [], + advisories: { + entries: [], + pageInfo: { hasNextPage: false, endCursor: null, totalCount: 0 }, + }, }, }, }, @@ -691,10 +722,134 @@ describe("PackageIntelligenceServiceImpl.packageVulnerabilities", () => { packageName: "express", }); - expect(result.security?.vulnerabilityCount).toBe(0); + expect(result.security?.affectedVulnerabilityCount).toBe(0); + expect(result.security?.nonAffectingVulnerabilityCount).toBe(2); + expect(result.security?.allVulnerabilityCount).toBe(2); expect(result.security?.vulnerabilities).toEqual([]); }); + it("fetches all affected advisory pages", async () => { + const firstPage = structuredClone(VULNS_HAPPY_BODY); + if (!firstPage.data.packageVulnerabilities.security) { + throw new Error("fixture missing security block"); + } + const firstPageInfo = firstPage.data.packageVulnerabilities.security + .advisories.pageInfo as { + hasNextPage: boolean; + endCursor: string | null; + totalCount: number; + }; + firstPageInfo.hasNextPage = true; + firstPageInfo.endCursor = "cursor-1"; + firstPageInfo.totalCount = 3; + const secondPage = structuredClone(VULNS_HAPPY_BODY); + if (!secondPage.data.packageVulnerabilities.security) { + throw new Error("fixture missing security block"); + } + secondPage.data.packageVulnerabilities.security.advisories.entries = [ + { + osvId: "GHSA-last-last-last", + summary: "Last page advisory", + severityScore: 4.1, + severityType: "CVSS_V3", + affectedVersionRanges: [">= 4.0.0, < 4.17.0"], + affectedVersionRangesCount: 1, + affectedVersionRangesTruncated: false, + fixedInVersions: ["4.17.0"], + publishedAt: "2023-01-01T00:00:00Z", + modifiedAt: null, + withdrawnAt: null, + aliases: [], + isMalicious: false, + affectsInspectedVersion: true, + matchedAffectedVersionRanges: [">= 4.0.0, < 4.17.0"], + duplicateIds: [], + }, + ]; + secondPage.data.packageVulnerabilities.security.advisories.pageInfo = { + hasNextPage: false, + endCursor: null, + totalCount: 3, + }; + + const fetchFn = mock((_url: string, init?: RequestInit) => { + const parsed = JSON.parse((init?.body as string | undefined) ?? "{}"); + if (parsed.variables.after === "cursor-1") { + return Promise.resolve(jsonResponse(secondPage)); + } + return Promise.resolve(jsonResponse(firstPage)); + }); + const service = new PackageIntelligenceServiceImpl( + ENDPOINT, + createMockTokenProvider(), + asFetchFn(fetchFn), + ); + + const result = await service.packageVulnerabilities({ + registry: "NPM", + packageName: "express", + }); + + expect(fetchFn).toHaveBeenCalledTimes(2); + expect(result.security?.vulnerabilities?.map((vuln) => vuln.osvId)).toEqual( + ["GHSA-xxxx-xxxx-xxxx", "GHSA-mmmm-mmmm-mmmm", "GHSA-last-last-last"], + ); + }); + + it("rejects incomplete advisory pagination", async () => { + const body = structuredClone(VULNS_HAPPY_BODY); + if (!body.data.packageVulnerabilities.security) { + throw new Error("fixture missing security block"); + } + body.data.packageVulnerabilities.security.advisories.pageInfo = { + hasNextPage: false, + endCursor: null, + totalCount: 3, + }; + const fetchFn = mock(() => Promise.resolve(jsonResponse(body))); + const service = new PackageIntelligenceServiceImpl( + ENDPOINT, + createMockTokenProvider(), + asFetchFn(fetchFn), + ); + + await expect( + service.packageVulnerabilities({ + registry: "NPM", + packageName: "express", + }), + ).rejects.toBeInstanceOf(MalformedPackageIntelligenceResponseError); + }); + + it("rejects repeated advisory pagination cursors", async () => { + const body = structuredClone(VULNS_HAPPY_BODY); + if (!body.data.packageVulnerabilities.security) { + throw new Error("fixture missing security block"); + } + const pageInfo = body.data.packageVulnerabilities.security.advisories + .pageInfo as { + hasNextPage: boolean; + endCursor: string | null; + totalCount: number; + }; + pageInfo.hasNextPage = true; + pageInfo.endCursor = "cursor-1"; + pageInfo.totalCount = 3; + const fetchFn = mock(() => Promise.resolve(jsonResponse(body))); + const service = new PackageIntelligenceServiceImpl( + ENDPOINT, + createMockTokenProvider(), + asFetchFn(fetchFn), + ); + + await expect( + service.packageVulnerabilities({ + registry: "NPM", + packageName: "express", + }), + ).rejects.toBeInstanceOf(MalformedPackageIntelligenceResponseError); + }); + it("classifies GraphQL VERSION_NOT_FOUND as typed error with structured details", async () => { const fetchFn = mock(() => Promise.resolve( @@ -1042,35 +1197,52 @@ describe("PackageIntelligenceServiceImpl.packageVulnerabilities", () => { packageVulnerabilities: { package: { name: "shady", registry: "NPM", version: "1.0.0" }, security: { - vulnerabilityCount: 2, + affectedVulnerabilityCount: 2, + nonAffectingVulnerabilityCount: 0, + allVulnerabilityCount: 2, currentVersionAffected: true, upgradePaths: [], - vulnerabilities: [ - { - osvId: "GHSA-mal", - summary: "Malicious", - severityScore: null, - affectedVersionRanges: [">= 1.0.0"], - fixedInVersions: [], - publishedAt: "2024-01-01T00:00:00Z", - modifiedAt: null, - withdrawnAt: null, - aliases: [], - isMalicious: true, - }, - { - osvId: "GHSA-wit", - summary: "Retracted advisory", - severityScore: 6.5, - affectedVersionRanges: [">= 1.0.0"], - fixedInVersions: ["1.0.1"], - publishedAt: "2023-12-01T00:00:00Z", - modifiedAt: null, - withdrawnAt: "2024-02-01T00:00:00Z", - aliases: [], - isMalicious: false, - }, - ], + advisories: { + entries: [ + { + osvId: "GHSA-mal", + summary: "Malicious", + severityScore: null, + severityType: null, + affectedVersionRanges: [">= 1.0.0"], + affectedVersionRangesCount: 1, + affectedVersionRangesTruncated: false, + fixedInVersions: [], + publishedAt: "2024-01-01T00:00:00Z", + modifiedAt: null, + withdrawnAt: null, + aliases: [], + isMalicious: true, + affectsInspectedVersion: true, + matchedAffectedVersionRanges: [">= 1.0.0"], + duplicateIds: [], + }, + { + osvId: "GHSA-wit", + summary: "Retracted advisory", + severityScore: 6.5, + severityType: null, + affectedVersionRanges: [">= 1.0.0"], + affectedVersionRangesCount: 1, + affectedVersionRangesTruncated: false, + fixedInVersions: ["1.0.1"], + publishedAt: "2023-12-01T00:00:00Z", + modifiedAt: null, + withdrawnAt: "2024-02-01T00:00:00Z", + aliases: [], + isMalicious: false, + affectsInspectedVersion: true, + matchedAffectedVersionRanges: [">= 1.0.0"], + duplicateIds: [], + }, + ], + pageInfo: { hasNextPage: false, endCursor: null, totalCount: 2 }, + }, }, }, }, diff --git a/src/services/package-intelligence-service.ts b/src/services/package-intelligence-service.ts index 38f92b60..ca14e09b 100644 --- a/src/services/package-intelligence-service.ts +++ b/src/services/package-intelligence-service.ts @@ -112,16 +112,23 @@ export interface VulnerabilityDetail { severityScore?: number; severityType?: string; affectedVersionRanges?: string[]; + affectedVersionRangesCount?: number; + affectedVersionRangesTruncated?: boolean; fixedInVersions?: string[]; publishedAt?: string; modifiedAt?: string; withdrawnAt?: string; aliases?: string[]; isMalicious?: boolean; + affectsInspectedVersion?: boolean; + matchedAffectedVersionRanges?: string[]; + duplicateIds?: string[]; } export interface VulnerabilitySecurityDetails { - vulnerabilityCount?: number; + affectedVulnerabilityCount: number; + nonAffectingVulnerabilityCount: number; + allVulnerabilityCount: number; currentVersionAffected?: boolean; vulnerabilities?: VulnerabilityDetail[]; upgradePaths?: string[]; @@ -700,19 +707,37 @@ const vulnerabilityDetailSchema = z.object({ severityScore: z.number().nullable().optional(), severityType: z.string().nullable().optional(), affectedVersionRanges: z.array(z.string()).nullable().optional(), + affectedVersionRangesCount: z.number().int(), + affectedVersionRangesTruncated: z.boolean(), fixedInVersions: z.array(z.string()).nullable().optional(), publishedAt: z.string().nullable().optional(), modifiedAt: z.string().nullable().optional(), withdrawnAt: z.string().nullable().optional(), aliases: z.array(z.string()).nullable().optional(), isMalicious: z.boolean().nullable().optional(), + affectsInspectedVersion: z.boolean(), + matchedAffectedVersionRanges: z.array(z.string()), + duplicateIds: z.array(z.string()), +}); + +const pageInfoSchema = z.object({ + hasNextPage: z.boolean(), + endCursor: z.string().nullable().optional(), + totalCount: z.number().int(), +}); + +const vulnerabilityAdvisoryPageSchema = z.object({ + entries: z.array(vulnerabilityDetailSchema), + pageInfo: pageInfoSchema, }); const vulnerabilitySecurityDetailsSchema = z .object({ - vulnerabilityCount: z.number().int().nullable().optional(), + affectedVulnerabilityCount: z.number().int(), + nonAffectingVulnerabilityCount: z.number().int(), + allVulnerabilityCount: z.number().int(), currentVersionAffected: z.boolean().nullable().optional(), - vulnerabilities: z.array(vulnerabilityDetailSchema).nullable().optional(), + advisories: vulnerabilityAdvisoryPageSchema, upgradePaths: z.array(z.string()).nullable().optional(), }) .nullable() @@ -742,6 +767,7 @@ query PackageVulnerabilities( $version: String $minSeverity: Float $includeWithdrawn: Boolean + $after: String ) { packageVulnerabilities( registry: $registry @@ -756,21 +782,35 @@ query PackageVulnerabilities( version } security { - vulnerabilityCount + affectedVulnerabilityCount + nonAffectingVulnerabilityCount + allVulnerabilityCount currentVersionAffected upgradePaths - vulnerabilities { - osvId - summary - severityScore - severityType - affectedVersionRanges - fixedInVersions - publishedAt - modifiedAt - withdrawnAt - aliases - isMalicious + advisories(scope: AFFECTED, first: 100, after: $after) { + entries { + osvId + summary + severityScore + severityType + affectedVersionRanges + affectedVersionRangesCount + affectedVersionRangesTruncated + fixedInVersions + publishedAt + modifiedAt + withdrawnAt + aliases + isMalicious + affectsInspectedVersion + matchedAffectedVersionRanges + duplicateIds + } + pageInfo { + hasNextPage + endCursor + totalCount + } } } } @@ -1510,6 +1550,83 @@ export class PackageIntelligenceServiceImpl token: string, params: PackageVulnerabilitiesParams, ): Promise { + let after: string | null = null; + let firstPage: + | z.infer + | undefined; + const entries: z.infer[] = []; + const seenCursors = new Set(); + + do { + const page = await this.fetchPackageVulnerabilitiesPage( + token, + params, + after, + ); + if (!firstPage) firstPage = page; + + const advisoryPage = page.security?.advisories; + if (!advisoryPage) { + after = null; + break; + } + + entries.push(...advisoryPage.entries); + if (advisoryPage.pageInfo.hasNextPage) { + const nextCursor = advisoryPage.pageInfo.endCursor; + if (!nextCursor) { + throw new MalformedPackageIntelligenceResponseError( + "Vulnerability response pagination omitted next cursor.", + ); + } + if (seenCursors.has(nextCursor)) { + throw new MalformedPackageIntelligenceResponseError( + "Vulnerability response pagination repeated a cursor.", + ); + } + seenCursors.add(nextCursor); + after = nextCursor; + } else { + after = null; + } + } while (after !== null); + + if (!firstPage) { + throw new MalformedPackageIntelligenceResponseError( + "Empty response from the package-intelligence service.", + ); + } + + if (firstPage.security) { + const expectedCount = firstPage.security.advisories.pageInfo.totalCount; + if (entries.length !== expectedCount) { + throw new MalformedPackageIntelligenceResponseError( + "Vulnerability response pagination returned an incomplete advisory set.", + ); + } + } + + const data = firstPage.security + ? { + ...firstPage, + security: { + ...firstPage.security, + advisories: { + ...firstPage.security.advisories, + entries, + }, + }, + } + : firstPage; + + return this.normaliseVulnerabilityReport(data); + } + + private async fetchPackageVulnerabilitiesPage( + token: string, + params: PackageVulnerabilitiesParams, + after: string | null, + ): Promise> { let response: PkgseerGraphqlResponse; try { response = await postPkgseerGraphql({ @@ -1522,6 +1639,7 @@ export class PackageIntelligenceServiceImpl version: params.version, minSeverity: params.minSeverity, includeWithdrawn: params.includeWithdrawn, + after, }, fetchFn: this.fetchFn, }); @@ -1562,7 +1680,7 @@ export class PackageIntelligenceServiceImpl ); } - return this.normaliseVulnerabilityReport(data); + return data; } private normaliseVulnerabilityReport( @@ -1584,23 +1702,30 @@ export class PackageIntelligenceServiceImpl const security: VulnerabilitySecurityDetails | undefined = data.security ? { - vulnerabilityCount: data.security.vulnerabilityCount ?? undefined, + affectedVulnerabilityCount: data.security.affectedVulnerabilityCount, + nonAffectingVulnerabilityCount: + data.security.nonAffectingVulnerabilityCount, + allVulnerabilityCount: data.security.allVulnerabilityCount, currentVersionAffected: data.security.currentVersionAffected ?? undefined, - vulnerabilities: - data.security.vulnerabilities?.map((vuln) => ({ - osvId: vuln.osvId ?? undefined, - summary: vuln.summary ?? undefined, - severityScore: vuln.severityScore ?? undefined, - severityType: vuln.severityType ?? undefined, - affectedVersionRanges: vuln.affectedVersionRanges ?? undefined, - fixedInVersions: vuln.fixedInVersions ?? undefined, - publishedAt: vuln.publishedAt ?? undefined, - modifiedAt: vuln.modifiedAt ?? undefined, - withdrawnAt: vuln.withdrawnAt ?? undefined, - aliases: vuln.aliases ?? undefined, - isMalicious: vuln.isMalicious ?? undefined, - })) ?? undefined, + vulnerabilities: data.security.advisories.entries.map((vuln) => ({ + osvId: vuln.osvId ?? undefined, + summary: vuln.summary ?? undefined, + severityScore: vuln.severityScore ?? undefined, + severityType: vuln.severityType ?? undefined, + affectedVersionRanges: vuln.affectedVersionRanges ?? undefined, + affectedVersionRangesCount: vuln.affectedVersionRangesCount, + affectedVersionRangesTruncated: vuln.affectedVersionRangesTruncated, + fixedInVersions: vuln.fixedInVersions ?? undefined, + publishedAt: vuln.publishedAt ?? undefined, + modifiedAt: vuln.modifiedAt ?? undefined, + withdrawnAt: vuln.withdrawnAt ?? undefined, + aliases: vuln.aliases ?? undefined, + isMalicious: vuln.isMalicious ?? undefined, + affectsInspectedVersion: vuln.affectsInspectedVersion, + matchedAffectedVersionRanges: vuln.matchedAffectedVersionRanges, + duplicateIds: vuln.duplicateIds, + })), upgradePaths: data.security.upgradePaths ?? undefined, } : undefined; diff --git a/src/services/test-helpers.ts b/src/services/test-helpers.ts index 7196652b..3596020c 100644 --- a/src/services/test-helpers.ts +++ b/src/services/test-helpers.ts @@ -442,7 +442,9 @@ export const defaultVulnerabilityReport: VulnerabilityReport = { version: "4.18.0", }, security: { - vulnerabilityCount: 6, + affectedVulnerabilityCount: 6, + nonAffectingVulnerabilityCount: 0, + allVulnerabilityCount: 6, currentVersionAffected: true, upgradePaths: ["4.18.2"], vulnerabilities: [ @@ -452,10 +454,15 @@ export const defaultVulnerabilityReport: VulnerabilityReport = { severityScore: 9.8, severityType: "CVSS_V3", affectedVersionRanges: [">= 4.17.0, < 4.18.1"], + affectedVersionRangesCount: 1, + affectedVersionRangesTruncated: false, fixedInVersions: [], publishedAt: "2024-07-10T00:00:00Z", aliases: [], isMalicious: true, + affectsInspectedVersion: true, + matchedAffectedVersionRanges: [">= 4.17.0, < 4.18.1"], + duplicateIds: [], }, { osvId: "GHSA-cccc-cccc-cccc", @@ -463,9 +470,14 @@ export const defaultVulnerabilityReport: VulnerabilityReport = { severityScore: 9.2, severityType: "CVSS_V3", affectedVersionRanges: [">= 4.0.0, < 4.18.2"], + affectedVersionRangesCount: 1, + affectedVersionRangesTruncated: false, fixedInVersions: ["4.18.2"], publishedAt: "2024-06-15T00:00:00Z", aliases: ["CVE-2024-4242"], + affectsInspectedVersion: true, + matchedAffectedVersionRanges: [">= 4.0.0, < 4.18.2"], + duplicateIds: [], }, { osvId: "GHSA-xxxx-xxxx-xxxx", @@ -473,9 +485,14 @@ export const defaultVulnerabilityReport: VulnerabilityReport = { severityScore: 7.5, severityType: "CVSS_V3", affectedVersionRanges: [">= 4.0.0, < 4.18.2"], + affectedVersionRangesCount: 1, + affectedVersionRangesTruncated: false, fixedInVersions: ["4.18.2"], publishedAt: "2024-06-01T00:00:00Z", aliases: ["CVE-2024-1234"], + affectsInspectedVersion: true, + matchedAffectedVersionRanges: [">= 4.0.0, < 4.18.2"], + duplicateIds: [], }, { osvId: "GHSA-yyyy-yyyy-yyyy", @@ -483,10 +500,15 @@ export const defaultVulnerabilityReport: VulnerabilityReport = { severityScore: 5.3, severityType: "CVSS_V3", affectedVersionRanges: [">= 4.0.0, < 4.17.4"], + affectedVersionRangesCount: 1, + affectedVersionRangesTruncated: false, fixedInVersions: ["4.17.4"], publishedAt: "2024-03-12T00:00:00Z", modifiedAt: "2024-04-02T00:00:00Z", aliases: ["CVE-2024-5678", "CVE-2024-5679"], + affectsInspectedVersion: true, + matchedAffectedVersionRanges: [">= 4.0.0, < 4.17.4"], + duplicateIds: [], }, { osvId: "GHSA-zzzz-zzzz-zzzz", @@ -494,13 +516,23 @@ export const defaultVulnerabilityReport: VulnerabilityReport = { severityScore: 3.2, severityType: "CVSS_V3", affectedVersionRanges: [">= 4.0.0, < 4.17.3"], + affectedVersionRangesCount: 1, + affectedVersionRangesTruncated: false, fixedInVersions: ["4.17.3"], publishedAt: "2024-02-01T00:00:00Z", + affectsInspectedVersion: true, + matchedAffectedVersionRanges: [">= 4.0.0, < 4.17.3"], + duplicateIds: [], }, { osvId: "GHSA-nnnn-nnnn-nnnn", summary: "Advisory without a CVSS score", + affectedVersionRangesCount: 0, + affectedVersionRangesTruncated: false, publishedAt: "2023-11-20T00:00:00Z", + affectsInspectedVersion: true, + matchedAffectedVersionRanges: [], + duplicateIds: [], }, ], }, diff --git a/src/shared/package-vulnerabilities-response.test.ts b/src/shared/package-vulnerabilities-response.test.ts index 2fd8a459..52ee9156 100644 --- a/src/shared/package-vulnerabilities-response.test.ts +++ b/src/shared/package-vulnerabilities-response.test.ts @@ -18,7 +18,9 @@ function zeroVulnsFixture(): VulnerabilityReport { return { package: { name: "clean", registry: "NPM", version: "1.0.0" }, security: { - vulnerabilityCount: 0, + affectedVulnerabilityCount: 0, + nonAffectingVulnerabilityCount: 0, + allVulnerabilityCount: 0, currentVersionAffected: false, upgradePaths: [], vulnerabilities: [], @@ -59,7 +61,8 @@ describe("computeBySeverity — partitioning buckets", () => { unrated: 1, }); // Every advisory lands in exactly one bucket — the sum equals total. - const total = defaultVulnerabilityReport.security?.vulnerabilityCount ?? 0; + const total = + defaultVulnerabilityReport.security?.affectedVulnerabilityCount ?? 0; const bucketSum = histogram.malware + histogram.critical + @@ -102,6 +105,9 @@ describe("buildPackageVulnerabilitiesSuccessPayload — happy path", () => { expect(payload.name).toBe("express"); expect(payload.version).toBe("4.18.0"); expect(payload.summary.total).toBe(6); + expect(payload.summary.affectedVulnerabilityCount).toBe(6); + expect(payload.summary.nonAffectingVulnerabilityCount).toBe(0); + expect(payload.summary.allVulnerabilityCount).toBe(6); expect(payload.summary.affected).toBe(true); expect(payload.summary.bySeverity).toEqual({ malware: 1, @@ -112,6 +118,10 @@ describe("buildPackageVulnerabilitiesSuccessPayload — happy path", () => { unrated: 1, }); expect(payload.advisories?.length).toBe(6); + expect(payload.advisories?.[0]?.affectsInspectedVersion).toBe(true); + expect(payload.advisories?.[0]?.matchedAffectedVersionRanges).toEqual([ + ">= 4.17.0, < 4.18.1", + ]); expect(payload.upgradePaths).toEqual(["4.18.2"]); }); @@ -144,11 +154,30 @@ describe("buildPackageVulnerabilitiesSuccessPayload — omission rules", () => { const payload = buildPackageVulnerabilitiesSuccessPayload( zeroVulnsFixture(), ); - expect(payload.summary).toEqual({ total: 0 }); + expect(payload.summary).toEqual({ + total: 0, + affectedVulnerabilityCount: 0, + nonAffectingVulnerabilityCount: 0, + allVulnerabilityCount: 0, + affected: false, + }); expect(payload.advisories).toBeUndefined(); expect(payload.upgradePaths).toBeUndefined(); }); + it("reports historical advisories separately from active version risk", () => { + const fixture = zeroVulnsFixture(); + if (fixture.security) { + fixture.security.nonAffectingVulnerabilityCount = 2; + fixture.security.allVulnerabilityCount = 2; + } + const payload = buildPackageVulnerabilitiesSuccessPayload(fixture); + expect(payload.summary.total).toBe(0); + expect(payload.summary.nonAffectingVulnerabilityCount).toBe(2); + expect(payload.summary.allVulnerabilityCount).toBe(2); + expect(payload.advisories).toBeUndefined(); + }); + it("omits empty aliases / fixedIn / affectedRanges arrays", () => { const fixture = cloneFixture(); const malware = fixture.security?.vulnerabilities?.[0]; @@ -274,7 +303,7 @@ describe("buildPackageVulnerabilitiesSuccessPayload — requestedVersion echo", it("partition invariant: bucket sum equals vulnerabilities.length (and summary.total when backend is consistent)", () => { // Client-side guarantee: the six buckets partition // `security.vulnerabilities[]`. In the default fixture the - // backend also keeps `vulnerabilityCount` in sync with the list + // backend also keeps `affectedVulnerabilityCount` in sync with the list // length, so the sum additionally matches `summary.total` — the // visible CLI / MCP reconciliation users see. const payload = buildPackageVulnerabilitiesSuccessPayload( @@ -314,6 +343,8 @@ describe("dedupAdvisoriesByAlias — alias-cluster collapse", () => { severityScore: 9.8, publishedAt: "2021-08-25T00:00:00Z", affectedVersionRanges: [">=0.10.8 <0.10.9"], + affectedVersionRangesCount: 1, + affectedVersionRangesTruncated: false, fixedInVersions: ["0.10.9"], }, { @@ -321,6 +352,8 @@ describe("dedupAdvisoriesByAlias — alias-cluster collapse", () => { aliases: ["CVE-2018-20997", "GHSA-xjxc-vfw2-cg96"], publishedAt: "2018-06-01T00:00:00Z", affectedVersionRanges: [">=0.10.8 <0.10.9"], + affectedVersionRangesCount: 3, + affectedVersionRangesTruncated: true, fixedInVersions: ["0.10.9"], }, ]); @@ -336,6 +369,8 @@ describe("dedupAdvisoriesByAlias — alias-cluster collapse", () => { ]); expect(merged.aliases).not.toContain("GHSA-xjxc-vfw2-cg96"); expect(merged.severityScore).toBe(9.8); + expect(merged.affectedVersionRangesCount).toBe(3); + expect(merged.affectedVersionRangesTruncated).toBe(true); }); it("links chains via shared aliases (transitive merge)", () => { @@ -601,12 +636,15 @@ describe("dedupAdvisoriesByAlias — alias-cluster collapse", () => { describe("buildPackageVulnerabilitiesSuccessPayload — alias-cluster dedup integration", () => { it("recomputes total and bySeverity from deduped list", () => { // Two GHSA/RUSTSEC pairs + one solo advisory. Pre-dedup: 5; after: 3. - // Backend `vulnerabilityCount` is intentionally stale (5) so we - // verify the builder re-derives total from the deduped output. + // Backend affected count is already deduped, while the inline list + // still carries source-level duplicates. Verify both the count and + // advisory list stay internally consistent after client-side dedup. const fixture = { package: { name: "pkg", registry: "CRATES" as const, version: "0.10.0" }, security: { - vulnerabilityCount: 5, + affectedVulnerabilityCount: 3, + nonAffectingVulnerabilityCount: 0, + allVulnerabilityCount: 3, currentVersionAffected: true, upgradePaths: ["0.10.78"], vulnerabilities: [ @@ -681,7 +719,37 @@ describe("formatPackageVulnerabilitiesTerminal", () => { const output = formatPackageVulnerabilitiesTerminal(zeroVulnsFixture(), { useColors: false, }); - expect(output).toBe("clean @ 1.0.0 · npm\nNo known vulnerabilities.\n"); + expect(output).toBe( + "clean @ 1.0.0 · npm\nNo known vulnerabilities affect this version.\n", + ); + }); + + it("renders historical package advisories without marking the version vulnerable", () => { + const fixture = zeroVulnsFixture(); + if (fixture.security) { + fixture.security.nonAffectingVulnerabilityCount = 2; + fixture.security.allVulnerabilityCount = 2; + } + const output = formatPackageVulnerabilitiesTerminal(fixture, { + useColors: false, + }); + expect(output).toBe( + "clean @ 1.0.0 · npm\nNo vulnerabilities affect this version (2 historical advisories do not apply).\n", + ); + }); + + it("uses singular grammar for one historical advisory", () => { + const fixture = zeroVulnsFixture(); + if (fixture.security) { + fixture.security.nonAffectingVulnerabilityCount = 1; + fixture.security.allVulnerabilityCount = 1; + } + const output = formatPackageVulnerabilitiesTerminal(fixture, { + useColors: false, + }); + expect(output).toBe( + "clean @ 1.0.0 · npm\nNo vulnerabilities affect this version (1 historical advisory does not apply).\n", + ); }); it("renders default terminal block with header, summary, breakdown, advisories, footer", () => { @@ -690,10 +758,10 @@ describe("formatPackageVulnerabilitiesTerminal", () => { { useColors: false }, ); expect(output).toContain("express @ 4.18.0 · npm"); - expect(output).toContain("6 known vulnerabilities · latest affected"); + expect(output).toContain("6 vulnerabilities affect this version"); expect(output).toContain("MALWARE"); expect(output).toContain("GHSA-mmmm-mmmm-mmmm"); - expect(output).toContain("Upgrade to 4.18.2."); + expect(output).toContain("Fix version: 4.18.2."); }); it("shows MALWARE · crit combined label for malicious + severe advisory", () => { @@ -710,14 +778,15 @@ describe("formatPackageVulnerabilitiesTerminal", () => { it("omits breakdown line when total is 1", () => { const fixture = cloneFixture(); if (fixture.security) { - fixture.security.vulnerabilityCount = 1; + fixture.security.affectedVulnerabilityCount = 1; + fixture.security.allVulnerabilityCount = 1; fixture.security.vulnerabilities = fixture.security.vulnerabilities?.slice(2, 3) ?? []; // keep one high CVE } const output = formatPackageVulnerabilitiesTerminal(fixture, { useColors: false, }); - expect(output).toContain("1 known vulnerability"); + expect(output).toContain("1 vulnerability affects this version"); expect(output).not.toContain("1 high"); }); @@ -737,7 +806,7 @@ describe("formatPackageVulnerabilitiesTerminal", () => { expect(output).toContain("(requested v4.18.0)"); }); - it("renders Upgrade options: A, B, C. for multiple paths", () => { + it("renders Fix versions: A, B, C. for multiple paths", () => { const fixture = cloneFixture(); if (fixture.security) { fixture.security.upgradePaths = ["4.17.4", "4.18.2"]; @@ -745,16 +814,16 @@ describe("formatPackageVulnerabilitiesTerminal", () => { const output = formatPackageVulnerabilitiesTerminal(fixture, { useColors: false, }); - expect(output).toContain("Upgrade options: 4.17.4, 4.18.2."); + expect(output).toContain("Fix versions: 4.17.4, 4.18.2."); }); - it("omits upgrade footer when no paths", () => { + it("omits fix-version footer when no paths", () => { const fixture = cloneFixture(); if (fixture.security) fixture.security.upgradePaths = []; const output = formatPackageVulnerabilitiesTerminal(fixture, { useColors: false, }); - expect(output).not.toContain("Upgrade"); + expect(output).not.toContain("Fix version"); }); it("verbose adds aliases, severity, published/modified rows where applicable", () => { @@ -815,6 +884,22 @@ describe("formatPackageVulnerabilitiesTerminal", () => { expect(output).not.toContain("==1.0.4,"); }); + it("surfaces backend-truncated affected ranges in compact output", () => { + const fixture = cloneFixture(); + const advisory = fixture.security?.vulnerabilities?.[1]; + if (advisory) { + advisory.affectedVersionRanges = ["==1.0.0", "==1.0.1"]; + advisory.affectedVersionRangesCount = 5; + advisory.affectedVersionRangesTruncated = true; + } + const output = formatPackageVulnerabilitiesTerminal(fixture, { + useColors: false, + }); + expect(output).toContain( + "affected ==1.0.0, ==1.0.1, … (+3 ranges omitted by service)", + ); + }); + it("verbose mode shows every affected range without truncation", () => { const fixture = cloneFixture(); const advisory = fixture.security?.vulnerabilities?.[1]; @@ -839,14 +924,15 @@ describe("formatPackageVulnerabilitiesTerminal", () => { it("singular vulnerability noun when total is 1", () => { const fixture = cloneFixture(); if (fixture.security) { - fixture.security.vulnerabilityCount = 1; + fixture.security.affectedVulnerabilityCount = 1; + fixture.security.allVulnerabilityCount = 1; fixture.security.vulnerabilities = fixture.security.vulnerabilities?.slice(2, 3) ?? []; } const output = formatPackageVulnerabilitiesTerminal(fixture, { useColors: false, }); - expect(output).toContain("1 known vulnerability ·"); + expect(output).toContain("1 vulnerability affects this version"); }); }); diff --git a/src/shared/package-vulnerabilities-response.ts b/src/shared/package-vulnerabilities-response.ts index 24c0a32b..678e2739 100644 --- a/src/shared/package-vulnerabilities-response.ts +++ b/src/shared/package-vulnerabilities-response.ts @@ -6,10 +6,9 @@ * Key design commitments (locked in the plan): * - Backend is the single source of truth for counts. `minSeverity` * and `includeWithdrawn` are passed through on the wire; the - * backend returns a filter-aware `vulnerabilityCount`. The builder - * does no selective filtering of its own — but it does collapse - * alias-clustered duplicates (see below) because that is shape - * normalisation rather than selection. + * backend returns version-aware affected/non-affecting/all advisory + * counts. `summary.total` is the affected count for the inspected + * version, not historical package advisory volume. * - Alias-cluster dedup runs before bucketing. Some registries (most * visibly Crates) return both the GHSA-prefixed and the * RUSTSEC-prefixed entry for the same underlying vulnerability; @@ -66,6 +65,11 @@ export interface LeanAdvisory { severity?: number; severityLabel?: VulnSeverityLabel; affectedRanges?: string[]; + affectedVersionRangesCount?: number; + affectedVersionRangesTruncated?: boolean; + affectsInspectedVersion?: boolean; + matchedAffectedVersionRanges?: string[]; + duplicateIds?: string[]; fixedIn?: string[]; publishedAt?: string; modifiedAt?: string; @@ -74,7 +78,11 @@ export interface LeanAdvisory { } export interface LeanVulnerabilitySummary { + /** Affected advisories for the inspected version. Kept for compatibility. */ total: number; + affectedVulnerabilityCount?: number; + nonAffectingVulnerabilityCount?: number; + allVulnerabilityCount?: number; affected?: boolean; bySeverity?: Partial>; } @@ -110,17 +118,11 @@ export function buildPackageVulnerabilitiesSuccessPayload( // `total` and `bySeverity` are derived from the deduped list so the // partition invariant holds. // - // This recomputation is only correct while the backend returns the - // entire filtered vulnerability set inline with `vulnerabilityCount`. - // If the backend later paginates the advisory list (delivering a - // page slice while `vulnerabilityCount` keeps the global filtered - // count), `total` here will underreport — revisit at that point and - // either compute from a backend-supplied deduped count or run dedup - // server-side. const dedupedAdvisories = dedupAdvisoriesByAlias( security?.vulnerabilities ?? [], ); - const total = dedupedAdvisories.length; + const total = + security?.affectedVulnerabilityCount ?? dedupedAdvisories.length; const payload: LeanVulnerabilityReport = { registry: lowerRegistry(pkg.registry), @@ -149,8 +151,8 @@ export function buildPackageVulnerabilitiesSuccessPayload( const upgradePaths = security?.upgradePaths; if (upgradePaths && upgradePaths.length > 0) { // Ascending semver-ish order (pre-releases sort below their base - // version) so the CLI footer reads `Upgrade options: 3.11.0, 4.5.0, - // 4.19.2, …` — presenting the minimum-churn upgrade first. Without + // version) so the CLI footer reads `Fix versions: 3.11.0, 4.5.0, + // 4.19.2, …` — presenting the minimum-churn fix first. Without // this sort the backend's advisory-iteration order produced // jarring mixes like `3.11.0, 4.5.0, 4.20.0, 5.0.0, 4.0.0-rc1`. const unique = Array.from(new Set(upgradePaths)); @@ -205,12 +207,19 @@ function buildSummary( dedupedAdvisories: readonly VulnerabilityDetail[], ): LeanVulnerabilitySummary { const summary: LeanVulnerabilitySummary = { total }; - if (total === 0) return summary; + if (security) { + summary.affectedVulnerabilityCount = security.affectedVulnerabilityCount; + summary.nonAffectingVulnerabilityCount = + security.nonAffectingVulnerabilityCount; + summary.allVulnerabilityCount = security.allVulnerabilityCount; + } if (typeof security?.currentVersionAffected === "boolean") { summary.affected = security.currentVersionAffected; } + if (total === 0) return summary; + const bySeverity = computeBySeverity(dedupedAdvisories); const anyCounted = Object.values(bySeverity).some((n) => n > 0); if (anyCounted) { @@ -421,8 +430,13 @@ function mergeAdvisoryCluster( const aliases = Array.from(aliasSet).sort(); const affectedRangesSet = new Set(); + const matchedAffectedRangesSet = new Set(); const fixedInSet = new Set(); + const duplicateIdsSet = new Set(); let isMalicious = false; + let affectsInspectedVersion = false; + let affectedVersionRangesTruncated = false; + let affectedVersionRangesCount = 0; // Latest `modifiedAt` wins, but only counts entries that explicitly // carry one — a sibling whose `modifiedAt` is absent should not // promote its `publishedAt` into the merged record. @@ -441,10 +455,26 @@ function mergeAdvisoryCluster( for (const range of member.affectedVersionRanges ?? []) { affectedRangesSet.add(range); } + for (const range of member.matchedAffectedVersionRanges ?? []) { + matchedAffectedRangesSet.add(range); + } for (const fix of member.fixedInVersions ?? []) { fixedInSet.add(fix); } + for (const duplicateId of member.duplicateIds ?? []) { + duplicateIdsSet.add(duplicateId); + } if (member.isMalicious === true) isMalicious = true; + if (member.affectsInspectedVersion === true) affectsInspectedVersion = true; + if (member.affectedVersionRangesTruncated === true) { + affectedVersionRangesTruncated = true; + } + if (typeof member.affectedVersionRangesCount === "number") { + affectedVersionRangesCount = Math.max( + affectedVersionRangesCount, + member.affectedVersionRangesCount, + ); + } if (member.modifiedAt) { if (!latestModifiedAt || member.modifiedAt > latestModifiedAt) { latestModifiedAt = member.modifiedAt; @@ -480,6 +510,23 @@ function mergeAdvisoryCluster( if (affectedRangesSet.size > 0) { merged.affectedVersionRanges = Array.from(affectedRangesSet); } + const exactOrLowerBoundCount = Math.max( + affectedVersionRangesCount, + affectedRangesSet.size, + ); + if (exactOrLowerBoundCount > 0) { + merged.affectedVersionRangesCount = exactOrLowerBoundCount; + } + if (affectedVersionRangesTruncated) { + merged.affectedVersionRangesTruncated = true; + } + if (matchedAffectedRangesSet.size > 0) { + merged.matchedAffectedVersionRanges = Array.from(matchedAffectedRangesSet); + } + if (affectsInspectedVersion) merged.affectsInspectedVersion = true; + if (duplicateIdsSet.size > 0) { + merged.duplicateIds = Array.from(duplicateIdsSet).sort(); + } if (fixedInSet.size > 0) { merged.fixedInVersions = Array.from(fixedInSet); } @@ -571,6 +618,25 @@ function buildAdvisory(advisory: VulnerabilityDetail): LeanAdvisory { ) { lean.affectedRanges = advisory.affectedVersionRanges.slice(); } + if (typeof advisory.affectedVersionRangesCount === "number") { + lean.affectedVersionRangesCount = advisory.affectedVersionRangesCount; + } + if (advisory.affectedVersionRangesTruncated === true) { + lean.affectedVersionRangesTruncated = true; + } + if (typeof advisory.affectsInspectedVersion === "boolean") { + lean.affectsInspectedVersion = advisory.affectsInspectedVersion; + } + if ( + advisory.matchedAffectedVersionRanges && + advisory.matchedAffectedVersionRanges.length > 0 + ) { + lean.matchedAffectedVersionRanges = + advisory.matchedAffectedVersionRanges.slice(); + } + if (advisory.duplicateIds && advisory.duplicateIds.length > 0) { + lean.duplicateIds = advisory.duplicateIds.slice(); + } if (advisory.fixedInVersions && advisory.fixedInVersions.length > 0) { lean.fixedIn = advisory.fixedInVersions.slice(); } @@ -708,7 +774,7 @@ export function formatPackageVulnerabilitiesTerminal( if (payload.summary.total === 0) { const lines = [headerLine]; if (requestedLine) lines.push(requestedLine); - lines.push("No known vulnerabilities."); + lines.push(formatNoAffectedVulnerabilitiesLine(payload)); return `${lines.join("\n")}\n`; } @@ -747,19 +813,32 @@ function formatSummaryLine( ): string { const n = payload.summary.total; const noun = n === 1 ? "vulnerability" : "vulnerabilities"; - const base = `${n} known ${noun}`; - // Colour reflects caller risk: yellow/warn when the latest version - // is affected; plain text when clean (so "latest clean" doesn't - // read as a caution signal). + const verb = n === 1 ? "affects" : "affect"; + const base = `${n} ${noun} ${verb} this version`; + // Colour reflects caller risk: yellow/warn when the inspected version + // is affected; plain text when clean. if (payload.summary.affected === true) { - return colorize(`${base} · latest affected`, "yellow", useColors); + return colorize(base, "yellow", useColors); } if (payload.summary.affected === false) { - return `${base} · latest clean`; + return base; } return base; } +function formatNoAffectedVulnerabilitiesLine( + payload: LeanVulnerabilityReport, +): string { + const historical = payload.summary.nonAffectingVulnerabilityCount ?? 0; + if (historical > 0) { + const noun = + historical === 1 ? "historical advisory" : "historical advisories"; + const verb = historical === 1 ? "does" : "do"; + return `No vulnerabilities affect this version (${historical} ${noun} ${verb} not apply).`; + } + return "No known vulnerabilities affect this version."; +} + function formatBreakdownLine( summary: LeanVulnerabilitySummary, useColors: boolean, @@ -897,7 +976,14 @@ function formatAdvisoryLines( if (advisory.affectedRanges && advisory.affectedRanges.length > 0) { pushRow( "affected", - formatRangeList(advisory.affectedRanges, verbose, useColors, rangeLimit), + formatRangeList( + advisory.affectedRanges, + verbose, + useColors, + rangeLimit, + advisory.affectedVersionRangesCount, + advisory.affectedVersionRangesTruncated, + ), ); } if (advisory.fixedIn && advisory.fixedIn.length > 0) { @@ -940,13 +1026,33 @@ function formatRangeList( verbose: boolean, useColors: boolean, limit: number, + totalCount: number | undefined, + backendTruncated: boolean | undefined, ): string { + const actualTotal = Math.max(totalCount ?? ranges.length, ranges.length); + const backendHidden = + backendTruncated === true ? actualTotal - ranges.length : 0; + const appendBackendHint = (shown: string): string => { + if (backendHidden > 0) { + const hint = dim( + `… (+${backendHidden} ranges omitted by service)`, + useColors, + ); + return shown.length > 0 ? `${shown}, ${hint}` : hint; + } + return shown; + }; + if (verbose || ranges.length <= limit) { - return ranges.join(", "); + return appendBackendHint(ranges.join(", ")); } const shown = ranges.slice(0, limit).join(", "); - const hiddenCount = ranges.length - limit; - const hint = dim(`… (+${hiddenCount} more; use -v)`, useColors); + const localHidden = ranges.length - limit; + const hintText = + backendHidden > 0 + ? `… (+${localHidden} more with -v; +${backendHidden} omitted by service)` + : `… (+${localHidden} more; use -v)`; + const hint = dim(hintText, useColors); return `${shown}, ${hint}`; } @@ -970,6 +1076,6 @@ function resolveAffectedRangesLimit(terminalWidth: number | undefined): number { function formatUpgradeFooter(paths: string[] | undefined): string | undefined { if (!paths || paths.length === 0) return undefined; - if (paths.length === 1) return `Upgrade to ${paths[0]}.`; - return `Upgrade options: ${paths.join(", ")}.`; + if (paths.length === 1) return `Fix version: ${paths[0]}.`; + return `Fix versions: ${paths.join(", ")}.`; } diff --git a/src/tools/package-vulnerabilities-parity.test.ts b/src/tools/package-vulnerabilities-parity.test.ts index 303b5db7..f8074405 100644 --- a/src/tools/package-vulnerabilities-parity.test.ts +++ b/src/tools/package-vulnerabilities-parity.test.ts @@ -99,7 +99,9 @@ function zeroVulnsReport(): VulnerabilityReport { return { package: { name: "clean", registry: "NPM", version: "1.0.0" }, security: { - vulnerabilityCount: 0, + affectedVulnerabilityCount: 0, + nonAffectingVulnerabilityCount: 0, + allVulnerabilityCount: 0, currentVersionAffected: false, upgradePaths: [], vulnerabilities: [], @@ -154,7 +156,9 @@ describe("package_vulnerabilities parity", () => { const filteredReport: VulnerabilityReport = { package: { name: "express", registry: "NPM", version: "4.18.0" }, security: { - vulnerabilityCount: 2, + affectedVulnerabilityCount: 2, + nonAffectingVulnerabilityCount: 0, + allVulnerabilityCount: 2, currentVersionAffected: true, upgradePaths: ["4.18.2"], vulnerabilities: [critAdvisory, highAdvisory], @@ -205,7 +209,9 @@ describe("package_vulnerabilities parity", () => { const resolvedReport: VulnerabilityReport = { package: { name: "express", registry: "NPM", version: "4.17.2" }, security: { - vulnerabilityCount: 0, + affectedVulnerabilityCount: 0, + nonAffectingVulnerabilityCount: 0, + allVulnerabilityCount: 0, currentVersionAffected: false, upgradePaths: [], vulnerabilities: [], diff --git a/src/tools/package-vulnerabilities.ts b/src/tools/package-vulnerabilities.ts index 742f7b45..fc9a4f50 100644 --- a/src/tools/package-vulnerabilities.ts +++ b/src/tools/package-vulnerabilities.ts @@ -48,8 +48,7 @@ const DESCRIPTION = "Check known vulnerabilities for a package on npm, PyPI, Hex, or " + "Crates (other registries are not yet supported for vulnerability " + "data). Returns a count summary, each advisory with OSV ID, " + - "severity, affected ranges and fix versions, plus suggested " + - "upgrade paths. Malicious-package advisories surface in a separate " + + "severity, affected ranges, and fix versions. Malicious-package advisories surface in a separate " + "bucket. Pass `version` to inspect a specific release; otherwise " + "the latest is checked. Use `min_severity` to filter to a threshold " + "(`low`, `medium`, `high`, `critical`) and `include_withdrawn` to " +