From 15cb87534c462f5ed102e41cd72d4f02731761e8 Mon Sep 17 00:00:00 2001 From: Alan Buscaglia Date: Fri, 28 Nov 2025 17:05:35 +0100 Subject: [PATCH] feat(attack-paths): apply Scope Rule pattern for feature-local organization (#9270) Co-authored-by: Claude --- .env | 1 - ui/.husky/pre-commit | 13 +- ui/CHANGELOG.md | 1 + ui/actions/attack-paths/index.ts | 4 + ui/actions/attack-paths/queries.adapter.ts | 55 + ui/actions/attack-paths/queries.ts | 97 ++ .../attack-paths/query-result.adapter.ts | 164 +++ ui/actions/attack-paths/scans.adapter.ts | 89 ++ ui/actions/attack-paths/scans.ts | 69 ++ .../(workflow)/_components/index.ts | 2 + .../(workflow)/_components/vertical-steps.tsx | 299 ++++++ .../_components/workflow-attack-paths.tsx | 49 + .../attack-paths/(workflow)/layout.tsx | 21 + .../_components/execute-button.tsx | 34 + .../_components/graph/attack-path-graph.tsx | 962 ++++++++++++++++++ .../_components/graph/graph-controls.tsx | 93 ++ .../_components/graph/graph-legend.tsx | 497 +++++++++ .../_components/graph/graph-loading.tsx | 24 + .../query-builder/_components/graph/index.ts | 5 + .../query-builder/_components/index.ts | 7 + .../_components/node-detail/index.ts | 4 + .../node-detail/node-detail-panel.tsx | 132 +++ .../_components/node-detail/node-findings.tsx | 102 ++ .../_components/node-detail/node-overview.tsx | 109 ++ .../node-detail/node-relationships.tsx | 105 ++ .../node-detail/node-remediation.tsx | 83 ++ .../node-detail/node-resources.tsx | 85 ++ .../_components/query-parameters-form.tsx | 122 +++ .../_components/query-selector.tsx | 46 + .../_components/scan-list-table.tsx | 350 +++++++ .../_components/scan-status-badge.tsx | 59 ++ .../(workflow)/query-builder/_hooks/index.ts | 3 + .../query-builder/_hooks/use-graph-state.ts | 109 ++ .../query-builder/_hooks/use-query-builder.ts | 98 ++ .../query-builder/_hooks/use-wizard-state.ts | 91 ++ .../(workflow)/query-builder/_lib/export.ts | 145 +++ .../(workflow)/query-builder/_lib/format.ts | 25 + .../query-builder/_lib/graph-colors.ts | 137 +++ .../(workflow)/query-builder/_lib/index.ts | 14 + .../(workflow)/query-builder/page.tsx | 588 +++++++++++ ui/app/(prowler)/attack-paths/page.tsx | 9 + ui/app/(prowler)/findings/page.tsx | 85 +- .../findings/finding-details-sheet.tsx | 46 + ui/components/findings/index.ts | 1 + .../findings/table/column-findings.tsx | 19 +- .../findings/table/finding-detail.tsx | 68 +- ui/components/scans/auto-refresh.tsx | 14 +- .../ui/breadcrumbs/breadcrumb-navigation.tsx | 4 + ui/components/ui/sidebar/menu-item.tsx | 24 +- ui/components/ui/sidebar/menu.tsx | 1 + ui/components/ui/table/status-badge.tsx | 30 +- ui/dependency-log.json | 20 +- ui/lib/menu-list.ts | 14 + ui/package-lock.json | 27 + ui/package.json | 2 + ui/styles/globals.css | 3 + ui/types/attack-paths.ts | 245 +++++ ui/types/components.ts | 1 + 58 files changed, 5441 insertions(+), 65 deletions(-) create mode 100644 ui/actions/attack-paths/index.ts create mode 100644 ui/actions/attack-paths/queries.adapter.ts create mode 100644 ui/actions/attack-paths/queries.ts create mode 100644 ui/actions/attack-paths/query-result.adapter.ts create mode 100644 ui/actions/attack-paths/scans.adapter.ts create mode 100644 ui/actions/attack-paths/scans.ts create mode 100644 ui/app/(prowler)/attack-paths/(workflow)/_components/index.ts create mode 100644 ui/app/(prowler)/attack-paths/(workflow)/_components/vertical-steps.tsx create mode 100644 ui/app/(prowler)/attack-paths/(workflow)/_components/workflow-attack-paths.tsx create mode 100644 ui/app/(prowler)/attack-paths/(workflow)/layout.tsx create mode 100644 ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/execute-button.tsx create mode 100644 ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/attack-path-graph.tsx create mode 100644 ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/graph-controls.tsx create mode 100644 ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/graph-legend.tsx create mode 100644 ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/graph-loading.tsx create mode 100644 ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/index.ts create mode 100644 ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/index.ts create mode 100644 ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/node-detail/index.ts create mode 100644 ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/node-detail/node-detail-panel.tsx create mode 100644 ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/node-detail/node-findings.tsx create mode 100644 ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/node-detail/node-overview.tsx create mode 100644 ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/node-detail/node-relationships.tsx create mode 100644 ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/node-detail/node-remediation.tsx create mode 100644 ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/node-detail/node-resources.tsx create mode 100644 ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/query-parameters-form.tsx create mode 100644 ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/query-selector.tsx create mode 100644 ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/scan-list-table.tsx create mode 100644 ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/scan-status-badge.tsx create mode 100644 ui/app/(prowler)/attack-paths/(workflow)/query-builder/_hooks/index.ts create mode 100644 ui/app/(prowler)/attack-paths/(workflow)/query-builder/_hooks/use-graph-state.ts create mode 100644 ui/app/(prowler)/attack-paths/(workflow)/query-builder/_hooks/use-query-builder.ts create mode 100644 ui/app/(prowler)/attack-paths/(workflow)/query-builder/_hooks/use-wizard-state.ts create mode 100644 ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/export.ts create mode 100644 ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/format.ts create mode 100644 ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/graph-colors.ts create mode 100644 ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/index.ts create mode 100644 ui/app/(prowler)/attack-paths/(workflow)/query-builder/page.tsx create mode 100644 ui/app/(prowler)/attack-paths/page.tsx create mode 100644 ui/components/findings/finding-details-sheet.tsx create mode 100644 ui/types/attack-paths.ts diff --git a/.env b/.env index 3be5e93724..3d9b1fdcbc 100644 --- a/.env +++ b/.env @@ -129,7 +129,6 @@ SENTRY_ENVIRONMENT=local SENTRY_RELEASE=local NEXT_PUBLIC_SENTRY_ENVIRONMENT=${SENTRY_ENVIRONMENT} - #### Prowler release version #### NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.12.2 diff --git a/ui/.husky/pre-commit b/ui/.husky/pre-commit index 21ccc62352..ef11e62711 100755 --- a/ui/.husky/pre-commit +++ b/ui/.husky/pre-commit @@ -37,8 +37,8 @@ CODE_REVIEW_ENABLED=$(echo "$CODE_REVIEW_ENABLED" | tr '[:upper:]' '[:lower:]') echo -e "${BLUE}â„šī¸ Code Review Status: ${CODE_REVIEW_ENABLED}${NC}" echo "" -# Get staged files (what will be committed) -STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM | grep -E '\.(tsx?|jsx?)$' || true) +# Get staged files in the UI folder only (what will be committed) +STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM -- 'ui/**' | grep -E '\.(tsx?|jsx?)$' || true) if [ "$CODE_REVIEW_ENABLED" = "true" ]; then if [ -z "$STAGED_FILES" ]; then @@ -135,7 +135,14 @@ else echo "" fi -# Run healthcheck (typecheck and lint check) +# Check if there are any UI files to validate +if [ -z "$STAGED_FILES" ] && [ "$CODE_REVIEW_ENABLED" = "true" ]; then + echo -e "${YELLOW}â­ī¸ No UI files to validate, skipping healthcheck${NC}" + echo "" + exit 0 +fi + +# Run healthcheck (typecheck and lint check) only if there are UI changes echo -e "${BLUE}đŸĨ Running healthcheck...${NC}" echo "" diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index c6e0b8630f..a8ba6b1b08 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -24,6 +24,7 @@ All notable changes to the **Prowler UI** are documented in this file. - PDF reporting for NIS2 compliance framework [(#9170)](https://github.com/prowler-cloud/prowler/pull/9170) - External resource link to IaC findings for direct navigation to source code in Git repositories [(#9151)](https://github.com/prowler-cloud/prowler/pull/9151) - New Overview page and new app styles [(#9234)](https://github.com/prowler-cloud/prowler/pull/9234) +- Attack Paths feature with query execution and graph visualization [(#PROWLER-383)](https://github.com/prowler-cloud/prowler/pull/9270) - Use branch name as region for IaC findings [(#9296)](https://github.com/prowler-cloud/prowler/pull/9296) ### 🔄 Changed diff --git a/ui/actions/attack-paths/index.ts b/ui/actions/attack-paths/index.ts new file mode 100644 index 0000000000..0120dceb86 --- /dev/null +++ b/ui/actions/attack-paths/index.ts @@ -0,0 +1,4 @@ +export * from "./queries"; +export * from "./queries.adapter"; +export * from "./scans"; +export * from "./scans.adapter"; diff --git a/ui/actions/attack-paths/queries.adapter.ts b/ui/actions/attack-paths/queries.adapter.ts new file mode 100644 index 0000000000..fd256739e1 --- /dev/null +++ b/ui/actions/attack-paths/queries.adapter.ts @@ -0,0 +1,55 @@ +import { MetaDataProps } from "@/types"; +import { + AttackPathQueriesResponse, + AttackPathQuery, +} from "@/types/attack-paths"; + +/** + * Adapts raw query API responses to enriched domain models + * - Enriches queries with metadata and computed properties + * - Co-locates related data for better performance + * - Preserves pagination metadata for list operations + * + * Uses plugin architecture for extensibility: + * - Handles query-specific response transformation + * - Can be composed with backend service plugins + * - Maintains separation of concerns between API layer and business logic + */ + +/** + * Adapt attack path queries response with enriched data + * + * @param response - Raw API response from attack-paths-scans/{id}/queries endpoint + * @returns Enriched queries data with metadata + */ +export function adaptAttackPathQueriesResponse( + response: AttackPathQueriesResponse | undefined, +): { + data: AttackPathQuery[]; + metadata?: MetaDataProps; +} { + if (!response?.data) { + return { data: [] }; + } + + // Enrich query data with computed properties + const enrichedData = response.data.map((query) => ({ + ...query, + // Can add computed properties here, e.g.: + // parameterCount: query.attributes.parameters.length, + // requiredParameters: query.attributes.parameters.filter(p => p.required), + // hasParameters: query.attributes.parameters.length > 0, + })); + + const metadata: MetaDataProps | undefined = { + pagination: { + page: 1, + pages: 1, + count: enrichedData.length, + itemsPerPage: [10, 25, 50, 100], + }, + version: "1.0", + }; + + return { data: enrichedData, metadata }; +} diff --git a/ui/actions/attack-paths/queries.ts b/ui/actions/attack-paths/queries.ts new file mode 100644 index 0000000000..8e68d54c50 --- /dev/null +++ b/ui/actions/attack-paths/queries.ts @@ -0,0 +1,97 @@ +"use server"; + +import { z } from "zod"; + +import { apiBaseUrl, getAuthHeaders } from "@/lib"; +import { handleApiResponse } from "@/lib/server-actions-helper"; +import { + AttackPathQueriesResponse, + AttackPathQuery, + AttackPathQueryResult, + ExecuteQueryRequest, +} from "@/types/attack-paths"; + +import { adaptAttackPathQueriesResponse } from "./queries.adapter"; + +// Validation schema for UUID - RFC 9562/4122 compliant +const UUIDSchema = z.uuid(); + +/** + * Fetch available queries for a specific attack path scan + */ +export const getAvailableQueries = async ( + scanId: string, +): Promise<{ data: AttackPathQuery[] } | undefined> => { + // Validate scanId is a valid UUID format to prevent request forgery + const validatedScanId = UUIDSchema.safeParse(scanId); + if (!validatedScanId.success) { + console.error("Invalid scan ID format"); + return undefined; + } + + const headers = await getAuthHeaders({ contentType: false }); + + try { + const response = await fetch( + `${apiBaseUrl}/attack-paths-scans/${validatedScanId.data}/queries`, + { + headers, + method: "GET", + }, + ); + + const apiResponse = (await handleApiResponse( + response, + )) as AttackPathQueriesResponse; + const adaptedData = adaptAttackPathQueriesResponse(apiResponse); + + return { data: adaptedData.data }; + } catch (error) { + console.error("Error fetching available queries for scan:", error); + return undefined; + } +}; + +/** + * Execute a query on an attack path scan + */ +export const executeQuery = async ( + scanId: string, + queryId: string, + parameters?: Record, +): Promise => { + // Validate scanId is a valid UUID format to prevent request forgery + const validatedScanId = UUIDSchema.safeParse(scanId); + if (!validatedScanId.success) { + console.error("Invalid scan ID format"); + return undefined; + } + + const headers = await getAuthHeaders({ contentType: true }); + + const requestBody: ExecuteQueryRequest = { + data: { + type: "attack-paths-query-run-request", + attributes: { + id: queryId, + ...(parameters && { parameters }), + }, + }, + }; + + try { + const response = await fetch( + `${apiBaseUrl}/attack-paths-scans/${validatedScanId.data}/queries/run`, + { + headers, + method: "POST", + body: JSON.stringify(requestBody), + }, + ); + + return handleApiResponse(response); + } catch (error) { + console.error("Error executing query on scan:", error); + return undefined; + } +}; diff --git a/ui/actions/attack-paths/query-result.adapter.ts b/ui/actions/attack-paths/query-result.adapter.ts new file mode 100644 index 0000000000..65b33843af --- /dev/null +++ b/ui/actions/attack-paths/query-result.adapter.ts @@ -0,0 +1,164 @@ +import { + AttackPathGraphData, + GraphEdge, + GraphNodeProperties, + GraphNodePropertyValue, + GraphRelationship, +} from "@/types/attack-paths"; + +/** + * Normalizes property values to ensure they are primitives + * Arrays are converted to comma-separated strings + * + * @param value - The property value to normalize + * @returns Normalized primitive value + */ +function normalizePropertyValue( + value: + | GraphNodePropertyValue + | GraphNodePropertyValue[] + | Record, +): string | number | boolean | null | undefined { + if (value === null || value === undefined) { + return value; + } + + if (Array.isArray(value)) { + // Convert arrays to comma-separated strings + return value.join(", "); + } + + if ( + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean" + ) { + return value; + } + + // For any other type, convert to string + return String(value); +} + +/** + * Normalizes all properties in an object to ensure they are primitives + * + * @param properties - The properties object to normalize + * @returns Normalized properties object + */ +function normalizeProperties( + properties: Record< + string, + GraphNodePropertyValue | GraphNodePropertyValue[] | Record + >, +): GraphNodeProperties { + const normalized: GraphNodeProperties = {}; + + for (const [key, value] of Object.entries(properties)) { + normalized[key] = normalizePropertyValue(value); + } + + return normalized; +} + +/** + * Adapts graph query result data for D3 visualization + * Transforms relationships array into edges array for D3 force-directed graph + * + * The adapter handles: + * - Converting relationship objects to edge objects compatible with D3 + * - Mapping relationship labels to edge types for graph styling + * - Normalizing array properties to strings (e.g., anonymous_actions: ["s3:GetObject"] -> "s3:GetObject") + * - Preserving node and relationship data structure + * - Adding findings array to each node based on HAS_FINDING edges + * - Adding resources array to finding nodes based on HAS_FINDING edges (reverse relationship) + * + * @param graphData - Raw graph data with nodes and relationships from API + * @returns Graph data with edges array formatted for D3 visualization and findings/resources on nodes + */ +export function adaptQueryResultToGraphData( + graphData: AttackPathGraphData, +): AttackPathGraphData { + // Normalize node properties to ensure all values are primitives + const normalizedNodes = graphData.nodes.map((node) => ({ + ...node, + properties: normalizeProperties( + node.properties as Record< + string, + GraphNodePropertyValue | GraphNodePropertyValue[] + >, + ), + findings: [] as string[], // Will be populated below + resources: [] as string[], // Will be populated below for finding nodes + })); + + // Transform relationships into D3-compatible edges if relationships exist + // Also handle case where edges are already provided (e.g., from mock data) + let edges: GraphEdge[] = []; + + if (graphData.relationships) { + edges = (graphData.relationships as GraphRelationship[]).map( + (relationship) => ({ + id: relationship.id, + source: relationship.source, + target: relationship.target, + type: relationship.label, // D3 uses 'type' for styling edge appearance + properties: relationship.properties + ? normalizeProperties( + relationship.properties as Record< + string, + GraphNodePropertyValue | GraphNodePropertyValue[] + >, + ) + : undefined, + }), + ); + } else if (graphData.edges) { + // If edges are already provided, just normalize their properties + edges = (graphData.edges as GraphEdge[]).map((edge) => ({ + ...edge, + properties: edge.properties + ? normalizeProperties( + edge.properties as Record< + string, + GraphNodePropertyValue | GraphNodePropertyValue[] + >, + ) + : undefined, + })); + } + + // Populate findings and resources based on HAS_FINDING edges + edges.forEach((edge) => { + if (edge.type === "HAS_FINDING") { + const sourceId = + typeof edge.source === "string" + ? edge.source + : (edge.source as { id?: string })?.id; + const targetId = + typeof edge.target === "string" + ? edge.target + : (edge.target as { id?: string })?.id; + + if (sourceId && targetId) { + // Add finding to source node (resource -> finding) + const sourceNode = normalizedNodes.find((n) => n.id === sourceId); + if (sourceNode) { + sourceNode.findings.push(targetId); + } + + // Add resource to target node (finding <- resource) + const targetNode = normalizedNodes.find((n) => n.id === targetId); + if (targetNode) { + targetNode.resources.push(sourceId); + } + } + } + }); + + return { + nodes: normalizedNodes, + edges, + relationships: graphData.relationships, // Preserve original relationships data + }; +} diff --git a/ui/actions/attack-paths/scans.adapter.ts b/ui/actions/attack-paths/scans.adapter.ts new file mode 100644 index 0000000000..a8236241a3 --- /dev/null +++ b/ui/actions/attack-paths/scans.adapter.ts @@ -0,0 +1,89 @@ +import { MetaDataProps } from "@/types"; +import { AttackPathScan, AttackPathScansResponse } from "@/types/attack-paths"; + +/** + * Adapts raw scan API responses to enriched domain models + * - Transforms raw scan data with computed properties + * - Co-locates related data for better performance + * - Preserves pagination metadata for list operations + * + * Uses plugin architecture for extensibility: + * - Handles scan-specific response transformation + * - Can be composed with backend service plugins + * - Maintains separation of concerns between API layer and business logic + */ + +/** + * Adapt attack path scans response with enriched data + * + * @param response - Raw API response from attack-paths-scans endpoint + * @returns Enriched scans data with metadata and computed properties + */ +export function adaptAttackPathScansResponse( + response: AttackPathScansResponse | undefined, +): { + data: AttackPathScan[]; + metadata?: MetaDataProps; +} { + if (!response?.data) { + return { data: [] }; + } + + // Enrich scan data with computed properties + const enrichedData = response.data.map((scan) => ({ + ...scan, + attributes: { + ...scan.attributes, + // Format duration for display + durationLabel: scan.attributes.duration + ? formatDuration(scan.attributes.duration) + : null, + // Check if scan is recent (completed within last 24 hours) + isRecent: isRecentScan(scan.attributes.completed_at), + }, + })); + + // Transform links to MetaDataProps format if pagination exists + const metadata: MetaDataProps | undefined = response.links + ? { + pagination: { + // Links-based pagination doesn't have traditional page numbers + // but we preserve the structure for consistency + page: 1, + pages: 1, + count: enrichedData.length, + itemsPerPage: [10, 25, 50, 100], + }, + version: "1.0", + } + : undefined; + + return { data: enrichedData, metadata }; +} + +/** + * Format duration in seconds to human-readable format + * + * @param seconds - Duration in seconds + * @returns Formatted duration string (e.g., "2m 30s") + */ +function formatDuration(seconds: number): string { + const minutes = Math.floor(seconds / 60); + const remainingSeconds = seconds % 60; + return `${minutes}m ${remainingSeconds}s`; +} + +/** + * Check if a scan is recent (completed within last 24 hours) + * + * @param completedAt - Completion timestamp + * @returns true if scan completed within last 24 hours + */ +function isRecentScan(completedAt: string | null): boolean { + if (!completedAt) return false; + + const completionTime = new Date(completedAt).getTime(); + const oneDayAgo = Date.now() - 24 * 60 * 60 * 1000; + + return completionTime > oneDayAgo; +} diff --git a/ui/actions/attack-paths/scans.ts b/ui/actions/attack-paths/scans.ts new file mode 100644 index 0000000000..11342f6ad3 --- /dev/null +++ b/ui/actions/attack-paths/scans.ts @@ -0,0 +1,69 @@ +"use server"; + +import { z } from "zod"; + +import { apiBaseUrl, getAuthHeaders } from "@/lib"; +import { handleApiResponse } from "@/lib/server-actions-helper"; +import { AttackPathScan, AttackPathScansResponse } from "@/types/attack-paths"; + +import { adaptAttackPathScansResponse } from "./scans.adapter"; + +// Validation schema for UUID - RFC 9562/4122 compliant +const UUIDSchema = z.uuid(); + +/** + * Fetch list of attack path scans (latest scan for each provider) + */ +export const getAttackPathScans = async (): Promise< + { data: AttackPathScan[] } | undefined +> => { + const headers = await getAuthHeaders({ contentType: false }); + + try { + const response = await fetch(`${apiBaseUrl}/attack-paths-scans`, { + headers, + method: "GET", + }); + + const apiResponse = (await handleApiResponse( + response, + )) as AttackPathScansResponse; + const adaptedData = adaptAttackPathScansResponse(apiResponse); + + return { data: adaptedData.data }; + } catch (error) { + console.error("Error fetching attack path scans:", error); + return undefined; + } +}; + +/** + * Fetch detail of a specific attack path scan + */ +export const getAttackPathScanDetail = async ( + scanId: string, +): Promise<{ data: AttackPathScan } | undefined> => { + // Validate scanId is a valid UUID format to prevent request forgery + const validatedScanId = UUIDSchema.safeParse(scanId); + if (!validatedScanId.success) { + console.error("Invalid scan ID format"); + return undefined; + } + + const headers = await getAuthHeaders({ contentType: false }); + + try { + const response = await fetch( + `${apiBaseUrl}/attack-paths-scans/${validatedScanId.data}`, + { + headers, + method: "GET", + }, + ); + + return handleApiResponse(response); + } catch (error) { + console.error("Error fetching attack path scan detail:", error); + return undefined; + } +}; diff --git a/ui/app/(prowler)/attack-paths/(workflow)/_components/index.ts b/ui/app/(prowler)/attack-paths/(workflow)/_components/index.ts new file mode 100644 index 0000000000..9dab45a6b5 --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/_components/index.ts @@ -0,0 +1,2 @@ +export { VerticalSteps } from "./vertical-steps"; +export { WorkflowAttackPaths } from "./workflow-attack-paths"; diff --git a/ui/app/(prowler)/attack-paths/(workflow)/_components/vertical-steps.tsx b/ui/app/(prowler)/attack-paths/(workflow)/_components/vertical-steps.tsx new file mode 100644 index 0000000000..9415e73134 --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/_components/vertical-steps.tsx @@ -0,0 +1,299 @@ +"use client"; + +import { useControlledState } from "@react-stately/utils"; +import { domAnimation, LazyMotion, m } from "framer-motion"; +import type { + ComponentProps, + CSSProperties, + HTMLAttributes, + ReactNode, +} from "react"; +import { forwardRef } from "react"; + +import { cn } from "@/lib/utils"; + +export type VerticalStepProps = { + className?: string; + description?: ReactNode; + title?: ReactNode; +}; + +const STEP_COLORS = { + primary: "primary", + secondary: "secondary", + success: "success", + warning: "warning", + danger: "danger", + default: "default", +} as const; + +type StepColor = (typeof STEP_COLORS)[keyof typeof STEP_COLORS]; + +export interface VerticalStepsProps extends HTMLAttributes { + /** + * An array of steps. + * + * @default [] + */ + steps?: VerticalStepProps[]; + /** + * The color of the steps. + * + * @default "primary" + */ + color?: StepColor; + /** + * The current step index. + */ + currentStep?: number; + /** + * The default step index. + * + * @default 0 + */ + defaultStep?: number; + /** + * Whether to hide the progress bars. + * + * @default false + */ + hideProgressBars?: boolean; + /** + * The custom class for the steps wrapper. + */ + className?: string; + /** + * The custom class for the step. + */ + stepClassName?: string; + /** + * Callback function when the step index changes. + */ + onStepChange?: (stepIndex: number) => void; +} + +function CheckIcon(props: ComponentProps<"svg">) { + return ( + + + + ); +} + +export const VerticalSteps = forwardRef( + ( + { + color = "primary", + steps = [], + defaultStep = 0, + onStepChange, + currentStep: currentStepProp, + hideProgressBars = false, + stepClassName, + className, + ...props + }, + ref, + ) => { + const [currentStep, setCurrentStep] = useControlledState( + currentStepProp, + defaultStep, + onStepChange, + ); + + let userColor; + let fgColor; + + const colorsVars = [ + "[--active-fg-color:var(--step-fg-color)]", + "[--active-border-color:var(--step-color)]", + "[--active-color:var(--step-color)]", + "[--complete-background-color:var(--step-color)]", + "[--complete-border-color:var(--step-color)]", + "[--inactive-border-color:hsl(var(--heroui-default-300))]", + "[--inactive-color:hsl(var(--heroui-default-300))]", + ]; + + switch (color) { + case "primary": + userColor = "[--step-color:hsl(var(--heroui-primary))]"; + fgColor = "[--step-fg-color:hsl(var(--heroui-primary-foreground))]"; + break; + case "secondary": + userColor = "[--step-color:hsl(var(--heroui-secondary))]"; + fgColor = "[--step-fg-color:hsl(var(--heroui-secondary-foreground))]"; + break; + case "success": + userColor = "[--step-color:hsl(var(--heroui-success))]"; + fgColor = "[--step-fg-color:hsl(var(--heroui-success-foreground))]"; + break; + case "warning": + userColor = "[--step-color:hsl(var(--heroui-warning))]"; + fgColor = "[--step-fg-color:hsl(var(--heroui-warning-foreground))]"; + break; + case "danger": + userColor = "[--step-color:hsl(var(--heroui-error))]"; + fgColor = "[--step-fg-color:hsl(var(--heroui-error-foreground))]"; + break; + case "default": + userColor = "[--step-color:hsl(var(--heroui-default))]"; + fgColor = "[--step-fg-color:hsl(var(--heroui-default-foreground))]"; + break; + default: + userColor = "[--step-color:hsl(var(--heroui-primary))]"; + fgColor = "[--step-fg-color:hsl(var(--heroui-primary-foreground))]"; + break; + } + + if (!className?.includes("--step-fg-color")) colorsVars.unshift(fgColor); + if (!className?.includes("--step-color")) colorsVars.unshift(userColor); + if (!className?.includes("--inactive-bar-color")) + colorsVars.push("[--inactive-bar-color:hsl(var(--heroui-default-300))]"); + + const colors = colorsVars; + + return ( + + ); + }, +); + +VerticalSteps.displayName = "VerticalSteps"; 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 new file mode 100644 index 0000000000..9e1f3684ca --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/_components/workflow-attack-paths.tsx @@ -0,0 +1,49 @@ +"use client"; + +import { usePathname } from "next/navigation"; + +import { VerticalSteps } from "./vertical-steps"; + +/** + * Workflow steps component for Attack Paths wizard + * Shows progress and navigation steps for the two-step process + */ +export const WorkflowAttackPaths = () => { + const pathname = usePathname(); + + // Determine current step based on pathname + const isQueryBuilderStep = pathname.includes("query-builder"); + + const currentStep = isQueryBuilderStep ? 1 : 0; // 0-indexed + + const steps = [ + { + title: "Select Attack Paths Scan", + description: "Choose an AWS account and its latest Attack Paths scan", + }, + { + title: "Build Query & Visualize", + description: "Create a query and view the Attack Paths graph", + }, + ]; + + const progressPercentage = (currentStep / (steps.length - 1)) * 100; + + return ( +
+
+
+
+
+

+ Step {currentStep + 1} of {steps.length} +

+
+ + +
+ ); +}; diff --git a/ui/app/(prowler)/attack-paths/(workflow)/layout.tsx b/ui/app/(prowler)/attack-paths/(workflow)/layout.tsx new file mode 100644 index 0000000000..8557ab5369 --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/layout.tsx @@ -0,0 +1,21 @@ +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/execute-button.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/execute-button.tsx new file mode 100644 index 0000000000..07caf5547a --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/execute-button.tsx @@ -0,0 +1,34 @@ +"use client"; + +import { Play } from "lucide-react"; + +import { Button } from "@/components/shadcn"; + +interface ExecuteButtonProps { + isLoading: boolean; + isDisabled: boolean; + onExecute: () => void; +} + +/** + * Execute query button component + * Triggers query execution with loading state + */ +export const ExecuteButton = ({ + isLoading, + isDisabled, + onExecute, +}: ExecuteButtonProps) => { + return ( + + ); +}; diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/attack-path-graph.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/attack-path-graph.tsx new file mode 100644 index 0000000000..16871fb319 --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/attack-path-graph.tsx @@ -0,0 +1,962 @@ +"use client"; + +import type { D3ZoomEvent, ZoomBehavior } from "d3"; +import { select, zoom, zoomIdentity } from "d3"; +import dagre from "dagre"; +import { + forwardRef, + type Ref, + useEffect, + useImperativeHandle, + useRef, + useState, +} from "react"; + +import type { AttackPathGraphData, GraphNode } from "@/types/attack-paths"; + +import { + formatNodeLabel, + getNodeBorderColor, + getNodeColor, + GRAPH_EDGE_COLOR, + GRAPH_SELECTION_COLOR, +} from "../../_lib"; + +export interface AttackPathGraphRef { + zoomIn: () => void; + zoomOut: () => void; + resetZoom: () => void; + getZoomLevel: () => number; + getSVGElement: () => SVGSVGElement | null; +} + +interface AttackPathGraphProps { + data: AttackPathGraphData; + onNodeClick?: (node: GraphNode) => void; + selectedNodeId?: string | null; + ref?: Ref; +} + +/** + * Node data type used throughout the graph visualization + */ +type NodeData = { id: string; x: number; y: number; data: GraphNode }; + +// Node dimensions - modern rounded pill style +const NODE_WIDTH = 180; +const NODE_HEIGHT = 50; +const NODE_RADIUS = 25; // Fully rounded ends for pill shape +const HEXAGON_WIDTH = 200; // Width for finding hexagons +const HEXAGON_HEIGHT = 55; // Height for finding hexagons + +/** + * D3 + Dagre hierarchical graph visualization for attack paths + * Renders rounded rectangle nodes with dashed edges + */ +const AttackPathGraphComponent = forwardRef< + AttackPathGraphRef, + AttackPathGraphProps +>(({ data, onNodeClick, selectedNodeId }, ref) => { + const svgRef = useRef(null); + const [zoomLevel, setZoomLevel] = useState(1); + const zoomBehaviorRef = useRef | null>( + null, + ); + const containerRef = useRef + > | null>(null); + const svgSelectionRef = useRef + > | null>(null); + const hiddenNodeIdsRef = useRef>(new Set()); + const onNodeClickRef = useRef(onNodeClick); + const nodeShapesRef = useRef + > | null>(null); + const resourcesWithFindingsRef = useRef>(new Set()); + + // Update ref when onNodeClick changes + useEffect(() => { + onNodeClickRef.current = onNodeClick; + }, [onNodeClick]); + + // Update selected node styling without re-rendering + useEffect(() => { + if (nodeShapesRef.current) { + const ALERT_BORDER_COLOR = "#ef4444"; // Red 500 + nodeShapesRef.current + .attr("stroke", (d: NodeData) => { + const isFinding = d.data.labels.some((label) => + label.toLowerCase().includes("finding"), + ); + const hasFindings = resourcesWithFindingsRef.current.has(d.id); + + // Resources with findings always keep red border + if (!isFinding && hasFindings) { + return ALERT_BORDER_COLOR; + } + // Selected nodes get selection color + if (d.id === selectedNodeId) { + return GRAPH_SELECTION_COLOR; + } + // Default border color + return getNodeBorderColor(d.data.labels, d.data.properties); + }) + .attr("stroke-width", (d: NodeData) => { + const isFinding = d.data.labels.some((label) => + label.toLowerCase().includes("finding"), + ); + const hasFindings = resourcesWithFindingsRef.current.has(d.id); + + // Resources with findings keep their wider stroke + if (!isFinding && hasFindings) { + return 2.5; + } + return d.id === selectedNodeId ? 3 : isFinding ? 2 : 1.5; + }); + } + }, [selectedNodeId]); + + useImperativeHandle(ref, () => ({ + zoomIn: () => { + if (svgSelectionRef.current && zoomBehaviorRef.current) { + svgSelectionRef.current + .transition() + .duration(300) + .call(zoomBehaviorRef.current.scaleBy, 1.3); + } + }, + zoomOut: () => { + if (svgSelectionRef.current && zoomBehaviorRef.current) { + svgSelectionRef.current + .transition() + .duration(300) + .call(zoomBehaviorRef.current.scaleBy, 0.77); + } + }, + resetZoom: () => { + if ( + svgSelectionRef.current && + zoomBehaviorRef.current && + containerRef.current + ) { + const bounds = containerRef.current.node()?.getBBox(); + if (!bounds) return; + + const fullWidth = svgRef.current?.clientWidth || 800; + const fullHeight = svgRef.current?.clientHeight || 500; + + const midX = bounds.x + bounds.width / 2; + const midY = bounds.y + bounds.height / 2; + const scale = + 0.8 / Math.max(bounds.width / fullWidth, bounds.height / fullHeight); + const tx = fullWidth / 2 - scale * midX; + const ty = fullHeight / 2 - scale * midY; + + svgSelectionRef.current + .transition() + .duration(300) + .call( + zoomBehaviorRef.current.transform, + zoomIdentity.translate(tx, ty).scale(scale), + ); + } + }, + getZoomLevel: () => zoomLevel, + getSVGElement: () => svgRef.current, + })); + + useEffect(() => { + if (!svgRef.current || !data.nodes || data.nodes.length === 0) return; + + // Set dimensions based on container size + const width = svgRef.current.clientWidth || 800; + const height = svgRef.current.clientHeight || 500; + + // Clear previous content + select(svgRef.current).selectAll("*").remove(); + + // Create SVG + const svg = select(svgRef.current) + .attr("width", width) + .attr("height", height) + .attr("viewBox", [0, 0, width, height]); + + // Create container for zoom/pan + const container = svg.append("g") as unknown as ReturnType< + typeof select + >; + containerRef.current = container; + svgSelectionRef.current = svg as unknown as ReturnType< + typeof select + >; + + // Container relationships (reverse direction for layout purposes) + const containerRelations = new Set([ + "RUNS_IN", + "BELONGS_TO", + "LOCATED_IN", + "PART_OF", + ]); + + // Create dagre graph + const g = new dagre.graphlib.Graph(); + g.setGraph({ + rankdir: "LR", // Left to right + nodesep: 80, // Vertical spacing between nodes + ranksep: 150, // Horizontal spacing between ranks + marginx: 50, + marginy: 50, + }); + g.setDefaultEdgeLabel(() => ({})); + + // Initially hide finding nodes + const initialHiddenNodes = new Set(); + data.nodes.forEach((node) => { + const isFinding = node.labels.some((label) => + label.toLowerCase().includes("finding"), + ); + if (isFinding) { + initialHiddenNodes.add(node.id); + } + }); + hiddenNodeIdsRef.current = initialHiddenNodes; + + // Create a map to store original node data + const nodeDataMap = new Map(data.nodes.map((node) => [node.id, node])); + + // Add nodes to dagre graph with appropriate sizes + data.nodes.forEach((node) => { + const isFinding = node.labels.some((label) => + label.toLowerCase().includes("finding"), + ); + g.setNode(node.id, { + label: node.id, + width: isFinding ? HEXAGON_WIDTH : NODE_WIDTH, + height: isFinding ? HEXAGON_HEIGHT : NODE_HEIGHT, + }); + }); + + // Add edges to dagre graph + if (data.edges && Array.isArray(data.edges)) { + data.edges.forEach((edge) => { + const source = edge.source; + const target = edge.target; + let sourceId = + typeof source === "string" + ? source + : typeof source === "object" && source !== null + ? (source as GraphNode).id + : ""; + let targetId = + typeof target === "string" + ? target + : typeof target === "object" && target !== null + ? (target as GraphNode).id + : ""; + + // Reverse container relationships for proper hierarchy + if (containerRelations.has(edge.type)) { + [sourceId, targetId] = [targetId, sourceId]; + } + + if (sourceId && targetId) { + g.setEdge(sourceId, targetId, { + originalSource: + typeof edge.source === "string" + ? edge.source + : (edge.source as GraphNode).id, + originalTarget: + typeof edge.target === "string" + ? edge.target + : (edge.target as GraphNode).id, + }); + } + }); + } + + // Run dagre layout + dagre.layout(g); + + // Draw edges + const edgesData: Array<{ + source: { x: number; y: number }; + target: { x: number; y: number }; + id: string; + sourceId: string; + targetId: string; + }> = []; + g.edges().forEach((e) => { + const sourceNode = g.node(e.v); + const targetNode = g.node(e.w); + + edgesData.push({ + source: { x: sourceNode.x, y: sourceNode.y }, + target: { x: targetNode.x, y: targetNode.y }, + id: `${e.v}-${e.w}`, + sourceId: e.v, + targetId: e.w, + }); + }); + + // Add defs for filters and markers FIRST (before using them) + const defs = svg.append("defs"); + + // Glow filter for nodes + const glowFilter = defs.append("filter").attr("id", "glow"); + glowFilter + .append("feGaussianBlur") + .attr("stdDeviation", "3") + .attr("result", "coloredBlur"); + const feMerge = glowFilter.append("feMerge"); + feMerge.append("feMergeNode").attr("in", "coloredBlur"); + feMerge.append("feMergeNode").attr("in", "SourceGraphic"); + + // Edge glow filter + const edgeGlowFilter = defs.append("filter").attr("id", "edgeGlow"); + edgeGlowFilter + .append("feGaussianBlur") + .attr("stdDeviation", "2") + .attr("result", "coloredBlur"); + const edgeFeMerge = edgeGlowFilter.append("feMerge"); + edgeFeMerge.append("feMergeNode").attr("in", "coloredBlur"); + edgeFeMerge.append("feMergeNode").attr("in", "SourceGraphic"); + + // Red glow filter for resources with findings + const redGlowFilter = defs.append("filter").attr("id", "redGlow"); + redGlowFilter + .append("feDropShadow") + .attr("dx", "0") + .attr("dy", "0") + .attr("stdDeviation", "4") + .attr("flood-color", "#ef4444") + .attr("flood-opacity", "0.6"); + + // Arrow marker - refX=10 places the arrow tip exactly at the line endpoint + defs + .append("marker") + .attr("id", "arrowhead") + .attr("viewBox", "0 0 10 10") + .attr("refX", 10) + .attr("refY", 5) + .attr("markerWidth", 6) + .attr("markerHeight", 6) + .attr("orient", "auto") + .append("path") + .attr("d", "M 0 0 L 10 5 L 0 10 z") + .attr("fill", GRAPH_EDGE_COLOR); + + // Add CSS animation for dashed lines and resource edge styles + svg.append("style").text(` + @keyframes dash { + to { + stroke-dashoffset: -20; + } + } + .animated-edge { + animation: dash 1s linear infinite; + } + .resource-edge { + stroke-opacity: 1; + } + `); + + const linkGroup = container.append("g").attr("class", "links"); + + // Calculate edge endpoints based on node shape + const getEdgePoints = ( + sourceId: string, + targetId: string, + source: { x: number; y: number }, + target: { x: number; y: number }, + ) => { + const sourceNode = nodeDataMap.get(sourceId); + const targetNode = nodeDataMap.get(targetId); + + const sourceIsFinding = sourceNode?.labels.some((label) => + label.toLowerCase().includes("finding"), + ); + const targetIsFinding = targetNode?.labels.some((label) => + label.toLowerCase().includes("finding"), + ); + const sourceIsInternet = sourceNode?.labels.some( + (label) => label.toLowerCase() === "internet", + ); + const targetIsInternet = targetNode?.labels.some( + (label) => label.toLowerCase() === "internet", + ); + + // Get appropriate widths based on node type + // Internet nodes are circles with radius = NODE_HEIGHT * 0.8 + const sourceHalfWidth = sourceIsInternet + ? NODE_HEIGHT * 0.8 + : sourceIsFinding + ? HEXAGON_WIDTH / 2 + : NODE_WIDTH / 2; + const targetHalfWidth = targetIsInternet + ? NODE_HEIGHT * 0.8 + : targetIsFinding + ? HEXAGON_WIDTH / 2 + : NODE_WIDTH / 2; + + // Source exits from right side + const x1 = source.x + sourceHalfWidth; + const y1 = source.y; + + // Target enters from left side - line ends at node edge, arrow extends from there + const x2 = target.x - targetHalfWidth; + const y2 = target.y; + + return { x1, y1, x2, y2 }; + }; + + // Helper to check if a node is a finding + const isNodeFinding = (nodeId: string) => { + const node = nodeDataMap.get(nodeId); + return node?.labels.some((label) => + label.toLowerCase().includes("finding"), + ); + }; + + const linkElements = linkGroup + .selectAll("line") + .data(edgesData) + .enter() + .append("line") + .attr( + "x1", + (d) => getEdgePoints(d.sourceId, d.targetId, d.source, d.target).x1, + ) + .attr( + "y1", + (d) => getEdgePoints(d.sourceId, d.targetId, d.source, d.target).y1, + ) + .attr( + "x2", + (d) => getEdgePoints(d.sourceId, d.targetId, d.source, d.target).x2, + ) + .attr( + "y2", + (d) => getEdgePoints(d.sourceId, d.targetId, d.source, d.target).y2, + ) + .attr("stroke", GRAPH_EDGE_COLOR) + .attr("stroke-width", 3) + .attr("stroke-linecap", "round") + .attr("stroke-dasharray", (d) => { + // Dashed lines only for edges connected to findings + const hasFinding = + isNodeFinding(d.sourceId) || isNodeFinding(d.targetId); + return hasFinding ? "8,6" : null; + }) + .attr("class", (d) => { + // Animate dashed lines + const hasFinding = + isNodeFinding(d.sourceId) || isNodeFinding(d.targetId); + return hasFinding ? "animated-edge" : "resource-edge"; + }) + .attr("marker-end", "url(#arrowhead)") + .each(function (d) { + // Resource-to-resource edges are ALWAYS visible + // Finding edges are only visible when the finding node is visible + const sourceIsFinding = isNodeFinding(d.sourceId); + const targetIsFinding = isNodeFinding(d.targetId); + + let visibility = "visible"; + if (sourceIsFinding || targetIsFinding) { + const sourceHidden = hiddenNodeIdsRef.current.has(d.sourceId); + const targetHidden = hiddenNodeIdsRef.current.has(d.targetId); + visibility = sourceHidden || targetHidden ? "hidden" : "visible"; + } + + select(this).style("visibility", visibility); + }); + + // Draw nodes + const nodesData = g.nodes().map((v) => { + const node = g.node(v); + return { + id: v, + x: node.x, + y: node.y, + data: nodeDataMap.get(v)!, + }; + }); + + const nodeGroup = container.append("g").attr("class", "nodes"); + + const nodeElements = nodeGroup + .selectAll("g.node") + .data(nodesData) + .enter() + .append("g") + .attr("class", "node") + .attr("transform", (d) => `translate(${d.x},${d.y})`) + .attr("cursor", "pointer") + .style("display", (d) => + hiddenNodeIdsRef.current.has(d.id) ? "none" : null, + ) + .on("click", function (event: PointerEvent, d) { + event.stopPropagation(); + + // Toggle visibility of connected finding nodes + const node = d.data; + const isFinding = node.labels.some((label) => + label.toLowerCase().includes("finding"), + ); + + if (!isFinding) { + // Find connected findings for THIS node + const connectedFindings = new Set(); + data.edges?.forEach((edge) => { + const sourceId = + typeof edge.source === "string" + ? edge.source + : (edge.source as GraphNode).id; + const targetId = + typeof edge.target === "string" + ? edge.target + : (edge.target as GraphNode).id; + + if (sourceId === node.id || targetId === node.id) { + const otherId = sourceId === node.id ? targetId : sourceId; + const otherNode = data.nodes.find((n) => n.id === otherId); + if ( + otherNode?.labels.some((label) => + label.toLowerCase().includes("finding"), + ) + ) { + connectedFindings.add(otherId); + } + } + }); + + // Clear hidden nodes and hide ALL findings + hiddenNodeIdsRef.current.clear(); + data.nodes.forEach((n) => { + const isNodeFinding = n.labels.some((label) => + label.toLowerCase().includes("finding"), + ); + if (isNodeFinding) { + hiddenNodeIdsRef.current.add(n.id); + } + }); + + // Show ONLY the findings connected to the clicked node + connectedFindings.forEach((findingId) => { + hiddenNodeIdsRef.current.delete(findingId); + }); + + // Update node visibility + nodeElements.style( + "display", + function (nodeData: { + id: string; + x: number; + y: number; + data: GraphNode; + }) { + return hiddenNodeIdsRef.current.has(nodeData.id) ? "none" : null; + }, + ); + + // Update edge visibility + linkElements.style( + "visibility", + function (edgeData: { + source: { x: number; y: number }; + target: { x: number; y: number }; + id: string; + sourceId: string; + targetId: string; + }) { + // Resource-to-resource edges are ALWAYS visible + const sourceIsFinding = isNodeFinding(edgeData.sourceId); + const targetIsFinding = isNodeFinding(edgeData.targetId); + + if (!sourceIsFinding && !targetIsFinding) { + return "visible"; + } + + // Finding edges only visible when finding is not hidden + return hiddenNodeIdsRef.current.has(edgeData.sourceId) || + hiddenNodeIdsRef.current.has(edgeData.targetId) + ? "hidden" + : "visible"; + }, + ); + + // Auto-adjust view to show the selected node and its findings + setTimeout(() => { + if ( + svgSelectionRef.current && + zoomBehaviorRef.current && + containerRef.current && + svgRef.current + ) { + // Calculate bounding box of visible nodes (clicked node + its findings) + const visibleNodeIds = new Set([ + node.id, + ...Array.from(connectedFindings), + ]); + const visibleNodesData = nodesData.filter((n) => + visibleNodeIds.has(n.id), + ); + + if (visibleNodesData.length > 0) { + // Find min/max coordinates of visible nodes + let minX = Infinity, + maxX = -Infinity, + minY = Infinity, + maxY = -Infinity; + visibleNodesData.forEach((n) => { + minX = Math.min(minX, n.x - NODE_WIDTH / 2); + maxX = Math.max(maxX, n.x + NODE_WIDTH / 2); + minY = Math.min(minY, n.y - NODE_HEIGHT / 2); + maxY = Math.max(maxY, n.y + NODE_HEIGHT / 2); + }); + + // Add padding + const padding = 80; + minX -= padding; + maxX += padding; + minY -= padding; + maxY += padding; + + // Get actual SVG dimensions from the DOM + const svgRect = svgRef.current.getBoundingClientRect(); + const fullWidth = svgRect.width; + const fullHeight = svgRect.height; + + const boxWidth = maxX - minX; + const boxHeight = maxY - minY; + const midX = minX + boxWidth / 2; + const midY = minY + boxHeight / 2; + + // Calculate scale to fit all visible nodes + const scale = + 0.9 / Math.max(boxWidth / fullWidth, boxHeight / fullHeight); + const tx = fullWidth / 2 - scale * midX; + const ty = fullHeight / 2 - scale * midY; + + svgSelectionRef.current + .transition() + .duration(500) + .call( + zoomBehaviorRef.current.transform, + zoomIdentity.translate(tx, ty).scale(scale), + ); + } + } + }, 50); + } + + onNodeClickRef.current?.(d.data); + }); + + // Add tooltip + nodeElements.append("title").text((d: (typeof nodesData)[0]): string => { + const isFinding = d.data.labels.some((label) => + label.toLowerCase().includes("finding"), + ); + const label = + d.data.labels && d.data.labels.length > 0 + ? formatNodeLabel(d.data.labels[0]) + : d.id; + + if (isFinding) { + return `${label}\nClick to view finding details`; + } else { + return `${label}\nClick to view related findings`; + } + }); + + // Build a set of resource nodes that have findings connected to them + const resourcesWithFindings = new Set(); + data.edges?.forEach((edge) => { + const sourceId = + typeof edge.source === "string" + ? edge.source + : (edge.source as GraphNode).id; + const targetId = + typeof edge.target === "string" + ? edge.target + : (edge.target as GraphNode).id; + + const sourceNode = nodeDataMap.get(sourceId); + const targetNode = nodeDataMap.get(targetId); + + const sourceIsFinding = sourceNode?.labels.some((l) => + l.toLowerCase().includes("finding"), + ); + const targetIsFinding = targetNode?.labels.some((l) => + l.toLowerCase().includes("finding"), + ); + + // If one end is a finding, the other is a resource with findings + if (sourceIsFinding && !targetIsFinding) { + resourcesWithFindings.add(targetId); + } + if (targetIsFinding && !sourceIsFinding) { + resourcesWithFindings.add(sourceId); + } + }); + + // Store in ref for use in selection updates + resourcesWithFindingsRef.current = resourcesWithFindings; + + // Red alert color for resources with findings + const ALERT_BORDER_COLOR = "#ef4444"; // Red 500 + + // Add shapes - hexagons for findings, rounded pill shapes for resources + nodeElements.each(function (d) { + const group = select(this); + const isFinding = d.data.labels.some((label) => + label.toLowerCase().includes("finding"), + ); + const nodeColor = getNodeColor(d.data.labels, d.data.properties); + const borderColor = getNodeBorderColor(d.data.labels, d.data.properties); + const hasFindings = resourcesWithFindings.has(d.id); + + if (isFinding) { + // Hexagon for findings - always has glow + const w = HEXAGON_WIDTH; + const h = HEXAGON_HEIGHT; + const sideInset = w * 0.15; + const hexPath = ` + M ${-w / 2 + sideInset} ${-h / 2} + L ${w / 2 - sideInset} ${-h / 2} + L ${w / 2} 0 + L ${w / 2 - sideInset} ${h / 2} + L ${-w / 2 + sideInset} ${h / 2} + L ${-w / 2} 0 + Z + `; + group + .append("path") + .attr("d", hexPath) + .attr("fill", nodeColor) + .attr("fill-opacity", 0.85) + .attr( + "stroke", + d.id === selectedNodeId ? GRAPH_SELECTION_COLOR : borderColor, + ) + .attr("stroke-width", d.id === selectedNodeId ? 3 : 2) + .attr("filter", "url(#glow)") + .attr("class", "node-shape"); + } else { + // Check if this is an Internet node + const isInternet = d.data.labels.some( + (label) => label.toLowerCase() === "internet", + ); + + // Resources with findings get red border and red glow (even when selected) + const strokeColor = hasFindings + ? ALERT_BORDER_COLOR + : d.id === selectedNodeId + ? GRAPH_SELECTION_COLOR + : borderColor; + + if (isInternet) { + // Globe shape for Internet nodes - larger than regular nodes + const radius = NODE_HEIGHT * 0.8; + + // Main circle + group + .append("circle") + .attr("cx", 0) + .attr("cy", 0) + .attr("r", radius) + .attr("fill", nodeColor) + .attr("fill-opacity", 0.85) + .attr("stroke", strokeColor) + .attr( + "stroke-width", + hasFindings ? 2.5 : d.id === selectedNodeId ? 3 : 1.5, + ) + .attr("filter", hasFindings ? "url(#redGlow)" : "url(#glow)") + .attr("class", "node-shape"); + + // Horizontal ellipse (equator) + group + .append("ellipse") + .attr("cx", 0) + .attr("cy", 0) + .attr("rx", radius) + .attr("ry", radius * 0.35) + .attr("fill", "none") + .attr("stroke", strokeColor) + .attr("stroke-width", 1) + .attr("stroke-opacity", 0.5); + + // Vertical ellipse (meridian) + group + .append("ellipse") + .attr("cx", 0) + .attr("cy", 0) + .attr("rx", radius * 0.35) + .attr("ry", radius) + .attr("fill", "none") + .attr("stroke", strokeColor) + .attr("stroke-width", 1) + .attr("stroke-opacity", 0.5); + } else { + // Rounded pill shape for other resources + group + .append("rect") + .attr("x", -NODE_WIDTH / 2) + .attr("y", -NODE_HEIGHT / 2) + .attr("width", NODE_WIDTH) + .attr("height", NODE_HEIGHT) + .attr("rx", NODE_RADIUS) + .attr("ry", NODE_RADIUS) + .attr("fill", nodeColor) + .attr("fill-opacity", 0.85) + .attr("stroke", strokeColor) + .attr( + "stroke-width", + hasFindings ? 2.5 : d.id === selectedNodeId ? 3 : 1.5, + ) + .attr("filter", hasFindings ? "url(#redGlow)" : null) + .attr("class", "node-shape"); + } + } + }); + + // Store reference for updating selection later (select all shapes) + const nodeShapes = nodeElements.selectAll(".node-shape"); + nodeShapesRef.current = nodeShapes as unknown as ReturnType< + typeof select + >; + + // Add label text - white text on all nodes (backgrounds are dark enough) + nodeElements.each(function (d) { + const group = select(this); + const isFinding = d.data.labels.some((label) => + label.toLowerCase().includes("finding"), + ); + + // Create text container - white text with shadow for readability + const textGroup = group + .append("text") + .attr("pointer-events", "none") + .attr("text-anchor", "middle") + .attr("dominant-baseline", "middle") + .attr("fill", "#ffffff") + .style("text-shadow", "0 1px 2px rgba(0,0,0,0.5)"); + + if (isFinding) { + // For findings: show check_title/name (severity is shown by color) + const title = String( + d.data.properties?.check_title || + d.data.properties?.name || + d.data.properties?.id || + "Finding", + ); + const maxChars = 24; + const displayTitle = + title.length > maxChars + ? title.substring(0, maxChars) + "..." + : title; + + textGroup + .append("tspan") + .attr("x", 0) + .attr("font-size", "11px") + .attr("font-weight", "600") + .text(displayTitle); + } else { + // For resources: show name with type below + const name = String( + d.data.properties?.name || + d.data.properties?.id || + (d.data.labels && d.data.labels.length > 0 + ? formatNodeLabel(d.data.labels[0]) + : "Unknown"), + ); + const maxChars = 22; + const displayName = + name.length > maxChars ? name.substring(0, maxChars) + "..." : name; + + // Name + textGroup + .append("tspan") + .attr("x", 0) + .attr("dy", "-0.3em") + .attr("font-size", "11px") + .attr("font-weight", "600") + .text(displayName); + + // Type label - slightly transparent white + const type = + d.data.labels && d.data.labels.length > 0 + ? formatNodeLabel(d.data.labels[0]) + : ""; + if (type) { + textGroup + .append("tspan") + .attr("x", 0) + .attr("dy", "1.3em") + .attr("font-size", "9px") + .attr("fill", "rgba(255,255,255,0.8)") + .text(type); + } + } + }); + + // Add zoom behavior + const zoomBehavior = zoom().on( + "zoom", + (event: D3ZoomEvent) => { + const transform = event.transform; + container.attr("transform", transform.toString()); + setZoomLevel(transform.k); + }, + ); + zoomBehaviorRef.current = zoomBehavior; + + svg.call(zoomBehavior); + + // Disable mouse wheel zoom (only allow programmatic zoom via buttons) + svg.on("wheel.zoom", null); + svg.on("dblclick.zoom", null); + + // Auto-fit to screen + setTimeout(() => { + if ( + svgSelectionRef.current && + zoomBehaviorRef.current && + containerRef.current + ) { + const bounds = containerRef.current.node()?.getBBox(); + if (!bounds) return; + + const fullWidth = svgRef.current?.clientWidth || 800; + const fullHeight = svgRef.current?.clientHeight || 500; + + const midX = bounds.x + bounds.width / 2; + const midY = bounds.y + bounds.height / 2; + const scale = + 0.8 / Math.max(bounds.width / fullWidth, bounds.height / fullHeight); + const tx = fullWidth / 2 - scale * midX; + const ty = fullHeight / 2 - scale * midY; + + svgSelectionRef.current.call( + zoomBehaviorRef.current.transform, + zoomIdentity.translate(tx, ty).scale(scale), + ); + } + }, 100); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [data]); + + return ( + + ); +}); + +AttackPathGraphComponent.displayName = "AttackPathGraph"; + +export const AttackPathGraph = AttackPathGraphComponent; diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/graph-controls.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/graph-controls.tsx new file mode 100644 index 0000000000..872cd57445 --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/graph-controls.tsx @@ -0,0 +1,93 @@ +"use client"; + +import { Download, Minimize2, ZoomIn, ZoomOut } from "lucide-react"; + +import { Button } from "@/components/shadcn"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/shadcn/tooltip"; + +interface GraphControlsProps { + onZoomIn: () => void; + onZoomOut: () => void; + onFitToScreen: () => void; + onExport: () => void; +} + +/** + * Controls for graph visualization (zoom, pan, export) + * Positioned as floating toolbar above graph + */ +export const GraphControls = ({ + onZoomIn, + onZoomOut, + onFitToScreen, + onExport, +}: GraphControlsProps) => { + return ( +
+
+ + + + + + Zoom in + + + + + + + Zoom out + + + + + + + Fit graph to view + + + + + + + Export graph + + +
+
+ ); +}; diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/graph-legend.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/graph-legend.tsx new file mode 100644 index 0000000000..033d0893fb --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/graph-legend.tsx @@ -0,0 +1,497 @@ +"use client"; + +import { Card, CardContent } from "@/components/shadcn"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/shadcn/tooltip"; +import type { AttackPathGraphData } from "@/types/attack-paths"; + +import { + getNodeBorderColor, + getNodeColor, + GRAPH_EDGE_COLOR, + GRAPH_NODE_BORDER_COLORS, + GRAPH_NODE_COLORS, +} from "../../_lib/graph-colors"; + +interface LegendItem { + label: string; + color: string; + borderColor: string; + description: string; + shape: "rectangle" | "hexagon" | "cloud"; +} + +// Map node labels to human-readable names and descriptions +const nodeTypeDescriptions: Record< + string, + { name: string; description: string } +> = { + // Findings + ProwlerFinding: { + name: "Finding", + description: "Security findings from Prowler scans", + }, + // AWS Account + AWSAccount: { + name: "AWS Account", + description: "AWS account root node", + }, + // Compute + EC2Instance: { + name: "EC2 Instance", + description: "Elastic Compute Cloud instance", + }, + LambdaFunction: { + name: "Lambda Function", + description: "AWS Lambda serverless function", + }, + // Storage + S3Bucket: { + name: "S3 Bucket", + description: "Simple Storage Service bucket", + }, + // IAM + IAMRole: { + name: "IAM Role", + description: "Identity and Access Management role", + }, + IAMPolicy: { + name: "IAM Policy", + description: "Identity and Access Management policy", + }, + AWSRole: { + name: "AWS Role", + description: "AWS IAM role", + }, + AWSPolicy: { + name: "AWS Policy", + description: "AWS IAM policy", + }, + AWSInlinePolicy: { + name: "AWS Inline Policy", + description: "AWS IAM inline policy", + }, + AWSPolicyStatement: { + name: "AWS Policy Statement", + description: "AWS IAM policy statement", + }, + AWSPrincipal: { + name: "AWS Principal", + description: "AWS IAM principal entity", + }, + // Networking + SecurityGroup: { + name: "Security Group", + description: "AWS security group for network access control", + }, + EC2SecurityGroup: { + name: "EC2 Security Group", + description: "EC2 security group for network access control", + }, + IpPermissionInbound: { + name: "IP Permission Inbound", + description: "Inbound IP permission rule", + }, + IpRule: { + name: "IP Rule", + description: "IP address rule", + }, + Internet: { + name: "Internet", + description: "Internet gateway or public access", + }, + // Tags + AWSTag: { + name: "AWS Tag", + description: "AWS resource tag", + }, + Tag: { + name: "Tag", + description: "Resource tag", + }, +}; + +/** + * Extract unique node types from graph data + */ +function extractNodeTypes( + nodes: AttackPathGraphData["nodes"] | undefined, +): string[] { + if (!nodes) return []; + + const nodeTypes = new Set(); + nodes.forEach((node) => { + node.labels.forEach((label) => { + nodeTypes.add(label); + }); + }); + + return Array.from(nodeTypes).sort(); +} + +/** + * Severity legend items - colors work in both light and dark themes + */ +const severityLegendItems: LegendItem[] = [ + { + label: "Critical", + color: GRAPH_NODE_COLORS.critical, + borderColor: GRAPH_NODE_BORDER_COLORS.critical, + description: "Critical severity finding", + shape: "hexagon", + }, + { + label: "High", + color: GRAPH_NODE_COLORS.high, + borderColor: GRAPH_NODE_BORDER_COLORS.high, + description: "High severity finding", + shape: "hexagon", + }, + { + label: "Medium", + color: GRAPH_NODE_COLORS.medium, + borderColor: GRAPH_NODE_BORDER_COLORS.medium, + description: "Medium severity finding", + shape: "hexagon", + }, + { + label: "Low", + color: GRAPH_NODE_COLORS.low, + borderColor: GRAPH_NODE_BORDER_COLORS.low, + description: "Low severity finding", + shape: "hexagon", + }, +]; + +/** + * Generate legend items from graph data + */ +function generateLegendItems( + nodeTypes: string[], + hasFindings: boolean, +): LegendItem[] { + const items: LegendItem[] = []; + const seenTypes = new Set(); + + // Add severity items if there are findings + if (hasFindings) { + items.push(...severityLegendItems); + } + + // Helper to format unknown node types (e.g., "AWSPolicyStatement" -> "AWS Policy Statement") + const formatNodeTypeName = (nodeType: string): string => { + return nodeType + .replace(/([A-Z])/g, " $1") // Add space before capitals + .replace(/^ /, "") // Remove leading space + .replace(/AWS /g, "AWS ") // Keep AWS together + .replace(/EC2 /g, "EC2 ") // Keep EC2 together + .replace(/S3 /g, "S3 ") // Keep S3 together + .replace(/IAM /g, "IAM ") // Keep IAM together + .replace(/IP /g, "IP ") // Keep IP together + .trim(); + }; + + nodeTypes.forEach((nodeType) => { + if (seenTypes.has(nodeType)) return; + seenTypes.add(nodeType); + + // Skip findings - we show severity colors instead + const isFinding = nodeType.toLowerCase().includes("finding"); + if (isFinding) return; + + const description = nodeTypeDescriptions[nodeType]; + + // Determine shape based on node type + const isInternet = nodeType.toLowerCase() === "internet"; + const shape: "rectangle" | "hexagon" | "cloud" = isInternet + ? "cloud" + : "rectangle"; + + if (description) { + items.push({ + label: description.name, + color: getNodeColor([nodeType]), + borderColor: getNodeBorderColor([nodeType]), + description: description.description, + shape, + }); + } else { + // Format unknown node types nicely + const formattedName = formatNodeTypeName(nodeType); + items.push({ + label: formattedName, + color: getNodeColor([nodeType]), + borderColor: getNodeBorderColor([nodeType]), + description: `${formattedName} node`, + shape, + }); + } + }); + + return items; +} + +/** + * Hexagon shape component for legend + */ +const HexagonShape = ({ + color, + borderColor, +}: { + color: string; + borderColor: string; +}) => ( + +); + +/** + * Pill shape component for legend + */ +const PillShape = ({ + color, + borderColor, +}: { + color: string; + borderColor: string; +}) => ( + +); + +/** + * Globe shape component for legend (used for Internet nodes) + */ +const GlobeShape = ({ + color, + borderColor, +}: { + color: string; + borderColor: string; +}) => ( + +); + +/** + * Edge line component for legend + */ +const EdgeLine = ({ dashed }: { dashed: boolean }) => ( + +); + +interface GraphLegendProps { + data?: AttackPathGraphData; +} + +/** + * Legend for attack path graph node types and edge styles + */ +export const GraphLegend = ({ data }: GraphLegendProps) => { + const nodeTypes = extractNodeTypes(data?.nodes); + + // Check if there are any findings in the data + const hasFindings = nodeTypes.some((type) => + type.toLowerCase().includes("finding"), + ); + + const legendItems = generateLegendItems(nodeTypes, hasFindings); + + if (legendItems.length === 0) { + return null; + } + + return ( + + +
+ {/* Node types section */} +
+ + {legendItems.map((item) => ( + + +
+ {item.shape === "hexagon" ? ( + + ) : item.shape === "cloud" ? ( + + ) : ( + + )} + + {item.label} + +
+
+ {item.description} +
+ ))} +
+
+ + {/* Edge types section */} +
+ + + +
+ + + Resource Connection + +
+
+ + Connection between infrastructure resources + +
+ + {hasFindings && ( + + +
+ + + Finding Connection + +
+
+ + Connection to a security finding + +
+ )} +
+
+
+
+
+ ); +}; diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/graph-loading.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/graph-loading.tsx new file mode 100644 index 0000000000..cf56231a05 --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/graph-loading.tsx @@ -0,0 +1,24 @@ +"use client"; + +import { Skeleton } from "@/components/shadcn/skeleton/skeleton"; + +/** + * Loading skeleton for graph visualization + * Shows while graph data is being fetched and processed + */ +export const GraphLoading = () => { + return ( +
+
+
+ + + +
+

+ Loading Attack Paths graph... +

+
+
+ ); +}; diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/index.ts b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/index.ts new file mode 100644 index 0000000000..ae529f31cd --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/index.ts @@ -0,0 +1,5 @@ +export type { AttackPathGraphRef } from "./attack-path-graph"; +export { AttackPathGraph } from "./attack-path-graph"; +export { GraphControls } from "./graph-controls"; +export { GraphLegend } from "./graph-legend"; +export { GraphLoading } from "./graph-loading"; diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/index.ts b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/index.ts new file mode 100644 index 0000000000..eac86fccc7 --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/index.ts @@ -0,0 +1,7 @@ +export { ExecuteButton } from "./execute-button"; +export * from "./graph"; +export * from "./node-detail"; +export { QueryParametersForm } from "./query-parameters-form"; +export { QuerySelector } from "./query-selector"; +export { ScanListTable } from "./scan-list-table"; +export { ScanStatusBadge } from "./scan-status-badge"; diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/node-detail/index.ts b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/node-detail/index.ts new file mode 100644 index 0000000000..c5895fe51f --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/node-detail/index.ts @@ -0,0 +1,4 @@ +export { NodeDetailContent, NodeDetailPanel } from "./node-detail-panel"; +export { NodeOverview } from "./node-overview"; +export { NodeRelationships } from "./node-relationships"; +export { NodeRemediation } from "./node-remediation"; diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/node-detail/node-detail-panel.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/node-detail/node-detail-panel.tsx new file mode 100644 index 0000000000..4d8885e65c --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/node-detail/node-detail-panel.tsx @@ -0,0 +1,132 @@ +"use client"; + +import { Button, Card, CardContent } from "@/components/shadcn"; +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet/sheet"; +import type { GraphNode } from "@/types/attack-paths"; + +import { NodeFindings } from "./node-findings"; +import { NodeOverview } from "./node-overview"; +import { NodeResources } from "./node-resources"; + +interface NodeDetailPanelProps { + node: GraphNode | null; + allNodes?: GraphNode[]; + onClose?: () => void; +} + +/** + * Node details content component (reusable) + */ +export const NodeDetailContent = ({ + node, + allNodes = [], +}: { + node: GraphNode; + allNodes?: GraphNode[]; +}) => { + const isProwlerFinding = node?.labels.some((label) => + label.toLowerCase().includes("finding"), + ); + + return ( +
+ {/* Node Overview Section */} + + +

