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..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
@@ -61,7 +61,7 @@
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..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 => ({
@@ -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,100 @@ describe('CveOverviewRightPanelComponent', () => {
});
});
- describe('ngOnChanges branch filtering', () => {
- it('filters unmaintained branches when showUnmaintained=false', () => {
+ describe('recommendedUpgrades()', () => {
+ it('lists per-branch patches first and the minimum long-term fix 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: { '9.2': makeRelease('r3', 'v10.1.0', 'release/10.1') },
});
- component.branchMaintainedMap = new Map([['9.0', true], ['7.0', false]]);
+ 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', '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', 'release/10.1');
+ component.vulnerabilityDetail = makeDetail({
+ affectedReleasesByBranch: { '10.1': [makeRelease('r1', 'v10.1.0', 'release/10.1')] },
+ 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: { '8.0': makeRelease('r3', 'v10.1.0', 'release/10.1') },
+ });
+ component.branchMaintainedMap = new Map([
+ ['7.0', false],
+ ['8.0', 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: { '8.0': makeRelease('r3', 'v10.1.0', 'release/10.1') },
});
- component.branchMaintainedMap = new Map([['9.0', true], ['7.0', false]]);
+ component.branchMaintainedMap = new Map([
+ ['7.0', false],
+ ['8.0', 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 +245,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..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
@@ -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,24 +49,65 @@ 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 filterBranches(branchMap: object): string[] {
- return sortVersionsAsc(
- Object.keys(branchMap).filter(
- (branch) => this.showUnmaintained || this.branchMaintainedMap.get(branch) !== false,
- ),
+ private buildRecommendedUpgrades(
+ shortTermFixByBranch: Record,
+ longTermFixByBranch: Record,
+ ): RecommendedUpgrade[] {
+ const latestStableByReleaseId = new Map();
+
+ 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),
+ }));
+
+ const patchIds = new Set(branchPatches.map((upgrade) => upgrade.release.id));
+
+ return [...branchPatches, ...latestStable.filter((upgrade) => !patchIds.has(upgrade.release.id))];
+ }
+
+ 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/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..fbcd1ab9 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;
@@ -138,6 +140,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;
@@ -619,10 +627,18 @@ private VulnerabilityDetailResponse toVulnerabilityDetailResponse(
buildLongTermFix(affected, allByEffectiveBranch));
}
- private Map> buildAffectedByBranch(List affectedReleases) {
- return affectedReleases.stream()
+ protected Map> buildAffectedByBranch(List affectedReleases) {
+ 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(
@@ -635,51 +651,61 @@ private Map> buildAffectedByBranch(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) {
- Map> affectedIdsByBranch = groupAffectedIdsByBranch(affectedReleases);
- Map result = new LinkedHashMap<>();
-
- for (Map.Entry> entry : affectedIdsByBranch.entrySet()) {
- String branchKey = entry.getKey();
- if (parseVersionParts(branchKey) == null) continue;
+ 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));
+ }
- 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)));
- }
+ 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)));
+ }
- return result;
+ 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 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.
- * Non-versioned families (e.g. "master", "master-nightly") are never shown as upgrade targets.
- */
- private Map buildLongTermFix(
+ protected Map buildLongTermFix(
List affectedReleases, Map> allByEffectiveBranch) {
Set affectedKeys = affectedReleases.stream()
.map(VulnerabilityService::getEffectiveBranchKey)
.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();
+ Map> affectedIdsByBranch = groupAffectedIdsByBranch(affectedReleases);
- String branchKey = newestBranch.get();
- Map result = new LinkedHashMap<>();
+ return candidateBranchesNewestFirst(allByEffectiveBranch.keySet(), maxAffectedVersion)
+ .flatMap(
+ branchKey -> findBranchLatestStable(
+ branchKey, affectedIdsByBranch.getOrDefault(branchKey, Set.of()), allByEffectiveBranch)
+ .stream())
+ .findFirst()
+ .map(entry -> Map.of(entry.getKey(), entry.getValue()))
+ .orElseGet(Collections::emptyMap);
+ }
- findLatestCleanRelease(allByEffectiveBranch.get(branchKey), -1)
- .ifPresent(release -> result.put(branchKey, mapper.toDTO(release, ReleaseResponse.class)));
+ private static Stream candidateBranchesNewestFirst(Set branchKeys, int[] maxAffectedVersion) {
+ return branchKeys.stream()
+ .filter(branchKey -> isUpgradeCandidateBranch(branchKey, maxAffectedVersion))
+ .sorted(NEWEST_BRANCH_FIRST);
+ }
- return result;
+ 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 +716,34 @@ 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 boolean isVersionedRelease(Release release) {
+ return !isNightly(release) && parseVersionParts(getEffectiveBranchKey(release)) != 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 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) {
@@ -717,25 +753,37 @@ 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;
+ 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();
+ }
- for (Map.Entry> entry : allByEffectiveBranch.entrySet()) {
- String branchKey = entry.getKey();
- if (affectedKeys.contains(branchKey)) continue;
+ private static int[] parseFullVersion(Release release) {
+ String tagName = release.getTagName();
+ if (tagName == null) return new int[0];
- int[] version = parseVersionParts(branchKey);
- if (version == null || !isVersionNewerThan(version, minVersion)) continue;
+ String tag = tagName.replaceFirst("^release/", "").replaceFirst("^v", "");
+ String[] parts = tag.split("\\.", -1);
+ if (parts.length < 2) return new int[0];
- if (newestVersion == null || isVersionNewerThan(version, newestVersion)) {
- newestVersion = version;
- newestBranchKey = branchKey;
- }
+ try {
+ return Arrays.stream(parts).mapToInt(Integer::parseInt).toArray();
+ } catch (NumberFormatException _) {
+ return new int[0];
}
+ }
- 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..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");
@@ -1814,13 +1811,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"));
@@ -1828,29 +1819,55 @@ public void testBuildShortTermFix_ReturnsLatestCleanPatch() throws Exception {
}
@Test
- public void testBuildShortTermFix_NoResultWhenAllReleasesAreAffected() 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");
+
+ 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);
+
+ Map result = vulnerabilityService.buildShortTermFix(affectedReleases, allByBranch);
+
+ assertEquals("v9.2.2", result.get("9.2").tagName());
+ }
+
+ @Test
+ 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");
+ 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);
+
+ Map result = vulnerabilityService.buildShortTermFix(affectedReleases, allByBranch);
+
+ assertEquals("v9.2.1", result.get("9.2").tagName());
+ }
+
+ @Test
+ public void testBuildShortTermFix_NoResultWhenAllReleasesAreAffected() {
Release r771 = makeRelease("id-771", "v7.7.1");
Release r772 = makeRelease("id-772", "v7.7.2");
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());
}
- // ── 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() {
Release r771 = makeRelease("id-771", "v7.7.1");
Release r800 = makeRelease("id-800", "v8.0.0");
Release r900 = makeRelease("id-900", "v9.0.0");
@@ -1866,22 +1883,15 @@ public void testBuildLongTermFix_ReturnsSingleNewestBranch() 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"), "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)
+ 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");
@@ -1895,20 +1905,15 @@ 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"));
assertEquals("v9.3.3", result.get("9.3").tagName());
}
@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");
@@ -1918,21 +1923,86 @@ 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);
+ Map result = vulnerabilityService.buildLongTermFix(affectedReleases, allByBranch);
- @SuppressWarnings("unchecked")
- Map result =
- (Map) method.invoke(vulnerabilityService, affectedReleases, allByBranch);
+ assertTrue(result.isEmpty());
+ }
+
+ @Test
+ 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");
+ 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);
+
+ Map result = vulnerabilityService.buildLongTermFix(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());
+ }
+
+ @Test
+ 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");
+
+ 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));
+
+ Map result = vulnerabilityService.buildLongTermFix(affectedReleases, allByBranch);
assertTrue(result.isEmpty());
}
+ @Test
+ 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");
+ nightly.setBranch(masterBranch);
+
+ 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));
+
+ assertEquals(1, result.size());
+ assertTrue(result.containsKey("master"));
+ assertEquals("nightly", result.get("master").getFirst().tagName());
+ }
+
+ @Test
+ public void testBuildAffectedByBranch_PrefersVersionedReleasesOverNightly() {
+ 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);
+
+ Map> result = vulnerabilityService.buildAffectedByBranch(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": {