From 5da8ef7fb4a08341ae62eec7847c063576ea7013 Mon Sep 17 00:00:00 2001 From: stijnpotters Date: Wed, 8 Jul 2026 11:38:09 +0200 Subject: [PATCH 1/6] fix: refactor vulnerability handling to improve upgrade recommendations --- .../cve-overview-right-panel.component.html | 24 ++- .../cve-overview-right-panel.component.scss | 11 ++ ...cve-overview-right-panel.component.spec.ts | 121 ++++++++++---- .../cve-overview-right-panel.component.ts | 54 +++++-- .../businessvalue/BusinessValueRequest.java | 5 +- .../UpdateBusinessValueRequest.java | 4 +- .../github/graphql/GitHubRefsDTO.java | 3 +- .../insights/issue/IssueRepository.java | 3 +- .../insights/label/LabelRepository.java | 3 +- .../VulnerabilityImpactRequest.java | 17 +- .../vulnerability/VulnerabilityService.java | 151 +++++++++++------- .../VulnerabilityServiceTest.java | 141 ++++++++++++++++ .../webhook/GitHubWebhookControllerTest.java | 3 +- 13 files changed, 422 insertions(+), 118 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 97608a7c..aac8b9d2 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 @@ -61,7 +61,7 @@

Description

} - @if (vulnerabilityDetail.vulnerability.cwes && vulnerabilityDetail.vulnerability.cwes.length > 0) { + @if (vulnerabilityDetail.vulnerability.cwes && hasCwes()) {

CWEs

@@ -72,9 +72,13 @@

CWEs

} - @if (affectedBranches().length > 0) { + @if (hasAffectedBranches()) {

Affected Releases

+

+ Releases whose bundled dependencies contain this CVE. The published date is when the vulnerability was + disclosed, not when it was introduced. A new CVE can affect releases that shipped years earlier. +

@for (branch of affectedBranches(); track branch) { @let fix = vulnerabilityDetail.shortTermFixByBranch[branch]; @@ -87,21 +91,25 @@

Affected Releases

fixed in {{ fix.tagName }} } + @if (isUnmaintained(branch)) { + unmaintained + }
}
} - @if (longTermBranches().length > 0) { + @if (hasRecommendedUpgrades()) {
-

Recommended Upgrade — Latest Stable

-

Upgrade to the newest stable Frank! Framework release

+

Recommended Upgrade

+

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

- @for (branch of longTermBranches(); track branch) { + @for (upgrade of recommendedUpgrades(); track upgrade.branch) {
- {{ branch }} - {{ vulnerabilityDetail.longTermFixByBranch[branch].tagName }} + {{ upgrade.branch }} + {{ upgrade.isLatestStable ? 'latest stable' : 'patched in' }} + {{ upgrade.release.tagName }}
}
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 7ecbaaa0..021825f8 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 @@ -275,6 +275,17 @@ color: #15803d; } +.tag-unmaintained { + margin-left: auto; + background: #f3f4f6; + border-color: #e5e7eb; + color: #6b7280; + font-size: 0.625rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.025em; +} + .branch-list { 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 931858c1..e1856859 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 @@ -58,12 +58,8 @@ describe('CveOverviewRightPanelComponent', () => { expect(component.affectedBranches()).toEqual([]); }); - it('shortTermBranches is empty when no vulnerabilityDetail', () => { - expect(component.shortTermBranches()).toEqual([]); - }); - - it('longTermBranches is empty when no vulnerabilityDetail', () => { - expect(component.longTermBranches()).toEqual([]); + it('recommendedUpgrades is empty when no vulnerabilityDetail', () => { + expect(component.recommendedUpgrades()).toEqual([]); }); it('shows empty state in DOM when no detail', () => { @@ -88,12 +84,10 @@ describe('CveOverviewRightPanelComponent', () => { expect(component.affectedBranches()).toEqual(['9.0']); }); - it('populates shortTermBranches from detail', () => { - expect(component.shortTermBranches()).toEqual(['9.0']); - }); - - it('longTermBranches is empty when no long-term fix', () => { - expect(component.longTermBranches()).toEqual([]); + it('populates recommendedUpgrades from the short-term fixes', () => { + expect(component.recommendedUpgrades()).toEqual([ + { branch: '9.0', release: makeRelease('r3', 'v9.0.2'), isLatestStable: false }, + ]); }); it('shows CVE ID in DOM', () => { @@ -143,33 +137,103 @@ describe('CveOverviewRightPanelComponent', () => { }); }); - describe('ngOnChanges branch filtering', () => { - it('filters unmaintained branches when showUnmaintained=false', () => { + describe('recommendedUpgrades()', () => { + it('lists per-branch patches first and the latest stable release last', () => { component.vulnerabilityDetail = makeDetail({ - affectedReleasesByBranch: { '9.0': [], '7.0': [] }, + affectedReleasesByBranch: { '9.2': [makeRelease('r1', 'v9.2.0')] }, + shortTermFixByBranch: { '9.2': makeRelease('r2', 'v9.2.1') }, + longTermFixByBranch: { '10.1': makeRelease('r3', 'v10.1.0') }, }); - component.branchMaintainedMap = new Map([['9.0', true], ['7.0', false]]); + component.branchMaintainedMap = new Map([ + ['9.2', true], + ['10.1', true], + ]); + component.ngOnChanges({ + vulnerabilityDetail: new SimpleChange(null, component.vulnerabilityDetail, true), + }); + + expect(component.recommendedUpgrades()).toEqual([ + { branch: '9.2', release: makeRelease('r2', 'v9.2.1'), isLatestStable: false }, + { branch: '10.1', release: makeRelease('r3', 'v10.1.0'), isLatestStable: true }, + ]); + }); + + it('merges a long-term fix that duplicates a short-term fix into a single entry', () => { + const patch = makeRelease('r2', 'v10.1.1'); + component.vulnerabilityDetail = makeDetail({ + affectedReleasesByBranch: { '10.1': [makeRelease('r1', 'v10.1.0')] }, + shortTermFixByBranch: { '10.1': patch }, + longTermFixByBranch: { '10.1': patch }, + }); + component.branchMaintainedMap = new Map([['10.1', true]]); + component.ngOnChanges({ + vulnerabilityDetail: new SimpleChange(null, component.vulnerabilityDetail, true), + }); + + expect(component.recommendedUpgrades()).toEqual([{ branch: '10.1', release: patch, isLatestStable: true }]); + }); + + it('filters upgrades for unmaintained branches when showUnmaintained=false', () => { + component.vulnerabilityDetail = makeDetail({ + shortTermFixByBranch: { '7.0': makeRelease('r2', 'v7.0.2') }, + longTermFixByBranch: { '10.1': makeRelease('r3', 'v10.1.0') }, + }); + component.branchMaintainedMap = new Map([ + ['7.0', false], + ['10.1', true], + ]); component.showUnmaintained = false; component.ngOnChanges({ vulnerabilityDetail: new SimpleChange(null, component.vulnerabilityDetail, true), }); - expect(component.affectedBranches()).toContain('9.0'); - expect(component.affectedBranches()).not.toContain('7.0'); + expect(component.recommendedUpgrades().map((upgrade) => upgrade.branch)).toEqual(['10.1']); }); - it('shows all branches when showUnmaintained=true', () => { + it('includes upgrades for unmaintained branches when showUnmaintained=true', () => { component.vulnerabilityDetail = makeDetail({ - affectedReleasesByBranch: { '9.0': [], '7.0': [] }, + shortTermFixByBranch: { '7.0': makeRelease('r2', 'v7.0.2') }, + longTermFixByBranch: { '10.1': makeRelease('r3', 'v10.1.0') }, }); - component.branchMaintainedMap = new Map([['9.0', true], ['7.0', false]]); + component.branchMaintainedMap = new Map([ + ['7.0', false], + ['10.1', true], + ]); component.showUnmaintained = true; component.ngOnChanges({ - showUnmaintained: new SimpleChange(false, true, false), + vulnerabilityDetail: new SimpleChange(null, component.vulnerabilityDetail, true), + }); + + expect(component.recommendedUpgrades().map((upgrade) => upgrade.branch)).toEqual(['7.0', '10.1']); + }); + }); + + describe('ngOnChanges branch handling', () => { + it('always shows affected branches, even unmaintained ones', () => { + component.vulnerabilityDetail = makeDetail({ + affectedReleasesByBranch: { '9.0': [], '7.0': [] }, + }); + component.branchMaintainedMap = new Map([ + ['9.0', true], + ['7.0', false], + ]); + component.showUnmaintained = false; + component.ngOnChanges({ + vulnerabilityDetail: new SimpleChange(null, component.vulnerabilityDetail, true), }); - expect(component.affectedBranches()).toContain('9.0'); - expect(component.affectedBranches()).toContain('7.0'); + expect(component.affectedBranches()).toEqual(['7.0', '9.0']); + }); + + it('flags unmaintained branches through isUnmaintained()', () => { + component.branchMaintainedMap = new Map([ + ['9.0', true], + ['7.0', false], + ]); + + expect(component.isUnmaintained('7.0')).toBeTrue(); + expect(component.isUnmaintained('9.0')).toBeFalse(); + expect(component.isUnmaintained('8.0')).toBeFalse(); }); it('clears all branch signals when vulnerabilityDetail set to null', () => { @@ -184,15 +248,18 @@ describe('CveOverviewRightPanelComponent', () => { }); expect(component.affectedBranches()).toEqual([]); - expect(component.shortTermBranches()).toEqual([]); - expect(component.longTermBranches()).toEqual([]); + expect(component.recommendedUpgrades()).toEqual([]); }); it('sorts affected branches ascending', () => { component.vulnerabilityDetail = makeDetail({ affectedReleasesByBranch: { '9.0': [], '7.8': [], '8.1': [] }, }); - component.branchMaintainedMap = new Map([['9.0', true], ['7.8', true], ['8.1', true]]); + component.branchMaintainedMap = new Map([ + ['9.0', true], + ['7.8', true], + ['8.1', true], + ]); component.showUnmaintained = false; component.ngOnChanges({ vulnerabilityDetail: new SimpleChange(null, component.vulnerabilityDetail, true), 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 2a6925c3..3a514355 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,4 +1,4 @@ -import { Component, Input, OnChanges, SimpleChanges, signal } from '@angular/core'; +import { Component, Input, OnChanges, SimpleChanges, WritableSignal, computed, signal } from '@angular/core'; import { sortVersionsAsc, compareVersions } from '../../../pipes/version-compare'; import { CommonModule, NgOptimizedImage } from '@angular/common'; import { RouterLink } from '@angular/router'; @@ -10,6 +10,12 @@ import { MarkdownPipe } from '../../../pipes/markdown.pipe'; const TRACKED_INPUTS = ['vulnerabilityDetail', 'branchMaintainedMap', 'showUnmaintained'] as const; +export interface RecommendedUpgrade { + branch: string; + release: ReleaseInfo; + isLatestStable: boolean; +} + @Component({ selector: 'app-cve-right-panel', standalone: true, @@ -23,9 +29,14 @@ export class CveOverviewRightPanelComponent implements OnChanges { @Input() showUnmaintained = false; @Input() canManage = false; - public readonly affectedBranches = signal([]); - public readonly shortTermBranches = signal([]); - public readonly longTermBranches = signal([]); + public readonly affectedBranches: WritableSignal = signal([]); + public readonly recommendedUpgrades: WritableSignal = signal([]); + public readonly hasAffectedBranches = computed(() => this.affectedBranches().length > 0); + public readonly hasRecommendedUpgrades = computed(() => this.recommendedUpgrades().length > 0); + + public hasCwes(): boolean { + return (this.vulnerabilityDetail?.vulnerability?.cwes?.length ?? 0) > 0; + } ngOnChanges(changes: SimpleChanges): void { if (TRACKED_INPUTS.some((inputKey) => inputKey in changes)) { @@ -38,17 +49,42 @@ export class CveOverviewRightPanelComponent implements OnChanges { return [...releases].toSorted((releaseA, releaseB) => compareVersions(releaseA.tagName, releaseB.tagName)); } + public isUnmaintained(branch: string): boolean { + return this.branchMaintainedMap.get(branch) === false; + } + private updateBranchSignals(): void { if (!this.vulnerabilityDetail) { this.affectedBranches.set([]); - this.shortTermBranches.set([]); - this.longTermBranches.set([]); + this.recommendedUpgrades.set([]); return; } const { affectedReleasesByBranch, shortTermFixByBranch, longTermFixByBranch } = this.vulnerabilityDetail; - this.affectedBranches.set(this.filterBranches(affectedReleasesByBranch)); - this.shortTermBranches.set(this.filterBranches(shortTermFixByBranch)); - this.longTermBranches.set(this.filterBranches(longTermFixByBranch)); + + this.affectedBranches.set(sortVersionsAsc(Object.keys(affectedReleasesByBranch))); + this.recommendedUpgrades.set(this.buildRecommendedUpgrades(shortTermFixByBranch, longTermFixByBranch)); + } + + private buildRecommendedUpgrades( + shortTermFixByBranch: Record, + longTermFixByBranch: Record, + ): RecommendedUpgrade[] { + const latestStable = this.filterBranches(longTermFixByBranch).map((branch) => ({ + branch, + release: longTermFixByBranch[branch], + isLatestStable: true, + })); + + const latestStableIds = new Set(latestStable.map((upgrade) => upgrade.release.id)); + const branchPatches = this.filterBranches(shortTermFixByBranch).map((branch) => ({ + branch, + release: shortTermFixByBranch[branch], + isLatestStable: latestStableIds.has(shortTermFixByBranch[branch].id), + })); + + const patchIds = new Set(branchPatches.map((upgrade) => upgrade.release.id)); + + return [...branchPatches, ...latestStable.filter((upgrade) => !patchIds.has(upgrade.release.id))]; } private filterBranches(branchMap: object): string[] { diff --git a/src/main/java/org/frankframework/insights/businessvalue/BusinessValueRequest.java b/src/main/java/org/frankframework/insights/businessvalue/BusinessValueRequest.java index 8f0a249f..af6f2451 100644 --- a/src/main/java/org/frankframework/insights/businessvalue/BusinessValueRequest.java +++ b/src/main/java/org/frankframework/insights/businessvalue/BusinessValueRequest.java @@ -5,7 +5,10 @@ public record BusinessValueRequest( @NotBlank @Size(min = 1, max = MAX_TITLE_LENGTH) String title, - @NotBlank @Size(min = 1, max = MAX_DESCRIPTION_LENGTH) String description, + + @NotBlank @Size(min = 1, max = MAX_DESCRIPTION_LENGTH) + String description, + @NotBlank String releaseId) { private static final int MAX_TITLE_LENGTH = 255; diff --git a/src/main/java/org/frankframework/insights/businessvalue/UpdateBusinessValueRequest.java b/src/main/java/org/frankframework/insights/businessvalue/UpdateBusinessValueRequest.java index 57a2abf9..de871a63 100644 --- a/src/main/java/org/frankframework/insights/businessvalue/UpdateBusinessValueRequest.java +++ b/src/main/java/org/frankframework/insights/businessvalue/UpdateBusinessValueRequest.java @@ -5,7 +5,9 @@ public record UpdateBusinessValueRequest( @NotBlank @Size(min = 1, max = MAX_TITLE_LENGTH) String title, - @NotBlank @Size(min = 1, max = MAX_DESCRIPTION_LENGTH) String description) { + + @NotBlank @Size(min = 1, max = MAX_DESCRIPTION_LENGTH) + String description) { private static final int MAX_TITLE_LENGTH = 255; private static final int MAX_DESCRIPTION_LENGTH = 1000; diff --git a/src/main/java/org/frankframework/insights/github/graphql/GitHubRefsDTO.java b/src/main/java/org/frankframework/insights/github/graphql/GitHubRefsDTO.java index 04ec8ba6..18e21370 100644 --- a/src/main/java/org/frankframework/insights/github/graphql/GitHubRefsDTO.java +++ b/src/main/java/org/frankframework/insights/github/graphql/GitHubRefsDTO.java @@ -8,7 +8,8 @@ public record GitHubRefsDTO(@JsonProperty("nodes") List nod @JsonIgnoreProperties(ignoreUnknown = true) public record GitHubBranchNodeDTO( - @JsonProperty("name") String name, @JsonProperty("target") GitHubTargetDTO target) {} + @JsonProperty("name") String name, + @JsonProperty("target") GitHubTargetDTO target) {} public record GitHubTargetDTO(@JsonProperty("history") GitHubTotalCountDTO history) {} } diff --git a/src/main/java/org/frankframework/insights/issue/IssueRepository.java b/src/main/java/org/frankframework/insights/issue/IssueRepository.java index fee7fe5d..6c1da85c 100644 --- a/src/main/java/org/frankframework/insights/issue/IssueRepository.java +++ b/src/main/java/org/frankframework/insights/issue/IssueRepository.java @@ -14,8 +14,7 @@ public interface IssueRepository extends JpaRepository { * cases, a custom @Query is more readable and maintainable than a very long * derived method name. */ - @Query( - """ + @Query(""" SELECT DISTINCT i FROM Issue i JOIN PullRequestIssue pri ON pri.issue = i diff --git a/src/main/java/org/frankframework/insights/label/LabelRepository.java b/src/main/java/org/frankframework/insights/label/LabelRepository.java index 4a4776c6..6842ded9 100644 --- a/src/main/java/org/frankframework/insights/label/LabelRepository.java +++ b/src/main/java/org/frankframework/insights/label/LabelRepository.java @@ -8,8 +8,7 @@ @Repository public interface LabelRepository extends JpaRepository { - @Query( - """ + @Query(""" SELECT il.label FROM IssueLabel il WHERE il.issue.id IN ( diff --git a/src/main/java/org/frankframework/insights/vulnerability/VulnerabilityImpactRequest.java b/src/main/java/org/frankframework/insights/vulnerability/VulnerabilityImpactRequest.java index 5f540ce6..f291b81b 100644 --- a/src/main/java/org/frankframework/insights/vulnerability/VulnerabilityImpactRequest.java +++ b/src/main/java/org/frankframework/insights/vulnerability/VulnerabilityImpactRequest.java @@ -7,15 +7,16 @@ public record VulnerabilityImpactRequest( @NotNull - @Min(value = 0, message = "Impact score must be between 0 and 10") - @Max(value = MAX_IMPACT_SCORE, message = "Impact score must be between 0 and 10") - Double impactScore, + @Min(value = 0, message = "Impact score must be between 0 and 10") + @Max(value = MAX_IMPACT_SCORE, message = "Impact score must be between 0 and 10") + Double impactScore, + @NotNull - @Size( - min = 1, - max = MAX_DESCRIPTION_LENGTH, - message = "Impact description must be between 1 and 1000 characters") - String impactDescription) { + @Size( + min = 1, + max = MAX_DESCRIPTION_LENGTH, + message = "Impact description must be between 1 and 1000 characters") + String impactDescription) { private static final int MAX_IMPACT_SCORE = 10; private static final int MAX_DESCRIPTION_LENGTH = 1000; diff --git a/src/main/java/org/frankframework/insights/vulnerability/VulnerabilityService.java b/src/main/java/org/frankframework/insights/vulnerability/VulnerabilityService.java index eb0fda83..8a8f4313 100644 --- a/src/main/java/org/frankframework/insights/vulnerability/VulnerabilityService.java +++ b/src/main/java/org/frankframework/insights/vulnerability/VulnerabilityService.java @@ -10,6 +10,8 @@ import java.util.regex.Matcher; import java.util.regex.Pattern; import java.util.stream.Collectors; +import java.util.stream.IntStream; +import java.util.stream.Stream; import java.util.zip.ZipEntry; import java.util.zip.ZipFile; import lombok.extern.slf4j.Slf4j; @@ -41,6 +43,7 @@ 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 FULL_VERSION_PATTERN = Pattern.compile("^v?(\\d+(?:\\.\\d+)+)"); private static final Set SKIP_EXTENSIONS = Set.of( ".java", ".cpp", @@ -138,6 +141,12 @@ public class VulnerabilityService { new Sort.Order(Sort.Direction.DESC, "publishedAt", Sort.NullHandling.NULLS_LAST), new Sort.Order(Sort.Direction.ASC, "cveId")); + private static final Comparator NEWEST_BRANCH_FIRST = Comparator.comparing( + VulnerabilityService::parseVersionParts, + Comparator.comparingInt((int[] version) -> version[0]) + .thenComparingInt(version -> version[1]) + .reversed()); + private final VulnerabilityRepository vulnerabilityRepository; private final ReleaseVulnerabilityRepository releaseVulnerabilityRepository; private final ReleaseRepository releaseRepository; @@ -620,9 +629,17 @@ private VulnerabilityDetailResponse toVulnerabilityDetailResponse( } private Map> buildAffectedByBranch(List affectedReleases) { - return affectedReleases.stream() + Map> versioned = groupAffectedByBranch(affectedReleases.stream() .filter(release -> !isNightly(release)) .filter(release -> parseVersionParts(getEffectiveBranchKey(release)) != null) + .toList()); + + if (!versioned.isEmpty()) return versioned; + return groupAffectedByBranch(affectedReleases); + } + + private Map> groupAffectedByBranch(List affectedReleases) { + return affectedReleases.stream() .collect(Collectors.groupingBy( VulnerabilityService::getEffectiveBranchKey, Collectors.mapping( @@ -637,26 +654,26 @@ private Map> buildAffectedByBranch(List a */ private Map buildShortTermFix( List affectedReleases, Map> allByEffectiveBranch) { - Map> affectedIdsByBranch = groupAffectedIdsByBranch(affectedReleases); - Map result = new LinkedHashMap<>(); - - for (Map.Entry> entry : affectedIdsByBranch.entrySet()) { - String branchKey = entry.getKey(); - if (parseVersionParts(branchKey) == null) continue; - - List allInFamily = allByEffectiveBranch.getOrDefault(branchKey, List.of()); - int lastAffectedIndex = findLastAffectedIndex(allInFamily, entry.getValue()); - findLatestCleanRelease(allInFamily, lastAffectedIndex) - .ifPresent(release -> result.put(branchKey, mapper.toDTO(release, ReleaseResponse.class))); - } + return groupAffectedIdsByBranch(affectedReleases).entrySet().stream() + .filter(entry -> parseVersionParts(entry.getKey()) != null) + .flatMap(entry -> findBranchFix(entry.getKey(), entry.getValue(), allByEffectiveBranch).stream()) + .collect(Collectors.toMap( + Map.Entry::getKey, Map.Entry::getValue, (first, second) -> first, LinkedHashMap::new)); + } - return result; + private Optional> findBranchFix( + String branchKey, Set affectedIds, Map> allByEffectiveBranch) { + List allInFamily = sortedByVersion(allByEffectiveBranch.getOrDefault(branchKey, List.of())); + int lastAffectedIndex = findLastAffectedIndex(allInFamily, affectedIds); + return findLatestCleanRelease(allInFamily, lastAffectedIndex) + .map(release -> Map.entry(branchKey, mapper.toDTO(release, ReleaseResponse.class))); } /** - * Returns the latest versioned, non-nightly release from the single newest version family - * that is strictly newer than the highest affected versioned branch. This gives one clear - * "recommended upgrade" to the most current stable release of the Frank! Framework. + * Returns the latest scanned-clean release of the newest version family that is at least as + * new as the highest affected versioned branch. A branch that is itself affected can still be + * the target as long as it has a clean release after its last affected one (e.g. 10.1 affected + * in 10.1.0 but clean since 10.1.1 still recommends the latest 10.1 patch). * Non-versioned families (e.g. "master", "master-nightly") are never shown as upgrade targets. */ private Map buildLongTermFix( @@ -666,20 +683,34 @@ private Map buildLongTermFix( .collect(Collectors.toSet()); int[] maxAffectedVersion = findMaxAffectedVersion(affectedKeys).orElse(null); - if (maxAffectedVersion == null) return Collections.emptyMap(); - Optional newestBranch = - findNewestUnaffectedBranch(allByEffectiveBranch, affectedKeys, maxAffectedVersion); - if (newestBranch.isEmpty()) return Collections.emptyMap(); + return findNewestBranchFix(affectedReleases, allByEffectiveBranch, maxAffectedVersion); + } - String branchKey = newestBranch.get(); - Map result = new LinkedHashMap<>(); + private Map findNewestBranchFix( + List affectedReleases, Map> allByEffectiveBranch, int[] maxAffectedVersion) { + Map> affectedIdsByBranch = groupAffectedIdsByBranch(affectedReleases); - findLatestCleanRelease(allByEffectiveBranch.get(branchKey), -1) - .ifPresent(release -> result.put(branchKey, mapper.toDTO(release, ReleaseResponse.class))); + return candidateBranchesNewestFirst(allByEffectiveBranch.keySet(), maxAffectedVersion) + .flatMap( + branchKey -> findBranchFix( + branchKey, affectedIdsByBranch.getOrDefault(branchKey, Set.of()), allByEffectiveBranch) + .stream()) + .findFirst() + .map(entry -> Map.of(entry.getKey(), entry.getValue())) + .orElseGet(Collections::emptyMap); + } - return result; + private static Stream candidateBranchesNewestFirst(Set branchKeys, int[] maxAffectedVersion) { + return branchKeys.stream() + .filter(branchKey -> isUpgradeCandidateBranch(branchKey, maxAffectedVersion)) + .sorted(NEWEST_BRANCH_FIRST); + } + + private static boolean isUpgradeCandidateBranch(String branchKey, int[] maxAffectedVersion) { + int[] version = parseVersionParts(branchKey); + return version != null && !isVersionNewerThan(maxAffectedVersion, version); } private static Map> groupAffectedIdsByBranch(List affectedReleases) { @@ -690,24 +721,23 @@ private static Map> groupAffectedIdsByBranch(List a } private static int findLastAffectedIndex(List releases, Set affectedIds) { - int lastIndex = -1; - for (int i = 0; i < releases.size(); i++) { - if (affectedIds.contains(releases.get(i).getId())) { - lastIndex = i; - } - } + return IntStream.range(0, releases.size()) + .filter(index -> affectedIds.contains(releases.get(index).getId())) + .max() + .orElse(-1); + } - return lastIndex; + private static boolean isCleanUpgradeCandidate(Release release) { + return !isNightly(release) + && parseVersionParts(getEffectiveBranchKey(release)) != null + && release.getLastScanned() != null; } private static Optional findLatestCleanRelease(List releases, int afterIndex) { - for (int i = releases.size() - 1; i > afterIndex; i--) { - Release candidate = releases.get(i); - if (!isNightly(candidate) && parseVersionParts(getEffectiveBranchKey(candidate)) != null) { - return Optional.of(candidate); - } - } - return Optional.empty(); + return IntStream.range(afterIndex + 1, releases.size()) + .mapToObj(releases::get) + .filter(VulnerabilityService::isCleanUpgradeCandidate) + .reduce((first, second) -> second); } private static Optional findMaxAffectedVersion(Set affectedKeys) { @@ -717,25 +747,32 @@ private static Optional findMaxAffectedVersion(Set affectedKeys) .max(Comparator.comparingInt((int[] version) -> version[0]).thenComparingInt(version -> version[1])); } - private static Optional findNewestUnaffectedBranch( - Map> allByEffectiveBranch, Set affectedKeys, int[] minVersion) { - String newestBranchKey = null; - int[] newestVersion = null; - - for (Map.Entry> entry : allByEffectiveBranch.entrySet()) { - String branchKey = entry.getKey(); - if (affectedKeys.contains(branchKey)) continue; - - int[] version = parseVersionParts(branchKey); - if (version == null || !isVersionNewerThan(version, minVersion)) continue; + private static List sortedByVersion(List releases) { + return releases.stream() + .sorted(Comparator.comparing( + VulnerabilityService::parseFullVersion, + Comparator.nullsFirst(VulnerabilityService::compareVersionArrays)) + .thenComparing(Release::getPublishedAt, Comparator.nullsFirst(Comparator.naturalOrder()))) + .toList(); + } - if (newestVersion == null || isVersionNewerThan(version, newestVersion)) { - newestVersion = version; - newestBranchKey = branchKey; - } - } + private static int[] parseFullVersion(Release release) { + if (release.getTagName() == null) return null; + String tag = release.getTagName().replaceFirst("^release/", ""); + Matcher matches = FULL_VERSION_PATTERN.matcher(tag); + if (!matches.find()) return null; + return Arrays.stream(matches.group(1).split("\\.")) + .mapToInt(Integer::parseInt) + .toArray(); + } - return Optional.ofNullable(newestBranchKey); + private static int compareVersionArrays(int[] left, int[] right) { + return IntStream.range(0, Math.max(left.length, right.length)) + .map(index -> + Integer.compare(index < left.length ? left[index] : 0, index < right.length ? right[index] : 0)) + .filter(comparison -> comparison != 0) + .findFirst() + .orElse(0); } private static boolean isVersionNewerThan(int[] version, int[] reference) { diff --git a/src/test/java/org/frankframework/insights/vulnerability/VulnerabilityServiceTest.java b/src/test/java/org/frankframework/insights/vulnerability/VulnerabilityServiceTest.java index c2d0416c..5e8ae9c9 100644 --- a/src/test/java/org/frankframework/insights/vulnerability/VulnerabilityServiceTest.java +++ b/src/test/java/org/frankframework/insights/vulnerability/VulnerabilityServiceTest.java @@ -1827,6 +1827,53 @@ public void testBuildShortTermFix_ReturnsLatestCleanPatch() throws Exception { assertEquals("v7.7.4", result.get("7.7").tagName()); } + @Test + public void testBuildShortTermFix_HandlesUnsortedReleaseLists() throws Exception { + Release r920 = makeRelease("id-920", "v9.2.0"); + Release r921 = makeRelease("id-921", "v9.2.1"); + Release r922 = makeRelease("id-922", "v9.2.2"); + + List affectedReleases = List.of(r920); + Map> allByBranch = Map.of("9.2", List.of(r922, r920, r921)); + + ReleaseResponse response922 = new ReleaseResponse("id-922", "v9.2.2", "9.2.2", null, null, null); + when(mapper.toDTO(r922, ReleaseResponse.class)).thenReturn(response922); + + java.lang.reflect.Method method = + VulnerabilityService.class.getDeclaredMethod("buildShortTermFix", List.class, Map.class); + method.setAccessible(true); + + @SuppressWarnings("unchecked") + Map result = + (Map) method.invoke(vulnerabilityService, affectedReleases, allByBranch); + + assertEquals("v9.2.2", result.get("9.2").tagName()); + } + + @Test + public void testBuildShortTermFix_SkipsUnscannedReleases() throws Exception { + Release r920 = makeRelease("id-920", "v9.2.0"); + Release r921 = makeRelease("id-921", "v9.2.1"); + Release r922 = makeRelease("id-922", "v9.2.2"); + r922.setLastScanned(null); + + List affectedReleases = List.of(r920); + Map> allByBranch = Map.of("9.2", List.of(r920, r921, r922)); + + ReleaseResponse response921 = new ReleaseResponse("id-921", "v9.2.1", "9.2.1", null, null, null); + when(mapper.toDTO(r921, ReleaseResponse.class)).thenReturn(response921); + + java.lang.reflect.Method method = + VulnerabilityService.class.getDeclaredMethod("buildShortTermFix", List.class, Map.class); + method.setAccessible(true); + + @SuppressWarnings("unchecked") + Map result = + (Map) method.invoke(vulnerabilityService, affectedReleases, allByBranch); + + assertEquals("v9.2.1", result.get("9.2").tagName()); + } + @Test public void testBuildShortTermFix_NoResultWhenAllReleasesAreAffected() throws Exception { Release r771 = makeRelease("id-771", "v7.7.1"); @@ -1929,10 +1976,104 @@ public void testBuildLongTermFix_EmptyWhenNoNewerCleanBranch() throws Exception assertTrue(result.isEmpty()); } + @Test + public void testBuildLongTermFix_NewestBranchAffectedButPatched() throws Exception { + Release r920 = makeRelease("id-920", "v9.2.0"); + Release r1010 = makeRelease("id-1010", "v10.1.0"); + Release r1011 = makeRelease("id-1011", "v10.1.1"); + Release r1012 = makeRelease("id-1012", "v10.1.2"); + + List affectedReleases = List.of(r920, r1010); + Map> allByBranch = new java.util.LinkedHashMap<>(); + allByBranch.put("9.2", List.of(r920)); + allByBranch.put("10.1", List.of(r1010, r1011, r1012)); + + ReleaseResponse response1012 = new ReleaseResponse("id-1012", "v10.1.2", "10.1.2", null, null, null); + when(mapper.toDTO(r1012, ReleaseResponse.class)).thenReturn(response1012); + + java.lang.reflect.Method method = + VulnerabilityService.class.getDeclaredMethod("buildLongTermFix", List.class, Map.class); + method.setAccessible(true); + + @SuppressWarnings("unchecked") + Map result = + (Map) method.invoke(vulnerabilityService, affectedReleases, allByBranch); + + assertEquals(1, result.size()); + assertEquals("v10.1.2", result.get("10.1").tagName()); + } + + @Test + public void testBuildLongTermFix_SkipsFullyAffectedNewestBranchAndOlderBranches() throws Exception { + Release r920 = makeRelease("id-920", "v9.2.0"); + Release r900 = makeRelease("id-900", "v9.0.0"); + Release r1010 = makeRelease("id-1010", "v10.1.0"); + + List affectedReleases = List.of(r920, r1010); + Map> allByBranch = new java.util.LinkedHashMap<>(); + allByBranch.put("9.0", List.of(r900)); + allByBranch.put("9.2", List.of(r920)); + allByBranch.put("10.1", List.of(r1010)); + + java.lang.reflect.Method method = + VulnerabilityService.class.getDeclaredMethod("buildLongTermFix", List.class, Map.class); + method.setAccessible(true); + + @SuppressWarnings("unchecked") + Map result = + (Map) method.invoke(vulnerabilityService, affectedReleases, allByBranch); + + assertTrue(result.isEmpty()); + } + + @Test + public void testBuildAffectedByBranch_FallsBackToNonVersionedReleases() throws Exception { + Release nightly = makeRelease("id-nightly", "nightly"); + org.frankframework.insights.branch.Branch masterBranch = new org.frankframework.insights.branch.Branch(); + masterBranch.setName("master"); + nightly.setBranch(masterBranch); + + ReleaseResponse nightlyResponse = new ReleaseResponse("id-nightly", "nightly", "nightly", null, null, null); + when(mapper.toDTO(nightly, ReleaseResponse.class)).thenReturn(nightlyResponse); + + java.lang.reflect.Method method = + VulnerabilityService.class.getDeclaredMethod("buildAffectedByBranch", List.class); + method.setAccessible(true); + + @SuppressWarnings("unchecked") + Map> result = + (Map>) method.invoke(vulnerabilityService, List.of(nightly)); + + assertEquals(1, result.size()); + assertTrue(result.containsKey("master")); + assertEquals("nightly", result.get("master").getFirst().tagName()); + } + + @Test + public void testBuildAffectedByBranch_PrefersVersionedReleasesOverNightly() throws Exception { + Release nightly = makeRelease("id-nightly", "nightly"); + 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); + + java.lang.reflect.Method method = + VulnerabilityService.class.getDeclaredMethod("buildAffectedByBranch", List.class); + method.setAccessible(true); + + @SuppressWarnings("unchecked") + Map> result = + (Map>) method.invoke(vulnerabilityService, List.of(nightly, r920)); + + assertEquals(1, result.size()); + assertTrue(result.containsKey("9.2")); + } + private static Release makeRelease(String id, String tagName) { Release r = new Release(); r.setId(id); r.setTagName(tagName); + r.setLastScanned(java.time.OffsetDateTime.now()); return r; } } diff --git a/src/test/java/org/frankframework/insights/webhook/GitHubWebhookControllerTest.java b/src/test/java/org/frankframework/insights/webhook/GitHubWebhookControllerTest.java index 3e3785b4..83ef3fa0 100644 --- a/src/test/java/org/frankframework/insights/webhook/GitHubWebhookControllerTest.java +++ b/src/test/java/org/frankframework/insights/webhook/GitHubWebhookControllerTest.java @@ -140,8 +140,7 @@ public void handleWebhook_whenReleaseActionIsNotPublished_returns200WithoutRefre @Test public void handleWebhook_withFullGitHubReleasePayload_triggersRefreshAndReturns202() throws Exception { - String fullPayload = - """ + String fullPayload = """ { "action": "published", "release": { From a602f9aa724a616132abadbdc2daf7d0f02ce730 Mon Sep 17 00:00:00 2001 From: stijnpotters Date: Wed, 8 Jul 2026 14:44:14 +0200 Subject: [PATCH 2/6] fix: enhance upgrade recommendation logic for better branch visibility and stability --- ...cve-overview-right-panel.component.spec.ts | 27 ++++----- .../cve-overview-right-panel.component.ts | 42 +++++++++----- .../vulnerability/VulnerabilityService.java | 55 +++++++++++++------ .../VulnerabilityServiceTest.java | 8 +-- 4 files changed, 82 insertions(+), 50 deletions(-) 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 e1856859..0bf30f66 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 @@ -4,12 +4,12 @@ import { CveOverviewRightPanelComponent } from './cve-overview-right-panel.compo import { VulnerabilityDetail, ReleaseInfo } from '../../../services/vulnerability.service'; import { SimpleChange } from '@angular/core'; -const makeRelease = (id: string, tagName: string): ReleaseInfo => ({ +const makeRelease = (id: string, tagName: string, branchName = 'release/9.0'): ReleaseInfo => ({ id, tagName, name: tagName, publishedAt: '2024-01-01', - branch: { id: 'b1', name: 'release/9.0' }, + branch: { id: 'b1', name: branchName }, }); const makeDetail = (overrides: Partial = {}): VulnerabilityDetail => ({ @@ -138,30 +138,27 @@ describe('CveOverviewRightPanelComponent', () => { }); describe('recommendedUpgrades()', () => { - it('lists per-branch patches first and the latest stable release last', () => { + it('lists per-branch patches first and the minimum long-term fix last', () => { component.vulnerabilityDetail = makeDetail({ affectedReleasesByBranch: { '9.2': [makeRelease('r1', 'v9.2.0')] }, shortTermFixByBranch: { '9.2': makeRelease('r2', 'v9.2.1') }, - longTermFixByBranch: { '10.1': makeRelease('r3', 'v10.1.0') }, + longTermFixByBranch: { '9.2': makeRelease('r3', 'v10.1.0', 'release/10.1') }, }); - component.branchMaintainedMap = new Map([ - ['9.2', true], - ['10.1', true], - ]); + component.branchMaintainedMap = new Map([['9.2', true]]); component.ngOnChanges({ vulnerabilityDetail: new SimpleChange(null, component.vulnerabilityDetail, true), }); expect(component.recommendedUpgrades()).toEqual([ { branch: '9.2', release: makeRelease('r2', 'v9.2.1'), isLatestStable: false }, - { branch: '10.1', release: makeRelease('r3', 'v10.1.0'), isLatestStable: true }, + { branch: '10.1', release: makeRelease('r3', 'v10.1.0', 'release/10.1'), isLatestStable: true }, ]); }); it('merges a long-term fix that duplicates a short-term fix into a single entry', () => { - const patch = makeRelease('r2', 'v10.1.1'); + const patch = makeRelease('r2', 'v10.1.1', 'release/10.1'); component.vulnerabilityDetail = makeDetail({ - affectedReleasesByBranch: { '10.1': [makeRelease('r1', 'v10.1.0')] }, + affectedReleasesByBranch: { '10.1': [makeRelease('r1', 'v10.1.0', 'release/10.1')] }, shortTermFixByBranch: { '10.1': patch }, longTermFixByBranch: { '10.1': patch }, }); @@ -176,11 +173,11 @@ describe('CveOverviewRightPanelComponent', () => { it('filters upgrades for unmaintained branches when showUnmaintained=false', () => { component.vulnerabilityDetail = makeDetail({ shortTermFixByBranch: { '7.0': makeRelease('r2', 'v7.0.2') }, - longTermFixByBranch: { '10.1': makeRelease('r3', 'v10.1.0') }, + longTermFixByBranch: { '8.0': makeRelease('r3', 'v10.1.0', 'release/10.1') }, }); component.branchMaintainedMap = new Map([ ['7.0', false], - ['10.1', true], + ['8.0', true], ]); component.showUnmaintained = false; component.ngOnChanges({ @@ -193,11 +190,11 @@ describe('CveOverviewRightPanelComponent', () => { it('includes upgrades for unmaintained branches when showUnmaintained=true', () => { component.vulnerabilityDetail = makeDetail({ shortTermFixByBranch: { '7.0': makeRelease('r2', 'v7.0.2') }, - longTermFixByBranch: { '10.1': makeRelease('r3', 'v10.1.0') }, + longTermFixByBranch: { '8.0': makeRelease('r3', 'v10.1.0', 'release/10.1') }, }); component.branchMaintainedMap = new Map([ ['7.0', false], - ['10.1', true], + ['8.0', true], ]); component.showUnmaintained = true; component.ngOnChanges({ 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 3a514355..236d208b 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 @@ -69,14 +69,26 @@ export class CveOverviewRightPanelComponent implements OnChanges { shortTermFixByBranch: Record, longTermFixByBranch: Record, ): RecommendedUpgrade[] { - const latestStable = this.filterBranches(longTermFixByBranch).map((branch) => ({ - branch, - release: longTermFixByBranch[branch], - isLatestStable: true, - })); + const latestStableByReleaseId = new Map(); - const latestStableIds = new Set(latestStable.map((upgrade) => upgrade.release.id)); - const branchPatches = this.filterBranches(shortTermFixByBranch).map((branch) => ({ + for (const [affectedBranch, release] of Object.entries(longTermFixByBranch)) { + if (!this.isBranchVisible(affectedBranch)) continue; + if (!latestStableByReleaseId.has(release.id)) { + latestStableByReleaseId.set(release.id, { + branch: this.releaseBranchKey(release), + release, + isLatestStable: true, + }); + } + } + + const latestStable = [...latestStableByReleaseId.values()]; + const latestStableIds = new Set(latestStableByReleaseId.keys()); + const visiblePatchBranches = sortVersionsAsc( + Object.keys(shortTermFixByBranch).filter((branch) => this.isBranchVisible(branch)), + ); + + const branchPatches = visiblePatchBranches.map((branch) => ({ branch, release: shortTermFixByBranch[branch], isLatestStable: latestStableIds.has(shortTermFixByBranch[branch].id), @@ -87,11 +99,15 @@ export class CveOverviewRightPanelComponent implements OnChanges { return [...branchPatches, ...latestStable.filter((upgrade) => !patchIds.has(upgrade.release.id))]; } - private filterBranches(branchMap: object): string[] { - return sortVersionsAsc( - Object.keys(branchMap).filter( - (branch) => this.showUnmaintained || this.branchMaintainedMap.get(branch) !== false, - ), - ); + private isBranchVisible(branch: string): boolean { + return this.showUnmaintained || this.branchMaintainedMap.get(branch) !== false; + } + + private releaseBranchKey(release: ReleaseInfo): string { + const name = release.branch?.name ?? ''; + const stripped = name.replace(/^.*\//, ''); + if (stripped) return stripped; + const tagMatch = release.tagName.match(/^v?(\d+\.\d+)/); + return tagMatch ? tagMatch[1] : name; } } diff --git a/src/main/java/org/frankframework/insights/vulnerability/VulnerabilityService.java b/src/main/java/org/frankframework/insights/vulnerability/VulnerabilityService.java index 8a8f4313..b573fe55 100644 --- a/src/main/java/org/frankframework/insights/vulnerability/VulnerabilityService.java +++ b/src/main/java/org/frankframework/insights/vulnerability/VulnerabilityService.java @@ -43,7 +43,6 @@ 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 FULL_VERSION_PATTERN = Pattern.compile("^v?(\\d+(?:\\.\\d+)+)"); private static final Set SKIP_EXTENSIONS = Set.of( ".java", ".cpp", @@ -669,11 +668,20 @@ private Optional> findBranchFix( .map(release -> Map.entry(branchKey, mapper.toDTO(release, ReleaseResponse.class))); } + private Optional> findBranchLatestStable( + String branchKey, Set affectedIds, Map> allByEffectiveBranch) { + List allInFamily = sortedByVersion(allByEffectiveBranch.getOrDefault(branchKey, List.of())); + int lastAffectedIndex = findLastAffectedIndex(allInFamily, affectedIds); + return findNewestVersionedRelease(allInFamily, lastAffectedIndex) + .map(release -> Map.entry(branchKey, mapper.toDTO(release, ReleaseResponse.class))); + } + /** - * Returns the latest scanned-clean release of the newest version family that is at least as - * new as the highest affected versioned branch. A branch that is itself affected can still be - * the target as long as it has a clean release after its last affected one (e.g. 10.1 affected - * in 10.1.0 but clean since 10.1.1 still recommends the latest 10.1 patch). + * Returns the newest versioned (non-nightly) release of the newest version family that is at least as + * new as the highest affected versioned branch. Unlike the short-term fix, scan status is NOT required: + * unscanned releases are included so that the recommendation always points to the actual latest stable, + * even when a release has not yet been analysed by Trivy. + * Keyed by the fix branch name so the frontend can display it as a "latest stable" recommendation. * Non-versioned families (e.g. "master", "master-nightly") are never shown as upgrade targets. */ private Map buildLongTermFix( @@ -685,16 +693,11 @@ private Map buildLongTermFix( int[] maxAffectedVersion = findMaxAffectedVersion(affectedKeys).orElse(null); if (maxAffectedVersion == null) return Collections.emptyMap(); - return findNewestBranchFix(affectedReleases, allByEffectiveBranch, maxAffectedVersion); - } - - private Map findNewestBranchFix( - List affectedReleases, Map> allByEffectiveBranch, int[] maxAffectedVersion) { Map> affectedIdsByBranch = groupAffectedIdsByBranch(affectedReleases); return candidateBranchesNewestFirst(allByEffectiveBranch.keySet(), maxAffectedVersion) .flatMap( - branchKey -> findBranchFix( + branchKey -> findBranchLatestStable( branchKey, affectedIdsByBranch.getOrDefault(branchKey, Set.of()), allByEffectiveBranch) .stream()) .findFirst() @@ -733,6 +736,10 @@ && parseVersionParts(getEffectiveBranchKey(release)) != null && release.getLastScanned() != null; } + private static boolean isVersionedRelease(Release release) { + return !isNightly(release) && parseVersionParts(getEffectiveBranchKey(release)) != null; + } + private static Optional findLatestCleanRelease(List releases, int afterIndex) { return IntStream.range(afterIndex + 1, releases.size()) .mapToObj(releases::get) @@ -740,6 +747,13 @@ private static Optional findLatestCleanRelease(List releases, .reduce((first, second) -> second); } + private static Optional findNewestVersionedRelease(List releases, int afterIndex) { + return IntStream.range(afterIndex + 1, releases.size()) + .mapToObj(releases::get) + .filter(VulnerabilityService::isVersionedRelease) + .reduce((first, second) -> second); + } + private static Optional findMaxAffectedVersion(Set affectedKeys) { return affectedKeys.stream() .map(VulnerabilityService::parseVersionParts) @@ -757,13 +771,18 @@ private static List sortedByVersion(List releases) { } private static int[] parseFullVersion(Release release) { - if (release.getTagName() == null) return null; - String tag = release.getTagName().replaceFirst("^release/", ""); - Matcher matches = FULL_VERSION_PATTERN.matcher(tag); - if (!matches.find()) return null; - return Arrays.stream(matches.group(1).split("\\.")) - .mapToInt(Integer::parseInt) - .toArray(); + String tagName = release.getTagName(); + if (tagName == null) return null; + + String tag = tagName.replaceFirst("^release/", "").replaceFirst("^v", ""); + String[] parts = tag.split("\\.", -1); + if (parts.length < 2) return null; + + try { + return Arrays.stream(parts).mapToInt(Integer::parseInt).toArray(); + } catch (NumberFormatException e) { + return null; + } } private static int compareVersionArrays(int[] left, int[] right) { diff --git a/src/test/java/org/frankframework/insights/vulnerability/VulnerabilityServiceTest.java b/src/test/java/org/frankframework/insights/vulnerability/VulnerabilityServiceTest.java index 5e8ae9c9..98781b0f 100644 --- a/src/test/java/org/frankframework/insights/vulnerability/VulnerabilityServiceTest.java +++ b/src/test/java/org/frankframework/insights/vulnerability/VulnerabilityServiceTest.java @@ -1896,8 +1896,7 @@ public void testBuildShortTermFix_NoResultWhenAllReleasesAreAffected() throws Ex // ── buildLongTermFix ─────────────────────────────────────────────────────────────────────── @Test - public void testBuildLongTermFix_ReturnsSingleNewestBranch() throws Exception { - // 7.7 is affected; 8.0, 9.0, 9.3 are available → should return ONLY 9.3 + public void testBuildLongTermFix_ReturnsNewestCleanBranch() throws Exception { Release r771 = makeRelease("id-771", "v7.7.1"); Release r800 = makeRelease("id-800", "v8.0.0"); Release r900 = makeRelease("id-900", "v9.0.0"); @@ -1922,13 +1921,12 @@ public void testBuildLongTermFix_ReturnsSingleNewestBranch() throws Exception { (Map) method.invoke(vulnerabilityService, affectedReleases, allByBranch); assertEquals(1, result.size(), "Should return exactly one long-term recommendation"); - assertTrue(result.containsKey("9.3"), "Should recommend the newest branch (9.3), not 8.0 or 9.0"); + assertTrue(result.containsKey("9.3"), "Key should be the fix branch (9.3), the newest clean branch"); assertEquals("v9.3.1", result.get("9.3").tagName()); } @Test public void testBuildLongTermFix_ReturnsLatestPatchOfNewestBranch() throws Exception { - // 7.7 is affected; 9.3 has multiple releases → should recommend the latest (v9.3.3) Release r771 = makeRelease("id-771", "v7.7.1"); Release r931 = makeRelease("id-931", "v9.3.1"); Release r932 = makeRelease("id-932", "v9.3.2"); @@ -1951,6 +1949,7 @@ public void testBuildLongTermFix_ReturnsLatestPatchOfNewestBranch() throws Excep (Map) method.invoke(vulnerabilityService, affectedReleases, allByBranch); assertEquals(1, result.size()); + assertTrue(result.containsKey("9.3")); assertEquals("v9.3.3", result.get("9.3").tagName()); } @@ -2000,6 +1999,7 @@ public void testBuildLongTermFix_NewestBranchAffectedButPatched() throws Excepti (Map) method.invoke(vulnerabilityService, affectedReleases, allByBranch); assertEquals(1, result.size()); + assertTrue(result.containsKey("10.1"), "Key should be the fix branch (10.1)"); assertEquals("v10.1.2", result.get("10.1").tagName()); } From 3d52404ba63dfae46dd2f0d3cbdbe69bf31aa475 Mon Sep 17 00:00:00 2001 From: stijnpotters Date: Wed, 8 Jul 2026 14:53:35 +0200 Subject: [PATCH 3/6] fix: update release label text for consistency and removed intellij javadoc --- .../cve-overview-right-panel.component.html | 2 +- .../insights/vulnerability/VulnerabilityService.java | 8 -------- 2 files changed, 1 insertion(+), 9 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 aac8b9d2..74264c8c 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 @@ -108,7 +108,7 @@

Recommended Upgrade

@for (upgrade of recommendedUpgrades(); track upgrade.branch) {
{{ upgrade.branch }} - {{ upgrade.isLatestStable ? 'latest stable' : 'patched in' }} + {{ upgrade.isLatestStable ? 'Latest stable' : 'Patched in' }} {{ upgrade.release.tagName }}
} diff --git a/src/main/java/org/frankframework/insights/vulnerability/VulnerabilityService.java b/src/main/java/org/frankframework/insights/vulnerability/VulnerabilityService.java index b573fe55..c183a9a0 100644 --- a/src/main/java/org/frankframework/insights/vulnerability/VulnerabilityService.java +++ b/src/main/java/org/frankframework/insights/vulnerability/VulnerabilityService.java @@ -676,14 +676,6 @@ private Optional> findBranchLatestStable( .map(release -> Map.entry(branchKey, mapper.toDTO(release, ReleaseResponse.class))); } - /** - * Returns the newest versioned (non-nightly) release of the newest version family that is at least as - * new as the highest affected versioned branch. Unlike the short-term fix, scan status is NOT required: - * unscanned releases are included so that the recommendation always points to the actual latest stable, - * even when a release has not yet been analysed by Trivy. - * Keyed by the fix branch name so the frontend can display it as a "latest stable" recommendation. - * Non-versioned families (e.g. "master", "master-nightly") are never shown as upgrade targets. - */ private Map buildLongTermFix( List affectedReleases, Map> allByEffectiveBranch) { Set affectedKeys = affectedReleases.stream() From 9ace774b4db37f60ca310305238f3b5e2a6a61b8 Mon Sep 17 00:00:00 2001 From: stijnpotters Date: Wed, 8 Jul 2026 14:58:11 +0200 Subject: [PATCH 4/6] fix: change method visibility to protected for better accessibility in VulnerabilityService --- .../vulnerability/VulnerabilityService.java | 6 +- .../VulnerabilityServiceTest.java | 88 +++---------------- 2 files changed, 14 insertions(+), 80 deletions(-) diff --git a/src/main/java/org/frankframework/insights/vulnerability/VulnerabilityService.java b/src/main/java/org/frankframework/insights/vulnerability/VulnerabilityService.java index c183a9a0..59e9e57d 100644 --- a/src/main/java/org/frankframework/insights/vulnerability/VulnerabilityService.java +++ b/src/main/java/org/frankframework/insights/vulnerability/VulnerabilityService.java @@ -627,7 +627,7 @@ private VulnerabilityDetailResponse toVulnerabilityDetailResponse( buildLongTermFix(affected, allByEffectiveBranch)); } - private Map> buildAffectedByBranch(List affectedReleases) { + protected Map> buildAffectedByBranch(List affectedReleases) { Map> versioned = groupAffectedByBranch(affectedReleases.stream() .filter(release -> !isNightly(release)) .filter(release -> parseVersionParts(getEffectiveBranchKey(release)) != null) @@ -651,7 +651,7 @@ private Map> groupAffectedByBranch(List a * Only considers families with a parseable version key (e.g. "7.4"), so non-versioned * families like "master" or "master-nightly" are never shown as upgrade targets. */ - private Map buildShortTermFix( + protected Map buildShortTermFix( List affectedReleases, Map> allByEffectiveBranch) { return groupAffectedIdsByBranch(affectedReleases).entrySet().stream() .filter(entry -> parseVersionParts(entry.getKey()) != null) @@ -676,7 +676,7 @@ private Optional> findBranchLatestStable( .map(release -> Map.entry(branchKey, mapper.toDTO(release, ReleaseResponse.class))); } - private Map buildLongTermFix( + protected Map buildLongTermFix( List affectedReleases, Map> allByEffectiveBranch) { Set affectedKeys = affectedReleases.stream() .map(VulnerabilityService::getEffectiveBranchKey) diff --git a/src/test/java/org/frankframework/insights/vulnerability/VulnerabilityServiceTest.java b/src/test/java/org/frankframework/insights/vulnerability/VulnerabilityServiceTest.java index 98781b0f..2d35daa9 100644 --- a/src/test/java/org/frankframework/insights/vulnerability/VulnerabilityServiceTest.java +++ b/src/test/java/org/frankframework/insights/vulnerability/VulnerabilityServiceTest.java @@ -1814,13 +1814,7 @@ public void testBuildShortTermFix_ReturnsLatestCleanPatch() throws Exception { ReleaseResponse response774 = new ReleaseResponse("id-774", "v7.7.4", "7.7.4", null, null, null); when(mapper.toDTO(r774, ReleaseResponse.class)).thenReturn(response774); - java.lang.reflect.Method method = - VulnerabilityService.class.getDeclaredMethod("buildShortTermFix", List.class, Map.class); - method.setAccessible(true); - - @SuppressWarnings("unchecked") - Map result = - (Map) method.invoke(vulnerabilityService, affectedReleases, allByBranch); + Map result = vulnerabilityService.buildShortTermFix(affectedReleases, allByBranch); assertEquals(1, result.size()); assertTrue(result.containsKey("7.7")); @@ -1839,13 +1833,7 @@ public void testBuildShortTermFix_HandlesUnsortedReleaseLists() throws Exception ReleaseResponse response922 = new ReleaseResponse("id-922", "v9.2.2", "9.2.2", null, null, null); when(mapper.toDTO(r922, ReleaseResponse.class)).thenReturn(response922); - java.lang.reflect.Method method = - VulnerabilityService.class.getDeclaredMethod("buildShortTermFix", List.class, Map.class); - method.setAccessible(true); - - @SuppressWarnings("unchecked") - Map result = - (Map) method.invoke(vulnerabilityService, affectedReleases, allByBranch); + Map result = vulnerabilityService.buildShortTermFix(affectedReleases, allByBranch); assertEquals("v9.2.2", result.get("9.2").tagName()); } @@ -1863,13 +1851,7 @@ public void testBuildShortTermFix_SkipsUnscannedReleases() throws Exception { ReleaseResponse response921 = new ReleaseResponse("id-921", "v9.2.1", "9.2.1", null, null, null); when(mapper.toDTO(r921, ReleaseResponse.class)).thenReturn(response921); - java.lang.reflect.Method method = - VulnerabilityService.class.getDeclaredMethod("buildShortTermFix", List.class, Map.class); - method.setAccessible(true); - - @SuppressWarnings("unchecked") - Map result = - (Map) method.invoke(vulnerabilityService, affectedReleases, allByBranch); + Map result = vulnerabilityService.buildShortTermFix(affectedReleases, allByBranch); assertEquals("v9.2.1", result.get("9.2").tagName()); } @@ -1882,13 +1864,7 @@ public void testBuildShortTermFix_NoResultWhenAllReleasesAreAffected() throws Ex List affectedReleases = List.of(r771, r772); Map> allByBranch = Map.of("7.7", List.of(r771, r772)); - java.lang.reflect.Method method = - VulnerabilityService.class.getDeclaredMethod("buildShortTermFix", List.class, Map.class); - method.setAccessible(true); - - @SuppressWarnings("unchecked") - Map result = - (Map) method.invoke(vulnerabilityService, affectedReleases, allByBranch); + Map result = vulnerabilityService.buildShortTermFix(affectedReleases, allByBranch); assertTrue(result.isEmpty()); } @@ -1912,13 +1888,7 @@ public void testBuildLongTermFix_ReturnsNewestCleanBranch() throws Exception { ReleaseResponse response931 = new ReleaseResponse("id-931", "v9.3.1", "9.3.1", null, null, null); when(mapper.toDTO(r931, ReleaseResponse.class)).thenReturn(response931); - java.lang.reflect.Method method = - VulnerabilityService.class.getDeclaredMethod("buildLongTermFix", List.class, Map.class); - method.setAccessible(true); - - @SuppressWarnings("unchecked") - Map result = - (Map) method.invoke(vulnerabilityService, affectedReleases, allByBranch); + Map result = vulnerabilityService.buildLongTermFix(affectedReleases, allByBranch); assertEquals(1, result.size(), "Should return exactly one long-term recommendation"); assertTrue(result.containsKey("9.3"), "Key should be the fix branch (9.3), the newest clean branch"); @@ -1940,13 +1910,7 @@ public void testBuildLongTermFix_ReturnsLatestPatchOfNewestBranch() throws Excep ReleaseResponse response933 = new ReleaseResponse("id-933", "v9.3.3", "9.3.3", null, null, null); when(mapper.toDTO(r933, ReleaseResponse.class)).thenReturn(response933); - java.lang.reflect.Method method = - VulnerabilityService.class.getDeclaredMethod("buildLongTermFix", List.class, Map.class); - method.setAccessible(true); - - @SuppressWarnings("unchecked") - Map result = - (Map) method.invoke(vulnerabilityService, affectedReleases, allByBranch); + Map result = vulnerabilityService.buildLongTermFix(affectedReleases, allByBranch); assertEquals(1, result.size()); assertTrue(result.containsKey("9.3")); @@ -1964,13 +1928,7 @@ public void testBuildLongTermFix_EmptyWhenNoNewerCleanBranch() throws Exception allByBranch.put("7.7", List.of(r771)); allByBranch.put("8.0", List.of(r800)); - java.lang.reflect.Method method = - VulnerabilityService.class.getDeclaredMethod("buildLongTermFix", List.class, Map.class); - method.setAccessible(true); - - @SuppressWarnings("unchecked") - Map result = - (Map) method.invoke(vulnerabilityService, affectedReleases, allByBranch); + Map result = vulnerabilityService.buildLongTermFix(affectedReleases, allByBranch); assertTrue(result.isEmpty()); } @@ -1990,13 +1948,7 @@ public void testBuildLongTermFix_NewestBranchAffectedButPatched() throws Excepti ReleaseResponse response1012 = new ReleaseResponse("id-1012", "v10.1.2", "10.1.2", null, null, null); when(mapper.toDTO(r1012, ReleaseResponse.class)).thenReturn(response1012); - java.lang.reflect.Method method = - VulnerabilityService.class.getDeclaredMethod("buildLongTermFix", List.class, Map.class); - method.setAccessible(true); - - @SuppressWarnings("unchecked") - Map result = - (Map) method.invoke(vulnerabilityService, affectedReleases, allByBranch); + Map result = vulnerabilityService.buildLongTermFix(affectedReleases, allByBranch); assertEquals(1, result.size()); assertTrue(result.containsKey("10.1"), "Key should be the fix branch (10.1)"); @@ -2015,13 +1967,7 @@ public void testBuildLongTermFix_SkipsFullyAffectedNewestBranchAndOlderBranches( allByBranch.put("9.2", List.of(r920)); allByBranch.put("10.1", List.of(r1010)); - java.lang.reflect.Method method = - VulnerabilityService.class.getDeclaredMethod("buildLongTermFix", List.class, Map.class); - method.setAccessible(true); - - @SuppressWarnings("unchecked") - Map result = - (Map) method.invoke(vulnerabilityService, affectedReleases, allByBranch); + Map result = vulnerabilityService.buildLongTermFix(affectedReleases, allByBranch); assertTrue(result.isEmpty()); } @@ -2036,13 +1982,7 @@ public void testBuildAffectedByBranch_FallsBackToNonVersionedReleases() throws E ReleaseResponse nightlyResponse = new ReleaseResponse("id-nightly", "nightly", "nightly", null, null, null); when(mapper.toDTO(nightly, ReleaseResponse.class)).thenReturn(nightlyResponse); - java.lang.reflect.Method method = - VulnerabilityService.class.getDeclaredMethod("buildAffectedByBranch", List.class); - method.setAccessible(true); - - @SuppressWarnings("unchecked") - Map> result = - (Map>) method.invoke(vulnerabilityService, List.of(nightly)); + Map> result = vulnerabilityService.buildAffectedByBranch(List.of(nightly)); assertEquals(1, result.size()); assertTrue(result.containsKey("master")); @@ -2057,13 +1997,7 @@ public void testBuildAffectedByBranch_PrefersVersionedReleasesOverNightly() thro ReleaseResponse response920 = new ReleaseResponse("id-920", "v9.2.0", "9.2.0", null, null, null); when(mapper.toDTO(r920, ReleaseResponse.class)).thenReturn(response920); - java.lang.reflect.Method method = - VulnerabilityService.class.getDeclaredMethod("buildAffectedByBranch", List.class); - method.setAccessible(true); - - @SuppressWarnings("unchecked") - Map> result = - (Map>) method.invoke(vulnerabilityService, List.of(nightly, r920)); + Map> result = vulnerabilityService.buildAffectedByBranch(List.of(nightly, r920)); assertEquals(1, result.size()); assertTrue(result.containsKey("9.2")); From 7c082164f48945d8e8fab9cc16a7c16c50a9c582 Mon Sep 17 00:00:00 2001 From: stijnpotters Date: Mon, 20 Jul 2026 09:30:16 +0200 Subject: [PATCH 5/6] fix: update method to return empty array instead of null and removed redundant exception throws --- .../vulnerability/VulnerabilityService.java | 8 +++--- .../VulnerabilityServiceTest.java | 27 ++++++++----------- 2 files changed, 15 insertions(+), 20 deletions(-) diff --git a/src/main/java/org/frankframework/insights/vulnerability/VulnerabilityService.java b/src/main/java/org/frankframework/insights/vulnerability/VulnerabilityService.java index 59e9e57d..116009e2 100644 --- a/src/main/java/org/frankframework/insights/vulnerability/VulnerabilityService.java +++ b/src/main/java/org/frankframework/insights/vulnerability/VulnerabilityService.java @@ -764,16 +764,16 @@ private static List sortedByVersion(List releases) { private static int[] parseFullVersion(Release release) { String tagName = release.getTagName(); - if (tagName == null) return null; + if (tagName == null) return new int[0]; String tag = tagName.replaceFirst("^release/", "").replaceFirst("^v", ""); String[] parts = tag.split("\\.", -1); - if (parts.length < 2) return null; + if (parts.length < 2) return new int[0]; try { return Arrays.stream(parts).mapToInt(Integer::parseInt).toArray(); - } catch (NumberFormatException e) { - return null; + } catch (NumberFormatException exception) { + return new int[0]; } } diff --git a/src/test/java/org/frankframework/insights/vulnerability/VulnerabilityServiceTest.java b/src/test/java/org/frankframework/insights/vulnerability/VulnerabilityServiceTest.java index 2d35daa9..158c1ecc 100644 --- a/src/test/java/org/frankframework/insights/vulnerability/VulnerabilityServiceTest.java +++ b/src/test/java/org/frankframework/insights/vulnerability/VulnerabilityServiceTest.java @@ -1798,11 +1798,8 @@ public void testScanWithSlashInTagName_CallsDownloadWithCorrectTagName() throws } } - // ── buildShortTermFix ────────────────────────────────────────────────────────────────────── - @Test - public void testBuildShortTermFix_ReturnsLatestCleanPatch() throws Exception { - // 7.7.1 and 7.7.2 are affected; 7.7.3 and 7.7.4 are clean → should recommend 7.7.4 + public void testBuildShortTermFix_ReturnsLatestCleanPatch() { Release r771 = makeRelease("id-771", "v7.7.1"); Release r772 = makeRelease("id-772", "v7.7.2"); Release r773 = makeRelease("id-773", "v7.7.3"); @@ -1822,7 +1819,7 @@ public void testBuildShortTermFix_ReturnsLatestCleanPatch() throws Exception { } @Test - public void testBuildShortTermFix_HandlesUnsortedReleaseLists() throws Exception { + public void testBuildShortTermFix_HandlesUnsortedReleaseLists() { Release r920 = makeRelease("id-920", "v9.2.0"); Release r921 = makeRelease("id-921", "v9.2.1"); Release r922 = makeRelease("id-922", "v9.2.2"); @@ -1839,7 +1836,7 @@ public void testBuildShortTermFix_HandlesUnsortedReleaseLists() throws Exception } @Test - public void testBuildShortTermFix_SkipsUnscannedReleases() throws Exception { + public void testBuildShortTermFix_SkipsUnscannedReleases() { Release r920 = makeRelease("id-920", "v9.2.0"); Release r921 = makeRelease("id-921", "v9.2.1"); Release r922 = makeRelease("id-922", "v9.2.2"); @@ -1857,7 +1854,7 @@ public void testBuildShortTermFix_SkipsUnscannedReleases() throws Exception { } @Test - public void testBuildShortTermFix_NoResultWhenAllReleasesAreAffected() throws Exception { + public void testBuildShortTermFix_NoResultWhenAllReleasesAreAffected() { Release r771 = makeRelease("id-771", "v7.7.1"); Release r772 = makeRelease("id-772", "v7.7.2"); @@ -1869,10 +1866,8 @@ public void testBuildShortTermFix_NoResultWhenAllReleasesAreAffected() throws Ex assertTrue(result.isEmpty()); } - // ── buildLongTermFix ─────────────────────────────────────────────────────────────────────── - @Test - public void testBuildLongTermFix_ReturnsNewestCleanBranch() throws Exception { + public void testBuildLongTermFix_ReturnsNewestCleanBranch() { Release r771 = makeRelease("id-771", "v7.7.1"); Release r800 = makeRelease("id-800", "v8.0.0"); Release r900 = makeRelease("id-900", "v9.0.0"); @@ -1896,7 +1891,7 @@ public void testBuildLongTermFix_ReturnsNewestCleanBranch() throws Exception { } @Test - public void testBuildLongTermFix_ReturnsLatestPatchOfNewestBranch() throws Exception { + public void testBuildLongTermFix_ReturnsLatestPatchOfNewestBranch() { Release r771 = makeRelease("id-771", "v7.7.1"); Release r931 = makeRelease("id-931", "v9.3.1"); Release r932 = makeRelease("id-932", "v9.3.2"); @@ -1918,7 +1913,7 @@ public void testBuildLongTermFix_ReturnsLatestPatchOfNewestBranch() throws Excep } @Test - public void testBuildLongTermFix_EmptyWhenNoNewerCleanBranch() throws Exception { + public void testBuildLongTermFix_EmptyWhenNoNewerCleanBranch() { // All branches are affected → no long-term fix Release r771 = makeRelease("id-771", "v7.7.1"); Release r800 = makeRelease("id-800", "v8.0.0"); @@ -1934,7 +1929,7 @@ public void testBuildLongTermFix_EmptyWhenNoNewerCleanBranch() throws Exception } @Test - public void testBuildLongTermFix_NewestBranchAffectedButPatched() throws Exception { + public void testBuildLongTermFix_NewestBranchAffectedButPatched() { Release r920 = makeRelease("id-920", "v9.2.0"); Release r1010 = makeRelease("id-1010", "v10.1.0"); Release r1011 = makeRelease("id-1011", "v10.1.1"); @@ -1956,7 +1951,7 @@ public void testBuildLongTermFix_NewestBranchAffectedButPatched() throws Excepti } @Test - public void testBuildLongTermFix_SkipsFullyAffectedNewestBranchAndOlderBranches() throws Exception { + public void testBuildLongTermFix_SkipsFullyAffectedNewestBranchAndOlderBranches() { Release r920 = makeRelease("id-920", "v9.2.0"); Release r900 = makeRelease("id-900", "v9.0.0"); Release r1010 = makeRelease("id-1010", "v10.1.0"); @@ -1973,7 +1968,7 @@ public void testBuildLongTermFix_SkipsFullyAffectedNewestBranchAndOlderBranches( } @Test - public void testBuildAffectedByBranch_FallsBackToNonVersionedReleases() throws Exception { + public void testBuildAffectedByBranch_FallsBackToNonVersionedReleases() { Release nightly = makeRelease("id-nightly", "nightly"); org.frankframework.insights.branch.Branch masterBranch = new org.frankframework.insights.branch.Branch(); masterBranch.setName("master"); @@ -1990,7 +1985,7 @@ public void testBuildAffectedByBranch_FallsBackToNonVersionedReleases() throws E } @Test - public void testBuildAffectedByBranch_PrefersVersionedReleasesOverNightly() throws Exception { + public void testBuildAffectedByBranch_PrefersVersionedReleasesOverNightly() { Release nightly = makeRelease("id-nightly", "nightly"); Release r920 = makeRelease("id-920", "v9.2.0"); From 887c389d0af55ddb6e03b822a0754effa1cd47c6 Mon Sep 17 00:00:00 2001 From: stijnpotters Date: Mon, 20 Jul 2026 10:38:35 +0200 Subject: [PATCH 6/6] fix: update exception handling to return empty array on NumberFormatException --- .../insights/vulnerability/VulnerabilityService.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/frankframework/insights/vulnerability/VulnerabilityService.java b/src/main/java/org/frankframework/insights/vulnerability/VulnerabilityService.java index 116009e2..fbcd1ab9 100644 --- a/src/main/java/org/frankframework/insights/vulnerability/VulnerabilityService.java +++ b/src/main/java/org/frankframework/insights/vulnerability/VulnerabilityService.java @@ -772,7 +772,7 @@ private static int[] parseFullVersion(Release release) { try { return Arrays.stream(parts).mapToInt(Integer::parseInt).toArray(); - } catch (NumberFormatException exception) { + } catch (NumberFormatException _) { return new int[0]; } }