583 lines
21 KiB
TypeScript
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>
|
|
);
|
|
}
|