feat(ui): expand attack paths node legend coverage

This commit is contained in:
Alan Buscaglia
2026-05-05 21:54:39 +02:00
parent 33ad74b53c
commit fc7e0c85e4
5 changed files with 530 additions and 89 deletions
@@ -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(<GraphLegend data={graphData} />);
render(
<GraphLegend data={graphData} expandedResources={new Set(["bucket"])} />,
);
// 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(<GraphLegend data={graphData} />);
// 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(<GraphLegend data={identityGraphData} />);
// 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: [] };
@@ -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<string>;
isFilteredView?: boolean;
}
interface GraphLegendState {
visibleNodes: GraphNode[];
visibleNodeIds: Set<string>;
visibleFindingIds: Set<string>;
visibleEdges: Array<{ source: string; target: string }>;
resourcesWithFindings: Set<string>;
}
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<string>,
isFilteredView: boolean,
): GraphLegendState => {
const findingNodeIds = new Set(
data.nodes.filter(isFindingNode).map((node) => node.id),
);
const findingToResources = new Map<string, Set<string>>();
const resourcesWithFindings = new Set<string>();
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<string>();
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<string, LegendVisualItem>();
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) => (
<section className="bg-bg-neutral-secondary/60 border-border-neutral-primary flex w-full min-w-0 flex-col gap-2 rounded-lg border p-3 sm:w-fit sm:max-w-full">
@@ -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) => {
<CardContent className="p-3">
<TooltipProvider>
<div className="flex w-full flex-wrap items-stretch gap-2">
<LegendSection title="Provider roots">
<LegendItem {...providerRootItem}>
<BadgePreview {...providerRootItem} />
</LegendItem>
</LegendSection>
<LegendSection title="Resource categories">
{resourceCategoryItems.map((item) => (
<LegendItem key={item.label} {...item}>
<BadgePreview {...item} />
{providerItem && (
<LegendSection title="Provider roots">
<LegendItem {...providerItem}>
<BadgePreview {...providerItem} />
</LegendItem>
))}
</LegendSection>
</LegendSection>
)}
<LegendSection title="Findings by risk">
{findingRiskItems.map((item) => (
<LegendItem key={item.label} {...item}>
<BadgePreview {...item} />
</LegendItem>
))}
</LegendSection>
{visibleNodeTypeItems.length > 0 && (
<LegendSection title="Node types">
{visibleNodeTypeItems.map((item) => (
<LegendItem key={item.label} {...item}>
<BadgePreview {...item} />
</LegendItem>
))}
</LegendSection>
)}
<LegendSection title="States">
{stateItems.map((item) => (
<LegendItem key={item.label} {...item}>
<StatePreview {...item} />
</LegendItem>
))}
</LegendSection>
{visibleFindingRiskItems.length > 0 && (
<LegendSection title="Findings by risk">
{visibleFindingRiskItems.map((item) => (
<LegendItem key={item.label} {...item}>
<BadgePreview {...item} />
</LegendItem>
))}
</LegendSection>
)}
<LegendSection title="Edges">
{edgeItems.map((item) => (
<LegendItem key={item.label} {...item}>
<EdgePreview variant={item.variant} edgeColor={edgeColor} />
</LegendItem>
))}
</LegendSection>
{visibleStateItems.length > 0 && (
<LegendSection title="States">
{visibleStateItems.map((item) => (
<LegendItem key={item.label} {...item}>
<StatePreview {...item} />
</LegendItem>
))}
</LegendSection>
)}
{visibleEdgeItems.length > 0 && (
<LegendSection title="Edges">
{visibleEdgeItems.map((item) => (
<LegendItem key={item.label} {...item}>
<EdgePreview variant={item.variant} edgeColor={edgeColor} />
</LegendItem>
))}
</LegendSection>
)}
</div>
</TooltipProvider>
</CardContent>
@@ -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", () => {
@@ -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<string, KnownNodeVisualMapping>;
type KnownNodeLabel = keyof typeof KNOWN_NODE_VISUALS;
@@ -667,7 +667,11 @@ export default function AttackPathsPage() {
{/* Legend below */}
<div className="flex justify-center overflow-x-auto">
<GraphLegend data={graphState.data} />
<GraphLegend
data={graphState.data}
expandedResources={graphState.expandedResources}
isFilteredView={graphState.isFilteredView}
/>
</div>
</>
) : null}