Skip to content

Commit 7068e26

Browse files
committed
fix: handle multiple ranges + add link
1 parent 2064a9a commit 7068e26

File tree

3 files changed

+107
-25
lines changed

3 files changed

+107
-25
lines changed

app/components/Package/VulnerabilityTree.vue

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

server/utils/dependency-analysis.ts

Lines changed: 41 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -149,30 +149,41 @@ function getVulnerabilityUrl(vuln: OsvVulnerability): string {
149149
}
150150

151151
/**
152-
* Check if a version falls within an OSV range (between introduced and fixed).
153-
* OSV ranges use events: introduced starts vulnerability, fixed ends it.
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
154157
*/
155-
function isVersionInRange(version: string, range: OsvRange): boolean {
156-
const introduced = range.events.find(e => e.introduced)?.introduced
157-
const fixed = range.events.find(e => e.fixed)?.fixed
158-
159-
if (!introduced) return false
160-
161-
// Handle "0" as "0.0.0" for semver comparison
162-
const introVersion = introduced === '0' ? '0.0.0' : introduced
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+
}
163174

164-
try {
165-
// Version must be >= introduced AND < fixed (if fixed exists)
166-
return semver.gte(version, introVersion) && (!fixed || semver.lt(version, fixed))
167-
} catch {
168-
// If semver parsing fails, skip this range
169-
return false
175+
// Handle trailing introduced with no fixed (still vulnerable)
176+
if (currentIntroduced !== undefined) {
177+
intervals.push({ introduced: currentIntroduced })
170178
}
179+
180+
return intervals
171181
}
172182

173183
/**
174184
* Extract the fixed version for a specific package version from vulnerability data.
175-
* Finds the range that contains the current version and returns its fixed version.
185+
* Finds the interval that contains the current version and returns its fixed version
186+
* Finds the interval that contains the current version and returns its fixed version.
176187
* @see https://ossf.github.io/osv-schema/#affectedrangesevents-fields
177188
*/
178189
function getFixedVersion(
@@ -187,18 +198,26 @@ function getFixedVersion(
187198
a => a.package.ecosystem === 'npm' && a.package.name === packageName,
188199
)
189200

190-
// Check each entry's ranges to find one that contains the current version
201+
// Check each entry's ranges to find the interval that contains the current version
191202
for (const entry of packageAffectedEntries) {
192203
if (!entry.ranges) continue
193204

194205
for (const range of entry.ranges) {
195206
// Only handle SEMVER ranges (most common for npm)
196207
if (range.type !== 'SEMVER') continue
197208

198-
if (isVersionInRange(currentVersion, range)) {
199-
// Found the matching range - return its fixed version
200-
const fixedEvent = range.events.find(e => e.fixed)
201-
if (fixedEvent?.fixed) return fixedEvent.fixed
209+
const intervals = parseRangeIntervals(range)
210+
for (const interval of intervals) {
211+
const introVersion = interval.introduced === '0' ? '0.0.0' : interval.introduced
212+
try {
213+
const afterIntro = semver.gte(currentVersion, introVersion)
214+
const beforeFixed = !interval.fixed || semver.lt(currentVersion, interval.fixed)
215+
if (afterIntro && beforeFixed && interval.fixed) {
216+
return interval.fixed
217+
}
218+
} catch {
219+
continue
220+
}
202221
}
203222
}
204223
}

test/unit/server/utils/dependency-analysis.spec.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -721,6 +721,68 @@ describe('dependency-analysis', () => {
721721
expect(result.vulnerablePackages[0]?.vulnerabilities[0]?.fixedIn).toBe('16.0.11')
722722
})
723723

724+
it('handles multiple introduced/fixed pairs in a single range', async () => {
725+
const mockResolved = new Map([
726+
[
727+
'example@1.5.0',
728+
{
729+
name: 'example',
730+
version: '1.5.0',
731+
size: 1000,
732+
optional: false,
733+
depth: 'root' as const,
734+
path: ['example@1.5.0'],
735+
},
736+
],
737+
])
738+
vi.mocked(resolveDependencyTree).mockResolvedValue(mockResolved)
739+
740+
// Single range with multiple introduced/fixed pairs (reintroduced vulnerability)
741+
// Range: 0-0.2.1, 1.0.0-1.2.3, 1.4.0-1.6.0
742+
// Version 1.5.0 should match the third interval (1.4.0-1.6.0)
743+
mockOsvApi(
744+
[{ vulns: [{ id: 'GHSA-multi-range', modified: '2024-01-01' }] }],
745+
new Map([
746+
[
747+
'example@1.5.0',
748+
{
749+
vulns: [
750+
{
751+
id: 'GHSA-multi-range',
752+
summary: 'Multi-range vulnerability',
753+
database_specific: { severity: 'HIGH' },
754+
affected: [
755+
{
756+
package: { ecosystem: 'npm', name: 'example' },
757+
ranges: [
758+
{
759+
type: 'SEMVER',
760+
events: [
761+
{ introduced: '0' },
762+
{ fixed: '0.2.1' },
763+
{ introduced: '1.0.0' },
764+
{ fixed: '1.2.3' },
765+
{ introduced: '1.4.0' },
766+
{ fixed: '1.6.0' },
767+
],
768+
},
769+
],
770+
},
771+
],
772+
},
773+
],
774+
},
775+
],
776+
]),
777+
)
778+
779+
const result = await analyzeDependencyTree('example', '1.5.0')
780+
781+
expect(result.vulnerablePackages).toHaveLength(1)
782+
// Should match the third interval and return its fixed version
783+
expect(result.vulnerablePackages[0]?.vulnerabilities[0]?.fixedIn).toBe('1.6.0')
784+
})
785+
724786
it('returns undefined fixedIn when no matching range has a fixed version', async () => {
725787
const mockResolved = new Map([
726788
[

0 commit comments

Comments
 (0)