-
-
Notifications
You must be signed in to change notification settings - Fork 433
feat: show fixed version for vulnerabilities #967
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 4 commits
6334e74
1c63744
9300c97
0571e1d
2064a9a
7068e26
01d2bdb
89845f9
c00d347
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -8,9 +8,12 @@ import type { | |
| PackageVulnerabilityInfo, | ||
| VulnerabilityTreeResult, | ||
| DeprecatedPackageInfo, | ||
| OsvAffected, | ||
| OsvRange, | ||
| } from '#shared/types/dependency-analysis' | ||
| import { mapWithConcurrency } from '#shared/utils/async' | ||
| import { resolveDependencyTree } from './dependency-resolver' | ||
| import * as semver from 'semver' | ||
|
|
||
| /** Maximum concurrent requests for fetching vulnerability details */ | ||
| const OSV_DETAIL_CONCURRENCY = 25 | ||
|
|
@@ -115,6 +118,7 @@ async function queryOsvDetails(pkg: PackageQueryInfo): Promise<PackageVulnerabil | |
| severity, | ||
| aliases: vuln.aliases || [], | ||
| url: getVulnerabilityUrl(vuln), | ||
| fixedIn: getFixedVersion(vuln.affected, pkg.name, pkg.version), | ||
| }) | ||
| } | ||
|
|
||
|
|
@@ -144,6 +148,64 @@ function getVulnerabilityUrl(vuln: OsvVulnerability): string { | |
| return `https://osv.dev/vulnerability/${vuln.id}` | ||
| } | ||
|
|
||
| /** | ||
| * Check if a version falls within an OSV range (between introduced and fixed). | ||
| * OSV ranges use events: introduced starts vulnerability, fixed ends it. | ||
| */ | ||
| function isVersionInRange(version: string, range: OsvRange): boolean { | ||
| const introduced = range.events.find(e => e.introduced)?.introduced | ||
| const fixed = range.events.find(e => e.fixed)?.fixed | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. could there be multiple of the same kind of event for a given package?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Normally not from what I've read
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 😁 I gotchu:
https://npmx-j14rd1m0y-poetry.vercel.app/package/next/v/15.5.8 Check out GHSA-5f7q-jpqc-wp7h |
||
|
|
||
| if (!introduced) return false | ||
|
|
||
| // Handle "0" as "0.0.0" for semver comparison | ||
| const introVersion = introduced === '0' ? '0.0.0' : introduced | ||
|
|
||
| try { | ||
| // Version must be >= introduced AND < fixed (if fixed exists) | ||
| return semver.gte(version, introVersion) && (!fixed || semver.lt(version, fixed)) | ||
| } catch { | ||
| // If semver parsing fails, skip this range | ||
| return false | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Extract the fixed version for a specific package version from vulnerability data. | ||
| * Finds the range that contains the current version and returns its fixed version. | ||
| * @see https://ossf.github.io/osv-schema/#affectedrangesevents-fields | ||
| */ | ||
| function getFixedVersion( | ||
| affected: OsvAffected[] | undefined, | ||
| packageName: string, | ||
| currentVersion: string, | ||
| ): string | undefined { | ||
| if (!affected) return undefined | ||
|
|
||
| // Find all affected entries for this specific package | ||
| const packageAffectedEntries = affected.filter( | ||
| a => a.package.ecosystem === 'npm' && a.package.name === packageName, | ||
| ) | ||
|
|
||
| // Check each entry's ranges to find one that contains the current version | ||
| for (const entry of packageAffectedEntries) { | ||
| if (!entry.ranges) continue | ||
|
|
||
| for (const range of entry.ranges) { | ||
| // Only handle SEMVER ranges (most common for npm) | ||
| if (range.type !== 'SEMVER') continue | ||
|
|
||
| if (isVersionInRange(currentVersion, range)) { | ||
| // Found the matching range - return its fixed version | ||
| const fixedEvent = range.events.find(e => e.fixed) | ||
| if (fixedEvent?.fixed) return fixedEvent.fixed | ||
| } | ||
| } | ||
| } | ||
|
|
||
| return undefined | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| function getSeverityLevel(vuln: OsvVulnerability): OsvSeverityLevel { | ||
| const dbSeverity = vuln.database_specific?.severity?.toLowerCase() | ||
| if (dbSeverity) { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -595,6 +595,174 @@ describe('dependency-analysis', () => { | |
| expect(result.deprecatedPackages[2]?.depth).toBe('transitive') | ||
| }) | ||
|
|
||
| it('extracts correct fixedIn version for the current version range', async () => { | ||
| const mockResolved = new Map([ | ||
| [ | ||
| '[email protected]', | ||
| { | ||
| name: 'minimist', | ||
| version: '1.0.0', | ||
| size: 1000, | ||
| optional: false, | ||
| depth: 'root' as const, | ||
| path: ['[email protected]'], | ||
| }, | ||
| ], | ||
| ]) | ||
| vi.mocked(resolveDependencyTree).mockResolvedValue(mockResolved) | ||
|
|
||
| // Mock OSV response with multiple affected ranges (like minimist) | ||
| // Range 1: 0 - 0.2.1, Range 2: 1.0.0 - 1.2.3 | ||
| // Version 1.0.0 should match Range 2, so fixedIn should be 1.2.3 | ||
| mockOsvApi( | ||
| [{ vulns: [{ id: 'GHSA-vh95-rmgr-6w4m', modified: '2024-01-01' }] }], | ||
| new Map([ | ||
| [ | ||
| '[email protected]', | ||
| { | ||
| vulns: [ | ||
| { | ||
| id: 'GHSA-vh95-rmgr-6w4m', | ||
| summary: 'Prototype Pollution in minimist', | ||
| database_specific: { severity: 'MODERATE' }, | ||
| affected: [ | ||
| { | ||
| package: { ecosystem: 'npm', name: 'minimist' }, | ||
| ranges: [ | ||
| { | ||
| type: 'SEMVER', | ||
| events: [{ introduced: '0' }, { fixed: '0.2.1' }], | ||
| }, | ||
| ], | ||
| }, | ||
| { | ||
| package: { ecosystem: 'npm', name: 'minimist' }, | ||
| ranges: [ | ||
| { | ||
| type: 'SEMVER', | ||
| events: [{ introduced: '1.0.0' }, { fixed: '1.2.3' }], | ||
| }, | ||
| ], | ||
| }, | ||
| ], | ||
| }, | ||
| ], | ||
| }, | ||
| ], | ||
| ]), | ||
| ) | ||
|
|
||
| const result = await analyzeDependencyTree('minimist', '1.0.0') | ||
|
|
||
| expect(result.vulnerablePackages).toHaveLength(1) | ||
| expect(result.vulnerablePackages[0]?.vulnerabilities[0]?.fixedIn).toBe('1.2.3') | ||
| }) | ||
|
|
||
| it('extracts correct fixedIn for prerelease versions (e.g., 16.0.0-beta.0)', async () => { | ||
| const mockResolved = new Map([ | ||
| [ | ||
| '[email protected]', | ||
| { | ||
| name: 'next', | ||
| version: '16.0.0-beta.0', | ||
| size: 1000, | ||
| optional: false, | ||
| depth: 'root' as const, | ||
| path: ['[email protected]'], | ||
| }, | ||
| ], | ||
| ]) | ||
| vi.mocked(resolveDependencyTree).mockResolvedValue(mockResolved) | ||
|
|
||
| // Mock OSV response with multiple ranges including prerelease | ||
| // Version 16.0.0-beta.0 should NOT match 13.0.0-15.0.8, but SHOULD match 16.0.0-beta.0-16.0.11 | ||
| mockOsvApi( | ||
| [{ vulns: [{ id: 'GHSA-test', modified: '2024-01-01' }] }], | ||
| new Map([ | ||
| [ | ||
| '[email protected]', | ||
| { | ||
| vulns: [ | ||
| { | ||
| id: 'GHSA-test', | ||
| summary: 'Test vulnerability', | ||
| database_specific: { severity: 'HIGH' }, | ||
| affected: [ | ||
| { | ||
| package: { ecosystem: 'npm', name: 'next' }, | ||
| ranges: [ | ||
| { | ||
| type: 'SEMVER', | ||
| events: [{ introduced: '13.0.0' }, { fixed: '15.0.8' }], | ||
| }, | ||
| ], | ||
| }, | ||
| { | ||
| package: { ecosystem: 'npm', name: 'next' }, | ||
| ranges: [ | ||
| { | ||
| type: 'SEMVER', | ||
| events: [{ introduced: '16.0.0-beta.0' }, { fixed: '16.0.11' }], | ||
| }, | ||
| ], | ||
| }, | ||
| ], | ||
| }, | ||
| ], | ||
| }, | ||
| ], | ||
| ]), | ||
| ) | ||
|
|
||
| const result = await analyzeDependencyTree('next', '16.0.0-beta.0') | ||
|
|
||
| expect(result.vulnerablePackages).toHaveLength(1) | ||
| // Should match the 16.x range, not the 13-15 range | ||
| expect(result.vulnerablePackages[0]?.vulnerabilities[0]?.fixedIn).toBe('16.0.11') | ||
| }) | ||
|
|
||
| it('returns undefined fixedIn when no matching range has a fixed version', async () => { | ||
| const mockResolved = new Map([ | ||
| [ | ||
| '[email protected]', | ||
| { | ||
| name: 'pkg', | ||
| version: '1.0.0', | ||
| size: 1000, | ||
| optional: false, | ||
| depth: 'root' as const, | ||
| path: ['[email protected]'], | ||
| }, | ||
| ], | ||
| ]) | ||
| vi.mocked(resolveDependencyTree).mockResolvedValue(mockResolved) | ||
|
|
||
| // Mock OSV response without affected data | ||
| mockOsvApi( | ||
| [{ vulns: [{ id: 'GHSA-no-fix', modified: '2024-01-01' }] }], | ||
| new Map([ | ||
| [ | ||
| '[email protected]', | ||
| { | ||
| vulns: [ | ||
| { | ||
| id: 'GHSA-no-fix', | ||
| summary: 'Vuln without fix info', | ||
| database_specific: { severity: 'LOW' }, | ||
| // No affected field | ||
| }, | ||
| ], | ||
| }, | ||
| ], | ||
| ]), | ||
| ) | ||
|
|
||
| const result = await analyzeDependencyTree('pkg', '1.0.0') | ||
|
|
||
| expect(result.vulnerablePackages).toHaveLength(1) | ||
| expect(result.vulnerablePackages[0]?.vulnerabilities[0]?.fixedIn).toBeUndefined() | ||
| }) | ||
|
|
||
| it('returns both vulnerabilities and deprecated packages together', async () => { | ||
| const mockResolved = new Map([ | ||
| [ | ||
|
|
||

Uh oh!
There was an error while loading. Please reload this page.