From e0eee0700a698693193d1f805027ee744dcd3558 Mon Sep 17 00:00:00 2001 From: stijnpotters Date: Wed, 22 Jul 2026 08:56:55 +0200 Subject: [PATCH 1/4] fix: included package name, path and other metadata in the trivy scan and displayed it in the cve-overview for details --- .../cve-overview-item.component.spec.ts | 40 +++- .../cve-overview-right-panel.component.html | 25 ++- .../cve-overview-right-panel.component.scss | 43 ++++ ...cve-overview-right-panel.component.spec.ts | 37 ++- .../cve-overview-right-panel.component.ts | 10 +- .../cve-overview.component.spec.ts | 12 +- .../src/app/services/vulnerability.service.ts | 12 +- .../ReleaseVulnerability.java | 16 +- .../ReleaseVulnerabilityId.java | 5 - .../ReleaseVulnerabilityRepository.java | 3 +- .../vulnerability/TrivyOccurrence.java | 3 + .../vulnerability/TrivyVulnerability.java | 14 +- .../VulnerabilityDetailResponse.java | 2 +- .../VulnerabilityOccurrenceResponse.java | 12 + .../vulnerability/VulnerabilityService.java | 133 +++++++---- ...__Add_Vulnerability_Occurrence_Columns.sql | 17 ++ .../VulnerabilityServiceTest.java | 211 +++++++++++++++--- 17 files changed, 488 insertions(+), 107 deletions(-) delete mode 100644 src/main/java/org/frankframework/insights/common/entityconnection/releasevulnerability/ReleaseVulnerabilityId.java create mode 100644 src/main/java/org/frankframework/insights/vulnerability/TrivyOccurrence.java create mode 100644 src/main/java/org/frankframework/insights/vulnerability/VulnerabilityOccurrenceResponse.java create mode 100644 src/main/resources/db/migration/V1.12__Add_Vulnerability_Occurrence_Columns.sql diff --git a/src/main/frontend/src/app/pages/cve-overview/cve-overview-left-panel/cve-overview-item/cve-overview-item.component.spec.ts b/src/main/frontend/src/app/pages/cve-overview/cve-overview-left-panel/cve-overview-item/cve-overview-item.component.spec.ts index ec39dded..122ec26f 100644 --- a/src/main/frontend/src/app/pages/cve-overview/cve-overview-left-panel/cve-overview-item/cve-overview-item.component.spec.ts +++ b/src/main/frontend/src/app/pages/cve-overview/cve-overview-left-panel/cve-overview-item/cve-overview-item.component.spec.ts @@ -15,7 +15,17 @@ const makeDetail = (overrides: Partial = {}): Vulnerability publishedAt: null, }, affectedReleasesByBranch: { - '9.0': [{ id: 'r1', tagName: 'v9.0.1', name: '9.0.1', publishedAt: '2024-01-01', branch: { id: 'b1', name: 'release/9.0' } }], + '9.0': [ + { + release: { id: 'r1', tagName: 'v9.0.1', name: '9.0.1', publishedAt: '2024-01-01', branch: { id: 'b1', name: 'release/9.0' } }, + target: null, + pkgName: null, + pkgPath: null, + purl: null, + installedVersion: null, + fixedVersion: null, + }, + ], }, shortTermFixByBranch: {}, longTermFixByBranch: {}, @@ -82,7 +92,19 @@ describe('CveOverviewItemComponent', () => { component.branchMaintainedMap = new Map([['7.0', false]]); component.showUnmaintained = false; component.detail = makeDetail({ - affectedReleasesByBranch: { '7.0': [{ id: 'r1', tagName: 'v7.0.1', name: '7.0.1', publishedAt: '2020-01-01', branch: { id: 'b1', name: 'release/7.0' } }] }, + affectedReleasesByBranch: { + '7.0': [ + { + release: { id: 'r1', tagName: 'v7.0.1', name: '7.0.1', publishedAt: '2020-01-01', branch: { id: 'b1', name: 'release/7.0' } }, + target: null, + pkgName: null, + pkgPath: null, + purl: null, + installedVersion: null, + fixedVersion: null, + }, + ], + }, shortTermFixByBranch: {}, longTermFixByBranch: {}, }); @@ -93,7 +115,19 @@ describe('CveOverviewItemComponent', () => { it('returns no-fix when affected branch is unmaintained but showUnmaintained=true', () => { component.showUnmaintained = true; component.detail = makeDetail({ - affectedReleasesByBranch: { '7.0': [{ id: 'r1', tagName: 'v7.0.1', name: '7.0.1', publishedAt: '2020-01-01', branch: { id: 'b1', name: 'release/7.0' } }] }, + affectedReleasesByBranch: { + '7.0': [ + { + release: { id: 'r1', tagName: 'v7.0.1', name: '7.0.1', publishedAt: '2020-01-01', branch: { id: 'b1', name: 'release/7.0' } }, + target: null, + pkgName: null, + pkgPath: null, + purl: null, + installedVersion: null, + fixedVersion: null, + }, + ], + }, shortTermFixByBranch: {}, longTermFixByBranch: {}, }); diff --git a/src/main/frontend/src/app/pages/cve-overview/cve-overview-right-panel/cve-overview-right-panel.component.html b/src/main/frontend/src/app/pages/cve-overview/cve-overview-right-panel/cve-overview-right-panel.component.html index 15ce1186..25c2e617 100644 --- a/src/main/frontend/src/app/pages/cve-overview/cve-overview-right-panel/cve-overview-right-panel.component.html +++ b/src/main/frontend/src/app/pages/cve-overview/cve-overview-right-panel/cve-overview-right-panel.component.html @@ -63,7 +63,7 @@

Affected Releases

{{ branch }} started in - {{ sortedAffectedReleases(branch)[0]?.tagName }} + {{ sortedAffectedOccurrences(branch)[0]?.release?.tagName }} @if (fix) { fixed in @@ -73,6 +73,29 @@

Affected Releases

unmaintained }
+
+ @for ( + occurrence of sortedAffectedOccurrences(branch); + track occurrence.release.id + (occurrence.pkgPath ?? '') + (occurrence.target ?? '') + ) { + @if (occurrence.pkgName) { +
+ {{ occurrence.release.tagName }} + {{ occurrence.pkgName }} + @if (occurrence.target) { + ({{ occurrence.target }}) + } + @if (occurrence.installedVersion) { + {{ occurrence.installedVersion }} + } + @if (occurrence.fixedVersion) { + + {{ occurrence.fixedVersion }} + } +
+ } + } +
} diff --git a/src/main/frontend/src/app/pages/cve-overview/cve-overview-right-panel/cve-overview-right-panel.component.scss b/src/main/frontend/src/app/pages/cve-overview/cve-overview-right-panel/cve-overview-right-panel.component.scss index 8de0ec49..2d16941d 100644 --- a/src/main/frontend/src/app/pages/cve-overview/cve-overview-right-panel/cve-overview-right-panel.component.scss +++ b/src/main/frontend/src/app/pages/cve-overview/cve-overview-right-panel/cve-overview-right-panel.component.scss @@ -269,6 +269,49 @@ color: #9ca3af; } +.occurrence-list { + display: flex; + flex-direction: column; + gap: 0.25rem; + margin: 0 0 0.5rem 0.85rem; + padding-left: 0.6rem; + border-left: 2px solid #f3f4f6; +} + +.occurrence-row { + display: flex; + align-items: baseline; + flex-wrap: wrap; + gap: 0.4rem; + font-size: 0.7rem; + color: #d1d5db; +} + +.occurrence-release { + color: #d1d5db; + min-width: 4rem; +} + +.occurrence-pkg { + color: #6b7280; + font-size: 0.7rem; + background: none; + padding: 0; +} + +.occurrence-target { + color: #d1d5db; + font-style: italic; +} + +.occurrence-version { + color: #9ca3af; +} + +.occurrence-fixed { + color: #15803d; +} + .empty-state { display: flex; flex-direction: column; diff --git a/src/main/frontend/src/app/pages/cve-overview/cve-overview-right-panel/cve-overview-right-panel.component.spec.ts b/src/main/frontend/src/app/pages/cve-overview/cve-overview-right-panel/cve-overview-right-panel.component.spec.ts index 0bf30f66..166a76f3 100644 --- a/src/main/frontend/src/app/pages/cve-overview/cve-overview-right-panel/cve-overview-right-panel.component.spec.ts +++ b/src/main/frontend/src/app/pages/cve-overview/cve-overview-right-panel/cve-overview-right-panel.component.spec.ts @@ -1,7 +1,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { provideRouter } from '@angular/router'; import { CveOverviewRightPanelComponent } from './cve-overview-right-panel.component'; -import { VulnerabilityDetail, ReleaseInfo } from '../../../services/vulnerability.service'; +import { VulnerabilityDetail, ReleaseInfo, VulnerabilityOccurrence } from '../../../services/vulnerability.service'; import { SimpleChange } from '@angular/core'; const makeRelease = (id: string, tagName: string, branchName = 'release/9.0'): ReleaseInfo => ({ @@ -12,6 +12,17 @@ const makeRelease = (id: string, tagName: string, branchName = 'release/9.0'): R branch: { id: 'b1', name: branchName }, }); +const makeOccurrence = (release: ReleaseInfo, overrides: Partial = {}): VulnerabilityOccurrence => ({ + release, + target: 'pom.xml', + pkgName: 'org.example:lib', + pkgPath: null, + purl: 'pkg:maven/org.example/lib@1.0.0', + installedVersion: '1.0.0', + fixedVersion: '1.0.1', + ...overrides, +}); + const makeDetail = (overrides: Partial = {}): VulnerabilityDetail => ({ vulnerability: { cveId: 'CVE-2024-0001', @@ -25,7 +36,7 @@ const makeDetail = (overrides: Partial = {}): Vulnerability publishedAt: '2024-01-15T00:00:00Z', }, affectedReleasesByBranch: { - '9.0': [makeRelease('r1', 'v9.0.0'), makeRelease('r2', 'v9.0.1')], + '9.0': [makeOccurrence(makeRelease('r1', 'v9.0.0')), makeOccurrence(makeRelease('r2', 'v9.0.1'))], }, shortTermFixByBranch: { '9.0': makeRelease('r3', 'v9.0.2'), @@ -111,36 +122,40 @@ describe('CveOverviewRightPanelComponent', () => { }); }); - describe('sortedAffectedReleases()', () => { - it('returns releases sorted by tagName ascending', () => { + describe('sortedAffectedOccurrences()', () => { + it('returns occurrences sorted by release tagName ascending', () => { component.vulnerabilityDetail = makeDetail({ affectedReleasesByBranch: { - '9.0': [makeRelease('r2', 'v9.0.2'), makeRelease('r1', 'v9.0.1'), makeRelease('r3', 'v9.0.3')], + '9.0': [ + makeOccurrence(makeRelease('r2', 'v9.0.2')), + makeOccurrence(makeRelease('r1', 'v9.0.1')), + makeOccurrence(makeRelease('r3', 'v9.0.3')), + ], }, }); - const sorted = component.sortedAffectedReleases('9.0'); + const sorted = component.sortedAffectedOccurrences('9.0'); - expect(sorted.map((r) => r.tagName)).toEqual(['v9.0.1', 'v9.0.2', 'v9.0.3']); + expect(sorted.map((occurence) => occurence.release.tagName)).toEqual(['v9.0.1', 'v9.0.2', 'v9.0.3']); }); it('returns empty array for unknown branch', () => { component.vulnerabilityDetail = makeDetail(); - expect(component.sortedAffectedReleases('unknown-branch')).toEqual([]); + expect(component.sortedAffectedOccurrences('unknown-branch')).toEqual([]); }); it('returns empty array when vulnerabilityDetail is null', () => { component.vulnerabilityDetail = null; - expect(component.sortedAffectedReleases('9.0')).toEqual([]); + expect(component.sortedAffectedOccurrences('9.0')).toEqual([]); }); }); describe('recommendedUpgrades()', () => { it('lists per-branch patches first and the minimum long-term fix last', () => { component.vulnerabilityDetail = makeDetail({ - affectedReleasesByBranch: { '9.2': [makeRelease('r1', 'v9.2.0')] }, + affectedReleasesByBranch: { '9.2': [makeOccurrence(makeRelease('r1', 'v9.2.0'))] }, shortTermFixByBranch: { '9.2': makeRelease('r2', 'v9.2.1') }, longTermFixByBranch: { '9.2': makeRelease('r3', 'v10.1.0', 'release/10.1') }, }); @@ -158,7 +173,7 @@ describe('CveOverviewRightPanelComponent', () => { it('merges a long-term fix that duplicates a short-term fix into a single entry', () => { const patch = makeRelease('r2', 'v10.1.1', 'release/10.1'); component.vulnerabilityDetail = makeDetail({ - affectedReleasesByBranch: { '10.1': [makeRelease('r1', 'v10.1.0', 'release/10.1')] }, + affectedReleasesByBranch: { '10.1': [makeOccurrence(makeRelease('r1', 'v10.1.0', 'release/10.1'))] }, shortTermFixByBranch: { '10.1': patch }, longTermFixByBranch: { '10.1': patch }, }); diff --git a/src/main/frontend/src/app/pages/cve-overview/cve-overview-right-panel/cve-overview-right-panel.component.ts b/src/main/frontend/src/app/pages/cve-overview/cve-overview-right-panel/cve-overview-right-panel.component.ts index fe69bd2f..aad302e2 100644 --- a/src/main/frontend/src/app/pages/cve-overview/cve-overview-right-panel/cve-overview-right-panel.component.ts +++ b/src/main/frontend/src/app/pages/cve-overview/cve-overview-right-panel/cve-overview-right-panel.component.ts @@ -2,7 +2,7 @@ import { Component, Input, OnChanges, SimpleChanges, WritableSignal, computed, s import { sortVersionsAsc, compareVersions } from '../../../pipes/version-compare'; import { CommonModule, NgOptimizedImage } from '@angular/common'; import { RouterLink } from '@angular/router'; -import { VulnerabilityDetail, ReleaseInfo } from '../../../services/vulnerability.service'; +import { VulnerabilityDetail, ReleaseInfo, VulnerabilityOccurrence } from '../../../services/vulnerability.service'; import { BadgeClassPipe } from '../../../pipes/badge-class.pipe'; import { CweUrlPipe } from '../../../pipes/cwe-url.pipe'; import { MarkdownPipe } from '../../../pipes/markdown.pipe'; @@ -52,9 +52,11 @@ export class CveOverviewRightPanelComponent implements OnChanges { } } - public sortedAffectedReleases(branch: string): ReleaseInfo[] { - const releases = this.vulnerabilityDetail?.affectedReleasesByBranch[branch] ?? []; - return [...releases].toSorted((releaseA, releaseB) => compareVersions(releaseA.tagName, releaseB.tagName)); + public sortedAffectedOccurrences(branch: string): VulnerabilityOccurrence[] { + const occurrences = this.vulnerabilityDetail?.affectedReleasesByBranch[branch] ?? []; + return [...occurrences].toSorted((vulnerabilityOcurrenceA, vulnerabilityOcurrenceB) => + compareVersions(vulnerabilityOcurrenceA.release.tagName, vulnerabilityOcurrenceB.release.tagName), + ); } public isUnmaintained(branch: string): boolean { diff --git a/src/main/frontend/src/app/pages/cve-overview/cve-overview.component.spec.ts b/src/main/frontend/src/app/pages/cve-overview/cve-overview.component.spec.ts index c00869d5..40742d47 100644 --- a/src/main/frontend/src/app/pages/cve-overview/cve-overview.component.spec.ts +++ b/src/main/frontend/src/app/pages/cve-overview/cve-overview.component.spec.ts @@ -21,7 +21,17 @@ const makeDetail = (cveId: string, severity = 'CRITICAL'): VulnerabilityDetail = publishedAt: null, }, affectedReleasesByBranch: { - '9.0': [{ id: 'r1', tagName: 'v9.0.1', name: '9.0.1', publishedAt: '2024-01-01', branch: { id: 'b1', name: 'release/9.0' } }], + '9.0': [ + { + release: { id: 'r1', tagName: 'v9.0.1', name: '9.0.1', publishedAt: '2024-01-01', branch: { id: 'b1', name: 'release/9.0' } }, + target: null, + pkgName: null, + pkgPath: null, + purl: null, + installedVersion: null, + fixedVersion: null, + }, + ], }, shortTermFixByBranch: {}, longTermFixByBranch: {}, diff --git a/src/main/frontend/src/app/services/vulnerability.service.ts b/src/main/frontend/src/app/services/vulnerability.service.ts index 923fd213..3b1444fd 100644 --- a/src/main/frontend/src/app/services/vulnerability.service.ts +++ b/src/main/frontend/src/app/services/vulnerability.service.ts @@ -39,9 +39,19 @@ export interface ReleaseInfo { branch: { id: string; name: string }; } +export interface VulnerabilityOccurrence { + release: ReleaseInfo; + target: string | null; + pkgName: string | null; + pkgPath: string | null; + purl: string | null; + installedVersion: string | null; + fixedVersion: string | null; +} + export interface VulnerabilityDetail { vulnerability: Vulnerability; - affectedReleasesByBranch: Record; + affectedReleasesByBranch: Record; shortTermFixByBranch: Record; longTermFixByBranch: Record; } diff --git a/src/main/java/org/frankframework/insights/common/entityconnection/releasevulnerability/ReleaseVulnerability.java b/src/main/java/org/frankframework/insights/common/entityconnection/releasevulnerability/ReleaseVulnerability.java index 577ff8c9..6dc6262c 100644 --- a/src/main/java/org/frankframework/insights/common/entityconnection/releasevulnerability/ReleaseVulnerability.java +++ b/src/main/java/org/frankframework/insights/common/entityconnection/releasevulnerability/ReleaseVulnerability.java @@ -1,9 +1,11 @@ package org.frankframework.insights.common.entityconnection.releasevulnerability; import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; import jakarta.persistence.Id; -import jakarta.persistence.IdClass; import jakarta.persistence.ManyToOne; +import java.util.UUID; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; @@ -14,16 +16,24 @@ @Getter @Setter @NoArgsConstructor -@IdClass(ReleaseVulnerabilityId.class) public class ReleaseVulnerability { @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + @ManyToOne private Release release; - @Id @ManyToOne private Vulnerability vulnerability; + private String target; + private String pkgName; + private String pkgPath; + private String purl; + private String installedVersion; + private String fixedVersion; + public ReleaseVulnerability(Release release, Vulnerability vulnerability) { this.release = release; this.vulnerability = vulnerability; diff --git a/src/main/java/org/frankframework/insights/common/entityconnection/releasevulnerability/ReleaseVulnerabilityId.java b/src/main/java/org/frankframework/insights/common/entityconnection/releasevulnerability/ReleaseVulnerabilityId.java deleted file mode 100644 index 48c76fee..00000000 --- a/src/main/java/org/frankframework/insights/common/entityconnection/releasevulnerability/ReleaseVulnerabilityId.java +++ /dev/null @@ -1,5 +0,0 @@ -package org.frankframework.insights.common.entityconnection.releasevulnerability; - -import java.io.Serializable; - -public record ReleaseVulnerabilityId(String release, String vulnerability) implements Serializable {} diff --git a/src/main/java/org/frankframework/insights/common/entityconnection/releasevulnerability/ReleaseVulnerabilityRepository.java b/src/main/java/org/frankframework/insights/common/entityconnection/releasevulnerability/ReleaseVulnerabilityRepository.java index 6997b9eb..0810a051 100644 --- a/src/main/java/org/frankframework/insights/common/entityconnection/releasevulnerability/ReleaseVulnerabilityRepository.java +++ b/src/main/java/org/frankframework/insights/common/entityconnection/releasevulnerability/ReleaseVulnerabilityRepository.java @@ -2,13 +2,14 @@ import java.util.Collection; import java.util.List; +import java.util.UUID; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; import org.springframework.stereotype.Repository; import org.springframework.transaction.annotation.Transactional; @Repository -public interface ReleaseVulnerabilityRepository extends JpaRepository { +public interface ReleaseVulnerabilityRepository extends JpaRepository { List findAllByReleaseId(String releaseId); List findAllByVulnerabilityCveIdIn(Collection cveIds); diff --git a/src/main/java/org/frankframework/insights/vulnerability/TrivyOccurrence.java b/src/main/java/org/frankframework/insights/vulnerability/TrivyOccurrence.java new file mode 100644 index 00000000..7747914e --- /dev/null +++ b/src/main/java/org/frankframework/insights/vulnerability/TrivyOccurrence.java @@ -0,0 +1,3 @@ +package org.frankframework.insights.vulnerability; + +public record TrivyOccurrence(String target, TrivyVulnerability vulnerability) {} diff --git a/src/main/java/org/frankframework/insights/vulnerability/TrivyVulnerability.java b/src/main/java/org/frankframework/insights/vulnerability/TrivyVulnerability.java index a022e214..6c804116 100644 --- a/src/main/java/org/frankframework/insights/vulnerability/TrivyVulnerability.java +++ b/src/main/java/org/frankframework/insights/vulnerability/TrivyVulnerability.java @@ -18,7 +18,19 @@ public record TrivyVulnerability( String Severity, List CweIDs, Map CVSS, - String PublishedDate) { + String PublishedDate, + String PkgName, + String PkgPath, + PkgIdentifier PkgIdentifier, + String InstalledVersion, + String FixedVersion) { + + @JsonIgnoreProperties(ignoreUnknown = true) + public record PkgIdentifier(String PURL) {} + + public String getPurl() { + return PkgIdentifier != null ? PkgIdentifier.PURL() : null; + } public String buildDescription() { return Description != null ? Description : ""; diff --git a/src/main/java/org/frankframework/insights/vulnerability/VulnerabilityDetailResponse.java b/src/main/java/org/frankframework/insights/vulnerability/VulnerabilityDetailResponse.java index 5ce33a90..831c1f22 100644 --- a/src/main/java/org/frankframework/insights/vulnerability/VulnerabilityDetailResponse.java +++ b/src/main/java/org/frankframework/insights/vulnerability/VulnerabilityDetailResponse.java @@ -6,6 +6,6 @@ public record VulnerabilityDetailResponse( VulnerabilityResponse vulnerability, - Map> affectedReleasesByBranch, + Map> affectedReleasesByBranch, Map shortTermFixByBranch, Map longTermFixByBranch) {} diff --git a/src/main/java/org/frankframework/insights/vulnerability/VulnerabilityOccurrenceResponse.java b/src/main/java/org/frankframework/insights/vulnerability/VulnerabilityOccurrenceResponse.java new file mode 100644 index 00000000..3cc92aab --- /dev/null +++ b/src/main/java/org/frankframework/insights/vulnerability/VulnerabilityOccurrenceResponse.java @@ -0,0 +1,12 @@ +package org.frankframework.insights.vulnerability; + +import org.frankframework.insights.release.ReleaseResponse; + +public record VulnerabilityOccurrenceResponse( + ReleaseResponse release, + String target, + String pkgName, + String pkgPath, + String purl, + String installedVersion, + String fixedVersion) {} diff --git a/src/main/java/org/frankframework/insights/vulnerability/VulnerabilityService.java b/src/main/java/org/frankframework/insights/vulnerability/VulnerabilityService.java index fbcd1ab9..af77d3dc 100644 --- a/src/main/java/org/frankframework/insights/vulnerability/VulnerabilityService.java +++ b/src/main/java/org/frankframework/insights/vulnerability/VulnerabilityService.java @@ -286,8 +286,8 @@ private void processSingleRelease(Release release) throws IOException, Interrupt TrivyReport report = executeTrivyScan(extractDir); - List vulns = parseVulnerabilitiesFromReport(report); - persistScanResults(release, vulns); + List occurrences = parseOccurrencesFromReport(report); + persistScanResults(release, occurrences); release.setLastScanned(OffsetDateTime.now()); releaseRepository.save(release); @@ -436,14 +436,22 @@ private void cleanupWorkspace() { } } - private void persistScanResults(Release release, List trivyVulnerabilities) { - if (trivyVulnerabilities.isEmpty()) { + private void persistScanResults(Release release, List occurrences) { + if (occurrences.isEmpty()) { handleEmptyScanResult(release); return; } - log.info("Trivy scan for {} found {} vulnerabilities.", release.getTagName(), trivyVulnerabilities.size()); + + log.info("Trivy scan for {} found {} vulnerability occurrences.", release.getTagName(), occurrences.size()); + List trivyVulnerabilities = + occurrences.stream().map(TrivyOccurrence::vulnerability).toList(); + Set savedVulnerabilities = getOrCreateVulnerabilities(trivyVulnerabilities); - updateReleaseVulnerabilityLinks(release, savedVulnerabilities); + + Map vulnerabilitiesByCveId = savedVulnerabilities.stream() + .collect(Collectors.toMap( + Vulnerability::getCveId, vulnerability -> vulnerability, (first, second) -> first)); + updateReleaseVulnerabilityLinks(release, occurrences, vulnerabilitiesByCveId); } private void handleEmptyScanResult(Release release) { @@ -483,21 +491,39 @@ private Set saveVulnerabilityBatch(Set vulnerabili return new HashSet<>(savedVulnerabilities); } - private void updateReleaseVulnerabilityLinks(Release release, Set vulnerabilities) { + private void updateReleaseVulnerabilityLinks( + Release release, List occurrences, Map vulnerabilitiesByCveId) { releaseVulnerabilityRepository.deleteAllByReleaseId(release.getId()); - if (vulnerabilities.isEmpty()) return; - - List newLinks = vulnerabilities.stream() - .map(v -> { - ReleaseVulnerability link = new ReleaseVulnerability(); - link.setRelease(release); - link.setVulnerability(v); - return link; - }) + if (occurrences.isEmpty()) return; + + List newLinks = occurrences.stream() + .map(occurrence -> toReleaseVulnerability(release, occurrence, vulnerabilitiesByCveId)) + .filter(Objects::nonNull) .toList(); + releaseVulnerabilityRepository.saveAll(newLinks); } + private ReleaseVulnerability toReleaseVulnerability( + Release release, TrivyOccurrence occurrence, Map vulnerabilitiesByCveId) { + TrivyVulnerability trivyVulnerability = occurrence.vulnerability(); + Vulnerability vulnerability = trivyVulnerability.VulnerabilityID() == null + ? null + : vulnerabilitiesByCveId.get(trivyVulnerability.VulnerabilityID()); + if (vulnerability == null) return null; + + ReleaseVulnerability link = new ReleaseVulnerability(); + link.setRelease(release); + link.setVulnerability(vulnerability); + link.setTarget(occurrence.target()); + link.setPkgName(trivyVulnerability.PkgName()); + link.setPkgPath(trivyVulnerability.PkgPath()); + link.setPurl(trivyVulnerability.getPurl()); + link.setInstalledVersion(trivyVulnerability.InstalledVersion()); + link.setFixedVersion(trivyVulnerability.FixedVersion()); + return link; + } + private Vulnerability mapTrivyVulnerabilityToEntity( TrivyVulnerability trivyVulnerability, Map existingVulnerabilitiesMap) { if (trivyVulnerability == null || trivyVulnerability.VulnerabilityID() == null) return null; @@ -556,10 +582,11 @@ public Set getAllVulnerabilities() { public Page getAllVulnerabilitiesPaginated(Pageable pageable, String search) { Page page = fetchVulnerabilityPage(pageable, search); List cveIds = page.stream().map(Vulnerability::getCveId).toList(); - Map> affectedByCveId = fetchAffectedReleasesByCveId(cveIds); + Map> occurrencesByCveId = fetchAffectedOccurrencesByCveId(cveIds); Map> allByEffectiveBranch = fetchAllReleasesGroupedByEffectiveBranch(); - return page.map( - vulnerability -> toVulnerabilityDetailResponse(vulnerability, affectedByCveId, allByEffectiveBranch)); + + return page.map(vulnerability -> + toVulnerabilityDetailResponse(vulnerability, occurrencesByCveId, allByEffectiveBranch)); } private static String getEffectiveBranchKey(Release release) { @@ -605,44 +632,67 @@ private Page fetchVulnerabilityPage(Pageable pageable, String sea return vulnerabilityRepository.findAll(effectivePageable); } - private Map> fetchAffectedReleasesByCveId(List cveIds) { + private Map> fetchAffectedOccurrencesByCveId(List cveIds) { if (cveIds.isEmpty()) return Collections.emptyMap(); return releaseVulnerabilityRepository.findAllByVulnerabilityCveIdIn(cveIds).stream() - .collect(Collectors.groupingBy( - releaseVulnerability -> - releaseVulnerability.getVulnerability().getCveId(), - Collectors.mapping(ReleaseVulnerability::getRelease, Collectors.toList()))); + .collect(Collectors.groupingBy(releaseVulnerability -> + releaseVulnerability.getVulnerability().getCveId())); } private VulnerabilityDetailResponse toVulnerabilityDetailResponse( Vulnerability vulnerability, - Map> affectedByCveId, + Map> occurrencesByCveId, Map> allByEffectiveBranch) { VulnerabilityResponse vulnResponse = mapper.toDTO(vulnerability, VulnerabilityResponse.class); - List affected = affectedByCveId.getOrDefault(vulnerability.getCveId(), List.of()); + List occurrences = occurrencesByCveId.getOrDefault(vulnerability.getCveId(), List.of()); + + List affected = distinctReleases(occurrences); + return new VulnerabilityDetailResponse( vulnResponse, - buildAffectedByBranch(affected), + buildAffectedByBranch(occurrences), buildShortTermFix(affected, allByEffectiveBranch), buildLongTermFix(affected, allByEffectiveBranch)); } - protected Map> buildAffectedByBranch(List affectedReleases) { - Map> versioned = groupAffectedByBranch(affectedReleases.stream() - .filter(release -> !isNightly(release)) - .filter(release -> parseVersionParts(getEffectiveBranchKey(release)) != null) + private static List distinctReleases(List occurrences) { + Map releasesById = new LinkedHashMap<>(); + for (ReleaseVulnerability occurrence : occurrences) { + Release release = occurrence.getRelease(); + if (release != null) releasesById.putIfAbsent(release.getId(), release); + } + + return new ArrayList<>(releasesById.values()); + } + + protected Map> buildAffectedByBranch( + List occurrences) { + Map> versioned = groupAffectedByBranch(occurrences.stream() + .filter(occurrence -> !isNightly(occurrence.getRelease())) + .filter(occurrence -> parseVersionParts(getEffectiveBranchKey(occurrence.getRelease())) != null) .toList()); if (!versioned.isEmpty()) return versioned; - return groupAffectedByBranch(affectedReleases); + return groupAffectedByBranch(occurrences); } - private Map> groupAffectedByBranch(List affectedReleases) { - return affectedReleases.stream() + private Map> groupAffectedByBranch( + List occurrences) { + return occurrences.stream() .collect(Collectors.groupingBy( - VulnerabilityService::getEffectiveBranchKey, - Collectors.mapping( - release -> mapper.toDTO(release, ReleaseResponse.class), Collectors.toList()))); + occurrence -> getEffectiveBranchKey(occurrence.getRelease()), + Collectors.mapping(this::toOccurrenceResponse, Collectors.toList()))); + } + + private VulnerabilityOccurrenceResponse toOccurrenceResponse(ReleaseVulnerability occurrence) { + return new VulnerabilityOccurrenceResponse( + mapper.toDTO(occurrence.getRelease(), ReleaseResponse.class), + occurrence.getTarget(), + occurrence.getPkgName(), + occurrence.getPkgPath(), + occurrence.getPurl(), + occurrence.getInstalledVersion(), + occurrence.getFixedVersion()); } /** @@ -853,12 +903,13 @@ private TrivyReport parseTrivyOutput(String jsonOutput) throws IOException { } } - private List parseVulnerabilitiesFromReport(TrivyReport report) { + private List parseOccurrencesFromReport(TrivyReport report) { if (report == null || report.Results == null) return Collections.emptyList(); return report.Results.stream() - .filter(r -> r.Vulnerabilities() != null) - .flatMap(r -> r.Vulnerabilities().stream()) - .filter(Objects::nonNull) + .filter(trivyResult -> trivyResult.Vulnerabilities() != null) + .flatMap(trivyResult -> trivyResult.Vulnerabilities().stream() + .filter(Objects::nonNull) + .map(trivyVulnerability -> new TrivyOccurrence(trivyResult.Target(), trivyVulnerability))) .collect(Collectors.toList()); } } diff --git a/src/main/resources/db/migration/V1.12__Add_Vulnerability_Occurrence_Columns.sql b/src/main/resources/db/migration/V1.12__Add_Vulnerability_Occurrence_Columns.sql new file mode 100644 index 00000000..1b4e021b --- /dev/null +++ b/src/main/resources/db/migration/V1.12__Add_Vulnerability_Occurrence_Columns.sql @@ -0,0 +1,17 @@ +DELETE FROM release_vulnerability; + +ALTER TABLE release_vulnerability DROP CONSTRAINT release_vulnerability_pkey; + +ALTER TABLE release_vulnerability ADD COLUMN id UUID NOT NULL DEFAULT gen_random_uuid(); +ALTER TABLE release_vulnerability ADD PRIMARY KEY (id); +ALTER TABLE release_vulnerability ALTER COLUMN id DROP DEFAULT; + +ALTER TABLE release_vulnerability ADD COLUMN target VARCHAR(1024); +ALTER TABLE release_vulnerability ADD COLUMN pkg_name VARCHAR(512); +ALTER TABLE release_vulnerability ADD COLUMN pkg_path VARCHAR(1024); +ALTER TABLE release_vulnerability ADD COLUMN purl TEXT; +ALTER TABLE release_vulnerability ADD COLUMN installed_version VARCHAR(255); +ALTER TABLE release_vulnerability ADD COLUMN fixed_version VARCHAR(255); + +CREATE INDEX idx_release_vulnerability_release_id ON release_vulnerability(release_id); +CREATE INDEX idx_release_vulnerability_vulnerability_cve_id ON release_vulnerability(vulnerability_cve_id); diff --git a/src/test/java/org/frankframework/insights/vulnerability/VulnerabilityServiceTest.java b/src/test/java/org/frankframework/insights/vulnerability/VulnerabilityServiceTest.java index 158c1ecc..4ab86101 100644 --- a/src/test/java/org/frankframework/insights/vulnerability/VulnerabilityServiceTest.java +++ b/src/test/java/org/frankframework/insights/vulnerability/VulnerabilityServiceTest.java @@ -981,7 +981,7 @@ public void testParseTrivyOutput_BlankInput() throws Exception { } @Test - public void testParseVulnerabilitiesFromReport_NullReport() throws Exception { + public void testParseOccurrencesFromReport_NullReport() throws Exception { VulnerabilityService testService = new VulnerabilityService( vulnerabilityRepository, releaseVulnerabilityRepository, @@ -993,17 +993,17 @@ public void testParseVulnerabilitiesFromReport_NullReport() throws Exception { "/tmp"); java.lang.reflect.Method method = - VulnerabilityService.class.getDeclaredMethod("parseVulnerabilitiesFromReport", TrivyReport.class); + VulnerabilityService.class.getDeclaredMethod("parseOccurrencesFromReport", TrivyReport.class); method.setAccessible(true); @SuppressWarnings("unchecked") - List result = (List) method.invoke(testService, (TrivyReport) null); + List result = (List) method.invoke(testService, (TrivyReport) null); assertNotNull(result); assertTrue(result.isEmpty()); } @Test - public void testParseVulnerabilitiesFromReport_NullResults() throws Exception { + public void testParseOccurrencesFromReport_NullResults() throws Exception { VulnerabilityService testService = new VulnerabilityService( vulnerabilityRepository, releaseVulnerabilityRepository, @@ -1015,20 +1015,20 @@ public void testParseVulnerabilitiesFromReport_NullResults() throws Exception { "/tmp"); java.lang.reflect.Method method = - VulnerabilityService.class.getDeclaredMethod("parseVulnerabilitiesFromReport", TrivyReport.class); + VulnerabilityService.class.getDeclaredMethod("parseOccurrencesFromReport", TrivyReport.class); method.setAccessible(true); TrivyReport report = new TrivyReport(); report.Results = null; @SuppressWarnings("unchecked") - List result = (List) method.invoke(testService, report); + List result = (List) method.invoke(testService, report); assertNotNull(result); assertTrue(result.isEmpty()); } @Test - public void testParseVulnerabilitiesFromReport_WithNullVulnerabilities() throws Exception { + public void testParseOccurrencesFromReport_WithNullVulnerabilities() throws Exception { VulnerabilityService testService = new VulnerabilityService( vulnerabilityRepository, releaseVulnerabilityRepository, @@ -1040,7 +1040,7 @@ public void testParseVulnerabilitiesFromReport_WithNullVulnerabilities() throws "/tmp"); java.lang.reflect.Method method = - VulnerabilityService.class.getDeclaredMethod("parseVulnerabilitiesFromReport", TrivyReport.class); + VulnerabilityService.class.getDeclaredMethod("parseOccurrencesFromReport", TrivyReport.class); method.setAccessible(true); TrivyReport report = new TrivyReport(); @@ -1048,13 +1048,13 @@ public void testParseVulnerabilitiesFromReport_WithNullVulnerabilities() throws report.Results = List.of(resultWithNullVulns); @SuppressWarnings("unchecked") - List result = (List) method.invoke(testService, report); + List result = (List) method.invoke(testService, report); assertNotNull(result); assertTrue(result.isEmpty()); } @Test - public void testParseVulnerabilitiesFromReport_FiltersNullVulnerabilities() throws Exception { + public void testParseOccurrencesFromReport_FiltersNullVulnerabilities() throws Exception { VulnerabilityService testService = new VulnerabilityService( vulnerabilityRepository, releaseVulnerabilityRepository, @@ -1066,20 +1066,52 @@ public void testParseVulnerabilitiesFromReport_FiltersNullVulnerabilities() thro "/tmp"); java.lang.reflect.Method method = - VulnerabilityService.class.getDeclaredMethod("parseVulnerabilitiesFromReport", TrivyReport.class); + VulnerabilityService.class.getDeclaredMethod("parseOccurrencesFromReport", TrivyReport.class); method.setAccessible(true); - TrivyVulnerability validVuln = - new TrivyVulnerability("CVE-2023-0001", "Title", "Desc", "HIGH", null, null, null); + TrivyVulnerability validVuln = new TrivyVulnerability( + "CVE-2023-0001", "Title", "Desc", "HIGH", null, null, null, null, null, null, null, null); TrivyReport report = new TrivyReport(); TrivyResult resultWithMixedVulns = new TrivyResult("target", "class", "type", java.util.Arrays.asList(validVuln, null, validVuln)); report.Results = List.of(resultWithMixedVulns); @SuppressWarnings("unchecked") - List result = (List) method.invoke(testService, report); + List result = (List) method.invoke(testService, report); assertNotNull(result); assertEquals(2, result.size()); + assertEquals("CVE-2023-0001", result.get(0).vulnerability().VulnerabilityID()); + assertEquals("target", result.get(0).target()); + } + + @Test + public void testParseOccurrencesFromReport_ThreadsDistinctTargetPerResult() throws Exception { + VulnerabilityService testService = new VulnerabilityService( + vulnerabilityRepository, + releaseVulnerabilityRepository, + releaseRepository, + releaseArtifactService, + mapper, + "trivy", + "/tmp", + "/tmp"); + + java.lang.reflect.Method method = + VulnerabilityService.class.getDeclaredMethod("parseOccurrencesFromReport", TrivyReport.class); + method.setAccessible(true); + + TrivyVulnerability sameVuln = new TrivyVulnerability( + "CVE-2023-0001", "Title", "Desc", "HIGH", null, null, null, null, null, null, null, null); + TrivyReport report = new TrivyReport(); + TrivyResult pomResult = new TrivyResult("pom.xml", "lang-pkgs", "jar", List.of(sameVuln)); + TrivyResult packageJsonResult = new TrivyResult("package-lock.json", "lang-pkgs", "npm", List.of(sameVuln)); + report.Results = List.of(pomResult, packageJsonResult); + + @SuppressWarnings("unchecked") + List result = (List) method.invoke(testService, report); + assertEquals(2, result.size()); + assertEquals("pom.xml", result.get(0).target()); + assertEquals("package-lock.json", result.get(1).target()); } @Test @@ -1304,7 +1336,8 @@ public void testMapTrivyVulnerabilityToEntity_WithNullVulnerabilityID() throws E "mapTrivyVulnerabilityToEntity", TrivyVulnerability.class, java.util.Map.class); method.setAccessible(true); - TrivyVulnerability vulnWithNullId = new TrivyVulnerability(null, "Title", "Desc", "HIGH", null, null, null); + TrivyVulnerability vulnWithNullId = + new TrivyVulnerability(null, "Title", "Desc", "HIGH", null, null, null, null, null, null, null, null); Vulnerability result = (Vulnerability) method.invoke(testService, vulnWithNullId, new java.util.HashMap<>()); Assertions.assertNull(result); } @@ -1332,6 +1365,11 @@ public void testMapTrivyVulnerabilityToEntity_CreatesNewVulnerability() throws E "CRITICAL", List.of("CWE-79", "CWE-89"), null, + null, + null, + null, + null, + null, null); java.util.Map existingMap = new java.util.HashMap<>(); @@ -1368,8 +1406,19 @@ public void testMapTrivyVulnerabilityToEntity_UpdatesExistingVulnerability() thr java.util.Map existingMap = new java.util.HashMap<>(); existingMap.put("CVE-2023-EXIST", existing); - TrivyVulnerability trivyVuln = - new TrivyVulnerability("CVE-2023-EXIST", "Updated Title", "Updated Desc", "HIGH", null, null, null); + TrivyVulnerability trivyVuln = new TrivyVulnerability( + "CVE-2023-EXIST", + "Updated Title", + "Updated Desc", + "HIGH", + null, + null, + null, + null, + null, + null, + null, + null); Vulnerability result = (Vulnerability) method.invoke(testService, trivyVuln, existingMap); @@ -1394,9 +1443,12 @@ public void testGetCveIdsFromReport_FiltersNullIds() throws Exception { VulnerabilityService.class.getDeclaredMethod("getCveIdsFromReport", List.class); method.setAccessible(true); - TrivyVulnerability vuln1 = new TrivyVulnerability("CVE-2023-0001", "Title1", "Desc1", "HIGH", null, null, null); - TrivyVulnerability vulnNullId = new TrivyVulnerability(null, "Title2", "Desc2", "MEDIUM", null, null, null); - TrivyVulnerability vuln2 = new TrivyVulnerability("CVE-2023-0002", "Title3", "Desc3", "LOW", null, null, null); + TrivyVulnerability vuln1 = new TrivyVulnerability( + "CVE-2023-0001", "Title1", "Desc1", "HIGH", null, null, null, null, null, null, null, null); + TrivyVulnerability vulnNullId = new TrivyVulnerability( + null, "Title2", "Desc2", "MEDIUM", null, null, null, null, null, null, null, null); + TrivyVulnerability vuln2 = new TrivyVulnerability( + "CVE-2023-0002", "Title3", "Desc3", "LOW", null, null, null, null, null, null, null, null); @SuppressWarnings("unchecked") Set result = (Set) method.invoke(testService, List.of(vuln1, vulnNullId, vuln2)); @@ -1423,8 +1475,8 @@ public void testGetEffectiveCvssScore_UsesExistingScore() throws Exception { "getEffectiveCvssScore", TrivyVulnerability.class, VulnerabilitySeverity.class, Double.class); method.setAccessible(true); - TrivyVulnerability vulnWithoutCvss = - new TrivyVulnerability("CVE-2023-0001", "Title", "Desc", "HIGH", null, null, null); + TrivyVulnerability vulnWithoutCvss = new TrivyVulnerability( + "CVE-2023-0001", "Title", "Desc", "HIGH", null, null, null, null, null, null, null, null); Double result = (Double) method.invoke(testService, vulnWithoutCvss, VulnerabilitySeverity.HIGH, 8.5); @@ -1447,8 +1499,8 @@ public void testGetEffectiveCvssScore_UsesSeverityRepresentativeScore() throws E "getEffectiveCvssScore", TrivyVulnerability.class, VulnerabilitySeverity.class, Double.class); method.setAccessible(true); - TrivyVulnerability vulnWithoutCvss = - new TrivyVulnerability("CVE-2023-0001", "Title", "Desc", "HIGH", null, null, null); + TrivyVulnerability vulnWithoutCvss = new TrivyVulnerability( + "CVE-2023-0001", "Title", "Desc", "HIGH", null, null, null, null, null, null, null, null); Double result = (Double) method.invoke(testService, vulnWithoutCvss, VulnerabilitySeverity.HIGH, null); @@ -1474,8 +1526,8 @@ public void testGetEffectiveCvssScore_UsesTrivyScore() throws Exception { java.util.Map cvssMap = new java.util.HashMap<>(); cvssMap.put("nvd", new TrivyCvssScore(null, null, 9.1, null)); - TrivyVulnerability vulnWithCvss = - new TrivyVulnerability("CVE-2023-0001", "Title", "Desc", "CRITICAL", null, cvssMap, null); + TrivyVulnerability vulnWithCvss = new TrivyVulnerability( + "CVE-2023-0001", "Title", "Desc", "CRITICAL", null, cvssMap, null, null, null, null, null, null); Double result = (Double) method.invoke(testService, vulnWithCvss, VulnerabilitySeverity.CRITICAL, 8.0); @@ -1520,13 +1572,13 @@ public void testUpdateReleaseVulnerabilityLinks_WithEmptyVulnerabilities() throw "/tmp"); java.lang.reflect.Method method = VulnerabilityService.class.getDeclaredMethod( - "updateReleaseVulnerabilityLinks", Release.class, Set.class); + "updateReleaseVulnerabilityLinks", Release.class, List.class, Map.class); method.setAccessible(true); Release testRelease = new Release(); testRelease.setId("test-release-id"); - method.invoke(testService, testRelease, Collections.emptySet()); + method.invoke(testService, testRelease, Collections.emptyList(), Collections.emptyMap()); verify(releaseVulnerabilityRepository).deleteAllByReleaseId("test-release-id"); verify(releaseVulnerabilityRepository, never()).saveAll(any()); @@ -1545,7 +1597,7 @@ public void testUpdateReleaseVulnerabilityLinks_SavesNewLinks() throws Exception "/tmp"); java.lang.reflect.Method method = VulnerabilityService.class.getDeclaredMethod( - "updateReleaseVulnerabilityLinks", Release.class, Set.class); + "updateReleaseVulnerabilityLinks", Release.class, List.class, Map.class); method.setAccessible(true); Release testRelease = new Release(); @@ -1556,9 +1608,16 @@ public void testUpdateReleaseVulnerabilityLinks_SavesNewLinks() throws Exception Vulnerability vuln2 = new Vulnerability(); vuln2.setCveId("CVE-2023-0002"); - Set vulnerabilities = Set.of(vuln1, vuln2); + TrivyVulnerability trivyVuln1 = new TrivyVulnerability( + "CVE-2023-0001", null, null, "HIGH", null, null, null, "pkg-a", null, null, null, null); + TrivyVulnerability trivyVuln2 = new TrivyVulnerability( + "CVE-2023-0002", null, null, "MEDIUM", null, null, null, "pkg-b", null, null, null, null); + + List occurrences = + List.of(new TrivyOccurrence("pom.xml", trivyVuln1), new TrivyOccurrence("package.json", trivyVuln2)); + Map vulnerabilitiesByCveId = Map.of("CVE-2023-0001", vuln1, "CVE-2023-0002", vuln2); - method.invoke(testService, testRelease, vulnerabilities); + method.invoke(testService, testRelease, occurrences, vulnerabilitiesByCveId); verify(releaseVulnerabilityRepository).deleteAllByReleaseId("test-release-id"); @@ -1570,6 +1629,52 @@ public void testUpdateReleaseVulnerabilityLinks_SavesNewLinks() throws Exception assertEquals(2, savedLinks.size()); } + @Test + public void testUpdateReleaseVulnerabilityLinks_KeepsMultipleOccurrencesOfSameCve() throws Exception { + VulnerabilityService testService = new VulnerabilityService( + vulnerabilityRepository, + releaseVulnerabilityRepository, + releaseRepository, + releaseArtifactService, + mapper, + "trivy", + "/tmp", + "/tmp"); + + java.lang.reflect.Method method = VulnerabilityService.class.getDeclaredMethod( + "updateReleaseVulnerabilityLinks", Release.class, List.class, Map.class); + method.setAccessible(true); + + Release testRelease = new Release(); + testRelease.setId("test-release-id"); + + Vulnerability vuln = new Vulnerability(); + vuln.setCveId("CVE-2023-0001"); + + TrivyVulnerability trivyVulnInPom = new TrivyVulnerability( + "CVE-2023-0001", null, null, "HIGH", null, null, null, "pkg-a", null, null, "1.0.0", "1.0.1"); + TrivyVulnerability trivyVulnInPackageJson = new TrivyVulnerability( + "CVE-2023-0001", null, null, "HIGH", null, null, null, "pkg-b", null, null, "2.0.0", "2.0.1"); + + List occurrences = List.of( + new TrivyOccurrence("pom.xml", trivyVulnInPom), + new TrivyOccurrence("package.json", trivyVulnInPackageJson)); + Map vulnerabilitiesByCveId = Map.of("CVE-2023-0001", vuln); + + method.invoke(testService, testRelease, occurrences, vulnerabilitiesByCveId); + + @SuppressWarnings("unchecked") + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(releaseVulnerabilityRepository).saveAll(captor.capture()); + + List savedLinks = captor.getValue(); + assertEquals(2, savedLinks.size(), "Two distinct package occurrences of the same CVE must not collapse"); + assertTrue(savedLinks.stream() + .anyMatch(link -> "pom.xml".equals(link.getTarget()) && "pkg-a".equals(link.getPkgName()))); + assertTrue(savedLinks.stream() + .anyMatch(link -> "package.json".equals(link.getTarget()) && "pkg-b".equals(link.getPkgName()))); + } + @Test public void testUnzipAndFilter_EmptyZip(@TempDir Path tempDir) throws Exception { Path zipFile = tempDir.resolve("empty.zip"); @@ -1977,11 +2082,16 @@ public void testBuildAffectedByBranch_FallsBackToNonVersionedReleases() { ReleaseResponse nightlyResponse = new ReleaseResponse("id-nightly", "nightly", "nightly", null, null, null); when(mapper.toDTO(nightly, ReleaseResponse.class)).thenReturn(nightlyResponse); - Map> result = vulnerabilityService.buildAffectedByBranch(List.of(nightly)); + Vulnerability vuln = new Vulnerability(); + vuln.setCveId("CVE-2023-0001"); + ReleaseVulnerability occurrence = new ReleaseVulnerability(nightly, vuln); + + Map> result = + vulnerabilityService.buildAffectedByBranch(List.of(occurrence)); assertEquals(1, result.size()); assertTrue(result.containsKey("master")); - assertEquals("nightly", result.get("master").getFirst().tagName()); + assertEquals("nightly", result.get("master").getFirst().release().tagName()); } @Test @@ -1992,12 +2102,45 @@ public void testBuildAffectedByBranch_PrefersVersionedReleasesOverNightly() { ReleaseResponse response920 = new ReleaseResponse("id-920", "v9.2.0", "9.2.0", null, null, null); when(mapper.toDTO(r920, ReleaseResponse.class)).thenReturn(response920); - Map> result = vulnerabilityService.buildAffectedByBranch(List.of(nightly, r920)); + Vulnerability vuln = new Vulnerability(); + vuln.setCveId("CVE-2023-0001"); + List occurrences = + List.of(new ReleaseVulnerability(nightly, vuln), new ReleaseVulnerability(r920, vuln)); + + Map> result = + vulnerabilityService.buildAffectedByBranch(occurrences); assertEquals(1, result.size()); assertTrue(result.containsKey("9.2")); } + @Test + public void testBuildAffectedByBranch_KeepsMultipleOccurrencesForSameRelease() { + Release r920 = makeRelease("id-920", "v9.2.0"); + ReleaseResponse response920 = new ReleaseResponse("id-920", "v9.2.0", "9.2.0", null, null, null); + when(mapper.toDTO(r920, ReleaseResponse.class)).thenReturn(response920); + + Vulnerability vuln = new Vulnerability(); + vuln.setCveId("CVE-2023-0001"); + + ReleaseVulnerability occurrenceInPom = new ReleaseVulnerability(r920, vuln); + occurrenceInPom.setTarget("pom.xml"); + occurrenceInPom.setPkgName("pkg-a"); + + ReleaseVulnerability occurrenceInPackageJson = new ReleaseVulnerability(r920, vuln); + occurrenceInPackageJson.setTarget("package.json"); + occurrenceInPackageJson.setPkgName("pkg-b"); + + Map> result = + vulnerabilityService.buildAffectedByBranch(List.of(occurrenceInPom, occurrenceInPackageJson)); + + assertEquals(1, result.size()); + List occurrencesForBranch = result.get("9.2"); + assertEquals(2, occurrencesForBranch.size(), "Two package occurrences in the same release must both surface"); + assertTrue(occurrencesForBranch.stream().anyMatch(o -> "pkg-a".equals(o.pkgName()))); + assertTrue(occurrencesForBranch.stream().anyMatch(o -> "pkg-b".equals(o.pkgName()))); + } + private static Release makeRelease(String id, String tagName) { Release r = new Release(); r.setId(id); From f519fe0c7f9823a30456f002317554028987895c Mon Sep 17 00:00:00 2001 From: stijnpotters Date: Wed, 22 Jul 2026 14:58:50 +0200 Subject: [PATCH 2/4] feat: add language filtering to filter component, places the paths under the badges at the stop and switched affected and recommended releases and sorted child items desc instead of asc --- .../cve-filter-panel.component.html | 16 ++ .../cve-filter-panel.component.ts | 19 ++- .../cve-overview-right-panel.component.html | 52 +++--- .../cve-overview-right-panel.component.scss | 39 +++-- ...cve-overview-right-panel.component.spec.ts | 47 +++++- .../cve-overview-right-panel.component.ts | 49 +++++- .../cve-overview/cve-overview.component.html | 2 + .../cve-overview.component.spec.ts | 158 +++++++++++++++++- .../cve-overview/cve-overview.component.ts | 86 +++++++--- src/main/frontend/src/app/pipes/language.ts | 24 +++ .../src/app/pipes/version-compare.spec.ts | 26 ++- .../frontend/src/app/pipes/version-compare.ts | 4 + .../vulnerability/VulnerabilityService.java | 10 +- .../VulnerabilityServiceTest.java | 22 +++ 14 files changed, 483 insertions(+), 71 deletions(-) create mode 100644 src/main/frontend/src/app/pipes/language.ts diff --git a/src/main/frontend/src/app/components/cve-filter-panel/cve-filter-panel.component.html b/src/main/frontend/src/app/components/cve-filter-panel/cve-filter-panel.component.html index 3bcffe10..64867e8b 100644 --- a/src/main/frontend/src/app/components/cve-filter-panel/cve-filter-panel.component.html +++ b/src/main/frontend/src/app/components/cve-filter-panel/cve-filter-panel.component.html @@ -64,6 +64,22 @@ } } + + @if (activeCategory === 'language') { + @if (languageOptions.length === 0) { + No languages available yet + } + @for (language of languageOptions; track language.id) { + + } + } diff --git a/src/main/frontend/src/app/components/cve-filter-panel/cve-filter-panel.component.ts b/src/main/frontend/src/app/components/cve-filter-panel/cve-filter-panel.component.ts index b5ac823d..d451df0e 100644 --- a/src/main/frontend/src/app/components/cve-filter-panel/cve-filter-panel.component.ts +++ b/src/main/frontend/src/app/components/cve-filter-panel/cve-filter-panel.component.ts @@ -7,6 +7,7 @@ export interface CveFilters { impact: string[]; branches: string[]; statuses: string[]; + languages: string[]; showUnmaintained: boolean; } @@ -15,15 +16,17 @@ export const DEFAULT_CVE_FILTERS: CveFilters = { impact: [], branches: [], statuses: [], + languages: [], showUnmaintained: false, }; -export type FilterCategory = 'cvss' | 'impact' | 'branches'; +export type FilterCategory = 'cvss' | 'impact' | 'branches' | 'language'; export const CVE_FILTER_CATEGORIES: { key: FilterCategory; label: string }[] = [ { key: 'cvss', label: 'CVSS Score' }, { key: 'impact', label: 'FF Impact' }, { key: 'branches', label: 'Branches' }, + { key: 'language', label: 'Language' }, ]; const SEVERITY_OPTIONS = ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW']; @@ -36,6 +39,11 @@ const STATUS_OPTIONS: { value: string; label: string }[] = [ { value: 'not-affected', label: 'Not affected' }, ]; +export interface LanguageOption { + id: string; + label: string; +} + @Component({ selector: 'app-cve-filter-panel', standalone: true, @@ -46,6 +54,7 @@ const STATUS_OPTIONS: { value: string; label: string }[] = [ export class CveFilterPanelComponent { @Input() filters: CveFilters = { ...DEFAULT_CVE_FILTERS }; @Input() availableBranches: string[] = []; + @Input() languageOptions: LanguageOption[] = []; @Input() visibleCategories: FilterCategory[] = ['cvss', 'impact', 'branches']; @Input() showStatusSection = true; @Output() filtersChange = new EventEmitter(); @@ -70,7 +79,8 @@ export class CveFilterPanelComponent { this.filters.severities.length + this.filters.impact.length + this.filters.branches.length + - this.filters.statuses.length + this.filters.statuses.length + + this.filters.languages.length ); } @@ -78,6 +88,7 @@ export class CveFilterPanelComponent { if (category === 'cvss') return this.filters.severities.length; if (category === 'impact') return this.filters.impact.length; if (category === 'branches') return this.filters.branches.length; + if (category === 'language') return this.filters.languages.length; return 0; } @@ -98,6 +109,10 @@ export class CveFilterPanelComponent { this.emit({ ...this.filters, branches: this.toggle(this.filters.branches, branch) }); } + protected toggleLanguage(language: string): void { + this.emit({ ...this.filters, languages: this.toggle(this.filters.languages, language) }); + } + protected toggleStatus(status: string): void { this.emit({ ...this.filters, statuses: this.toggle(this.filters.statuses, status) }); } diff --git a/src/main/frontend/src/app/pages/cve-overview/cve-overview-right-panel/cve-overview-right-panel.component.html b/src/main/frontend/src/app/pages/cve-overview/cve-overview-right-panel/cve-overview-right-panel.component.html index 25c2e617..9615cb12 100644 --- a/src/main/frontend/src/app/pages/cve-overview/cve-overview-right-panel/cve-overview-right-panel.component.html +++ b/src/main/frontend/src/app/pages/cve-overview/cve-overview-right-panel/cve-overview-right-panel.component.html @@ -20,6 +20,22 @@ } + + @if (hasPackageInfos()) { +
+ @for (pkg of packageInfos(); track pkg.pkgName + (pkg.pkgPath ?? '') + (pkg.target ?? '')) { + + {{ pkg.pkgName }} + @if (pkg.target) { + ({{ pkg.target }}) + } + @if (pkg.pkgPath) { + {{ pkg.pkgPath }} + } + + } +
+ }
@@ -50,6 +66,22 @@

CWEs

} + @if (hasRecommendedUpgrades()) { +
+

Recommended Upgrade

+

Upgrade to the patched release of your branch, or move to the latest stable release

+
+ @for (upgrade of recommendedUpgrades(); track upgrade.branch) { +
+ {{ upgrade.branch }} + {{ upgrade.isLatestStable ? 'Latest stable' : 'Patched in' }} + {{ upgrade.release.tagName }} +
+ } +
+
+ } + @if (hasAffectedBranches()) {

Affected Releases

@@ -81,10 +113,6 @@

Affected Releases

@if (occurrence.pkgName) {
{{ occurrence.release.tagName }} - {{ occurrence.pkgName }} - @if (occurrence.target) { - ({{ occurrence.target }}) - } @if (occurrence.installedVersion) { {{ occurrence.installedVersion }} } @@ -100,22 +128,6 @@

Affected Releases

} - - @if (hasRecommendedUpgrades()) { -
-

Recommended Upgrade

-

Upgrade to the patched release of your branch, or move to the latest stable release

-
- @for (upgrade of recommendedUpgrades(); track upgrade.branch) { -
- {{ upgrade.branch }} - {{ upgrade.isLatestStable ? 'Latest stable' : 'Patched in' }} - {{ upgrade.release.tagName }} -
- } -
-
- } } @else {
diff --git a/src/main/frontend/src/app/pages/cve-overview/cve-overview-right-panel/cve-overview-right-panel.component.scss b/src/main/frontend/src/app/pages/cve-overview/cve-overview-right-panel/cve-overview-right-panel.component.scss index 2d16941d..bc848d25 100644 --- a/src/main/frontend/src/app/pages/cve-overview/cve-overview-right-panel/cve-overview-right-panel.component.scss +++ b/src/main/frontend/src/app/pages/cve-overview/cve-overview-right-panel/cve-overview-right-panel.component.scss @@ -104,6 +104,33 @@ margin: 0.5rem 0 0.75rem 0; } +.package-row { + display: flex; + flex-wrap: wrap; + gap: 0.375rem; + margin-top: 0.75rem; +} + +.tag-package { + display: inline-flex; + align-items: baseline; + gap: 0.375rem; + background: #f5f3ff; + border-color: #ddd6fe; + color: #5b21b6; + + code { + font-size: 0.75rem; + color: inherit; + } +} + +.package-meta { + font-size: 0.7rem; + color: #7c3aed; + opacity: 0.75; +} + .badge { flex-shrink: 0; padding: 0.25rem 0.5rem; @@ -292,18 +319,6 @@ min-width: 4rem; } -.occurrence-pkg { - color: #6b7280; - font-size: 0.7rem; - background: none; - padding: 0; -} - -.occurrence-target { - color: #d1d5db; - font-style: italic; -} - .occurrence-version { color: #9ca3af; } diff --git a/src/main/frontend/src/app/pages/cve-overview/cve-overview-right-panel/cve-overview-right-panel.component.spec.ts b/src/main/frontend/src/app/pages/cve-overview/cve-overview-right-panel/cve-overview-right-panel.component.spec.ts index 166a76f3..49049a79 100644 --- a/src/main/frontend/src/app/pages/cve-overview/cve-overview-right-panel/cve-overview-right-panel.component.spec.ts +++ b/src/main/frontend/src/app/pages/cve-overview/cve-overview-right-panel/cve-overview-right-panel.component.spec.ts @@ -101,6 +101,18 @@ describe('CveOverviewRightPanelComponent', () => { ]); }); + it('populates packageInfos deduplicated across occurrences', () => { + expect(component.packageInfos()).toEqual([{ pkgName: 'org.example:lib', pkgPath: null, target: 'pom.xml' }]); + }); + + it('shows package info in the panel header, not under each branch card', () => { + const packageRow = fixture.nativeElement.querySelector('.panel-header .package-row'); + const branchCards = fixture.nativeElement.querySelectorAll('.branch-card .tag-package'); + + expect(packageRow?.textContent).toContain('org.example:lib'); + expect(branchCards.length).toBe(0); + }); + it('shows CVE ID in DOM', () => { const cveId = fixture.nativeElement.querySelector('.cve-id'); @@ -150,6 +162,35 @@ describe('CveOverviewRightPanelComponent', () => { expect(component.sortedAffectedOccurrences('9.0')).toEqual([]); }); + + it('collapses occurrences for the same release and version pulled in via different files', () => { + const release = makeRelease('r1', 'v9.0.1'); + component.vulnerabilityDetail = makeDetail({ + affectedReleasesByBranch: { + '9.0': [ + makeOccurrence(release, { pkgName: 'dompurify', target: 'a/package-lock.json' }), + makeOccurrence(release, { pkgName: 'dompurify', target: 'b/package-lock.json' }), + makeOccurrence(release, { pkgName: 'dompurify', target: 'c/package-lock.json' }), + ], + }, + }); + + expect(component.sortedAffectedOccurrences('9.0').length).toBe(1); + }); + + it('keeps occurrences with different installed/fixed versions for the same release', () => { + const release = makeRelease('r1', 'v9.0.1'); + component.vulnerabilityDetail = makeDetail({ + affectedReleasesByBranch: { + '9.0': [ + makeOccurrence(release, { pkgName: 'dompurify', installedVersion: '1.0.0', fixedVersion: '1.0.1' }), + makeOccurrence(release, { pkgName: 'other-pkg', installedVersion: '2.0.0', fixedVersion: '2.0.1' }), + ], + }, + }); + + expect(component.sortedAffectedOccurrences('9.0').length).toBe(2); + }); }); describe('recommendedUpgrades()', () => { @@ -234,7 +275,7 @@ describe('CveOverviewRightPanelComponent', () => { vulnerabilityDetail: new SimpleChange(null, component.vulnerabilityDetail, true), }); - expect(component.affectedBranches()).toEqual(['7.0', '9.0']); + expect(component.affectedBranches()).toEqual(['9.0', '7.0']); }); it('flags unmaintained branches through isUnmaintained()', () => { @@ -263,7 +304,7 @@ describe('CveOverviewRightPanelComponent', () => { expect(component.recommendedUpgrades()).toEqual([]); }); - it('sorts affected branches ascending', () => { + it('sorts affected branches descending', () => { component.vulnerabilityDetail = makeDetail({ affectedReleasesByBranch: { '9.0': [], '7.8': [], '8.1': [] }, }); @@ -277,7 +318,7 @@ describe('CveOverviewRightPanelComponent', () => { vulnerabilityDetail: new SimpleChange(null, component.vulnerabilityDetail, true), }); - expect(component.affectedBranches()).toEqual(['7.8', '8.1', '9.0']); + expect(component.affectedBranches()).toEqual(['9.0', '8.1', '7.8']); }); it('does not trigger update for untracked input changes', () => { diff --git a/src/main/frontend/src/app/pages/cve-overview/cve-overview-right-panel/cve-overview-right-panel.component.ts b/src/main/frontend/src/app/pages/cve-overview/cve-overview-right-panel/cve-overview-right-panel.component.ts index aad302e2..f3338c96 100644 --- a/src/main/frontend/src/app/pages/cve-overview/cve-overview-right-panel/cve-overview-right-panel.component.ts +++ b/src/main/frontend/src/app/pages/cve-overview/cve-overview-right-panel/cve-overview-right-panel.component.ts @@ -1,5 +1,5 @@ import { Component, Input, OnChanges, SimpleChanges, WritableSignal, computed, signal } from '@angular/core'; -import { sortVersionsAsc, compareVersions } from '../../../pipes/version-compare'; +import { sortVersionsDesc, compareVersions } from '../../../pipes/version-compare'; import { CommonModule, NgOptimizedImage } from '@angular/common'; import { RouterLink } from '@angular/router'; import { VulnerabilityDetail, ReleaseInfo, VulnerabilityOccurrence } from '../../../services/vulnerability.service'; @@ -16,6 +16,12 @@ export interface RecommendedUpgrade { isLatestStable: boolean; } +export interface PackageInfo { + pkgName: string; + pkgPath: string | null; + target: string | null; +} + @Component({ selector: 'app-cve-right-panel', standalone: true, @@ -39,8 +45,10 @@ export class CveOverviewRightPanelComponent implements OnChanges { public readonly affectedBranches: WritableSignal = signal([]); public readonly recommendedUpgrades: WritableSignal = signal([]); + public readonly packageInfos: WritableSignal = signal([]); public readonly hasAffectedBranches = computed(() => this.affectedBranches().length > 0); public readonly hasRecommendedUpgrades = computed(() => this.recommendedUpgrades().length > 0); + public readonly hasPackageInfos = computed(() => this.packageInfos().length > 0); public hasCwes(): boolean { return (this.vulnerabilityDetail?.vulnerability?.cwes?.length ?? 0) > 0; @@ -54,25 +62,58 @@ export class CveOverviewRightPanelComponent implements OnChanges { public sortedAffectedOccurrences(branch: string): VulnerabilityOccurrence[] { const occurrences = this.vulnerabilityDetail?.affectedReleasesByBranch[branch] ?? []; - return [...occurrences].toSorted((vulnerabilityOcurrenceA, vulnerabilityOcurrenceB) => + const sorted = [...occurrences].toSorted((vulnerabilityOcurrenceA, vulnerabilityOcurrenceB) => compareVersions(vulnerabilityOcurrenceA.release.tagName, vulnerabilityOcurrenceB.release.tagName), ); + return this.dedupeByDisplayedVersion(sorted); } public isUnmaintained(branch: string): boolean { return this.branchMaintainedMap.get(branch) === false; } + private dedupeByDisplayedVersion(occurrences: VulnerabilityOccurrence[]): VulnerabilityOccurrence[] { + const seen = new Set(); + const deduped: VulnerabilityOccurrence[] = []; + + for (const occurrence of occurrences) { + const key = `${occurrence.release.id}|${occurrence.installedVersion ?? ''}|${occurrence.fixedVersion ?? ''}`; + if (seen.has(key)) continue; + seen.add(key); + deduped.push(occurrence); + } + + return deduped; + } + private updateBranchSignals(): void { if (!this.vulnerabilityDetail) { this.affectedBranches.set([]); this.recommendedUpgrades.set([]); + this.packageInfos.set([]); return; } const { affectedReleasesByBranch, shortTermFixByBranch, longTermFixByBranch } = this.vulnerabilityDetail; - this.affectedBranches.set(sortVersionsAsc(Object.keys(affectedReleasesByBranch))); + this.affectedBranches.set(sortVersionsDesc(Object.keys(affectedReleasesByBranch))); this.recommendedUpgrades.set(this.buildRecommendedUpgrades(shortTermFixByBranch, longTermFixByBranch)); + this.packageInfos.set(this.buildPackageInfos(affectedReleasesByBranch)); + } + + private buildPackageInfos(affectedReleasesByBranch: Record): PackageInfo[] { + const seen = new Map(); + + for (const occurrences of Object.values(affectedReleasesByBranch)) { + for (const occurrence of occurrences) { + if (!occurrence.pkgName) continue; + const key = `${occurrence.pkgName}|${occurrence.pkgPath ?? ''}|${occurrence.target ?? ''}`; + if (!seen.has(key)) { + seen.set(key, { pkgName: occurrence.pkgName, pkgPath: occurrence.pkgPath, target: occurrence.target }); + } + } + } + + return [...seen.values()]; } private buildRecommendedUpgrades( @@ -94,7 +135,7 @@ export class CveOverviewRightPanelComponent implements OnChanges { const latestStable = [...latestStableByReleaseId.values()]; const latestStableIds = new Set(latestStableByReleaseId.keys()); - const visiblePatchBranches = sortVersionsAsc( + const visiblePatchBranches = sortVersionsDesc( Object.keys(shortTermFixByBranch).filter((branch) => this.isBranchVisible(branch)), ); diff --git a/src/main/frontend/src/app/pages/cve-overview/cve-overview.component.html b/src/main/frontend/src/app/pages/cve-overview/cve-overview.component.html index feccd543..1e31c2fe 100644 --- a/src/main/frontend/src/app/pages/cve-overview/cve-overview.component.html +++ b/src/main/frontend/src/app/pages/cve-overview/cve-overview.component.html @@ -22,6 +22,8 @@ diff --git a/src/main/frontend/src/app/pages/cve-overview/cve-overview.component.spec.ts b/src/main/frontend/src/app/pages/cve-overview/cve-overview.component.spec.ts index 40742d47..e9b500c3 100644 --- a/src/main/frontend/src/app/pages/cve-overview/cve-overview.component.spec.ts +++ b/src/main/frontend/src/app/pages/cve-overview/cve-overview.component.spec.ts @@ -57,6 +57,22 @@ const singleItemPage = (): VulnerabilityPage => ({ first: true, }); +const makeOccurrence = (purl: string) => ({ + release: { + id: 'r1', + tagName: 'v9.0.1', + name: '9.0.1', + publishedAt: '2024-01-01', + branch: { id: 'b1', name: 'release/9.0' }, + }, + target: null, + pkgName: 'lib', + pkgPath: null, + purl, + installedVersion: null, + fixedVersion: null, +}); + describe('CveOverviewComponent', () => { let component: CveOverviewComponent; let fixture: ComponentFixture; @@ -293,6 +309,7 @@ describe('CveOverviewComponent', () => { impact: [], branches: [], statuses: [], + languages: [], showUnmaintained: true, }); @@ -305,6 +322,68 @@ describe('CveOverviewComponent', () => { impact: [], branches: [], statuses: [], + languages: [], + showUnmaintained: true, + }); + + expect(component.filteredDetails().length).toBe(0); + }); + + it('filters by language (matching)', () => { + const page: VulnerabilityPage = { + content: [ + { + ...makeDetail('CVE-2024-0001'), + affectedReleasesByBranch: { + '9.0': [ + { + release: { + id: 'r1', + tagName: 'v9.0.1', + name: '9.0.1', + publishedAt: '2024-01-01', + branch: { id: 'b1', name: 'release/9.0' }, + }, + target: 'pom.xml', + pkgName: 'org.example:lib', + pkgPath: null, + purl: 'pkg:maven/org.example/lib@1.0.0', + installedVersion: '1.0.0', + fixedVersion: null, + }, + ], + }, + }, + ], + totalElements: 1, + totalPages: 1, + number: 0, + size: 20, + last: true, + first: true, + }; + vulnerabilityService.getAllVulnerabilitiesPaginated.and.returnValue(of(page)); + component.fetchPage(0); + + component.onFiltersChange({ + severities: [], + impact: [], + branches: [], + statuses: [], + languages: ['maven'], + showUnmaintained: true, + }); + + expect(component.filteredDetails().length).toBe(1); + }); + + it('filters by language (non-matching)', () => { + component.onFiltersChange({ + severities: [], + impact: [], + branches: [], + statuses: [], + languages: ['npm'], showUnmaintained: true, }); @@ -340,20 +419,34 @@ describe('CveOverviewComponent', () => { }); it('counts severity filters', () => { - component.onFiltersChange({ severities: ['CRITICAL', 'HIGH'], impact: [], branches: [], statuses: [], showUnmaintained: false }); + component.onFiltersChange({ + severities: ['CRITICAL', 'HIGH'], + impact: [], + branches: [], + statuses: [], + languages: [], + showUnmaintained: false, + }); expect(component.activeFilterCount()).toBe(2); }); it('counts all filter types combined', () => { - component.onFiltersChange({ severities: ['CRITICAL'], impact: ['High'], branches: ['9.0'], statuses: ['has-fix'], showUnmaintained: false }); + component.onFiltersChange({ + severities: ['CRITICAL'], + impact: ['High'], + branches: ['9.0'], + statuses: ['has-fix'], + languages: ['maven'], + showUnmaintained: false, + }); - expect(component.activeFilterCount()).toBe(4); + expect(component.activeFilterCount()).toBe(5); }); }); describe('computed: availableBranches()', () => { - it('returns empty list when no data', () => { + it('returns empty available branches list when no data', () => { expect(component.availableBranches()).toEqual([]); }); @@ -367,7 +460,14 @@ describe('CveOverviewComponent', () => { }; vulnerabilityService.getAllVulnerabilitiesPaginated.and.returnValue(of(page)); component.fetchPage(0); - component.onFiltersChange({ severities: [], impact: [], branches: [], statuses: [], showUnmaintained: true }); + component.onFiltersChange({ + severities: [], + impact: [], + branches: [], + statuses: [], + languages: [], + showUnmaintained: true, + }); const branches = component.availableBranches(); const indexOf90 = branches.indexOf('9.0'); @@ -377,9 +477,55 @@ describe('CveOverviewComponent', () => { }); }); + describe('computed: availableLanguages()', () => { + it('returns empty available languages list when no data', () => { + expect(component.availableLanguages()).toEqual([]); + }); + + it('derives languages from occurrence purls, deduplicated', () => { + const page: VulnerabilityPage = { + content: [ + { + ...makeDetail('CVE-1'), + affectedReleasesByBranch: { + '9.0': [ + makeOccurrence('pkg:maven/org.example/lib@1.0.0'), + makeOccurrence('pkg:maven/org.example/other@1.0.0'), + ], + }, + }, + { + ...makeDetail('CVE-2'), + affectedReleasesByBranch: { '9.0': [makeOccurrence('pkg:npm/left-pad@1.0.0')] }, + }, + ], + totalElements: 2, + totalPages: 1, + number: 0, + size: 20, + last: true, + first: true, + }; + vulnerabilityService.getAllVulnerabilitiesPaginated.and.returnValue(of(page)); + component.fetchPage(0); + + expect(component.availableLanguages()).toEqual([ + { id: 'maven', label: 'Java (Maven)' }, + { id: 'npm', label: 'TypeScript (npm)' }, + ]); + }); + }); + describe('onFiltersChange()', () => { it('updates filters signal', () => { - const newFilters = { severities: ['HIGH'], impact: [], branches: [], statuses: [], showUnmaintained: false }; + const newFilters = { + severities: ['HIGH'], + impact: [], + branches: [], + statuses: [], + languages: [], + showUnmaintained: false, + }; component.onFiltersChange(newFilters); expect(component.filters()).toEqual(newFilters); diff --git a/src/main/frontend/src/app/pages/cve-overview/cve-overview.component.ts b/src/main/frontend/src/app/pages/cve-overview/cve-overview.component.ts index a03fd757..1c440a14 100644 --- a/src/main/frontend/src/app/pages/cve-overview/cve-overview.component.ts +++ b/src/main/frontend/src/app/pages/cve-overview/cve-overview.component.ts @@ -19,6 +19,8 @@ import { import { sortVersionsAsc } from '../../pipes/version-compare'; import { isBranchMaintained } from '../../pipes/branch-lifecycle'; import { ImpactLabelPipe } from '../../pipes/impact-label.pipe'; +import { languageFromPurl } from '../../pipes/language'; +import { LanguageOption } from '../../components/cve-filter-panel/cve-filter-panel.component'; @Component({ selector: 'app-cve-overview', @@ -70,9 +72,21 @@ export class CveOverviewComponent implements OnInit, OnDestroy { return sortVersionsAsc(showUnmaintained ? all : all.filter((branch) => maintainedMap.get(branch) !== false)); }); + public readonly availableLanguages = computed(() => { + const byId = new Map(); + + for (const detail of this.allDetails()) { + for (const language of CveOverviewComponent.languagesForDetail(detail)) { + if (!byId.has(language.id)) byId.set(language.id, { id: language.id, label: language.label }); + } + } + + return [...byId.values()].toSorted((optionA, optionB) => optionA.label.localeCompare(optionB.label)); + }); + public readonly activeFilterCount = computed(() => { - const { severities, impact, branches, statuses } = this.filters(); - return severities.length + impact.length + branches.length + statuses.length; + const { severities, impact, branches, statuses, languages } = this.filters(); + return severities.length + impact.length + branches.length + statuses.length + languages.length; }); public readonly filteredDetails = computed(() => { @@ -112,6 +126,19 @@ export class CveOverviewComponent implements OnInit, OnDestroy { }; } + private static languagesForDetail(detail: VulnerabilityDetail): LanguageOption[] { + const byId = new Map(); + + for (const occurrences of Object.values(detail.affectedReleasesByBranch)) { + for (const occurrence of occurrences) { + const language = languageFromPurl(occurrence.purl); + if (language && !byId.has(language.id)) byId.set(language.id, { id: language.id, label: language.label }); + } + } + + return [...byId.values()]; + } + private static branchKeyFromRelease(release: Release): string | null { const tagName = release.tagName?.replace(/^release\//, '') ?? ''; const versionMatch = tagName.match(/^v?(\d+\.\d+)\.\d+/); @@ -167,6 +194,21 @@ export class CveOverviewComponent implements OnInit, OnDestroy { return statuses.some((status) => CveOverviewComponent.matchesCveStatus(status, hasFix, isAffectedInMaintained)); } + private static matchesBranchesFilter(detail: VulnerabilityDetail, branches: string[]): boolean { + const allBranches = new Set([ + ...Object.keys(detail.affectedReleasesByBranch), + ...Object.keys(detail.shortTermFixByBranch), + ...Object.keys(detail.longTermFixByBranch), + ]); + + return branches.some((branch) => allBranches.has(branch)); + } + + private static matchesLanguagesFilter(detail: VulnerabilityDetail, languages: string[]): boolean { + const detailLanguageIds = new Set(CveOverviewComponent.languagesForDetail(detail).map((language) => language.id)); + return languages.some((language) => detailLanguageIds.has(language)); + } + ngOnInit(): void { this.searchSubject .pipe(debounceTime(CveOverviewComponent.SEARCH_DEBOUNCE_MS), distinctUntilChanged(), takeUntil(this.destroy$)) @@ -306,32 +348,16 @@ export class CveOverviewComponent implements OnInit, OnDestroy { filters: CveFilters, maintainedMap: Map, ): boolean { - const { severities, impact, branches, statuses, showUnmaintained } = filters; + const { severities, impact, branches, statuses, languages, showUnmaintained } = filters; - if (!showUnmaintained) { - const hasMaintainedAffected = Object.keys(detail.affectedReleasesByBranch).some( - (branch) => maintainedMap.get(branch) !== false, - ); - const hasFixData = - Object.keys(detail.shortTermFixByBranch).length > 0 || Object.keys(detail.longTermFixByBranch).length > 0; - - if (!hasMaintainedAffected && !hasFixData) return false; - } + if (!this.matchesMaintainedFilter(detail, showUnmaintained, maintainedMap)) return false; if (severities.length > 0 && !severities.includes(detail.vulnerability.severity)) return false; if (impact.length > 0 && !impact.includes(this.impactLabelPipe.transform(detail.vulnerability.impactScore))) return false; - if (branches.length > 0) { - const allBranches = new Set([ - ...Object.keys(detail.affectedReleasesByBranch), - ...Object.keys(detail.shortTermFixByBranch), - ...Object.keys(detail.longTermFixByBranch), - ]); - - if (!branches.some((branch) => allBranches.has(branch))) return false; - } + if (branches.length > 0 && !CveOverviewComponent.matchesBranchesFilter(detail, branches)) return false; if ( statuses.length > 0 && @@ -339,6 +365,24 @@ export class CveOverviewComponent implements OnInit, OnDestroy { ) return false; + if (languages.length > 0 && !CveOverviewComponent.matchesLanguagesFilter(detail, languages)) return false; + return true; } + + private matchesMaintainedFilter( + detail: VulnerabilityDetail, + showUnmaintained: boolean, + maintainedMap: Map, + ): boolean { + if (showUnmaintained) return true; + + const hasMaintainedAffected = Object.keys(detail.affectedReleasesByBranch).some( + (branch) => maintainedMap.get(branch) !== false, + ); + const hasFixData = + Object.keys(detail.shortTermFixByBranch).length > 0 || Object.keys(detail.longTermFixByBranch).length > 0; + + return hasMaintainedAffected || hasFixData; + } } diff --git a/src/main/frontend/src/app/pipes/language.ts b/src/main/frontend/src/app/pipes/language.ts new file mode 100644 index 00000000..c146fa78 --- /dev/null +++ b/src/main/frontend/src/app/pipes/language.ts @@ -0,0 +1,24 @@ +export interface LanguageInfo { + id: string; + label: string; + domain: 'frontend' | 'backend' | 'unknown'; +} + +const LANGUAGE_BY_PURL_TYPE: Record = { + maven: { label: 'Java (Maven)', domain: 'backend' }, + npm: { label: 'TypeScript (npm)', domain: 'frontend' }, +}; + +export function extractPurlType(purl: string | null | undefined): string | null { + if (!purl) return null; + const match = /^pkg:([^/]+)\//.exec(purl); + return match ? match[1].toLowerCase() : null; +} + +export function languageFromPurl(purl: string | null | undefined): LanguageInfo | null { + const type = extractPurlType(purl); + if (!type) return null; + + const known = LANGUAGE_BY_PURL_TYPE[type]; + return { id: type, label: known?.label ?? type, domain: known?.domain ?? 'unknown' }; +} diff --git a/src/main/frontend/src/app/pipes/version-compare.spec.ts b/src/main/frontend/src/app/pipes/version-compare.spec.ts index 1184276f..b80e436e 100644 --- a/src/main/frontend/src/app/pipes/version-compare.spec.ts +++ b/src/main/frontend/src/app/pipes/version-compare.spec.ts @@ -1,4 +1,4 @@ -import { parseVersion, compareVersions, sortVersionsAsc } from './version-compare'; +import { parseVersion, compareVersions, sortVersionsAsc, sortVersionsDesc } from './version-compare'; describe('parseVersion', () => { it('parses simple major.minor', () => { @@ -98,3 +98,27 @@ describe('sortVersionsAsc', () => { expect(result.at(-1)).toContain('9'); }); }); + +describe('sortVersionsDesc', () => { + it('sorts a simple list descending', () => { + expect(sortVersionsDesc(['9.0', '7.8', '8.1'])).toEqual(['9.0', '8.1', '7.8']); + }); + + it('does not mutate the original array (desc)', () => { + const original = ['9.0', '7.8', '8.1']; + const copy = [...original]; + sortVersionsDesc(original); + + expect(original).toEqual(copy); + }); + + it('returns empty array for empty input (desc)', () => { + expect(sortVersionsDesc([])).toEqual([]); + }); + + it('handles many branches in the right order (desc)', () => { + const input = ['9.1', '7.0', '8.0', '7.7', '9.0', '8.1', '7.8']; + + expect(sortVersionsDesc(input)).toEqual(['9.1', '9.0', '8.1', '8.0', '7.8', '7.7', '7.0']); + }); +}); diff --git a/src/main/frontend/src/app/pipes/version-compare.ts b/src/main/frontend/src/app/pipes/version-compare.ts index 1985a1a2..09e37a56 100644 --- a/src/main/frontend/src/app/pipes/version-compare.ts +++ b/src/main/frontend/src/app/pipes/version-compare.ts @@ -18,3 +18,7 @@ export function compareVersions(a: string, b: string): number { export function sortVersionsAsc(versions: string[]): string[] { return [...versions].toSorted(compareVersions); } + +export function sortVersionsDesc(versions: string[]): string[] { + return [...versions].toSorted((a, b) => compareVersions(b, a)); +} diff --git a/src/main/java/org/frankframework/insights/vulnerability/VulnerabilityService.java b/src/main/java/org/frankframework/insights/vulnerability/VulnerabilityService.java index af77d3dc..75e76b3a 100644 --- a/src/main/java/org/frankframework/insights/vulnerability/VulnerabilityService.java +++ b/src/main/java/org/frankframework/insights/vulnerability/VulnerabilityService.java @@ -43,6 +43,8 @@ public class VulnerabilityService { private static final int BUFFER_SIZE = 8192; private static final Pattern VERSION_TAG_PATTERN = Pattern.compile("^v?(\\d+\\.\\d+)\\.\\d+"); private static final Pattern VERSION_FAMILY_PATTERN = Pattern.compile("^(\\d+)\\.(\\d+)$"); + private static final Pattern ARCHIVE_ROOT_PATTERN = Pattern.compile("^frankframework-[^/\\\\]+[/\\\\]"); + private static final Set SKIP_EXTENSIONS = Set.of( ".java", ".cpp", @@ -687,14 +689,18 @@ private Map> groupAffectedByBranch private VulnerabilityOccurrenceResponse toOccurrenceResponse(ReleaseVulnerability occurrence) { return new VulnerabilityOccurrenceResponse( mapper.toDTO(occurrence.getRelease(), ReleaseResponse.class), - occurrence.getTarget(), + stripArchiveRoot(occurrence.getTarget()), occurrence.getPkgName(), - occurrence.getPkgPath(), + stripArchiveRoot(occurrence.getPkgPath()), occurrence.getPurl(), occurrence.getInstalledVersion(), occurrence.getFixedVersion()); } + private static String stripArchiveRoot(String path) { + return path == null ? null : ARCHIVE_ROOT_PATTERN.matcher(path).replaceFirst(""); + } + /** * Returns the latest versioned, non-nightly release in the same version family that comes * after the last affected release. This gives the most up-to-date patch in the same branch diff --git a/src/test/java/org/frankframework/insights/vulnerability/VulnerabilityServiceTest.java b/src/test/java/org/frankframework/insights/vulnerability/VulnerabilityServiceTest.java index 4ab86101..7f362a09 100644 --- a/src/test/java/org/frankframework/insights/vulnerability/VulnerabilityServiceTest.java +++ b/src/test/java/org/frankframework/insights/vulnerability/VulnerabilityServiceTest.java @@ -2141,6 +2141,28 @@ public void testBuildAffectedByBranch_KeepsMultipleOccurrencesForSameRelease() { assertTrue(occurrencesForBranch.stream().anyMatch(o -> "pkg-b".equals(o.pkgName()))); } + @Test + public void testBuildAffectedByBranch_StripsArchiveRootFromTargetAndPkgPath() { + Release r796 = makeRelease("id-796", "v7.9.6"); + ReleaseResponse response796 = new ReleaseResponse("id-796", "v7.9.6", "7.9.6", null, null, null); + when(mapper.toDTO(r796, ReleaseResponse.class)).thenReturn(response796); + + Vulnerability vuln = new Vulnerability(); + vuln.setCveId("CVE-2023-0001"); + + ReleaseVulnerability occurrence = new ReleaseVulnerability(r796, vuln); + occurrence.setTarget("frankframework-7.9.6/console/frontend/src/main/frontend/package-lock.json"); + occurrence.setPkgPath("frankframework-7.9.6/console/frontend/src/main/frontend/package-lock.json"); + occurrence.setPkgName("dompurify"); + + Map> result = + vulnerabilityService.buildAffectedByBranch(List.of(occurrence)); + + VulnerabilityOccurrenceResponse response = result.get("7.9").getFirst(); + assertEquals("console/frontend/src/main/frontend/package-lock.json", response.target()); + assertEquals("console/frontend/src/main/frontend/package-lock.json", response.pkgPath()); + } + private static Release makeRelease(String id, String tagName) { Release r = new Release(); r.setId(id); From 2f10177689fd8b828e904084b7e8c18ef2bfdd37 Mon Sep 17 00:00:00 2001 From: stijnpotters Date: Wed, 22 Jul 2026 15:40:57 +0200 Subject: [PATCH 3/4] feat: rebuilded table and opdated seeder --- src/main/resources/db/e2e/R__Seed_Data.sql | 18 ++++++------- ...__Add_Vulnerability_Occurrence_Columns.sql | 25 +++++++++---------- 2 files changed, 21 insertions(+), 22 deletions(-) diff --git a/src/main/resources/db/e2e/R__Seed_Data.sql b/src/main/resources/db/e2e/R__Seed_Data.sql index 5985d2fe..0a3f6c7d 100644 --- a/src/main/resources/db/e2e/R__Seed_Data.sql +++ b/src/main/resources/db/e2e/R__Seed_Data.sql @@ -220,12 +220,12 @@ MERGE INTO vulnerability_cwes (vulnerability_cve_id, cwes) KEY(vulnerability_cve ('CVE-2024-0007', 'CWE-400'), ('CVE-2024-0008', 'CWE-276'); -MERGE INTO release_vulnerability (release_id, vulnerability_cve_id) KEY(release_id, vulnerability_cve_id) VALUES - ('RE_kwDOAIg5ds4MnUo_', 'CVE-2024-0001'), - ('RE_kwDOAIg5ds4MnUo_', 'CVE-2024-0002'), - ('RE_kwDOAIg5ds4MnUo_', 'CVE-2024-0003'), - ('RE_kwDOAIg5ds4MnUo_', 'CVE-2024-0004'), - ('RE_kwDOAIg5ds4MnUo_', 'CVE-2024-0005'), - ('RE_kwDOAIg5ds4MnUo_', 'CVE-2024-0006'), - ('RE_kwDOAIg5ds4MnUo_', 'CVE-2024-0007'), - ('RE_kwDOAIg5ds4MnUo_', 'CVE-2024-0008'); +MERGE INTO release_vulnerability (id, release_id, vulnerability_cve_id) KEY(id) VALUES + ('11111111-1111-1111-1111-111111110001', 'RE_kwDOAIg5ds4MnUo_', 'CVE-2024-0001'), + ('11111111-1111-1111-1111-111111110002', 'RE_kwDOAIg5ds4MnUo_', 'CVE-2024-0002'), + ('11111111-1111-1111-1111-111111110003', 'RE_kwDOAIg5ds4MnUo_', 'CVE-2024-0003'), + ('11111111-1111-1111-1111-111111110004', 'RE_kwDOAIg5ds4MnUo_', 'CVE-2024-0004'), + ('11111111-1111-1111-1111-111111110005', 'RE_kwDOAIg5ds4MnUo_', 'CVE-2024-0005'), + ('11111111-1111-1111-1111-111111110006', 'RE_kwDOAIg5ds4MnUo_', 'CVE-2024-0006'), + ('11111111-1111-1111-1111-111111110007', 'RE_kwDOAIg5ds4MnUo_', 'CVE-2024-0007'), + ('11111111-1111-1111-1111-111111110008', 'RE_kwDOAIg5ds4MnUo_', 'CVE-2024-0008'); diff --git a/src/main/resources/db/migration/V1.12__Add_Vulnerability_Occurrence_Columns.sql b/src/main/resources/db/migration/V1.12__Add_Vulnerability_Occurrence_Columns.sql index 1b4e021b..169517a9 100644 --- a/src/main/resources/db/migration/V1.12__Add_Vulnerability_Occurrence_Columns.sql +++ b/src/main/resources/db/migration/V1.12__Add_Vulnerability_Occurrence_Columns.sql @@ -1,17 +1,16 @@ -DELETE FROM release_vulnerability; +DROP TABLE release_vulnerability; -ALTER TABLE release_vulnerability DROP CONSTRAINT release_vulnerability_pkey; - -ALTER TABLE release_vulnerability ADD COLUMN id UUID NOT NULL DEFAULT gen_random_uuid(); -ALTER TABLE release_vulnerability ADD PRIMARY KEY (id); -ALTER TABLE release_vulnerability ALTER COLUMN id DROP DEFAULT; - -ALTER TABLE release_vulnerability ADD COLUMN target VARCHAR(1024); -ALTER TABLE release_vulnerability ADD COLUMN pkg_name VARCHAR(512); -ALTER TABLE release_vulnerability ADD COLUMN pkg_path VARCHAR(1024); -ALTER TABLE release_vulnerability ADD COLUMN purl TEXT; -ALTER TABLE release_vulnerability ADD COLUMN installed_version VARCHAR(255); -ALTER TABLE release_vulnerability ADD COLUMN fixed_version VARCHAR(255); +CREATE TABLE release_vulnerability ( + id UUID NOT NULL PRIMARY KEY, + release_id VARCHAR(255) NOT NULL REFERENCES release(id), + vulnerability_cve_id VARCHAR(128) NOT NULL REFERENCES vulnerability(cve_id), + target VARCHAR(1024), + pkg_name VARCHAR(512), + pkg_path VARCHAR(1024), + purl TEXT, + installed_version VARCHAR(255), + fixed_version VARCHAR(255) +); CREATE INDEX idx_release_vulnerability_release_id ON release_vulnerability(release_id); CREATE INDEX idx_release_vulnerability_vulnerability_cve_id ON release_vulnerability(vulnerability_cve_id); From 3751b3562c3ad8dd46e12075664b789734f72200 Mon Sep 17 00:00:00 2001 From: stijnpotters Date: Mon, 27 Jul 2026 15:33:58 +0200 Subject: [PATCH 4/4] feat: improve tracking functions for package and occurrence in cve-overview --- .../cve-overview-right-panel.component.html | 7 ++----- .../cve-overview-right-panel.component.ts | 8 ++++++++ src/main/frontend/src/app/pipes/language.ts | 4 ++-- 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/src/main/frontend/src/app/pages/cve-overview/cve-overview-right-panel/cve-overview-right-panel.component.html b/src/main/frontend/src/app/pages/cve-overview/cve-overview-right-panel/cve-overview-right-panel.component.html index 9615cb12..31318a66 100644 --- a/src/main/frontend/src/app/pages/cve-overview/cve-overview-right-panel/cve-overview-right-panel.component.html +++ b/src/main/frontend/src/app/pages/cve-overview/cve-overview-right-panel/cve-overview-right-panel.component.html @@ -23,7 +23,7 @@ @if (hasPackageInfos()) {
- @for (pkg of packageInfos(); track pkg.pkgName + (pkg.pkgPath ?? '') + (pkg.target ?? '')) { + @for (pkg of packageInfos(); track trackPackageInfo(pkg)) { {{ pkg.pkgName }} @if (pkg.target) { @@ -106,10 +106,7 @@

Affected Releases

}
- @for ( - occurrence of sortedAffectedOccurrences(branch); - track occurrence.release.id + (occurrence.pkgPath ?? '') + (occurrence.target ?? '') - ) { + @for (occurrence of sortedAffectedOccurrences(branch); track trackOccurrence(occurrence)) { @if (occurrence.pkgName) {
{{ occurrence.release.tagName }} diff --git a/src/main/frontend/src/app/pages/cve-overview/cve-overview-right-panel/cve-overview-right-panel.component.ts b/src/main/frontend/src/app/pages/cve-overview/cve-overview-right-panel/cve-overview-right-panel.component.ts index f3338c96..04e8cee1 100644 --- a/src/main/frontend/src/app/pages/cve-overview/cve-overview-right-panel/cve-overview-right-panel.component.ts +++ b/src/main/frontend/src/app/pages/cve-overview/cve-overview-right-panel/cve-overview-right-panel.component.ts @@ -54,6 +54,14 @@ export class CveOverviewRightPanelComponent implements OnChanges { return (this.vulnerabilityDetail?.vulnerability?.cwes?.length ?? 0) > 0; } + public trackPackageInfo(package_: PackageInfo): string { + return `${package_.pkgName}|${package_.pkgPath ?? ''}|${package_.target ?? ''}`; + } + + public trackOccurrence(occurrence: VulnerabilityOccurrence): string { + return `${occurrence.release.id}|${occurrence.pkgPath ?? ''}|${occurrence.target ?? ''}`; + } + ngOnChanges(changes: SimpleChanges): void { if (TRACKED_INPUTS.some((inputKey) => inputKey in changes)) { this.updateBranchSignals(); diff --git a/src/main/frontend/src/app/pipes/language.ts b/src/main/frontend/src/app/pipes/language.ts index c146fa78..2dd1c9a2 100644 --- a/src/main/frontend/src/app/pipes/language.ts +++ b/src/main/frontend/src/app/pipes/language.ts @@ -9,13 +9,13 @@ const LANGUAGE_BY_PURL_TYPE: Record