diff --git a/backend/src/utilities/database.py b/backend/src/utilities/database.py index 7fa1fa8..ae4b63c 100644 --- a/backend/src/utilities/database.py +++ b/backend/src/utilities/database.py @@ -496,6 +496,10 @@ class DatabasePool: stream_kind: str, stream_id: int, observed_at_ms: int, + src_ip: str, + dst_ip: str, + src_port: int, + dst_port: int, enrichment: Dict[str, Any], window_ms: int, ) -> int: @@ -522,12 +526,26 @@ class DatabasePool: AND (capture_iface = $2 OR ingress_if = $2 OR egress_if = $2) AND timestamp BETWEEN $5 AND $6 AND ( - CASE - WHEN $3 = 'tcp' THEN COALESCE(dpi_metadata -> 'tcp' ->> 'stream', '') - WHEN $3 = 'udp' THEN COALESCE(dpi_metadata -> 'udp' ->> 'stream', '') - ELSE '' - END - ) = $4 + ( + CASE + WHEN $3 = 'tcp' THEN COALESCE(dpi_metadata -> 'tcp' ->> 'stream', '') + WHEN $3 = 'udp' THEN COALESCE(dpi_metadata -> 'udp' ->> 'stream', '') + ELSE '' + END + ) = $4 + OR ( + src_ip = $7::inet + AND dst_ip = $8::inet + AND COALESCE(src_port, 0) = $9 + AND COALESCE(dst_port, 0) = $10 + ) + OR ( + src_ip = $8::inet + AND dst_ip = $7::inet + AND COALESCE(src_port, 0) = $10 + AND COALESCE(dst_port, 0) = $9 + ) + ) AND ( app_protocol IS NULL OR app_protocol IN ('TCP', 'UDP', 'IP', 'IPv6', 'ETH') @@ -546,21 +564,21 @@ class DatabasePool: updated_at = NOW(), app_protocol = CASE WHEN packets.app_protocol IS NULL OR packets.app_protocol IN ('TCP', 'UDP', 'IP', 'IPv6', 'ETH') - THEN COALESCE($7, packets.app_protocol) + THEN COALESCE($11, packets.app_protocol) ELSE packets.app_protocol END, app_category = CASE WHEN packets.app_category IS NULL OR packets.app_category IN ('Transport', 'Network', 'Protocol') - THEN COALESCE($8, packets.app_category) + THEN COALESCE($12, packets.app_category) ELSE packets.app_category END, app_confidence = CASE WHEN packets.app_protocol IS NULL OR packets.app_protocol IN ('TCP', 'UDP', 'IP', 'IPv6', 'ETH') - THEN COALESCE($9, packets.app_confidence) + THEN COALESCE($13, packets.app_confidence) ELSE packets.app_confidence END, - app_hostname = COALESCE(packets.app_hostname, $10), - app_is_encrypted = COALESCE(packets.app_is_encrypted, $11), + app_hostname = COALESCE(packets.app_hostname, $14), + app_is_encrypted = COALESCE(packets.app_is_encrypted, $15), flow_id = COALESCE( packets.flow_id, COALESCE(NULLIF(packets.capture_session_id, '') || ':', '') || $3 || ':' || $4 @@ -570,7 +588,7 @@ class DatabasePool: SELECT DISTINCT source FROM unnest( COALESCE(packets.capture_sources, ARRAY[]::text[]) || - COALESCE($12::text[], ARRAY[]::text[]) + COALESCE($16::text[], ARRAY[]::text[]) ) AS source ) ) @@ -584,6 +602,10 @@ class DatabasePool: str(stream_id), lower_bound, upper_bound, + src_ip, + dst_ip, + src_port, + dst_port, enrichment.get("app_protocol"), enrichment.get("app_category"), enrichment.get("app_confidence"), diff --git a/backend/src/utilities/tshark_manager.py b/backend/src/utilities/tshark_manager.py index 1316229..d977e75 100644 --- a/backend/src/utilities/tshark_manager.py +++ b/backend/src/utilities/tshark_manager.py @@ -956,6 +956,10 @@ class TsharkManager: stream_kind=stream_kind, stream_id=stream_id, observed_at_ms=int(event["observed_at_ms"]), + src_ip=str(event["src_ip"]), + dst_ip=str(event["dst_ip"]), + src_port=int(event["src_port"]), + dst_port=int(event["dst_port"]), enrichment=stream_enrichment, window_ms=max(settings.tshark_match_window_ms, 10_000), ) diff --git a/frontend/src/Layout/Sidebar.tsx b/frontend/src/Layout/Sidebar.tsx index 17949c1..5daa036 100644 --- a/frontend/src/Layout/Sidebar.tsx +++ b/frontend/src/Layout/Sidebar.tsx @@ -1,7 +1,7 @@ // src/components/Sidebar.tsx import { ApartmentOutlined, - ClusterOutlined, + AreaChartOutlined, HomeOutlined, InfoCircleOutlined, MonitorOutlined, @@ -20,14 +20,14 @@ const { Sider } = Layout; const menuItems = [ { key: PATHS.HOME, icon: , label: 'Home' }, { key: PATHS.NETWORK, icon: , label: 'Network' }, - { key: PATHS.ANALYSIS, icon: , label: 'Analysis' }, + { key: PATHS.FIREWALL, icon: , label: 'Firewall' }, { key: PATHS.SNIFFING, icon: , label: 'Sniffing' }, { key: PATHS.SCRIPTING, icon: , label: 'Scripting', }, - { key: PATHS.FIREWALL, icon: , label: 'Firewall' }, + { key: PATHS.ANALYSIS, icon: , label: 'Analysis' }, { key: '/about', icon: , label: 'About' }, { key: '/settings', icon: , label: 'Settings' }, ]; diff --git a/frontend/src/pages/Analysis.tsx b/frontend/src/pages/Analysis.tsx index fcf98fb..a5917ca 100644 --- a/frontend/src/pages/Analysis.tsx +++ b/frontend/src/pages/Analysis.tsx @@ -47,6 +47,7 @@ type TopologyLink = { source: string; target: string; value: number; + packetCount: number; label: string; }; @@ -124,6 +125,14 @@ function protocolColor(protocol: string) { return palette[hash % palette.length]; } +function clamp(value: number, min: number, max: number) { + return Math.min(max, Math.max(min, value)); +} + +function sankeyVisualWeight(packetCount: number) { + return Math.max(1, Math.sqrt(Math.max(0, packetCount))); +} + function buildTopologyData(interfaces: InterfaceProtocolAttachment[]): TopologyData { const nodes = new Map(); const links = new Map(); @@ -157,7 +166,8 @@ function buildTopologyData(interfaces: InterfaceProtocolAttachment[]): TopologyD links.set(interfaceHostLinkId, { source: interfaceNodeId, target: hostId, - value: host.packet_count, + value: sankeyVisualWeight(host.packet_count), + packetCount: host.packet_count, label: `${entry.interface} -> ${host.ip_address ?? host.mac_address ?? 'host'} (${host.packet_count})`, }); @@ -187,7 +197,8 @@ function buildTopologyData(interfaces: InterfaceProtocolAttachment[]): TopologyD links.set(hostProtocolLinkId, { source: hostId, target: protocolId, - value: protocol.packet_count, + value: sankeyVisualWeight(protocol.packet_count), + packetCount: protocol.packet_count, label: `${host.ip_address ?? host.mac_address ?? 'host'} -> ${protocol.protocol} (${protocol.packet_count})`, }); @@ -229,7 +240,13 @@ function SankeyTopology({ data }: { data: TopologyData }) { if (!svgRef.current) return; const width = 1160; - const height = Math.max(420, data.nodes.length * 26); + const nodesPerLayer = { + interface: data.nodes.filter((node) => node.kind === 'interface').length, + host: data.nodes.filter((node) => node.kind === 'host').length, + protocol: data.nodes.filter((node) => node.kind === 'protocol').length, + }; + const maxNodesInLayer = Math.max(nodesPerLayer.interface, nodesPerLayer.host, nodesPerLayer.protocol, 1); + const height = clamp(maxNodesInLayer * 56 + 120, 260, 760); const svg = d3.select(svgRef.current); svg.selectAll('*').remove(); svg.attr('viewBox', `0 0 ${width} ${height}`); @@ -246,7 +263,7 @@ function SankeyTopology({ data }: { data: TopologyData }) { const sankeyLayout = d3Sankey() .nodeId((node) => node.id) .nodeWidth(18) - .nodePadding(16) + .nodePadding(maxNodesInLayer <= 4 ? 28 : maxNodesInLayer <= 8 ? 22 : 16) .extent([ [24, 28], [width - 24, height - 28], @@ -254,22 +271,7 @@ function SankeyTopology({ data }: { data: TopologyData }) { const layout = sankeyLayout(graph); - const defs = svg.append('defs'); - defs - .append('marker') - .attr('id', 'sankey-arrow') - .attr('markerWidth', 10) - .attr('markerHeight', 10) - .attr('refX', 8) - .attr('refY', 5) - .attr('orient', 'auto') - .append('path') - .attr('d', 'M0,0 L10,5 L0,10 z') - .attr('fill', '#8394a7'); - - svg.append('rect').attr('x', 0).attr('y', 0).attr('width', width).attr('height', height).attr('rx', 18).attr('fill', '#f8fbff'); - - const linkLayer = svg.append('g').attr('fill', 'none').attr('stroke-opacity', 0.35); + const linkLayer = svg.append('g').attr('fill', 'none').attr('stroke-opacity', 0.4); linkLayer .selectAll('path') .data(layout.links as SankeyLinkDatum[]) @@ -280,12 +282,11 @@ function SankeyTopology({ data }: { data: TopologyData }) { if (target.kind === 'protocol' && target.protocol) { return protocolColor(target.protocol); } - return '#7f8ea3'; + return '#9aa7b5'; }) .attr('stroke-width', (link) => Math.max(1, link.width || 1)) - .attr('marker-end', 'url(#sankey-arrow)') .append('title') - .text((link) => `${link.label}\nPackets: ${link.value}`); + .text((link) => `${link.label}\nPackets: ${link.packetCount}`); const nodeLayer = svg.append('g'); const node = nodeLayer @@ -299,7 +300,6 @@ function SankeyTopology({ data }: { data: TopologyData }) { .attr('y', (d) => d.y0 ?? 0) .attr('width', (d) => (d.x1 ?? 0) - (d.x0 ?? 0)) .attr('height', (d) => Math.max(8, (d.y1 ?? 0) - (d.y0 ?? 0))) - .attr('rx', 8) .attr('fill', (d) => { if (d.kind === 'interface') return '#20405d'; if (d.kind === 'host') return '#d7e7f5'; @@ -307,7 +307,7 @@ function SankeyTopology({ data }: { data: TopologyData }) { }) .attr('stroke', (d) => (d.kind === 'host' ? '#9bb8d6' : 'none')) .append('title') - .text((d) => `${d.label}\nPackets: ${d.value ?? d.packetCount}`); + .text((d) => `${d.label}\nPackets: ${d.packetCount}`); node .append('text') @@ -338,8 +338,8 @@ function ForceTopology({ data }: { data: TopologyData }) { useEffect(() => { if (!svgRef.current) return; - const width = 1160; - const height = 720; + const width = 1320; + const height = 860; const svg = d3.select(svgRef.current); svg.selectAll('*').remove(); svg.attr('viewBox', `0 0 ${width} ${height}`); @@ -350,6 +350,32 @@ function ForceTopology({ data }: { data: TopologyData }) { const nodes: ForceNode[] = data.nodes.map((node) => ({ ...node })); const links: ForceLink[] = data.links.map((link) => ({ ...link })); + const groupedNodes = { + interface: nodes.filter((node) => node.kind === 'interface').sort((left, right) => left.label.localeCompare(right.label)), + host: nodes.filter((node) => node.kind === 'host').sort((left, right) => (left.ipAddress ?? left.macAddress ?? left.label).localeCompare(right.ipAddress ?? right.macAddress ?? right.label)), + protocol: nodes.filter((node) => node.kind === 'protocol').sort((left, right) => left.label.localeCompare(right.label)), + }; + + const distributedY = (group: ForceNode[], top: number, bottom: number) => { + const targets = new Map(); + if (group.length === 0) { + return targets; + } + if (group.length === 1) { + targets.set(group[0].id, (top + bottom) / 2); + return targets; + } + const step = (bottom - top) / (group.length - 1); + group.forEach((node, index) => { + targets.set(node.id, top + step * index); + }); + return targets; + }; + + const interfaceY = distributedY(groupedNodes.interface, 120, height - 120); + const hostY = distributedY(groupedNodes.host, 90, height - 90); + const protocolY = distributedY(groupedNodes.protocol, 120, height - 120); + const targetY = (node: ForceNode) => interfaceY.get(node.id) ?? hostY.get(node.id) ?? protocolY.get(node.id) ?? height / 2; const simulation = d3 .forceSimulation(nodes) @@ -360,20 +386,29 @@ function ForceTopology({ data }: { data: TopologyData }) { .id((node) => node.id) .distance((link) => { const source = link.source as ForceNode; - return source.kind === 'interface' ? 140 : 120; + return source.kind === 'interface' ? 250 : 200; + }) + .strength((link) => { + const source = link.source as ForceNode; + return source.kind === 'interface' ? 0.45 : 0.35; }), ) - .force('charge', d3.forceManyBody().strength(-280)) - .force('collision', d3.forceCollide().radius((node) => (node.kind === 'host' ? 38 : 28))) + .force('charge', d3.forceManyBody().strength(-720)) + .force('collision', d3.forceCollide().radius((node) => { + if (node.kind === 'interface') return 52; + if (node.kind === 'host') return 44; + return 34; + })) .force( 'x', d3.forceX().x((node) => { - if (node.kind === 'interface') return 150; + if (node.kind === 'interface') return 180; if (node.kind === 'host') return width / 2; - return width - 180; - }).strength(0.32), + return width - 200; + }).strength(0.42), ) - .force('y', d3.forceY(height / 2).strength(0.08)); + .force('y', d3.forceY().y((node) => targetY(node)).strength(0.22)) + .force('center', d3.forceCenter(width / 2, height / 2).strength(0.06)); svg.append('rect').attr('x', 0).attr('y', 0).attr('width', width).attr('height', height).attr('rx', 18).attr('fill', '#fbfcfe'); @@ -445,7 +480,7 @@ function ForceTopology({ data }: { data: TopologyData }) { return ; } - return ; + return ; } function ProtocolHeatmap({ data }: { data: TopologyData }) {