"use client"; import { useTheme } from "next-themes"; import type { ElementType, ReactNode } from "react"; import { Card, CardContent } from "@/components/shadcn"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@/components/shadcn/tooltip"; import { getNodeBorderColor, getNodeColor, GRAPH_ALERT_BORDER_COLOR, GRAPH_EDGE_COLOR_DARK, GRAPH_EDGE_COLOR_LIGHT, GRAPH_EDGE_HIGHLIGHT_COLOR, GRAPH_NODE_BORDER_COLORS, GRAPH_NODE_COLORS, } from "@/lib/attack-paths/graph-colors"; import { resolveHiddenFindingIds } from "@/lib/attack-paths/graph-utils"; import { isProwlerFindingNode } from "@/lib/attack-paths/node-types"; import { NODE_CATEGORY, resolveNodeVisual, } from "@/lib/attack-paths/node-visuals"; import { isCloud } from "@/lib/shared/env"; import type { AttackPathGraphData, GraphNode } from "@/types/attack-paths"; const LEGEND_PREVIEW = { BADGE_RADIUS: 16, BADGE_CENTER: 18, ICON_SIZE: 20, ICON_OFFSET: 8, SVG_SIZE: 36, } as const; const EDGE_VARIANT = { NORMAL: "normal", FINDING: "finding", HIGHLIGHTED: "highlighted", } as const; type EdgeVariant = (typeof EDGE_VARIANT)[keyof typeof EDGE_VARIANT]; interface LegendVisualItem { label: string; description: string; Icon: ElementType; fillColor: string; borderColor: string; glow?: boolean; } interface LegendStateItem { label: string; description: string; fillColor: string; borderColor: string; strokeWidth: number; glowColor?: string; } interface LegendEdgeItem { label: string; description: string; variant: EdgeVariant; } interface LegendSectionProps { title: string; children: ReactNode; } interface LegendItemProps { label: string; description: string; children: ReactNode; } interface GraphLegendProps { data?: AttackPathGraphData; expandedResources?: ReadonlySet; isFilteredView?: boolean; } interface GraphLegendState { visibleNodes: GraphNode[]; visibleNodeIds: Set; visibleFindingIds: Set; visibleEdges: Array<{ source: string; target: string }>; resourcesWithFindings: Set; } const buildNode = ( labels: string[], properties: GraphNode["properties"] = {}, ): GraphNode => ({ id: labels[0] ?? "legend-node", labels, properties, }); const buildVisualItem = ( label: string, description: string, node: GraphNode, fillColor: string, borderColor: string, glow = false, ): LegendVisualItem => ({ label, description, Icon: resolveNodeVisual(node).Icon, fillColor, borderColor, glow, }); // Only shown in OSS, whose flat graph still renders the account/provider hub // (Cloud's view transform removes it). See `providerItem` below. const providerRootItem = buildVisualItem( "Provider", "Cloud account, tenant, project, organization, or cluster entry point.", buildNode(["AWSAccount"], { name: "Provider root" }), GRAPH_NODE_COLORS.awsAccount, GRAPH_NODE_BORDER_COLORS.awsAccount, ); const findingRiskItems: LegendVisualItem[] = [ buildVisualItem( "Critical", "Highest-risk finding node with severity-colored badge and glow.", buildNode(["ProwlerFinding"], { severity: "critical" }), GRAPH_NODE_COLORS.critical, GRAPH_NODE_BORDER_COLORS.critical, true, ), buildVisualItem( "High", "High-risk finding node with severity-colored badge and glow.", buildNode(["ProwlerFinding"], { severity: "high" }), GRAPH_NODE_COLORS.high, GRAPH_NODE_BORDER_COLORS.high, true, ), buildVisualItem( "Medium", "Medium-risk finding node with severity-colored badge and glow.", buildNode(["ProwlerFinding"], { severity: "medium" }), GRAPH_NODE_COLORS.medium, GRAPH_NODE_BORDER_COLORS.medium, true, ), buildVisualItem( "Low / Info", "Lower-risk informational findings use the info-style risk icon.", buildNode(["ProwlerFinding"], { severity: "info" }), GRAPH_NODE_COLORS.info, GRAPH_NODE_BORDER_COLORS.info, true, ), ]; const stateItems: LegendStateItem[] = [ { label: "Selected node", description: "Active node with a stronger animated selection ring.", fillColor: GRAPH_NODE_COLORS.default, borderColor: GRAPH_EDGE_HIGHLIGHT_COLOR, strokeWidth: 4, glowColor: GRAPH_EDGE_HIGHLIGHT_COLOR, }, { label: "Node with findings", description: "Resource node linked to one or more findings.", fillColor: GRAPH_NODE_COLORS.default, borderColor: GRAPH_ALERT_BORDER_COLOR, strokeWidth: 3, glowColor: GRAPH_ALERT_BORDER_COLOR, }, ]; const edgeItems: LegendEdgeItem[] = [ { label: "Normal edge", description: "Relationship between resources in the attack path.", variant: EDGE_VARIANT.NORMAL, }, { label: "Finding edge", description: "Animated dashed edge that connects a resource to a finding.", variant: EDGE_VARIANT.FINDING, }, { label: "Highlighted path", description: "Prowler green path shown when hovering or selecting related graph nodes.", variant: EDGE_VARIANT.HIGHLIGHTED, }, ]; const isFindingNode = (node: GraphNode): boolean => isProwlerFindingNode(node.labels); const getGraphEdges = ( data: AttackPathGraphData, ): Array<{ source: string; target: string }> => data.relationships ?? data.edges ?? []; const resolveLegendState = ( data: AttackPathGraphData, expandedResources: ReadonlySet, isFilteredView: boolean, ): GraphLegendState => { const findingNodeIds = new Set( data.nodes.filter(isFindingNode).map((node) => node.id), ); const findingToResources = new Map>(); const resourcesWithFindings = new Set(); const graphEdges = getGraphEdges(data); for (const edge of graphEdges) { const sourceIsFinding = findingNodeIds.has(edge.source); const targetIsFinding = findingNodeIds.has(edge.target); if (sourceIsFinding) { resourcesWithFindings.add(edge.target); const resources = findingToResources.get(edge.source) ?? new Set(); resources.add(edge.target); findingToResources.set(edge.source, resources); } if (targetIsFinding) { resourcesWithFindings.add(edge.source); const resources = findingToResources.get(edge.target) ?? new Set(); resources.add(edge.source); findingToResources.set(edge.target, resources); } } const hiddenFindingIds = resolveHiddenFindingIds({ expandedResources, findingNodeIds, findingToResources, isFilteredView, }); const visibleNodes = data.nodes.filter( (node) => !hiddenFindingIds.has(node.id), ); const visibleNodeIds = new Set(visibleNodes.map((node) => node.id)); const visibleFindingIds = new Set( visibleNodes.filter(isFindingNode).map((node) => node.id), ); const visibleEdges = graphEdges.filter( (edge) => visibleNodeIds.has(edge.source) && visibleNodeIds.has(edge.target), ); return { visibleNodes, visibleNodeIds, visibleFindingIds, visibleEdges, resourcesWithFindings, }; }; const resolveNodeTypeItems = ( visibleNodes: GraphNode[], ): LegendVisualItem[] => { const itemsByType = new Map(); for (const node of visibleNodes) { if (isFindingNode(node)) continue; const visual = resolveNodeVisual(node); if (visual.category === NODE_CATEGORY.ACCOUNT) continue; const key = `${visual.category}:${visual.description}`; if (!itemsByType.has(key)) { itemsByType.set(key, { label: visual.description, description: `${visual.description} node`, Icon: visual.Icon, fillColor: getNodeColor(node.labels), borderColor: getNodeBorderColor(node.labels), }); } } return Array.from(itemsByType.values()); }; const resolveFindingRiskItems = ( visibleNodes: GraphNode[], ): LegendVisualItem[] => { const visibleSeverities = new Set( visibleNodes .filter(isFindingNode) .map((node) => String(node.properties.severity ?? "").toLowerCase()), ); return findingRiskItems.filter((item) => { if (item.label === "Low / Info") { return ( visibleSeverities.has("low") || visibleSeverities.has("info") || visibleSeverities.has("informational") ); } return visibleSeverities.has(item.label.toLowerCase()); }); }; const LegendSection = ({ title, children }: LegendSectionProps) => (