+ Node Overview +

+ +
+
+ + {/* Related Findings Section - Only show for non-Finding nodes */} + {!isProwlerFinding && ( + + +

+ Related Findings +

+
+ Findings connected to this node +
+ +
+
+ )} + + {/* Affected Resources Section - Only show for Finding nodes */} + {isProwlerFinding && ( + + +

+ Affected Resources +

+
+ Resources affected by this finding +
+ +
+
+ )} +
+ ); +}; + +/** + * Right-side sheet panel for node details + * Shows comprehensive information about selected graph node + * Uses shadcn Sheet component for sliding panel from right + */ +export const NodeDetailPanel = ({ + node, + allNodes = [], + onClose, +}: NodeDetailPanelProps) => { + const isOpen = node !== null; + + const isProwlerFinding = node?.labels.some((label) => + label.toLowerCase().includes("finding"), + ); + + return ( + !open && onClose?.()}> + + +
+
+ Node Details + + {String(node?.properties?.name || node?.id.substring(0, 20))} + +
+ {node && isProwlerFinding && ( + + )} +
+
+ + {node && ( +
+ +
+ )} +
+
+ ); +}; diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/node-detail/node-findings.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/node-detail/node-findings.tsx new file mode 100644 index 0000000000..bb424a818c --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/node-detail/node-findings.tsx @@ -0,0 +1,102 @@ +"use client"; + +import { SeverityBadge } from "@/components/ui/table/severity-badge"; +import type { GraphNode } from "@/types/attack-paths"; + +const SEVERITY_LEVELS = { + informational: "informational", + low: "low", + medium: "medium", + high: "high", + critical: "critical", +} as const; + +type Severity = (typeof SEVERITY_LEVELS)[keyof typeof SEVERITY_LEVELS]; + +interface NodeFindingsProps { + node: GraphNode; + allNodes?: GraphNode[]; +} + +/** + * Node findings section showing related findings for the selected node + * Displays findings that are connected to the node via HAS_FINDING edges + */ +export const NodeFindings = ({ node, allNodes = [] }: NodeFindingsProps) => { + // Get finding IDs from the node's findings array (populated by adapter) + const findingIds = node.findings || []; + + // Get the actual finding nodes + const findingNodes = allNodes.filter((n) => findingIds.includes(n.id)); + + if (findingNodes.length === 0) { + return null; + } + + const normalizeSeverity = ( + severity?: string | number | boolean | string[] | number[] | null, + ): Severity => { + const sev = String( + Array.isArray(severity) ? severity[0] : severity || "", + ).toLowerCase(); + if (sev in SEVERITY_LEVELS) { + return sev as Severity; + } + return "informational"; + }; + + return ( +
    + {findingNodes.map((finding) => { + // Get the finding name (check_title preferred, then name) + const findingName = String( + finding.properties?.check_title || + finding.properties?.name || + finding.properties?.finding_id || + "Unknown Finding", + ); + // Use properties.id for display, fallback to graph node id + const findingId = String(finding.properties?.id || finding.id); + + return ( +
  • +
    +
    +
    + {finding.properties?.severity && ( + + )} +
    + {findingName} +
    +
    +

    + ID: {findingId} +

    +
    + + View Full Finding → + +
    + {finding.properties?.description && ( +
    + {String(finding.properties.description)} +
    + )} +
  • + ); + })} +
