diff --git a/backend/src/api/analysis_api.py b/backend/src/api/analysis_api.py index 6adecf9..11025d3 100644 --- a/backend/src/api/analysis_api.py +++ b/backend/src/api/analysis_api.py @@ -20,6 +20,17 @@ class InterfaceHostEvidence(BaseModel): destination_on_egress_count: int = Field(..., description="Packets where this endpoint appeared as the destination on egress.") +class ProtocolLayerPathEvidence(BaseModel): + ethernet_protocol: Optional[str] = Field(None, description="Ethernet protocol label for this path, if known.") + ip_protocol: Optional[str] = Field(None, description="IP protocol label for this path, if known.") + packet_count: int = Field(..., description="Packet observations supporting this path.") + last_seen: datetime = Field(..., description="Most recent packet timestamp supporting this path.") + accept_count: int = Field(0, description="Packets with verdict=accept for this path.") + drop_count: int = Field(0, description="Packets with verdict=drop for this path.") + reject_count: int = Field(0, description="Packets with verdict=reject for this path.") + unknown_count: int = Field(0, description="Packets with verdict pending/unknown or without a verdict.") + + class ProtocolEvidence(BaseModel): protocol: str = Field(..., description="Detected application or fallback transport/network protocol.") packet_count: int = Field(..., description="Packet observations supporting this interface-host-protocol mapping.") @@ -30,23 +41,12 @@ class ProtocolEvidence(BaseModel): unknown_count: int = Field(0, description="Packets with verdict pending/unknown or without a verdict.") ethernet_protocol: Optional[str] = Field(None, description="Dominant Ethernet protocol associated with this protocol evidence.") ip_protocol: Optional[str] = Field(None, description="Dominant IP protocol associated with this protocol evidence.") - layer_paths: List["ProtocolLayerPathEvidence"] = Field( + layer_paths: List[ProtocolLayerPathEvidence] = Field( default_factory=list, description="Optional Ethernet/IP breakdown contributing to this protocol evidence.", ) -class ProtocolLayerPathEvidence(BaseModel): - ethernet_protocol: Optional[str] = Field(None, description="Ethernet protocol label for this path, if known.") - ip_protocol: Optional[str] = Field(None, description="IP protocol label for this path, if known.") - packet_count: int = Field(..., description="Packet observations supporting this path.") - last_seen: datetime = Field(..., description="Most recent packet timestamp supporting this path.") - accept_count: int = Field(0, description="Packets with verdict=accept for this path.") - drop_count: int = Field(0, description="Packets with verdict=drop for this path.") - reject_count: int = Field(0, description="Packets with verdict=reject for this path.") - unknown_count: int = Field(0, description="Packets with verdict pending/unknown or without a verdict.") - - class InterfaceHostProtocolEvidence(InterfaceHostEvidence): protocols: List[ProtocolEvidence] = Field(default_factory=list, description="Protocols observed for this host on the interface.") diff --git a/backend/src/utilities/database.py b/backend/src/utilities/database.py index ea7a14d..f8e0c93 100644 --- a/backend/src/utilities/database.py +++ b/backend/src/utilities/database.py @@ -32,19 +32,35 @@ def _analysis_protocol_name(app_protocol: Any, ip_proto_raw: Any, eth_type_raw: if app_protocol not in (None, ""): return str(app_protocol) + ip_protocol_name = _analysis_ip_protocol_name(ip_proto_raw) + if ip_protocol_name is not None: + return ip_protocol_name + + ethernet_protocol_name = _analysis_ethernet_protocol_name(eth_type_raw) + if ethernet_protocol_name is not None: + return ethernet_protocol_name + + return "UNKNOWN" + + +def _analysis_ip_protocol_name(ip_proto_raw: Any) -> Optional[str]: if ip_proto_raw is not None: try: return str(protocol_from_number(int(ip_proto_raw))) except Exception: return f"IP_PROTO_{ip_proto_raw}" + return None + + +def _analysis_ethernet_protocol_name(eth_type_raw: Any) -> Optional[str]: if eth_type_raw is not None: try: return str(ethertype_from_int(int(eth_type_raw))) except Exception: return f"ETH_TYPE_{eth_type_raw}" - return "UNKNOWN" + return None def _serialize_row_for_broadcast(row: Dict[str, Any]) -> Dict[str, Any]: @@ -958,6 +974,8 @@ class DatabasePool: ) if protocol_name not in (None, ""): protocol_key = str(protocol_name) + ip_protocol_name = _analysis_ip_protocol_name(record.get("ip_proto_raw")) + ethernet_protocol_name = _analysis_ethernet_protocol_name(record.get("eth_type_raw")) protocol_last_seen_raw = record.get("protocol_last_seen") protocol_last_seen = ( protocol_last_seen_raw.isoformat() @@ -975,6 +993,10 @@ class DatabasePool: "drop_count": 0, "reject_count": 0, "unknown_count": 0, + "ethernet_protocol": ethernet_protocol_name, + "ip_protocol": ip_protocol_name, + "layer_paths": [], + "_layer_index": {}, } protocol_index[protocol_key] = protocol_record host_record["protocols"].append(protocol_record) @@ -989,11 +1011,54 @@ class DatabasePool: or str(protocol_last_seen) > str(protocol_record.get("last_seen")) ): protocol_record["last_seen"] = protocol_last_seen + if protocol_record.get("ethernet_protocol") is None and ethernet_protocol_name is not None: + protocol_record["ethernet_protocol"] = ethernet_protocol_name + if protocol_record.get("ip_protocol") is None and ip_protocol_name is not None: + protocol_record["ip_protocol"] = ip_protocol_name + + layer_key = f"{ethernet_protocol_name or 'no-eth'}|{ip_protocol_name or 'no-ip'}" + layer_index = protocol_record["_layer_index"] + layer_record = layer_index.get(layer_key) + if layer_record is None: + layer_record = { + "ethernet_protocol": ethernet_protocol_name, + "ip_protocol": ip_protocol_name, + "packet_count": 0, + "last_seen": protocol_last_seen, + "accept_count": 0, + "drop_count": 0, + "reject_count": 0, + "unknown_count": 0, + } + layer_index[layer_key] = layer_record + protocol_record["layer_paths"].append(layer_record) + + layer_record["packet_count"] += int(record.get("protocol_packet_count") or 0) + layer_record["accept_count"] += int(record.get("accept_count") or 0) + layer_record["drop_count"] += int(record.get("drop_count") or 0) + layer_record["reject_count"] += int(record.get("reject_count") or 0) + layer_record["unknown_count"] += int(record.get("unknown_count") or 0) + if protocol_last_seen and ( + layer_record.get("last_seen") in (None, "") + or str(protocol_last_seen) > str(layer_record.get("last_seen")) + ): + layer_record["last_seen"] = protocol_last_seen result: List[Dict[str, Any]] = [] for iface, hosts in sorted(grouped.items()): for host in hosts.values(): host.pop("_protocol_index", None) + for protocol in host["protocols"]: + protocol.pop("_layer_index", None) + protocol["layer_paths"] = sorted( + protocol["layer_paths"], + key=lambda item: ( + -int(item.get("packet_count") or 0), + str(item.get("last_seen") or ""), + str(item.get("ethernet_protocol") or ""), + str(item.get("ip_protocol") or ""), + ), + ) host["protocols"] = sorted( host["protocols"], key=lambda item: ( diff --git a/frontend/src/pages/Analysis.tsx b/frontend/src/pages/Analysis.tsx index a5917ca..ec90eaa 100644 --- a/frontend/src/pages/Analysis.tsx +++ b/frontend/src/pages/Analysis.tsx @@ -3,6 +3,7 @@ import { Alert, Button, Card, + Checkbox, Col, Empty, InputNumber, @@ -31,11 +32,12 @@ import type { const { Title, Text, Paragraph } = Typography; type GraphNodeKind = 'interface' | 'host' | 'protocol'; +type TopologyLayerKind = GraphNodeKind | 'ethernet' | 'ip'; type TopologyNode = { id: string; label: string; - kind: GraphNodeKind; + kind: TopologyLayerKind; packetCount: number; interfaceName?: string; ipAddress?: string | null; @@ -81,6 +83,11 @@ type TopologyData = { tableRows: ProtocolTableRow[]; }; +type TopologyOptions = { + includeEthernetLayer: boolean; + includeIpLayer: boolean; +}; + type ForceNode = d3.SimulationNodeDatum & TopologyNode; type ForceLink = d3.SimulationLinkDatum & TopologyLink; @@ -133,7 +140,38 @@ function sankeyVisualWeight(packetCount: number) { return Math.max(1, Math.sqrt(Math.max(0, packetCount))); } -function buildTopologyData(interfaces: InterfaceProtocolAttachment[]): TopologyData { +function addOrUpdateLink(links: Map, source: string, target: string, packetCount: number, label: string) { + const linkId = `${source}->${target}`; + const existing = links.get(linkId); + if (existing) { + existing.packetCount += packetCount; + existing.value = sankeyVisualWeight(existing.packetCount); + existing.label = `${existing.label.split(' (')[0]} (${existing.packetCount})`; + return; + } + links.set(linkId, { + source, + target, + value: sankeyVisualWeight(packetCount), + packetCount, + label: `${label} (${packetCount})`, + }); +} + +function ensureProtocolNode(nodes: Map, id: string, label: string, kind: TopologyLayerKind) { + if (!nodes.has(id)) { + nodes.set(id, { + id, + label, + kind, + packetCount: 0, + protocol: label, + }); + } + return nodes.get(id)!; +} + +function buildTopologyData(interfaces: InterfaceProtocolAttachment[], options: TopologyOptions): TopologyData { const nodes = new Map(); const links = new Map(); const heatmapByHost = new Map(); @@ -179,28 +217,74 @@ function buildTopologyData(interfaces: InterfaceProtocolAttachment[]): TopologyD }; for (const protocol of host.protocols) { - const protocolId = `protocol:${protocol.protocol}`; protocols.add(protocol.protocol); - if (!nodes.has(protocolId)) { - nodes.set(protocolId, { - id: protocolId, - label: protocol.protocol, - kind: 'protocol', - packetCount: 0, - protocol: protocol.protocol, - }); - } - const existingProtocolNode = nodes.get(protocolId)!; - existingProtocolNode.packetCount += protocol.packet_count; + const layerPaths = + protocol.layer_paths.length > 0 + ? protocol.layer_paths + : [ + { + ethernet_protocol: protocol.ethernet_protocol ?? null, + ip_protocol: protocol.ip_protocol ?? null, + packet_count: protocol.packet_count, + last_seen: protocol.last_seen, + accept_count: protocol.accept_count, + drop_count: protocol.drop_count, + reject_count: protocol.reject_count, + unknown_count: protocol.unknown_count, + }, + ]; - const hostProtocolLinkId = `${hostId}->${protocolId}`; - links.set(hostProtocolLinkId, { - source: hostId, - target: protocolId, - value: sankeyVisualWeight(protocol.packet_count), - packetCount: protocol.packet_count, - label: `${host.ip_address ?? host.mac_address ?? 'host'} -> ${protocol.protocol} (${protocol.packet_count})`, - }); + for (const layerPath of layerPaths) { + let currentNodeId = hostId; + let currentLabel = host.ip_address ?? host.mac_address ?? 'host'; + + if ( + options.includeEthernetLayer && + layerPath.ethernet_protocol && + layerPath.ethernet_protocol !== protocol.protocol + ) { + const ethernetId = `ethernet:${layerPath.ethernet_protocol}`; + const ethernetNode = ensureProtocolNode(nodes, ethernetId, layerPath.ethernet_protocol, 'ethernet'); + ethernetNode.packetCount += layerPath.packet_count; + addOrUpdateLink( + links, + currentNodeId, + ethernetId, + layerPath.packet_count, + `${currentLabel} -> ${layerPath.ethernet_protocol}`, + ); + currentNodeId = ethernetId; + currentLabel = layerPath.ethernet_protocol; + } + + if (options.includeIpLayer && layerPath.ip_protocol && layerPath.ip_protocol !== currentLabel && layerPath.ip_protocol !== protocol.protocol) { + const ipId = `ip:${layerPath.ip_protocol}`; + const ipNode = ensureProtocolNode(nodes, ipId, layerPath.ip_protocol, 'ip'); + ipNode.packetCount += layerPath.packet_count; + addOrUpdateLink( + links, + currentNodeId, + ipId, + layerPath.packet_count, + `${currentLabel} -> ${layerPath.ip_protocol}`, + ); + currentNodeId = ipId; + currentLabel = layerPath.ip_protocol; + } + + if (currentLabel !== protocol.protocol || currentNodeId === hostId) { + const protocolId = `protocol:${protocol.protocol}`; + const protocolNode = ensureProtocolNode(nodes, protocolId, protocol.protocol, 'protocol'); + protocolNode.packetCount += layerPath.packet_count; + addOrUpdateLink( + links, + currentNodeId, + protocolId, + layerPath.packet_count, + `${currentLabel} -> ${protocol.protocol}`, + ); + } + } heatmapRow.values[protocol.protocol] = protocol.packet_count; @@ -243,9 +327,11 @@ function SankeyTopology({ data }: { data: TopologyData }) { const nodesPerLayer = { interface: data.nodes.filter((node) => node.kind === 'interface').length, host: data.nodes.filter((node) => node.kind === 'host').length, + ethernet: data.nodes.filter((node) => node.kind === 'ethernet').length, + ip: data.nodes.filter((node) => node.kind === 'ip').length, protocol: data.nodes.filter((node) => node.kind === 'protocol').length, }; - const maxNodesInLayer = Math.max(nodesPerLayer.interface, nodesPerLayer.host, nodesPerLayer.protocol, 1); + const maxNodesInLayer = Math.max(nodesPerLayer.interface, nodesPerLayer.host, nodesPerLayer.ethernet, nodesPerLayer.ip, nodesPerLayer.protocol, 1); const height = clamp(maxNodesInLayer * 56 + 120, 260, 760); const svg = d3.select(svgRef.current); svg.selectAll('*').remove(); @@ -279,7 +365,7 @@ function SankeyTopology({ data }: { data: TopologyData }) { .attr('d', sankeyLinkHorizontal()) .attr('stroke', (link) => { const target = link.target as SankeyNodeDatum; - if (target.kind === 'protocol' && target.protocol) { + if ((target.kind === 'protocol' || target.kind === 'ethernet' || target.kind === 'ip') && target.protocol) { return protocolColor(target.protocol); } return '#9aa7b5'; @@ -303,6 +389,8 @@ function SankeyTopology({ data }: { data: TopologyData }) { .attr('fill', (d) => { if (d.kind === 'interface') return '#20405d'; if (d.kind === 'host') return '#d7e7f5'; + if (d.kind === 'ethernet') return '#d7c09c'; + if (d.kind === 'ip') return '#a8c8df'; return d.protocol ? protocolColor(d.protocol) : '#d8d8d8'; }) .attr('stroke', (d) => (d.kind === 'host' ? '#9bb8d6' : 'none')) @@ -353,6 +441,8 @@ function ForceTopology({ data }: { data: TopologyData }) { 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)), + ethernet: nodes.filter((node) => node.kind === 'ethernet').sort((left, right) => left.label.localeCompare(right.label)), + ip: nodes.filter((node) => node.kind === 'ip').sort((left, right) => left.label.localeCompare(right.label)), protocol: nodes.filter((node) => node.kind === 'protocol').sort((left, right) => left.label.localeCompare(right.label)), }; @@ -374,8 +464,11 @@ function ForceTopology({ data }: { data: TopologyData }) { const interfaceY = distributedY(groupedNodes.interface, 120, height - 120); const hostY = distributedY(groupedNodes.host, 90, height - 90); + const ethernetY = distributedY(groupedNodes.ethernet, 120, height - 120); + const ipY = distributedY(groupedNodes.ip, 120, height - 120); 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 targetY = (node: ForceNode) => + interfaceY.get(node.id) ?? hostY.get(node.id) ?? ethernetY.get(node.id) ?? ipY.get(node.id) ?? protocolY.get(node.id) ?? height / 2; const simulation = d3 .forceSimulation(nodes) @@ -397,13 +490,17 @@ function ForceTopology({ data }: { data: TopologyData }) { .force('collision', d3.forceCollide().radius((node) => { if (node.kind === 'interface') return 52; if (node.kind === 'host') return 44; + if (node.kind === 'ethernet') return 36; + if (node.kind === 'ip') return 35; return 34; })) .force( 'x', d3.forceX().x((node) => { if (node.kind === 'interface') return 180; - if (node.kind === 'host') return width / 2; + if (node.kind === 'host') return 420; + if (node.kind === 'ethernet') return 690; + if (node.kind === 'ip') return 930; return width - 200; }).strength(0.42), ) @@ -420,11 +517,13 @@ function ForceTopology({ data }: { data: TopologyData }) { .join('line') .attr('stroke', (d) => { const target = d.target as ForceNode; - return target.kind === 'protocol' && target.protocol ? protocolColor(target.protocol) : '#92a1b2'; + return (target.kind === 'protocol' || target.kind === 'ethernet' || target.kind === 'ip') && target.protocol + ? protocolColor(target.protocol) + : '#92a1b2'; }) .attr('stroke-width', (d) => Math.max(1.5, Math.sqrt(d.value))); - link.append('title').text((d) => `${d.label}\nPackets: ${d.value}`); + link.append('title').text((d) => `${d.label}\nPackets: ${d.packetCount}`); const node = svg .append('g') @@ -437,11 +536,15 @@ function ForceTopology({ data }: { data: TopologyData }) { .attr('r', (d) => { if (d.kind === 'interface') return 26; if (d.kind === 'host') return 22; + if (d.kind === 'ethernet') return 19; + if (d.kind === 'ip') return 18; return 18; }) .attr('fill', (d) => { if (d.kind === 'interface') return '#20405d'; if (d.kind === 'host') return '#d7e7f5'; + if (d.kind === 'ethernet') return '#d7c09c'; + if (d.kind === 'ip') return '#a8c8df'; return d.protocol ? protocolColor(d.protocol) : '#cfd7df'; }) .attr('stroke', (d) => (d.kind === 'host' ? '#8aa8c6' : '#ffffff')) @@ -582,6 +685,8 @@ export default function Analysis(): ReactElement { const [sinceMinutes, setSinceMinutes] = useState(null); const [limitPerInterface, setLimitPerInterface] = useState(50); const [limitProtocolsPerHost, setLimitProtocolsPerHost] = useState(12); + const [includeEthernetLayer, setIncludeEthernetLayer] = useState(false); + const [includeIpLayer, setIncludeIpLayer] = useState(false); const [data, setData] = useState(null); const [loading, setLoading] = useState(false); @@ -601,7 +706,14 @@ export default function Analysis(): ReactElement { loadData().catch(() => undefined); }, [loadData]); - const topologyData = useMemo(() => buildTopologyData(data?.interfaces ?? []), [data]); + const topologyData = useMemo( + () => + buildTopologyData(data?.interfaces ?? [], { + includeEthernetLayer, + includeIpLayer, + }), + [data, includeEthernetLayer, includeIpLayer], + ); const columns = useMemo>( () => [ @@ -680,6 +792,12 @@ export default function Analysis(): ReactElement { Max protocols per host setLimitProtocolsPerHost(value ?? 12)} /> + setIncludeEthernetLayer(event.target.checked)}> + Ethernet layer + + setIncludeIpLayer(event.target.checked)}> + IP layer + diff --git a/frontend/src/types/analysis.ts b/frontend/src/types/analysis.ts index c29d568..f0f116d 100644 --- a/frontend/src/types/analysis.ts +++ b/frontend/src/types/analysis.ts @@ -26,6 +26,20 @@ export interface ProtocolEvidence { drop_count: number; reject_count: number; unknown_count: number; + ethernet_protocol?: string | null; + ip_protocol?: string | null; + layer_paths: ProtocolLayerPathEvidence[]; +} + +export interface ProtocolLayerPathEvidence { + ethernet_protocol?: string | null; + ip_protocol?: string | null; + packet_count: number; + last_seen: string; + accept_count: number; + drop_count: number; + reject_count: number; + unknown_count: number; } export interface InterfaceHostProtocolEvidence extends InterfaceHostEvidence {