app proto flow determination improvement
All checks were successful
Build and Deploy MITM Webserver / traffic_target (push) Successful in 1s
Build and Deploy MITM Webserver / build (push) Successful in 11s

This commit is contained in:
2026-03-30 22:06:41 +02:00
parent dbd052a7ac
commit de6edd6b68
4 changed files with 111 additions and 50 deletions

View File

@@ -496,6 +496,10 @@ class DatabasePool:
stream_kind: str, stream_kind: str,
stream_id: int, stream_id: int,
observed_at_ms: int, observed_at_ms: int,
src_ip: str,
dst_ip: str,
src_port: int,
dst_port: int,
enrichment: Dict[str, Any], enrichment: Dict[str, Any],
window_ms: int, window_ms: int,
) -> int: ) -> int:
@@ -522,12 +526,26 @@ class DatabasePool:
AND (capture_iface = $2 OR ingress_if = $2 OR egress_if = $2) AND (capture_iface = $2 OR ingress_if = $2 OR egress_if = $2)
AND timestamp BETWEEN $5 AND $6 AND timestamp BETWEEN $5 AND $6
AND ( AND (
CASE (
WHEN $3 = 'tcp' THEN COALESCE(dpi_metadata -> 'tcp' ->> 'stream', '') CASE
WHEN $3 = 'udp' THEN COALESCE(dpi_metadata -> 'udp' ->> 'stream', '') WHEN $3 = 'tcp' THEN COALESCE(dpi_metadata -> 'tcp' ->> 'stream', '')
ELSE '' WHEN $3 = 'udp' THEN COALESCE(dpi_metadata -> 'udp' ->> 'stream', '')
END ELSE ''
) = $4 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 ( AND (
app_protocol IS NULL app_protocol IS NULL
OR app_protocol IN ('TCP', 'UDP', 'IP', 'IPv6', 'ETH') OR app_protocol IN ('TCP', 'UDP', 'IP', 'IPv6', 'ETH')
@@ -546,21 +564,21 @@ class DatabasePool:
updated_at = NOW(), updated_at = NOW(),
app_protocol = CASE app_protocol = CASE
WHEN packets.app_protocol IS NULL OR packets.app_protocol IN ('TCP', 'UDP', 'IP', 'IPv6', 'ETH') 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 ELSE packets.app_protocol
END, END,
app_category = CASE app_category = CASE
WHEN packets.app_category IS NULL OR packets.app_category IN ('Transport', 'Network', 'Protocol') 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 ELSE packets.app_category
END, END,
app_confidence = CASE app_confidence = CASE
WHEN packets.app_protocol IS NULL OR packets.app_protocol IN ('TCP', 'UDP', 'IP', 'IPv6', 'ETH') 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 ELSE packets.app_confidence
END, END,
app_hostname = COALESCE(packets.app_hostname, $10), app_hostname = COALESCE(packets.app_hostname, $14),
app_is_encrypted = COALESCE(packets.app_is_encrypted, $11), app_is_encrypted = COALESCE(packets.app_is_encrypted, $15),
flow_id = COALESCE( flow_id = COALESCE(
packets.flow_id, packets.flow_id,
COALESCE(NULLIF(packets.capture_session_id, '') || ':', '') || $3 || ':' || $4 COALESCE(NULLIF(packets.capture_session_id, '') || ':', '') || $3 || ':' || $4
@@ -570,7 +588,7 @@ class DatabasePool:
SELECT DISTINCT source SELECT DISTINCT source
FROM unnest( FROM unnest(
COALESCE(packets.capture_sources, ARRAY[]::text[]) || COALESCE(packets.capture_sources, ARRAY[]::text[]) ||
COALESCE($12::text[], ARRAY[]::text[]) COALESCE($16::text[], ARRAY[]::text[])
) AS source ) AS source
) )
) )
@@ -584,6 +602,10 @@ class DatabasePool:
str(stream_id), str(stream_id),
lower_bound, lower_bound,
upper_bound, upper_bound,
src_ip,
dst_ip,
src_port,
dst_port,
enrichment.get("app_protocol"), enrichment.get("app_protocol"),
enrichment.get("app_category"), enrichment.get("app_category"),
enrichment.get("app_confidence"), enrichment.get("app_confidence"),

View File

@@ -956,6 +956,10 @@ class TsharkManager:
stream_kind=stream_kind, stream_kind=stream_kind,
stream_id=stream_id, stream_id=stream_id,
observed_at_ms=int(event["observed_at_ms"]), 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, enrichment=stream_enrichment,
window_ms=max(settings.tshark_match_window_ms, 10_000), window_ms=max(settings.tshark_match_window_ms, 10_000),
) )

View File

