From e6ded31f98d51fdb7a2920eb293e9a1ca6f40277 Mon Sep 17 00:00:00 2001 From: Hoda Noori Date: Tue, 19 May 2026 19:04:14 +0200 Subject: [PATCH 01/11] feat(heureka): add Mitigate Manually action to vulnerability rows Add a new "Mitigate Manually" popup action to both the active and remediated vulnerability tabs. Opens a modal (same shape as False Positive) that creates a remediation with type=mitigation. Revert works automatically via the existing RemediationHistoryPanel flow. Signed-off-by: Hoda Noori --- .../IssuesDataRows/IssuesDataRow/index.tsx | 57 +++++ .../IssuesDataRows/IssuesDataRows.test.tsx | 3 + .../ImageIssuesList/IssuesDataRows/index.tsx | 3 + .../RemediatedIssueDataRow/index.tsx | 12 + .../ImageDetails/ImageIssuesList/index.tsx | 16 +- .../MitigateManuallyModal/index.tsx | 227 ++++++++++++++++++ 6 files changed, 317 insertions(+), 1 deletion(-) create mode 100644 apps/heureka/src/components/Service/ImageDetails/MitigateManuallyModal/index.tsx diff --git a/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/IssuesDataRows/IssuesDataRow/index.tsx b/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/IssuesDataRows/IssuesDataRow/index.tsx index 8284cc2df7..4a5556edbc 100644 --- a/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/IssuesDataRows/IssuesDataRow/index.tsx +++ b/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/IssuesDataRows/IssuesDataRow/index.tsx @@ -20,6 +20,7 @@ import { ImageVulnerability } from "../../../../../Services/utils" import { getSeverityColor, useTextOverflow } from "../../../../../../utils" import { FalsePositiveModal } from "../../../FalsePositiveModal" import { RiskAcceptanceModal } from "../../../RiskAcceptanceModal" +import { MitigateManuallyModal } from "../../../MitigateManuallyModal" import { useRouteContext } from "@tanstack/react-router" import { createRemediation } from "../../../../../../api/createRemediation" import { RemediationInput } from "../../../../../../generated/graphql" @@ -42,6 +43,7 @@ type IssuesDataRowProps = { showFalsePositiveAction?: boolean onFalsePositiveSuccess?: (cveNumber: string) => void | Promise onRiskAcceptanceSuccess?: (cveNumber: string) => void | Promise + onMitigateManuallySuccess?: (cveNumber: string) => void | Promise } export const IssuesDataRow = ({ @@ -51,10 +53,12 @@ export const IssuesDataRow = ({ showFalsePositiveAction = true, onFalsePositiveSuccess, onRiskAcceptanceSuccess, + onMitigateManuallySuccess, }: IssuesDataRowProps) => { const [isExpanded, setIsExpanded] = useState(false) const [isModalOpen, setIsModalOpen] = useState(false) const [isRiskAcceptanceModalOpen, setIsRiskAcceptanceModalOpen] = useState(false) + const [isMitigateManuallyModalOpen, setIsMitigateManuallyModalOpen] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false) const { needsExpansion, textRef } = useTextOverflow(issue?.description || "") const { apiClient, queryClient } = useRouteContext({ from: "/services/$service" }) @@ -158,6 +162,49 @@ export const IssuesDataRow = ({ } } + const handleMitigateManuallyConfirm = async (input: RemediationInput): Promise<{ error: string } | void> => { + setIsSubmitting(true) + try { + const remediation = await createRemediation({ apiClient, input }) + const cveNumber = issue?.name || "unknown" + if (remediation) { + queryClient.setQueriesData( + { + predicate: (query) => { + const [key, filter] = query.queryKey as [string, any] + if (key !== "remediations") return false + if (filter?.service && !filter.service.includes(service)) return false + if (filter?.image && !filter.image.includes(image)) return false + if (filter?.vulnerability && !filter.vulnerability.includes(cveNumber)) return false + return true + }, + }, + (old: any) => { + if (!old?.data?.Remediations) return old + const edges = old.data.Remediations.edges ?? [] + if (edges.some((e: any) => e?.node?.id === remediation.id)) return old + return { + ...old, + data: { + ...old.data, + Remediations: { + ...old.data.Remediations, + edges: [...edges, { node: remediation }], + totalCount: (old.data.Remediations.totalCount ?? 0) + 1, + }, + }, + } + } + ) + } + await onMitigateManuallySuccess?.(cveNumber) + } catch (error) { + return { error: error instanceof Error ? error.message : "Failed to create remediation" } + } finally { + setIsSubmitting(false) + } + } + return ( <> @@ -207,6 +254,7 @@ export const IssuesDataRow = ({ setIsModalOpen(true)} /> setIsRiskAcceptanceModalOpen(true)} /> + setIsMitigateManuallyModalOpen(true)} /> )} @@ -233,6 +281,15 @@ export const IssuesDataRow = ({ service={service} image={image} /> + setIsMitigateManuallyModalOpen(false)} + onConfirm={handleMitigateManuallyConfirm} + vulnerability={issue.name} + severity={issue.severity} + service={service} + image={image} + /> )} diff --git a/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/IssuesDataRows/IssuesDataRows.test.tsx b/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/IssuesDataRows/IssuesDataRows.test.tsx index 9e31ca3543..f0cd9ec481 100644 --- a/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/IssuesDataRows/IssuesDataRows.test.tsx +++ b/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/IssuesDataRows/IssuesDataRows.test.tsx @@ -123,6 +123,7 @@ function renderWithRouter( image="repo/image" onFalsePositiveSuccess={() => {}} onRiskAcceptanceSuccess={() => {}} + onMitigateManuallySuccess={() => {}} /> ), @@ -169,6 +170,7 @@ describe("IssuesDataRows — active/remediated split", () => { image="repo/image" onFalsePositiveSuccess={() => {}} onRiskAcceptanceSuccess={() => {}} + onMitigateManuallySuccess={() => {}} /> ) @@ -209,6 +211,7 @@ describe("IssuesDataRows — active/remediated split", () => { image="repo/image" onFalsePositiveSuccess={() => {}} onRiskAcceptanceSuccess={() => {}} + onMitigateManuallySuccess={() => {}} /> ) diff --git a/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/IssuesDataRows/index.tsx b/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/IssuesDataRows/index.tsx index 4e60492bc2..2d741e4b3e 100644 --- a/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/IssuesDataRows/index.tsx +++ b/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/IssuesDataRows/index.tsx @@ -17,6 +17,7 @@ type IssuesDataRowsProps = { image: string onFalsePositiveSuccess: (cveNumber: string) => void | Promise onRiskAcceptanceSuccess: (cveNumber: string) => void | Promise + onMitigateManuallySuccess: (cveNumber: string) => void | Promise } export const IssuesDataRows = ({ @@ -26,6 +27,7 @@ export const IssuesDataRows = ({ image, onFalsePositiveSuccess, onRiskAcceptanceSuccess, + onMitigateManuallySuccess, }: IssuesDataRowsProps) => { const { error, data } = use(issuesPromise) const remediationsResult = use(remediationsPromise) @@ -55,6 +57,7 @@ export const IssuesDataRows = ({ image={image} onFalsePositiveSuccess={onFalsePositiveSuccess} onRiskAcceptanceSuccess={onRiskAcceptanceSuccess} + onMitigateManuallySuccess={onMitigateManuallySuccess} /> )) } diff --git a/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/RemediatedIssuesDataRows/RemediatedIssueDataRow/index.tsx b/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/RemediatedIssuesDataRows/RemediatedIssueDataRow/index.tsx index a889f81068..f128af43c1 100644 --- a/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/RemediatedIssuesDataRows/RemediatedIssueDataRow/index.tsx +++ b/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/RemediatedIssuesDataRows/RemediatedIssueDataRow/index.tsx @@ -20,6 +20,7 @@ import { ImageVulnerability } from "../../../../../Services/utils" import { getSeverityColor, useTextOverflow } from "../../../../../../utils" import { FalsePositiveModal } from "../../../FalsePositiveModal" import { RiskAcceptanceModal } from "../../../RiskAcceptanceModal" +import { MitigateManuallyModal } from "../../../MitigateManuallyModal" import { useRouteContext } from "@tanstack/react-router" import { createRemediation } from "../../../../../../api/createRemediation" import { RemediationInput, RemediationTypeValues } from "../../../../../../generated/graphql" @@ -54,6 +55,7 @@ export const RemediatedIssueDataRow = ({ const [isExpanded, setIsExpanded] = useState(false) const [isFalsePositiveModalOpen, setIsFalsePositiveModalOpen] = useState(false) const [isRiskAcceptanceModalOpen, setIsRiskAcceptanceModalOpen] = useState(false) + const [isMitigateManuallyModalOpen, setIsMitigateManuallyModalOpen] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false) const { needsExpansion, textRef } = useTextOverflow(issue?.description || "") const { apiClient, queryClient } = useRouteContext({ from: "/services/$service" }) @@ -179,6 +181,7 @@ export const RemediatedIssueDataRow = ({ setIsFalsePositiveModalOpen(true)} /> setIsRiskAcceptanceModalOpen(true)} /> + setIsMitigateManuallyModalOpen(true)} /> )} @@ -202,6 +205,15 @@ export const RemediatedIssueDataRow = ({ service={service} image={image} /> + setIsMitigateManuallyModalOpen(false)} + onConfirm={handleRemediationConfirm} + vulnerability={issue.name} + severity={issue.severity} + service={service} + image={image} + /> ) } diff --git a/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/index.tsx b/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/index.tsx index 52dac08d93..4f8dbfa36c 100644 --- a/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/index.tsx +++ b/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/index.tsx @@ -54,6 +54,7 @@ const VulnerabilitiesTabContent = ({ successMessage, onFalsePositiveSuccess, onRiskAcceptanceSuccess, + onMitigateManuallySuccess, }: { service: string image: ServiceImage @@ -64,6 +65,7 @@ const VulnerabilitiesTabContent = ({ successMessage: string | null onFalsePositiveSuccess: (cveNumber: string) => void | Promise onRiskAcceptanceSuccess: (cveNumber: string) => void | Promise + onMitigateManuallySuccess: (cveNumber: string) => void | Promise }) => { return ( <> @@ -107,6 +109,7 @@ const VulnerabilitiesTabContent = ({ image={image.repository} onFalsePositiveSuccess={onFalsePositiveSuccess} onRiskAcceptanceSuccess={onRiskAcceptanceSuccess} + onMitigateManuallySuccess={onMitigateManuallySuccess} /> @@ -349,10 +352,20 @@ export const ImageIssuesList = ({ ) }, []) + const handleMitigateManuallySuccess = useCallback((cveNumber: string) => { + setVulnerabilitiesSuccessMessage( + `Vulnerability ${cveNumber} has been manually mitigated and moved to the Remediated list.` + ) + }, []) + const handleRemediatedTabRemediationSuccess = useCallback( (cveNumber: string, remediationType: RemediationTypeValues) => { const remediationTypeLabel = - remediationType === RemediationTypeValues.FalsePositive ? "a false positive" : "a risk acceptance" + remediationType === RemediationTypeValues.FalsePositive + ? "a false positive" + : remediationType === RemediationTypeValues.Mitigation + ? "manually mitigated" + : "a risk acceptance" const text = `Vulnerability ${cveNumber} has been marked as ${remediationTypeLabel}.` setRemediatedSuccessMessage(text) }, @@ -377,6 +390,7 @@ export const ImageIssuesList = ({ successMessage={vulnerabilitiesSuccessMessage} onFalsePositiveSuccess={handleFalsePositiveSuccess} onRiskAcceptanceSuccess={handleRiskAcceptanceSuccess} + onMitigateManuallySuccess={handleMitigateManuallySuccess} /> diff --git a/apps/heureka/src/components/Service/ImageDetails/MitigateManuallyModal/index.tsx b/apps/heureka/src/components/Service/ImageDetails/MitigateManuallyModal/index.tsx new file mode 100644 index 0000000000..2a647afbda --- /dev/null +++ b/apps/heureka/src/components/Service/ImageDetails/MitigateManuallyModal/index.tsx @@ -0,0 +1,227 @@ +/* + * SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and Juno contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useState, useRef, useEffect } from "react" +import { + Modal, + ModalFooter, + Button, + Stack, + Textarea, + TextInput, + DateTimePicker, + Message, +} from "@cloudoperators/juno-ui-components" +import { RemediationInput, RemediationTypeValues, SeverityValues } from "../../../../generated/graphql" +import { useAuth } from "@cloudoperators/greenhouse-auth-provider" + +type MitigateManuallyModalProps = { + open: boolean + onClose: () => void + onConfirm: (input: RemediationInput) => Promise<{ error: string } | void> + vulnerability: string + severity?: string + service: string + image: string +} + +const CONFIRM_LABEL = "Mitigate Manually" +const CANCEL_LABEL = "Cancel" + +const toSeverityValue = (severity: string): SeverityValues | undefined => { + if (!severity) return undefined + const normalized = severity.charAt(0).toUpperCase() + severity.slice(1).toLowerCase() + const value = normalized as SeverityValues + return Object.values(SeverityValues).includes(value) ? value : undefined +} + +export const MitigateManuallyModal: React.FC = ({ + open, + onClose, + onConfirm, + vulnerability, + severity, + service, + image, +}) => { + const auth = useAuth() + const authUserId = auth.status === "authenticated" ? auth.userId || auth.userName : null + const [description, setDescription] = useState("") + const [manualUserId, setManualUserId] = useState("") + const [expirationDate, setExpirationDate] = useState(null) + const [isSubmitting, setIsSubmitting] = useState(false) + const [descriptionError, setDescriptionError] = useState("") + const [userIdError, setUserIdError] = useState("") + const [expirationDateError, setExpirationDateError] = useState("") + const [apiError, setApiError] = useState(null) + const isMountedRef = useRef(true) + + const manualUserIdTrimmed = manualUserId.trim() + const remediatedBy = authUserId ?? (manualUserIdTrimmed || undefined) + const isUserIdValid = !!remediatedBy + + useEffect(() => { + isMountedRef.current = true + return () => { + isMountedRef.current = false + } + }, []) + + useEffect(() => { + if (!open) { + setDescription("") + setManualUserId("") + setExpirationDate(null) + setDescriptionError("") + setUserIdError("") + setExpirationDateError("") + setApiError(null) + } + }, [open]) + + const descriptionTrimmed = description.trim() + + const handleConfirm = async () => { + if (!descriptionTrimmed) { + setDescriptionError("Description is required") + return + } + if (!remediatedBy) { + setUserIdError("User ID is required") + return + } + if (!expirationDate) { + setExpirationDateError("Expiration date is required") + return + } + + setDescriptionError("") + setUserIdError("") + setExpirationDateError("") + setIsSubmitting(true) + try { + const severityValue = severity ? toSeverityValue(severity) : undefined + const input: RemediationInput = { + type: RemediationTypeValues.Mitigation, + vulnerability, + service, + image, + description: descriptionTrimmed, + ...(remediatedBy && { remediatedBy }), + ...(severityValue !== undefined && { severity: severityValue }), + expirationDate: expirationDate.toISOString(), + } + const result = await onConfirm(input) + if (result?.error) { + setApiError(result.error) + } else if (isMountedRef.current) { + setDescription("") + setManualUserId("") + setExpirationDate(null) + onClose() + } + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to create remediation" + setApiError(message) + } finally { + setIsSubmitting(false) + } + } + + const handleClose = () => { + setDescription("") + setManualUserId("") + setExpirationDate(null) + setDescriptionError("") + setUserIdError("") + setExpirationDateError("") + setApiError(null) + onClose() + } + + const handleDescriptionChange = (e: React.ChangeEvent) => { + setDescription(e.target.value) + if (descriptionError) { + setDescriptionError("") + } + } + + return ( + + +