diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/graph-legend.test.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/graph-legend.test.tsx index 5a38ef96cc..288a8b8ae4 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/graph-legend.test.tsx +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/graph-legend.test.tsx @@ -24,21 +24,28 @@ const graphData: AttackPathGraphData = { properties: { check_title: "Public bucket", severity: "critical" }, }, ], + relationships: [ + { id: "r1", source: "aws-account", target: "bucket", label: "HAS" }, + { id: "r2", source: "bucket", target: "vpc", label: "CAN_ACCESS" }, + { id: "r3", source: "bucket", target: "finding", label: "HAS_FINDING" }, + ], }; describe("GraphLegend", () => { - it("should explain graph visuals by semantic groups instead of repeating node labels", () => { + it("should explain concrete visible node types without generic categories", () => { // Given - A graph with provider, resource, and finding nodes // When - render(); + render( + , + ); // Then expect( screen.getByRole("heading", { name: /provider roots/i }), ).toBeInTheDocument(); expect( - screen.getByRole("heading", { name: /resource categories/i }), + screen.getByRole("heading", { name: /node types/i }), ).toBeInTheDocument(); expect( screen.getByRole("heading", { name: /findings by risk/i }), @@ -49,16 +56,18 @@ describe("GraphLegend", () => { expect(screen.getByRole("heading", { name: /edges/i })).toBeInTheDocument(); expect(screen.getByText("Provider / account root")).toBeInTheDocument(); - expect(screen.getByText("Storage")).toBeInTheDocument(); - expect(screen.getByText("Network")).toBeInTheDocument(); - expect(screen.getByText("Compute")).toBeInTheDocument(); - expect(screen.getByText("Identity")).toBeInTheDocument(); - expect(screen.getByText("Secret / misc")).toBeInTheDocument(); + expect(screen.getByText("S3 Bucket")).toBeInTheDocument(); + expect(screen.getByText("VPC")).toBeInTheDocument(); + expect(screen.queryByText("Storage")).not.toBeInTheDocument(); + expect(screen.queryByText("Network")).not.toBeInTheDocument(); + expect(screen.queryByText("Compute")).not.toBeInTheDocument(); + expect(screen.queryByText("Identity")).not.toBeInTheDocument(); + expect(screen.queryByText("Secret / misc")).not.toBeInTheDocument(); expect(screen.getByText("Critical")).toBeInTheDocument(); - expect(screen.getByText("High")).toBeInTheDocument(); - expect(screen.getByText("Medium")).toBeInTheDocument(); - expect(screen.getByText("Low / Info")).toBeInTheDocument(); + expect(screen.queryByText("High")).not.toBeInTheDocument(); + expect(screen.queryByText("Medium")).not.toBeInTheDocument(); + expect(screen.queryByText("Low / Info")).not.toBeInTheDocument(); expect(screen.getByText("Selected node")).toBeInTheDocument(); expect(screen.getByText("Node with findings")).toBeInTheDocument(); @@ -66,12 +75,66 @@ describe("GraphLegend", () => { expect(screen.getByText("Finding edge")).toBeInTheDocument(); expect(screen.getByText("Highlighted path")).toBeInTheDocument(); - expect(screen.queryByText("S3 Bucket")).not.toBeInTheDocument(); - expect(screen.queryByText("VPC")).not.toBeInTheDocument(); expect(screen.queryByText(/ctrl/i)).not.toBeInTheDocument(); expect(screen.queryByText(/scroll to zoom/i)).not.toBeInTheDocument(); }); + it("should hide finding legend items when finding nodes are hidden", () => { + // Given - A resource has related findings, but it is not expanded yet + + // When + render(); + + // Then + expect( + screen.queryByRole("heading", { name: /findings by risk/i }), + ).not.toBeInTheDocument(); + expect(screen.queryByText("Finding edge")).not.toBeInTheDocument(); + expect(screen.getByText("Node with findings")).toBeInTheDocument(); + }); + + it("should list policy and role node types separately", () => { + // Given - A graph whose visible nodes are all identity-related, but distinct + const identityGraphData: AttackPathGraphData = { + nodes: [ + { + id: "aws-account", + labels: ["AWSAccount"], + properties: { name: "Production" }, + }, + { + id: "role", + labels: ["PermissionRole"], + properties: { name: "prowler-pro-dev-gha-role" }, + }, + { + id: "policy", + labels: ["AWSPolicy"], + properties: { name: "IAMPermissions" }, + }, + { + id: "statement", + labels: ["AWSPolicyStatement"], + properties: { name: "policy statement" }, + }, + ], + relationships: [ + { id: "r1", source: "aws-account", target: "role", label: "HAS" }, + { id: "r2", source: "role", target: "policy", label: "HAS" }, + { id: "r3", source: "policy", target: "statement", label: "HAS" }, + ], + }; + + // When + render(); + + // Then + expect(screen.getByText("Permission Role")).toBeInTheDocument(); + expect(screen.getByText("AWS Policy")).toBeInTheDocument(); + expect(screen.getByText("AWS Policy Statement")).toBeInTheDocument(); + expect(screen.queryByText("Identity")).not.toBeInTheDocument(); + }); + it("should stay hidden until graph nodes are available", () => { // Given - No graph nodes have been loaded yet const emptyGraphData: AttackPathGraphData = { nodes: [] }; diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/graph-legend.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/graph-legend.tsx index 68484fd51b..bcc81f9de0 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/graph-legend.tsx +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/graph-legend.tsx @@ -13,6 +13,8 @@ import { import type { AttackPathGraphData, GraphNode } from "@/types/attack-paths"; import { + getNodeBorderColor, + getNodeColor, GRAPH_ALERT_BORDER_COLOR, GRAPH_EDGE_COLOR_DARK, GRAPH_EDGE_COLOR_LIGHT, @@ -75,6 +77,16 @@ interface LegendItemProps { 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 = ( @@ -110,44 +122,6 @@ const providerRootItem = buildVisualItem( GRAPH_NODE_BORDER_COLORS.awsAccount, ); -const resourceCategoryItems: LegendVisualItem[] = [ - buildVisualItem( - "Storage", - "Data stores such as buckets and object storage.", - buildNode(["S3Bucket"], { name: NODE_CATEGORY.STORAGE }), - GRAPH_NODE_COLORS.s3Bucket, - GRAPH_NODE_BORDER_COLORS.s3Bucket, - ), - buildVisualItem( - "Network", - "VPCs, security boundaries, gateways, and reachable network resources.", - buildNode(["VPC"], { name: NODE_CATEGORY.NETWORK }), - GRAPH_NODE_COLORS.securityGroup, - GRAPH_NODE_BORDER_COLORS.securityGroup, - ), - buildVisualItem( - "Compute", - "Instances, virtual machines, functions, and execution surfaces.", - buildNode(["EC2Instance"], { name: NODE_CATEGORY.COMPUTE }), - GRAPH_NODE_COLORS.ec2Instance, - GRAPH_NODE_BORDER_COLORS.ec2Instance, - ), - buildVisualItem( - "Identity", - "Users, roles, policies, and permission-bearing principals.", - buildNode(["IAMRole"], { name: NODE_CATEGORY.IDENTITY }), - GRAPH_NODE_COLORS.iamRole, - GRAPH_NODE_BORDER_COLORS.iamRole, - ), - buildVisualItem( - "Secret / misc", - "Secrets, access keys, or fallback resources that do not map to a known category.", - buildNode(["AccessKey"], { name: NODE_CATEGORY.SECRET }), - GRAPH_NODE_COLORS.default, - GRAPH_NODE_BORDER_COLORS.default, - ), -]; - const findingRiskItems: LegendVisualItem[] = [ buildVisualItem( "Critical", @@ -220,8 +194,128 @@ const edgeItems: LegendEdgeItem[] = [ }, ]; -const hasLegendData = (data?: AttackPathGraphData): boolean => - (data?.nodes.length ?? 0) > 0; +const isFindingNode = (node: GraphNode): boolean => + node.labels.some((label) => label.toLowerCase().includes("finding")); + +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 = new Set(); + if (!isFilteredView) { + for (const findingId of Array.from(findingNodeIds)) { + const connectedResources = findingToResources.get(findingId); + const isVisible = connectedResources + ? Array.from(connectedResources).some((resourceId) => + expandedResources.has(resourceId), + ) + : false; + + if (!isVisible) hiddenFindingIds.add(findingId); + } + } + + 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) => (
@@ -375,10 +469,57 @@ const EdgePreview = ({ /** * Compact semantic legend for the Attack Paths graph visual language. */ -export const GraphLegend = ({ data }: GraphLegendProps) => { +export const GraphLegend = ({ + data, + expandedResources = new Set(), + isFilteredView = false, +}: GraphLegendProps) => { const { resolvedTheme } = useTheme(); - if (!hasLegendData(data)) { + if (!data || data.nodes.length === 0) { + return null; + } + + const legendState = resolveLegendState( + data, + expandedResources, + isFilteredView, + ); + const providerItem = 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; } @@ -390,43 +531,53 @@ export const GraphLegend = ({ data }: GraphLegendProps) => {
- - - - - - - - {resourceCategoryItems.map((item) => ( - - + {providerItem && ( + + + - ))} - + + )} - - {findingRiskItems.map((item) => ( - - - - ))} - + {visibleNodeTypeItems.length > 0 && ( + + {visibleNodeTypeItems.map((item) => ( + + + + ))} + + )} - - {stateItems.map((item) => ( - - - - ))} - + {visibleFindingRiskItems.length > 0 && ( + + {visibleFindingRiskItems.map((item) => ( + + + + ))} + + )} - - {edgeItems.map((item) => ( - - - - ))} - + {visibleStateItems.length > 0 && ( + + {visibleStateItems.map((item) => ( + + + + ))} + + )} + + {visibleEdgeItems.length > 0 && ( + + {visibleEdgeItems.map((item) => ( + + + + ))} + + )}
diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/node-visuals.test.ts b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/node-visuals.test.ts index f8ff4792ba..bf286a6074 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/node-visuals.test.ts +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/node-visuals.test.ts @@ -1,12 +1,20 @@ import { AlertTriangle, + Bot, Braces, CircleAlert, FileKey2, + Globe2, Info, + Route, + Server, + Shield, ShieldCheck, Siren, + Tags, UserCog, + Users, + Waypoints, } from "lucide-react"; import { describe, expect, it } from "vitest"; @@ -17,9 +25,11 @@ import { KS8ProviderBadge, } from "@/components/icons/providers-badge"; import { + AmazonRDSIcon, AmazonS3Icon, AmazonVPCIcon, AWSIAMIcon, + AWSLambdaIcon, } from "@/components/icons/services/IconServices"; import type { GraphNode } from "@/types/attack-paths"; @@ -274,6 +284,130 @@ describe("resolveNodeVisual", () => { expect(visual.Icon).toBe(identityNode.Icon); } }); + + it("should resolve all AWS labels used by predefined Attack Paths queries", () => { + // Given + const awsQueryNodes = [ + { + label: "AWSTag", + category: NODE_CATEGORY.MISC, + description: "AWS Tag", + Icon: Tags, + }, + { + label: "EC2SecurityGroup", + category: NODE_CATEGORY.NETWORK, + description: "EC2 Security Group", + Icon: Shield, + }, + { + label: "IpPermissionInbound", + category: NODE_CATEGORY.NETWORK, + description: "Inbound IP Permission", + Icon: Shield, + }, + { + label: "IpRange", + category: NODE_CATEGORY.NETWORK, + description: "IP Range", + Icon: Globe2, + }, + { + label: "AWSPrincipal", + category: NODE_CATEGORY.IDENTITY, + description: "AWS Principal", + Icon: ShieldCheck, + }, + { + label: "AWSGroup", + category: NODE_CATEGORY.IDENTITY, + description: "AWS Group", + Icon: Users, + }, + { + label: "RDSInstance", + category: NODE_CATEGORY.STORAGE, + description: "RDS Instance", + Icon: AmazonRDSIcon, + }, + { + label: "LoadBalancer", + category: NODE_CATEGORY.NETWORK, + description: "Load Balancer", + Icon: Route, + }, + { + label: "ELBListener", + category: NODE_CATEGORY.NETWORK, + description: "ELB Listener", + Icon: Route, + }, + { + label: "LoadBalancerV2", + category: NODE_CATEGORY.NETWORK, + description: "Load Balancer V2", + Icon: Route, + }, + { + label: "ELBV2Listener", + category: NODE_CATEGORY.NETWORK, + description: "ELB V2 Listener", + Icon: Route, + }, + { + label: "ElasticIPAddress", + category: NODE_CATEGORY.NETWORK, + description: "Elastic IP Address", + Icon: Globe2, + }, + { + label: "EC2PrivateIp", + category: NODE_CATEGORY.NETWORK, + description: "EC2 Private IP", + Icon: Waypoints, + }, + { + label: "NetworkInterface", + category: NODE_CATEGORY.NETWORK, + description: "Network Interface", + Icon: Waypoints, + }, + { + label: "LaunchTemplate", + category: NODE_CATEGORY.COMPUTE, + description: "Launch Template", + Icon: Server, + }, + { + label: "AWSLambda", + category: NODE_CATEGORY.COMPUTE, + description: "AWS Lambda", + Icon: AWSLambdaIcon, + }, + { + label: "AWSSageMakerNotebookInstance", + category: NODE_CATEGORY.COMPUTE, + description: "SageMaker Notebook Instance", + Icon: Bot, + }, + ]; + + for (const awsQueryNode of awsQueryNodes) { + // When + const visual = resolveNodeVisual( + buildNode([awsQueryNode.label], { name: awsQueryNode.description }), + ); + + // Then + expect(visual).toMatchObject({ + category: awsQueryNode.category, + displayName: awsQueryNode.description, + description: awsQueryNode.description, + fallbackUsed: false, + }); + expect(visual.Icon).toBe(awsQueryNode.Icon); + } + }); }); describe("fallback behavior", () => { diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/node-visuals.ts b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/node-visuals.ts index 626a295b08..34c7b15450 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/node-visuals.ts +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/node-visuals.ts @@ -13,7 +13,9 @@ import { Shield, ShieldCheck, Siren, + Tags, UserCog, + Users, Waypoints, } from "lucide-react"; import type { ElementType } from "react"; @@ -37,9 +39,11 @@ import { } from "@/components/icons/providers-badge"; import { AmazonEC2Icon, + AmazonRDSIcon, AmazonS3Icon, AmazonVPCIcon, AWSIAMIcon, + AWSLambdaIcon, } from "@/components/icons/services/IconServices"; import type { GraphNode, GraphNodePropertyValue } from "@/types/attack-paths"; @@ -196,6 +200,36 @@ const KNOWN_NODE_VISUALS = { description: "Security Group", Icon: Shield, }, + ec2securitygroup: { + category: NODE_CATEGORY.NETWORK, + description: "EC2 Security Group", + Icon: Shield, + }, + ippermissioninbound: { + category: NODE_CATEGORY.NETWORK, + description: "Inbound IP Permission", + Icon: Shield, + }, + iprange: { + category: NODE_CATEGORY.NETWORK, + description: "IP Range", + Icon: Globe2, + }, + elasticipaddress: { + category: NODE_CATEGORY.NETWORK, + description: "Elastic IP Address", + Icon: Globe2, + }, + ec2privateip: { + category: NODE_CATEGORY.NETWORK, + description: "EC2 Private IP", + Icon: Waypoints, + }, + networkinterface: { + category: NODE_CATEGORY.NETWORK, + description: "Network Interface", + Icon: Waypoints, + }, internetgateway: { category: NODE_CATEGORY.NETWORK, description: "Internet Gateway", @@ -206,16 +240,56 @@ const KNOWN_NODE_VISUALS = { description: "Default Gateway", Icon: Route, }, + loadbalancer: { + category: NODE_CATEGORY.NETWORK, + description: "Load Balancer", + Icon: Route, + }, + loadbalancerv2: { + category: NODE_CATEGORY.NETWORK, + description: "Load Balancer V2", + Icon: Route, + }, + elblistener: { + category: NODE_CATEGORY.NETWORK, + description: "ELB Listener", + Icon: Route, + }, + elbv2listener: { + category: NODE_CATEGORY.NETWORK, + description: "ELB V2 Listener", + Icon: Route, + }, ec2instance: { category: NODE_CATEGORY.COMPUTE, description: "EC2 Instance", Icon: AmazonEC2Icon, }, + launchtemplate: { + category: NODE_CATEGORY.COMPUTE, + description: "Launch Template", + Icon: Server, + }, + awslambda: { + category: NODE_CATEGORY.COMPUTE, + description: "AWS Lambda", + Icon: AWSLambdaIcon, + }, + awssagemakernotebookinstance: { + category: NODE_CATEGORY.COMPUTE, + description: "SageMaker Notebook Instance", + Icon: Bot, + }, virtualmachine: { category: NODE_CATEGORY.COMPUTE, description: "Virtual Machine", Icon: AmazonEC2Icon, }, + rdsinstance: { + category: NODE_CATEGORY.STORAGE, + description: "RDS Instance", + Icon: AmazonRDSIcon, + }, compute: { category: NODE_CATEGORY.COMPUTE, description: "Compute", @@ -236,6 +310,16 @@ const KNOWN_NODE_VISUALS = { description: "AWS User", Icon: UserCog, }, + awsgroup: { + category: NODE_CATEGORY.IDENTITY, + description: "AWS Group", + Icon: Users, + }, + awsprincipal: { + category: NODE_CATEGORY.IDENTITY, + description: "AWS Principal", + Icon: ShieldCheck, + }, iamrole: { category: NODE_CATEGORY.IDENTITY, description: "IAM Role", @@ -291,6 +375,11 @@ const KNOWN_NODE_VISUALS = { description: "Service Account", Icon: Bot, }, + awstag: { + category: NODE_CATEGORY.MISC, + description: "AWS Tag", + Icon: Tags, + }, } as const satisfies Record; type KnownNodeLabel = keyof typeof KNOWN_NODE_VISUALS; diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.tsx index 8b9cb29c13..99e11f3f48 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.tsx +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.tsx @@ -667,7 +667,11 @@ export default function AttackPathsPage() { {/* Legend below */}
- +
) : null}