@@ -1,7 +1,7 @@
// src/components/Sidebar.tsx // src/components/Sidebar.tsx
import { import {
ApartmentOutlined, ApartmentOutlined,
ClusterOutlined, AreaChartOutlined,
HomeOutlined, HomeOutlined,
InfoCircleOutlined, InfoCircleOutlined,
MonitorOutlined, MonitorOutlined,
@@ -20,14 +20,14 @@ const { Sider } = Layout;
const menuItems = [ const menuItems = [
{ key: PATHS.HOME, icon: <HomeOutlined style={{ fontSize: '18px' }} />, label: 'Home' }, { key: PATHS.HOME, icon: <HomeOutlined style={{ fontSize: '18px' }} />, label: 'Home' },
{ key: PATHS.NETWORK, icon: <ApartmentOutlined style={{ fontSize: '18px' }} />, label: 'Network' }, { key: PATHS.NETWORK, icon: <ApartmentOutlined style={{ fontSize: '18px' }} />, label: 'Network' },
{ key: PATHS.ANALYSIS, icon: <ClusterOutlined style={{ fontSize: '18px' }} />, label: 'Analysis' }, { key: PATHS.FIREWALL, icon: <FirewallIcon style={{ fontSize: '18px' }} />, label: 'Firewall' },
{ key: PATHS.SNIFFING, icon: <MonitorOutlined style={{ fontSize: '18px' }} />, label: 'Sniffing' }, { key: PATHS.SNIFFING, icon: <MonitorOutlined style={{ fontSize: '18px' }} />, label: 'Sniffing' },
{ {
key: PATHS.SCRIPTING, key: PATHS.SCRIPTING,
icon: <TerminalIcon style={{ fontSize: '18px' }} width={18} height={18} />, icon: <TerminalIcon style={{ fontSize: '18px' }} width={18} height={18} />,
label: 'Scripting', label: 'Scripting',
}, },
{ key: PATHS.FIREWALL, icon: <FirewallIcon style={{ fontSize: '18px' }} />, label: 'Firewall' }, { key: PATHS.ANALYSIS, icon: <AreaChartOutlined style={{ fontSize: '18px' }} />, label: 'Analysis' },
{ key: '/about', icon: <InfoCircleOutlined style={{ fontSize: '18px' }} />, label: 'About' }, { key: '/about', icon: <InfoCircleOutlined style={{ fontSize: '18px' }} />, label: 'About' },
{ key: '/settings', icon: <SettingOutlined style={{ fontSize: '18px' }} />, label: 'Settings' }, { key: '/settings', icon: <SettingOutlined style={{ fontSize: '18px' }} />, label: 'Settings' },
]; ];

View File

@@ -47,6 +47,7 @@ type TopologyLink = {
source: string; source: string;
target: string; target: string;
value: number; value: number;
packetCount: number;
label: string; label: string;
}; };
@@ -124,6 +125,14 @@ function protocolColor(protocol: string) {
return palette[hash % palette.length]; 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 { function buildTopologyData(interfaces: InterfaceProtocolAttachment[]): TopologyData {
const nodes = new Map<string, TopologyNode>(); const nodes = new Map<string, TopologyNode>();
const links = new Map<string, TopologyLink>(); const links = new Map<string, TopologyLink>();
@@ -157,7 +166,8 @@ function buildTopologyData(interfaces: InterfaceProtocolAttachment[]): TopologyD
links.set(interfaceHostLinkId, { links.set(interfaceHostLinkId, {
source: interfaceNodeId, source: interfaceNodeId,
target: hostId, 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})`, 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, { links.set(hostProtocolLinkId, {
source: hostId, source: hostId,
target: protocolId, 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})`, 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; if (!svgRef.current) return;
const width = 1160; 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); const svg = d3.select(svgRef.current);
svg.selectAll('*').remove(); svg.selectAll('*').remove();
svg.attr('viewBox', `0 0 ${width} ${height}`); svg.attr('viewBox', `0 0 ${width} ${height}`);
@@ -246,7 +263,7 @@ function SankeyTopology({ data }: { data: TopologyData }) {
const sankeyLayout = d3Sankey<TopologyNode, TopologyLink>() const sankeyLayout = d3Sankey<TopologyNode, TopologyLink>()
.nodeId((node) => node.id) .nodeId((node) => node.id)
.nodeWidth(18) .nodeWidth(18)
.nodePadding(16) .nodePadding(maxNodesInLayer <= 4 ? 28 : maxNodesInLayer <= 8 ? 22 : 16)
.extent([ .extent([
[24, 28], [24, 28],
[width - 24, height - 28], [width - 24, height - 28],
@@ -254,22 +271,7 @@ function SankeyTopology({ data }: { data: TopologyData }) {
const layout = sankeyLayout(graph); const layout = sankeyLayout(graph);
const defs = svg.append('defs'); const linkLayer = svg.append('g').attr('fill', 'none').attr('stroke-opacity', 0.4);
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);
linkLayer linkLayer
.selectAll('path') .selectAll('path')
.data(layout.links as SankeyLinkDatum[]) .data(layout.links as SankeyLinkDatum[])
@@ -280,12 +282,11 @@ function SankeyTopology({ data }: { data: TopologyData }) {
if (target.kind === 'protocol' && target.protocol) { if (target.kind === 'protocol' && target.protocol) {
return protocolColor(target.protocol); return protocolColor(target.protocol);
} }
return '#7f8ea3'; return '#9aa7b5';
}) })
.attr('stroke-width', (link) => Math.max(1, link.width || 1)) .attr('stroke-width', (link) => Math.max(1, link.width || 1))
.attr('marker-end', 'url(#sankey-arrow)')
.append('title') .append('title')
.text((link) => `${link.label}\nPackets: ${link.value}`); .text((link) => `${link.label}\nPackets: ${link.packetCount}`);
const nodeLayer = svg.append('g'); const nodeLayer = svg.append('g');
const node = nodeLayer const node = nodeLayer
@@ -299,7 +300,6 @@ function SankeyTopology({ data }: { data: TopologyData }) {
.attr('y', (d) => d.y0 ?? 0) .attr('y', (d) => d.y0 ?? 0)
.attr('width', (d) => (d.x1 ?? 0) - (d.x0 ?? 0)) .attr('width', (d) => (d.x1 ?? 0) - (d.x0 ?? 0))
.attr('height', (d) => Math.max(8, (d.y1 ?? 0) - (d.y0 ?? 0))) .attr('height', (d) => Math.max(8, (d.y1 ?? 0) - (d.y0 ?? 0)))
.attr('rx', 8)
.attr('fill', (d) => { .attr('fill', (d) => {
if (d.kind === 'interface') return '#20405d'; if (d.kind === 'interface') return '#20405d';
if (d.kind === 'host') return '#d7e7f5'; if (d.kind === 'host') return '#d7e7f5';
@@ -307,7 +307,7 @@ function SankeyTopology({ data }: { data: TopologyData }) {
}) })
.attr('stroke', (d) => (d.kind === 'host' ? '#9bb8d6' : 'none')) .attr('stroke', (d) => (d.kind === 'host' ? '#9bb8d6' : 'none'))
.append('title') .append('title')
.text((d) => `${d.label}\nPackets: ${d.value ?? d.packetCount}`); .text((d) => `${d.label}\nPackets: ${d.packetCount}`);
node node
.append('text') .append('text')
@@ -338,8 +338,8 @@ function ForceTopology({ data }: { data: TopologyData }) {
useEffect(() => { useEffect(() => {
if (!svgRef.current) return; if (!svgRef.current) return;
const width = 1160; const width = 1320;
const height = 720; const height = 860;
const svg = d3.select(svgRef.current); const svg = d3.select(svgRef.current);
svg.selectAll('*').remove(); svg.selectAll('*').remove();
svg.attr('viewBox', `0 0 ${width} ${height}`); 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 nodes: ForceNode[] = data.nodes.map((node) => ({ ...node }));
const links: ForceLink[] = data.links.map((link) => ({ ...link })); 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<string, number>();
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 const simulation = d3
.forceSimulation<ForceNode>(nodes) .forceSimulation<ForceNode>(nodes)
@@ -360,20 +386,29 @@ function ForceTopology({ data }: { data: TopologyData }) {
.id((node) => node.id) .id((node) => node.id)
.distance((link) => { .distance((link) => {
const source = link.source as ForceNode; 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('charge', d3.forceManyBody().strength(-720))
.force('collision', d3.forceCollide<ForceNode>().radius((node) => (node.kind === 'host' ? 38 : 28))) .force('collision', d3.forceCollide<ForceNode>().radius((node) => {
if (node.kind === 'interface') return 52;
if (node.kind === 'host') return 44;
return 34;
}))
.force( .force(
'x', 'x',
d3.forceX<ForceNode>().x((node) => { d3.forceX<ForceNode>().x((node) => {
if (node.kind === 'interface') return 150; if (node.kind === 'interface') return 180;
if (node.kind === 'host') return width / 2; if (node.kind === 'host') return width / 2;
return width - 180; return width - 200;
}).strength(0.32), }).strength(0.42),
) )
.force('y', d3.forceY<ForceNode>(height / 2).strength(0.08)); .force('y', d3.forceY<ForceNode>().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'); 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 <Empty description="No graph data available yet" />; return <Empty description="No graph data available yet" />;
} }
return <svg ref={svgRef} style={{ width: '100%', minWidth: 960 }} />; return <svg ref={svgRef} style={{ width: '100%', minWidth: 1120 }} />;
} }
function ProtocolHeatmap({ data }: { data: TopologyData }) { function ProtocolHeatmap({ data }: { data: TopologyData }) {