Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
8 changes: 8 additions & 0 deletions app/components/Package/VulnerabilityTree.vue
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,14 @@ function getDepthStyle(depth: string | undefined) {
{{ vuln.id }}
</a>
<span class="truncate w-0 flex-1">{{ vuln.summary }}</span>
<NuxtLink
v-if="vuln.fixedIn"
:to="packageRoute(pkg.name, vuln.fixedIn)"
class="shrink-0 font-mono text-emerald-600 dark:text-emerald-400 hover:underline"
:title="$t('package.vulnerabilities.fixed_in_title', { version: vuln.fixedIn })"
>
→ {{ vuln.fixedIn }}
</NuxtLink>
</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 @@ -325,7 +325,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 @@ -374,7 +374,8 @@
"high": "high",
"moderate": "moderate",
"low": "low"
}
},
"fixed_in_title": "Fixed in version {version}"
},
"deprecated": {
"label": "Deprecated",
Expand Down
3 changes: 3 additions & 0 deletions i18n/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -1128,6 +1128,9 @@
}
},
"additionalProperties": false
},
"fixed_in_title": {
"type": "string"
}
},
"additionalProperties": false
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 @@ -324,7 +324,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 @@ -373,7 +373,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 @@ -373,7 +373,8 @@
"high": "high",
"moderate": "moderate",
"low": "low"
}
},
"fixed_in_title": "Fixed in version {version}"
},
"deprecated": {
"label": "Deprecated",
Expand Down
87 changes: 87 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,89 @@ function getVulnerabilityUrl(vuln: OsvVulnerability): string {
return `https://osv.dev/vulnerability/${vuln.id}`
}

/**
* Parse OSV range events into introduced/fixed pairs.
* OSV events form a timeline: [introduced, fixed, introduced, fixed, ...]
* A single range can have multiple introduced/fixed pairs representing
* periods where the vulnerability was active, was fixed, and was reintroduced.
* @see https://ossf.github.io/osv-schema/#affectedrangesevents-fields
*/
function parseRangeIntervals(range: OsvRange): Array<{ introduced: string; fixed?: string }> {
const intervals: Array<{ introduced: string; fixed?: string }> = []
let currentIntroduced: string | undefined

for (const event of range.events) {
if (event.introduced !== undefined) {
// Start a new interval (close previous open one if any)
if (currentIntroduced !== undefined) {
intervals.push({ introduced: currentIntroduced })
}
currentIntroduced = event.introduced
} else if (event.fixed !== undefined && currentIntroduced !== undefined) {
intervals.push({ introduced: currentIntroduced, fixed: event.fixed })
currentIntroduced = undefined
}
}

// Handle trailing introduced with no fixed (still vulnerable)
if (currentIntroduced !== undefined) {
intervals.push({ introduced: currentIntroduced })
}

return intervals
}

/**
* Extract the fixed version for a specific package version from vulnerability data.
* Finds all intervals that contain the current version and returns the closest fix,
* preferring a nearby backport over a distant major-version bump.
* @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,
)

// Collect all matching fixed versions across all ranges
const matchingFixedVersions: string[] = []

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

const intervals = parseRangeIntervals(range)
for (const interval of intervals) {
const introVersion = interval.introduced === '0' ? '0.0.0' : interval.introduced
try {
const afterIntro = semver.gte(currentVersion, introVersion)
const beforeFixed = !interval.fixed || semver.lt(currentVersion, interval.fixed)
if (afterIntro && beforeFixed && interval.fixed) {
matchingFixedVersions.push(interval.fixed)
}
} catch {
continue
}
}
}
}

if (matchingFixedVersions.length === 0) return undefined
if (matchingFixedVersions.length === 1) return matchingFixedVersions[0]

// Return the lowest (closest) fixed version — the smallest bump from the current version
return matchingFixedVersions.sort(semver.compare)[0]
}
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
Loading
Loading