From 8bd8f26fd99b5df735358996c9955a15e9b54679 Mon Sep 17 00:00:00 2001 From: stijnpotters Date: Tue, 21 Jul 2026 10:44:56 +0200 Subject: [PATCH 1/4] feat: add CVSS vector handling and calculator integration for vulnerability impact details --- .../cvss-calculator.component.html | 81 +++++ .../cvss-calculator.component.scss | 254 +++++++++++++++ .../cvss-calculator.component.spec.ts | 120 ++++++++ .../cvss-calculator.component.ts | 113 +++++++ ...ulnerability-impact-details.component.html | 14 +- ...ulnerability-impact-details.component.scss | 16 + ...erability-impact-details.component.spec.ts | 24 ++ .../vulnerability-impact-details.component.ts | 18 +- src/main/frontend/src/app/pipes/cvss.spec.ts | 85 ++++++ src/main/frontend/src/app/pipes/cvss.ts | 288 ++++++++++++++++++ .../src/app/services/vulnerability.service.ts | 1 + .../vulnerability/TrivyVulnerability.java | 45 +-- .../insights/vulnerability/Vulnerability.java | 2 + .../vulnerability/VulnerabilityResponse.java | 3 +- .../vulnerability/VulnerabilityService.java | 5 + .../V1.12__Add_Vulnerability_Cvss_Vector.sql | 1 + .../VulnerabilityControllerTest.java | 60 +++- .../VulnerabilityServiceTest.java | 112 ++++++- 18 files changed, 1189 insertions(+), 53 deletions(-) create mode 100644 src/main/frontend/src/app/components/cvss-calculator/cvss-calculator.component.html create mode 100644 src/main/frontend/src/app/components/cvss-calculator/cvss-calculator.component.scss create mode 100644 src/main/frontend/src/app/components/cvss-calculator/cvss-calculator.component.spec.ts create mode 100644 src/main/frontend/src/app/components/cvss-calculator/cvss-calculator.component.ts create mode 100644 src/main/frontend/src/app/pipes/cvss.spec.ts create mode 100644 src/main/frontend/src/app/pipes/cvss.ts create mode 100644 src/main/resources/db/migration/V1.12__Add_Vulnerability_Cvss_Vector.sql diff --git a/src/main/frontend/src/app/components/cvss-calculator/cvss-calculator.component.html b/src/main/frontend/src/app/components/cvss-calculator/cvss-calculator.component.html new file mode 100644 index 00000000..d7725cd3 --- /dev/null +++ b/src/main/frontend/src/app/components/cvss-calculator/cvss-calculator.component.html @@ -0,0 +1,81 @@ + +
+ @if (referenceScore !== null) { +

+ Original NVD score for reference: {{ referenceScore }} +

+ } + +
+ +
+ + +
+ @if (vectorError()) { + {{ vectorError() }} + } +
+ +
+ @for (metric of metrics; track metric.key) { +
+ + {{ metric.label }} + +
+ @for (option of metric.options; track option.key) { + + } +
+
+ } +
+ + @if (activeTooltip(); as tooltip) { + + } + +
+
+ {{ result()?.score ?? '—' }} + {{ result()?.severity ?? 'Incomplete' }} +
+ @if (currentVectorString()) { + {{ currentVectorString() }} + } +
+ +
+ + +
+
+
diff --git a/src/main/frontend/src/app/components/cvss-calculator/cvss-calculator.component.scss b/src/main/frontend/src/app/components/cvss-calculator/cvss-calculator.component.scss new file mode 100644 index 00000000..91316df9 --- /dev/null +++ b/src/main/frontend/src/app/components/cvss-calculator/cvss-calculator.component.scss @@ -0,0 +1,254 @@ +:host { + display: block; +} + +.cvss-calculator { + display: flex; + flex-direction: column; + gap: 1.25rem; + overflow-x: hidden; +} + +.reference-note { + margin: 0; + font-size: 0.875rem; + color: #6b7280; + + strong { + color: #1f2937; + } +} + +.vector-field { + display: flex; + flex-direction: column; + gap: 0.5rem; + + label { + color: #1f2937; + font-weight: 600; + font-size: 0.85rem; + } +} + +.vector-input-row { + display: flex; + gap: 0.5rem; +} + +.vector-input { + flex: 1; + min-width: 0; + padding: 0.6rem 0.75rem; + border: 1px solid #d1d5db; + border-radius: 8px; + font-size: 0.875rem; + background: #fff; + color: #1f2937; + + &:focus { + outline: none; + border-color: #3b82f6; + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); + } +} + +.load-btn { + padding: 0.6rem 1rem; + background: #f3f4f6; + color: #1f2937; + border: 1px solid #d1d5db; + border-radius: 8px; + font-weight: 600; + font-size: 0.85rem; + cursor: pointer; + transition: all 0.2s; + + &:hover { + background: #e5e7eb; + } +} + +.error-message { + color: #dc2626; + font-weight: 600; + font-size: 0.8rem; +} + +.metrics-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 1rem; + + @media (max-width: 540px) { + grid-template-columns: 1fr; + } +} + +.metric-group { + display: flex; + flex-direction: column; + gap: 0.4rem; +} + +.metric-label { + font-weight: 600; + font-size: 0.8rem; + color: #6b7280; + cursor: help; + width: fit-content; +} + +.floating-tooltip { + position: fixed; + transform: translate(-50%, -100%); + z-index: 100; + width: max-content; + max-width: min(240px, calc(100vw - 2rem)); + padding: 0.5rem 0.65rem; + border-radius: 6px; + background: #1f2937; + color: #f9fafb; + font-weight: 500; + font-size: 0.75rem; + text-align: left; + pointer-events: none; + + strong { + color: #fff; + } +} + +.metric-options { + display: flex; + gap: 0.35rem; +} + +.metric-option { + flex: 1; + padding: 0.4rem 0; + border: 1px solid #d1d5db; + border-radius: 6px; + background: #fff; + color: #374151; + font-weight: 600; + font-size: 0.8rem; + cursor: pointer; + transition: all 0.15s; + + &:hover { + border-color: #93c5fd; + background: #eff6ff; + } + + &.active { + background: #3b82f6; + border-color: #3b82f6; + color: #fff; + } +} + +.result-row { + display: flex; + align-items: center; + gap: 0.75rem; + flex-wrap: wrap; + padding-top: 0.5rem; + border-top: 1px solid #e5e7eb; +} + +.result-score { + display: flex; + align-items: baseline; + gap: 0.4rem; + padding: 0.4rem 0.75rem; + border-radius: 8px; + background: #f3f4f6; + color: #374151; + + .score-value { + font-weight: 700; + font-size: 1.2rem; + } + + .score-severity { + font-weight: 600; + font-size: 0.8rem; + text-transform: uppercase; + letter-spacing: 0.02em; + } + + &[data-severity='Critical'] { + background: #fee2e2; + color: #991b1b; + } + + &[data-severity='High'] { + background: #fed7aa; + color: #9a3412; + } + + &[data-severity='Medium'] { + background: #fef3c7; + color: #92400e; + } + + &[data-severity='Low'] { + background: #dbeafe; + color: #1e40af; + } + + &[data-severity='None'] { + background: #f3f4f6; + color: #6b7280; + } +} + +.result-vector { + font-size: 0.8125rem; + color: #6b7280; + word-break: break-all; +} + +.actions-row { + display: flex; + justify-content: flex-end; + gap: 0.75rem; +} + +.cancel-btn { + padding: 0.65rem 1.25rem; + background: transparent; + color: #374151; + border: 1px solid #d1d5db; + border-radius: 8px; + font-weight: 600; + font-size: 0.85rem; + cursor: pointer; + transition: all 0.2s; + + &:hover { + background: #f3f4f6; + } +} + +.use-btn { + padding: 0.65rem 1.25rem; + background: #3b82f6; + color: #fff; + border: none; + border-radius: 8px; + font-weight: 600; + font-size: 0.85rem; + cursor: pointer; + transition: all 0.2s; + + &:hover:not(:disabled) { + background: #2563eb; + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } +} diff --git a/src/main/frontend/src/app/components/cvss-calculator/cvss-calculator.component.spec.ts b/src/main/frontend/src/app/components/cvss-calculator/cvss-calculator.component.spec.ts new file mode 100644 index 00000000..bd6626fd --- /dev/null +++ b/src/main/frontend/src/app/components/cvss-calculator/cvss-calculator.component.spec.ts @@ -0,0 +1,120 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { CvssCalculatorComponent } from './cvss-calculator.component'; + +describe('CvssCalculatorComponent', () => { + let component: CvssCalculatorComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [CvssCalculatorComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(CvssCalculatorComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should have no result until every metric is selected', () => { + expect(component.result()).toBeNull(); + + component.selectMetric('AV', 'N'); + component.selectMetric('AC', 'L'); + component.selectMetric('PR', 'N'); + component.selectMetric('UI', 'N'); + component.selectMetric('S', 'U'); + component.selectMetric('C', 'H'); + component.selectMetric('I', 'H'); + + expect(component.result()).toBeNull(); + + component.selectMetric('A', 'H'); + + expect(component.result()).toEqual({ score: 9.8, severity: 'Critical' }); + }); + + it('should build the vector string as metrics are selected', () => { + component.selectMetric('AV', 'N'); + component.selectMetric('AC', 'L'); + + expect(component.currentVectorString()).toBe('CVSS:3.1/AV:N/AC:L'); + }); + + it('should parse a pasted vector string and populate the selection', () => { + component.vectorInput = 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H'; + component.applyVectorInput(); + + expect(component.selection()).toEqual({ AV: 'N', AC: 'L', PR: 'N', UI: 'N', S: 'U', C: 'H', I: 'H', A: 'H' }); + expect(component.result()).toEqual({ score: 9.8, severity: 'Critical' }); + expect(component.vectorError()).toBeNull(); + }); + + it('should set an error when the pasted vector string is incomplete', () => { + component.vectorInput = 'CVSS:3.1/AV:N/AC:L'; + component.applyVectorInput(); + + expect(component.vectorError()).not.toBeNull(); + expect(component.selection()).toEqual({}); + }); + + it('should emit scoreSelected with the calculated score when useScore is called', () => { + const emitSpy = spyOn(component.scoreSelected, 'emit'); + + component.vectorInput = 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H'; + component.applyVectorInput(); + component.useScore(); + + expect(emitSpy).toHaveBeenCalledWith(9.8); + }); + + it('should not emit scoreSelected when the vector is incomplete', () => { + const emitSpy = spyOn(component.scoreSelected, 'emit'); + + component.useScore(); + + expect(emitSpy).not.toHaveBeenCalled(); + }); + + it('should emit closed when close is called', () => { + const emitSpy = spyOn(component.closed, 'emit'); + + component.close(); + + expect(emitSpy).toHaveBeenCalledWith(); + }); + + it('should prefill the selection and vector input from a valid referenceVector on init', () => { + const prefilledFixture = TestBed.createComponent(CvssCalculatorComponent); + prefilledFixture.componentInstance.referenceVector = 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H'; + + prefilledFixture.detectChanges(); + + expect(prefilledFixture.componentInstance.selection()).toEqual({ + AV: 'N', + AC: 'L', + PR: 'N', + UI: 'N', + S: 'U', + C: 'H', + I: 'H', + A: 'H', + }); + + expect(prefilledFixture.componentInstance.vectorInput).toBe('CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H'); + expect(prefilledFixture.componentInstance.result()).toEqual({ score: 9.8, severity: 'Critical' }); + }); + + it('should ignore an invalid or incomplete referenceVector on init', () => { + const prefilledFixture = TestBed.createComponent(CvssCalculatorComponent); + prefilledFixture.componentInstance.referenceVector = 'not-a-vector'; + + prefilledFixture.detectChanges(); + + expect(prefilledFixture.componentInstance.selection()).toEqual({}); + expect(prefilledFixture.componentInstance.vectorInput).toBe(''); + }); +}); diff --git a/src/main/frontend/src/app/components/cvss-calculator/cvss-calculator.component.ts b/src/main/frontend/src/app/components/cvss-calculator/cvss-calculator.component.ts new file mode 100644 index 00000000..0bddcfc4 --- /dev/null +++ b/src/main/frontend/src/app/components/cvss-calculator/cvss-calculator.component.ts @@ -0,0 +1,113 @@ +import { + Component, + computed, + EventEmitter, + Input, + OnInit, + Output, + Signal, + signal, + WritableSignal, +} from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { ModalComponent } from '../modal/modal.component'; +import { + calculateCvssScore, + CVSS_METRICS, + CvssMetricKey, + CvssResult, + CvssVector, + parseVectorString, + vectorToString, +} from '../../pipes/cvss'; + +export interface CvssTooltipState { + top: string; + left: string; + title: string; + description: string; +} + +@Component({ + selector: 'app-cvss-calculator', + standalone: true, + imports: [CommonModule, FormsModule, ModalComponent], + templateUrl: './cvss-calculator.component.html', + styleUrl: './cvss-calculator.component.scss', +}) +export class CvssCalculatorComponent implements OnInit { + @Input() referenceScore: number | null = null; + @Input() referenceVector: string | null = null; + + @Output() closed = new EventEmitter(); + @Output() scoreSelected = new EventEmitter(); + + public readonly metrics = CVSS_METRICS; + + public vectorInput = ''; + public vectorError: WritableSignal = signal(null); + + public selection: WritableSignal = signal({}); + + public activeTooltip: WritableSignal = signal(null); + + public result: Signal = computed(() => calculateCvssScore(this.selection())); + public currentVectorString: Signal = computed(() => vectorToString(this.selection())); + + ngOnInit(): void { + if (!this.referenceVector) return; + + const parsed = parseVectorString(this.referenceVector); + if (!parsed) return; + + this.vectorInput = this.referenceVector; + this.selection.set(parsed); + } + + public selectMetric(key: CvssMetricKey, optionKey: string): void { + this.selection.update((current) => ({ ...current, [key]: optionKey })); + this.vectorError.set(null); + } + + public applyVectorInput(): void { + const parsed = parseVectorString(this.vectorInput); + if (!parsed) { + this.vectorError.set( + 'Enter a complete CVSS 3.1 vector string, for example: CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H', + ); + return; + } + + this.selection.set(parsed); + this.vectorError.set(null); + } + + public close(): void { + this.closed.emit(); + } + + public showTooltip(event: Event, title: string, description: string): void { + const target = event.currentTarget as HTMLElement; + const rect = target.getBoundingClientRect(); + const gap = 8; + + this.activeTooltip.set({ + top: `${rect.top - gap}px`, + left: `${rect.left + rect.width / 2}px`, + title, + description, + }); + } + + public hideTooltip(): void { + this.activeTooltip.set(null); + } + + public useScore(): void { + const result = this.result(); + if (!result) return; + + this.scoreSelected.emit(result.score); + } +} diff --git a/src/main/frontend/src/app/pages/release-manage/vulnerability-impact-manage/vulnerability-impact-details/vulnerability-impact-details.component.html b/src/main/frontend/src/app/pages/release-manage/vulnerability-impact-manage/vulnerability-impact-details/vulnerability-impact-details.component.html index 32ce09d7..90a166f0 100644 --- a/src/main/frontend/src/app/pages/release-manage/vulnerability-impact-manage/vulnerability-impact-details/vulnerability-impact-details.component.html +++ b/src/main/frontend/src/app/pages/release-manage/vulnerability-impact-manage/vulnerability-impact-details/vulnerability-impact-details.component.html @@ -23,7 +23,10 @@

Impact Details

- +
+ + +
Impact Details
+ + @if (showCvssCalculator()) { + + } } diff --git a/src/main/frontend/src/app/pages/release-manage/vulnerability-impact-manage/vulnerability-impact-details/vulnerability-impact-details.component.scss b/src/main/frontend/src/app/pages/release-manage/vulnerability-impact-manage/vulnerability-impact-details/vulnerability-impact-details.component.scss index ec2799b0..4739f985 100644 --- a/src/main/frontend/src/app/pages/release-manage/vulnerability-impact-manage/vulnerability-impact-details/vulnerability-impact-details.component.scss +++ b/src/main/frontend/src/app/pages/release-manage/vulnerability-impact-manage/vulnerability-impact-details/vulnerability-impact-details.component.scss @@ -76,6 +76,22 @@ textarea.form-control { resize: vertical; min-height: 120px; } label { margin-bottom: 0; } } +.calculate-cvss-btn { + padding: .3rem .7rem; + background: #eff6ff; + color: #2563eb; + border: 1px solid #bfdbfe; + border-radius: 6px; + font-weight: 600; + font-size: 0.8rem; + cursor: pointer; + transition: all .2s; + + &:hover { + background: #dbeafe; + } +} + .md-toggle { display: flex; background: #f3f4f6; diff --git a/src/main/frontend/src/app/pages/release-manage/vulnerability-impact-manage/vulnerability-impact-details/vulnerability-impact-details.component.spec.ts b/src/main/frontend/src/app/pages/release-manage/vulnerability-impact-manage/vulnerability-impact-details/vulnerability-impact-details.component.spec.ts index 2639d7f5..ce0e3694 100644 --- a/src/main/frontend/src/app/pages/release-manage/vulnerability-impact-manage/vulnerability-impact-details/vulnerability-impact-details.component.spec.ts +++ b/src/main/frontend/src/app/pages/release-manage/vulnerability-impact-manage/vulnerability-impact-details/vulnerability-impact-details.component.spec.ts @@ -192,6 +192,30 @@ describe('VulnerabilityImpactDetailsComponent', () => { expect(component.impactForm.get('impactScore')?.value).toBeNull(); }); + it('should toggle the CVSS calculator visibility', () => { + expect(component.showCvssCalculator()).toBe(false); + + component.openCvssCalculator(); + + expect(component.showCvssCalculator()).toBe(true); + + component.closeCvssCalculator(); + + expect(component.showCvssCalculator()).toBe(false); + }); + + it('should apply a calculated score to the impact score field and close the calculator', () => { + componentReference.setInput('vulnerability', mockVulnerabilityWithoutImpact); + fixture.detectChanges(); + + component.openCvssCalculator(); + component.applyCalculatedScore(9.1); + + expect(component.impactForm.get('impactScore')?.value).toBe(9.1); + expect(component.impactForm.get('impactScore')?.touched).toBe(true); + expect(component.showCvssCalculator()).toBe(false); + }); + it('should populate the form on init when created already bound to a vulnerability', () => { const inlineFixture = TestBed.createComponent(VulnerabilityImpactDetailsComponent); inlineFixture.componentRef.setInput('vulnerability', mockVulnerabilityWithImpact); diff --git a/src/main/frontend/src/app/pages/release-manage/vulnerability-impact-manage/vulnerability-impact-details/vulnerability-impact-details.component.ts b/src/main/frontend/src/app/pages/release-manage/vulnerability-impact-manage/vulnerability-impact-details/vulnerability-impact-details.component.ts index 590ccc1c..cc458066 100644 --- a/src/main/frontend/src/app/pages/release-manage/vulnerability-impact-manage/vulnerability-impact-details/vulnerability-impact-details.component.ts +++ b/src/main/frontend/src/app/pages/release-manage/vulnerability-impact-manage/vulnerability-impact-details/vulnerability-impact-details.component.ts @@ -15,11 +15,12 @@ import { CommonModule } from '@angular/common'; import { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms'; import { MarkdownPipe } from '../../../../pipes/markdown.pipe'; import { Vulnerability, VulnerabilityImpact } from '../../../../services/vulnerability.service'; +import { CvssCalculatorComponent } from '../../../../components/cvss-calculator/cvss-calculator.component'; @Component({ selector: 'app-vulnerability-impact-details', standalone: true, - imports: [CommonModule, FormsModule, ReactiveFormsModule, MarkdownPipe], + imports: [CommonModule, FormsModule, ReactiveFormsModule, MarkdownPipe, CvssCalculatorComponent], templateUrl: './vulnerability-impact-details.component.html', styleUrl: './vulnerability-impact-details.component.scss', }) @@ -33,6 +34,7 @@ export class VulnerabilityImpactDetailsComponent implements OnInit, OnChanges { public impactForm!: FormGroup; public initialFormValue: { impactScore: number | null; impactDescription: string | null } | null = null; public impactDescriptionPreview = signal(false); + public showCvssCalculator = signal(false); public hasImpact = computed(() => { const vuln = this.vulnerability(); @@ -77,6 +79,20 @@ export class VulnerabilityImpactDetailsComponent implements OnInit, OnChanges { this.impactDeleted.emit(); } + public openCvssCalculator(): void { + this.showCvssCalculator.set(true); + } + + public closeCvssCalculator(): void { + this.showCvssCalculator.set(false); + } + + public applyCalculatedScore(score: number): void { + this.impactForm.get('impactScore')?.setValue(score); + this.impactForm.get('impactScore')?.markAsTouched(); + this.showCvssCalculator.set(false); + } + public isFormChanged(): boolean { if (!this.initialFormValue || !this.impactForm) return false; diff --git a/src/main/frontend/src/app/pipes/cvss.spec.ts b/src/main/frontend/src/app/pipes/cvss.spec.ts new file mode 100644 index 00000000..41d69701 --- /dev/null +++ b/src/main/frontend/src/app/pipes/cvss.spec.ts @@ -0,0 +1,85 @@ +import { CvssVector, calculateCvssScore, isVectorComplete, parseVectorString, severityForScore, vectorToString } from './cvss'; + +describe('cvss', () => { + describe('calculateCvssScore', () => { + it('returns null for an incomplete vector', () => { + expect(calculateCvssScore({ AV: 'N', AC: 'L' })).toBeNull(); + }); + + it('matches the published score for CVE-2021-44228 (Log4Shell)', () => { + const vector: CvssVector = { AV: 'N', AC: 'L', PR: 'N', UI: 'N', S: 'C', C: 'H', I: 'H', A: 'H' }; + + expect(calculateCvssScore(vector)).toEqual({ score: 10, severity: 'Critical' }); + }); + + it('matches the hand-computed score for a network, low-complexity, no-interaction attack', () => { + const vector: CvssVector = { AV: 'N', AC: 'L', PR: 'N', UI: 'N', S: 'U', C: 'H', I: 'H', A: 'H' }; + + expect(calculateCvssScore(vector)).toEqual({ score: 9.8, severity: 'Critical' }); + }); + + it('returns a 0 score and None severity when there is no impact', () => { + const vector: CvssVector = { AV: 'N', AC: 'L', PR: 'N', UI: 'N', S: 'U', C: 'N', I: 'N', A: 'N' }; + + expect(calculateCvssScore(vector)).toEqual({ score: 0, severity: 'None' }); + }); + }); + + describe('severityForScore', () => { + it('maps scores to the official CVSS v3.1 qualitative severity ratings', () => { + expect(severityForScore(0)).toBe('None'); + expect(severityForScore(3.9)).toBe('Low'); + expect(severityForScore(4)).toBe('Medium'); + expect(severityForScore(6.9)).toBe('Medium'); + expect(severityForScore(7)).toBe('High'); + expect(severityForScore(8.9)).toBe('High'); + expect(severityForScore(9)).toBe('Critical'); + expect(severityForScore(10)).toBe('Critical'); + }); + }); + + describe('isVectorComplete', () => { + it('returns false when any metric is missing', () => { + expect(isVectorComplete({ AV: 'N', AC: 'L', PR: 'N', UI: 'N', S: 'U', C: 'H', I: 'H' })).toBe(false); + }); + + it('returns true when all eight metrics are present', () => { + expect( + isVectorComplete({ AV: 'N', AC: 'L', PR: 'N', UI: 'N', S: 'U', C: 'H', I: 'H', A: 'H' }) + ).toBe(true); + }); + }); + + describe('vectorToString / parseVectorString', () => { + const vector: CvssVector = { AV: 'N', AC: 'L', PR: 'N', UI: 'N', S: 'U', C: 'H', I: 'H', A: 'H' }; + const vectorString = 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H'; + + it('serializes a complete vector in the canonical metric order', () => { + expect(vectorToString(vector)).toBe(vectorString); + }); + + it('parses a canonical vector string back into a vector', () => { + expect(parseVectorString(vectorString)).toEqual(vector); + }); + + it('parses a vector string without the CVSS:3.1 prefix', () => { + expect(parseVectorString('AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H')).toEqual(vector); + }); + + it('is case-insensitive for the prefix and metric values', () => { + expect(parseVectorString(vectorString.toLowerCase())).toEqual(vector); + }); + + it('returns null for an incomplete vector string', () => { + expect(parseVectorString('CVSS:3.1/AV:N/AC:L')).toBeNull(); + }); + + it('returns null for empty input', () => { + expect(parseVectorString(' ')).toBeNull(); + }); + + it('ignores unknown or malformed segments', () => { + expect(parseVectorString('CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H/XX:Z/garbage')).toEqual(vector); + }); + }); +}); diff --git a/src/main/frontend/src/app/pipes/cvss.ts b/src/main/frontend/src/app/pipes/cvss.ts new file mode 100644 index 00000000..4825969e --- /dev/null +++ b/src/main/frontend/src/app/pipes/cvss.ts @@ -0,0 +1,288 @@ +export type CvssMetricKey = 'AV' | 'AC' | 'PR' | 'UI' | 'S' | 'C' | 'I' | 'A'; + +export interface CvssMetricOption { + key: string; + fullLabel: string; + description: string; +} + +export interface CvssMetricDefinition { + key: CvssMetricKey; + label: string; + description: string; + options: CvssMetricOption[]; +} + +export type CvssVector = Partial>; + +export type CvssSeverity = 'None' | 'Low' | 'Medium' | 'High' | 'Critical'; + +export interface CvssResult { + score: number; + severity: CvssSeverity; +} + +const METRIC_ORDER: CvssMetricKey[] = ['AV', 'AC', 'PR', 'UI', 'S', 'C', 'I', 'A']; + +export const CVSS_METRICS: CvssMetricDefinition[] = [ + { + key: 'AV', + label: 'Attack Vector', + description: 'Reflects the context by which vulnerability exploitation is possible.', + options: [ + { + key: 'N', + fullLabel: 'Network', + description: + 'The vulnerable component is bound to the network stack and the attacker can reach it remotely, up to and including the entire Internet.', + }, + { + key: 'A', + fullLabel: 'Adjacent', + description: + 'The attack is limited to protocols on a logically adjacent topology, such as the same shared physical network, Bluetooth, or local IP subnet.', + }, + { + key: 'L', + fullLabel: 'Local', + description: + 'The attacker requires local access (keyboard, console) or relies on a separate user opening a malicious file or program.', + }, + { + key: 'P', + fullLabel: 'Physical', + description: 'The attack requires the attacker to physically touch or manipulate the vulnerable component.', + }, + ], + }, + { + key: 'AC', + label: 'Attack Complexity', + description: + 'Describes the conditions beyond the attacker’s control that must exist in order to exploit the vulnerability.', + options: [ + { + key: 'L', + fullLabel: 'Low', + description: + 'No specialized conditions exist; an attacker can expect repeatable success against the vulnerable component.', + }, + { + key: 'H', + fullLabel: 'High', + description: + 'A successful attack depends on conditions beyond the attacker’s control, requiring significant preparation before exploitation.', + }, + ], + }, + { + key: 'PR', + label: 'Privileges Required', + description: + 'Describes the level of privileges an attacker must possess before successfully exploiting the vulnerability.', + options: [ + { + key: 'N', + fullLabel: 'None', + description: 'The attacker is unauthorized prior to the attack and requires no access to settings or files.', + }, + { + key: 'L', + fullLabel: 'Low', + description: + 'The attacker requires privileges that provide basic user capabilities, typically only affecting settings and files owned by that user.', + }, + { + key: 'H', + fullLabel: 'High', + description: + 'The attacker requires privileges that provide significant, e.g. administrative, control over the vulnerable component.', + }, + ], + }, + { + key: 'UI', + label: 'User Interaction', + description: 'Captures whether a human user, other than the attacker, must participate for the attack to succeed.', + options: [ + { + key: 'N', + fullLabel: 'None', + description: 'The vulnerable system can be exploited without any interaction from any user.', + }, + { + key: 'R', + fullLabel: 'Required', + description: + 'Successful exploitation requires a user to take some action before the vulnerability can be exploited.', + }, + ], + }, + { + key: 'S', + label: 'Scope', + description: + 'Captures whether a vulnerability in one vulnerable component impacts resources beyond its own security scope.', + options: [ + { + key: 'U', + fullLabel: 'Unchanged', + description: 'An exploited vulnerability can only affect resources managed by the same security authority.', + }, + { + key: 'C', + fullLabel: 'Changed', + description: + 'An exploited vulnerability can affect resources beyond its own security scope, i.e. a different security authority.', + }, + ], + }, + { + key: 'C', + label: 'Confidentiality', + description: + 'Measures the impact to the confidentiality of information resources managed by the affected component.', + options: [ + { + key: 'N', + fullLabel: 'None', + description: 'There is no loss of confidentiality within the impacted component.', + }, + { + key: 'L', + fullLabel: 'Low', + description: + 'Some loss of confidentiality occurred; the attacker does not control what information is obtained, or the loss is constrained.', + }, + { + key: 'H', + fullLabel: 'High', + description: + 'Total loss of confidentiality; all resources within the impacted component are disclosed to the attacker.', + }, + ], + }, + { + key: 'I', + label: 'Integrity', + description: + 'Measures the impact to the trustworthiness and veracity of information managed by the affected component.', + options: [ + { key: 'N', fullLabel: 'None', description: 'There is no loss of integrity within the impacted component.' }, + { + key: 'L', + fullLabel: 'Low', + description: + 'Modification of data is possible, but the attacker does not control the consequence, or the amount of modification is limited.', + }, + { + key: 'H', + fullLabel: 'High', + description: + 'Total loss of integrity; the attacker can modify any or all files protected by the impacted component.', + }, + ], + }, + { + key: 'A', + label: 'Availability', + description: 'Measures the impact to the availability of the affected component itself.', + options: [ + { key: 'N', fullLabel: 'None', description: 'There is no impact to availability within the impacted component.' }, + { + key: 'L', + fullLabel: 'Low', + description: 'Performance is reduced or there are interruptions in resource availability.', + }, + { + key: 'H', + fullLabel: 'High', + description: + 'Total loss of availability; the attacker can fully deny access to resources in the impacted component.', + }, + ], + }, +]; + +const AV_WEIGHTS: Record = { N: 0.85, A: 0.62, L: 0.55, P: 0.2 }; +const AC_WEIGHTS: Record = { L: 0.77, H: 0.44 }; +const PR_WEIGHTS_UNCHANGED: Record = { N: 0.85, L: 0.62, H: 0.27 }; +const PR_WEIGHTS_CHANGED: Record = { N: 0.85, L: 0.68, H: 0.5 }; +const UI_WEIGHTS: Record = { N: 0.85, R: 0.62 }; +const CIA_WEIGHTS: Record = { N: 0, L: 0.22, H: 0.56 }; + +export function isVectorComplete(vector: CvssVector): boolean { + return METRIC_ORDER.every((key) => !!vector[key]); +} + +export function vectorToString(vector: CvssVector): string { + const parts = METRIC_ORDER.filter((key) => vector[key]).map((key) => `${key}:${vector[key]}`); + return parts.length > 0 ? `CVSS:3.1/${parts.join('/')}` : ''; +} + +export function parseVectorString(input: string): CvssVector | null { + const trimmed = input.trim(); + if (!trimmed) return null; + + const withoutPrefix = trimmed.replace(/^CVSS:3\.[01]\//i, ''); + const segments = withoutPrefix.split('/').filter(Boolean); + const vector: CvssVector = {}; + + for (const segment of segments) { + const [rawKey, rawValue] = segment.split(':'); + if (!rawKey || !rawValue) continue; + + const metricKey = rawKey.toUpperCase() as CvssMetricKey; + const definition = CVSS_METRICS.find((metric) => metric.key === metricKey); + const validOption = definition?.options.find((option) => option.key === rawValue.toUpperCase()); + if (validOption) { + vector[metricKey] = validOption.key; + } + } + + return isVectorComplete(vector) ? vector : null; +} + +function roundUp(input: number): number { + const intInput = Math.round(input * 100_000); + if (intInput % 10_000 === 0) { + return intInput / 100_000; + } + return (Math.floor(intInput / 10_000) + 1) / 10; +} + +export function severityForScore(score: number): CvssSeverity { + if (score <= 0) return 'None'; + if (score < 4) return 'Low'; + if (score < 7) return 'Medium'; + if (score < 9) return 'High'; + return 'Critical'; +} + +export function calculateCvssScore(vector: CvssVector): CvssResult | null { + if (!isVectorComplete(vector)) return null; + + const scopeChanged = vector.S === 'C'; + const av = AV_WEIGHTS[vector.AV as string]; + const ac = AC_WEIGHTS[vector.AC as string]; + const pr = (scopeChanged ? PR_WEIGHTS_CHANGED : PR_WEIGHTS_UNCHANGED)[vector.PR as string]; + const ui = UI_WEIGHTS[vector.UI as string]; + const c = CIA_WEIGHTS[vector.C as string]; + const index = CIA_WEIGHTS[vector.I as string]; + const a = CIA_WEIGHTS[vector.A as string]; + + const iscBase = 1 - (1 - c) * (1 - index) * (1 - a); + const impact = scopeChanged ? 7.52 * (iscBase - 0.029) - 3.25 * Math.pow(iscBase - 0.02, 15) : 6.42 * iscBase; + + if (impact <= 0) { + return { score: 0, severity: 'None' }; + } + + const exploitability = 8.22 * av * ac * pr * ui; + + const score = scopeChanged + ? roundUp(Math.min(1.08 * (impact + exploitability), 10)) + : roundUp(Math.min(impact + exploitability, 10)); + + return { score, severity: severityForScore(score) }; +} diff --git a/src/main/frontend/src/app/services/vulnerability.service.ts b/src/main/frontend/src/app/services/vulnerability.service.ts index 923fd213..457dab37 100644 --- a/src/main/frontend/src/app/services/vulnerability.service.ts +++ b/src/main/frontend/src/app/services/vulnerability.service.ts @@ -29,6 +29,7 @@ export interface Vulnerability { impactScore?: number | null; impactDescription?: string | null; publishedAt?: string | null; + cvssVector?: string | null; } export interface ReleaseInfo { diff --git a/src/main/java/org/frankframework/insights/vulnerability/TrivyVulnerability.java b/src/main/java/org/frankframework/insights/vulnerability/TrivyVulnerability.java index a022e214..6cf666e8 100644 --- a/src/main/java/org/frankframework/insights/vulnerability/TrivyVulnerability.java +++ b/src/main/java/org/frankframework/insights/vulnerability/TrivyVulnerability.java @@ -5,9 +5,9 @@ import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Optional; import java.util.Set; -import java.util.function.Function; import org.frankframework.insights.common.enums.VulnerabilitySeverity; @JsonIgnoreProperties(ignoreUnknown = true) @@ -32,48 +32,21 @@ public VulnerabilitySeverity toVulnerabilitySeverity() { return VulnerabilitySeverity.fromString(Severity); } - /** - * Extracts the CVSS score from the CVSS map, prioritizing sources and versions. - * Priority order: - * 1. NVD V3 score - * 2. RedHat V3 score - * 3. Any other V3 score - * 4. Any V2 score - * - * @return The extracted CVSS score, or empty if none found - */ - public Optional extractCvssScore() { + private Optional resolvePrimaryCvssSource() { if (CVSS == null || CVSS.isEmpty()) { return Optional.empty(); } return Optional.ofNullable(CVSS.get("nvd")) - .map(TrivyCvssScore::V3Score) - .or(() -> Optional.ofNullable(CVSS.get("redhat")).map(TrivyCvssScore::V3Score)) - .or(() -> findFirstCvssScore(TrivyCvssScore::V3Score)) - .or(() -> findFirstCvssScore(TrivyCvssScore::V2Score)); + .or(() -> Optional.ofNullable(CVSS.get("redhat"))) + .or(() -> CVSS.values().stream().filter(Objects::nonNull).findFirst()); } - /** - * Helper method to find the first non-null score from the CVSS map - * using the provided score extraction function. - * - * @param scoreExtractor A function to extract the desired score (e.g., V3Score or V2Score) - * @return The first non-null score found, or empty Optional - */ - private Optional findFirstCvssScore(Function scoreExtractor) { - if (CVSS == null) { - return Optional.empty(); - } + public Optional extractCvssScore() { + return resolvePrimaryCvssSource().map(source -> source.V3Score() != null ? source.V3Score() : source.V2Score()); + } - for (TrivyCvssScore score : CVSS.values()) { - if (score != null) { - Double extractedScore = scoreExtractor.apply(score); - if (extractedScore != null) { - return Optional.of(extractedScore); - } - } - } - return Optional.empty(); + public Optional extractCvssVector() { + return resolvePrimaryCvssSource().map(TrivyCvssScore::V3Vector).filter(vector -> !vector.isBlank()); } } diff --git a/src/main/java/org/frankframework/insights/vulnerability/Vulnerability.java b/src/main/java/org/frankframework/insights/vulnerability/Vulnerability.java index dfa0e93e..27b6de3d 100644 --- a/src/main/java/org/frankframework/insights/vulnerability/Vulnerability.java +++ b/src/main/java/org/frankframework/insights/vulnerability/Vulnerability.java @@ -35,6 +35,8 @@ public class Vulnerability { private Double cvssScore; + private String cvssVector; + @Column(columnDefinition = "TEXT") private String description; diff --git a/src/main/java/org/frankframework/insights/vulnerability/VulnerabilityResponse.java b/src/main/java/org/frankframework/insights/vulnerability/VulnerabilityResponse.java index 6d68704d..1bdb8245 100644 --- a/src/main/java/org/frankframework/insights/vulnerability/VulnerabilityResponse.java +++ b/src/main/java/org/frankframework/insights/vulnerability/VulnerabilityResponse.java @@ -13,4 +13,5 @@ public record VulnerabilityResponse( Set cwes, Double impactScore, String impactDescription, - OffsetDateTime publishedAt) {} + OffsetDateTime publishedAt, + String cvssVector) {} diff --git a/src/main/java/org/frankframework/insights/vulnerability/VulnerabilityService.java b/src/main/java/org/frankframework/insights/vulnerability/VulnerabilityService.java index fbcd1ab9..9e5b7629 100644 --- a/src/main/java/org/frankframework/insights/vulnerability/VulnerabilityService.java +++ b/src/main/java/org/frankframework/insights/vulnerability/VulnerabilityService.java @@ -515,6 +515,7 @@ private Vulnerability mapTrivyVulnerabilityToEntity( VulnerabilitySeverity severity = trivyVulnerability.toVulnerabilitySeverity(); vulnerability.setSeverity(severity); vulnerability.setCvssScore(getEffectiveCvssScore(trivyVulnerability, severity, vulnerability.getCvssScore())); + vulnerability.setCvssVector(getEffectiveCvssVector(trivyVulnerability, vulnerability.getCvssVector())); if (vulnerability.getPublishedAt() == null) { vulnerability.setPublishedAt(parsePublishedDate(trivyVulnerability.PublishedDate())); } @@ -540,6 +541,10 @@ private Double getEffectiveCvssScore( .orElseGet(severity::getRepresentativeScore); } + protected String getEffectiveCvssVector(TrivyVulnerability trivyVulnerability, String existingVector) { + return trivyVulnerability.extractCvssVector().orElse(existingVector); + } + @Transactional(readOnly = true) public Set getAllVulnerabilities() { try { diff --git a/src/main/resources/db/migration/V1.12__Add_Vulnerability_Cvss_Vector.sql b/src/main/resources/db/migration/V1.12__Add_Vulnerability_Cvss_Vector.sql new file mode 100644 index 00000000..06f556fa --- /dev/null +++ b/src/main/resources/db/migration/V1.12__Add_Vulnerability_Cvss_Vector.sql @@ -0,0 +1 @@ +ALTER TABLE vulnerability ADD COLUMN cvss_vector VARCHAR(255); diff --git a/src/test/java/org/frankframework/insights/vulnerability/VulnerabilityControllerTest.java b/src/test/java/org/frankframework/insights/vulnerability/VulnerabilityControllerTest.java index 23f84234..9452c2de 100644 --- a/src/test/java/org/frankframework/insights/vulnerability/VulnerabilityControllerTest.java +++ b/src/test/java/org/frankframework/insights/vulnerability/VulnerabilityControllerTest.java @@ -70,6 +70,7 @@ public void getVulnerabilitiesByReleaseId_returnsOkWithVulnerabilities() throws Set.of("CWE-89"), null, null, + null, null); VulnerabilityResponse vuln2 = new VulnerabilityResponse( @@ -81,6 +82,7 @@ public void getVulnerabilitiesByReleaseId_returnsOkWithVulnerabilities() throws Set.of("CWE-78", "CWE-502"), null, null, + null, null); Set vulnerabilities = Set.of(vuln1, vuln2); @@ -121,7 +123,9 @@ public void getVulnerabilitiesByReleaseId_withSingleVulnerability() throws Excep Set.of("CWE-200"), null, null, + null, null); + Set vulnerabilities = Set.of(vuln); when(vulnerabilityService.getVulnerabilitiesByReleaseId(releaseId)).thenReturn(vulnerabilities); @@ -158,6 +162,7 @@ public void getVulnerabilitiesByReleaseId_withVulnerabilitiesOfDifferentSeveriti Set.of("CWE-1"), null, null, + null, null); VulnerabilityResponse medium = new VulnerabilityResponse( "CVE-2024-0020", @@ -168,6 +173,7 @@ public void getVulnerabilitiesByReleaseId_withVulnerabilitiesOfDifferentSeveriti Set.of("CWE-2"), null, null, + null, null); VulnerabilityResponse high = new VulnerabilityResponse( "CVE-2024-0030", @@ -178,6 +184,7 @@ public void getVulnerabilitiesByReleaseId_withVulnerabilitiesOfDifferentSeveriti Set.of("CWE-3"), null, null, + null, null); VulnerabilityResponse critical = new VulnerabilityResponse( "CVE-2024-0040", @@ -188,6 +195,7 @@ public void getVulnerabilitiesByReleaseId_withVulnerabilitiesOfDifferentSeveriti Set.of("CWE-4"), null, null, + null, null); Set vulnerabilities = Set.of(low, medium, high, critical); @@ -214,6 +222,7 @@ public void getVulnerabilitiesByReleaseId_withVulnerabilityWithMultipleCwes() th Set.of("CWE-79", "CWE-89", "CWE-352", "CWE-434"), null, null, + null, null); Set vulnerabilities = Set.of(vuln); @@ -242,6 +251,7 @@ public void getVulnerabilitiesByReleaseId_withVulnerabilityWithNoCwes() throws E Collections.emptySet(), null, null, + null, null); Set vulnerabilities = Set.of(vuln); @@ -269,6 +279,7 @@ public void getAllVulnerabilities_returnsOkWithVulnerabilities() throws Exceptio Set.of("CWE-89"), 8.0, "High impact on production", + null, null); VulnerabilityResponse vuln2 = new VulnerabilityResponse( "CVE-2023-5678", @@ -279,6 +290,7 @@ public void getAllVulnerabilities_returnsOkWithVulnerabilities() throws Exceptio Set.of("CWE-78", "CWE-502"), 9.5, "Critical impact", + null, null); Set vulnerabilities = Set.of(vuln1, vuln2); @@ -317,6 +329,7 @@ public void getAllVulnerabilities_withImpactScores() throws Exception { Set.of("CWE-200"), 7.0, "Medium impact on business operations", + null, null); Set vulnerabilities = Set.of(vuln); @@ -334,6 +347,31 @@ public void getAllVulnerabilities_withImpactScores() throws Exception { verify(vulnerabilityService, times(1)).getAllVulnerabilities(); } + @Test + public void getAllVulnerabilities_includesCvssVector() throws Exception { + VulnerabilityResponse vuln = new VulnerabilityResponse( + "CVE-2024-0001", + null, + VulnerabilitySeverity.CRITICAL, + 9.8, + "Remote code execution", + Set.of("CWE-78"), + null, + null, + null, + "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H"); + + Set vulnerabilities = Set.of(vuln); + + when(vulnerabilityService.getAllVulnerabilities()).thenReturn(vulnerabilities); + + mockMvc.perform(get("/api/vulnerabilities")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].cvssVector").value("CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H")); + + verify(vulnerabilityService, times(1)).getAllVulnerabilities(); + } + @Test public void getAllVulnerabilitiesPaginated_returnsPageOfDetails() throws Exception { VulnerabilityResponse vulnResponse = new VulnerabilityResponse( @@ -345,6 +383,7 @@ public void getAllVulnerabilitiesPaginated_returnsPageOfDetails() throws Excepti Set.of("CWE-78"), null, null, + null, null); VulnerabilityDetailResponse detail = new VulnerabilityDetailResponse(vulnResponse, Map.of("9.0", List.of()), Map.of(), Map.of()); @@ -407,7 +446,17 @@ public void getAllVulnerabilitiesPaginated_withPageParams_passesPageableToServic @Test public void getAllVulnerabilitiesPaginated_responseContainsFixMaps() throws Exception { VulnerabilityResponse vulnResponse = new VulnerabilityResponse( - "CVE-2024-0001", null, VulnerabilitySeverity.HIGH, 8.5, "XSS", Set.of("CWE-79"), null, null, null); + "CVE-2024-0001", + null, + VulnerabilitySeverity.HIGH, + 8.5, + "XSS", + Set.of("CWE-79"), + null, + null, + null, + null); + ReleaseResponse patchRelease = new ReleaseResponse("r1", "v9.0.2", "9.0.2", null, null, null); VulnerabilityDetailResponse detail = new VulnerabilityDetailResponse( vulnResponse, Map.of("9.0", List.of()), Map.of("9.0", patchRelease), Map.of()); @@ -427,9 +476,9 @@ public void getAllVulnerabilitiesPaginated_responseContainsFixMaps() throws Exce @Test public void getAllVulnerabilitiesPaginated_multipleVulnerabilitiesOnPage() throws Exception { VulnerabilityResponse v1 = new VulnerabilityResponse( - "CVE-2024-0001", null, VulnerabilitySeverity.CRITICAL, 10.0, "RCE", null, null, null, null); + "CVE-2024-0001", null, VulnerabilitySeverity.CRITICAL, 10.0, "RCE", null, null, null, null, null); VulnerabilityResponse v2 = new VulnerabilityResponse( - "CVE-2024-0002", null, VulnerabilitySeverity.HIGH, 8.5, "XSS", null, null, null, null); + "CVE-2024-0002", null, VulnerabilitySeverity.HIGH, 8.5, "XSS", null, null, null, null, null); Page page = new PageImpl<>(List.of( new VulnerabilityDetailResponse(v1, Map.of(), Map.of(), Map.of()), new VulnerabilityDetailResponse(v2, Map.of(), Map.of(), Map.of()))); @@ -458,6 +507,7 @@ public void updateVulnerability_returnsOkWithUpdatedVulnerability() throws Excep Set.of("CWE-89"), 8.5, "High impact on production", + null, null); when(vulnerabilityService.updateVulnerabilityImpact(eq(cveId), any(VulnerabilityImpactRequest.class))) @@ -558,7 +608,7 @@ public void updateVulnerabilityImpact_withBoundaryScoreZero_returnsOk() throws E String cveId = "CVE-2023-1234"; VulnerabilityImpactRequest updateRequest = new VulnerabilityImpactRequest(0.0, "Minimal impact"); VulnerabilityResponse response = new VulnerabilityResponse( - cveId, null, VulnerabilitySeverity.LOW, 3.0, "desc", null, 0.0, "Minimal impact", null); + cveId, null, VulnerabilitySeverity.LOW, 3.0, "desc", null, 0.0, "Minimal impact", null, null); when(vulnerabilityService.updateVulnerabilityImpact(eq(cveId), any())).thenReturn(response); mockMvc.perform(put("/api/vulnerabilities/{cveId}/impact", cveId) @@ -573,7 +623,7 @@ public void updateVulnerabilityImpact_withBoundaryScoreTen_returnsOk() throws Ex String cveId = "CVE-2023-1234"; VulnerabilityImpactRequest updateRequest = new VulnerabilityImpactRequest(10.0, "Maximum impact"); VulnerabilityResponse response = new VulnerabilityResponse( - cveId, null, VulnerabilitySeverity.CRITICAL, 9.8, "desc", null, 10.0, "Maximum impact", null); + cveId, null, VulnerabilitySeverity.CRITICAL, 9.8, "desc", null, 10.0, "Maximum impact", null, null); when(vulnerabilityService.updateVulnerabilityImpact(eq(cveId), any())).thenReturn(response); mockMvc.perform(put("/api/vulnerabilities/{cveId}/impact", cveId) diff --git a/src/test/java/org/frankframework/insights/vulnerability/VulnerabilityServiceTest.java b/src/test/java/org/frankframework/insights/vulnerability/VulnerabilityServiceTest.java index 158c1ecc..89ef977e 100644 --- a/src/test/java/org/frankframework/insights/vulnerability/VulnerabilityServiceTest.java +++ b/src/test/java/org/frankframework/insights/vulnerability/VulnerabilityServiceTest.java @@ -92,7 +92,7 @@ public void testGetVulnerabilitiesByReleaseId_Success() { vuln.setCveId("CVE-2023-0001"); ReleaseVulnerability link = new ReleaseVulnerability(release, vuln); VulnerabilityResponse response = new VulnerabilityResponse( - "CVE-2023-0001", null, VulnerabilitySeverity.HIGH, 7.5, "Desc", null, null, null, null); + "CVE-2023-0001", null, VulnerabilitySeverity.HIGH, 7.5, "Desc", null, null, null, null, null); when(releaseVulnerabilityRepository.findAllByReleaseId(releaseId)).thenReturn(List.of(link)); when(mapper.toDTO(vuln, VulnerabilityResponse.class)).thenReturn(response); @@ -150,9 +150,9 @@ public void testGetAllVulnerabilities_Success() { vuln2.setCvssScore(9.8); VulnerabilityResponse response1 = new VulnerabilityResponse( - "CVE-2023-0001", null, VulnerabilitySeverity.HIGH, 7.5, "Desc1", null, null, null, null); + "CVE-2023-0001", null, VulnerabilitySeverity.HIGH, 7.5, "Desc1", null, null, null, null, null); VulnerabilityResponse response2 = new VulnerabilityResponse( - "CVE-2023-0002", null, VulnerabilitySeverity.CRITICAL, 9.8, "Desc2", null, null, null, null); + "CVE-2023-0002", null, VulnerabilitySeverity.CRITICAL, 9.8, "Desc2", null, null, null, null, null); when(vulnerabilityRepository.findAll()).thenReturn(List.of(vuln1, vuln2)); when(mapper.toDTO(vuln1, VulnerabilityResponse.class)).thenReturn(response1); @@ -216,6 +216,7 @@ public void testUpdateVulnerabilityImpact_Success() throws VulnerabilityNotFound null, 8.5, "High impact on production", + null, null); when(vulnerabilityRepository.findById(cveId)).thenReturn(Optional.of(existingVuln)); @@ -336,11 +337,11 @@ public void testGetVulnerabilitiesByReleaseId_WithMultipleVulnerabilities() { ReleaseVulnerability link3 = new ReleaseVulnerability(release, vuln3); VulnerabilityResponse response1 = new VulnerabilityResponse( - "CVE-2023-0001", null, VulnerabilitySeverity.HIGH, 7.5, "Desc1", null, null, null, null); + "CVE-2023-0001", null, VulnerabilitySeverity.HIGH, 7.5, "Desc1", null, null, null, null, null); VulnerabilityResponse response2 = new VulnerabilityResponse( - "CVE-2023-0002", null, VulnerabilitySeverity.MEDIUM, 5.0, "Desc2", null, null, null, null); + "CVE-2023-0002", null, VulnerabilitySeverity.MEDIUM, 5.0, "Desc2", null, null, null, null, null); VulnerabilityResponse response3 = new VulnerabilityResponse( - "CVE-2023-0003", null, VulnerabilitySeverity.CRITICAL, 9.8, "Desc3", null, null, null, null); + "CVE-2023-0003", null, VulnerabilitySeverity.CRITICAL, 9.8, "Desc3", null, null, null, null, null); when(releaseVulnerabilityRepository.findAllByReleaseId(releaseId)).thenReturn(List.of(link1, link2, link3)); when(mapper.toDTO(vuln1, VulnerabilityResponse.class)).thenReturn(response1); @@ -423,7 +424,16 @@ public void testUpdateVulnerabilityImpact_UpdatesExistingFields() throws Vulnera when(vulnerabilityRepository.save(any(Vulnerability.class))).thenAnswer(inv -> inv.getArgument(0)); when(mapper.toDTO(any(Vulnerability.class), any())) .thenReturn(new VulnerabilityResponse( - cveId, null, VulnerabilitySeverity.MEDIUM, 5.0, "Desc", null, 6.0, "Updated impact", null)); + cveId, + null, + VulnerabilitySeverity.MEDIUM, + 5.0, + "Desc", + null, + 6.0, + "Updated impact", + null, + null)); vulnerabilityService.updateVulnerabilityImpact(cveId, updateRequest); @@ -487,7 +497,8 @@ public void testGetAllVulnerabilities_WithMixedSeverities() { when(vulnerabilityRepository.findAll()).thenReturn(List.of(low, medium, high, critical)); when(mapper.toDTO(any(Vulnerability.class), any())).thenAnswer(inv -> { Vulnerability v = inv.getArgument(0); - return new VulnerabilityResponse(v.getCveId(), null, v.getSeverity(), 0.0, "Desc", null, null, null, null); + return new VulnerabilityResponse( + v.getCveId(), null, v.getSeverity(), 0.0, "Desc", null, null, null, null, null); }); Set result = vulnerabilityService.getAllVulnerabilities(); @@ -1094,7 +1105,7 @@ public void testGetVulnerabilitiesByReleaseId_WithNullVulnerabilityInLink() { ReleaseVulnerability validLink = new ReleaseVulnerability(release, validVuln); VulnerabilityResponse response = new VulnerabilityResponse( - "CVE-2023-0001", null, VulnerabilitySeverity.HIGH, 7.5, "Desc", null, null, null, null); + "CVE-2023-0001", null, VulnerabilitySeverity.HIGH, 7.5, "Desc", null, null, null, null, null); when(releaseVulnerabilityRepository.findAllByReleaseId(releaseId)).thenReturn(List.of(linkWithNull, validLink)); when(mapper.toDTO(validVuln, VulnerabilityResponse.class)).thenReturn(response); @@ -1482,6 +1493,89 @@ public void testGetEffectiveCvssScore_UsesTrivyScore() throws Exception { assertEquals(9.1, result); } + @Test + public void testGetEffectiveCvssVector_UsesExistingVector() { + VulnerabilityService testService = new VulnerabilityService( + vulnerabilityRepository, + releaseVulnerabilityRepository, + releaseRepository, + releaseArtifactService, + mapper, + "trivy", + "/tmp", + "/tmp"); + + TrivyVulnerability vulnWithoutCvss = + new TrivyVulnerability("CVE-2023-0001", "Title", "Desc", "HIGH", null, null, null); + + String result = + testService.getEffectiveCvssVector(vulnWithoutCvss, "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H"); + + assertEquals("CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", result); + } + + @Test + public void testGetEffectiveCvssVector_UsesTrivyVector() { + VulnerabilityService testService = new VulnerabilityService( + vulnerabilityRepository, + releaseVulnerabilityRepository, + releaseRepository, + releaseArtifactService, + mapper, + "trivy", + "/tmp", + "/tmp"); + + java.util.Map cvssMap = new java.util.HashMap<>(); + cvssMap.put("nvd", new TrivyCvssScore(null, null, 9.1, "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H")); + + TrivyVulnerability vulnWithCvss = + new TrivyVulnerability("CVE-2023-0001", "Title", "Desc", "CRITICAL", null, cvssMap, null); + + String result = testService.getEffectiveCvssVector(vulnWithCvss, null); + + assertEquals("CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H", result); + } + + @Test + public void testExtractCvssVector_EmptyCvssMap_ReturnsEmpty() { + TrivyVulnerability vuln = new TrivyVulnerability("CVE-2023-0001", "Title", "Desc", "HIGH", null, null, null); + + assertTrue(vuln.extractCvssVector().isEmpty()); + } + + @Test + public void testExtractCvssVector_PrefersNvdOverOtherSources() { + java.util.Map cvssMap = new java.util.HashMap<>(); + cvssMap.put("redhat", new TrivyCvssScore(null, null, 8.0, "CVSS:3.1/AV:A/AC:H/PR:L/UI:R/S:U/C:L/I:L/A:N")); + cvssMap.put("nvd", new TrivyCvssScore(null, null, 9.8, "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H")); + + TrivyVulnerability vuln = + new TrivyVulnerability("CVE-2023-0001", "Title", "Desc", "CRITICAL", null, cvssMap, null); + + assertEquals(Optional.of("CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H"), vuln.extractCvssVector()); + } + + @Test + public void testExtractCvssVector_FallsBackToRedhatWhenNvdMissing() { + java.util.Map cvssMap = new java.util.HashMap<>(); + cvssMap.put("redhat", new TrivyCvssScore(null, null, 8.0, "CVSS:3.1/AV:A/AC:H/PR:L/UI:R/S:U/C:L/I:L/A:N")); + + TrivyVulnerability vuln = new TrivyVulnerability("CVE-2023-0001", "Title", "Desc", "HIGH", null, cvssMap, null); + + assertEquals(Optional.of("CVSS:3.1/AV:A/AC:H/PR:L/UI:R/S:U/C:L/I:L/A:N"), vuln.extractCvssVector()); + } + + @Test + public void testExtractCvssVector_FallsBackToAnySourceWhenNvdAndRedhatMissing() { + java.util.Map cvssMap = new java.util.HashMap<>(); + cvssMap.put("ghsa", new TrivyCvssScore(null, null, 7.5, "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:N/A:N")); + + TrivyVulnerability vuln = new TrivyVulnerability("CVE-2023-0001", "Title", "Desc", "HIGH", null, cvssMap, null); + + assertEquals(Optional.of("CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:N/A:N"), vuln.extractCvssVector()); + } + @Test public void testHandleEmptyScanResult_DeletesExistingLinks() throws Exception { VulnerabilityService testService = new VulnerabilityService( From 6a6a794e693f6f136a4fa4c9541c4c01f39d2298 Mon Sep 17 00:00:00 2001 From: stijnpotters Date: Tue, 21 Jul 2026 10:59:52 +0200 Subject: [PATCH 2/4] feat: explain cvss calculation is not being pulled out of thin air --- src/main/frontend/src/app/pipes/cvss.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/main/frontend/src/app/pipes/cvss.ts b/src/main/frontend/src/app/pipes/cvss.ts index 4825969e..6fb40881 100644 --- a/src/main/frontend/src/app/pipes/cvss.ts +++ b/src/main/frontend/src/app/pipes/cvss.ts @@ -1,3 +1,6 @@ +// The source this logic is based on is: https://www.first.org/cvss/v3-1/specification-document#Scoring +// Here you can see all the metric equations and values that are being used in the original calculator which are now used inside our calculate functions. + export type CvssMetricKey = 'AV' | 'AC' | 'PR' | 'UI' | 'S' | 'C' | 'I' | 'A'; export interface CvssMetricOption { From 08b26b7538f4ed978e584e9ed502bdb74a1409b3 Mon Sep 17 00:00:00 2001 From: stijnpotters Date: Wed, 22 Jul 2026 17:28:09 +0200 Subject: [PATCH 3/4] fix: removed one letter indicators and replaced with full words --- .../cvss-calculator/cvss-calculator.component.html | 2 +- .../cvss-calculator/cvss-calculator.component.scss | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/main/frontend/src/app/components/cvss-calculator/cvss-calculator.component.html b/src/main/frontend/src/app/components/cvss-calculator/cvss-calculator.component.html index d7725cd3..2dc6665f 100644 --- a/src/main/frontend/src/app/components/cvss-calculator/cvss-calculator.component.html +++ b/src/main/frontend/src/app/components/cvss-calculator/cvss-calculator.component.html @@ -49,7 +49,7 @@ (mouseleave)="hideTooltip()" (blur)="hideTooltip()" > - {{ option.key }} + {{ option.fullLabel }} }
diff --git a/src/main/frontend/src/app/components/cvss-calculator/cvss-calculator.component.scss b/src/main/frontend/src/app/components/cvss-calculator/cvss-calculator.component.scss index 91316df9..8190d362 100644 --- a/src/main/frontend/src/app/components/cvss-calculator/cvss-calculator.component.scss +++ b/src/main/frontend/src/app/components/cvss-calculator/cvss-calculator.component.scss @@ -121,18 +121,20 @@ .metric-options { display: flex; + flex-wrap: wrap; gap: 0.35rem; } .metric-option { flex: 1; - padding: 0.4rem 0; + min-width: 4.5rem; + padding: 0.4rem 0.35rem; border: 1px solid #d1d5db; border-radius: 6px; background: #fff; color: #374151; font-weight: 600; - font-size: 0.8rem; + font-size: 0.75rem; cursor: pointer; transition: all 0.15s; From f7fbf03dd4c802fe7fe1ba64d598b35b580fe9ab Mon Sep 17 00:00:00 2001 From: stijnpotters Date: Mon, 27 Jul 2026 16:13:52 +0200 Subject: [PATCH 4/4] feat: refactor tooltip handling to use shared TooltipService for improved maintainability --- src/main/frontend/src/app/app.component.ts | 2 +- .../cvss-calculator.component.html | 6 -- .../cvss-calculator.component.scss | 20 ----- .../cvss-calculator.component.spec.ts | 22 ++++++ .../cvss-calculator.component.ts | 26 ++----- .../components/tooltip/tooltip.component.html | 13 ++++ .../tooltip/tooltip.component.scss | 2 +- .../tooltip/tooltip.component.spec.ts | 76 ++++++++++--------- .../tooltip/tooltip.component.ts | 0 .../tooltip/tooltip.service.ts | 13 +++- .../issue-bar/issue-bar.component.spec.ts | 6 +- .../issue-bar/issue-bar.component.ts | 12 ++- .../issue-bar/tooltip/tooltip.component.html | 11 --- 13 files changed, 108 insertions(+), 101 deletions(-) create mode 100644 src/main/frontend/src/app/components/tooltip/tooltip.component.html rename src/main/frontend/src/app/{pages/release-roadmap/issue-bar => components}/tooltip/tooltip.component.scss (96%) rename src/main/frontend/src/app/{pages/release-roadmap/issue-bar => components}/tooltip/tooltip.component.spec.ts (67%) rename src/main/frontend/src/app/{pages/release-roadmap/issue-bar => components}/tooltip/tooltip.component.ts (100%) rename src/main/frontend/src/app/{pages/release-roadmap/issue-bar => components}/tooltip/tooltip.service.ts (75%) delete mode 100644 src/main/frontend/src/app/pages/release-roadmap/issue-bar/tooltip/tooltip.component.html diff --git a/src/main/frontend/src/app/app.component.ts b/src/main/frontend/src/app/app.component.ts index 3b0ca233..c3ac9c93 100644 --- a/src/main/frontend/src/app/app.component.ts +++ b/src/main/frontend/src/app/app.component.ts @@ -11,7 +11,7 @@ import { import { LoaderComponent } from './components/loader/loader.component'; import { FeedbackComponent } from './components/feedback/feedback.component'; import { HeaderComponent } from './pages/header/header.component'; -import { TooltipComponent } from './pages/release-roadmap/issue-bar/tooltip/tooltip.component'; +import { TooltipComponent } from './components/tooltip/tooltip.component'; import { AuthService } from './services/auth.service'; import { GraphStateService } from './services/graph-state.service'; diff --git a/src/main/frontend/src/app/components/cvss-calculator/cvss-calculator.component.html b/src/main/frontend/src/app/components/cvss-calculator/cvss-calculator.component.html index 2dc6665f..dc67732c 100644 --- a/src/main/frontend/src/app/components/cvss-calculator/cvss-calculator.component.html +++ b/src/main/frontend/src/app/components/cvss-calculator/cvss-calculator.component.html @@ -57,12 +57,6 @@ } - @if (activeTooltip(); as tooltip) { - - } -
{{ result()?.score ?? '—' }} diff --git a/src/main/frontend/src/app/components/cvss-calculator/cvss-calculator.component.scss b/src/main/frontend/src/app/components/cvss-calculator/cvss-calculator.component.scss index 8190d362..344998ec 100644 --- a/src/main/frontend/src/app/components/cvss-calculator/cvss-calculator.component.scss +++ b/src/main/frontend/src/app/components/cvss-calculator/cvss-calculator.component.scss @@ -99,26 +99,6 @@ width: fit-content; } -.floating-tooltip { - position: fixed; - transform: translate(-50%, -100%); - z-index: 100; - width: max-content; - max-width: min(240px, calc(100vw - 2rem)); - padding: 0.5rem 0.65rem; - border-radius: 6px; - background: #1f2937; - color: #f9fafb; - font-weight: 500; - font-size: 0.75rem; - text-align: left; - pointer-events: none; - - strong { - color: #fff; - } -} - .metric-options { display: flex; flex-wrap: wrap; diff --git a/src/main/frontend/src/app/components/cvss-calculator/cvss-calculator.component.spec.ts b/src/main/frontend/src/app/components/cvss-calculator/cvss-calculator.component.spec.ts index bd6626fd..31f17336 100644 --- a/src/main/frontend/src/app/components/cvss-calculator/cvss-calculator.component.spec.ts +++ b/src/main/frontend/src/app/components/cvss-calculator/cvss-calculator.component.spec.ts @@ -1,13 +1,18 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { CvssCalculatorComponent } from './cvss-calculator.component'; +import { TooltipService } from '../tooltip/tooltip.service'; describe('CvssCalculatorComponent', () => { let component: CvssCalculatorComponent; let fixture: ComponentFixture; + let mockTooltipService: jasmine.SpyObj; beforeEach(async () => { + mockTooltipService = jasmine.createSpyObj('TooltipService', ['show', 'hide']); + await TestBed.configureTestingModule({ imports: [CvssCalculatorComponent], + providers: [{ provide: TooltipService, useValue: mockTooltipService }], }).compileComponents(); fixture = TestBed.createComponent(CvssCalculatorComponent); @@ -79,6 +84,23 @@ describe('CvssCalculatorComponent', () => { expect(emitSpy).not.toHaveBeenCalled(); }); + it('should show a tooltip via the shared TooltipService', () => { + const target = document.createElement('span'); + const event = { currentTarget: target } as unknown as Event; + + component.showTooltip(event, 'Attack Vector', 'How the vulnerability is exploited'); + + expect(mockTooltipService.show).toHaveBeenCalledWith(target, 'Attack Vector', [ + { value: 'How the vulnerability is exploited' }, + ]); + }); + + it('should hide the tooltip via the shared TooltipService', () => { + component.hideTooltip(); + + expect(mockTooltipService.hide).toHaveBeenCalledWith(); + }); + it('should emit closed when close is called', () => { const emitSpy = spyOn(component.closed, 'emit'); diff --git a/src/main/frontend/src/app/components/cvss-calculator/cvss-calculator.component.ts b/src/main/frontend/src/app/components/cvss-calculator/cvss-calculator.component.ts index 0bddcfc4..bee3405b 100644 --- a/src/main/frontend/src/app/components/cvss-calculator/cvss-calculator.component.ts +++ b/src/main/frontend/src/app/components/cvss-calculator/cvss-calculator.component.ts @@ -2,6 +2,7 @@ import { Component, computed, EventEmitter, + inject, Input, OnInit, Output, @@ -12,6 +13,7 @@ import { import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { ModalComponent } from '../modal/modal.component'; +import { TooltipService } from '../tooltip/tooltip.service'; import { calculateCvssScore, CVSS_METRICS, @@ -22,13 +24,6 @@ import { vectorToString, } from '../../pipes/cvss'; -export interface CvssTooltipState { - top: string; - left: string; - title: string; - description: string; -} - @Component({ selector: 'app-cvss-calculator', standalone: true, @@ -50,11 +45,11 @@ export class CvssCalculatorComponent implements OnInit { public selection: WritableSignal = signal({}); - public activeTooltip: WritableSignal = signal(null); - public result: Signal = computed(() => calculateCvssScore(this.selection())); public currentVectorString: Signal = computed(() => vectorToString(this.selection())); + private tooltipService = inject(TooltipService); + ngOnInit(): void { if (!this.referenceVector) return; @@ -88,20 +83,11 @@ export class CvssCalculatorComponent implements OnInit { } public showTooltip(event: Event, title: string, description: string): void { - const target = event.currentTarget as HTMLElement; - const rect = target.getBoundingClientRect(); - const gap = 8; - - this.activeTooltip.set({ - top: `${rect.top - gap}px`, - left: `${rect.left + rect.width / 2}px`, - title, - description, - }); + this.tooltipService.show(event.currentTarget as HTMLElement, title, [{ value: description }]); } public hideTooltip(): void { - this.activeTooltip.set(null); + this.tooltipService.hide(); } public useScore(): void { diff --git a/src/main/frontend/src/app/components/tooltip/tooltip.component.html b/src/main/frontend/src/app/components/tooltip/tooltip.component.html new file mode 100644 index 00000000..5c15d99d --- /dev/null +++ b/src/main/frontend/src/app/components/tooltip/tooltip.component.html @@ -0,0 +1,13 @@ +@if (tooltipState$ | async; as state) { +
+

{{ state.title }}

+ @for (detail of state.details; track detail.value) { +

+ @if (detail.label) { + {{ detail.label }}: + } + {{ detail.value }} +

+ } +
+} diff --git a/src/main/frontend/src/app/pages/release-roadmap/issue-bar/tooltip/tooltip.component.scss b/src/main/frontend/src/app/components/tooltip/tooltip.component.scss similarity index 96% rename from src/main/frontend/src/app/pages/release-roadmap/issue-bar/tooltip/tooltip.component.scss rename to src/main/frontend/src/app/components/tooltip/tooltip.component.scss index 2068e01d..3372985a 100644 --- a/src/main/frontend/src/app/pages/release-roadmap/issue-bar/tooltip/tooltip.component.scss +++ b/src/main/frontend/src/app/components/tooltip/tooltip.component.scss @@ -8,7 +8,7 @@ box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1); padding: 1rem; width: 300px; - max-width: 90vw; + max-width: 50vw; z-index: 100; pointer-events: none; diff --git a/src/main/frontend/src/app/pages/release-roadmap/issue-bar/tooltip/tooltip.component.spec.ts b/src/main/frontend/src/app/components/tooltip/tooltip.component.spec.ts similarity index 67% rename from src/main/frontend/src/app/pages/release-roadmap/issue-bar/tooltip/tooltip.component.spec.ts rename to src/main/frontend/src/app/components/tooltip/tooltip.component.spec.ts index 7fc594a4..567f9b4e 100644 --- a/src/main/frontend/src/app/pages/release-roadmap/issue-bar/tooltip/tooltip.component.spec.ts +++ b/src/main/frontend/src/app/components/tooltip/tooltip.component.spec.ts @@ -1,36 +1,15 @@ - import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { BehaviorSubject } from 'rxjs'; -import { provideHttpClient } from '@angular/common/http'; -import { provideHttpClientTesting } from '@angular/common/http/testing'; import { TooltipComponent } from './tooltip.component'; import { TooltipData, TooltipService } from './tooltip.service'; -import { GitHubStates } from '../../../../app.service'; -import { Issue } from '../../../../services/issue.service'; - -const MOCK_ISSUE: Issue = { - id: '1', - number: 123, - title: 'Tooltip Test Issue', - url: 'http://example.com', - state: GitHubStates.OPEN, - points: 8, - issuePriority: { id: 'p1', name: 'High', color: 'ff0000', description: '' }, -}; describe('TooltipService', () => { let service: TooltipService; beforeEach(() => { - TestBed.configureTestingModule({ - providers: [ - TooltipService, - provideHttpClient(), - provideHttpClientTesting(), - ], - }); + TestBed.configureTestingModule({}); service = TestBed.inject(TooltipService); }); @@ -50,19 +29,32 @@ describe('TooltipService', () => { service.tooltipState$.subscribe((data) => { if (data) { - expect(data.issue).toBe(MOCK_ISSUE); + expect(data.title).toBe('Test Title'); + expect(data.details).toEqual([{ label: 'Priority', value: 'High' }]); expect(data.top).toBe('92px'); expect(data.left).toBe('225px'); done(); } }); - service.show(mockElement, MOCK_ISSUE); + service.show(mockElement, 'Test Title', [{ label: 'Priority', value: 'High' }]); }); - it('should emit null on hide()', (done) => { + it('should default to an empty details array', (done) => { + const mockElement = document.createElement('div'); + + service.tooltipState$.subscribe((data) => { + if (data) { + expect(data.details).toEqual([]); + done(); + } + }); - service.show(document.createElement('div'), MOCK_ISSUE); + service.show(mockElement, 'Test Title'); + }); + + it('should emit null on hide()', (done) => { + service.show(document.createElement('div'), 'Test Title'); service.tooltipState$.subscribe((data) => { if (data === null) { @@ -85,11 +77,7 @@ describe('TooltipComponent', () => { await TestBed.configureTestingModule({ imports: [TooltipComponent], - providers: [ - { provide: TooltipService, useValue: { tooltipState$: tooltipSubject.asObservable() } }, - provideHttpClient(), - provideHttpClientTesting(), - ], + providers: [{ provide: TooltipService, useValue: { tooltipState$: tooltipSubject.asObservable() } }], }).compileComponents(); fixture = TestBed.createComponent(TooltipComponent); @@ -112,7 +100,11 @@ describe('TooltipComponent', () => { it('should display the tooltip with correct data when service emits state', fakeAsync(() => { const tooltipData: TooltipData = { - issue: MOCK_ISSUE, + title: 'Tooltip Test Issue', + details: [ + { label: 'Priority', value: 'High' }, + { label: 'Points', value: '8' }, + ], top: '100px', left: '200px', }; @@ -127,14 +119,30 @@ describe('TooltipComponent', () => { const titleElement = tooltipElement.query(By.css('.tooltip-title')).nativeElement; const detailsElement = tooltipElement.queryAll(By.css('.tooltip-detail')); - expect(titleElement.textContent).toContain(MOCK_ISSUE.title); + expect(titleElement.textContent).toContain(tooltipData.title); expect(detailsElement.length).toBe(2); expect(detailsElement[0].nativeElement.textContent).toContain('Priority: High'); expect(detailsElement[1].nativeElement.textContent).toContain('Points: 8'); })); + it('should render a detail without a label as plain text', fakeAsync(() => { + const tooltipData: TooltipData = { + title: 'Attack Vector', + details: [{ value: 'How the vulnerability is exploited' }], + top: '100px', + left: '200px', + }; + tooltipSubject.next(tooltipData); + fixture.detectChanges(); + tick(); + + const detailElement = fixture.debugElement.query(By.css('.tooltip-detail')); + + expect(detailElement.nativeElement.textContent.trim()).toBe('How the vulnerability is exploited'); + })); + it('should hide the tooltip when service emits null after showing', fakeAsync(() => { - const tooltipData: TooltipData = { issue: MOCK_ISSUE, top: '100px', left: '200px' }; + const tooltipData: TooltipData = { title: 'Tooltip Test Issue', details: [], top: '100px', left: '200px' }; tooltipSubject.next(tooltipData); fixture.detectChanges(); tick(); diff --git a/src/main/frontend/src/app/pages/release-roadmap/issue-bar/tooltip/tooltip.component.ts b/src/main/frontend/src/app/components/tooltip/tooltip.component.ts similarity index 100% rename from src/main/frontend/src/app/pages/release-roadmap/issue-bar/tooltip/tooltip.component.ts rename to src/main/frontend/src/app/components/tooltip/tooltip.component.ts diff --git a/src/main/frontend/src/app/pages/release-roadmap/issue-bar/tooltip/tooltip.service.ts b/src/main/frontend/src/app/components/tooltip/tooltip.service.ts similarity index 75% rename from src/main/frontend/src/app/pages/release-roadmap/issue-bar/tooltip/tooltip.service.ts rename to src/main/frontend/src/app/components/tooltip/tooltip.service.ts index 6fa19ef1..2695b1ce 100644 --- a/src/main/frontend/src/app/pages/release-roadmap/issue-bar/tooltip/tooltip.service.ts +++ b/src/main/frontend/src/app/components/tooltip/tooltip.service.ts @@ -1,9 +1,14 @@ import { Injectable } from '@angular/core'; import { BehaviorSubject, Observable } from 'rxjs'; -import { Issue } from '../../../../services/issue.service'; + +export interface TooltipDetail { + label?: string; + value: string; +} export interface TooltipData { - issue: Issue; + title: string; + details: TooltipDetail[]; top: string; left: string; } @@ -15,9 +20,9 @@ export class TooltipService { public tooltipSubject = new BehaviorSubject(null); public readonly tooltipState$: Observable = this.tooltipSubject.asObservable(); - public show(hostElement: HTMLElement, issue: Issue): void { + public show(hostElement: HTMLElement, title: string, details: TooltipDetail[] = []): void { const position = this.calculatePosition(hostElement); - this.tooltipSubject.next({ issue, ...position }); + this.tooltipSubject.next({ title, details, ...position }); } public hide(): void { diff --git a/src/main/frontend/src/app/pages/release-roadmap/issue-bar/issue-bar.component.spec.ts b/src/main/frontend/src/app/pages/release-roadmap/issue-bar/issue-bar.component.spec.ts index 8d41a0b7..627b1986 100644 --- a/src/main/frontend/src/app/pages/release-roadmap/issue-bar/issue-bar.component.spec.ts +++ b/src/main/frontend/src/app/pages/release-roadmap/issue-bar/issue-bar.component.spec.ts @@ -5,7 +5,7 @@ import { provideHttpClientTesting } from '@angular/common/http/testing'; import { IssueBarComponent } from './issue-bar.component'; import { Issue, IssuePriority, IssueState, IssueType } from '../../../services/issue.service'; import { GitHubStates } from '../../../app.service'; -import { TooltipService } from './tooltip/tooltip.service'; +import { TooltipService } from '../../../components/tooltip/tooltip.service'; const EPIC_TYPE: IssueType = { id: 'epic-1', @@ -318,7 +318,9 @@ describe('IssueBarComponent', () => { const issueLink = fixture.debugElement.query(By.css('.issue-bar')); issueLink.triggerEventHandler('mouseenter', null); - expect(mockTooltipService.show).toHaveBeenCalledWith(issueLink.nativeElement, component.issue); + expect(mockTooltipService.show).toHaveBeenCalledWith(issueLink.nativeElement, component.issue.title, [ + { label: 'Points', value: '5' }, + ]); }); it('should call TooltipService.hide on mouseleave', () => { diff --git a/src/main/frontend/src/app/pages/release-roadmap/issue-bar/issue-bar.component.ts b/src/main/frontend/src/app/pages/release-roadmap/issue-bar/issue-bar.component.ts index f7f9452c..33f8e0b6 100644 --- a/src/main/frontend/src/app/pages/release-roadmap/issue-bar/issue-bar.component.ts +++ b/src/main/frontend/src/app/pages/release-roadmap/issue-bar/issue-bar.component.ts @@ -2,7 +2,7 @@ import { Component, Input, OnInit, ElementRef, ViewChild, inject } from '@angula import { CommonModule } from '@angular/common'; import { GitHubStates } from '../../../app.service'; import { Issue } from '../../../services/issue.service'; -import { TooltipService } from './tooltip/tooltip.service'; +import { TooltipDetail, TooltipService } from '../../../components/tooltip/tooltip.service'; import { ISSUE_STATE_STYLES, CLOSED_STYLE, OPEN_STYLE, ViewMode } from '../release-roadmap.component'; @Component({ @@ -41,7 +41,15 @@ export class IssueBarComponent implements OnInit { } if (this.issueLinkRef) { - this.tooltipService.show(this.issueLinkRef.nativeElement, this.issue); + const details: TooltipDetail[] = []; + if (this.issue.issuePriority) { + details.push({ label: 'Priority', value: this.issue.issuePriority.name }); + } + if (this.issue.points) { + details.push({ label: 'Points', value: `${this.issue.points}` }); + } + + this.tooltipService.show(this.issueLinkRef.nativeElement, this.issue.title, details); } } diff --git a/src/main/frontend/src/app/pages/release-roadmap/issue-bar/tooltip/tooltip.component.html b/src/main/frontend/src/app/pages/release-roadmap/issue-bar/tooltip/tooltip.component.html deleted file mode 100644 index b12bb6dc..00000000 --- a/src/main/frontend/src/app/pages/release-roadmap/issue-bar/tooltip/tooltip.component.html +++ /dev/null @@ -1,11 +0,0 @@ -@if (tooltipState$ | async; as state) { -
-

{{ state.issue.title }}

- @if (state.issue.issuePriority) { -

Priority: {{ state.issue.issuePriority.name }}

- } - @if (state.issue.points) { -

Points: {{ state.issue.points }}

- } -
-}