[READ-ONLY] a fast, modern browser for the npm registry
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: show fixed version for vulnerabilities (#967)

authored by

Florian Heuberger and committed by
GitHub
14449ea6 93ddda3c

+440 -5
+8
app/components/Package/VulnerabilityTree.vue
··· 158 158 {{ vuln.id }} 159 159 </a> 160 160 <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> 161 169 </li> 162 170 <li 163 171 v-if="pkg.vulnerabilities.length > 2 && !showAllVulnerabilities"
+2 -1
i18n/locales/de-DE.json
··· 365 365 "high": "Hoch", 366 366 "moderate": "Mittel", 367 367 "low": "Niedrig" 368 - } 368 + }, 369 + "fixed_in_title": "Behoben in Version {version}" 369 370 }, 370 371 "deprecated": { 371 372 "label": "Veraltet",
+2 -1
i18n/locales/en.json
··· 384 384 "high": "high", 385 385 "moderate": "moderate", 386 386 "low": "low" 387 - } 387 + }, 388 + "fixed_in_title": "Fixed in version {version}" 388 389 }, 389 390 "deprecated": { 390 391 "label": "Deprecated",
+3
i18n/schema.json
··· 1158 1158 } 1159 1159 }, 1160 1160 "additionalProperties": false 1161 + }, 1162 + "fixed_in_title": { 1163 + "type": "string" 1161 1164 } 1162 1165 }, 1163 1166 "additionalProperties": false
+2 -1
lunaria/files/de-DE.json
··· 364 364 "high": "Hoch", 365 365 "moderate": "Mittel", 366 366 "low": "Niedrig" 367 - } 367 + }, 368 + "fixed_in_title": "Behoben in Version {version}" 368 369 }, 369 370 "deprecated": { 370 371 "label": "Veraltet",
+2 -1
lunaria/files/en-GB.json
··· 383 383 "high": "high", 384 384 "moderate": "moderate", 385 385 "low": "low" 386 - } 386 + }, 387 + "fixed_in_title": "Fixed in version {version}" 387 388 }, 388 389 "deprecated": { 389 390 "label": "Deprecated",
+2 -1
lunaria/files/en-US.json
··· 383 383 "high": "high", 384 384 "moderate": "moderate", 385 385 "low": "low" 386 - } 386 + }, 387 + "fixed_in_title": "Fixed in version {version}" 387 388 }, 388 389 "deprecated": { 389 390 "label": "Deprecated",
+87
server/utils/dependency-analysis.ts
··· 8 8 PackageVulnerabilityInfo, 9 9 VulnerabilityTreeResult, 10 10 DeprecatedPackageInfo, 11 + OsvAffected, 12 + OsvRange, 11 13 } from '#shared/types/dependency-analysis' 12 14 import { mapWithConcurrency } from '#shared/utils/async' 13 15 import { resolveDependencyTree } from './dependency-resolver' 16 + import * as semver from 'semver' 14 17 15 18 /** Maximum concurrent requests for fetching vulnerability details */ 16 19 const OSV_DETAIL_CONCURRENCY = 25 ··· 115 118 severity, 116 119 aliases: vuln.aliases || [], 117 120 url: getVulnerabilityUrl(vuln), 121 + fixedIn: getFixedVersion(vuln.affected, pkg.name, pkg.version), 118 122 }) 119 123 } 120 124 ··· 142 146 return `https://nvd.nist.gov/vuln/detail/${cveAlias}` 143 147 } 144 148 return `https://osv.dev/vulnerability/${vuln.id}` 149 + } 150 + 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] 145 232 } 146 233 147 234 function getSeverityLevel(vuln: OsvVulnerability): OsvSeverityLevel {
+34
shared/types/dependency-analysis.ts
··· 37 37 } 38 38 39 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 + 70 + /** 40 71 * Individual vulnerability record from OSV 41 72 */ 42 73 export interface OsvVulnerability { ··· 48 79 published?: string 49 80 severity?: OsvSeverity[] 50 81 references?: OsvReference[] 82 + affected?: OsvAffected[] 51 83 database_specific?: { 52 84 severity?: string 53 85 cwe_ids?: string[] ··· 97 129 severity: OsvSeverityLevel 98 130 aliases: string[] 99 131 url: string 132 + /** Version that fixes this vulnerability (if known) */ 133 + fixedIn?: string 100 134 } 101 135 102 136 /**
+298
test/unit/server/utils/dependency-analysis.spec.ts
··· 595 595 expect(result.deprecatedPackages[2]?.depth).toBe('transitive') 596 596 }) 597 597 598 + it('extracts correct fixedIn version for the current version range', async () => { 599 + const mockResolved = new Map([ 600 + [ 601 + 'minimist@1.0.0', 602 + { 603 + name: 'minimist', 604 + version: '1.0.0', 605 + size: 1000, 606 + optional: false, 607 + depth: 'root' as const, 608 + path: ['minimist@1.0.0'], 609 + }, 610 + ], 611 + ]) 612 + vi.mocked(resolveDependencyTree).mockResolvedValue(mockResolved) 613 + 614 + // Mock OSV response with multiple affected ranges (like minimist) 615 + // Range 1: 0 - 0.2.1, Range 2: 1.0.0 - 1.2.3 616 + // Version 1.0.0 should match Range 2, so fixedIn should be 1.2.3 617 + mockOsvApi( 618 + [{ vulns: [{ id: 'GHSA-vh95-rmgr-6w4m', modified: '2024-01-01' }] }], 619 + new Map([ 620 + [ 621 + 'minimist@1.0.0', 622 + { 623 + vulns: [ 624 + { 625 + id: 'GHSA-vh95-rmgr-6w4m', 626 + summary: 'Prototype Pollution in minimist', 627 + database_specific: { severity: 'MODERATE' }, 628 + affected: [ 629 + { 630 + package: { ecosystem: 'npm', name: 'minimist' }, 631 + ranges: [ 632 + { 633 + type: 'SEMVER', 634 + events: [{ introduced: '0' }, { fixed: '0.2.1' }], 635 + }, 636 + ], 637 + }, 638 + { 639 + package: { ecosystem: 'npm', name: 'minimist' }, 640 + ranges: [ 641 + { 642 + type: 'SEMVER', 643 + events: [{ introduced: '1.0.0' }, { fixed: '1.2.3' }], 644 + }, 645 + ], 646 + }, 647 + ], 648 + }, 649 + ], 650 + }, 651 + ], 652 + ]), 653 + ) 654 + 655 + const result = await analyzeDependencyTree('minimist', '1.0.0') 656 + 657 + expect(result.vulnerablePackages).toHaveLength(1) 658 + expect(result.vulnerablePackages[0]?.vulnerabilities[0]?.fixedIn).toBe('1.2.3') 659 + }) 660 + 661 + it('extracts correct fixedIn for prerelease versions (e.g., 16.0.0-beta.0)', async () => { 662 + const mockResolved = new Map([ 663 + [ 664 + 'next@16.0.0-beta.0', 665 + { 666 + name: 'next', 667 + version: '16.0.0-beta.0', 668 + size: 1000, 669 + optional: false, 670 + depth: 'root' as const, 671 + path: ['next@16.0.0-beta.0'], 672 + }, 673 + ], 674 + ]) 675 + vi.mocked(resolveDependencyTree).mockResolvedValue(mockResolved) 676 + 677 + // Mock OSV response with multiple ranges including prerelease 678 + // 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 679 + mockOsvApi( 680 + [{ vulns: [{ id: 'GHSA-test', modified: '2024-01-01' }] }], 681 + new Map([ 682 + [ 683 + 'next@16.0.0-beta.0', 684 + { 685 + vulns: [ 686 + { 687 + id: 'GHSA-test', 688 + summary: 'Test vulnerability', 689 + database_specific: { severity: 'HIGH' }, 690 + affected: [ 691 + { 692 + package: { ecosystem: 'npm', name: 'next' }, 693 + ranges: [ 694 + { 695 + type: 'SEMVER', 696 + events: [{ introduced: '13.0.0' }, { fixed: '15.0.8' }], 697 + }, 698 + ], 699 + }, 700 + { 701 + package: { ecosystem: 'npm', name: 'next' }, 702 + ranges: [ 703 + { 704 + type: 'SEMVER', 705 + events: [{ introduced: '16.0.0-beta.0' }, { fixed: '16.0.11' }], 706 + }, 707 + ], 708 + }, 709 + ], 710 + }, 711 + ], 712 + }, 713 + ], 714 + ]), 715 + ) 716 + 717 + const result = await analyzeDependencyTree('next', '16.0.0-beta.0') 718 + 719 + expect(result.vulnerablePackages).toHaveLength(1) 720 + // Should match the 16.x range, not the 13-15 range 721 + expect(result.vulnerablePackages[0]?.vulnerabilities[0]?.fixedIn).toBe('16.0.11') 722 + }) 723 + 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 + 786 + it('suggests closest fixedIn when multiple ranges match (backport fix preferred)', async () => { 787 + const mockResolved = new Map([ 788 + [ 789 + 'example@3.4.6', 790 + { 791 + name: 'example', 792 + version: '3.4.6', 793 + size: 1000, 794 + optional: false, 795 + depth: 'root' as const, 796 + path: ['example@3.4.6'], 797 + }, 798 + ], 799 + ]) 800 + vi.mocked(resolveDependencyTree).mockResolvedValue(mockResolved) 801 + 802 + // Two affected ranges: 803 + // Range 1 (broad): >= 3.4.5-foo.2, < 3.5.9-foo.15 (fix: 3.5.9-foo.15) 804 + // Range 2 (narrow backport): >= 3.4.5-foo.2, < 3.4.8 (fix: 3.4.8) 805 + // 806 + // Version 3.4.6 falls in BOTH ranges. 807 + // The broad range is listed first, so the current early-return picks 3.5.9-foo.15. 808 + // But 3.4.8 is the closer/more appropriate fix for someone on 3.4.x. 809 + mockOsvApi( 810 + [{ vulns: [{ id: 'GHSA-backport', modified: '2024-01-01' }] }], 811 + new Map([ 812 + [ 813 + 'example@3.4.6', 814 + { 815 + vulns: [ 816 + { 817 + id: 'GHSA-backport', 818 + summary: 'Vulnerability with backported fix', 819 + database_specific: { severity: 'HIGH' }, 820 + affected: [ 821 + { 822 + package: { ecosystem: 'npm', name: 'example' }, 823 + ranges: [ 824 + { 825 + type: 'SEMVER', 826 + events: [{ introduced: '3.4.5-foo.2' }, { fixed: '3.5.9-foo.15' }], 827 + }, 828 + ], 829 + }, 830 + { 831 + package: { ecosystem: 'npm', name: 'example' }, 832 + ranges: [ 833 + { 834 + type: 'SEMVER', 835 + events: [{ introduced: '3.4.5-foo.2' }, { fixed: '3.4.8' }], 836 + }, 837 + ], 838 + }, 839 + ], 840 + }, 841 + ], 842 + }, 843 + ], 844 + ]), 845 + ) 846 + 847 + const result = await analyzeDependencyTree('example', '3.4.6') 848 + 849 + expect(result.vulnerablePackages).toHaveLength(1) 850 + // Should suggest 3.4.8 (the closest fix), not 3.5.9-foo.15 851 + expect(result.vulnerablePackages[0]?.vulnerabilities[0]?.fixedIn).toBe('3.4.8') 852 + }) 853 + 854 + it('returns undefined fixedIn when no matching range has a fixed version', async () => { 855 + const mockResolved = new Map([ 856 + [ 857 + 'pkg@1.0.0', 858 + { 859 + name: 'pkg', 860 + version: '1.0.0', 861 + size: 1000, 862 + optional: false, 863 + depth: 'root' as const, 864 + path: ['pkg@1.0.0'], 865 + }, 866 + ], 867 + ]) 868 + vi.mocked(resolveDependencyTree).mockResolvedValue(mockResolved) 869 + 870 + // Mock OSV response without affected data 871 + mockOsvApi( 872 + [{ vulns: [{ id: 'GHSA-no-fix', modified: '2024-01-01' }] }], 873 + new Map([ 874 + [ 875 + 'pkg@1.0.0', 876 + { 877 + vulns: [ 878 + { 879 + id: 'GHSA-no-fix', 880 + summary: 'Vuln without fix info', 881 + database_specific: { severity: 'LOW' }, 882 + // No affected field 883 + }, 884 + ], 885 + }, 886 + ], 887 + ]), 888 + ) 889 + 890 + const result = await analyzeDependencyTree('pkg', '1.0.0') 891 + 892 + expect(result.vulnerablePackages).toHaveLength(1) 893 + expect(result.vulnerablePackages[0]?.vulnerabilities[0]?.fixedIn).toBeUndefined() 894 + }) 895 + 598 896 it('returns both vulnerabilities and deprecated packages together', async () => { 599 897 const mockResolved = new Map([ 600 898 [