fix(ui): classify attack path findings exactly (#11244)

This commit is contained in:
Alan Buscaglia
2026-07-27 14:48:01 +02:00
committed by GitHub
parent 6b21e31a28
commit f18c2841a8
18 changed files with 138 additions and 43 deletions
@@ -31,9 +31,10 @@ import {
getNodeColor,
getPathEdges,
GRAPH_EDGE_HIGHLIGHT_COLOR,
isProwlerFindingNode,
resolveHiddenFindingIds,
} from "../../_lib";
import { isFindingNode, layoutWithDagre } from "../../_lib/layout";
import { layoutWithDagre } from "../../_lib/layout";
import { FindingNode } from "./nodes/finding-node";
import { InternetNode } from "./nodes/internet-node";
@@ -388,7 +389,7 @@ const GraphCanvas = ({
const findingToResources = new Map<string, Set<string>>();
nodes.forEach((n) => {
if (isFindingNode(n.labels)) findingNodeIds.add(n.id);
if (isProwlerFindingNode(n.labels)) findingNodeIds.add(n.id);
});
const resourcesWithFindings = new Set<string>();
@@ -460,7 +461,7 @@ const GraphCanvas = ({
hidden: hiddenFindingIds.has(node.id),
className: cn(
node.className,
isFindingNode(node.data.graphNode.labels) ||
isProwlerFindingNode(node.data.graphNode.labels) ||
resourcesWithFindings.has(node.id)
? "cursor-pointer"
: "cursor-default",
@@ -23,6 +23,7 @@ import {
GRAPH_NODE_COLORS,
} from "../../_lib/graph-colors";
import { resolveHiddenFindingIds } from "../../_lib/graph-utils";
import { isProwlerFindingNode } from "../../_lib/node-types";
import { NODE_CATEGORY, resolveNodeVisual } from "../../_lib/node-visuals";
const LEGEND_PREVIEW = {
@@ -197,7 +198,7 @@ const edgeItems: LegendEdgeItem[] = [
];
const isFindingNode = (node: GraphNode): boolean =>
node.labels.some((label) => label.toLowerCase().includes("finding"));
isProwlerFindingNode(node.labels);
const getGraphEdges = (
data: AttackPathGraphData,
@@ -50,6 +50,15 @@ const resourceNode: GraphNode = {
},
};
const guardDutyNode: GraphNode = {
id: "guard-duty-node-id",
labels: ["GuardDutyFinding"],
properties: {
id: "guard-duty-123",
title: "Port probe",
},
};
describe("NodeDetailPanel", () => {
it("renders the view finding button only for finding nodes", () => {
const { rerender } = render(<NodeDetailPanel node={findingNode} />);
@@ -65,6 +74,17 @@ describe("NodeDetailPanel", () => {
).not.toBeInTheDocument();
});
it("does not render the view finding button for cloud-provider finding resources", () => {
// Given/When
render(<NodeDetailPanel node={guardDutyNode} />);
// Then
expect(
screen.queryByRole("button", { name: /view finding/i }),
).not.toBeInTheDocument();
expect(screen.getByText("Node findings")).toBeInTheDocument();
});
it("calls onViewFinding with the node finding id", async () => {
const user = userEvent.setup();
const onViewFinding = vi.fn();
@@ -11,6 +11,8 @@ import {
import { Spinner } from "@/components/shadcn/spinner/spinner";
import type { GraphNode } from "@/types/attack-paths";
import { isProwlerFindingNode } from "../../_lib";
import { NodeFindings } from "./node-findings";
import { NodeOverview } from "./node-overview";
import { NodeResources } from "./node-resources";
@@ -37,9 +39,7 @@ export const NodeDetailContent = ({
onViewFinding?: (findingId: string) => void;
viewFindingLoading?: boolean;
}) => {
const isProwlerFinding = node?.labels.some((label) =>
label.toLowerCase().includes("finding"),
);
const isProwlerFinding = isProwlerFindingNode(node.labels);
return (
<div className="flex flex-col gap-6">
@@ -105,9 +105,7 @@ export const NodeDetailPanel = ({
}: NodeDetailPanelProps) => {
const isOpen = node !== null;
const isProwlerFinding = node?.labels.some((label) =>
label.toLowerCase().includes("finding"),
);
const isProwlerFinding = node ? isProwlerFindingNode(node.labels) : false;
const findingId = node ? String(node.properties?.id || node.id) : "";
return (
@@ -5,7 +5,7 @@ import { DateWithTime } from "@/components/shadcn/entities/date-with-time";
import { InfoField } from "@/components/shadcn/info-field/info-field";
import type { GraphNode, GraphNodePropertyValue } from "@/types/attack-paths";
import { formatNodeLabels } from "../../_lib";
import { formatNodeLabels, isProwlerFindingNode } from "../../_lib";
interface NodeOverviewProps {
node: GraphNode;
@@ -25,9 +25,7 @@ export const NodeOverview = ({ node }: NodeOverviewProps) => {
return String(value);
};
const isFinding = node.labels.some((label) =>
label.toLowerCase().includes("finding"),
);
const isFinding = isProwlerFindingNode(node.labels);
return (
<div className="flex flex-col gap-4">
@@ -22,6 +22,7 @@ import {
RESOURCE_NODE_DIMENSIONS,
} from "./node-dimensions";
import { getNodeLabelDisplay } from "./node-label-lines";
import { isProwlerFindingNode } from "./node-types";
import { resolveNodeVisual } from "./node-visuals";
interface ExportGraphOptions {
@@ -67,8 +68,7 @@ const downloadDataUrl = (dataUrl: string, filename: string) => {
document.body.removeChild(link);
};
const isFindingNode = (labels: string[]) =>
labels.some((label) => label.toLowerCase().includes("finding"));
const isFindingNode = isProwlerFindingNode;
const getGraphEdges = (graphData: AttackPathGraphData): GraphEdge[] => {
if (graphData.edges?.length) return graphData.edges;
@@ -1,3 +1,5 @@
import { isProwlerFindingNode } from "./node-types";
/**
* Color constants for attack path graph visualization
* Colors chosen to work well in both light and dark themes
@@ -67,7 +69,7 @@ export const getNodeColor = (
labels: string[],
properties?: Record<string, unknown>,
): string => {
const isFinding = labels.some((l) => l.toLowerCase().includes("finding"));
const isFinding = isProwlerFindingNode(labels);
if (isFinding && properties?.severity) {
const severity = String(properties.severity).toLowerCase();
if (severity === "critical") return GRAPH_NODE_COLORS.critical;
@@ -100,7 +102,7 @@ export const getNodeBorderColor = (
labels: string[],
properties?: Record<string, unknown>,
): string => {
const isFinding = labels.some((l) => l.toLowerCase().includes("finding"));
const isFinding = isProwlerFindingNode(labels);
if (isFinding && properties?.severity) {
const severity = String(properties.severity).toLowerCase();
if (severity === "critical") return GRAPH_NODE_BORDER_COLORS.critical;
@@ -4,6 +4,8 @@
import type { AttackPathGraphData } from "@/types/attack-paths";
import { isProwlerFindingNode } from "./node-types";
export const resolveHiddenFindingIds = ({
expandedResources,
findingNodeIds,
@@ -103,11 +105,11 @@ export const computeFilteredSubgraph = (
// Also include findings directly connected to the selected node
const nodeLabelMap = new Map(nodes.map((n) => [n.id, n.labels]));
edges.forEach((edge) => {
const sourceIsFinding = (nodeLabelMap.get(edge.source) ?? []).some((l) =>
l.toLowerCase().includes("finding"),
const sourceIsFinding = isProwlerFindingNode(
nodeLabelMap.get(edge.source) ?? [],
);
const targetIsFinding = (nodeLabelMap.get(edge.target) ?? []).some((l) =>
l.toLowerCase().includes("finding"),
const targetIsFinding = isProwlerFindingNode(
nodeLabelMap.get(edge.target) ?? [],
);
// Include findings connected to the selected node
@@ -18,6 +18,7 @@ export {
resolveHiddenFindingIds,
} from "./graph-utils";
export { layoutWithDagre } from "./layout";
export { isProwlerFindingLabel, isProwlerFindingNode } from "./node-types";
export {
NODE_CATEGORY,
type NodeCategory,
@@ -17,6 +17,18 @@ const resourceNode: GraphNode = {
properties: { name: "bucket-1" },
};
const guardDutyNode: GraphNode = {
id: "guard-duty-1",
labels: ["GuardDutyFinding"],
properties: { title: "Port probe", severity: "high" },
};
const inspectorNode: GraphNode = {
id: "inspector-1",
labels: ["AWSInspectorFinding"],
properties: { title: "Package vulnerability", severity: "high" },
};
const internetNode: GraphNode = {
id: "internet-1",
labels: ["Internet"],
@@ -54,6 +66,42 @@ describe("layoutWithDagre", () => {
});
});
it("treats cloud-provider finding resources as resource nodes", () => {
const { rfNodes } = layoutWithDagre(
[findingNode, guardDutyNode, inspectorNode],
[],
);
const byId = new Map(rfNodes.map((n) => [n.id, n]));
expect(byId.get("finding-1")?.type).toBe("finding");
expect(byId.get("guard-duty-1")).toMatchObject({
type: "resource",
width: 136,
height: 124,
});
expect(byId.get("inspector-1")?.type).toBe("resource");
});
it("does not animate edges that only touch cloud-provider finding resources", () => {
const { rfEdges } = layoutWithDagre(
[resourceNode, guardDutyNode],
[
{
id: "e1",
source: "guard-duty-1",
target: "resource-1",
type: "AFFECTS",
},
],
);
expect(rfEdges[0]).toMatchObject({
animated: false,
className: "resource-edge",
});
});
it("is deterministic: same input produces equal output across runs", () => {
const nodes = [findingNode, resourceNode];
const edges: GraphEdge[] = [
@@ -13,6 +13,7 @@ import {
INTERNET_NODE_DIMENSIONS,
RESOURCE_NODE_DIMENSIONS,
} from "./node-dimensions";
import { isProwlerFindingNode } from "./node-types";
// Container relationships that get reversed for proper hierarchy
const CONTAINER_RELATIONS = new Set([
@@ -34,8 +35,7 @@ const NODE_TYPE = {
type NodeType = (typeof NODE_TYPE)[keyof typeof NODE_TYPE];
export const isFindingNode = (labels: string[]): boolean =>
labels.some((l) => l.toLowerCase().includes("finding"));
const isFindingNode = isProwlerFindingNode;
const getNodeType = (labels: string[]): NodeType => {
if (isFindingNode(labels)) return NODE_TYPE.FINDING;
@@ -0,0 +1,8 @@
const normalizeNodeLabel = (label: string): string =>
label.toLowerCase().replace(/[^a-z0-9]/g, "");
export const isProwlerFindingLabel = (label: string): boolean =>
normalizeNodeLabel(label) === "prowlerfinding";
export const isProwlerFindingNode = (labels: string[]): boolean =>
labels.some(isProwlerFindingLabel);
@@ -193,6 +193,28 @@ describe("resolveNodeVisual", () => {
});
});
it("should resolve cloud-provider finding resources as non-finding nodes", () => {
// Given
const guardDutyNode = buildNode(["GuardDutyFinding"], {
title: "Port probe",
severity: "high",
});
const inspectorNode = buildNode(["AWSInspectorFinding"], {
title: "Package vulnerability",
severity: "high",
});
// When
const guardDutyVisual = resolveNodeVisual(guardDutyNode);
const inspectorVisual = resolveNodeVisual(inspectorNode);
// Then
expect(guardDutyVisual.category).not.toBe(NODE_CATEGORY.FINDING);
expect(guardDutyVisual.description).toBe("Guard Duty Finding");
expect(inspectorVisual.category).not.toBe(NODE_CATEGORY.FINDING);
expect(inspectorVisual.description).toBe("Aws Inspector Finding");
});
it("should resolve finding icons from severity", () => {
// Given
const findingNodes = [
@@ -49,6 +49,7 @@ import {
import type { GraphNode, GraphNodePropertyValue } from "@/types/attack-paths";
import { formatNodeLabel } from "./format";
import { isProwlerFindingLabel } from "./node-types";
export const NODE_CATEGORY = {
FINDING: "finding",
@@ -396,8 +397,7 @@ const normalizeLabel = (label: string): string =>
const isKnownNodeLabel = (label: string): label is KnownNodeLabel =>
label in KNOWN_NODE_VISUALS;
const isFindingLabel = (label: string): boolean =>
normalizeLabel(label).includes("finding");
const isFindingLabel = isProwlerFindingLabel;
const isInternetLabel = (label: string): boolean =>
normalizeLabel(label) === "internet";
@@ -35,8 +35,8 @@ vi.mock("@/actions/findings", async () => {
});
import { useGraphStore } from "./_hooks/use-graph-state";
import { getPathEdges } from "./_lib";
import { isFindingNode, layoutWithDagre } from "./_lib/layout";
import { getPathEdges, isProwlerFindingNode } from "./_lib";
import { layoutWithDagre } from "./_lib/layout";
import AttackPathsPage from "./attack-paths-page";
import { fixtures, type PageFixture } from "./attack-paths-page.fixtures";
import { AttackPathPageHarness } from "./attack-paths-page.harness";
@@ -268,7 +268,7 @@ describe("running a query", () => {
if (!fixture.queryResult) throw new Error("Expected graph fixture data");
const visibleNodes = fixture.queryResult.nodes.filter(
(node) => !isFindingNode(node.labels),
(node) => !isProwlerFindingNode(node.labels),
);
const visibleNodeIds = new Set(visibleNodes.map((node) => node.id));
const visibleEdges = (fixture.queryResult.relationships ?? [])
@@ -477,7 +477,7 @@ describe("exploring the graph", () => {
const findingIds = new Set(
(fixture.queryResult?.nodes ?? [])
.filter((node) => isFindingNode(node.labels))
.filter((node) => isProwlerFindingNode(node.labels))
.map((node) => node.id),
);
const visibleEdges = (fixture.queryResult?.relationships ?? [])
@@ -7,6 +7,7 @@
import { vi } from "vitest";
import { userEvent } from "vitest/browser";
import { isProwlerFindingNode } from "./_lib";
import type { PageFixture } from "./attack-paths-page.fixtures";
export class AttackPathPageHarness {
@@ -458,9 +459,7 @@ export class AttackPathPageHarness {
async clickFirstResourceNodeWithoutFindings(): Promise<HTMLElement> {
const findingIds = new Set(
(this.fixture.queryResult?.nodes ?? [])
.filter((n) =>
n.labels.some((l) => l.toLowerCase().includes("finding")),
)
.filter((n) => isProwlerFindingNode(n.labels))
.map((n) => n.id),
);
const resourceWithFindingIds = new Set<string>();
@@ -528,9 +527,7 @@ export class AttackPathPageHarness {
async expandAllFindings(): Promise<void> {
const findingIds = new Set(
(this.fixture.queryResult?.nodes ?? [])
.filter((n) =>
n.labels.some((l) => l.toLowerCase().includes("finding")),
)
.filter((n) => isProwlerFindingNode(n.labels))
.map((n) => n.id),
);
const resourceWithFindingIds = new Set<string>();
@@ -60,7 +60,7 @@ import type { GraphHandle } from "./_components/graph/attack-path-graph";
import { useAttackPathScans } from "./_hooks/use-attack-path-scans";
import { useGraphState } from "./_hooks/use-graph-state";
import { useQueryBuilder } from "./_hooks/use-query-builder";
import { exportGraphAsPNG } from "./_lib";
import { exportGraphAsPNG, isProwlerFindingNode } from "./_lib";
import {
ATTACK_PATHS_VIEW_STATES,
getAttackPathsViewState,
@@ -325,9 +325,7 @@ export default function AttackPathsPage() {
};
const handleNodeClick = (node: GraphNode) => {
const isFinding = node.labels.some((label) =>
label.toLowerCase().includes("finding"),
);
const isFinding = isProwlerFindingNode(node.labels);
if (isFinding) {
if (findingNavigationInFlightRef.current) {
@@ -347,9 +345,7 @@ export default function AttackPathsPage() {
if (edge.source !== node.id && edge.target !== node.id) return false;
const otherId = edge.source === node.id ? edge.target : edge.source;
const otherNode = sourceData.nodes?.find(({ id }) => id === otherId);
return otherNode?.labels.some((label) =>
label.toLowerCase().includes("finding"),
);
return otherNode ? isProwlerFindingNode(otherNode.labels) : false;
});
if (hasFindings) {
@@ -0,0 +1 @@
Attack Paths now classify cloud-provider finding resources separately from Prowler findings