test visuals
All checks were successful
Build and Deploy MITM Webserver / traffic_target (push) Successful in 0s
Build and Deploy MITM Webserver / build (push) Successful in 11s

This commit is contained in:
2026-03-31 22:37:00 +02:00
parent 72bab93aa3
commit ae432b7437
4 changed files with 194 additions and 0 deletions

View File

@@ -6,6 +6,7 @@ import {
Checkbox,
Col,
Empty,
Drawer,
InputNumber,
Row,
Space,
@@ -1167,6 +1168,188 @@ function ProtocolHeatmap({ data }: { data: TopologyData }) {
return <svg ref={svgRef} style={{ width: '100%', minWidth: 960 }} />;
}
function ConversationMatrix({ conversations }: { conversations: ConversationEvidence[] }) {
const svgRef = useRef<SVGSVGElement | null>(null);
const matrixData = useMemo(() => {
const sourceTotals = new Map<string, number>();
const destinationTotals = new Map<string, number>();
const cellMap = new Map<
string,
{
source: string;
destination: string;
packetCount: number;
byteCount: number;
protocols: Set<string>;
lastSeen: string;
}
>();
for (const conversation of conversations) {
const source = endpointText(conversation.src_ip_address, conversation.src_mac_address);
const destination = endpointText(conversation.dst_ip_address, conversation.dst_mac_address);
sourceTotals.set(source, (sourceTotals.get(source) ?? 0) + conversation.packet_count);
destinationTotals.set(destination, (destinationTotals.get(destination) ?? 0) + conversation.packet_count);
const key = `${source}->${destination}`;
const existing = cellMap.get(key);
if (existing) {
existing.packetCount += conversation.packet_count;
existing.byteCount += conversation.byte_count;
existing.protocols.add(conversation.protocol);
if (conversation.last_seen > existing.lastSeen) {
existing.lastSeen = conversation.last_seen;
}
} else {
cellMap.set(key, {
source,
destination,
packetCount: conversation.packet_count,
byteCount: conversation.byte_count,
protocols: new Set([conversation.protocol]),
lastSeen: conversation.last_seen,
});
}
}
const sources = Array.from(sourceTotals.entries())
.sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0]))
.slice(0, 18)
.map(([label]) => label);
const destinations = Array.from(destinationTotals.entries())
.sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0]))
.slice(0, 18)
.map(([label]) => label);
const cells = Array.from(cellMap.values()).filter(
(cell) => sources.includes(cell.source) && destinations.includes(cell.destination),
);
return { sources, destinations, cells };
}, [conversations]);
useEffect(() => {
if (!svgRef.current) return;
const svg = d3.select(svgRef.current);
svg.selectAll('*').remove();
if (matrixData.sources.length === 0 || matrixData.destinations.length === 0) {
return;
}
const margin = { top: 170, right: 30, bottom: 30, left: 250 };
const cellWidth = 42;
const cellHeight = 34;
const width = Math.max(980, margin.left + margin.right + matrixData.destinations.length * cellWidth);
const height = Math.max(360, margin.top + margin.bottom + matrixData.sources.length * cellHeight);
svg.attr('viewBox', `0 0 ${width} ${height}`);
const x = d3
.scaleBand<string>()
.domain(matrixData.destinations)
.range([margin.left, width - margin.right])
.paddingInner(0.08);
const y = d3
.scaleBand<string>()
.domain(matrixData.sources)
.range([margin.top, height - margin.bottom])
.paddingInner(0.08);
const maxPackets = d3.max(matrixData.cells, (cell) => cell.packetCount) ?? 1;
const color = d3.scaleSequential(d3.interpolateOrRd).domain([0, maxPackets]);
svg
.append('rect')
.attr('x', 0)
.attr('y', 0)
.attr('width', width)
.attr('height', height)
.attr('rx', 18)
.attr('fill', '#fbfcfe');
const cellLayer = svg.append('g');
for (const source of matrixData.sources) {
for (const destination of matrixData.destinations) {
const cell = matrixData.cells.find((entry) => entry.source === source && entry.destination === destination);
const packetCount = cell?.packetCount ?? 0;
const byteCount = cell?.byteCount ?? 0;
const group = cellLayer
.append('g')
.attr('transform', `translate(${x(destination) ?? 0},${y(source) ?? 0})`);
group
.append('rect')
.attr('width', x.bandwidth())
.attr('height', y.bandwidth())
.attr('rx', 8)
.attr('fill', packetCount > 0 ? color(packetCount) : '#eef3f8')
.attr('stroke', '#dce5ef');
if (packetCount > 0) {
group
.append('text')
.attr('x', x.bandwidth() / 2)
.attr('y', y.bandwidth() / 2 + 4)
.attr('text-anchor', 'middle')
.attr('font-size', 10)
.attr('font-weight', 700)
.attr('fill', packetCount > maxPackets * 0.45 ? '#ffffff' : '#7f2704')
.text(packetCount);
}
group.append('title').text(
[
`${source} -> ${destination}`,
`Packets: ${packetCount}`,
`Bytes: ${formatBytes(byteCount)}`,
`Protocols: ${cell ? Array.from(cell.protocols).sort().join(', ') : '—'}`,
`Last seen: ${cell ? formatTimestamp(cell.lastSeen) : '—'}`,
].join('\n'),
);
}
}
svg
.append('g')
.selectAll('text.destination-label')
.data(matrixData.destinations)
.join('text')
.attr('class', 'destination-label')
.attr('x', (destination) => (x(destination) ?? 0) + x.bandwidth() / 2)
.attr('y', margin.top - 12)
.attr(
'transform',
(destination) => `rotate(-40, ${(x(destination) ?? 0) + x.bandwidth() / 2}, ${margin.top - 12})`,
)
.attr('text-anchor', 'start')
.attr('font-size', 11)
.attr('font-weight', 600)
.attr('fill', '#29445d')
.text((destination) => destination);
svg
.append('g')
.selectAll('text.source-label')
.data(matrixData.sources)
.join('text')
.attr('class', 'source-label')
.attr('x', margin.left - 12)
.attr('y', (source) => (y(source) ?? 0) + y.bandwidth() / 2 + 4)
.attr('text-anchor', 'end')
.attr('font-size', 11)
.attr('fill', '#29445d')
.text((source) => source);
}, [matrixData]);
if (conversations.length === 0) {
return <Empty description="No conversation matrix data available yet" />;
}
return <svg ref={svgRef} style={{ width: '100%', minWidth: 980 }} />;
}
export default function Analysis(): ReactElement {
const [sinceMinutes, setSinceMinutes] = useState<number | null>(null);
const [limitPerInterface, setLimitPerInterface] = useState(50);
@@ -1784,6 +1967,14 @@ export default function Analysis(): ReactElement {
label: 'Communication',
children: (
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<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.

View File

@@ -199,6 +199,7 @@ export interface BeaconCandidateEvidence {
packet_count: number;
avg_interval_seconds: number;
jitter_ratio: number;
interval_samples: number[];
first_seen: string;
last_seen: string;
}