diff --git a/ui/app/(prowler)/attack-paths/(workflow)/_components/workflow-attack-paths.tsx b/ui/app/(prowler)/attack-paths/(workflow)/_components/workflow-attack-paths.tsx index 9e1f3684ca..101f97d5b7 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/_components/workflow-attack-paths.tsx +++ b/ui/app/(prowler)/attack-paths/(workflow)/_components/workflow-attack-paths.tsx @@ -12,7 +12,7 @@ export const WorkflowAttackPaths = () => { const pathname = usePathname(); // Determine current step based on pathname - const isQueryBuilderStep = pathname.includes("query-builder"); + const isQueryBuilderStep = pathname.includes("/attack-paths"); const currentStep = isQueryBuilderStep ? 1 : 0; // 0-indexed diff --git a/ui/app/(prowler)/attack-paths/(workflow)/layout.tsx b/ui/app/(prowler)/attack-paths/(workflow)/layout.tsx deleted file mode 100644 index 8557ab5369..0000000000 --- a/ui/app/(prowler)/attack-paths/(workflow)/layout.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { Navbar } from "@/components/ui/nav-bar/navbar"; - -/** - * Workflow layout for Attack Paths - * Displays content with navbar - */ -export default function AttackPathsWorkflowLayout({ - children, -}: { - children: React.ReactNode; -}) { - return ( - <> - -
- {/* Content */} -
{children}
-
- - ); -} diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/graph-loading.test.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/graph-loading.test.tsx new file mode 100644 index 0000000000..a87fb546b2 --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/graph-loading.test.tsx @@ -0,0 +1,27 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { GraphLoading } from "./graph-loading"; + +describe("GraphLoading", () => { + it("uses the provider wizard loading pattern", () => { + render(); + + expect(screen.getByTestId("graph-loading")).toHaveClass( + "flex", + "min-h-[320px]", + "items-center", + "justify-center", + "gap-4", + "text-center", + ); + expect(screen.getByLabelText("Loading")).toHaveClass( + "size-6", + "animate-spin", + ); + expect(screen.getByText("Loading Attack Paths graph...")).toHaveClass( + "text-muted-foreground", + "text-sm", + ); + }); +}); diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/query-parameters-form.test.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/query-parameters-form.test.tsx new file mode 100644 index 0000000000..ac2b81be7f --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/query-parameters-form.test.tsx @@ -0,0 +1,73 @@ +import { render, screen } from "@testing-library/react"; +import { FormProvider, useForm } from "react-hook-form"; +import { describe, expect, it } from "vitest"; + +import type { AttackPathQuery } from "@/types/attack-paths"; + +import { QueryParametersForm } from "./query-parameters-form"; + +const mockQuery: AttackPathQuery = { + type: "attack-paths-scans", + id: "query-with-string-parameter", + attributes: { + name: "Query With String Parameter", + short_description: "Requires a tag key", + description: "Returns buckets filtered by tag", + provider: "aws", + attribution: null, + parameters: [ + { + name: "tag_key", + label: "Tag key", + data_type: "string", + description: "Tag key to filter the S3 bucket.", + placeholder: "DataClassification", + required: true, + }, + ], + }, +}; + +function TestForm() { + const form = useForm({ + defaultValues: { + tag_key: "", + }, + }); + + return ( + + + + ); +} + +describe("QueryParametersForm", () => { + it("uses the field description as the placeholder instead of rendering helper text below", () => { + // Given + render(); + + // When + const input = screen.getByRole("textbox", { name: /tag key/i }); + + // Then + expect(input).toHaveAttribute("data-slot", "input"); + expect(input).toHaveAttribute( + "placeholder", + "Tag key to filter the S3 bucket.", + ); + expect(screen.getByTestId("query-parameters-grid")).toHaveClass( + "grid", + "grid-cols-1", + "md:grid-cols-2", + ); + expect(screen.getByText("Tag key")).toHaveClass( + "text-text-neutral-tertiary", + "text-xs", + "font-medium", + ); + expect( + screen.queryByText("Tag key to filter the S3 bucket."), + ).not.toBeInTheDocument(); + }); +}); diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/query-selector.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/query-selector.tsx index f7d36c9a68..57319449f4 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/query-selector.tsx +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/query-selector.tsx @@ -27,7 +27,7 @@ export const QuerySelector = ({ return ( { - setSelectedPageSize(value); - - const params = new URLSearchParams(searchParams); - - // Preserve scanId if it exists - const scanId = searchParams.get("scanId"); - - params.set("scanPageSize", value); - params.set("scanPage", "1"); - - // Ensure that scanId is preserved - if (scanId) params.set("scanId", scanId); - - router.push(`${pathname}?${params.toString()}`); - }} - > - - - - - {PAGE_SIZE_OPTIONS.map((size) => ( - - {size} - - ))} - - - -
- Page {currentPage} of {totalPages} -
-
- isFirstPage && e.preventDefault()} - > -
- - )} - - )} - - + ); }; diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_hooks/use-query-builder.test.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_hooks/use-query-builder.test.tsx new file mode 100644 index 0000000000..79f9f594f1 --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_hooks/use-query-builder.test.tsx @@ -0,0 +1,80 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import type { AttackPathQuery } from "@/types/attack-paths"; + +import { useQueryBuilder } from "./use-query-builder"; + +const mockQueries: AttackPathQuery[] = [ + { + type: "attack-paths-scans", + id: "query-with-parameters", + attributes: { + name: "Query With Parameters", + short_description: "Requires a principal ARN", + description: "Returns paths for a principal", + provider: "aws", + attribution: null, + parameters: [ + { + name: "principal_arn", + label: "Principal ARN", + data_type: "string", + description: "Principal to analyze", + required: true, + }, + ], + }, + }, + { + type: "attack-paths-scans", + id: "query-without-parameters", + attributes: { + name: "Query Without Parameters", + short_description: "Returns all privileged paths", + description: "Returns all privileged paths", + provider: "aws", + attribution: null, + parameters: [], + }, + }, +]; + +describe("useQueryBuilder", () => { + it("drops stale parameter values when switching to a query without parameters", async () => { + // Given + const { result } = renderHook(() => useQueryBuilder(mockQueries)); + + act(() => { + result.current.handleQueryChange("query-with-parameters"); + }); + + await waitFor(() => { + expect(result.current.selectedQueryData?.id).toBe( + "query-with-parameters", + ); + }); + + act(() => { + result.current.form.setValue( + "principal_arn", + "arn:aws:iam::123:user/alex", + ); + }); + + expect(result.current.getQueryParameters()).toEqual({ + principal_arn: "arn:aws:iam::123:user/alex", + }); + + // When + act(() => { + result.current.handleQueryChange("query-without-parameters"); + }); + + // Then + expect(result.current.selectedQueryData?.id).toBe( + "query-without-parameters", + ); + expect(result.current.getQueryParameters()).toBeUndefined(); + }); +}); diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_hooks/use-wizard-state.ts b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_hooks/use-wizard-state.ts index 787cb56617..fc1aa8bc9c 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_hooks/use-wizard-state.ts +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_hooks/use-wizard-state.ts @@ -44,21 +44,21 @@ export const useWizardState = () => { // Derive current step from URL path const currentStep: 1 | 2 = typeof window !== "undefined" - ? window.location.pathname.includes("query-builder") + ? window.location.pathname.includes("attack-paths") ? 2 : 1 : 1; const goToSelectScan = useCallback(() => { store.setCurrentStep(1); - router.push("/attack-paths/select-scan"); + router.push("/attack-paths"); }, [router, store]); const goToQueryBuilder = useCallback( (scanId: string) => { store.setSelectedScanId(scanId); store.setCurrentStep(2); - router.push(`/attack-paths/query-builder?scanId=${scanId}`); + router.push(`/attack-paths?scanId=${scanId}`); }, [router, store], ); 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 new file mode 100644 index 0000000000..286b0c0ca8 --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.tsx @@ -0,0 +1,716 @@ +"use client"; + +import { ArrowLeft, Info, Maximize2, X } from "lucide-react"; +import Link from "next/link"; +import { useSearchParams } from "next/navigation"; +import { Suspense, useEffect, useRef, useState } from "react"; +import { FormProvider } from "react-hook-form"; + +import { + executeQuery, + getAttackPathScans, + getAvailableQueries, +} from "@/actions/attack-paths"; +import { adaptQueryResultToGraphData } from "@/actions/attack-paths/query-result.adapter"; +import { AutoRefresh } from "@/components/scans"; +import { + Alert, + AlertDescription, + AlertTitle, + Button, + Card, + CardContent, +} from "@/components/shadcn"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/shadcn/dialog"; +import { useToast } from "@/components/ui"; +import type { + AttackPathQuery, + AttackPathQueryError, + AttackPathScan, + GraphNode, +} from "@/types/attack-paths"; + +import { + AttackPathGraph, + ExecuteButton, + GraphControls, + GraphLegend, + GraphLoading, + NodeDetailContent, + QueryParametersForm, + QuerySelector, + ScanListTable, +} from "./_components"; +import type { AttackPathGraphRef } from "./_components/graph/attack-path-graph"; +import { useGraphState } from "./_hooks/use-graph-state"; +import { useQueryBuilder } from "./_hooks/use-query-builder"; +import { exportGraphAsSVG, formatNodeLabel } from "./_lib"; + +/** + * Attack Paths + * Allows users to select a scan, build a query, and visualize the attack path graph + */ +export default function AttackPathsPage() { + const searchParams = useSearchParams(); + const scanId = searchParams.get("scanId"); + const graphState = useGraphState(); + const { toast } = useToast(); + + const [scansLoading, setScansLoading] = useState(true); + const [scans, setScans] = useState([]); + const [queriesLoading, setQueriesLoading] = useState(true); + const [queriesError, setQueriesError] = useState(null); + const [isFullscreenOpen, setIsFullscreenOpen] = useState(false); + const graphRef = useRef(null); + const fullscreenGraphRef = useRef(null); + const hasResetRef = useRef(false); + const nodeDetailsRef = useRef(null); + const graphContainerRef = useRef(null); + + const [queries, setQueries] = useState([]); + + // Use custom hook for query builder form state and validation + const queryBuilder = useQueryBuilder(queries); + + // Reset graph state when component mounts + useEffect(() => { + if (!hasResetRef.current) { + hasResetRef.current = true; + graphState.resetGraph(); + } + }, [graphState]); + + // Load available scans on mount + useEffect(() => { + const loadScans = async () => { + setScansLoading(true); + try { + const scansData = await getAttackPathScans(); + if (scansData?.data) { + setScans(scansData.data); + } else { + setScans([]); + } + } catch (error) { + console.error("Failed to load scans:", error); + setScans([]); + } finally { + setScansLoading(false); + } + }; + + loadScans(); + }, []); + + // Check if there's an executing scan for auto-refresh + const hasExecutingScan = scans.some( + (scan) => + scan.attributes.state === "executing" || + scan.attributes.state === "scheduled", + ); + + // Callback to refresh scans (used by AutoRefresh component) + const refreshScans = async () => { + try { + const scansData = await getAttackPathScans(); + if (scansData?.data) { + setScans(scansData.data); + } + } catch (error) { + console.error("Failed to refresh scans:", error); + } + }; + + // Load available queries on mount + useEffect(() => { + const loadQueries = async () => { + if (!scanId) { + setQueriesError("No scan selected"); + setQueriesLoading(false); + return; + } + + setQueriesLoading(true); + try { + const queriesData = await getAvailableQueries(scanId); + if (queriesData?.data) { + setQueries(queriesData.data); + setQueriesError(null); + } else { + setQueriesError("Failed to load available queries"); + toast({ + title: "Error", + description: "Failed to load queries for this scan", + variant: "destructive", + }); + } + } catch (error) { + const errorMsg = + error instanceof Error ? error.message : "Unknown error"; + setQueriesError(errorMsg); + toast({ + title: "Error", + description: "Failed to load queries", + variant: "destructive", + }); + } finally { + setQueriesLoading(false); + } + }; + + loadQueries(); + }, [scanId, toast]); + + const handleQueryChange = (queryId: string) => { + queryBuilder.handleQueryChange(queryId); + }; + + const showErrorToast = (title: string, description: string) => { + toast({ + title, + description, + variant: "destructive", + }); + }; + + const handleExecuteQuery = async () => { + if (!scanId || !queryBuilder.selectedQuery) { + showErrorToast("Error", "Please select both a scan and a query"); + return; + } + + // Validate form before executing query + const isValid = await queryBuilder.form.trigger(); + if (!isValid) { + showErrorToast( + "Validation Error", + "Please fill in all required parameters", + ); + return; + } + + graphState.startLoading(); + graphState.setError(null); + + try { + const parameters = queryBuilder.getQueryParameters() as Record< + string, + string | number | boolean + >; + const result = await executeQuery( + scanId, + queryBuilder.selectedQuery, + parameters, + ); + + if (result && "error" in result) { + const apiError = result as AttackPathQueryError; + graphState.resetGraph(); + + if (apiError.status === 404) { + graphState.resetGraph(); + showErrorToast("No data found", "The query returned no data"); + } else if (apiError.status === 403) { + graphState.setError("Not enough permissions to execute this query"); + showErrorToast( + "Error", + "Not enough permissions to execute this query", + ); + } else if (apiError.status >= 500) { + const serverDownMessage = + "Server is temporarily unavailable. Please try again in a few minutes."; + graphState.setError(serverDownMessage); + showErrorToast("Error", serverDownMessage); + } else { + graphState.setError(apiError.error); + showErrorToast("Error", apiError.error); + } + } else if (result?.data?.attributes) { + const graphData = adaptQueryResultToGraphData(result.data.attributes); + graphState.updateGraphData(graphData); + toast({ + title: "Success", + description: "Query executed successfully", + variant: "default", + }); + + // Scroll to graph after successful query execution + setTimeout(() => { + graphContainerRef.current?.scrollIntoView({ + behavior: "smooth", + block: "start", + }); + }, 100); + } else { + graphState.resetGraph(); + graphState.setError("Failed to execute query due to an unknown error"); + showErrorToast( + "Error", + "Failed to execute query due to an unknown error", + ); + } + } catch (error) { + const rawErrorMsg = + error instanceof Error ? error.message : "Failed to execute query"; + const errorMsg = rawErrorMsg.includes("Server Components render") + ? "Server is temporarily unavailable. Please try again in a few minutes." + : rawErrorMsg; + graphState.resetGraph(); + graphState.setError(errorMsg); + showErrorToast("Error", errorMsg); + } finally { + graphState.stopLoading(); + } + }; + + const handleNodeClick = (node: GraphNode) => { + // Enter filtered view showing only paths containing this node + graphState.enterFilteredView(node.id); + + // For findings, also scroll to the details section + const isFinding = node.labels.some((label) => + label.toLowerCase().includes("finding"), + ); + + if (isFinding) { + setTimeout(() => { + nodeDetailsRef.current?.scrollIntoView({ + behavior: "smooth", + block: "nearest", + }); + }, 100); + } + }; + + const handleBackToFullView = () => { + graphState.exitFilteredView(); + }; + + const handleCloseDetails = () => { + graphState.selectNode(null); + }; + + const handleGraphExport = (svgElement: SVGSVGElement | null) => { + try { + if (svgElement) { + exportGraphAsSVG(svgElement, "attack-path-graph.svg"); + toast({ + title: "Success", + description: "Graph exported as SVG", + variant: "default", + }); + } else { + throw new Error("Could not find graph element"); + } + } catch (error) { + toast({ + title: "Error", + description: + error instanceof Error ? error.message : "Failed to export graph", + variant: "destructive", + }); + } + }; + + return ( +
+ {/* Auto-refresh scans when there's an executing scan */} + + + {/* Header */} +
+

+ Attack Paths +

+

+ Select a scan, build a query, and visualize Attack Paths in your + infrastructure. +

+

+ Scans can be selected when data is available. A new scan does not + interrupt access to existing data. +

+
+ + {scansLoading ? ( +
+

Loading scans...

+
+ ) : scans.length === 0 ? ( + + + No scans available + + + You need to run a scan before you can analyze attack paths.{" "} + + Go to Scan Jobs + + + + + ) : ( + <> + {/* Scans Table */} + Loading scans...
}> + + + + {/* Query Builder Section - shown only after selecting a scan */} + {scanId && ( +
+ {queriesLoading ? ( +

Loading queries...

+ ) : queriesError ? ( +

+ {queriesError} +

+ ) : ( + <> + + + + {queryBuilder.selectedQueryData && ( +
+
+ +

+ { + queryBuilder.selectedQueryData.attributes + .description + } +

+
+ {queryBuilder.selectedQueryData.attributes + .attribution && ( +

+ Source:{" "} + + { + queryBuilder.selectedQueryData.attributes + .attribution.text + } + +

+ )} +
+ )} + + {queryBuilder.selectedQuery && ( + + )} +
+ +
+ +
+ + {graphState.error && ( +
+ {graphState.error} +
+ )} + + )} +
+ )} + + {/* Graph Visualization (Full Width) */} + {(graphState.loading || + (graphState.data && + graphState.data.nodes && + graphState.data.nodes.length > 0)) && ( +
+ {graphState.loading ? ( + + ) : graphState.data && + graphState.data.nodes && + graphState.data.nodes.length > 0 ? ( + <> + {/* Info message and controls */} +
+ {graphState.isFilteredView ? ( +
+ +
+ + + Showing paths for:{" "} + + {graphState.filteredNode?.properties?.name || + graphState.filteredNode?.properties?.id || + "Selected node"} + + +
+
+ ) : ( +
+ + + Click on any node to filter and view its connected + paths + +
+ )} + + {/* Graph controls and fullscreen button together */} +
+ graphRef.current?.zoomIn()} + onZoomOut={() => graphRef.current?.zoomOut()} + onFitToScreen={() => graphRef.current?.resetZoom()} + onExport={() => + handleGraphExport( + graphRef.current?.getSVGElement() || null, + ) + } + /> + + {/* Fullscreen button */} +
+ + + + + + + Fullscreen graph view + +
+ + fullscreenGraphRef.current?.zoomIn() + } + onZoomOut={() => + fullscreenGraphRef.current?.zoomOut() + } + onFitToScreen={() => + fullscreenGraphRef.current?.resetZoom() + } + onExport={() => + handleGraphExport( + fullscreenGraphRef.current?.getSVGElement() || + null, + ) + } + /> +
+
+
+ +
+ {/* Node Detail Panel - Side by side */} + {graphState.selectedNode && ( +
+ + +
+

+ Node Details +

+ +
+

+ {graphState.selectedNode?.labels.some( + (label) => + label + .toLowerCase() + .includes("finding"), + ) + ? graphState.selectedNode?.properties + ?.check_title || + graphState.selectedNode?.properties + ?.id || + "Unknown Finding" + : graphState.selectedNode?.properties + ?.name || + graphState.selectedNode?.properties + ?.id || + "Unknown Resource"} +

+
+
+

+ Type +

+

+ {graphState.selectedNode?.labels + .map(formatNodeLabel) + .join(", ")} +

+
+
+
+
+
+ )} +
+
+
+
+
+
+ + {/* Graph in the middle */} +
+ +
+ + {/* Legend below */} +
+ +
+ + ) : null} +
+ )} + + {/* Node Detail Panel - Below Graph */} + {graphState.selectedNode && graphState.data && ( +
+
+
+

Node Details

+

+ {String( + graphState.selectedNode.labels.some((label) => + label.toLowerCase().includes("finding"), + ) + ? graphState.selectedNode.properties?.check_title || + graphState.selectedNode.properties?.id || + "Unknown Finding" + : graphState.selectedNode.properties?.name || + graphState.selectedNode.properties?.id || + "Unknown Resource", + )} +

+
+
+ {graphState.selectedNode.labels.some((label) => + label.toLowerCase().includes("finding"), + ) && ( + + )} + +
+
+ + +
+ )} + + )} + + ); +} diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/page.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/page.tsx index 22f931d6d6..5788a3f427 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/page.tsx +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/page.tsx @@ -1,712 +1,31 @@ -"use client"; +import { redirect } from "next/navigation"; -import { ArrowLeft, Info, Maximize2, X } from "lucide-react"; -import Link from "next/link"; -import { useSearchParams } from "next/navigation"; -import { Suspense, useCallback, useEffect, useRef, useState } from "react"; -import { FormProvider } from "react-hook-form"; +import { SearchParamsProps } from "@/types"; -import { - executeQuery, - getAttackPathScans, - getAvailableQueries, -} from "@/actions/attack-paths"; -import { adaptQueryResultToGraphData } from "@/actions/attack-paths/query-result.adapter"; -import { AutoRefresh } from "@/components/scans"; -import { - Alert, - AlertDescription, - AlertTitle, - Button, - Card, - CardContent, -} from "@/components/shadcn"; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogTrigger, - useToast, -} from "@/components/ui"; -import type { - AttackPathQuery, - AttackPathQueryError, - AttackPathScan, - GraphNode, -} from "@/types/attack-paths"; +const buildQueryString = (searchParams: SearchParamsProps) => { + const params = new URLSearchParams(); -import { - AttackPathGraph, - ExecuteButton, - GraphControls, - GraphLegend, - GraphLoading, - NodeDetailContent, - QueryParametersForm, - QuerySelector, - ScanListTable, -} from "./_components"; -import type { AttackPathGraphRef } from "./_components/graph/attack-path-graph"; -import { useGraphState } from "./_hooks/use-graph-state"; -import { useQueryBuilder } from "./_hooks/use-query-builder"; -import { exportGraphAsSVG, formatNodeLabel } from "./_lib"; - -/** - * Attack Paths Analysis - * Allows users to select a scan, build a query, and visualize the Attack Paths graph - */ -export default function AttackPathAnalysisPage() { - const searchParams = useSearchParams(); - const scanId = searchParams.get("scanId"); - const graphState = useGraphState(); - const { toast } = useToast(); - - const [scansLoading, setScansLoading] = useState(true); - const [scans, setScans] = useState([]); - const [queriesLoading, setQueriesLoading] = useState(true); - const [queriesError, setQueriesError] = useState(null); - const [isFullscreenOpen, setIsFullscreenOpen] = useState(false); - const graphRef = useRef(null); - const fullscreenGraphRef = useRef(null); - const hasResetRef = useRef(false); - const nodeDetailsRef = useRef(null); - const graphContainerRef = useRef(null); - - const [queries, setQueries] = useState([]); - - // Use custom hook for query builder form state and validation - const queryBuilder = useQueryBuilder(queries); - - // Reset graph state when component mounts - useEffect(() => { - if (!hasResetRef.current) { - hasResetRef.current = true; - graphState.resetGraph(); - } - }, [graphState]); - - // Load available scans on mount - useEffect(() => { - const loadScans = async () => { - setScansLoading(true); - try { - const scansData = await getAttackPathScans(); - if (scansData?.data) { - setScans(scansData.data); - } else { - setScans([]); - } - } catch (error) { - console.error("Failed to load scans:", error); - setScans([]); - } finally { - setScansLoading(false); - } - }; - - loadScans(); - }, []); - - // Check if there's an executing scan for auto-refresh - const hasExecutingScan = scans.some( - (scan) => - scan.attributes.state === "executing" || - scan.attributes.state === "scheduled", - ); - - // Callback to refresh scans (used by AutoRefresh component) - const refreshScans = useCallback(async () => { - try { - const scansData = await getAttackPathScans(); - if (scansData?.data) { - setScans(scansData.data); - } - } catch (error) { - console.error("Failed to refresh scans:", error); - } - }, []); - - // Load available queries on mount - useEffect(() => { - const loadQueries = async () => { - if (!scanId) { - setQueriesError("No scan selected"); - setQueriesLoading(false); - return; - } - - setQueriesLoading(true); - try { - const queriesData = await getAvailableQueries(scanId); - if (queriesData?.data) { - setQueries(queriesData.data); - setQueriesError(null); - } else { - setQueriesError("Failed to load available queries"); - toast({ - title: "Error", - description: "Failed to load queries for this scan", - variant: "destructive", - }); - } - } catch (error) { - const errorMsg = - error instanceof Error ? error.message : "Unknown error"; - setQueriesError(errorMsg); - toast({ - title: "Error", - description: "Failed to load queries", - variant: "destructive", - }); - } finally { - setQueriesLoading(false); - } - }; - - loadQueries(); - }, [scanId, toast]); - - const handleQueryChange = (queryId: string) => { - queryBuilder.handleQueryChange(queryId); - }; - - const showErrorToast = (title: string, description: string) => { - toast({ - title, - description, - variant: "destructive", - }); - }; - - const handleExecuteQuery = async () => { - if (!scanId || !queryBuilder.selectedQuery) { - showErrorToast("Error", "Please select both a scan and a query"); - return; + for (const [key, value] of Object.entries(searchParams)) { + if (Array.isArray(value)) { + value.forEach((item) => params.append(key, item)); + continue; } - // Validate form before executing query - const isValid = await queryBuilder.form.trigger(); - if (!isValid) { - showErrorToast( - "Validation Error", - "Please fill in all required parameters", - ); - return; + if (typeof value === "string") { + params.set(key, value); } + } - graphState.startLoading(); - graphState.setError(null); + return params.toString(); +}; - try { - const parameters = queryBuilder.getQueryParameters() as Record< - string, - string | number | boolean - >; - const result = await executeQuery( - scanId, - queryBuilder.selectedQuery, - parameters, - ); +export default async function AttackPathsQueryBuilderRedirectPage({ + searchParams, +}: { + searchParams: Promise; +}) { + const resolvedSearchParams = await searchParams; + const queryString = buildQueryString(resolvedSearchParams); - if (result && "error" in result) { - const apiError = result as AttackPathQueryError; - graphState.resetGraph(); - - if (apiError.status === 404) { - graphState.setError("No data found"); - showErrorToast("No data found", "The query returned no data"); - } else if (apiError.status === 403) { - graphState.setError("Not enough permissions to execute this query"); - showErrorToast( - "Error", - "Not enough permissions to execute this query", - ); - } else if (apiError.status >= 500) { - const serverDownMessage = - "Server is temporarily unavailable. Please try again in a few minutes."; - graphState.setError(serverDownMessage); - showErrorToast("Error", serverDownMessage); - } else { - graphState.setError(apiError.error); - showErrorToast("Error", apiError.error); - } - } else if (result?.data?.attributes) { - const graphData = adaptQueryResultToGraphData(result.data.attributes); - graphState.updateGraphData(graphData); - toast({ - title: "Success", - description: "Query executed successfully", - variant: "default", - }); - - // Scroll to graph after successful query execution - setTimeout(() => { - graphContainerRef.current?.scrollIntoView({ - behavior: "smooth", - block: "start", - }); - }, 100); - } else { - graphState.resetGraph(); - graphState.setError("Failed to execute query due to an unknown error"); - showErrorToast( - "Error", - "Failed to execute query due to an unknown error", - ); - } - } catch (error) { - const rawErrorMsg = - error instanceof Error ? error.message : "Failed to execute query"; - const errorMsg = rawErrorMsg.includes("Server Components render") - ? "Server is temporarily unavailable. Please try again in a few minutes." - : rawErrorMsg; - graphState.resetGraph(); - graphState.setError(errorMsg); - showErrorToast("Error", errorMsg); - } finally { - graphState.stopLoading(); - } - }; - - const handleNodeClick = (node: GraphNode) => { - // Enter filtered view showing only paths containing this node - graphState.enterFilteredView(node.id); - - // For findings, also scroll to the details section - const isFinding = node.labels.some((label) => - label.toLowerCase().includes("finding"), - ); - - if (isFinding) { - setTimeout(() => { - nodeDetailsRef.current?.scrollIntoView({ - behavior: "smooth", - block: "nearest", - }); - }, 100); - } - }; - - const handleBackToFullView = () => { - graphState.exitFilteredView(); - }; - - const handleCloseDetails = () => { - graphState.selectNode(null); - }; - - const handleGraphExport = (svgElement: SVGSVGElement | null) => { - try { - if (svgElement) { - exportGraphAsSVG(svgElement, "attack-path-graph.svg"); - toast({ - title: "Success", - description: "Graph exported as SVG", - variant: "default", - }); - } else { - throw new Error("Could not find graph element"); - } - } catch (error) { - toast({ - title: "Error", - description: - error instanceof Error ? error.message : "Failed to export graph", - variant: "destructive", - }); - } - }; - - return ( -
- {/* Auto-refresh scans when there's an executing scan */} - - - {/* Header */} -
-

- Attack Paths Analysis -

-

- Select a scan, build a query, and visualize Attack Paths in your - infrastructure. -

-

- Scans can be selected when data is available. A new scan does not - interrupt access to existing data. -

-
- - {scansLoading ? ( -
-

Loading scans...

-
- ) : scans.length === 0 ? ( - - - No scans available - - - You need to run a scan before you can analyze attack paths.{" "} - - Go to Scan Jobs - - - - - ) : ( - <> - {/* Scans Table */} - Loading scans...
}> - - - - {/* Query Builder Section - shown only after selecting a scan */} - {scanId && ( -
- {queriesLoading ? ( -

Loading queries...

- ) : queriesError ? ( -

- {queriesError} -

- ) : ( - <> - - - - {queryBuilder.selectedQueryData && ( -
-

- { - queryBuilder.selectedQueryData.attributes - .description - } -

- {queryBuilder.selectedQueryData.attributes - .attribution && ( -

- Source:{" "} - - { - queryBuilder.selectedQueryData.attributes - .attribution.text - } - -

- )} -
- )} - - {queryBuilder.selectedQuery && ( - - )} -
- -
- -
- - {graphState.error && ( -
- {graphState.error} -
- )} - - )} -
- )} - - {/* Graph Visualization (Full Width) */} - {(graphState.loading || - (graphState.data && - graphState.data.nodes && - graphState.data.nodes.length > 0)) && ( -
- {graphState.loading ? ( - - ) : graphState.data && - graphState.data.nodes && - graphState.data.nodes.length > 0 ? ( - <> - {/* Info message and controls */} -
- {graphState.isFilteredView ? ( -
- -
- - - Showing paths for:{" "} - - {graphState.filteredNode?.properties?.name || - graphState.filteredNode?.properties?.id || - "Selected node"} - - -
-
- ) : ( -
- - - Click on any node to filter and view its connected - paths - -
- )} - - {/* Graph controls and fullscreen button together */} -
- graphRef.current?.zoomIn()} - onZoomOut={() => graphRef.current?.zoomOut()} - onFitToScreen={() => graphRef.current?.resetZoom()} - onExport={() => - handleGraphExport( - graphRef.current?.getSVGElement() || null, - ) - } - /> - - {/* Fullscreen button */} -
- - - - - - - - Graph Fullscreen View - - -
- - fullscreenGraphRef.current?.zoomIn() - } - onZoomOut={() => - fullscreenGraphRef.current?.zoomOut() - } - onFitToScreen={() => - fullscreenGraphRef.current?.resetZoom() - } - onExport={() => - handleGraphExport( - fullscreenGraphRef.current?.getSVGElement() || - null, - ) - } - /> -
-
-
- -
- {/* Node Detail Panel - Side by side */} - {graphState.selectedNode && ( -
- - -
-

- Node Details -

- -
-

- {graphState.selectedNode?.labels.some( - (label) => - label - .toLowerCase() - .includes("finding"), - ) - ? graphState.selectedNode?.properties - ?.check_title || - graphState.selectedNode?.properties - ?.id || - "Unknown Finding" - : graphState.selectedNode?.properties - ?.name || - graphState.selectedNode?.properties - ?.id || - "Unknown Resource"} -

-
-
-

- Type -

-

- {graphState.selectedNode?.labels - .map(formatNodeLabel) - .join(", ")} -

-
-
-
-
-
- )} -
-
-
-
-
-
- - {/* Graph in the middle */} -
- -
- - {/* Legend below */} -
- -
- - ) : null} -
- )} - - {/* Node Detail Panel - Below Graph */} - {graphState.selectedNode && graphState.data && ( -
-
-
-

Node Details

-

- {String( - graphState.selectedNode.labels.some((label) => - label.toLowerCase().includes("finding"), - ) - ? graphState.selectedNode.properties?.check_title || - graphState.selectedNode.properties?.id || - "Unknown Finding" - : graphState.selectedNode.properties?.name || - graphState.selectedNode.properties?.id || - "Unknown Resource", - )} -

-
-
- {graphState.selectedNode.labels.some((label) => - label.toLowerCase().includes("finding"), - ) && ( - - )} - -
-
- - -
- )} - - )} - - ); + redirect(queryString ? `/attack-paths?${queryString}` : "/attack-paths"); } diff --git a/ui/app/(prowler)/attack-paths/layout.tsx b/ui/app/(prowler)/attack-paths/layout.tsx new file mode 100644 index 0000000000..5ff93faab2 --- /dev/null +++ b/ui/app/(prowler)/attack-paths/layout.tsx @@ -0,0 +1,13 @@ +import { ContentLayout } from "@/components/ui"; + +export default function AttackPathsLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + {children} + + ); +} diff --git a/ui/app/(prowler)/attack-paths/page.tsx b/ui/app/(prowler)/attack-paths/page.tsx index 3fe92b08f8..03058704b1 100644 --- a/ui/app/(prowler)/attack-paths/page.tsx +++ b/ui/app/(prowler)/attack-paths/page.tsx @@ -1,9 +1 @@ -import { redirect } from "next/navigation"; - -/** - * Landing page for Attack Paths feature - * Redirects to the integrated attack path analysis view - */ -export default function AttackPathsPage() { - redirect("/attack-paths/query-builder"); -} +export { default } from "./(workflow)/query-builder/attack-paths-page"; diff --git a/ui/app/(prowler)/providers/page.tsx b/ui/app/(prowler)/providers/page.tsx index 190980f0f5..3520670c32 100644 --- a/ui/app/(prowler)/providers/page.tsx +++ b/ui/app/(prowler)/providers/page.tsx @@ -71,9 +71,11 @@ const ProvidersTableFallback = () => {
{/* ProviderTypeSelector */} - {/* Account filter */} + {/* Organizations filter */} - {/* Connection Status filter */} + {/* Account Groups filter */} + + {/* Status filter */} {/* Action buttons */}
diff --git a/ui/components/compliance/compliance-header/compliance-scan-info.tsx b/ui/components/compliance/compliance-header/compliance-scan-info.tsx index 086a9183ed..76bf8e46be 100644 --- a/ui/components/compliance/compliance-header/compliance-scan-info.tsx +++ b/ui/components/compliance/compliance-header/compliance-scan-info.tsx @@ -20,24 +20,23 @@ interface ComplianceScanInfoProps { export const ComplianceScanInfo = ({ scan }: ComplianceScanInfoProps) => { return ( -
-
+
+
- -
+ +
-

+

{scan.attributes.name || "- -"}

diff --git a/ui/components/compliance/compliance-header/scan-selector.tsx b/ui/components/compliance/compliance-header/scan-selector.tsx index 160b27f3ed..a330c35be9 100644 --- a/ui/components/compliance/compliance-header/scan-selector.tsx +++ b/ui/components/compliance/compliance-header/scan-selector.tsx @@ -39,7 +39,7 @@ export const ScanSelector = ({ } }} > - + {selectedScan ? ( @@ -48,9 +48,13 @@ export const ScanSelector = ({ )} - + {scans.map((scan) => ( - + ))} diff --git a/ui/components/providers/index.ts b/ui/components/providers/index.ts index 6ae4b6bece..71a18944ef 100644 --- a/ui/components/providers/index.ts +++ b/ui/components/providers/index.ts @@ -4,7 +4,6 @@ export * from "./credentials-update-info"; export * from "./forms/delete-form"; export * from "./link-to-scans"; export * from "./muted-findings-config-button"; -export * from "./provider-info"; export * from "./providers-accounts-table"; export * from "./providers-filters"; export * from "./radio-card"; diff --git a/ui/components/providers/provider-info.tsx b/ui/components/providers/provider-info.tsx deleted file mode 100644 index 75ebe018b1..0000000000 --- a/ui/components/providers/provider-info.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { ProviderType } from "@/types"; - -import { getProviderLogo } from "../ui/entities"; - -interface ProviderInfoProps { - provider: ProviderType; - providerAlias: string | null; - providerUID?: string; -} - -export const ProviderInfo = ({ - provider, - providerAlias, - providerUID, -}: ProviderInfoProps) => { - return ( -
-
-
{getProviderLogo(provider)}
-
- - {providerAlias || providerUID} - - {providerUID && ( - - UID: {providerUID} - - )} -
-
-
- ); -}; diff --git a/ui/components/providers/table/column-providers.tsx b/ui/components/providers/table/column-providers.tsx index 095722a59e..83c9a80589 100644 --- a/ui/components/providers/table/column-providers.tsx +++ b/ui/components/providers/table/column-providers.tsx @@ -9,62 +9,33 @@ import { ShieldOff, } from "lucide-react"; -import { Badge } from "@/components/shadcn/badge/badge"; import { Checkbox } from "@/components/shadcn/checkbox/checkbox"; -import { DateWithTime } from "@/components/ui/entities"; +import { CodeSnippet } from "@/components/ui/code-snippet/code-snippet"; +import { DateWithTime, EntityInfo } from "@/components/ui/entities"; import { DataTableColumnHeader } from "@/components/ui/table"; import { DataTableExpandAllToggle } from "@/components/ui/table/data-table-expand-all-toggle"; import { DataTableExpandableCell } from "@/components/ui/table/data-table-expandable-cell"; import { isProvidersOrganizationRow, PROVIDERS_GROUP_KIND, - ProvidersOrganizationRow, ProvidersProviderRow, ProvidersTableRow, } from "@/types/providers-table"; import { LinkToScans } from "../link-to-scans"; -import { ProviderInfo } from "../provider-info"; import { DataTableRowActions } from "./data-table-row-actions"; interface GroupNameChipsProps { groupNames?: string[]; } -interface OrganizationCellProps { - organization: ProvidersOrganizationRow; - selectionLabel?: string; -} - -const OrganizationCell = ({ - organization, - selectionLabel, -}: OrganizationCellProps) => { +const OrganizationIcon = ({ groupKind }: { groupKind: string }) => { const Icon = - organization.groupKind === PROVIDERS_GROUP_KIND.ORGANIZATION - ? Building2 - : FolderTree; + groupKind === PROVIDERS_GROUP_KIND.ORGANIZATION ? Building2 : FolderTree; return ( -
-
- -
-
-
- {organization.name} - {selectionLabel && ( - - ({selectionLabel}) - - )} -
- {organization.externalId && ( - - UID: {organization.externalId} - - )} -
+
+
); }; @@ -191,9 +162,12 @@ export function getColumnProviders( hideChildIcon checkboxSlot={checkboxSlot} > - } + entityAlias={row.original.name} + entityId={row.original.externalId ?? undefined} + badge={getSelectionLabel(row)} + showCopyAction /> ); @@ -207,10 +181,10 @@ export function getColumnProviders( isExpanded={isExpanded} checkboxSlot={checkboxSlot} > - ); @@ -364,9 +338,7 @@ export function GroupNameChips({ groupNames }: GroupNameChipsProps) { return (
{groupNames.map((name, index) => ( - - {name} - + ))}
); diff --git a/ui/components/ui/breadcrumbs/breadcrumb-navigation.tsx b/ui/components/ui/breadcrumbs/breadcrumb-navigation.tsx index 02740bae7a..2b8436e505 100644 --- a/ui/components/ui/breadcrumbs/breadcrumb-navigation.tsx +++ b/ui/components/ui/breadcrumbs/breadcrumb-navigation.tsx @@ -107,7 +107,7 @@ export function BreadcrumbNavigation({ }; const renderTitleWithIcon = (titleText: string, isLink: boolean = false) => ( - <> +
{typeof icon === "string" ? ( {titleText} - +
); // Determine which breadcrumbs to use diff --git a/ui/components/ui/code-snippet/code-snippet.tsx b/ui/components/ui/code-snippet/code-snippet.tsx index deea7481e4..7fefa8b552 100644 --- a/ui/components/ui/code-snippet/code-snippet.tsx +++ b/ui/components/ui/code-snippet/code-snippet.tsx @@ -80,7 +80,7 @@ export const CodeSnippet = ({ return (
diff --git a/ui/components/ui/entities/entity-info.tsx b/ui/components/ui/entities/entity-info.tsx index b816416801..410cf9d086 100644 --- a/ui/components/ui/entities/entity-info.tsx +++ b/ui/components/ui/entities/entity-info.tsx @@ -1,5 +1,7 @@ "use client"; +import { ReactNode } from "react"; + import { Tooltip, TooltipContent, @@ -11,73 +13,67 @@ import type { ProviderType } from "@/types"; import { getProviderLogo } from "./get-provider-logo"; interface EntityInfoProps { - cloudProvider: ProviderType; + cloudProvider?: ProviderType; + icon?: ReactNode; entityAlias?: string; entityId?: string; - snippetWidth?: string; - showConnectionStatus?: boolean; - maxWidth?: string; + badge?: string; showCopyAction?: boolean; + /** @deprecated No longer used — layout handles overflow naturally */ + maxWidth?: string; + /** @deprecated No longer used */ + showConnectionStatus?: boolean; + /** @deprecated No longer used */ + snippetWidth?: string; } export const EntityInfo = ({ cloudProvider, + icon, entityAlias, entityId, - showConnectionStatus = false, - maxWidth = "w-[120px]", + badge, showCopyAction = true, }: EntityInfoProps) => { const canCopy = Boolean(entityId && showCopyAction); + const renderedIcon = + icon ?? (cloudProvider ? getProviderLogo(cloudProvider) : null); return ( -
-
- {getProviderLogo(cloudProvider)} - {showConnectionStatus && ( - - - - - Connected - - )} -
-
- {entityAlias ? ( - - -

- {entityAlias} -

-
- {entityAlias} -
- ) : ( - - -

- - -

-
- No alias -
- )} - {entityId && ( -
+
+
+ {renderedIcon &&
{renderedIcon}
} +
+
-

- {entityId} -

+ + {entityAlias || entityId || "-"} +
- {entityId} + + {entityAlias || entityId || "No alias"} +
- {canCopy && ( - + {badge && ( + + ({badge}) + )}
- )} + {entityId && ( +
+ + UID: + + +
+ )} +
);