mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-24 21:11:53 +00:00
feat(attack-paths): apply Scope Rule pattern for feature-local organization (#9270)
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -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
@@ -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 ""
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
+962
@@ -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;
|
||||
+93
@@ -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>
|
||||
);
|
||||
};
|
||||
+497
@@ -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>
|
||||
);
|
||||
};
|
||||
+24
@@ -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";
|
||||
+4
@@ -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";
|
||||
+132
@@ -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>
|
||||
);
|
||||
};
|
||||
+102
@@ -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>
|
||||
);
|
||||
};
|
||||
+109
@@ -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();
|
||||
}
|
||||
+105
@@ -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>
|
||||
);
|
||||
};
|
||||
+83
@@ -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>
|
||||
);
|
||||
};
|
||||
+85
@@ -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>
|
||||
);
|
||||
};
|
||||
+122
@@ -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 "Execute Query" 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>
|
||||
);
|
||||
};
|
||||
+350
@@ -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 "Completed" status can be
|
||||
selected. Scans in progress will update automatically.
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+59
@@ -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 "Execute Query" 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>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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 +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
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -119,6 +119,7 @@ export const Menu = ({ isOpen }: { isOpen: boolean }) => {
|
||||
target={menu.target}
|
||||
tooltip={menu.tooltip}
|
||||
isOpen={isOpen}
|
||||
highlight={menu.highlight}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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: [
|
||||
|
||||
Generated
+27
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -33,6 +33,7 @@ export type MenuProps = {
|
||||
defaultOpen?: boolean;
|
||||
target?: string;
|
||||
tooltip?: string;
|
||||
highlight?: boolean;
|
||||
};
|
||||
|
||||
export type GroupProps = {
|
||||
|
||||
Reference in New Issue
Block a user