@@ -8,9 +8,12 @@ import type {
88 PackageVulnerabilityInfo ,
99 VulnerabilityTreeResult ,
1010 DeprecatedPackageInfo ,
11+ OsvAffected ,
12+ OsvRange ,
1113} from '#shared/types/dependency-analysis'
1214import { mapWithConcurrency } from '#shared/utils/async'
1315import { resolveDependencyTree } from './dependency-resolver'
16+ import * as semver from 'semver'
1417
1518/** Maximum concurrent requests for fetching vulnerability details */
1619const 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+
147234function getSeverityLevel ( vuln : OsvVulnerability ) : OsvSeverityLevel {
148235 const dbSeverity = vuln . database_specific ?. severity ?. toLowerCase ( )
149236 if ( dbSeverity ) {
0 commit comments