+ ); +}; diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/node-detail/node-overview.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/node-detail/node-overview.tsx new file mode 100644 index 0000000000..e614ce521a --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/node-detail/node-overview.tsx @@ -0,0 +1,109 @@ +"use client"; + +import { CodeSnippet } from "@/components/ui/code-snippet/code-snippet"; +import { InfoField } from "@/components/ui/entities"; +import { DateWithTime } from "@/components/ui/entities/date-with-time"; +import type { GraphNode, GraphNodePropertyValue } from "@/types/attack-paths"; + +import { formatNodeLabels } from "../../_lib"; + +interface NodeOverviewProps { + node: GraphNode; +} + +/** + * Node overview section showing basic node information + */ +export const NodeOverview = ({ node }: NodeOverviewProps) => { + const renderValue = (value: GraphNodePropertyValue) => { + if (value === null || value === undefined || value === "") { + return "-"; + } + if (Array.isArray(value)) { + return value.join(", "); + } + return String(value); + }; + + const isFinding = node.labels.some((label) => + label.toLowerCase().includes("finding"), + ); + + return ( +
+
+ {formatNodeLabels(node.labels)} + {isFinding && node.properties.check_title && ( + + {String(node.properties.check_title)} + + )} + {isFinding && node.properties.id && ( + + + + )} +
+ + {/* Display all properties */} +
+

+ Properties +

+
+ {Object.entries(node.properties).map(([key, value]) => { + // Skip internal properties + if (key.startsWith("_")) { + return null; + } + + // Skip check_title and id for findings as they're shown prominently above + if (isFinding && (key === "check_title" || key === "id")) { + return null; + } + + // Format timestamp values + const isTimestamp = + key.includes("date") || + key.includes("time") || + key.includes("at") || + key.includes("seen"); + + return ( + + {isTimestamp && typeof value === "number" ? ( + + ) : isTimestamp && + typeof value === "string" && + value.match(/^\d+$/) ? ( + + ) : typeof value === "object" ? ( + + {JSON.stringify(value).substring(0, 50)}... + + ) : ( + renderValue(value) + )} + + ); + })} +
+
+
+ ); +}; + +// Helper function to format property names +function formatPropertyName(name: string): string { + return name + .replace(/([A-Z])/g, " $1") + .replace(/_/g, " ") + .replace(/\b\w/g, (l) => l.toUpperCase()) + .trim(); +} diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/node-detail/node-relationships.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/node-detail/node-relationships.tsx new file mode 100644 index 0000000000..7370204990 --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/node-detail/node-relationships.tsx @@ -0,0 +1,105 @@ +"use client"; + +import { cn } from "@/lib/utils"; +import type { GraphEdge } from "@/types/attack-paths"; + +interface NodeRelationshipsProps { + incomingEdges: GraphEdge[]; + outgoingEdges: GraphEdge[]; +} + +/** + * Format edge type to human-readable label + * e.g., "HAS_FINDING" -> "Has Finding" + */ +function formatEdgeType(edgeType: string): string { + return edgeType + .split("_") + .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) + .join(" "); +} + +interface EdgeItemProps { + edge: GraphEdge; + isOutgoing: boolean; +} + +/** + * Reusable edge item component + */ +function EdgeItem({ edge, isOutgoing }: EdgeItemProps) { + const targetId = + typeof edge.target === "string" ? edge.target : String(edge.target); + const sourceId = + typeof edge.source === "string" ? edge.source : String(edge.source); + const displayId = (isOutgoing ? targetId : sourceId).substring(0, 30); + + return ( +
+ + {displayId} + + + {formatEdgeType(edge.type)} + +
+ ); +} + +/** + * Node relationships section showing incoming and outgoing edges + */ +export const NodeRelationships = ({ + incomingEdges, + outgoingEdges, +}: NodeRelationshipsProps) => { + return ( +
+ {/* Outgoing Relationships */} +
+

+ Outgoing Relationships ({outgoingEdges.length}) +

+ {outgoingEdges.length > 0 ? ( +
+ {outgoingEdges.map((edge) => ( + + ))} +
+ ) : ( +

+ No outgoing relationships +

+ )} +
+ + {/* Incoming Relationships */} +
+

+ Incoming Relationships ({incomingEdges.length}) +

+ {incomingEdges.length > 0 ? ( +
+ {incomingEdges.map((edge) => ( + + ))} +
+ ) : ( +

+ No incoming relationships +

+ )} +
+
+ ); +}; diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/node-detail/node-remediation.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/node-detail/node-remediation.tsx new file mode 100644 index 0000000000..e3421e5a43 --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/node-detail/node-remediation.tsx @@ -0,0 +1,83 @@ +"use client"; + +import Link from "next/link"; + +import { Badge } from "@/components/shadcn/badge/badge"; + +interface Finding { + id: string; + title: string; + severity: "critical" | "high" | "medium" | "low" | "info"; + status: "PASS" | "FAIL" | "MANUAL"; +} + +interface NodeRemediationProps { + findings: Finding[]; +} + +/** + * Node remediation section showing related Prowler findings + */ +export const NodeRemediation = ({ findings }: NodeRemediationProps) => { + const getSeverityVariant = (severity: string) => { + switch (severity) { + case "critical": + return "destructive"; + case "high": + return "default"; + case "medium": + return "secondary"; + case "low": + return "outline"; + default: + return "default"; + } + }; + + const getStatusVariant = (status: string) => { + if (status === "PASS") return "default"; + if (status === "FAIL") return "destructive"; + return "secondary"; + }; + + return ( +
+ {findings.map((finding) => ( +
+
+
+
+ {finding.title} +
+

+ ID: {finding.id.substring(0, 12)}... +

+
+
+ + {finding.severity} + + + {finding.status} + +
+
+
+ + View Full Finding → + +
+
+ ))} +
+ ); +}; diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/node-detail/node-resources.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/node-detail/node-resources.tsx new file mode 100644 index 0000000000..47f1f8db5f --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/node-detail/node-resources.tsx @@ -0,0 +1,85 @@ +"use client"; + +import { Badge } from "@/components/shadcn/badge/badge"; +import { cn } from "@/lib/utils"; +import type { GraphNode } from "@/types/attack-paths"; + +interface NodeResourcesProps { + node: GraphNode; + allNodes?: GraphNode[]; +} + +/** + * Node resources section showing affected resources for the selected finding node + * Displays resources that are connected to the finding node via HAS_FINDING edges + */ +export const NodeResources = ({ node, allNodes = [] }: NodeResourcesProps) => { + // Get resource IDs from the node's resources array (populated by adapter) + const resourceIds = node.resources || []; + + // Get the actual resource nodes + const resourceNodes = allNodes.filter((n) => resourceIds.includes(n.id)); + + if (resourceNodes.length === 0) { + return null; + } + + const getResourceTypeColor = (labels: string[]): string => { + const label = (labels[0] || "").toLowerCase(); + switch (label) { + case "s3bucket": + case "awsaccount": + case "ec2instance": + case "iamrole": + case "lambdafunction": + case "securitygroup": + return "bg-bg-data-aws"; + default: + return "bg-bg-data-muted"; + } + }; + + return ( +
    + {resourceNodes.map((resource) => { + // Use properties.id for display, fallback to graph node id + const resourceId = String(resource.properties?.id || resource.id); + + return ( +
  • +
    +
    +
    + {resource.labels && ( + + {resource.labels[0]} + + )} +
    + {String(resource.properties?.name || resourceId)} +
    +
    +

    + ID: {resourceId} +

    +
    +
    + {resource.properties?.arn && ( +
    + ARN: {String(resource.properties.arn)} +
    + )} +
  • + ); + })} +
+ ); +}; diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/query-parameters-form.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/query-parameters-form.tsx new file mode 100644 index 0000000000..ccbcf60547 --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/query-parameters-form.tsx @@ -0,0 +1,122 @@ +"use client"; + +import { Controller, useFormContext } from "react-hook-form"; + +import type { AttackPathQuery } from "@/types/attack-paths"; + +interface QueryParametersFormProps { + selectedQuery: AttackPathQuery | null | undefined; +} + +/** + * Dynamic form component for query parameters + * Renders form fields based on selected query's parameters + */ +export const QueryParametersForm = ({ + selectedQuery, +}: QueryParametersFormProps) => { + const { + control, + formState: { errors }, + } = useFormContext(); + + if (!selectedQuery || !selectedQuery.attributes.parameters.length) { + return ( +
+

+ This query requires no parameters. Click "Execute Query" to + proceed. +

+
+ ); + } + + return ( +
+

+ Query Parameters +

+ + {selectedQuery.attributes.parameters.map((param) => ( + { + if (param.data_type === "boolean") { + return ( +
+ +
+ ); + } + + const errorMessage = (() => { + const error = errors[param.name]; + if (error && typeof error.message === "string") { + return error.message; + } + return undefined; + })(); + + const descriptionId = `${param.name}-description`; + return ( +
+ + + {param.description && ( + + {param.description} + + )} + {errorMessage && ( + {errorMessage} + )} +
+ ); + }} + /> + ))} +
+ ); +}; 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 new file mode 100644 index 0000000000..65acb8a84a --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/query-selector.tsx @@ -0,0 +1,46 @@ +"use client"; + +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/shadcn"; +import type { AttackPathQuery } from "@/types/attack-paths"; + +interface QuerySelectorProps { + queries: AttackPathQuery[]; + selectedQueryId: string | null; + onQueryChange: (queryId: string) => void; +} + +/** + * Query selector dropdown component + * Allows users to select from available Attack Paths queries + */ +export const QuerySelector = ({ + queries, + selectedQueryId, + onQueryChange, +}: QuerySelectorProps) => { + return ( + + ); +}; diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/scan-list-table.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/scan-list-table.tsx new file mode 100644 index 0000000000..8e10305617 --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/scan-list-table.tsx @@ -0,0 +1,350 @@ +"use client"; + +import { + ChevronLeftIcon, + ChevronRightIcon, + DoubleArrowLeftIcon, + DoubleArrowRightIcon, +} from "@radix-ui/react-icons"; +import Link from "next/link"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import { useState } from "react"; + +import { Button } from "@/components/shadcn/button/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/shadcn/select/select"; +import { DateWithTime } from "@/components/ui/entities/date-with-time"; +import { EntityInfo } from "@/components/ui/entities/entity-info"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { cn } from "@/lib/utils"; +import type { ProviderType } from "@/types"; +import type { AttackPathScan } from "@/types/attack-paths"; +import { SCAN_STATES } from "@/types/attack-paths"; + +import { ScanStatusBadge } from "./scan-status-badge"; + +interface ScanListTableProps { + scans: AttackPathScan[]; +} + +const TABLE_COLUMN_COUNT = 6; +const DEFAULT_PAGE_SIZE = 5; +const PAGE_SIZE_OPTIONS = [2, 5, 10, 15]; + +const baseLinkClass = + "relative block rounded border-0 bg-transparent px-3 py-1.5 text-button-primary outline-none transition-all duration-300 hover:bg-bg-neutral-tertiary hover:text-text-neutral-primary focus:shadow-none dark:hover:bg-bg-neutral-secondary dark:hover:text-text-neutral-primary"; + +const disabledLinkClass = + "text-border-neutral-secondary dark:text-border-neutral-secondary hover:bg-transparent hover:text-border-neutral-secondary dark:hover:text-border-neutral-secondary cursor-default pointer-events-none"; + +/** + * Table displaying AWS account Attack Paths scans + * Shows scan metadata and allows selection of completed scans + */ +export const ScanListTable = ({ scans }: ScanListTableProps) => { + const pathname = usePathname(); + const searchParams = useSearchParams(); + const router = useRouter(); + + const selectedScanId = searchParams.get("scanId"); + const currentPage = parseInt(searchParams.get("scanPage") ?? "1"); + const pageSize = parseInt( + searchParams.get("scanPageSize") ?? String(DEFAULT_PAGE_SIZE), + ); + const [selectedPageSize, setSelectedPageSize] = useState(String(pageSize)); + + const totalPages = Math.ceil(scans.length / pageSize); + const startIndex = (currentPage - 1) * pageSize; + const endIndex = startIndex + pageSize; + const paginatedScans = scans.slice(startIndex, endIndex); + + const handleSelectScan = (scanId: string) => { + const params = new URLSearchParams(searchParams); + params.set("scanId", scanId); + router.push(`${pathname}?${params.toString()}`); + }; + + const isSelectDisabled = (scan: AttackPathScan) => { + return ( + scan.attributes.state !== SCAN_STATES.COMPLETED || + selectedScanId === scan.id + ); + }; + + const getSelectButtonLabel = (scan: AttackPathScan) => { + if (selectedScanId === scan.id) { + return "Selected"; + } + if (scan.attributes.state === SCAN_STATES.SCHEDULED) { + return "Scheduled"; + } + if (scan.attributes.state === SCAN_STATES.EXECUTING) { + return "Waiting..."; + } + if (scan.attributes.state === SCAN_STATES.FAILED) { + return "Failed"; + } + return "Select"; + }; + + const createPageUrl = (pageNumber: number | string) => { + const params = new URLSearchParams(searchParams); + + // Preserve scanId if it exists + const scanId = searchParams.get("scanId"); + + if (+pageNumber > totalPages) { + return `${pathname}?${params.toString()}`; + } + + params.set("scanPage", pageNumber.toString()); + + // Ensure that scanId is preserved + if (scanId) params.set("scanId", scanId); + + return `${pathname}?${params.toString()}`; + }; + + const isFirstPage = currentPage === 1; + const isLastPage = currentPage === totalPages; + + return ( + <> +
+ + + + Provider / Account + Last Scan Date + Status + Progress + Duration + Action + + + + {scans.length === 0 ? ( + + + No Attack Paths scans available. + + + ) : ( + paginatedScans.map((scan) => { + const isDisabled = isSelectDisabled(scan); + const isSelected = selectedScanId === scan.id; + const duration = scan.attributes.duration + ? `${Math.floor(scan.attributes.duration / 60)}m ${scan.attributes.duration % 60}s` + : "-"; + + return ( + + + + + + {scan.attributes.completed_at ? ( + + ) : ( + "-" + )} + + + + + + + {scan.attributes.progress}% + + + + {duration} + + + + + + ); + }) + )} + +
+ + {/* Pagination Controls */} + {scans.length > 0 && ( +
+
+ {scans.length} scans in total +
+ {scans.length > DEFAULT_PAGE_SIZE && ( +
+ {/* Rows per page selector */} +
+

+ Rows per page +

+ +
+
+ Page {currentPage} of {totalPages} +
+
+ isFirstPage && e.preventDefault()} + > +
+
+ )} +
+ )} +
+

