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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@
"@tanstack/react-query-devtools": "^5.61.0",
"axios": "^1.16.0",
"dayjs": "^1.11.18",
"echarts": "^5.6.0",
"echarts-for-react": "^3.0.2",
"file-saver": "^2.0.5",
"oidc-client-ts": "^3.3.0",
"packageurl-js": "^2.0.1",
Expand Down
99 changes: 55 additions & 44 deletions client/src/app/pages/advisory-details/advisory-details.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,11 @@ import {
useFetchAdvisoryById,
} from "@app/queries/advisories";

import { DocumentMetadata } from "@app/components/DocumentMetadata";

import { CsafAdvisoryDetails } from "./csaf-advisory-details";
import { Overview } from "./overview";
import { VulnerabilitiesByAdvisory } from "./vulnerabilities-by-advisory";
import { DocumentMetadata } from "@app/components/DocumentMetadata";

export const AdvisoryDetails: React.FC = () => {
const navigate = useNavigate();
Expand Down Expand Up @@ -87,11 +89,13 @@ export const AdvisoryDetails: React.FC = () => {
const { mutate: deleteAdvisory, isPending: isDeleting } =
useDeleteAdvisoryMutation(onDeleteAdvisorySuccess, onDeleteAdvisoryError);

// Tabs
const isCsaf = advisory?.labels.type === "csaf";

// Tabs (default non-CSAF layout)
const {
propHelpers: { getTabsProps, getTabProps, getTabContentProps },
} = useTabControls({
persistenceKeyPrefix: "ad", // ad="advisory details"
persistenceKeyPrefix: "ad",
persistTo: "urlParams",
tabKeys: ["info", "vulnerabilities"],
});
Expand Down Expand Up @@ -177,47 +181,54 @@ export const AdvisoryDetails: React.FC = () => {
</SplitItem>
</Split>
</PageSection>
<PageSection>
<Tabs
mountOnEnter
{...getTabsProps()}
aria-label="Tabs that contain the Advisory information"
role="region"
>
<Tab
{...getTabProps("info")}
title={<TabTitleText>Info</TabTitleText>}
tabContentRef={infoTabRef}
/>
<Tab
{...getTabProps("vulnerabilities")}
title={<TabTitleText>Vulnerabilities</TabTitleText>}
tabContentRef={vulnerabilitiesTabRef}
/>
</Tabs>
</PageSection>
<PageSection>
<TabContent
{...getTabContentProps("info")}
ref={infoTabRef}
aria-label="Information of the Advisory"
>
<LoadingWrapper isFetching={isFetching} fetchError={fetchError}>
{advisory && <Overview advisory={advisory} />}
</LoadingWrapper>
</TabContent>
<TabContent
{...getTabContentProps("vulnerabilities")}
ref={vulnerabilitiesTabRef}
aria-label="Vulnerabilities within the Advisory"
>
<VulnerabilitiesByAdvisory
isFetching={isFetching}
fetchError={fetchError}
vulnerabilities={advisory?.vulnerabilities || []}
/>
</TabContent>
</PageSection>

{isCsaf ? (
<CsafAdvisoryDetails advisoryId={advisoryId} />
) : (
<>
<PageSection>
<Tabs
mountOnEnter
{...getTabsProps()}
aria-label="Tabs that contain the Advisory information"
role="region"
>
<Tab
{...getTabProps("info")}
title={<TabTitleText>Info</TabTitleText>}
tabContentRef={infoTabRef}
/>
<Tab
{...getTabProps("vulnerabilities")}
title={<TabTitleText>Vulnerabilities</TabTitleText>}
tabContentRef={vulnerabilitiesTabRef}
/>
</Tabs>
</PageSection>
<PageSection>
<TabContent
{...getTabContentProps("info")}
ref={infoTabRef}
aria-label="Information of the Advisory"
>
<LoadingWrapper isFetching={isFetching} fetchError={fetchError}>
{advisory && <Overview advisory={advisory} />}
</LoadingWrapper>
</TabContent>
<TabContent
{...getTabContentProps("vulnerabilities")}
ref={vulnerabilitiesTabRef}
aria-label="Vulnerabilities within the Advisory"
>
<VulnerabilitiesByAdvisory
isFetching={isFetching}
fetchError={fetchError}
vulnerabilities={advisory?.vulnerabilities || []}
/>
</TabContent>
</PageSection>
</>
)}

<ConfirmDialog
{...advisoryDeleteDialogProps(advisory)}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/** Expandable CVSS v3 breakdown showing all scoring metrics. */
import React from "react";

import {
DescriptionList,
DescriptionListDescription,
DescriptionListGroup,
DescriptionListTerm,
ExpandableSection,
} from "@patternfly/react-core";

import type { CsafCvssV3 } from "@app/types/csaf";

interface CsafCvssDetailsProps {
cvss: CsafCvssV3;
}

export const CsafCvssDetails: React.FC<CsafCvssDetailsProps> = ({ cvss }) => {
const [isExpanded, setIsExpanded] = React.useState(false);

const metrics: { label: string; value?: string }[] = [
{ label: "Attack Vector", value: cvss.attackVector },
{ label: "Attack Complexity", value: cvss.attackComplexity },
{ label: "Privileges Required", value: cvss.privilegesRequired },
{ label: "User Interaction", value: cvss.userInteraction },
{ label: "Scope", value: cvss.scope },
{ label: "Confidentiality Impact", value: cvss.confidentialityImpact },
{ label: "Integrity Impact", value: cvss.integrityImpact },
{ label: "Availability Impact", value: cvss.availabilityImpact },
{ label: "Vector String", value: cvss.vectorString },
];

return (
<ExpandableSection
toggleText={isExpanded ? "Hide CVSS details" : "Show CVSS details"}
onToggle={(_event, expanded) => setIsExpanded(expanded)}
isExpanded={isExpanded}
>
<DescriptionList isHorizontal isCompact>
{metrics
.filter((m) => m.value)
.map((m) => (
<DescriptionListGroup key={m.label}>
<DescriptionListTerm>{m.label}</DescriptionListTerm>
<DescriptionListDescription>{m.value}</DescriptionListDescription>
</DescriptionListGroup>
))}
</DescriptionList>
</ExpandableSection>
);
};
157 changes: 157 additions & 0 deletions client/src/app/pages/advisory-details/components/csaf-remediations.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
/** Remediations section grouped by category with expandable product lists. */
import React from "react";

import {
Card,
CardBody,
CardTitle,
ExpandableSection,
Flex,
FlexItem,
Label,
List,
ListItem,
} from "@patternfly/react-core";
import ExternalLinkAltIcon from "@patternfly/react-icons/dist/esm/icons/external-link-alt-icon";

import type { CsafRemediation } from "@app/types/csaf";

interface CsafRemediationsProps {
remediations: CsafRemediation[];
productNameMap: Map<string, string>;
}

/** Groups remediations by category. */
function groupByCategory(
remediations: CsafRemediation[],
): Map<string, CsafRemediation[]> {
const groups = new Map<string, CsafRemediation[]>();
for (const rem of remediations) {
const existing = groups.get(rem.category);
if (existing) {
existing.push(rem);
} else {
groups.set(rem.category, [rem]);
}
}
return groups;
}

/** Renders text with URLs converted to links. */
const LinkifiedText: React.FC<{ text: string }> = ({ text }) => {
const urlRegex = /(https?:\/\/[^\s)]+)/g;
const parts = text.split(urlRegex);
return (
<>
{parts.map((part, i) =>
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
urlRegex.test(part) ? (
<a
key={`link-${i}`}
href={part}
target="_blank"
rel="noopener noreferrer"
>
{part} <ExternalLinkAltIcon />
</a>
) : (
<span key={`text-${i}`}>{part}</span>
),
)}
</>
);
};

/** Single remediation card within a category group. */
const RemediationCard: React.FC<{
remediation: CsafRemediation;
productNameMap: Map<string, string>;
}> = ({ remediation, productNameMap }) => {
const [showProducts, setShowProducts] = React.useState(false);
const productIds = remediation.product_ids || [];

return (
<Card isPlain isCompact>
<CardBody>
<Flex direction={{ default: "column" }} gap={{ default: "gapSm" }}>
<FlexItem>
<LinkifiedText text={remediation.details} />
</FlexItem>
{remediation.url && (
<FlexItem>
<a
href={remediation.url}
target="_blank"
rel="noopener noreferrer"
>
{remediation.url} <ExternalLinkAltIcon />
</a>
</FlexItem>
)}
{productIds.length > 0 && (
<FlexItem>
<ExpandableSection
toggleText={
showProducts
? "Hide affected products"
: `Show ${productIds.length} affected products`
}
onToggle={(_event, expanded) => setShowProducts(expanded)}
isExpanded={showProducts}
>
<List isPlain>
{productIds.map((pid) => (
<ListItem key={pid}>
{productNameMap.get(pid) || pid}
</ListItem>
))}
</List>
</ExpandableSection>
</FlexItem>
)}
</Flex>
</CardBody>
</Card>
);
};

export const CsafRemediations: React.FC<CsafRemediationsProps> = ({
remediations,
productNameMap,
}) => {
const grouped = React.useMemo(
() => groupByCategory(remediations),
[remediations],
);

return (
<ExpandableSection toggleText="Remediations">
<Flex direction={{ default: "column" }} gap={{ default: "gapMd" }}>
{Array.from(grouped.entries()).map(([category, rems]) => (
<FlexItem key={category}>
<Card isPlain>
<CardTitle>
<Label isCompact>{category.replace(/_/g, " ")}</Label> (
{rems.length})
</CardTitle>
<CardBody>
<Flex
direction={{ default: "column" }}
gap={{ default: "gapSm" }}
>
{rems.map((rem, i) => (
<FlexItem key={`${category}-${i}`}>
<RemediationCard
remediation={rem}
productNameMap={productNameMap}
/>
</FlexItem>
))}
</Flex>
</CardBody>
</Card>
</FlexItem>
))}
</Flex>
</ExpandableSection>
);
};
Loading
Loading