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 }}
+
+ @for (occurrence of sortedAffectedOccurrences(branch); track trackOccurrence(occurrence)) {
+ @if (occurrence.pkgName) {
+
+ {{ occurrence.release.tagName }}
+ @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..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;
@@ -269,6 +296,37 @@
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-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..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
@@ -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'),
@@ -90,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');
@@ -111,36 +134,69 @@ 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([]);
+ });
+
+ 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()', () => {
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 +214,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 },
});
@@ -219,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()', () => {
@@ -248,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': [] },
});
@@ -262,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 fe69bd2f..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
@@ -1,8 +1,8 @@
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 } 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';
@@ -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,38 +45,83 @@ 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;
}
+ 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();
}
}
- 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] ?? [];
+ 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(
@@ -92,7 +143,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 c00869d5..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
@@ -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: {},
@@ -47,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;
@@ -283,6 +309,7 @@ describe('CveOverviewComponent', () => {
impact: [],
branches: [],
statuses: [],
+ languages: [],
showUnmaintained: true,
});
@@ -295,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,
});
@@ -330,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([]);
});
@@ -357,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');
@@ -367,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..2dd1c9a2
--- /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): string | null {
+ if (!purl) return null;
+ const match = /^pkg:([^/]+)\//.exec(purl);
+ return match ? match[1].toLowerCase() : null;
+}
+
+export function languageFromPurl(purl?: string | null): 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/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..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",
@@ -286,8 +288,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 +438,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 +493,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 +584,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 +634,71 @@ 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),
+ stripArchiveRoot(occurrence.getTarget()),
+ occurrence.getPkgName(),
+ 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("");
}
/**
@@ -853,12 +909,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/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
new file mode 100644
index 00000000..169517a9
--- /dev/null
+++ b/src/main/resources/db/migration/V1.12__Add_Vulnerability_Occurrence_Columns.sql
@@ -0,0 +1,16 @@
+DROP TABLE release_vulnerability;
+
+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);
diff --git a/src/test/java/org/frankframework/insights/vulnerability/VulnerabilityServiceTest.java b/src/test/java/org/frankframework/insights/vulnerability/VulnerabilityServiceTest.java
index 158c1ecc..7f362a09 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,67 @@ 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())));
+ }
+
+ @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);