+ Only Attack Paths scans with "Completed" status can be + selected. Scans in progress will update automatically. +

+ + ); +}; diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/scan-status-badge.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/scan-status-badge.tsx new file mode 100644 index 0000000000..74c7302126 --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/scan-status-badge.tsx @@ -0,0 +1,59 @@ +"use client"; + +import { Loader2 } from "lucide-react"; + +import { Badge } from "@/components/shadcn/badge/badge"; +import type { ScanState } from "@/types/attack-paths"; + +interface ScanStatusBadgeProps { + status: ScanState; + progress?: number; +} + +/** + * Status badge for attack path scan status + * Shows visual indicator and text for scan progress + */ +export const ScanStatusBadge = ({ + status, + progress = 0, +}: ScanStatusBadgeProps) => { + if (status === "scheduled") { + return ( + + Scheduled + + ); + } + + if (status === "available") { + return ( + + Queued + + ); + } + + if (status === "executing") { + return ( + + + In Progress ({progress}%) + + ); + } + + if (status === "completed") { + return ( + + Completed + + ); + } + + return ( + + Failed + + ); +}; diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_hooks/index.ts b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_hooks/index.ts new file mode 100644 index 0000000000..eec8e5f81a --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_hooks/index.ts @@ -0,0 +1,3 @@ +export { useGraphState } from "./use-graph-state"; +export { useQueryBuilder } from "./use-query-builder"; +export { useWizardState } from "./use-wizard-state"; diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_hooks/use-graph-state.ts b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_hooks/use-graph-state.ts new file mode 100644 index 0000000000..9664891907 --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_hooks/use-graph-state.ts @@ -0,0 +1,109 @@ +"use client"; + +import { create } from "zustand"; + +import type { + AttackPathGraphData, + GraphNode, + GraphState, +} from "@/types/attack-paths"; + +interface GraphStore extends GraphState { + setGraphData: (data: AttackPathGraphData) => void; + setSelectedNodeId: (nodeId: string | null) => void; + setLoading: (loading: boolean) => void; + setError: (error: string | null) => void; + setZoom: (zoomLevel: number) => void; + setPan: (panX: number, panY: number) => void; + reset: () => void; +} + +const initialState: GraphState = { + data: null, + selectedNodeId: null, + loading: false, + error: null, + zoomLevel: 1, + panX: 0, + panY: 0, +}; + +const useGraphStore = create((set) => ({ + ...initialState, + setGraphData: (data) => set({ data, error: null }), + setSelectedNodeId: (nodeId) => set({ selectedNodeId: nodeId }), + setLoading: (loading) => set({ loading }), + setError: (error) => set({ error }), + setZoom: (zoomLevel) => set({ zoomLevel }), + setPan: (panX, panY) => set({ panX, panY }), + reset: () => set(initialState), +})); + +/** + * Custom hook for managing graph visualization state + * Handles graph data, node selection, zoom/pan, and loading states + */ +export const useGraphState = () => { + const store = useGraphStore(); + + // Zustand store methods are stable, no need to memoize + const updateGraphData = (data: AttackPathGraphData) => { + store.setGraphData(data); + }; + + const selectNode = (nodeId: string | null) => { + store.setSelectedNodeId(nodeId); + }; + + const getSelectedNode = (): GraphNode | null => { + if (!store.data?.nodes || !store.selectedNodeId) return null; + return ( + store.data.nodes.find((node) => node.id === store.selectedNodeId) || null + ); + }; + + const startLoading = () => { + store.setLoading(true); + }; + + const stopLoading = () => { + store.setLoading(false); + }; + + const setError = (error: string | null) => { + store.setError(error); + }; + + const updateZoomAndPan = (zoomLevel: number, panX: number, panY: number) => { + store.setZoom(zoomLevel); + store.setPan(panX, panY); + }; + + const resetGraph = () => { + store.reset(); + }; + + const clearGraph = () => { + store.setGraphData({ nodes: [], edges: [] }); + store.setSelectedNodeId(null); + }; + + return { + data: store.data, + selectedNodeId: store.selectedNodeId, + selectedNode: getSelectedNode(), + loading: store.loading, + error: store.error, + zoomLevel: store.zoomLevel, + panX: store.panX, + panY: store.panY, + updateGraphData, + selectNode, + startLoading, + stopLoading, + setError, + updateZoomAndPan, + resetGraph, + clearGraph, + }; +}; diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_hooks/use-query-builder.ts b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_hooks/use-query-builder.ts new file mode 100644 index 0000000000..b05c9d463c --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_hooks/use-query-builder.ts @@ -0,0 +1,98 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { useEffect, useState } from "react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; + +import type { AttackPathQuery } from "@/types/attack-paths"; + +/** + * Custom hook for managing query builder form state + * Handles query selection, parameter validation, and form submission + */ +export const useQueryBuilder = (availableQueries: AttackPathQuery[]) => { + const [selectedQuery, setSelectedQuery] = useState(null); + + // Generate dynamic Zod schema based on selected query parameters + const getValidationSchema = (queryId: string | null) => { + const schemaObject: Record = {}; + + if (queryId) { + const query = availableQueries.find((q) => q.id === queryId); + + if (query) { + query.attributes.parameters.forEach((param) => { + let fieldSchema: z.ZodTypeAny = z + .string() + .min(1, `${param.label} is required`); + + if (param.data_type === "number") { + fieldSchema = z.coerce.number().refine((val) => val >= 0, { + message: `${param.label} must be a non-negative number`, + }); + } else if (param.data_type === "boolean") { + fieldSchema = z.boolean().default(false); + } + + schemaObject[param.name] = fieldSchema; + }); + } + } + + return z.object(schemaObject); + }; + + const getDefaultValues = (queryId: string | null) => { + const defaults: Record = {}; + + const query = availableQueries.find((q) => q.id === queryId); + if (query) { + query.attributes.parameters.forEach((param) => { + defaults[param.name] = param.data_type === "boolean" ? false : ""; + }); + } + + return defaults; + }; + + const form = useForm({ + resolver: zodResolver(getValidationSchema(selectedQuery)), + mode: "onChange", + defaultValues: getDefaultValues(selectedQuery), + }); + + // Update form when selectedQuery changes + useEffect(() => { + form.reset(getDefaultValues(selectedQuery), { + keepDirtyValues: false, + }); + }, [selectedQuery]); // eslint-disable-line react-hooks/exhaustive-deps + + const selectedQueryData = availableQueries.find( + (q) => q.id === selectedQuery, + ); + + const handleQueryChange = (queryId: string) => { + setSelectedQuery(queryId); + form.reset(); + }; + + const getQueryParameters = () => { + return form.getValues(); + }; + + const isFormValid = () => { + return form.formState.isValid; + }; + + return { + selectedQuery, + selectedQueryData, + availableQueries, + form, + handleQueryChange, + getQueryParameters, + isFormValid, + }; +}; 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 new file mode 100644 index 0000000000..787cb56617 --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_hooks/use-wizard-state.ts @@ -0,0 +1,91 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useCallback } from "react"; +import { create } from "zustand"; + +import type { WizardState } from "@/types/attack-paths"; + +interface WizardStore extends WizardState { + setCurrentStep: (step: 1 | 2) => void; + setSelectedScanId: (scanId: string) => void; + setSelectedQuery: (queryId: string) => void; + setQueryParameters: ( + parameters: Record, + ) => void; + reset: () => void; +} + +const initialState: WizardState = { + currentStep: 1, + selectedScanId: null, + selectedQuery: null, + queryParameters: {}, +}; + +const useWizardStore = create((set) => ({ + ...initialState, + setCurrentStep: (step) => set({ currentStep: step }), + setSelectedScanId: (scanId) => set({ selectedScanId: scanId }), + setSelectedQuery: (queryId) => set({ selectedQuery: queryId }), + setQueryParameters: (parameters) => set({ queryParameters: parameters }), + reset: () => set(initialState), +})); + +/** + * Custom hook for managing Attack Paths wizard state + * Handles step navigation, scan selection, and query configuration + */ +export const useWizardState = () => { + const router = useRouter(); + + const store = useWizardStore(); + + // Derive current step from URL path + const currentStep: 1 | 2 = + typeof window !== "undefined" + ? window.location.pathname.includes("query-builder") + ? 2 + : 1 + : 1; + + const goToSelectScan = useCallback(() => { + store.setCurrentStep(1); + router.push("/attack-paths/select-scan"); + }, [router, store]); + + const goToQueryBuilder = useCallback( + (scanId: string) => { + store.setSelectedScanId(scanId); + store.setCurrentStep(2); + router.push(`/attack-paths/query-builder?scanId=${scanId}`); + }, + [router, store], + ); + + const updateQueryParameters = useCallback( + (parameters: Record) => { + store.setQueryParameters(parameters); + }, + [store], + ); + + const getScanIdFromUrl = useCallback(() => { + const params = new URLSearchParams( + typeof window !== "undefined" ? window.location.search : "", + ); + return params.get("scanId") || store.selectedScanId; + }, [store.selectedScanId]); + + return { + currentStep, + selectedScanId: store.selectedScanId || getScanIdFromUrl(), + selectedQuery: store.selectedQuery, + queryParameters: store.queryParameters, + goToSelectScan, + goToQueryBuilder, + setSelectedQuery: store.setSelectedQuery, + updateQueryParameters, + reset: store.reset, + }; +}; diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/export.ts b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/export.ts new file mode 100644 index 0000000000..fd04b6a31e --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/export.ts @@ -0,0 +1,145 @@ +/** + * Export utilities for attack path graphs + * Handles exporting graph visualization to various formats + */ + +/** + * Helper function to download a blob as a file + * @param blob The blob to download + * @param filename The name of the file + */ +const downloadBlob = (blob: Blob, filename: string) => { + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); +}; + +/** + * Export graph as SVG image + * @param svgElement The SVG element to export + * @param filename The name of the file to download + */ +export const exportGraphAsSVG = ( + svgElement: SVGSVGElement | null, + filename: string = "attack-path-graph.svg", +) => { + if (!svgElement) return; + + try { + // Clone the SVG element to avoid modifying the original + const clonedSvg = svgElement.cloneNode(true) as SVGSVGElement; + + // Find the main container group (first g element with transform) + const containerGroup = clonedSvg.querySelector("g"); + if (!containerGroup) { + throw new Error("Could not find graph container"); + } + + // Get the bounding box of the actual graph content + // We need to get it from the original SVG since cloned elements don't have computed geometry + const originalContainer = svgElement.querySelector("g"); + if (!originalContainer) { + throw new Error("Could not find original graph container"); + } + + const bbox = originalContainer.getBBox(); + + // Add padding around the content + const padding = 50; + const contentWidth = bbox.width + padding * 2; + const contentHeight = bbox.height + padding * 2; + + // Set the SVG dimensions to fit the content + clonedSvg.setAttribute("width", `${contentWidth}`); + clonedSvg.setAttribute("height", `${contentHeight}`); + clonedSvg.setAttribute( + "viewBox", + `${bbox.x - padding} ${bbox.y - padding} ${contentWidth} ${contentHeight}`, + ); + + // Remove the zoom transform from the container - the viewBox now handles positioning + containerGroup.removeAttribute("transform"); + + // Add white background for better visibility + const bgRect = document.createElementNS( + "http://www.w3.org/2000/svg", + "rect", + ); + bgRect.setAttribute("x", `${bbox.x - padding}`); + bgRect.setAttribute("y", `${bbox.y - padding}`); + bgRect.setAttribute("width", `${contentWidth}`); + bgRect.setAttribute("height", `${contentHeight}`); + bgRect.setAttribute("fill", "#1c1917"); // Dark background matching the app + clonedSvg.insertBefore(bgRect, clonedSvg.firstChild); + + const svgData = new XMLSerializer().serializeToString(clonedSvg); + const blob = new Blob([svgData], { type: "image/svg+xml" }); + downloadBlob(blob, filename); + } catch (error) { + console.error("Failed to export graph as SVG:", error); + throw new Error("Failed to export graph"); + } +}; + +/** + * Export graph as PNG image + * @param svgElement The SVG element to export + * @param filename The name of the file to download + */ +export const exportGraphAsPNG = async ( + svgElement: SVGSVGElement | null, + filename: string = "attack-path-graph.png", +) => { + if (!svgElement) return; + + try { + const svgData = new XMLSerializer().serializeToString(svgElement); + const canvas = document.createElement("canvas"); + const ctx = canvas.getContext("2d") as CanvasRenderingContext2D; + + if (!ctx) throw new Error("Could not get canvas context"); + + const svg = new Image(); + svg.onload = () => { + canvas.width = svg.width; + canvas.height = svg.height; + ctx.drawImage(svg, 0, 0); + canvas.toBlob((blob) => { + if (blob) { + downloadBlob(blob, filename); + } + }); + }; + svg.onerror = () => { + throw new Error("Failed to load SVG for PNG conversion"); + }; + svg.src = `data:image/svg+xml;base64,${btoa(svgData)}`; + } catch (error) { + console.error("Failed to export graph as PNG:", error); + throw new Error("Failed to export graph"); + } +}; + +/** + * Export graph data as JSON + * @param graphData The graph data to export + * @param filename The name of the file to download + */ +export const exportGraphAsJSON = ( + graphData: Record, + filename: string = "attack-path-graph.json", +) => { + try { + const jsonString = JSON.stringify(graphData, null, 2); + const blob = new Blob([jsonString], { type: "application/json" }); + downloadBlob(blob, filename); + } catch (error) { + console.error("Failed to export graph as JSON:", error); + throw new Error("Failed to export graph"); + } +}; diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/format.ts b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/format.ts new file mode 100644 index 0000000000..02871e270e --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/format.ts @@ -0,0 +1,25 @@ +/** + * Formatting utilities for attack path graph nodes + */ + +/** + * Format camelCase labels to space-separated text + * e.g., "ProwlerFinding" -> "Prowler Finding", "AWSAccount" -> "Aws Account" + */ +export function formatNodeLabel(label: string): string { + return label + .replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2") + .replace(/([a-z\d])([A-Z])/g, "$1 $2") + .trim() + .split(" ") + .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) + .join(" "); +} + +/** + * Format multiple node labels into a readable string + * e.g., ["ProwlerFinding"] -> "Prowler Finding" + */ +export function formatNodeLabels(labels: string[]): string { + return labels.map(formatNodeLabel).join(", "); +} diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/graph-colors.ts b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/graph-colors.ts new file mode 100644 index 0000000000..8a9a46a154 --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/graph-colors.ts @@ -0,0 +1,137 @@ +/** + * Color constants for attack path graph visualization + * Colors chosen to work well in both light and dark themes + */ + +/** + * Node fill colors - darker versions of design system severity colors + * Darkened to ensure white text has proper contrast (WCAG AA) + */ +export const GRAPH_NODE_COLORS = { + // Finding severities - darkened versions for white text readability + critical: "#cc0055", // Darker pink (from #ff006a) + high: "#c45a3a", // Darker coral (from #f77852) + medium: "#b8860b", // Dark goldenrod (from #fec94d) + low: "#8b9a3e", // Olive/dark yellow-green (from #fdfbd4) + info: "#2563eb", // Darker blue (from #3c8dff) + // Node types + prowlerFinding: "#ea580c", + awsAccount: "#f59e0b", // Amber 500 - AWS orange + attackPattern: "#16a34a", + summary: "#16a34a", + // Infrastructure + ec2Instance: "#0891b2", // Cyan 600 + s3Bucket: "#0284c7", // Sky 600 + iamRole: "#7c3aed", // Violet 600 + iamPolicy: "#7c3aed", + lambdaFunction: "#d97706", // Amber 600 + securityGroup: "#0891b2", + default: "#0891b2", +} as const; + +/** + * Node border colors - using original design system colors as borders (lighter than fill) + */ +export const GRAPH_NODE_BORDER_COLORS = { + critical: "#ff006a", // Original --bg-data-critical + high: "#f77852", // Original --bg-data-high + medium: "#fec94d", // Original --bg-data-medium + low: "#c4d4a0", // Lighter olive + info: "#3c8dff", // Original --bg-data-info + prowlerFinding: "#fb923c", + awsAccount: "#fbbf24", // Amber 400 + attackPattern: "#4ade80", + summary: "#4ade80", + ec2Instance: "#22d3ee", // Cyan 400 + s3Bucket: "#38bdf8", // Sky 400 + iamRole: "#a78bfa", // Violet 400 + iamPolicy: "#a78bfa", + lambdaFunction: "#fbbf24", + securityGroup: "#22d3ee", + default: "#22d3ee", +} as const; + +export const GRAPH_EDGE_COLOR = "#f97316"; // Orange 500 +export const GRAPH_EDGE_GLOW_COLOR = "#fb923c"; +export const GRAPH_SELECTION_COLOR = "#ffffff"; +export const GRAPH_BORDER_COLOR = "#374151"; + +/** + * Get node fill color based on labels and properties + */ +export const getNodeColor = ( + labels: string[], + properties?: Record, +): string => { + const isFinding = labels.some((l) => l.toLowerCase().includes("finding")); + if (isFinding && properties?.severity) { + const severity = String(properties.severity).toLowerCase(); + if (severity === "critical") return GRAPH_NODE_COLORS.critical; + if (severity === "high") return GRAPH_NODE_COLORS.high; + if (severity === "medium") return GRAPH_NODE_COLORS.medium; + if (severity === "low") return GRAPH_NODE_COLORS.low; + if (severity === "informational" || severity === "info") + return GRAPH_NODE_COLORS.info; + return GRAPH_NODE_COLORS.prowlerFinding; + } + + if (labels.some((l) => l.toLowerCase().includes("attackpattern"))) + return GRAPH_NODE_COLORS.attackPattern; + if (labels.includes("AWSAccount")) return GRAPH_NODE_COLORS.awsAccount; + if (labels.includes("EC2Instance")) return GRAPH_NODE_COLORS.ec2Instance; + if (labels.includes("S3Bucket")) return GRAPH_NODE_COLORS.s3Bucket; + if (labels.includes("IAMRole")) return GRAPH_NODE_COLORS.iamRole; + if (labels.includes("IAMPolicy")) return GRAPH_NODE_COLORS.iamPolicy; + if (labels.includes("LambdaFunction")) + return GRAPH_NODE_COLORS.lambdaFunction; + if (labels.includes("SecurityGroup")) return GRAPH_NODE_COLORS.securityGroup; + + return GRAPH_NODE_COLORS.default; +}; + +/** + * Get node border color based on labels and properties + */ +export const getNodeBorderColor = ( + labels: string[], + properties?: Record, +): string => { + const isFinding = labels.some((l) => l.toLowerCase().includes("finding")); + if (isFinding && properties?.severity) { + const severity = String(properties.severity).toLowerCase(); + if (severity === "critical") return GRAPH_NODE_BORDER_COLORS.critical; + if (severity === "high") return GRAPH_NODE_BORDER_COLORS.high; + if (severity === "medium") return GRAPH_NODE_BORDER_COLORS.medium; + if (severity === "low") return GRAPH_NODE_BORDER_COLORS.low; + if (severity === "informational" || severity === "info") + return GRAPH_NODE_BORDER_COLORS.info; + return GRAPH_NODE_BORDER_COLORS.prowlerFinding; + } + + if (labels.some((l) => l.toLowerCase().includes("attackpattern"))) + return GRAPH_NODE_BORDER_COLORS.attackPattern; + if (labels.includes("AWSAccount")) return GRAPH_NODE_BORDER_COLORS.awsAccount; + if (labels.includes("EC2Instance")) + return GRAPH_NODE_BORDER_COLORS.ec2Instance; + if (labels.includes("S3Bucket")) return GRAPH_NODE_BORDER_COLORS.s3Bucket; + if (labels.includes("IAMRole")) return GRAPH_NODE_BORDER_COLORS.iamRole; + if (labels.includes("IAMPolicy")) return GRAPH_NODE_BORDER_COLORS.iamPolicy; + if (labels.includes("LambdaFunction")) + return GRAPH_NODE_BORDER_COLORS.lambdaFunction; + if (labels.includes("SecurityGroup")) + return GRAPH_NODE_BORDER_COLORS.securityGroup; + + return GRAPH_NODE_BORDER_COLORS.default; +}; + +/** + * Check if a background color is light (for determining text color) + */ +export const isLightBackground = (backgroundColor: string): boolean => { + const hex = backgroundColor.replace("#", ""); + const r = parseInt(hex.substring(0, 2), 16); + const g = parseInt(hex.substring(2, 4), 16); + const b = parseInt(hex.substring(4, 6), 16); + const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255; + return luminance > 0.5; +}; diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/index.ts b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/index.ts new file mode 100644 index 0000000000..04bfc406b7 --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/index.ts @@ -0,0 +1,14 @@ +export { + exportGraphAsJSON, + exportGraphAsPNG, + exportGraphAsSVG, +} from "./export"; +export { formatNodeLabel, formatNodeLabels } from "./format"; +export { + getNodeBorderColor, + getNodeColor, + GRAPH_EDGE_COLOR, + GRAPH_NODE_BORDER_COLORS, + GRAPH_NODE_COLORS, + GRAPH_SELECTION_COLOR, +} from "./graph-colors"; diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/page.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/page.tsx new file mode 100644 index 0000000000..7577b1448d --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/page.tsx @@ -0,0 +1,588 @@ +"use client"; + +import { Maximize2, X } from "lucide-react"; +import { useSearchParams } from "next/navigation"; +import { Suspense, useCallback, 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 { Button, Card, CardContent } from "@/components/shadcn"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, + useToast, +} from "@/components/ui"; +import type { + AttackPathQuery, + 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 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; + } + + // 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?.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("No data returned from query"); + showErrorToast("Error", "Query returned no data"); + } + } catch (error) { + const errorMsg = + error instanceof Error ? error.message : "Failed to execute query"; + graphState.resetGraph(); + graphState.setError(errorMsg); + showErrorToast("Error", errorMsg); + } finally { + graphState.stopLoading(); + } + }; + + const handleNodeClick = (node: GraphNode) => { + graphState.selectNode(node.id); + + // Only scroll to details if it's a finding node + const isFinding = node.labels.some((label) => + label.toLowerCase().includes("finding"), + ); + + if (isFinding) { + // Scroll to node details section after a short delay + setTimeout(() => { + nodeDetailsRef.current?.scrollIntoView({ + behavior: "smooth", + block: "nearest", + }); + }, 100); + } + }; + + 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. +

+
+ + {/* Top Section - Scans Table and Query Builder (2 columns) */} +
+ {/* Scans Table Section - Left Column */} +
+ {scansLoading ? ( +
+

Loading scans...

+
+ ) : scans.length === 0 ? ( +
+

No scans available

+
+ ) : ( + Loading scans...
}> + + + )} +
+ + {/* Query Builder Section - Right Column */} +
+ {!scanId ? ( +

+ Select a scan from the table on the left to begin. +

+ ) : queriesLoading ? ( +

Loading queries...

+ ) : queriesError ? ( +

+ {queriesError} +

+ ) : ( + <> + + + + {queryBuilder.selectedQuery && ( + + )} + + +
+ +
+ + {graphState.error && ( +
+ {graphState.error} +
+ )} + + )} +
+
+ + {/* Bottom Section - Graph Visualization (Full Width) */} +
+ {graphState.loading ? ( + + ) : graphState.data && + graphState.data.nodes && + graphState.data.nodes.length > 0 ? ( + <> + {/* Info message and controls */} +
+
+ + + Click on any resource node to view its related findings + +
+ + {/* 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 */} +
+ +
+ + ) : ( +
+

+ Select a query and click "Execute Query" to visualize + the Attack Paths graph +

+
+ )} +
+ + {/* 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/page.tsx b/ui/app/(prowler)/attack-paths/page.tsx new file mode 100644 index 0000000000..3fe92b08f8 --- /dev/null +++ b/ui/app/(prowler)/attack-paths/page.tsx @@ -0,0 +1,9 @@ +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"); +} diff --git a/ui/app/(prowler)/findings/page.tsx b/ui/app/(prowler)/findings/page.tsx index a25abfe411..47599d8335 100644 --- a/ui/app/(prowler)/findings/page.tsx +++ b/ui/app/(prowler)/findings/page.tsx @@ -2,6 +2,7 @@ import { Spacer } from "@heroui/spacer"; import { Suspense } from "react"; import { + getFindingById, getFindings, getLatestFindings, getLatestMetadataInfo, @@ -9,6 +10,7 @@ import { } from "@/actions/findings"; import { getProviders } from "@/actions/providers"; import { getScans } from "@/actions/scans"; +import { FindingDetailsSheet } from "@/components/findings"; import { FindingsFilters } from "@/components/findings/findings-filters"; import { ColumnFindings, @@ -43,15 +45,79 @@ export default async function Findings({ // Check if the searchParams contain any date or scan filter const hasDateOrScan = hasDateOrScanFilter(resolvedSearchParams); - const [metadataInfoData, providersData, scansData] = await Promise.all([ - (hasDateOrScan ? getMetadataInfo : getLatestMetadataInfo)({ - query, - sort: encodedSort, - filters, - }), - getProviders({ pageSize: 50 }), - getScans({ pageSize: 50 }), - ]); + // Check if there's a specific finding ID to fetch + const findingId = resolvedSearchParams.id?.toString(); + + const [metadataInfoData, providersData, scansData, findingByIdData] = + await Promise.all([ + (hasDateOrScan ? getMetadataInfo : getLatestMetadataInfo)({ + query, + sort: encodedSort, + filters, + }), + getProviders({ pageSize: 50 }), + getScans({ pageSize: 50 }), + findingId + ? getFindingById(findingId, "resources,scan.provider") + : Promise.resolve(null), + ]); + + // Process the finding data to match the expected structure + const processedFinding = findingByIdData?.data + ? (() => { + const finding = findingByIdData.data; + const included = findingByIdData.included || []; + + // Build dictionaries from included data + type IncludedItem = { + type: string; + id: string; + attributes: Record; + relationships?: { + provider?: { data?: { id: string } }; + }; + }; + + const resourceDict: Record = {}; + const scanDict: Record = {}; + const providerDict: Record = {}; + + included.forEach((item: IncludedItem) => { + if (item.type === "resources") { + resourceDict[item.id] = { + id: item.id, + attributes: item.attributes, + }; + } else if (item.type === "scans") { + scanDict[item.id] = item; + } else if (item.type === "providers") { + providerDict[item.id] = { + id: item.id, + attributes: item.attributes, + }; + } + }); + + const scanId = finding.relationships?.scan?.data?.id; + const resourceId = finding.relationships?.resources?.data?.[0]?.id; + const scan = scanId ? scanDict[scanId] : undefined; + const providerId = scan?.relationships?.provider?.data?.id; + + const resource = resourceId ? resourceDict[resourceId] : undefined; + const provider = providerId ? providerDict[providerId] : undefined; + + return { + ...finding, + relationships: { + scan: scan + ? { data: scan, attributes: scan.attributes } + : undefined, + resource: resource, + provider: provider, + }, + } as FindingProps; + })() + : null; // Extract unique regions and services from the new endpoint const uniqueRegions = metadataInfoData?.data?.attributes?.regions || []; @@ -98,6 +164,7 @@ export default async function Findings({ }> + {processedFinding && } ); } diff --git a/ui/components/findings/finding-details-sheet.tsx b/ui/components/findings/finding-details-sheet.tsx new file mode 100644 index 0000000000..ddea0e13c4 --- /dev/null +++ b/ui/components/findings/finding-details-sheet.tsx @@ -0,0 +1,46 @@ +"use client"; + +import { usePathname, useRouter, useSearchParams } from "next/navigation"; + +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet"; +import { FindingProps } from "@/types/components"; + +import { FindingDetail } from "./table/finding-detail"; + +interface FindingDetailsSheetProps { + finding: FindingProps; +} + +export const FindingDetailsSheet = ({ finding }: FindingDetailsSheetProps) => { + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + + const handleOpenChange = (open: boolean) => { + if (!open) { + const params = new URLSearchParams(searchParams.toString()); + params.delete("id"); + router.push(`${pathname}?${params.toString()}`, { scroll: false }); + } + }; + + return ( + + + + Finding Details + + View the finding details + + + + + + ); +}; diff --git a/ui/components/findings/index.ts b/ui/components/findings/index.ts index 2e2bece9f7..a43ec5f622 100644 --- a/ui/components/findings/index.ts +++ b/ui/components/findings/index.ts @@ -1 +1,2 @@ +export * from "./finding-details-sheet"; export * from "./muted"; diff --git a/ui/components/findings/table/column-findings.tsx b/ui/components/findings/table/column-findings.tsx index 88554f49fe..ceed02d8bc 100644 --- a/ui/components/findings/table/column-findings.tsx +++ b/ui/components/findings/table/column-findings.tsx @@ -2,7 +2,7 @@ import { ColumnDef } from "@tanstack/react-table"; import { Database } from "lucide-react"; -import { useSearchParams } from "next/navigation"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { DataTableRowDetails } from "@/components/findings/table"; import { DataTableRowActions } from "@/components/findings/table/data-table-row-actions"; @@ -51,13 +51,18 @@ const getProviderData = ( ); }; -const FindingDetailsCell = ({ row }: { row: any }) => { +const FindingDetailsCell = ({ row }: { row: { original: FindingProps } }) => { + const router = useRouter(); + const pathname = usePathname(); const searchParams = useSearchParams(); - const findingId = searchParams.get("id"); - const isOpen = findingId === row.original.id; + const findingIdFromUrl = searchParams.get("id"); + + // If there's an id in the URL, the sheet is controlled by FindingDetailsSheet component + // so we don't open a local sheet for any row + const isUrlControlled = !!findingIdFromUrl; const handleOpenChange = (open: boolean) => { - const params = new URLSearchParams(searchParams); + const params = new URLSearchParams(searchParams.toString()); if (open) { params.set("id", row.original.id); @@ -65,7 +70,7 @@ const FindingDetailsCell = ({ row }: { row: any }) => { params.delete("id"); } - window.history.pushState({}, "", `?${params.toString()}`); + router.push(`${pathname}?${params.toString()}`, { scroll: false }); }; return ( @@ -76,7 +81,7 @@ const FindingDetailsCell = ({ row }: { row: any }) => { } title="Finding Details" description="View the finding details" - defaultOpen={isOpen} + open={isUrlControlled ? false : undefined} onOpenChange={handleOpenChange} >

{renderValue(attributes.check_metadata.checktitle)} - - + + + + + Copy finding link to clipboard

@@ -164,16 +169,16 @@ export const FindingDetail = ({ {attributes.status === "FAIL" && ( - {attributes.check_metadata.risk} - + )} @@ -223,11 +228,13 @@ export const FindingDetail = ({ {/* CLI Command section */} {attributes.check_metadata.remediation.code.cli && ( - +
{attributes.check_metadata.remediation.code.cli} - +
)} @@ -276,16 +283,21 @@ export const FindingDetail = ({ Resource Details {providerDetails.provider === "iac" && gitUrl && ( - - - - + + + + + + + + Go to Resource in the Repository + )} diff --git a/ui/components/scans/auto-refresh.tsx b/ui/components/scans/auto-refresh.tsx index fa2c93424f..3a61bbba8e 100644 --- a/ui/components/scans/auto-refresh.tsx +++ b/ui/components/scans/auto-refresh.tsx @@ -5,9 +5,11 @@ import { useEffect } from "react"; interface AutoRefreshProps { hasExecutingScan: boolean; + /** Optional callback for client-side refresh (used when data is managed in local state) */ + onRefresh?: () => void | Promise; } -export function AutoRefresh({ hasExecutingScan }: AutoRefreshProps) { +export function AutoRefresh({ hasExecutingScan, onRefresh }: AutoRefreshProps) { const router = useRouter(); const searchParams = useSearchParams(); @@ -19,11 +21,17 @@ export function AutoRefresh({ hasExecutingScan }: AutoRefreshProps) { if (scanId) return; const interval = setInterval(() => { - router.refresh(); + if (onRefresh) { + // Use custom refresh callback for client-side state management + onRefresh(); + } else { + // Default: trigger server-side refresh + router.refresh(); + } }, 5000); return () => clearInterval(interval); - }, [hasExecutingScan, router, searchParams]); + }, [hasExecutingScan, router, searchParams, onRefresh]); return null; } diff --git a/ui/components/ui/breadcrumbs/breadcrumb-navigation.tsx b/ui/components/ui/breadcrumbs/breadcrumb-navigation.tsx index cbc46c6d25..02740bae7a 100644 --- a/ui/components/ui/breadcrumbs/breadcrumb-navigation.tsx +++ b/ui/components/ui/breadcrumbs/breadcrumb-navigation.tsx @@ -54,6 +54,7 @@ export function BreadcrumbNavigation({ "/manage-groups": "lucide:users-2", "/services": "lucide:server", "/workloads": "lucide:layers", + "/attack-paths": "lucide:git-branch", }; const pathSegments = pathname @@ -156,6 +157,7 @@ export function BreadcrumbNavigation({ > {breadcrumb.icon && typeof breadcrumb.icon === "string" ? (