Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions app/components/Package/VulnerabilityTree.vue
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,13 @@ function getDepthStyle(depth: string | undefined) {
{{ vuln.id }}
</a>
<span class="truncate w-0 flex-1">{{ vuln.summary }}</span>
<span
v-if="vuln.fixedIn"
class="shrink-0 font-mono text-emerald-600 dark:text-emerald-400"
:title="$t('package.vulnerabilities.fixed_in_title', { version: vuln.fixedIn })"
>
→ {{ vuln.fixedIn }}
</span>
Comment thread
danielroe marked this conversation as resolved.
Outdated
</li>
<li
v-if="pkg.vulnerabilities.length > 2 && !showAllVulnerabilities"
Expand Down
3 changes: 2 additions & 1 deletion i18n/locales/de-DE.json
Original file line number Diff line number Diff line change
Expand Up @@ -353,7 +353,8 @@
"high": "Hoch",
"moderate": "Mittel",
"low": "Niedrig"
}
},
"fixed_in_title": "Behoben in Version {version}"
},
"deprecated": {
"label": "Veraltet",
Expand Down
3 changes: 2 additions & 1 deletion i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -359,7 +359,8 @@
"high": "high",
"moderate": "moderate",
"low": "low"
}
},
"fixed_in_title": "Fixed in version {version}"
},
"deprecated": {
"label": "Deprecated",
Expand Down
3 changes: 2 additions & 1 deletion lunaria/files/de-DE.json
Original file line number Diff line number Diff line change
Expand Up @@ -353,7 +353,8 @@
"high": "Hoch",
"moderate": "Mittel",
"low": "Niedrig"
}
},
"fixed_in_title": "Behoben in Version {version}"
},
"deprecated": {
"label": "Veraltet",
Expand Down
3 changes: 2 additions & 1 deletion lunaria/files/en-GB.json
Original file line number Diff line number Diff line change
Expand Up @@ -359,7 +359,8 @@
"high": "high",
"moderate": "moderate",
"low": "low"
}
},
"fixed_in_title": "Fixed in version {version}"
},
"deprecated": {
"label": "Deprecated",
Expand Down
3 changes: 2 additions & 1 deletion lunaria/files/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -359,7 +359,8 @@
"high": "high",
"moderate": "moderate",
"low": "low"
}
},
"fixed_in_title": "Fixed in version {version}"
},
"deprecated": {
"label": "Deprecated",
Expand Down
62 changes: 62 additions & 0 deletions server/utils/dependency-analysis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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),
})
}

Expand Down Expand Up @@ -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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Normally not from what I've read

Copy link
Copy Markdown
Member

@serhalp serhalp Feb 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.


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
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

function getSeverityLevel(vuln: OsvVulnerability): OsvSeverityLevel {
const dbSeverity = vuln.database_specific?.severity?.toLowerCase()
if (dbSeverity) {
Expand Down
34 changes: 34 additions & 0 deletions shared/types/dependency-analysis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,37 @@ export interface OsvReference {
url: string
}

/**
* Version range event from OSV affected data
* @see https://ossf.github.io/osv-schema/#affectedrangesevents-fields
*/
export interface OsvRangeEvent {
introduced?: string
fixed?: string
last_affected?: string
limit?: string
}

/**
* Version range from OSV affected data
*/
export interface OsvRange {
type: 'SEMVER' | 'ECOSYSTEM' | 'GIT'
events: OsvRangeEvent[]
}

/**
* Affected package info from OSV
*/
export interface OsvAffected {
package: {
ecosystem: string
name: string
}
ranges?: OsvRange[]
versions?: string[]
}

/**
* Individual vulnerability record from OSV
*/
Expand All @@ -48,6 +79,7 @@ export interface OsvVulnerability {
published?: string
severity?: OsvSeverity[]
references?: OsvReference[]
affected?: OsvAffected[]
database_specific?: {
severity?: string
cwe_ids?: string[]
Expand Down Expand Up @@ -97,6 +129,8 @@ export interface VulnerabilitySummary {
severity: OsvSeverityLevel
aliases: string[]
url: string
/** Version that fixes this vulnerability (if known) */
fixedIn?: string
}

/**
Expand Down
168 changes: 168 additions & 0 deletions test/unit/server/utils/dependency-analysis.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([
[
Expand Down
Loading