feat(attack-paths): apply Scope Rule pattern for feature-local organization (#9270)

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Alan Buscaglia
2025-11-28 17:05:35 +01:00
committed by GitHub
parent 5a85db103d
commit 15cb87534c
58 changed files with 5441 additions and 65 deletions
-1
View File
@@ -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
+10 -3
View File
@@ -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 ""
+1
View File
@@ -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
+4
View File
@@ -0,0 +1,4 @@
export * from "./queries";
export * from "./queries.adapter";
export * from "./scans";
export * from "./scans.adapter";
@@ -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 };
}
+97
View File
@@ -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<string, string | number | boolean>,
): Promise<AttackPathQueryResult | 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: 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;
}
};
@@ -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, unknown>,
): 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<string, unknown>
>,
): 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
};
}
+89
View File
@@ -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;
}
+69
View File
@@ -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;
}
};
@@ -0,0 +1,2 @@
export { VerticalSteps } from "./vertical-steps";
export { WorkflowAttackPaths } from "./workflow-attack-paths";
@@ -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<HTMLButtonElement> {
/**
* 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 (
<svg
{...props}
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<m.path
animate={{ pathLength: 1 }}
d="M5 13l4 4L19 7"
initial={{ pathLength: 0 }}
strokeLinecap="round"
strokeLinejoin="round"
transition={{
delay: 0.2,
type: "tween",
ease: "easeOut",
duration: 0.3,
}}
/>
</svg>
);
}
export const VerticalSteps = forwardRef<HTMLButtonElement, VerticalStepsProps>(
(
{
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 (
<nav aria-label="Progress" className="max-w-fit">
<ol className={cn("flex flex-col gap-y-3", colors, className)}>
{steps?.map((step, stepIdx) => {
const status =
currentStep === stepIdx
? "active"
: currentStep < stepIdx
? "inactive"
: "complete";
return (
<li key={stepIdx} className="relative">
<div className="flex w-full max-w-full items-center">
<button
key={stepIdx}
ref={ref}
aria-current={status === "active" ? "step" : undefined}
className={cn(
"group rounded-large flex w-full cursor-pointer items-center justify-center gap-4 px-3 py-2.5",
stepClassName,
)}
onClick={() => setCurrentStep(stepIdx)}
{...props}
>
<div className="flex h-full items-center">
<LazyMotion features={domAnimation}>
<div className="relative">
<m.div
animate={status}
className={cn(
"border-medium text-large text-default-foreground relative flex h-[34px] w-[34px] items-center justify-center rounded-full font-semibold",
{
"shadow-lg": status === "complete",
},
)}
data-status={status}
initial={false}
transition={{ duration: 0.25 }}
variants={{
inactive: {
backgroundColor: "transparent",
borderColor: "var(--inactive-border-color)",
color: "var(--inactive-color)",
},
active: {
backgroundColor: "transparent",
borderColor: "var(--active-border-color)",
color: "var(--active-color)",
},
complete: {
backgroundColor:
"var(--complete-background-color)",
borderColor: "var(--complete-border-color)",
},
}}
>
<div className="flex items-center justify-center">
{status === "complete" ? (
<CheckIcon className="h-6 w-6 text-(--active-fg-color)" />
) : (
<span>{stepIdx + 1}</span>
)}
</div>
</m.div>
</div>
</LazyMotion>
</div>
<div className="flex-1 text-left">
<div>
<div
className={cn(
"text-medium text-default-foreground font-medium transition-[color,opacity] duration-300 group-active:opacity-70",
{
"text-default-500": status === "inactive",
},
)}
>
{step.title}
</div>
<div
className={cn(
"text-tiny text-default-600 lg:text-small transition-[color,opacity] duration-300 group-active:opacity-70",
{
"text-default-500": status === "inactive",
},
)}
>
{step.description}
</div>
</div>
</div>
</button>
</div>
{stepIdx < steps.length - 1 && !hideProgressBars && (
<div
aria-hidden="true"
className={cn(
"pointer-events-none absolute top-[calc(64px*var(--idx)+1)] left-3 flex h-1/2 -translate-y-1/3 items-center px-4",
)}
style={
{
"--idx": stepIdx,
} as CSSProperties
}
>
<div
className={cn(
"relative h-full w-0.5 bg-(--inactive-bar-color) transition-colors duration-300",
"after:absolute after:block after:h-0 after:w-full after:bg-(--active-border-color) after:transition-[height] after:duration-300 after:content-['']",
{
"after:h-full": stepIdx < currentStep,
},
)}
/>
</div>
)}
</li>
);
})}
</ol>
</nav>
);
},
);
VerticalSteps.displayName = "VerticalSteps";
@@ -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 (
<section className="flex flex-col gap-6">
<div>
<div className="bg-bg-neutral-tertiary mb-4 h-2 w-full overflow-hidden rounded-full">
<div
className="bg-success-primary h-full transition-all duration-300"
style={{ width: `${progressPercentage}%` }}
/>
</div>
<h3 className="dark:text-prowler-theme-pale/90 text-sm font-semibold">
Step {currentStep + 1} of {steps.length}
</h3>
</div>
<VerticalSteps currentStep={currentStep} steps={steps} color="success" />
</section>
);
};
@@ -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 (
<>
<Navbar title="Attack Paths Analysis" icon="" />
<div className="px-6 py-4 sm:px-8 xl:px-10">
{/* Content */}
<div>{children}</div>
</div>
</>
);
}
@@ -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 (
<Button
variant="default"
size="lg"
disabled={isDisabled || isLoading}
onClick={onExecute}
className="w-full gap-2 font-semibold sm:w-auto"
>
{!isLoading && <Play size={18} />}
{isLoading ? "Executing Query..." : "Execute Query"}
</Button>
);
};
@@ -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<AttackPathGraphRef>;
}
/**
* 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<SVGSVGElement>(null);
const [zoomLevel, setZoomLevel] = useState(1);
const zoomBehaviorRef = useRef<ZoomBehavior<SVGSVGElement, unknown> | null>(
null,
);
const containerRef = useRef<ReturnType<
typeof select<SVGGElement, unknown>
> | null>(null);
const svgSelectionRef = useRef<ReturnType<
typeof select<SVGSVGElement, unknown>
> | null>(null);
const hiddenNodeIdsRef = useRef<Set<string>>(new Set());
const onNodeClickRef = useRef(onNodeClick);
const nodeShapesRef = useRef<ReturnType<
typeof select<SVGRectElement, NodeData>
> | null>(null);
const resourcesWithFindingsRef = useRef<Set<string>>(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<SVGGElement, unknown>
>;
containerRef.current = container;
svgSelectionRef.current = svg as unknown as ReturnType<
typeof select<SVGSVGElement, unknown>
>;
// 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<string>();
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<string>();
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<string>();
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<SVGRectElement, NodeData>
>;
// 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<SVGSVGElement, unknown>().on(
"zoom",
(event: D3ZoomEvent<SVGSVGElement, unknown>) => {
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 (
<svg
ref={svgRef}
className="dark:bg-bg-neutral-secondary bg-bg-neutral-secondary h-full w-full rounded-lg"
/>
);
});
AttackPathGraphComponent.displayName = "AttackPathGraph";
export const AttackPathGraph = AttackPathGraphComponent;
@@ -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 (
<div className="flex items-center">
<div className="border-border-neutral-primary bg-bg-neutral-tertiary flex gap-1 rounded-lg border p-1">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={onZoomIn}
className="h-8 w-8 p-0"
>
<ZoomIn size={18} />
</Button>
</TooltipTrigger>
<TooltipContent>Zoom in</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={onZoomOut}
className="h-8 w-8 p-0"
>
<ZoomOut size={18} />
</Button>
</TooltipTrigger>
<TooltipContent>Zoom out</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={onFitToScreen}
className="h-8 w-8 p-0"
>
<Minimize2 size={18} />
</Button>
</TooltipTrigger>
<TooltipContent>Fit graph to view</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={onExport}
className="h-8 w-8 p-0"
>
<Download size={18} />
</Button>
</TooltipTrigger>
<TooltipContent>Export graph</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
);
};
@@ -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<string>();
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<string>();
// 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;
}) => (
<svg width="32" height="22" viewBox="0 0 32 22" aria-hidden="true">
<defs>
<filter id="legendGlow" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="1" result="coloredBlur" />
<feMerge>
<feMergeNode in="coloredBlur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
</defs>
<path
d="M5 1 L27 1 L31 11 L27 21 L5 21 L1 11 Z"
fill={color}
fillOpacity={0.85}
stroke={borderColor}
strokeWidth={1.5}
filter="url(#legendGlow)"
/>
</svg>
);
/**
* Pill shape component for legend
*/
const PillShape = ({
color,
borderColor,
}: {
color: string;
borderColor: string;
}) => (
<svg width="36" height="20" viewBox="0 0 36 20" aria-hidden="true">
<defs>
<filter id="legendGlow2" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="1" result="coloredBlur" />
<feMerge>
<feMergeNode in="coloredBlur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
</defs>
<rect
x="1"
y="1"
width="34"
height="18"
rx="9"
ry="9"
fill={color}
fillOpacity={0.85}
stroke={borderColor}
strokeWidth={1.5}
filter="url(#legendGlow2)"
/>
</svg>
);
/**
* Globe shape component for legend (used for Internet nodes)
*/
const GlobeShape = ({
color,
borderColor,
}: {
color: string;
borderColor: string;
}) => (
<svg width="24" height="24" viewBox="0 0 24 24" aria-hidden="true">
<defs>
<filter id="legendGlow3" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="1" result="coloredBlur" />
<feMerge>
<feMergeNode in="coloredBlur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
</defs>
{/* Globe circle */}
<circle
cx="12"
cy="12"
r="10"
fill={color}
fillOpacity={0.85}
stroke={borderColor}
strokeWidth={1.5}
filter="url(#legendGlow3)"
/>
{/* Horizontal line */}
<ellipse
cx="12"
cy="12"
rx="10"
ry="4"
fill="none"
stroke={borderColor}
strokeWidth={1}
strokeOpacity={0.6}
/>
{/* Vertical ellipse */}
<ellipse
cx="12"
cy="12"
rx="4"
ry="10"
fill="none"
stroke={borderColor}
strokeWidth={1}
strokeOpacity={0.6}
/>
</svg>
);
/**
* Edge line component for legend
*/
const EdgeLine = ({ dashed }: { dashed: boolean }) => (
<svg
width="60"
height="20"
viewBox="0 0 60 20"
aria-hidden="true"
style={{ overflow: "visible" }}
>
{/* Line */}
<line
x1="4"
y1="10"
x2="44"
y2="10"
stroke={GRAPH_EDGE_COLOR}
strokeWidth={3}
strokeLinecap="round"
strokeDasharray={dashed ? "8,6" : undefined}
/>
{/* Arrow head */}
<polygon points="44,5 56,10 44,15" fill={GRAPH_EDGE_COLOR} />
</svg>
);
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 (
<Card className="w-fit border-0">
<CardContent className="gap-3 p-4">
<div className="flex flex-col gap-4">
{/* Node types section */}
<div className="flex flex-col items-start gap-3 lg:flex-row lg:flex-wrap lg:items-center">
<TooltipProvider>
{legendItems.map((item) => (
<Tooltip key={item.label}>
<TooltipTrigger asChild>
<div
className="flex cursor-help items-center gap-2"
role="img"
aria-label={`${item.label}: ${item.description}`}
>
{item.shape === "hexagon" ? (
<HexagonShape
color={item.color}
borderColor={item.borderColor}
/>
) : item.shape === "cloud" ? (
<GlobeShape
color={item.color}
borderColor={item.borderColor}
/>
) : (
<PillShape
color={item.color}
borderColor={item.borderColor}
/>
)}
<span className="text-text-neutral-secondary text-xs">
{item.label}
</span>
</div>
</TooltipTrigger>
<TooltipContent>{item.description}</TooltipContent>
</Tooltip>
))}
</TooltipProvider>
</div>
{/* Edge types section */}
<div className="border-border-neutral-primary flex flex-col items-start gap-3 border-t pt-3 lg:flex-row lg:flex-wrap lg:items-center">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div
className="flex cursor-help items-center gap-2"
role="img"
aria-label="Solid line: Resource connection"
>
<EdgeLine dashed={false} />
<span className="text-text-neutral-secondary text-xs">
Resource Connection
</span>
</div>
</TooltipTrigger>
<TooltipContent>
Connection between infrastructure resources
</TooltipContent>
</Tooltip>
{hasFindings && (
<Tooltip>
<TooltipTrigger asChild>
<div
className="flex cursor-help items-center gap-2"
role="img"
aria-label="Dashed line: Finding connection"
>
<EdgeLine dashed={true} />
<span className="text-text-neutral-secondary text-xs">
Finding Connection
</span>
</div>
</TooltipTrigger>
<TooltipContent>
Connection to a security finding
</TooltipContent>
</Tooltip>
)}
</TooltipProvider>
</div>
</div>
</CardContent>
</Card>
);
};
@@ -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 (
<div className="dark:bg-prowler-blue-400 flex h-96 items-center justify-center rounded-lg bg-gray-50">
<div className="flex flex-col items-center gap-3">
<div className="flex gap-2">
<Skeleton className="h-3 w-3 rounded-full" />
<Skeleton className="h-3 w-3 rounded-full" />
<Skeleton className="h-3 w-3 rounded-full" />
</div>
<p className="text-sm text-gray-600 dark:text-gray-400">
Loading Attack Paths graph...
</p>
</div>
</div>
);
};
@@ -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";
@@ -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";
@@ -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";
@@ -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 (
<div className="flex flex-col gap-6">
{/* Node Overview Section */}
<Card className="border-border-neutral-secondary">
<CardContent className="flex flex-col gap-3 p-4">
<h3 className="dark:text-prowler-theme-pale/90 text-sm font-semibold">
Node Overview
</h3>
<NodeOverview node={node} />
</CardContent>
</Card>
{/* Related Findings Section - Only show for non-Finding nodes */}
{!isProwlerFinding && (
<Card className="border-border-neutral-secondary">
<CardContent className="flex flex-col gap-3 p-4">
<h3 className="dark:text-prowler-theme-pale/90 text-sm font-semibold">
Related Findings
</h3>
<div className="text-text-neutral-secondary dark:text-text-neutral-secondary text-xs">
Findings connected to this node
</div>
<NodeFindings node={node} allNodes={allNodes} />
</CardContent>
</Card>
)}
{/* Affected Resources Section - Only show for Finding nodes */}
{isProwlerFinding && (
<Card className="border-border-neutral-secondary">
<CardContent className="flex flex-col gap-3 p-4">
<h3 className="dark:text-prowler-theme-pale/90 text-sm font-semibold">
Affected Resources
</h3>
<div className="text-text-neutral-secondary dark:text-text-neutral-secondary text-xs">
Resources affected by this finding
</div>
<NodeResources node={node} allNodes={allNodes} />
</CardContent>
</Card>
)}
</div>
);
};
/**
* 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 (
<Sheet open={isOpen} onOpenChange={(open) => !open && onClose?.()}>
<SheetContent className="dark:bg-prowler-theme-midnight my-4 max-h-[calc(100vh-2rem)] max-w-[95vw] overflow-y-auto rounded-l-xl pt-10 md:my-8 md:max-h-[calc(100vh-4rem)] md:max-w-[55vw]">
<SheetHeader>
<div className="flex items-start justify-between gap-2">
<div className="flex-1">
<SheetTitle>Node Details</SheetTitle>
<SheetDescription>
{String(node?.properties?.name || node?.id.substring(0, 20))}
</SheetDescription>
</div>
{node && isProwlerFinding && (
<Button asChild variant="default" size="sm" className="mt-1">
<a
href={`/findings?id=${String(node.properties?.id || node.id)}`}
target="_blank"
rel="noopener noreferrer"
aria-label={`View finding ${String(node.properties?.id || node.id)}`}
>
View Finding
</a>
</Button>
)}
</div>
</SheetHeader>
{node && (
<div className="pt-6">
<NodeDetailContent node={node} allNodes={allNodes} />
</div>
)}
</SheetContent>
</Sheet>
);
};
@@ -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 (
<ul className="flex flex-col gap-3">
{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 (
<li
key={finding.id}
className="border-border-neutral-secondary rounded-lg border p-3"
>
<div className="flex items-start justify-between gap-2">
<div className="flex-1">
<div className="flex items-center gap-2">
{finding.properties?.severity && (
<SeverityBadge
severity={normalizeSeverity(finding.properties.severity)}
/>
)}
<h5 className="dark:text-prowler-theme-pale/90 text-sm font-medium">
{findingName}
</h5>
</div>
<p className="text-text-neutral-tertiary dark:text-text-neutral-tertiary mt-1 text-xs">
ID: {findingId}
</p>
</div>
<a
href={`/findings?id=${findingId}`}
target="_blank"
rel="noopener noreferrer"
aria-label={`View full finding for ${findingName}`}
className="text-text-info dark:text-text-info h-auto shrink-0 p-0 text-xs font-medium hover:underline"
>
View Full Finding
</a>
</div>
{finding.properties?.description && (
<div className="text-text-neutral-secondary dark:text-text-neutral-secondary mt-2 text-xs">
{String(finding.properties.description)}
</div>
)}
</li>
);
})}
</ul>
);
};
@@ -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 (
<div className="flex flex-col gap-4">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<InfoField label="Type">{formatNodeLabels(node.labels)}</InfoField>
{isFinding && node.properties.check_title && (
<InfoField label="Check Title">
{String(node.properties.check_title)}
</InfoField>
)}
{isFinding && node.properties.id && (
<InfoField label="Finding ID" variant="simple">
<CodeSnippet value={String(node.properties.id)} />
</InfoField>
)}
</div>
{/* Display all properties */}
<div className="mt-4 border-t border-gray-200 pt-4 dark:border-gray-700">
<h4 className="dark:text-prowler-theme-pale/90 mb-3 text-sm font-semibold">
Properties
</h4>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
{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 (
<InfoField key={key} label={formatPropertyName(key)}>
{isTimestamp && typeof value === "number" ? (
<DateWithTime
inline
dateTime={new Date(value).toISOString()}
/>
) : isTimestamp &&
typeof value === "string" &&
value.match(/^\d+$/) ? (
<DateWithTime
inline
dateTime={new Date(parseInt(value)).toISOString()}
/>
) : typeof value === "object" ? (
<code className="text-xs">
{JSON.stringify(value).substring(0, 50)}...
</code>
) : (
renderValue(value)
)}
</InfoField>
);
})}
</div>
</div>
</div>
);
};
// 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();
}
@@ -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 (
<div
key={edge.id}
className="border-border-neutral-tertiary dark:border-border-neutral-tertiary flex items-center justify-between rounded border p-2"
>
<code className="text-text-neutral-secondary dark:text-text-neutral-secondary text-xs">
{displayId}
</code>
<span
className={cn(
"rounded px-2 py-1 text-xs font-medium",
isOutgoing
? "bg-bg-data-info text-text-neutral-primary dark:text-text-neutral-primary"
: "bg-bg-pass-primary text-text-neutral-primary dark:text-text-neutral-primary",
)}
>
{formatEdgeType(edge.type)}
</span>
</div>
);
}
/**
* Node relationships section showing incoming and outgoing edges
*/
export const NodeRelationships = ({
incomingEdges,
outgoingEdges,
}: NodeRelationshipsProps) => {
return (
<div className="flex flex-col gap-6">
{/* Outgoing Relationships */}
<div>
<h4 className="dark:text-prowler-theme-pale/90 mb-3 text-sm font-semibold">
Outgoing Relationships ({outgoingEdges.length})
</h4>
{outgoingEdges.length > 0 ? (
<div className="space-y-2">
{outgoingEdges.map((edge) => (
<EdgeItem key={edge.id} edge={edge} isOutgoing />
))}
</div>
) : (
<p className="text-text-neutral-tertiary dark:text-text-neutral-tertiary text-xs">
No outgoing relationships
</p>
)}
</div>
{/* Incoming Relationships */}
<div className="border-border-neutral-tertiary dark:border-border-neutral-tertiary border-t pt-6">
<h4 className="dark:text-prowler-theme-pale/90 mb-3 text-sm font-semibold">
Incoming Relationships ({incomingEdges.length})
</h4>
{incomingEdges.length > 0 ? (
<div className="space-y-2">
{incomingEdges.map((edge) => (
<EdgeItem key={edge.id} edge={edge} isOutgoing={false} />
))}
</div>
) : (
<p className="text-text-neutral-tertiary dark:text-text-neutral-tertiary text-xs">
No incoming relationships
</p>
)}
</div>
</div>
);
};
@@ -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 (
<div className="flex flex-col gap-3">
{findings.map((finding) => (
<div
key={finding.id}
className="rounded-lg border border-gray-200 p-3 dark:border-gray-700"
>
<div className="flex items-start justify-between gap-2">
<div className="flex-1">
<h5 className="dark:text-prowler-theme-pale/90 text-sm font-medium">
{finding.title}
</h5>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
ID: {finding.id.substring(0, 12)}...
</p>
</div>
<div className="flex flex-col gap-1">
<Badge variant={getSeverityVariant(finding.severity)}>
{finding.severity}
</Badge>
<Badge variant={getStatusVariant(finding.status)}>
{finding.status}
</Badge>
</div>
</div>
<div className="mt-2">
<Link
href={`/findings?id=${finding.id}`}
target="_blank"
rel="noopener noreferrer"
aria-label={`View full finding for ${finding.title}`}
className="text-text-info dark:text-text-info text-sm transition-all hover:opacity-80 dark:hover:opacity-80"
>
View Full Finding
</Link>
</div>
</div>
))}
</div>
);
};
@@ -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 (
<ul className="flex flex-col gap-3">
{resourceNodes.map((resource) => {
// Use properties.id for display, fallback to graph node id
const resourceId = String(resource.properties?.id || resource.id);
return (
<li
key={resource.id}
className="border-border-neutral-secondary rounded-lg border p-3"
>
<div className="flex items-start justify-between gap-2">
<div className="flex-1">
<div className="flex items-center gap-2">
{resource.labels && (
<Badge
className={cn(
getResourceTypeColor(resource.labels),
"text-text-neutral-primary",
)}
>
{resource.labels[0]}
</Badge>
)}
<h5 className="dark:text-prowler-theme-pale/90 text-sm font-medium">
{String(resource.properties?.name || resourceId)}
</h5>
</div>
<p className="text-text-neutral-tertiary dark:text-text-neutral-tertiary mt-1 text-xs">
ID: {resourceId}
</p>
</div>
</div>
{resource.properties?.arn && (
<div className="text-text-neutral-secondary dark:text-text-neutral-secondary mt-2 text-xs">
ARN: {String(resource.properties.arn)}
</div>
)}
</li>
);
})}
</ul>
);
};
@@ -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 (
<div className="rounded-lg bg-blue-50 p-4 dark:bg-blue-950/20">
<p className="text-sm text-blue-700 dark:text-blue-300">
This query requires no parameters. Click &quot;Execute Query&quot; to
proceed.
</p>
</div>
);
}
return (
<div className="flex flex-col gap-4">
<h3 className="dark:text-prowler-theme-pale/90 text-sm font-semibold">
Query Parameters
</h3>
{selectedQuery.attributes.parameters.map((param) => (
<Controller
key={param.name}
name={param.name}
control={control}
render={({ field }) => {
if (param.data_type === "boolean") {
return (
<div className="flex flex-col gap-2">
<label className="flex cursor-pointer items-center gap-3">
<input
type="checkbox"
id={param.name}
checked={field.value === true || field.value === "true"}
onChange={(e) => field.onChange(e.target.checked)}
aria-label={param.label}
className="border-border-neutral-secondary bg-bg-neutral-primary text-text-primary focus:ring-primary dark:border-border-neutral-secondary dark:bg-bg-neutral-primary dark:text-text-primary h-4 w-4 rounded border focus:ring-2"
/>
<div className="flex flex-col gap-1">
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">
{param.label}
</span>
{param.description && (
<span className="text-xs text-gray-600 dark:text-gray-400">
{param.description}
</span>
)}
</div>
</label>
</div>
);
}
const errorMessage = (() => {
const error = errors[param.name];
if (error && typeof error.message === "string") {
return error.message;
}
return undefined;
})();
const descriptionId = `${param.name}-description`;
return (
<div className="flex flex-col gap-2">
<label
htmlFor={param.name}
className="text-sm font-medium text-gray-700 dark:text-gray-300"
>
{param.label}
{param.required && <span className="text-red-500"> *</span>}
</label>
<input
{...field}
id={param.name}
type={param.data_type === "number" ? "number" : "text"}
placeholder={
param.placeholder || `Enter ${param.label.toLowerCase()}`
}
value={field.value ?? ""}
aria-describedby={
param.description ? descriptionId : undefined
}
className="border-border-neutral-secondary bg-bg-neutral-primary text-text-neutral-primary placeholder-text-neutral-secondary focus:border-border-primary focus:ring-primary dark:border-border-neutral-secondary dark:bg-bg-neutral-primary dark:text-text-neutral-primary dark:placeholder-text-neutral-secondary dark:focus:border-border-primary rounded-md border px-3 py-2 text-sm focus:ring-1 focus:outline-none"
/>
{param.description && (
<span
id={descriptionId}
className="text-xs text-gray-600 dark:text-gray-400"
>
{param.description}
</span>
)}
{errorMessage && (
<span className="text-xs text-red-500">{errorMessage}</span>
)}
</div>
);
}}
/>
))}
</div>
);
};
@@ -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 (
<Select value={selectedQueryId || ""} onValueChange={onQueryChange}>
<SelectTrigger className="w-full text-left">
<SelectValue placeholder="Choose a query..." />
</SelectTrigger>
<SelectContent>
{queries.map((query) => (
<SelectItem key={query.id} value={query.id}>
<div className="flex flex-col gap-1">
<span className="font-medium">{query.attributes.name}</span>
<span className="text-xs text-gray-500">
{query.attributes.description}
</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
);
};
@@ -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 (
<>
<div className="minimal-scrollbar rounded-large shadow-small border-border-neutral-secondary bg-bg-neutral-secondary relative z-0 flex w-full flex-col gap-4 overflow-auto border p-4">
<Table aria-label="Attack Paths scans table listing provider accounts, scan dates, status, progress, and duration">
<TableHeader>
<TableRow>
<TableHead>Provider / Account</TableHead>
<TableHead>Last Scan Date</TableHead>
<TableHead>Status</TableHead>
<TableHead>Progress</TableHead>
<TableHead>Duration</TableHead>
<TableHead className="text-right">Action</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{scans.length === 0 ? (
<TableRow>
<TableCell
colSpan={TABLE_COLUMN_COUNT}
className="h-24 text-center"
>
No Attack Paths scans available.
</TableCell>
</TableRow>
) : (
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 (
<TableRow
key={scan.id}
className={
isSelected
? "bg-button-primary/10 dark:bg-button-primary/10"
: ""
}
>
<TableCell className="font-medium">
<EntityInfo
cloudProvider={
scan.attributes.provider_type as ProviderType
}
entityAlias={scan.attributes.provider_alias}
entityId={scan.attributes.provider_uid}
/>
</TableCell>
<TableCell>
{scan.attributes.completed_at ? (
<DateWithTime
inline
dateTime={scan.attributes.completed_at}
/>
) : (
"-"
)}
</TableCell>
<TableCell>
<ScanStatusBadge
status={scan.attributes.state}
progress={scan.attributes.progress}
/>
</TableCell>
<TableCell>
<span className="text-sm">
{scan.attributes.progress}%
</span>
</TableCell>
<TableCell>
<span className="text-sm">{duration}</span>
</TableCell>
<TableCell className="text-right">
<Button
type="button"
aria-label="Select scan"
disabled={isDisabled}
variant={isDisabled ? "secondary" : "default"}
onClick={() => handleSelectScan(scan.id)}
className="w-full max-w-24"
>
{getSelectButtonLabel(scan)}
</Button>
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
{/* Pagination Controls */}
{scans.length > 0 && (
<div className="flex w-full flex-col-reverse items-center justify-between gap-4 overflow-auto p-1 sm:flex-row sm:gap-8">
<div className="text-sm whitespace-nowrap">
{scans.length} scans in total
</div>
{scans.length > DEFAULT_PAGE_SIZE && (
<div className="flex flex-col-reverse items-center gap-4 sm:flex-row sm:gap-6 lg:gap-8">
{/* Rows per page selector */}
<div className="flex items-center gap-2">
<p className="text-sm font-medium whitespace-nowrap">
Rows per page
</p>
<Select
value={selectedPageSize}
onValueChange={(value) => {
setSelectedPageSize(value);
const params = new URLSearchParams(searchParams);
// Preserve scanId if it exists
const scanId = searchParams.get("scanId");
params.set("scanPageSize", value);
params.set("scanPage", "1");
// Ensure that scanId is preserved
if (scanId) params.set("scanId", scanId);
router.push(`${pathname}?${params.toString()}`);
}}
>
<SelectTrigger className="h-8 w-18">
<SelectValue />
</SelectTrigger>
<SelectContent side="top">
{PAGE_SIZE_OPTIONS.map((size) => (
<SelectItem
key={size}
value={`${size}`}
className="cursor-pointer"
>
{size}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center justify-center text-sm font-medium">
Page {currentPage} of {totalPages}
</div>
<div className="flex items-center gap-2">
<Link
aria-label="Go to first page"
className={cn(
baseLinkClass,
isFirstPage && disabledLinkClass,
)}
href={
isFirstPage
? pathname + "?" + searchParams.toString()
: createPageUrl(1)
}
aria-disabled={isFirstPage}
onClick={(e) => isFirstPage && e.preventDefault()}
>
<DoubleArrowLeftIcon
className="size-4"
aria-hidden="true"
/>
</Link>
<Link
aria-label="Go to previous page"
className={cn(
baseLinkClass,
isFirstPage && disabledLinkClass,
)}
href={
isFirstPage
? pathname + "?" + searchParams.toString()
: createPageUrl(currentPage - 1)
}
aria-disabled={isFirstPage}
onClick={(e) => isFirstPage && e.preventDefault()}
>
<ChevronLeftIcon className="size-4" aria-hidden="true" />
</Link>
<Link
aria-label="Go to next page"
className={cn(
baseLinkClass,
isLastPage && disabledLinkClass,
)}
href={
isLastPage
? pathname + "?" + searchParams.toString()
: createPageUrl(currentPage + 1)
}
aria-disabled={isLastPage}
onClick={(e) => isLastPage && e.preventDefault()}
>
<ChevronRightIcon className="size-4" aria-hidden="true" />
</Link>
<Link
aria-label="Go to last page"
className={cn(
baseLinkClass,
isLastPage && disabledLinkClass,
)}
href={
isLastPage
? pathname + "?" + searchParams.toString()
: createPageUrl(totalPages)
}
aria-disabled={isLastPage}
onClick={(e) => isLastPage && e.preventDefault()}
>
<DoubleArrowRightIcon
className="size-4"
aria-hidden="true"
/>
</Link>
</div>
</div>
)}
</div>
)}
</div>
<p className="text-text-neutral-secondary dark:text-text-neutral-secondary mt-6 text-xs">
Only Attack Paths scans with &quot;Completed&quot; status can be
selected. Scans in progress will update automatically.
</p>
</>
);
};
@@ -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 (
<Badge className="bg-bg-neutral-tertiary text-text-neutral-primary gap-2">
<span>Scheduled</span>
</Badge>
);
}
if (status === "available") {
return (
<Badge className="bg-bg-neutral-tertiary text-text-neutral-primary gap-2">
<span>Queued</span>
</Badge>
);
}
if (status === "executing") {
return (
<Badge className="bg-bg-warning-secondary text-text-neutral-primary gap-2">
<Loader2 size={14} className="animate-spin" />
<span>In Progress ({progress}%)</span>
</Badge>
);
}
if (status === "completed") {
return (
<Badge className="bg-bg-pass-secondary text-text-success-primary gap-2">
<span>Completed</span>
</Badge>
);
}
return (
<Badge className="bg-bg-fail-secondary text-text-error-primary gap-2">
<span>Failed</span>
</Badge>
);
};
@@ -0,0 +1,3 @@
export { useGraphState } from "./use-graph-state";
export { useQueryBuilder } from "./use-query-builder";
export { useWizardState } from "./use-wizard-state";
@@ -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<GraphStore>((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,
};
};
@@ -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<string | null>(null);
// Generate dynamic Zod schema based on selected query parameters
const getValidationSchema = (queryId: string | null) => {
const schemaObject: Record<string, z.ZodTypeAny> = {};
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<string, unknown> = {};
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,
};
};
@@ -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<string, string | number | boolean>,
) => void;
reset: () => void;
}
const initialState: WizardState = {
currentStep: 1,
selectedScanId: null,
selectedQuery: null,
queryParameters: {},
};
const useWizardStore = create<WizardStore>((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<string, string | number | boolean>) => {
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,
};
};
@@ -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<string, unknown>,
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");
}
};
@@ -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(", ");
}
@@ -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, unknown>,
): 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, unknown>,
): 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;
};
@@ -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";
@@ -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<AttackPathScan[]>([]);
const [queriesLoading, setQueriesLoading] = useState(true);
const [queriesError, setQueriesError] = useState<string | null>(null);
const [isFullscreenOpen, setIsFullscreenOpen] = useState(false);
const graphRef = useRef<AttackPathGraphRef>(null);
const fullscreenGraphRef = useRef<AttackPathGraphRef>(null);
const hasResetRef = useRef(false);
const nodeDetailsRef = useRef<HTMLDivElement>(null);
const graphContainerRef = useRef<HTMLDivElement>(null);
const [queries, setQueries] = useState<AttackPathQuery[]>([]);
// 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 (
<div className="flex flex-col gap-6">
{/* Auto-refresh scans when there's an executing scan */}
<AutoRefresh
hasExecutingScan={hasExecutingScan}
onRefresh={refreshScans}
/>
{/* Header */}
<div>
<h2 className="dark:text-prowler-theme-pale/90 text-xl font-semibold">
Attack Paths Analysis
</h2>
<p className="text-text-neutral-secondary dark:text-text-neutral-secondary mt-2 text-sm">
Select a scan, build a query, and visualize Attack Paths in your
infrastructure.
</p>
</div>
{/* Top Section - Scans Table and Query Builder (2 columns) */}
<div className="grid grid-cols-1 gap-8 xl:grid-cols-2">
{/* Scans Table Section - Left Column */}
<div>
{scansLoading ? (
<div className="minimal-scrollbar rounded-large shadow-small border-border-neutral-secondary bg-bg-neutral-secondary relative z-0 flex w-full flex-col gap-4 overflow-auto border p-4">
<p className="text-sm">Loading scans...</p>
</div>
) : scans.length === 0 ? (
<div className="minimal-scrollbar rounded-large shadow-small border-border-neutral-secondary bg-bg-neutral-secondary relative z-0 flex w-full flex-col gap-4 overflow-auto border p-4">
<p className="text-sm">No scans available</p>
</div>
) : (
<Suspense fallback={<div>Loading scans...</div>}>
<ScanListTable scans={scans} />
</Suspense>
)}
</div>
{/* Query Builder Section - Right Column */}
<div className="minimal-scrollbar rounded-large shadow-small border-border-neutral-secondary bg-bg-neutral-secondary relative z-0 flex w-full flex-col gap-4 overflow-auto border p-4">
{!scanId ? (
<p className="text-text-info dark:text-text-info text-sm">
Select a scan from the table on the left to begin.
</p>
) : queriesLoading ? (
<p className="text-sm">Loading queries...</p>
) : queriesError ? (
<p className="text-text-danger dark:text-text-danger text-sm">
{queriesError}
</p>
) : (
<>
<FormProvider {...queryBuilder.form}>
<QuerySelector
queries={queries}
selectedQueryId={queryBuilder.selectedQuery}
onQueryChange={handleQueryChange}
/>
{queryBuilder.selectedQuery && (
<QueryParametersForm
selectedQuery={queryBuilder.selectedQueryData}
/>
)}
</FormProvider>
<div className="flex gap-3">
<ExecuteButton
isLoading={graphState.loading}
isDisabled={!queryBuilder.selectedQuery}
onExecute={handleExecuteQuery}
/>
</div>
{graphState.error && (
<div className="bg-bg-danger-secondary text-text-danger dark:bg-bg-danger-secondary dark:text-text-danger rounded p-3 text-sm">
{graphState.error}
</div>
)}
</>
)}
</div>
</div>
{/* Bottom Section - Graph Visualization (Full Width) */}
<div className="minimal-scrollbar rounded-large shadow-small border-border-neutral-secondary bg-bg-neutral-secondary relative z-0 flex w-full flex-col gap-4 overflow-auto border p-4">
{graphState.loading ? (
<GraphLoading />
) : graphState.data &&
graphState.data.nodes &&
graphState.data.nodes.length > 0 ? (
<>
{/* Info message and controls */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div
className="bg-button-primary hover:bg-button-primary-hover inline-flex cursor-default items-center gap-2 rounded-md px-3 py-2 text-xs font-medium text-black shadow-sm transition-colors sm:px-4 sm:text-sm"
role="status"
aria-label="Graph interaction instructions"
>
<span className="flex-shrink-0" aria-hidden="true">
💡
</span>
<span className="flex-1">
Click on any resource node to view its related findings
</span>
</div>
{/* Graph controls and fullscreen button together */}
<div className="flex items-center gap-2">
<GraphControls
onZoomIn={() => graphRef.current?.zoomIn()}
onZoomOut={() => graphRef.current?.zoomOut()}
onFitToScreen={() => graphRef.current?.resetZoom()}
onExport={() =>
handleGraphExport(graphRef.current?.getSVGElement() || null)
}
/>
{/* Fullscreen button */}
<div className="border-border-neutral-primary bg-bg-neutral-tertiary flex gap-1 rounded-lg border p-1">
<Dialog
open={isFullscreenOpen}
onOpenChange={setIsFullscreenOpen}
>
<DialogTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
aria-label="Fullscreen"
>
<Maximize2 size={18} />
</Button>
</DialogTrigger>
<DialogContent className="flex h-full max-h-screen w-full max-w-full flex-col gap-0 p-0">
<DialogHeader className="px-4 pt-4 sm:px-6 sm:pt-6">
<DialogTitle className="text-lg">
Graph Fullscreen View
</DialogTitle>
</DialogHeader>
<div className="px-4 pt-4 pb-4 sm:px-6 sm:pt-6">
<GraphControls
onZoomIn={() => fullscreenGraphRef.current?.zoomIn()}
onZoomOut={() =>
fullscreenGraphRef.current?.zoomOut()
}
onFitToScreen={() =>
fullscreenGraphRef.current?.resetZoom()
}
onExport={() =>
handleGraphExport(
fullscreenGraphRef.current?.getSVGElement() ||
null,
)
}
/>
</div>
<div className="flex flex-1 gap-4 overflow-hidden px-4 pb-4 sm:px-6 sm:pb-6">
<div className="flex flex-1 items-center justify-center">
<AttackPathGraph
ref={fullscreenGraphRef}
data={graphState.data}
onNodeClick={handleNodeClick}
selectedNodeId={graphState.selectedNodeId}
/>
</div>
{/* Node Detail Panel - Side by side */}
{graphState.selectedNode && (
<section aria-labelledby="node-details-heading">
<Card className="w-96 overflow-y-auto">
<CardContent className="p-4">
<div className="mb-4 flex items-center justify-between">
<h3
id="node-details-heading"
className="text-sm font-semibold"
>
Node Details
</h3>
<Button
onClick={handleCloseDetails}
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
aria-label="Close node details"
>
<X size={16} />
</Button>
</div>
<p className="text-text-neutral-secondary dark:text-text-neutral-secondary mb-4 text-xs">
{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"}
</p>
<div className="flex flex-col gap-4">
<div>
<h4 className="mb-2 text-xs font-semibold">
Type
</h4>
<p className="text-text-neutral-secondary dark:text-text-neutral-secondary text-xs">
{graphState.selectedNode?.labels
.map(formatNodeLabel)
.join(", ")}
</p>
</div>
</div>
</CardContent>
</Card>
</section>
)}
</div>
</DialogContent>
</Dialog>
</div>
</div>
</div>
{/* Graph in the middle */}
<div ref={graphContainerRef} className="h-[calc(100vh-22rem)]">
<AttackPathGraph
ref={graphRef}
data={graphState.data}
onNodeClick={handleNodeClick}
selectedNodeId={graphState.selectedNodeId}
/>
</div>
{/* Legend below */}
<div className="hidden justify-center lg:flex">
<GraphLegend data={graphState.data} />
</div>
</>
) : (
<div className="flex flex-1 items-center justify-center text-center">
<p className="text-text-neutral-secondary dark:text-text-neutral-secondary text-sm">
Select a query and click &quot;Execute Query&quot; to visualize
the Attack Paths graph
</p>
</div>
)}
</div>
{/* Node Detail Panel - Below Graph */}
{graphState.selectedNode && graphState.data && (
<div
ref={nodeDetailsRef}
className="minimal-scrollbar rounded-large shadow-small border-border-neutral-secondary bg-bg-neutral-secondary relative z-0 flex w-full flex-col gap-4 overflow-auto border p-4"
>
<div className="flex items-center justify-between">
<div className="flex-1">
<h3 className="text-lg font-semibold">Node Details</h3>
<p className="text-text-neutral-secondary dark:text-text-neutral-secondary mt-1 text-sm">
{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",
)}
</p>
</div>
<div className="flex items-center gap-2">
{graphState.selectedNode.labels.some((label) =>
label.toLowerCase().includes("finding"),
) && (
<Button asChild variant="default" size="sm">
<a
href={`/findings?id=${String(graphState.selectedNode.properties?.id || graphState.selectedNode.id)}`}
target="_blank"
rel="noopener noreferrer"
aria-label={`View finding ${String(graphState.selectedNode.properties?.id || graphState.selectedNode.id)}`}
>
View Finding
</a>
</Button>
)}
<Button
onClick={handleCloseDetails}
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
aria-label="Close node details"
>
<X size={16} />
</Button>
</div>
</div>
<NodeDetailContent
node={graphState.selectedNode}
allNodes={graphState.data.nodes}
/>
</div>
)}
</div>
);
}
+9
View File
@@ -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");
}
+76 -9
View File
@@ -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<string, unknown>;
relationships?: {
provider?: { data?: { id: string } };
};
};
const resourceDict: Record<string, unknown> = {};
const scanDict: Record<string, IncludedItem> = {};
const providerDict: Record<string, unknown> = {};
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({
<Suspense key={searchParamsKey} fallback={<SkeletonTableFindings />}>
<SSRDataTable searchParams={resolvedSearchParams} />
</Suspense>
{processedFinding && <FindingDetailsSheet finding={processedFinding} />}
</ContentLayout>
);
}
@@ -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 (
<Sheet open={true} onOpenChange={handleOpenChange}>
<SheetContent className="my-4 max-h-[calc(100vh-2rem)] max-w-[95vw] overflow-y-auto pt-10 md:my-8 md:max-h-[calc(100vh-4rem)] md:max-w-[55vw]">
<SheetHeader>
<SheetTitle className="sr-only">Finding Details</SheetTitle>
<SheetDescription className="sr-only">
View the finding details
</SheetDescription>
</SheetHeader>
<FindingDetail findingDetails={finding} />
</SheetContent>
</Sheet>
);
};
+1
View File
@@ -1 +1,2 @@
export * from "./finding-details-sheet";
export * from "./muted";
@@ -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}
>
<DataTableRowDetails
+40 -28
View File
@@ -1,7 +1,5 @@
"use client";
import { Snippet } from "@heroui/snippet";
import { Tooltip } from "@heroui/tooltip";
import { ExternalLink, Link } from "lucide-react";
import ReactMarkdown from "react-markdown";
@@ -11,6 +9,9 @@ import {
CardContent,
CardHeader,
CardTitle,
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/shadcn";
import { CodeSnippet } from "@/components/ui/code-snippet/code-snippet";
import { CustomLink } from "@/components/ui/custom/custom-link";
@@ -18,6 +19,7 @@ import { EntityInfo, InfoField } from "@/components/ui/entities";
import { DateWithTime } from "@/components/ui/entities/date-with-time";
import { SeverityBadge } from "@/components/ui/table/severity-badge";
import { buildGitFileUrl, extractLineRangeFromUid } from "@/lib/iac-utils";
import { cn } from "@/lib/utils";
import { FindingProps, ProviderType } from "@/types";
import { Muted } from "../muted";
@@ -83,14 +85,17 @@ export const FindingDetail = ({
<div>
<h2 className="dark:text-prowler-theme-pale/90 line-clamp-2 flex items-center gap-2 text-lg leading-tight font-medium text-gray-800">
{renderValue(attributes.check_metadata.checktitle)}
<Tooltip content="Copy finding link to clipboard" size="sm">
<button
onClick={() => navigator.clipboard.writeText(url)}
className="text-bg-data-info inline-flex cursor-pointer transition-opacity hover:opacity-80"
aria-label="Copy finding link to clipboard"
>
<Link size={16} />
</button>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => navigator.clipboard.writeText(url)}
className="text-text-info inline-flex cursor-pointer transition-opacity hover:opacity-80"
aria-label="Copy finding link to clipboard"
>
<Link size={16} />
</button>
</TooltipTrigger>
<TooltipContent>Copy finding link to clipboard</TooltipContent>
</Tooltip>
</h2>
</div>
@@ -164,16 +169,16 @@ export const FindingDetail = ({
{attributes.status === "FAIL" && (
<InfoField label="Risk" variant="simple">
<Snippet
className="max-w-full py-2"
color="danger"
hideCopyButton
hideSymbol
<div
className={cn(
"max-w-full rounded-md border p-2",
"border-border-error-primary bg-bg-fail-secondary",
)}
>
<MarkdownContainer>
{attributes.check_metadata.risk}
</MarkdownContainer>
</Snippet>
</div>
</InfoField>
)}
@@ -223,11 +228,13 @@ export const FindingDetail = ({
{/* CLI Command section */}
{attributes.check_metadata.remediation.code.cli && (
<InfoField label="CLI Command" variant="simple">
<Snippet>
<div
className={cn("rounded-md p-2", "bg-bg-neutral-tertiary")}
>
<span className="text-xs whitespace-pre-line">
{attributes.check_metadata.remediation.code.cli}
</span>
</Snippet>
</div>
</InfoField>
)}
@@ -276,16 +283,21 @@ export const FindingDetail = ({
<CardTitle>Resource Details</CardTitle>
{providerDetails.provider === "iac" && gitUrl && (
<CardAction>
<Tooltip content="Go to Resource in the Repository" size="sm">
<a
href={gitUrl}
target="_blank"
rel="noopener noreferrer"
className="text-bg-data-info inline-flex cursor-pointer"
aria-label="Open resource in repository"
>
<ExternalLink size={16} className="inline" />
</a>
<Tooltip>
<TooltipTrigger asChild>
<a
href={gitUrl}
target="_blank"
rel="noopener noreferrer"
className="text-text-info inline-flex cursor-pointer"
aria-label="Open resource in repository"
>
<ExternalLink size={16} className="inline" />
</a>
</TooltipTrigger>
<TooltipContent>
Go to Resource in the Repository
</TooltipContent>
</Tooltip>
</CardAction>
)}
+11 -3
View File
@@ -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<void>;
}
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;
}
@@ -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" ? (
<Icon
aria-hidden="true"
className="text-text-neutral-primary"
height={24}
icon={breadcrumb.icon}
@@ -177,6 +179,7 @@ export function BreadcrumbNavigation({
>
{breadcrumb.icon && typeof breadcrumb.icon === "string" ? (
<Icon
aria-hidden="true"
className="text-text-neutral-primary"
height={24}
icon={breadcrumb.icon}
@@ -195,6 +198,7 @@ export function BreadcrumbNavigation({
<div className="flex items-center gap-2">
{breadcrumb.icon && typeof breadcrumb.icon === "string" ? (
<Icon
aria-hidden="true"
className="text-default-500"
height={24}
icon={breadcrumb.icon}
+21 -3
View File
@@ -20,6 +20,7 @@ interface MenuItemProps {
target?: string;
tooltip?: string;
isOpen: boolean;
highlight?: boolean;
}
export const MenuItem = ({
@@ -30,6 +31,7 @@ export const MenuItem = ({
target,
tooltip,
isOpen,
highlight,
}: MenuItemProps) => {
const pathname = usePathname();
const isActive = active !== undefined ? active : pathname.startsWith(href);
@@ -44,15 +46,31 @@ export const MenuItem = ({
variant={isActive ? "menu-active" : "menu-inactive"}
className={cn(
isOpen ? "w-full justify-start" : "w-14 justify-center",
highlight &&
"relative overflow-hidden before:absolute before:inset-0 before:rounded-lg before:bg-gradient-to-r before:from-emerald-500/20 before:via-teal-400/20 before:to-emerald-300/20 before:opacity-70",
)}
asChild
>
<Link href={href} target={target}>
<div className="flex items-center">
<span className={cn(isOpen ? "mr-4" : "")}>
<div className="relative z-10 flex items-center">
<span
className={cn(
isOpen ? "mr-4" : "",
highlight && "text-button-primary",
)}
>
<Icon size={18} />
</span>
{isOpen && <p className="max-w-[200px] truncate">{label}</p>}
{isOpen && (
<p className="max-w-[200px] truncate">
{label}
{highlight && (
<span className="ml-2 rounded-sm bg-emerald-500 px-1.5 py-0.5 text-[10px] font-semibold text-white">
NEW
</span>
)}
</p>
)}
</div>
</Link>
</Button>
+1
View File
@@ -119,6 +119,7 @@ export const Menu = ({ isOpen }: { isOpen: boolean }) => {
target={menu.target}
tooltip={menu.tooltip}
isOpen={isOpen}
highlight={menu.highlight}
/>
)}
</div>
+21 -9
View File
@@ -1,16 +1,18 @@
import { Chip } from "@heroui/chip";
import clsx from "clsx";
import React from "react";
import { SpinnerIcon } from "@/components/icons";
export type Status =
| "available"
| "scheduled"
| "executing"
| "completed"
| "failed"
| "cancelled";
const STATUS = {
available: "available",
scheduled: "scheduled",
executing: "executing",
completed: "completed",
failed: "failed",
cancelled: "cancelled",
} as const;
export type Status = (typeof STATUS)[keyof typeof STATUS];
const statusColorMap: Record<
Status,
@@ -24,6 +26,15 @@ const statusColorMap: Record<
cancelled: "danger",
};
const statusDisplayMap: Record<Status, string> = {
available: "queued",
scheduled: "scheduled",
executing: "executing",
completed: "completed",
failed: "failed",
cancelled: "cancelled",
};
export const StatusBadge = ({
status,
size = "sm",
@@ -37,6 +48,7 @@ export const StatusBadge = ({
className?: string;
}) => {
const color = statusColorMap[status as keyof typeof statusColorMap];
const displayLabel = statusDisplayMap[status] || status;
return (
<Chip
@@ -59,7 +71,7 @@ export const StatusBadge = ({
<span>executing</span>
</div>
) : (
<span className="flex items-center justify-center">{status}</span>
<span className="flex items-center justify-center">{displayLabel}</span>
)}
</Chip>
);
+18 -2
View File
@@ -125,7 +125,7 @@
"from": "1.1.15",
"to": "1.1.15",
"strategy": "installed",
"generatedAt": "2025-11-20T08:20:16.313Z"
"generatedAt": "2025-11-19T12:28:39.510Z"
},
{
"section": "dependencies",
@@ -207,6 +207,14 @@
"strategy": "installed",
"generatedAt": "2025-10-22T12:36:37.962Z"
},
{
"section": "dependencies",
"name": "@types/dagre",
"from": "0.7.53",
"to": "0.7.53",
"strategy": "installed",
"generatedAt": "2025-11-27T11:47:22.908Z"
},
{
"section": "dependencies",
"name": "@types/js-yaml",
@@ -253,7 +261,7 @@
"from": "1.1.1",
"to": "1.1.1",
"strategy": "installed",
"generatedAt": "2025-11-20T08:20:16.313Z"
"generatedAt": "2025-11-19T12:28:39.510Z"
},
{
"section": "dependencies",
@@ -263,6 +271,14 @@
"strategy": "installed",
"generatedAt": "2025-10-22T12:36:37.962Z"
},
{
"section": "dependencies",
"name": "dagre",
"from": "0.8.5",
"to": "0.8.5",
"strategy": "installed",
"generatedAt": "2025-11-27T11:47:22.908Z"
},
{
"section": "dependencies",
"name": "date-fns",
+14
View File
@@ -1,6 +1,7 @@
import {
CloudCog,
Cog,
GitBranch,
Group,
Mail,
MessageCircleQuestion,
@@ -75,6 +76,19 @@ export const getMenuList = ({
},
],
},
{
groupLabel: "",
menus: [
{
href: "/attack-paths",
label: "Attack Paths",
icon: GitBranch,
active: pathname.startsWith("/attack-paths"),
highlight: true,
},
],
},
{
groupLabel: "",
menus: [
+27
View File
@@ -35,6 +35,7 @@
"@tailwindcss/postcss": "4.1.13",
"@tailwindcss/typography": "0.5.16",
"@tanstack/react-table": "8.21.3",
"@types/dagre": "0.7.53",
"@types/js-yaml": "4.0.9",
"ai": "5.0.59",
"alert": "6.0.2",
@@ -42,6 +43,7 @@
"clsx": "2.1.1",
"cmdk": "1.1.1",
"d3": "7.9.0",
"dagre": "0.8.5",
"date-fns": "4.1.0",
"framer-motion": "11.18.2",
"intl-messageformat": "10.7.16",
@@ -11800,6 +11802,12 @@
"@types/d3-selection": "*"
}
},
"node_modules/@types/dagre": {
"version": "0.7.53",
"resolved": "https://registry.npmjs.org/@types/dagre/-/dagre-0.7.53.tgz",
"integrity": "sha512-f4gkWqzPZvYmKhOsDnhq/R8mO4UMcKdxZo+i5SCkOU1wvGeHJeUXGIHeE9pnwGyPMDof1Vx5ZQo4nxpeg2TTVQ==",
"license": "MIT"
},
"node_modules/@types/debug": {
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz",
@@ -14534,6 +14542,16 @@
"node": ">=12"
}
},
"node_modules/dagre": {
"version": "0.8.5",
"resolved": "https://registry.npmjs.org/dagre/-/dagre-0.8.5.tgz",
"integrity": "sha512-/aTqmnRta7x7MCCpExk7HQL2O4owCT2h8NT//9I1OQ9vt29Pa0BzSAkR5lwFUcQ7491yVi/3CXU9jQ5o0Mn2Sw==",
"license": "MIT",
"dependencies": {
"graphlib": "^2.1.8",
"lodash": "^4.17.15"
}
},
"node_modules/dagre-d3-es": {
"version": "7.0.13",
"resolved": "https://registry.npmjs.org/dagre-d3-es/-/dagre-d3-es-7.0.13.tgz",
@@ -16872,6 +16890,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/graphlib": {
"version": "2.1.8",
"resolved": "https://registry.npmjs.org/graphlib/-/graphlib-2.1.8.tgz",
"integrity": "sha512-jcLLfkpoVGmH7/InMC/1hIvOPSUh38oJtGhvrOFGzioE1DZ+0YW16RgmOJhHiuWTvGiJQ9Z1Ik43JvkRPRvE+A==",
"license": "MIT",
"dependencies": {
"lodash": "^4.17.15"
}
},
"node_modules/graphql": {
"version": "16.12.0",
"resolved": "https://registry.npmjs.org/graphql/-/graphql-16.12.0.tgz",
+2
View File
@@ -49,6 +49,7 @@
"@tailwindcss/postcss": "4.1.13",
"@tailwindcss/typography": "0.5.16",
"@tanstack/react-table": "8.21.3",
"@types/dagre": "0.7.53",
"@types/js-yaml": "4.0.9",
"ai": "5.0.59",
"alert": "6.0.2",
@@ -56,6 +57,7 @@
"clsx": "2.1.1",
"cmdk": "1.1.1",
"d3": "7.9.0",
"dagre": "0.8.5",
"date-fns": "4.1.0",
"framer-motion": "11.18.2",
"intl-messageformat": "10.7.16",
+3
View File
@@ -54,6 +54,7 @@
--bg-pass-primary: var(--color-emerald-400);
--bg-pass-secondary: var(--color-emerald-50);
--bg-warning-primary: var(--color-orange-500);
--bg-warning-secondary: var(--color-orange-50);
--bg-fail-primary: var(--color-rose-500);
--bg-fail-secondary: var(--color-rose-50);
@@ -123,6 +124,7 @@
--bg-pass-primary: var(--color-green-400);
--bg-pass-secondary: var(--color-emerald-900);
--bg-warning-primary: var(--color-orange-400);
--bg-warning-secondary: var(--color-orange-900);
--bg-fail-primary: var(--color-rose-500);
--bg-fail-secondary: #432232;
@@ -209,6 +211,7 @@
--color-bg-pass: var(--bg-pass-primary);
--color-bg-pass-secondary: var(--bg-pass-secondary);
--color-bg-warning: var(--bg-warning-primary);
--color-bg-warning-secondary: var(--bg-warning-secondary);
--color-bg-fail: var(--bg-fail-primary);
--color-bg-fail-secondary: var(--bg-fail-secondary);
}
+245
View File
@@ -0,0 +1,245 @@
/**
* Attack Paths Feature Types
* Defines all TypeScript interfaces for the Attack Paths visualization feature
*/
// Scan state constants
export const SCAN_STATES = {
AVAILABLE: "available",
SCHEDULED: "scheduled",
EXECUTING: "executing",
COMPLETED: "completed",
FAILED: "failed",
} as const;
export type ScanState = (typeof SCAN_STATES)[keyof typeof SCAN_STATES];
// Attack Path Scan - Relationship Data
export interface RelationshipData {
type: string;
id: string;
}
export interface RelationshipWrapper {
data: RelationshipData;
}
export interface ScanRelationships {
provider: RelationshipWrapper;
scan: RelationshipWrapper;
task: RelationshipWrapper;
}
// Provider type constants
export const PROVIDER_TYPES = {
AWS: "aws",
AZURE: "azure",
GCP: "gcp",
} as const;
export type ProviderType = (typeof PROVIDER_TYPES)[keyof typeof PROVIDER_TYPES];
// Attack Path Scan Response
export interface AttackPathScanAttributes {
state: ScanState;
progress: number;
provider_alias: string;
provider_type: ProviderType;
provider_uid: string;
inserted_at: string;
started_at: string;
completed_at: string | null;
duration: number | null;
}
export interface AttackPathScan {
type: "attack-paths-scans";
id: string;
attributes: AttackPathScanAttributes;
relationships: ScanRelationships;
}
export interface PaginationLinks {
first: string;
last: string;
next: string | null;
prev: string | null;
}
export interface AttackPathScansResponse {
data: AttackPathScan[];
links: PaginationLinks;
}
// Data type constants
const DATA_TYPES = {
STRING: "string",
NUMBER: "number",
BOOLEAN: "boolean",
} as const;
type DataType = (typeof DATA_TYPES)[keyof typeof DATA_TYPES];
// Query Types
export interface AttackPathQueryParameter {
name: string;
label: string;
data_type: DataType;
description: string;
placeholder?: string;
required?: boolean;
}
export interface AttackPathQueryAttributes {
name: string;
description: string;
provider: string;
parameters: AttackPathQueryParameter[];
}
export interface AttackPathQuery {
type: "attack-paths-scans";
id: string;
attributes: AttackPathQueryAttributes;
}
export interface AttackPathQueriesResponse {
data: AttackPathQuery[];
}
// Graph Data Types
// Property values from graph nodes can be any primitive type or arrays
export type GraphNodePropertyValue =
| string
| number
| boolean
| null
| undefined
| string[]
| number[];
export interface GraphNodeProperties {
[key: string]: GraphNodePropertyValue;
}
export interface GraphNode {
id: string;
labels: string[]; // e.g., ["S3Bucket"], ["EC2Instance"], ["ProwlerFinding"]
properties: GraphNodeProperties;
findings?: string[]; // IDs of finding nodes connected via HAS_FINDING edges
resources?: string[]; // IDs of resource nodes connected via HAS_FINDING edges
}
export interface GraphEdge {
id: string;
source: string | object;
target: string | object;
type: string;
properties?: GraphNodeProperties;
}
export interface GraphRelationship {
id: string;
label: string;
source: string;
target: string;
properties?: GraphNodeProperties;
}
export interface AttackPathGraphData {
nodes: GraphNode[];
edges?: GraphEdge[];
relationships?: GraphRelationship[];
}
export interface QueryResultAttributes {
nodes: GraphNode[];
relationships?: GraphRelationship[];
}
export interface QueryResultData {
type: "attack-paths-query-run-request";
id: string | null;
attributes: QueryResultAttributes;
}
export interface AttackPathQueryResult {
data: QueryResultData;
}
// Finding severity and status constants
const FINDING_SEVERITIES = {
CRITICAL: "critical",
HIGH: "high",
MEDIUM: "medium",
LOW: "low",
INFO: "info",
} as const;
type FindingSeverity =
(typeof FINDING_SEVERITIES)[keyof typeof FINDING_SEVERITIES];
const FINDING_STATUSES = {
PASS: "PASS",
FAIL: "FAIL",
MANUAL: "MANUAL",
} as const;
type FindingStatus = (typeof FINDING_STATUSES)[keyof typeof FINDING_STATUSES];
export interface RelatedFinding {
id: string;
title: string;
severity: FindingSeverity;
status: FindingStatus;
}
// Node Detail Types
export interface NodeDetailData extends GraphNode {
relatedFindings?: RelatedFinding[];
incomingEdges?: GraphEdge[];
outgoingEdges?: GraphEdge[];
}
// Wizard State Types
export interface WizardState {
currentStep: 1 | 2;
selectedScanId: string | null;
selectedQuery: string | null;
queryParameters: Record<string, string | number | boolean>;
}
// Graph State Types
export interface GraphState {
data: AttackPathGraphData | null;
selectedNodeId: string | null;
loading: boolean;
error: string | null;
zoomLevel: number;
panX: number;
panY: number;
}
// Provider Integration
export interface ProviderWithScanStatus {
id: string;
alias: string;
provider: string;
scan: AttackPathScan;
connected: boolean;
}
// API Request/Response Helpers
export interface QueryRequestAttributes {
id: string;
parameters?: Record<string, string | number | boolean>;
}
export interface ExecuteQueryRequestData {
type: "attack-paths-query-run-request";
attributes: QueryRequestAttributes;
}
export interface ExecuteQueryRequest {
data: ExecuteQueryRequestData;
}
+1
View File
@@ -33,6 +33,7 @@ export type MenuProps = {
defaultOpen?: boolean;
target?: string;
tooltip?: string;
highlight?: boolean;
};
export type GroupProps = {