Skip to content

Commit 14449ea

Browse files
authored
feat: show fixed version for vulnerabilities (#967)
1 parent 93ddda3 commit 14449ea

File tree

10 files changed

+440
-5
lines changed

10 files changed

+440
-5
lines changed

app/components/Package/VulnerabilityTree.vue

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,14 @@ function getDepthStyle(depth: string | undefined) {
158158
{{ vuln.id }}
159159
</a>
160160
<span class="truncate w-0 flex-1">{{ vuln.summary }}</span>
161+
<NuxtLink
162+
v-if="vuln.fixedIn"
163+
:to="packageRoute(pkg.name, vuln.fixedIn)"
164+
class="shrink-0 font-mono text-emerald-600 dark:text-emerald-400 hover:underline"
165+
:title="$t('package.vulnerabilities.fixed_in_title', { version: vuln.fixedIn })"
166+
>
167+
→ {{ vuln.fixedIn }}
168+
</NuxtLink>
161169
</li>
162170
<li
163171
v-if="pkg.vulnerabilities.length > 2 && !showAllVulnerabilities"

i18n/locales/de-DE.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -365,7 +365,8 @@
365365
"high": "Hoch",
366366
"moderate": "Mittel",
367367
"low": "Niedrig"
368-
}
368+
},
369+
"fixed_in_title": "Behoben in Version {version}"
369370
},
370371
"deprecated": {
371372
"label": "Veraltet",

i18n/locales/en.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -384,7 +384,8 @@
384384
"high": "high",
385385
"moderate": "moderate",
386386
"low": "low"
387-
}
387+
},
388+
"fixed_in_title": "Fixed in version {version}"
388389
},
389390
"deprecated": {
390391
"label": "Deprecated",

i18n/schema.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1158,6 +1158,9 @@
11581158
}
11591159
},
11601160
"additionalProperties": false
1161+
},
1162+
"fixed_in_title": {
1163+
"type": "string"
11611164
}
11621165
},
11631166
"additionalProperties": false

lunaria/files/de-DE.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -364,7 +364,8 @@
364364
"high": "Hoch",
365365
"moderate": "Mittel",
366366
"low": "Niedrig"
367-
}
367+
},
368+
"fixed_in_title": "Behoben in Version {version}"
368369
},
369370
"deprecated": {
370371
"label": "Veraltet",

lunaria/files/en-GB.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -383,7 +383,8 @@
383383
"high": "high",
384384
"moderate": "moderate",
385385
"low": "low"
386-
}
386+
},
387+
"fixed_in_title": "Fixed in version {version}"
387388
},
388389
"deprecated": {
389390
"label": "Deprecated",

lunaria/files/en-US.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -383,7 +383,8 @@
383383
"high": "high",
384384
"moderate": "moderate",
385385
"low": "low"
386-
}
386+
},
387+
"fixed_in_title": "Fixed in version {version}"
387388
},
388389
"deprecated": {
389390
"label": "Deprecated",

server/utils/dependency-analysis.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,12 @@ import type {
88
PackageVulnerabilityInfo,
99
VulnerabilityTreeResult,
1010
DeprecatedPackageInfo,
11+
OsvAffected,
12+
OsvRange,
1113
} from '#shared/types/dependency-analysis'
1214
import { mapWithConcurrency } from '#shared/utils/async'
1315
import { resolveDependencyTree } from './dependency-resolver'
16+
import * as semver from 'semver'
1417

1518
/** Maximum concurrent requests for fetching vulnerability details */
1619
const OSV_DETAIL_CONCURRENCY = 25
@@ -115,6 +118,7 @@ async function queryOsvDetails(pkg: PackageQueryInfo): Promise<PackageVulnerabil
115118
severity,
116119
aliases: vuln.aliases || [],
117120
url: getVulnerabilityUrl(vuln),
121+
fixedIn: getFixedVersion(vuln.affected, pkg.name, pkg.version),
118122
})
119123
}
120124

@@ -144,6 +148,89 @@ function getVulnerabilityUrl(vuln: OsvVulnerability): string {
144148
return `https://osv.dev/vulnerability/${vuln.id}`
145149
}
146150

