Compare commits

...

1 Commits

Author SHA1 Message Date
Alan Buscaglia f9c140dbbd fix(ui): classify attack path findings exactly
- Treat only ProwlerFinding as a clickable finding node
- Keep GuardDuty and Inspector findings as graph resources
- Add regression coverage for provider finding resources
2026-05-19 18:17:38 +02:00
17 changed files with 136 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";
import { ResourceNode } from "./nodes/resource-node";
@@ -387,7 +388,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>();
@@ -459,7 +460,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,7 @@ import {
} from "@/components/ui/sheet/sheet";
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 +38,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 +104,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 { CodeSnippet } from "@/components/ui/code-snippet/code-snippet";
import { DateWithTime } from "@/components/ui/entities/date-with-time";
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 = [
@@ -48,6 +48,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",
@@ -390,8 +391,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";
@@ -218,7 +218,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 ?? [])
@@ -427,7 +427,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 {
@@ -442,9 +443,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>();
@@ -512,9 +511,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>();
@@ -55,7 +55,7 @@ import {
import type { GraphHandle } from "./_components/graph/attack-path-graph";
import { useGraphState } from "./_hooks/use-graph-state";
import { useQueryBuilder } from "./_hooks/use-query-builder";
import { exportGraphAsPNG } from "./_lib";
import { exportGraphAsPNG, isProwlerFindingNode } from "./_lib";
/**
* Attack Paths
@@ -287,9 +287,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) {
@@ -313,9 +311,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) {