{title}

{children}
); const LegendItem = ({ label, description, children }: LegendItemProps) => (
{children} {label}
{description}
); const BadgePreview = ({ Icon, fillColor, borderColor, glow, }: LegendVisualItem) => ( ); const StatePreview = ({ fillColor, borderColor, strokeWidth, glowColor, }: LegendStateItem) => ( ); const EdgePreview = ({ variant, edgeColor, }: { variant: EdgeVariant; edgeColor: string; }) => { const isFindingEdge = variant === EDGE_VARIANT.FINDING; const isHighlightedPath = variant === EDGE_VARIANT.HIGHLIGHTED; const strokeColor = isHighlightedPath ? GRAPH_EDGE_HIGHLIGHT_COLOR : edgeColor; return ( ); }; /** * Compact semantic legend for the Attack Paths graph visual language. */ export const GraphLegend = ({ data, expandedResources = new Set(), isFilteredView = false, }: GraphLegendProps) => { const { resolvedTheme } = useTheme(); if (!data || data.nodes.length === 0) { return null; } const legendState = resolveLegendState( data, expandedResources, isFilteredView, ); // OSS only: the hub node is present in the flat graph but stripped by Cloud's // view transform, so the legend entry follows the same split. const providerItem = !isCloud() && legendState.visibleNodes.some( (node) => resolveNodeVisual(node).category === NODE_CATEGORY.ACCOUNT, ) ? providerRootItem : null; const visibleNodeTypeItems = resolveNodeTypeItems(legendState.visibleNodes); const visibleFindingRiskItems = resolveFindingRiskItems( legendState.visibleNodes, ); const visibleStateItems = stateItems.filter( (item) => item.label === "Selected node" || Array.from(legendState.resourcesWithFindings).some((resourceId) => legendState.visibleNodeIds.has(resourceId), ), ); const visibleEdgeItems = edgeItems.filter((item) => { if (item.variant === EDGE_VARIANT.FINDING) { return legendState.visibleEdges.some( (edge) => legendState.visibleFindingIds.has(edge.source) || legendState.visibleFindingIds.has(edge.target), ); } return legendState.visibleEdges.length > 0; }); if ( !providerItem && visibleNodeTypeItems.length === 0 && visibleFindingRiskItems.length === 0 && visibleStateItems.length === 0 && visibleEdgeItems.length === 0 ) { return null; } const edgeColor = resolvedTheme === "dark" ? GRAPH_EDGE_COLOR_DARK : GRAPH_EDGE_COLOR_LIGHT; return (
{providerItem && ( )} {visibleNodeTypeItems.length > 0 && ( {visibleNodeTypeItems.map((item) => ( ))} )} {visibleFindingRiskItems.length > 0 && ( {visibleFindingRiskItems.map((item) => ( ))} )} {visibleStateItems.length > 0 && ( {visibleStateItems.map((item) => ( ))} )} {visibleEdgeItems.length > 0 && ( {visibleEdgeItems.map((item) => ( ))} )}
); };