Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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>();
Expand Down Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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} />);
Expand All @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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">
Expand Down Expand Up @@ -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 (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

import type { AttackPathGraphData } from "@/types/attack-paths";

import { isProwlerFindingNode } from "./node-types";

export const resolveHiddenFindingIds = ({
expandedResources,
findingNodeIds,
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export {
resolveHiddenFindingIds,
} from "./graph-utils";
export { layoutWithDagre } from "./layout";
export { isProwlerFindingLabel, isProwlerFindingNode } from "./node-types";
export {
NODE_CATEGORY,
type NodeCategory,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down Expand Up @@ -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[] = [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand All @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 ?? [])
Expand Down Expand Up @@ -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 ?? [])
Expand Down
Loading
Loading