Files
mitm-webserver/frontend/src/pages/Analysis.tsx
malmert 74b961e152
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
add visualizations, better scaling
2026-04-01 23:10:59 +02:00

583 lines
21 KiB
TypeScript

import { ReloadOutlined } from '@ant-design/icons';
import {
Button,
Card,
Checkbox,
Col,
InputNumber,
Row,
Space,
Spin,
Table,
Tabs,
Tag,
Typography,
message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { ReactElement, useCallback, useEffect, useMemo, useState } from 'react';
import {
fetchConversationAnalysis,
fetchConversationFlowDetail,
fetchHostIntelligenceAnalysis,
fetchInterfaceHostProtocolAnalysis,
fetchInterfaceProtocolPathAnalysis,
} from '../api/apiClient';
import {
ConversationFlowDrawer,
ConversationMatrix,
ConversationTimeline,
} from '../components/analysis/CommunicationViews';
import {
HostDetailDrawer,
} from '../components/analysis/IntelligenceRiskViews';
import { ForceTopology, PacketPathLanes, ProtocolHeatmap, SankeyTopology } from '../components/analysis/TopologyViews';
import {
buildTopologyData,
conversationRowKey,
endpointText,
formatBytes,
formatDurationMs,
formatTimestamp,
protocolColor,
renderLabelCountTags,
renderLabelTags,
type ProtocolTableRow,
} from '../components/analysis/shared.tsx';
import type {
ConversationAnalysisResponse,
ConversationEvidence,
ConversationFlowDetailResponse,
HostIntelligenceAnalysisResponse,
HostIntelligenceEvidence,
InterfaceHostProtocolAnalysisResponse,
InterfaceProtocolPathAnalysisResponse,
} from '../types/analysis';
const { Title, Text, Paragraph } = Typography;
export default function Analysis(): ReactElement {
const [sinceMinutes, setSinceMinutes] = useState<number | null>(null);
const [limitPerInterface, setLimitPerInterface] = useState(50);
const [limitProtocolsPerHost, setLimitProtocolsPerHost] = useState(12);
const [limitPaths, setLimitPaths] = useState(500);
const [limitConversations, setLimitConversations] = useState(300);
const [limitHostIntelligence, setLimitHostIntelligence] = useState(40);
const [includeEthernetLayer, setIncludeEthernetLayer] = useState(false);
const [includeIpLayer, setIncludeIpLayer] = useState(false);
const [data, setData] = useState<InterfaceHostProtocolAnalysisResponse | null>(null);
const [pathData, setPathData] = useState<InterfaceProtocolPathAnalysisResponse | null>(null);
const [conversationData, setConversationData] = useState<ConversationAnalysisResponse | null>(null);
const [hostIntelligenceData, setHostIntelligenceData] = useState<HostIntelligenceAnalysisResponse | null>(null);
const [selectedHost, setSelectedHost] = useState<HostIntelligenceEvidence | null>(null);
const [selectedConversation, setSelectedConversation] = useState<ConversationEvidence | null>(null);
const [conversationDetail, setConversationDetail] = useState<ConversationFlowDetailResponse | null>(null);
const [conversationDetailLoading, setConversationDetailLoading] = useState(false);
const [loading, setLoading] = useState(false);
const loadData = useCallback(async () => {
setLoading(true);
try {
const [hostResponse, pathResponse, conversationsResponse, hostIntelResponse] = await Promise.all([
fetchInterfaceHostProtocolAnalysis(sinceMinutes, limitPerInterface, limitProtocolsPerHost),
fetchInterfaceProtocolPathAnalysis(sinceMinutes, limitPaths),
fetchConversationAnalysis(sinceMinutes, limitConversations),
fetchHostIntelligenceAnalysis(sinceMinutes, limitHostIntelligence),
]);
setData(hostResponse);
setPathData(pathResponse);
setConversationData(conversationsResponse);
setHostIntelligenceData(hostIntelResponse);
} catch (error: any) {
message.error(error?.message ?? 'Failed to load analysis data');
} finally {
setLoading(false);
}
}, [
sinceMinutes,
limitPerInterface,
limitProtocolsPerHost,
limitPaths,
limitConversations,
limitHostIntelligence,
]);
const openConversationDetail = useCallback(
async (conversation: ConversationEvidence) => {
setSelectedConversation(conversation);
setConversationDetail(null);
setConversationDetailLoading(true);
try {
const detailResponse = await fetchConversationFlowDetail({
flowId: conversation.flow_ids.length === 1 ? conversation.flow_ids[0] : null,
srcIpAddress: conversation.src_ip_address,
srcMacAddress: conversation.src_mac_address,
dstIpAddress: conversation.dst_ip_address,
dstMacAddress: conversation.dst_mac_address,
srcPort: conversation.src_port,
dstPort: conversation.dst_port,
protocol: conversation.protocol,
sinceMinutes,
limitPackets: 1500,
});
setConversationDetail(detailResponse);
} catch (error: any) {
message.error(error?.message ?? 'Failed to load conversation detail');
} finally {
setConversationDetailLoading(false);
}
},
[sinceMinutes],
);
useEffect(() => {
loadData().catch(() => undefined);
}, [loadData]);
const topologyData = useMemo(
() =>
buildTopologyData(data?.interfaces ?? [], {
includeEthernetLayer,
includeIpLayer,
}),
[data, includeEthernetLayer, includeIpLayer],
);
const analysisNotes = useMemo(
() =>
Array.from(
new Set([
...(data?.notes ?? []),
...(conversationData?.notes ?? []),
...(hostIntelligenceData?.notes ?? []),
]),
),
[data, conversationData, hostIntelligenceData],
);
const columns = useMemo<ColumnsType<ProtocolTableRow>>(
() => [
{
title: 'Interface',
dataIndex: 'interface',
key: 'interface',
width: 140,
render: (value: string) => <Tag color="blue">{value}</Tag>,
},
{
title: 'IP',
dataIndex: 'ip_address',
key: 'ip_address',
render: (value?: string | null) => value ?? '—',
},
{
title: 'MAC',
dataIndex: 'mac_address',
key: 'mac_address',
render: (value?: string | null) => value ?? '—',
},
{
title: 'Protocol',
dataIndex: 'protocol',
key: 'protocol',
width: 140,
render: (value: string) => <Tag color={protocolColor(value)}>{value}</Tag>,
},
{ title: 'Host Packets', dataIndex: 'host_packet_count', key: 'host_packet_count', width: 110 },
{ title: 'Protocol Packets', dataIndex: 'protocol_packet_count', key: 'protocol_packet_count', width: 130 },
{ title: 'Accept', dataIndex: 'accept_count', key: 'accept_count', width: 90 },
{ title: 'Drop', dataIndex: 'drop_count', key: 'drop_count', width: 90 },
{ title: 'Reject', dataIndex: 'reject_count', key: 'reject_count', width: 90 },
{ title: 'Unknown', dataIndex: 'unknown_count', key: 'unknown_count', width: 90 },
{
title: 'Last Seen',
dataIndex: 'last_seen',
key: 'last_seen',
width: 220,
render: (value: string) => formatTimestamp(value),
},
],
[],
);
const conversationColumns = useMemo<ColumnsType<ConversationEvidence>>(
() => [
{
title: 'Source',
key: 'source',
render: (_, row) => endpointText(row.src_ip_address, row.src_mac_address),
},
{
title: 'Destination',
key: 'destination',
render: (_, row) => endpointText(row.dst_ip_address, row.dst_mac_address),
},
{
title: 'Ports',
key: 'ports',
width: 130,
render: (_, row) => `${row.src_port ?? '—'} -> ${row.dst_port ?? '—'}`,
},
{
title: 'Protocol',
dataIndex: 'protocol',
key: 'protocol',
width: 140,
render: (value: string) => <Tag color={protocolColor(value)}>{value}</Tag>,
},
{
title: 'Hostnames',
key: 'hostnames',
render: (_, row) => renderLabelTags(row.hostnames.slice(0, 4), 'geekblue'),
},
{ title: 'Packets', dataIndex: 'packet_count', key: 'packet_count', width: 90 },
{ title: 'Flows', dataIndex: 'flow_count', key: 'flow_count', width: 80 },
{
title: 'Bytes',
dataIndex: 'byte_count',
key: 'byte_count',
width: 110,
render: (value: number) => formatBytes(value),
},
{
title: 'Duration',
dataIndex: 'duration_ms',
key: 'duration_ms',
width: 110,
render: (value: number) => formatDurationMs(value),
},
{
title: 'Verdict',
key: 'verdict',
width: 180,
render: (_, row) => (
<Text type="secondary">
A {row.accept_count} / D {row.drop_count} / R {row.reject_count}
</Text>
),
},
{
title: 'Last Seen',
dataIndex: 'last_seen',
key: 'last_seen',
width: 220,
render: (value: string) => formatTimestamp(value),
},
],
[],
);
const hostColumns = useMemo<ColumnsType<HostIntelligenceEvidence>>(
() => [
{
title: 'Host',
key: 'host',
render: (_, row) => (
<div>
<div>{row.ip_address ?? '—'}</div>
<Text type="secondary">{row.mac_address ?? '—'}</Text>
</div>
),
},
{
title: 'Interfaces',
key: 'interfaces',
render: (_, row) => renderLabelTags(row.interfaces, 'blue'),
},
{
title: 'Hostnames',
key: 'hostnames',
render: (_, row) => renderLabelTags(row.hostnames.slice(0, 4), 'geekblue'),
},
{
title: 'Top Protocols',
key: 'top_protocols',
render: (_, row) => renderLabelCountTags(row.top_protocols),
},
{ title: 'Packets', dataIndex: 'packet_count', key: 'packet_count', width: 90 },
{
title: 'Bytes',
dataIndex: 'byte_count',
key: 'byte_count',
width: 110,
render: (value: number) => formatBytes(value),
},
{
title: 'Role Bias',
key: 'role_bias',
width: 140,
render: (_, row) => (
<Text type="secondary">
src {row.source_count} / dst {row.destination_count}
</Text>
),
},
{
title: 'Last Seen',
dataIndex: 'last_seen',
key: 'last_seen',
width: 220,
render: (value: string) => formatTimestamp(value),
},
],
[],
);
return (
<div style={{ padding: 16 }}>
<Row justify="space-between" align="middle" style={{ marginBottom: 12 }}>
<Col>
<Title level={2} style={{ margin: 0 }}>
Analysis
</Title>
<Text type="secondary">
Explore inferred interface, host, and protocol relationships from captured traffic.
</Text>
</Col>
</Row>
<Card style={{ marginBottom: 16 }}>
<Space wrap size={[12, 12]}>
<Space>
<Text>Look back</Text>
<InputNumber
min={1}
max={60 * 24 * 30}
value={sinceMinutes}
placeholder="All history"
onChange={(value) => setSinceMinutes(value == null ? null : Number(value))}
/>
<Text type="secondary">minutes, blank = all history</Text>
</Space>
<Space>
<Text>Max hosts per interface</Text>
<InputNumber
min={1}
max={1000}
value={limitPerInterface}
onChange={(value) => setLimitPerInterface(value ?? 50)}
/>
</Space>
<Space>
<Text>Max protocols per host</Text>
<InputNumber
min={1}
max={100}
value={limitProtocolsPerHost}
onChange={(value) => setLimitProtocolsPerHost(value ?? 12)}
/>
</Space>
<Space>
<Text>Max packet paths</Text>
<InputNumber min={1} max={5000} value={limitPaths} onChange={(value) => setLimitPaths(value ?? 500)} />
</Space>
<Checkbox checked={includeEthernetLayer} onChange={(event) => setIncludeEthernetLayer(event.target.checked)}>
Ethernet layer
</Checkbox>
<Checkbox checked={includeIpLayer} onChange={(event) => setIncludeIpLayer(event.target.checked)}>
IP layer
</Checkbox>
<Button icon={<ReloadOutlined />} onClick={() => loadData()} loading={loading} type="primary">
Refresh
</Button>
</Space>
</Card>
<Card style={{ marginBottom: 16 }}>
<Space wrap size={[12, 12]}>
<Space>
<Text>Max conversations</Text>
<InputNumber
min={1}
max={5000}
value={limitConversations}
onChange={(value) => setLimitConversations(value ?? 300)}
/>
</Space>
<Space>
<Text>Max host intelligence rows</Text>
<InputNumber
min={1}
max={500}
value={limitHostIntelligence}
onChange={(value) => setLimitHostIntelligence(value ?? 40)}
/>
</Space>
</Space>
</Card>
<Spin spinning={loading}>
<Tabs
items={[
{
key: 'overview',
label: 'Topology & Protocols',
children: (
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<Card
title="Topology Views"
extra={data?.since ? <Text type="secondary">Since {formatTimestamp(data.since)}</Text> : null}
>
<Tabs
items={[
{
key: 'sankey',
label: 'Sankey',
children: (
<div>
<Paragraph type="secondary">
Shows aggregated interface, host, and protocol relationships across the captured
traffic.
</Paragraph>
<SankeyTopology data={topologyData} />
</div>
),
},
{
key: 'force',
label: 'Force Graph',
children: (
<div>
<Paragraph type="secondary">
Useful for exploring clusters and protocol neighborhoods across interfaces and hosts.
</Paragraph>
<ForceTopology data={topologyData} />
</div>
),
},
{
key: 'heatmap',
label: 'Heatmap',
children: (
<div>
<Paragraph type="secondary">
Useful for comparing which hosts are most active in which protocols.
</Paragraph>
<ProtocolHeatmap data={topologyData} />
</div>
),
},
]}
/>
</Card>
<Card title="Protocol Evidence Table">
<Paragraph type="secondary" style={{ marginTop: -4 }}>
This is the underlying aggregated evidence used by the topology views, including verdict counts
per interface, host, and protocol.
</Paragraph>
<Table
rowKey="key"
columns={columns}
dataSource={topologyData.tableRows}
size="small"
bordered
pagination={{ pageSize: 25 }}
locale={{
emptyText: loading ? 'Loading…' : 'No interface-host-protocol evidence available yet.',
}}
/>
</Card>
</Space>
),
},
{
key: 'communication',
label: 'Communication',
children: (
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<Card title="Packet Paths">
<Paragraph type="secondary" style={{ marginTop: -4 }}>
Parallel-coordinates view of grouped packet paths as source endpoint to ingress to protocol to
egress to destination endpoint.
</Paragraph>
<PacketPathLanes
paths={pathData?.paths ?? []}
includeEthernetLayer={includeEthernetLayer}
includeIpLayer={includeIpLayer}
/>
</Card>
<Card title="Conversation Timeline">
<Paragraph type="secondary" style={{ marginTop: -4 }}>
Time-ordered view of the busiest conversations. Click a bar to inspect the full packet sequence,
subflows, and derived request/response events.
</Paragraph>
<ConversationTimeline
conversations={conversationData?.conversations ?? []}
selectedKey={selectedConversation ? conversationRowKey(selectedConversation) : null}
onSelect={openConversationDetail}
/>
</Card>
<Card title="Conversation Matrix">
<Paragraph type="secondary" style={{ marginTop: -4 }}>
Source-to-destination adjacency matrix for the busiest conversations. Cell color and value reflect
packet volume, which makes the dominant communication relationships stand out quickly.
</Paragraph>
<ConversationMatrix conversations={conversationData?.conversations ?? []} />
</Card>
<Card title="Conversation Explorer">
<Paragraph type="secondary" style={{ marginTop: -4 }}>
Directional conversations grouped by source, destination, ports, protocol, and verdict outcome.
Click a row for packet-level drill-down.
</Paragraph>
<Table
rowKey={conversationRowKey}
columns={conversationColumns}
dataSource={conversationData?.conversations ?? []}
size="small"
bordered
onRow={(row) => ({
onClick: () => openConversationDetail(row),
style: { cursor: 'pointer' },
})}
pagination={{ pageSize: 20 }}
locale={{ emptyText: loading ? 'Loading…' : 'No conversation evidence available yet.' }}
/>
</Card>
</Space>
),
},
{
key: 'identity',
label: 'Identity & Services',
children: (
<Card title="Host Intelligence">
<Paragraph type="secondary" style={{ marginTop: -4 }}>
Asset-focused view combining interfaces, hostname hints, dominant protocols, likely services, and
peer relationships. Click a row to open a focused host detail drawer.
</Paragraph>
<Table
rowKey={(row) => `${row.ip_address ?? 'no-ip'}|${row.mac_address ?? 'no-mac'}`}
columns={hostColumns}
dataSource={hostIntelligenceData?.hosts ?? []}
size="small"
bordered
onRow={(row) => ({
onClick: () => setSelectedHost(row),
style: { cursor: 'pointer' },
})}
pagination={{ pageSize: 15 }}
locale={{ emptyText: loading ? 'Loading…' : 'No host intelligence available yet.' }}
/>
</Card>
),
},
]}
/>
</Spin>
<HostDetailDrawer host={selectedHost} open={selectedHost != null} onClose={() => setSelectedHost(null)} />
<ConversationFlowDrawer
conversation={selectedConversation}
detail={conversationDetail}
open={selectedConversation != null}
loading={conversationDetailLoading}
onClose={() => {
setSelectedConversation(null);
setConversationDetail(null);
setConversationDetailLoading(false);
}}
/>
</div>
);
}