151+
/**
152+
* Parse OSV range events into introduced/fixed pairs.
153+
* OSV events form a timeline: [introduced, fixed, introduced, fixed, ...]
154+
* A single range can have multiple introduced/fixed pairs representing
155+
* periods where the vulnerability was active, was fixed, and was reintroduced.
156+
* @see https://ossf.github.io/osv-schema/#affectedrangesevents-fields
157+
*/
158+
function parseRangeIntervals(range: OsvRange): Array<{ introduced: string; fixed?: string }> {
159+
const intervals: Array<{ introduced: string; fixed?: string }> = []
160+
let currentIntroduced: string | undefined
161+
162+
for (const event of range.events) {
163+
if (event.introduced !== undefined) {
164+
// Start a new interval (close previous open one if any)
165+
if (currentIntroduced !== undefined) {
166+
intervals.push({ introduced: currentIntroduced })
167+
}
168+
currentIntroduced = event.introduced
169+
} else if (event.fixed !== undefined && currentIntroduced !== undefined) {
170+
intervals.push({ introduced: currentIntroduced, fixed: event.fixed })
171+
currentIntroduced = undefined
172+
}
173+
}
174+
175+
// Handle trailing introduced with no fixed (still vulnerable)
176+
if (currentIntroduced !== undefined) {
177+
intervals.push({ introduced: currentIntroduced })
178+
}
179+
180+
return intervals
181+
}
182+
183+
/**
184+
* Extract the fixed version for a specific package version from vulnerability data.
185+
* Finds all intervals that contain the current version and returns the closest fix,
186+
* preferring a nearby backport over a distant major-version bump.
187+
* @see https://ossf.github.io/osv-schema/#affectedrangesevents-fields
188+
*/
189+
function getFixedVersion(
190+
affected: OsvAffected[] | undefined,
191+
packageName: string,
192+
currentVersion: string,
193+
): string | undefined {
194+
if (!affected) return undefined
195+
196+
// Find all affected entries for this specific package
197+
const packageAffectedEntries = affected.filter(
198+
a => a.package.ecosystem === 'npm' && a.package.name === packageName,
199+
)
200+
201+
// Collect all matching fixed versions across all ranges
202+
const matchingFixedVersions: string[] = []
203+
204+
for (const entry of packageAffectedEntries) {
205+
if (!entry.ranges) continue
206+
207+
for (const range of entry.ranges) {
208+
// Only handle SEMVER ranges (most common for npm)
209+
if (range.type !== 'SEMVER') continue
210+
211+
const intervals = parseRangeIntervals(range)
212+
for (const interval of intervals) {
213+
const introVersion = interval.introduced === '0' ? '0.0.0' : interval.introduced
214+
try {
215+
const afterIntro = semver.gte(currentVersion, introVersion)
216+
const beforeFixed = !interval.fixed || semver.lt(currentVersion, interval.fixed)
217+
if (afterIntro && beforeFixed && interval.fixed) {
218+
matchingFixedVersions.push(interval.fixed)
219+
}
220+
} catch {
221+
continue
222+
}
223+
}
224+
}
225+
}
226+
227+
if (matchingFixedVersions.length === 0) return undefined
228+
if (matchingFixedVersions.length === 1) return matchingFixedVersions[0]
229+
230+
// Return the lowest (closest) fixed version — the smallest bump from the current version
231+
return matchingFixedVersions.sort(semver.compare)[0]
232+
}
233+
147234
function getSeverityLevel(vuln: OsvVulnerability): OsvSeverityLevel {
148235
const dbSeverity = vuln.database_specific?.severity?.toLowerCase()
149236
if (dbSeverity) {

shared/types/dependency-analysis.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,37 @@ export interface OsvReference {
3636
url: string
3737
}
3838

39+
/**
40+
* Version range event from OSV affected data
41+
* @see https://ossf.github.io/osv-schema/#affectedrangesevents-fields
42+
*/
43+
export interface OsvRangeEvent {
44+
introduced?: string
45+
fixed?: string
46+
last_affected?: string
47+
limit?: string
48+
}
49+
50+
/**
51+
* Version range from OSV affected data
52+
*/
53+
export interface OsvRange {
54+
type: 'SEMVER' | 'ECOSYSTEM' | 'GIT'
55+
events: OsvRangeEvent[]
56+
}
57+
58+
/**
59+
* Affected package info from OSV
60+
*/
61+
export interface OsvAffected {
62+
package: {
63+
ecosystem: string
64+
name: string
65+
}
66+
ranges?: OsvRange[]
67+
versions?: string[]
68+
}
69+
3970
/**
4071
* Individual vulnerability record from OSV
4172
*/
@@ -48,6 +79,7 @@ export interface OsvVulnerability {
4879
published?: string
4980
severity?: OsvSeverity[]
5081
references?: OsvReference[]
82+
affected?: OsvAffected[]
5183
database_specific?: {
5284
severity?: string
5385
cwe_ids?: string[]
@@ -97,6 +129,8 @@ export interface VulnerabilitySummary {
97129
severity: OsvSeverityLevel
98130
aliases: string[]
99131
url: string
132+
/** Version that fixes this vulnerability (if known) */
133+
fixedIn?: string
100134
}
101135

102136
/**

0 commit comments

Comments
 (0)