Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ <h4 class="section-title">Description</h4>
}
</div>

@if (vulnerabilityDetail.vulnerability.cwes && vulnerabilityDetail.vulnerability.cwes.length > 0) {
@if (vulnerabilityDetail.vulnerability.cwes && hasCwes()) {
<div class="detail-section">
<h4 class="section-title">CWEs</h4>
<div class="tag-row">
Expand All @@ -72,9 +72,13 @@ <h4 class="section-title">CWEs</h4>
</div>
}

@if (affectedBranches().length > 0) {
@if (hasAffectedBranches()) {
<div class="detail-section">
<h4 class="section-title">Affected Releases</h4>
<p class="fix-hint">
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.
</p>
<div class="branch-list">
@for (branch of affectedBranches(); track branch) {
@let fix = vulnerabilityDetail.shortTermFixByBranch[branch];
Expand All @@ -87,21 +91,25 @@ <h4 class="section-title">Affected Releases</h4>
<span class="release-label">fixed in</span>
<span class="tag tag-patched">{{ fix.tagName }}</span>
}
@if (isUnmaintained(branch)) {
<span class="tag tag-unmaintained">unmaintained</span>
}
</div>
}
</div>
</div>
}

@if (longTermBranches().length > 0) {
@if (hasRecommendedUpgrades()) {
<div class="detail-section">
<h4 class="section-title">Recommended Upgrade — Latest Stable</h4>
<p class="fix-hint">Upgrade to the newest stable Frank! Framework release</p>
<h4 class="section-title">Recommended Upgrade</h4>
<p class="fix-hint">Upgrade to the patched release of your branch, or move to the latest stable release</p>
<div class="branch-list">
@for (branch of longTermBranches(); track branch) {
@for (upgrade of recommendedUpgrades(); track upgrade.branch) {
<div class="branch-card branch-patched">
<span class="branch-name">{{ branch }}</span>
<span class="tag tag-patched">{{ vulnerabilityDetail.longTermFixByBranch[branch].tagName }}</span>
<span class="branch-name">{{ upgrade.branch }}</span>
<span class="release-label">{{ upgrade.isLatestStable ? 'Latest stable' : 'Patched in' }}</span>
<span class="tag tag-patched">{{ upgrade.release.tagName }}</span>
</div>
}
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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> = {}): VulnerabilityDetail => ({
Expand Down Expand Up @@ -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', () => {
Expand All @@ -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', () => {
Expand Down Expand Up @@ -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', () => {
Expand All @@ -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),
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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,
Expand All @@ -23,9 +29,14 @@ export class CveOverviewRightPanelComponent implements OnChanges {
@Input() showUnmaintained = false;
@Input() canManage = false;

public readonly affectedBranches = signal<string[]>([]);
public readonly shortTermBranches = signal<string[]>([]);
public readonly longTermBranches = signal<string[]>([]);
public readonly affectedBranches: WritableSignal<string[]> = signal([]);
public readonly recommendedUpgrades: WritableSignal<RecommendedUpgrade[]> = 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)) {
Expand All @@ -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<string, ReleaseInfo>,
longTermFixByBranch: Record<string, ReleaseInfo>,
): RecommendedUpgrade[] {
const latestStableByReleaseId = new Map<string, RecommendedUpgrade>();

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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading