Compare commits

...

7 Commits

Author SHA1 Message Date
Pablo F.G
105bc5fe27 chore: update openspec with PR0 task completion
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 14:07:59 +02:00
Pablo F.G
9b4667bfac fix(ui): add key to Suspense in navbar to prevent stale fallback
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 14:07:44 +02:00
Pablo F.G
a443350d8b refactor(ui): normalize graph edge types and remove dead code
- Narrow GraphEdge.source/target from string | object to string
- Remove typeof guards in adapter, graph component, and utilities
- Remove unused panX, panY, zoomLevel fields from graph state store
- Delete orphaned NodeRelationships component (never integrated)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 13:47:51 +02:00
Pablo F.G
5c981f5683 chore: update openspec with restructured expect-cli tests
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 16:49:47 +02:00
Pablo F.G
9922b15391 chore: update openspec with expect-cli validation tasks
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 16:43:48 +02:00
Pablo F.G
6e77abea01 chore: update openspec submodule with react-flow-migration proposal
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 16:31:24 +02:00
Pablo F.G
4d57f3bef1 chore: add prowler-openspec-opensource as git submodule
Adds the openspec repository as a submodule at openspec/ for shared
spec definitions used by SDD tooling across AI coding assistants.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 11:56:09 +02:00
11 changed files with 40 additions and 234 deletions

3
.gitmodules vendored Normal file
View File

@@ -0,0 +1,3 @@
[submodule "openspec"]
path = openspec
url = https://github.com/prowler-cloud/prowler-openspec-opensource.git

1
openspec Submodule

Submodule openspec added at 4e50159a6d

View File

@@ -131,27 +131,16 @@ export function adaptQueryResultToGraphData(
// 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;
// Add finding to source node (resource -> finding)
const sourceNode = normalizedNodes.find((n) => n.id === edge.source);
if (sourceNode) {
sourceNode.findings.push(edge.target);
}
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);
}
// Add resource to target node (finding <- resource)
const targetNode = normalizedNodes.find((n) => n.id === edge.target);
if (targetNode) {
targetNode.resources.push(edge.source);
}
}
});

View File

@@ -302,20 +302,8 @@ const AttackPathGraphComponent = forwardRef<
// 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
: "";
let sourceId = edge.source;
let targetId = edge.target;
// Reverse container relationships for proper hierarchy
if (containerRelations.has(edge.type)) {
@@ -324,14 +312,8 @@ const AttackPathGraphComponent = forwardRef<
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,
originalSource: edge.source,
originalTarget: edge.target,
});
}
});
@@ -682,17 +664,9 @@ const AttackPathGraphComponent = forwardRef<
// 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;
if (edge.source === node.id || edge.target === node.id) {
const otherId =
edge.source === node.id ? edge.target : edge.source;
const otherNode = data.nodes.find((n) => n.id === otherId);
if (
otherNode?.labels.some((label) =>
@@ -847,17 +821,8 @@ const AttackPathGraphComponent = forwardRef<
// 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 sourceNode = nodeDataMap.get(edge.source);
const targetNode = nodeDataMap.get(edge.target);
const sourceIsFinding = sourceNode?.labels.some((l) =>
l.toLowerCase().includes("finding"),
@@ -868,10 +833,10 @@ const AttackPathGraphComponent = forwardRef<
// If one end is a finding, the other is a resource with findings
if (sourceIsFinding && !targetIsFinding) {
resourcesWithFindings.add(targetId);
resourcesWithFindings.add(edge.target);
}
if (targetIsFinding && !sourceIsFinding) {
resourcesWithFindings.add(sourceId);
resourcesWithFindings.add(edge.source);
}
});

View File

@@ -1,4 +1,3 @@
export { NodeDetailContent, NodeDetailPanel } from "./node-detail-panel";
export { NodeOverview } from "./node-overview";
export { NodeRelationships } from "./node-relationships";
export { NodeRemediation } from "./node-remediation";

View File

@@ -1,105 +0,0 @@
"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>
);
};

View File

@@ -21,8 +21,6 @@ interface GraphStore extends GraphState, FilteredViewState {
setSelectedNodeId: (nodeId: string | null) => void;
setLoading: (loading: boolean) => void;
setError: (error: string | null) => void;
setZoom: (zoomLevel: number) => void;
setPan: (panX: number, panY: number) => void;
setFilteredView: (
isFiltered: boolean,
nodeId: string | null,
@@ -37,9 +35,6 @@ const initialState: GraphState & FilteredViewState = {
selectedNodeId: null,
loading: false,
error: null,
zoomLevel: 1,
panX: 0,
panY: 0,
isFilteredView: false,
filteredNodeId: null,
fullData: null,
@@ -58,8 +53,6 @@ const useGraphStore = create<GraphStore>((set) => ({
setSelectedNodeId: (nodeId) => set({ selectedNodeId: nodeId }),
setLoading: (loading) => set({ loading }),
setError: (error) => set({ error }),
setZoom: (zoomLevel) => set({ zoomLevel }),
setPan: (panX, panY) => set({ panX, panY }),
setFilteredView: (isFiltered, nodeId, filteredData, fullData) =>
set({
isFilteredView: isFiltered,
@@ -106,11 +99,6 @@ export const useGraphState = () => {
store.setError(error);
};
const updateZoomAndPan = (zoomLevel: number, panX: number, panY: number) => {
store.setZoom(zoomLevel);
store.setPan(panX, panY);
};
const resetGraph = () => {
store.reset();
};
@@ -162,9 +150,6 @@ export const useGraphState = () => {
selectedNode: getSelectedNode(),
loading: store.loading,
error: store.error,
zoomLevel: store.zoomLevel,
panX: store.panX,
panY: store.panY,
isFilteredView: store.isFilteredView,
filteredNodeId: store.filteredNodeId,
filteredNode: getFilteredNode(),
@@ -173,7 +158,6 @@ export const useGraphState = () => {
startLoading,
stopLoading,
setError,
updateZoomAndPan,
resetGraph,
clearGraph,
enterFilteredView,

View File

@@ -4,23 +4,6 @@
import type { AttackPathGraphData } from "@/types/attack-paths";
/**
* Type for edge node reference - can be a string ID or an object with id property
* Note: We use `object` to match GraphEdge type from attack-paths.ts
*/
export type EdgeNodeRef = string | object;
/**
* Helper to get edge source/target ID from string or object
*/
export const getEdgeNodeId = (nodeRef: EdgeNodeRef): string => {
if (typeof nodeRef === "string") {
return nodeRef;
}
// Edge node references are objects with an id property
return (nodeRef as { id: string }).id;
};
/**
* Compute a filtered subgraph containing only the path through the target node.
* This follows the directed graph structure of attack paths:
@@ -44,10 +27,8 @@ export const computeFilteredSubgraph = (
});
edges.forEach((edge) => {
const sourceId = getEdgeNodeId(edge.source);
const targetId = getEdgeNodeId(edge.target);
forwardEdges.get(sourceId)?.add(targetId);
backwardEdges.get(targetId)?.add(sourceId);
forwardEdges.get(edge.source)?.add(edge.target);
backwardEdges.get(edge.target)?.add(edge.source);
});
const visibleNodeIds = new Set<string>();
@@ -85,10 +66,8 @@ export const computeFilteredSubgraph = (
// Also include findings directly connected to the selected node
edges.forEach((edge) => {
const sourceId = getEdgeNodeId(edge.source);
const targetId = getEdgeNodeId(edge.target);
const sourceNode = nodes.find((n) => n.id === sourceId);
const targetNode = nodes.find((n) => n.id === targetId);
const sourceNode = nodes.find((n) => n.id === edge.source);
const targetNode = nodes.find((n) => n.id === edge.target);
const sourceIsFinding = sourceNode?.labels.some((l) =>
l.toLowerCase().includes("finding"),
@@ -98,21 +77,20 @@ export const computeFilteredSubgraph = (
);
// Include findings connected to the selected node
if (sourceId === targetNodeId && targetIsFinding) {
visibleNodeIds.add(targetId);
if (edge.source === targetNodeId && targetIsFinding) {
visibleNodeIds.add(edge.target);
}
if (targetId === targetNodeId && sourceIsFinding) {
visibleNodeIds.add(sourceId);
if (edge.target === targetNodeId && sourceIsFinding) {
visibleNodeIds.add(edge.source);
}
});
// Filter nodes and edges to only include visible ones
const filteredNodes = nodes.filter((node) => visibleNodeIds.has(node.id));
const filteredEdges = edges.filter((edge) => {
const sourceId = getEdgeNodeId(edge.source);
const targetId = getEdgeNodeId(edge.target);
return visibleNodeIds.has(sourceId) && visibleNodeIds.has(targetId);
});
const filteredEdges = edges.filter(
(edge) =>
visibleNodeIds.has(edge.source) && visibleNodeIds.has(edge.target),
);
return {
nodes: filteredNodes,

View File

@@ -15,9 +15,4 @@ export {
GRAPH_NODE_COLORS,
GRAPH_SELECTION_COLOR,
} from "./graph-colors";
export {
computeFilteredSubgraph,
type EdgeNodeRef,
getEdgeNodeId,
getPathEdges,
} from "./graph-utils";
export { computeFilteredSubgraph, getPathEdges } from "./graph-utils";

View File

@@ -15,7 +15,7 @@ export function Navbar({ title, icon }: NavbarProps) {
title={title}
icon={icon}
feedsSlot={
<Suspense fallback={<FeedsLoadingFallback />}>
<Suspense key="feeds" fallback={<FeedsLoadingFallback />}>
<FeedsServer limit={15} />
</Suspense>
}

View File

@@ -180,8 +180,8 @@ export interface GraphNode {
export interface GraphEdge {
id: string;
source: string | object;
target: string | object;
source: string;
target: string;
type: string;
properties?: GraphNodeProperties;
}
@@ -268,9 +268,6 @@ export interface GraphState {
selectedNodeId: string | null;
loading: boolean;
error: string | null;
zoomLevel: number;
panX: number;
panY: number;
}
// Provider Integration