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 @@

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..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": {