diff --git a/src/app/(loading-group)/[organizationSlug]/projects/[projectSlug]/assets/[assetSlug]/refs/[assetVersionSlug]/advisory/[advisoryId]/page.tsx b/src/app/(loading-group)/[organizationSlug]/projects/[projectSlug]/assets/[assetSlug]/refs/[assetVersionSlug]/advisory/[advisoryId]/page.tsx index e7b995b61..e118254b1 100644 --- a/src/app/(loading-group)/[organizationSlug]/projects/[projectSlug]/assets/[assetSlug]/refs/[assetVersionSlug]/advisory/[advisoryId]/page.tsx +++ b/src/app/(loading-group)/[organizationSlug]/projects/[projectSlug]/assets/[assetSlug]/refs/[assetVersionSlug]/advisory/[advisoryId]/page.tsx @@ -6,11 +6,13 @@ import { fetcher } from "@/data-fetcher/fetcher"; import { useAssetMenu } from "@/hooks/useAssetMenu"; import useDecodedParams from "@/hooks/useDecodedParams"; import { Skeleton } from "@/components/ui/skeleton"; -import type { SecurityAdvisory } from "@/types/api/api"; +import type { + DetailedSecurityAdvisoryDTO, + VulnEventDTO, +} from "@/types/api/api"; import Severity from "@/components/common/Severity"; import Markdown from "@/components/common/Markdown"; -import FormatDate from "@/components/risk-assessment/FormatDate"; -import { Button, buttonVariants } from "@/components/ui/button"; +import { AsyncButton, Button } from "@/components/ui/button"; import { browserApiClient } from "@/services/devGuardApi"; import { AlertDialog, @@ -38,11 +40,27 @@ import AdvisoryDialog, { } from "@/components/AdvisoryDialog"; import AuthGuard from "@/components/AuthGuard"; import { useConfig } from "@/context/ConfigContext"; +import RiskAssessmentFeed from "@/components/risk-assessment/RiskAssessmentFeed"; +import { withVPrefix } from "@/services/versionCheck"; +import { useDeleteEvent } from "@/hooks/useDeleteEvent"; +import { Card, CardContent } from "@/components/ui/card"; +import { useSession } from "@/context/SessionContext"; +import dynamic from "next/dynamic"; + +const MarkdownEditor = dynamic( + () => import("@/components/common/MarkdownEditor"), + { ssr: false }, +); const Index = () => { const router = useRouter(); const params = useDecodedParams(); const config = useConfig(); + const deleteEvent = useDeleteEvent(); + const { session } = useSession(); + const [justification, setJustification] = useState( + undefined, + ); const { organizationSlug, projectSlug, @@ -78,10 +96,13 @@ const Index = () => { }; const handlePublishAdvisory = async () => { - const resp = await browserApiClient(`${advisoryUrl}` + `/${advisoryId}/`, { - method: "PATCH", - body: JSON.stringify({ visibility: "public" }), - }); + const resp = await browserApiClient( + `${advisoryUrl}` + `/${advisoryId}/events/`, + { + method: "POST", + body: JSON.stringify({ status: "published" }), + }, + ); if (resp.ok) { toast.success("Advisory published successfully"); mutate(`${advisoryUrl}` + `/${advisoryId}/`); @@ -95,10 +116,13 @@ const Index = () => { }; const handleWithdrawAdvisory = async () => { - const resp = await browserApiClient(`${advisoryUrl}` + `/${advisoryId}/`, { - method: "PATCH", - body: JSON.stringify({ visibility: "withdrawn" }), - }); + const resp = await browserApiClient( + `${advisoryUrl}` + `/${advisoryId}/events/`, + { + method: "POST", + body: JSON.stringify({ status: "withdrawn" }), + }, + ); if (resp.ok) { toast.success("Advisory withdrawn successfully"); mutate(`${advisoryUrl}` + `/${advisoryId}/`); @@ -130,7 +154,8 @@ const Index = () => { data: advisory, isLoading, error, - } = useSWR( + mutate: mutateAdvisory, + } = useSWR( organizationSlug && projectSlug && assetSlug && @@ -163,7 +188,7 @@ const Index = () => { ? parseCvssVector(advisory.vectorString) : null; const csafYear = new Date(advisory.createdAt).getFullYear(); - const csafUrl = `${config.devguardApiUrlPublicInternet}/api/v1/organizations/${organizationSlug}/projects/${projectSlug}/assets/${assetSlug}/csaf/white/${csafYear}/dgsa-${csafYear}-${advisory.id}.json`; + const csafUrl = `${config.devguardApiUrlPublicInternet}/api/v1/organizations/${organizationSlug}/projects/${projectSlug}/assets/${assetSlug}/csaf/white/${csafYear}/dgsa-${advisory.id}.json`; const metricDefs = parsed?.version === "4.0" ? CVSS40_METRICS @@ -179,7 +204,7 @@ const Index = () => { title: "Are you sure you want to delete this advisory?", description: "This action cannot be undone. All data associated with this advisory will be deleted.", - confirmClassName: buttonVariants({ variant: "default" }), + variant: "destructive", onConfirm: handleDeleteAdvisory, }, publish: { @@ -187,7 +212,7 @@ const Index = () => { title: "Are you sure you want to publish this advisory?", description: "NOTE: This feature is still work in progress. This publishment will only add the Advisory to the CSAF report. This action cannot be undone. All data associated with this advisory will be published.", - confirmClassName: buttonVariants({ variant: "default" }), + variant: "default", onConfirm: handlePublishAdvisory, }, withdraw: { @@ -197,7 +222,7 @@ const Index = () => { title: "Are you sure you want to withdraw this advisory?", description: "This action cannot be undone. The advisory stays public but is marked as withdrawn and can no longer be changed.", - confirmClassName: buttonVariants({ variant: "destructive" }), + variant: "destructive", onConfirm: handleWithdrawAdvisory, }, } as const; @@ -211,12 +236,81 @@ const Index = () => { } as const; const visibilityBadge = visibilityConfig[ - advisory.visibility as keyof typeof visibilityConfig + advisory.state as keyof typeof visibilityConfig ] ?? { - label: advisory.visibility, + label: advisory.state, variant: "secondary" as const, }; + const handleDeleteEvent = async (eventId: string) => { + await deleteEvent(eventId); + mutateAdvisory(); + }; + + const handleSubmit = async (data: { + status?: VulnEventDTO["type"]; + justification?: string; + mechanicalJustification?: string; + }): Promise => { + if (data.status === undefined || !advisory) { + return false; + } + + if (!Boolean(data.justification)) { + toast.error("Please provide a justification"); + return false; + } + + const optimisticEvent = { + type: data.status, + id: "optimistic", + createdAt: new Date().toISOString(), + justification: data.justification ?? "", + mechanicalJustification: data.mechanicalJustification ?? "", + userId: session?.identity.id ?? "", + vulnId: advisory.id, + vulnType: "securityAdvisory", + vulnerabilityName: advisory.title ?? advisory.id, + createdByVexRule: false, + } as VulnEventDTO; + + const mutatePromise = mutateAdvisory( + async (current) => { + const resp = await browserApiClient( + `${advisoryUrl}` + `/${advisoryId}/events/`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data), + }, + ); + const json = await resp.json(); + + if (!json.events) { + toast.error("Failed to add comment"); + throw new Error("Failed to add comment"); + } + setJustification(""); + return { + ...current!, + ...json, + events: current!.events.concat([json.events.slice(-1)[0]]), + }; + }, + { + optimisticData: { + ...advisory, + events: advisory.events.concat([optimisticEvent]), + }, + rollbackOnError: true, + revalidate: false, + }, + ); + + mutatePromise.then(() => toast.success("Comment added")).catch(() => {}); + return true; + }; + return ( { {visibilityBadge.label} - {advisory.visibility !== "draft" && ( + {advisory.state !== "draft" && ( { {pkg.packageName} - {pkg.semverStart ? `< v${pkg.semverStart}` : "—"} + {pkg.versionStart + ? `< ${withVPrefix(pkg.versionStart)}` + : "—"} - {pkg.semverEnd ? `v${pkg.semverEnd}` : "—"} + {pkg.versionEnd ? withVPrefix(pkg.versionEnd) : "—"} ))} @@ -283,36 +379,77 @@ const Index = () => { {advisory.description} )} - {advisory.visibility === "draft" && ( -
- - - - - + {advisory.events && advisory.events.length > 0 && ( +
+
)} - {advisory.visibility === "public" && ( -
+ + - +
+
+ + +
+
+
+ {advisory.state === "draft" && ( + <> + + + + + )} + {advisory.state === "public" && ( + + )} + + handleSubmit({ status: "comment", justification }) + } + variant={"default"} + > + Comment + +
+
+
-
- )} + +
@@ -396,7 +533,7 @@ const Index = () => { affectedPackages: (advisory.affectedPackages ?? []).map( ({ id, ...rest }) => rest, ), - visibility: advisory.visibility, + state: advisory.state, }} onSubmit={handleChangeAdvisory} /> @@ -418,7 +555,7 @@ const Index = () => { Cancel activeConfirm?.onConfirm()} > Confirm diff --git a/src/app/(loading-group)/[organizationSlug]/projects/[projectSlug]/assets/[assetSlug]/refs/[assetVersionSlug]/advisory/page.tsx b/src/app/(loading-group)/[organizationSlug]/projects/[projectSlug]/assets/[assetSlug]/refs/[assetVersionSlug]/advisory/page.tsx index a11d490e2..529f2cd25 100644 --- a/src/app/(loading-group)/[organizationSlug]/projects/[projectSlug]/assets/[assetSlug]/refs/[assetVersionSlug]/advisory/page.tsx +++ b/src/app/(loading-group)/[organizationSlug]/projects/[projectSlug]/assets/[assetSlug]/refs/[assetVersionSlug]/advisory/page.tsx @@ -94,9 +94,9 @@ const buildColumnsDef = ( meta: { className: "w-24 whitespace-nowrap" }, cell: (info) => { const advisory = info.row.original; - if (advisory.visibility !== "public") return null; + if (advisory.state !== "public") return null; const year = new Date(advisory.createdAt).getFullYear(); - const href = `${csafBaseUrl}/csaf/white/${year}/dgsa-${year}-${advisory.id}.json`; + const href = `${csafBaseUrl}/csaf/white/${year}/dgsa-${advisory.id}.json`; return ( []; - visibility: string; + state: string; } interface AdvisoryDialogProps { @@ -61,11 +61,11 @@ type PackageRow = Omit; const emptyPackage = (): PackageRow => ({ ecosystem: "", packageName: "", - semverStart: "", - semverEnd: "", + versionStart: "", + versionEnd: "", }); -const SEMVER_RE = /^v?\d+\.\d+\.\d+$/; +const Version_RE = /^v?\d+\.\d+\.\d+$/; const defaultValues = (initialValues?: AdvisoryFormData): AdvisoryFormData => ({ title: initialValues?.title ?? "", @@ -76,7 +76,7 @@ const defaultValues = (initialValues?: AdvisoryFormData): AdvisoryFormData => ({ initialValues?.affectedPackages && initialValues.affectedPackages.length > 0 ? initialValues.affectedPackages : [emptyPackage()], - visibility: initialValues?.visibility ?? "draft", + state: initialValues?.state ?? "draft", }); const AdvisoryDialog: FunctionComponent = ({ @@ -150,10 +150,10 @@ const AdvisoryDialog: FunctionComponent = ({ severity: vectorStringToSeverity(data.vectorString) ?? "", affectedPackages: data.affectedPackages.map((pkg) => ({ ...pkg, - semverStart: pkg.semverStart || null, - semverEnd: pkg.semverEnd || null, + versionStart: pkg.versionStart || null, + versionEnd: pkg.versionEnd || null, })), - visibility: "draft", + state: "draft", }); handleClose(false); } finally { @@ -365,17 +365,17 @@ const AdvisoryDialog: FunctionComponent = ({ /> ( - Semver Start + Version Start = ({ /> { const start = form.getValues( - `affectedPackages.${index}.semverStart`, + `affectedPackages.${index}.versionStart`, ); if ( !value || !start || - !SEMVER_RE.test(value) || - !SEMVER_RE.test(start) + !Version_RE.test(value) || + !Version_RE.test(start) ) { return true; } @@ -417,7 +417,7 @@ const AdvisoryDialog: FunctionComponent = ({ }} render={({ field }) => ( - Semver End + Version End ; case "removedComplianceComponent": return ; + case "published": + return ; + case "withdrawn": + return ; + case "created": + return ; } } diff --git a/src/components/ui/alert-dialog.tsx b/src/components/ui/alert-dialog.tsx index f8974ba31..8d8c9ec56 100644 --- a/src/components/ui/alert-dialog.tsx +++ b/src/components/ui/alert-dialog.tsx @@ -3,6 +3,7 @@ import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"; import { cn } from "@/lib/utils"; import { buttonVariants } from "@/components/ui/button"; +import { type VariantProps } from "class-variance-authority"; const AlertDialog = AlertDialogPrimitive.Root; @@ -98,11 +99,12 @@ AlertDialogDescription.displayName = const AlertDialogAction = React.forwardRef< React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( + React.ComponentPropsWithoutRef & + Pick, "variant"> +>(({ className, variant = "destructive", ...props }, ref) => ( )); diff --git a/src/services/versionCheck.ts b/src/services/versionCheck.ts index e0c25763a..ca89f9396 100644 --- a/src/services/versionCheck.ts +++ b/src/services/versionCheck.ts @@ -15,6 +15,10 @@ export interface VersionCheckResult { updateAvailable: boolean; } +export function withVPrefix(version: string): string { + return /^v/i.test(version) ? version : `v${version}`; +} + /** Parse a semver-style tag "v1.2.3" → [1, 2, 3]. Returns null on failure. */ function parseSemver(tag: string): [number, number, number] | null { const m = tag.match(/^v?(\d+)\.(\d+)\.(\d+)/); diff --git a/src/types/api/api.ts b/src/types/api/api.ts index 804f17b5a..7a561f74a 100644 --- a/src/types/api/api.ts +++ b/src/types/api/api.ts @@ -287,7 +287,11 @@ interface BaseVulnEventDTO { createdAt: string; id: string; vulnId: string; - vulnType: "dependencyVuln" | "firstPartyVuln" | "compliancePosture"; + vulnType: + | "dependencyVuln" + | "firstPartyVuln" + | "compliancePosture" + | "securityAdvisory"; justification: string; mechanicalJustification: string; vulnerabilityName: string | null; @@ -402,6 +406,18 @@ export interface RemovedComplianceComponentEventDTO extends BaseVulnEventDTO { }; } +export interface PublishedEventDTO extends BaseVulnEventDTO { + type: "published"; +} + +export interface WithdrawnEventDTO extends BaseVulnEventDTO { + type: "withdrawn"; +} + +export interface CreatedEventDTO extends BaseVulnEventDTO { + type: "created"; +} + export type VulnEventDTO = | AcceptedEventDTO | FixedEventDTO @@ -418,7 +434,10 @@ export type VulnEventDTO = | ImplementedEventDTO | NotApplicableEventDTO | AttachedComplianceComponentEventDTO - | RemovedComplianceComponentEventDTO; + | RemovedComplianceComponentEventDTO + | PublishedEventDTO + | WithdrawnEventDTO + | CreatedEventDTO; export interface CWE { cwe: string; @@ -1328,7 +1347,7 @@ export interface SecurityAdvisory { vectorString: string; assetID: string; affectedPackages: AdvisoryAffectedPackage[] | null; - visibility: string; + state: string; createdAt: string; updatedAt: string; } @@ -1337,6 +1356,10 @@ export interface AdvisoryAffectedPackage { id: string; ecosystem: string; packageName: string; - semverStart: string | null; - semverEnd: string | null; + versionStart: string | null; + versionEnd: string | null; +} + +export interface DetailedSecurityAdvisoryDTO extends SecurityAdvisory { + events: VulnEventDTO[]; } diff --git a/src/utils/view.ts b/src/utils/view.ts index 12314f185..139fccf26 100644 --- a/src/utils/view.ts +++ b/src/utils/view.ts @@ -12,7 +12,6 @@ // // You should have received a copy of the GNU Affero General Public License -import { UpstreamState } from "@/types/api/api"; import type { AssetDTO, ComponentRisk, @@ -182,6 +181,18 @@ export const eventTypeMessages = ( } break; } + case "published": { + message = "published " + flawName; + break; + } + case "withdrawn": { + message = "withdrew " + flawName; + break; + } + case "created": { + message = "created " + flawName; + break; + } } if (event.userAgent === "devguard-mcp-server") { message += " (applied by AI agent)"; @@ -206,6 +217,9 @@ export const evTypeBackground: { [key in VulnEventDTO["type"]]: string } = { licenseDecision: "bg-warning text-warning-foreground!", attachedComplianceComponent: "bg-success text-success-foreground!", removedComplianceComponent: "bg-secondary text-secondary-foreground!", + published: "bg-info text-info-foreground!", + withdrawn: "bg-destructive text-destructive-foreground!", + created: "bg-success text-success-foreground!", }; export const osiLicenseHexColors: Record = {