diff --git a/backend/src/api/analysis_api.py b/backend/src/api/analysis_api.py index 872863c..2c6f604 100644 --- a/backend/src/api/analysis_api.py +++ b/backend/src/api/analysis_api.py @@ -7,6 +7,7 @@ from fastapi import APIRouter, HTTPException, Query from pydantic import BaseModel, Field import src.shared_objects as shared +from src.Models.packets import PacketDBModel router = APIRouter() @@ -128,8 +129,11 @@ class ConversationEvidence(BaseModel): ethernet_protocol: Optional[str] = None ip_protocol: Optional[str] = None hostnames: List[str] = Field(default_factory=list) + flow_ids: List[str] = Field(default_factory=list) + flow_count: int = 0 packet_count: int byte_count: int + duration_ms: int = 0 first_seen: datetime last_seen: datetime accept_count: int = 0 @@ -150,6 +154,43 @@ class ConversationAnalysisResponse(BaseModel): ) +class ConversationFlowSummaryEvidence(BaseModel): + flow_id: str + protocol: str + packet_count: int + byte_count: int + first_seen: datetime + last_seen: datetime + client_label: str + server_label: str + request_count: int = 0 + response_count: int = 0 + + +class ConversationFlowEventEvidence(BaseModel): + flow_id: str + timestamp: datetime + kind: str + label: str + src_label: str + dst_label: str + packet_id: Optional[str] = None + + +class ConversationFlowDetailResponse(BaseModel): + since: Optional[datetime] = None + packets: List[PacketDBModel] = Field(default_factory=list) + flows: List[ConversationFlowSummaryEvidence] = Field(default_factory=list) + events: List[ConversationFlowEventEvidence] = Field(default_factory=list) + notes: List[str] = Field( + default_factory=lambda: [ + "This detail view is reconstructed from ordered captured packets for one directional conversation or flow.", + "Events are inferred from HTTP metadata and TCP packet types, so lower-layer traffic may have fewer high-level annotations.", + "If multiple flow ids exist for the same directional tuple, the drawer shows all matching packets in timestamp order.", + ] + ) + + class LabelCountEvidence(BaseModel): label: str packet_count: int @@ -453,6 +494,69 @@ async def analysis_conversations( return ConversationAnalysisResponse(since=since, conversations=conversations) +@router.get("/conversation-flow-detail", response_model=ConversationFlowDetailResponse) +async def analysis_conversation_flow_detail( + flow_id: Optional[str] = Query( + None, + description="Specific flow_id to inspect. If omitted, the directional conversation tuple is used.", + ), + src_ip_address: Optional[str] = Query(None), + src_mac_address: Optional[str] = Query(None), + dst_ip_address: Optional[str] = Query(None), + dst_mac_address: Optional[str] = Query(None), + src_port: Optional[int] = Query(None, ge=0, le=65535), + dst_port: Optional[int] = Query(None, ge=0, le=65535), + protocol: Optional[str] = Query(None), + since_minutes: Optional[int] = Query( + None, + ge=1, + le=60 * 24 * 30, + description="Analyze only packets seen within the last N minutes. Omit to cover all captured history.", + ), + limit_packets: int = Query( + 1500, + ge=1, + le=10000, + description="Maximum number of packets returned for this conversation detail view.", + ), +) -> ConversationFlowDetailResponse: + """Return ordered packet detail, subflows, and derived request/response events for one conversation.""" + db = shared.db + if db is None: + raise HTTPException(status_code=503, detail="Database not available") + + if flow_id is None and all( + value is None + for value in [src_ip_address, src_mac_address, dst_ip_address, dst_mac_address, src_port, dst_port] + ): + raise HTTPException( + status_code=400, + detail="Provide either flow_id or enough directional conversation fields to identify the conversation.", + ) + + since: Optional[datetime] = None + if since_minutes is not None: + since = datetime.now(timezone.utc) - timedelta(minutes=since_minutes) + + try: + result = await db.fetch_conversation_flow_detail( + flow_id=flow_id, + src_ip_address=src_ip_address, + src_mac_address=src_mac_address, + dst_ip_address=dst_ip_address, + dst_mac_address=dst_mac_address, + src_port=src_port, + dst_port=dst_port, + protocol=protocol, + since=since, + limit_packets=limit_packets, + ) + except Exception as exc: + raise HTTPException(status_code=500, detail=f"Failed to analyze conversation flow detail: {exc}") from exc + + return ConversationFlowDetailResponse(since=since, **result) + + @router.get("/host-intelligence", response_model=HostIntelligenceAnalysisResponse) async def analysis_host_intelligence( since_minutes: Optional[int] = Query( diff --git a/backend/src/utilities/database.py b/backend/src/utilities/database.py index 6d06137..a5077d7 100644 --- a/backend/src/utilities/database.py +++ b/backend/src/utilities/database.py @@ -1278,6 +1278,8 @@ class DatabasePool: ip_proto_raw, eth_type_raw, ARRAY_REMOVE(ARRAY_AGG(DISTINCT NULLIF(app_hostname::text, '')), NULL) AS hostnames, + ARRAY_REMOVE(ARRAY_AGG(DISTINCT NULLIF(flow_id::text, '')), NULL) AS flow_ids, + COUNT(DISTINCT NULLIF(flow_id::text, '')) AS flow_count, COUNT(*) AS packet_count, COALESCE(SUM(length), 0) AS byte_count, MIN(timestamp) AS first_seen, @@ -1339,8 +1341,21 @@ class DatabasePool: "ethernet_protocol": _analysis_ethernet_protocol_name(record.get("eth_type_raw")), "ip_protocol": _analysis_ip_protocol_name(record.get("ip_proto_raw")), "hostnames": list(record.get("hostnames") or []), + "flow_ids": list(record.get("flow_ids") or []), + "flow_count": int(record.get("flow_count") or 0), "packet_count": int(record.get("packet_count") or 0), "byte_count": int(record.get("byte_count") or 0), + "duration_ms": max( + 0, + int( + ( + last_seen_raw - first_seen_raw + ).total_seconds() + * 1000 + ) + if hasattr(last_seen_raw, "timestamp") and hasattr(first_seen_raw, "timestamp") + else 0, + ), "first_seen": first_seen_raw.isoformat() if hasattr(first_seen_raw, "isoformat") else first_seen_raw, "last_seen": last_seen_raw.isoformat() if hasattr(last_seen_raw, "isoformat") else last_seen_raw, "accept_count": int(record.get("accept_count") or 0), @@ -1351,6 +1366,161 @@ class DatabasePool: ) return result + async def fetch_conversation_flow_detail( + self, + *, + flow_id: Optional[str] = None, + src_ip_address: Optional[str] = None, + src_mac_address: Optional[str] = None, + dst_ip_address: Optional[str] = None, + dst_mac_address: Optional[str] = None, + src_port: Optional[int] = None, + dst_port: Optional[int] = None, + protocol: Optional[str] = None, + since: Optional[datetime] = None, + limit_packets: int = 1500, + ) -> Dict[str, Any]: + """Fetch packet-level detail for one conversation or flow.""" + if self._pool is None: + await self.init_pool() + + query = """ + SELECT * + FROM packets + WHERE ($1::timestamptz IS NULL OR timestamp >= $1) + AND ( + ($2::text IS NOT NULL AND flow_id = $2) + OR ( + $2::text IS NULL + AND ( + ( + src_ip::text IS NOT DISTINCT FROM $3 + AND src_mac::text IS NOT DISTINCT FROM $4 + AND dst_ip::text IS NOT DISTINCT FROM $5 + AND dst_mac::text IS NOT DISTINCT FROM $6 + AND src_port IS NOT DISTINCT FROM $7 + AND dst_port IS NOT DISTINCT FROM $8 + ) + OR ( + src_ip::text IS NOT DISTINCT FROM $5 + AND src_mac::text IS NOT DISTINCT FROM $6 + AND dst_ip::text IS NOT DISTINCT FROM $3 + AND dst_mac::text IS NOT DISTINCT FROM $4 + AND src_port IS NOT DISTINCT FROM $8 + AND dst_port IS NOT DISTINCT FROM $7 + ) + ) + AND ( + $9::text IS NULL + OR COALESCE(NULLIF(app_protocol::text, ''), '') = $9 + OR COALESCE(NULLIF(app_protocol::text, ''), '') = '' + ) + ) + ) + ORDER BY timestamp ASC, id ASC + LIMIT $10 + """ + + async with self._pool.acquire() as conn: + rows = await conn.fetch( + query, + since, + flow_id, + src_ip_address, + src_mac_address, + dst_ip_address, + dst_mac_address, + src_port, + dst_port, + protocol, + limit_packets, + ) + + packets: List[PacketDBModel] = [] + for row in rows: + data = dict(row) + _normalize_json_fields(data) + _attach_derived_fields(data) + + raw_val = data.get("raw") + if isinstance(raw_val, (bytes, bytearray)): + data["raw_b64"] = base64.b64encode(raw_val).decode("ascii") + data.pop("raw", None) + + try: + packets.append(PacketDBModel(**data)) + except ValidationError as exc: + logger.warning("Skipping flow-detail packet row validation failure (id=%s): %s", data.get("id"), exc) + + flow_summaries: Dict[str, Dict[str, Any]] = {} + request_response_events: List[Dict[str, Any]] = [] + + for packet in packets: + subflow_id = packet.flow_id or f"tuple:{packet.src_ip}:{packet.src_port}->{packet.dst_ip}:{packet.dst_port}" + summary = flow_summaries.get(subflow_id) + if summary is None: + summary = { + "flow_id": subflow_id, + "protocol": packet.app_protocol or packet.ip_proto or packet.eth_type or "UNKNOWN", + "packet_count": 0, + "byte_count": 0, + "first_seen": packet.timestamp.isoformat(), + "last_seen": packet.timestamp.isoformat(), + "client_label": f"{packet.src_ip or packet.src_mac or 'unknown'}:{packet.src_port or '-'}", + "server_label": f"{packet.dst_ip or packet.dst_mac or 'unknown'}:{packet.dst_port or '-'}", + "request_count": 0, + "response_count": 0, + } + flow_summaries[subflow_id] = summary + + summary["packet_count"] += 1 + summary["byte_count"] += int(packet.length or 0) + packet_ts = packet.timestamp.isoformat() + if packet_ts < str(summary["first_seen"]): + summary["first_seen"] = packet_ts + if packet_ts > str(summary["last_seen"]): + summary["last_seen"] = packet_ts + + dpi_metadata = packet.dpi_metadata if isinstance(packet.dpi_metadata, dict) else {} + http_meta = dpi_metadata.get("http") if isinstance(dpi_metadata.get("http"), dict) else {} + tcp_meta = dpi_metadata.get("tcp") if isinstance(dpi_metadata.get("tcp"), dict) else {} + + event_label = None + event_kind = None + if http_meta.get("method"): + event_kind = "request" + event_label = f"{http_meta.get('method')} {http_meta.get('uri') or http_meta.get('path') or ''}".strip() + summary["request_count"] += 1 + elif http_meta.get("response_code") is not None: + event_kind = "response" + event_label = f"{http_meta.get('response_code')} {http_meta.get('response_phrase') or ''}".strip() + summary["response_count"] += 1 + elif tcp_meta.get("packet_type"): + event_kind = "tcp" + event_label = str(tcp_meta.get("packet_type")) + + if event_label: + request_response_events.append( + { + "flow_id": subflow_id, + "timestamp": packet.timestamp.isoformat(), + "kind": event_kind, + "label": event_label, + "src_label": f"{packet.src_ip or packet.src_mac or 'unknown'}:{packet.src_port or '-'}", + "dst_label": f"{packet.dst_ip or packet.dst_mac or 'unknown'}:{packet.dst_port or '-'}", + "packet_id": packet.id, + } + ) + + return { + "packets": packets, + "flows": sorted( + flow_summaries.values(), + key=lambda item: (str(item.get("first_seen") or ""), str(item.get("flow_id") or "")), + ), + "events": request_response_events, + } + async def analyze_host_intelligence( self, *, diff --git a/frontend/src/api/apiClient.ts b/frontend/src/api/apiClient.ts index e4b3849..82b8192 100644 --- a/frontend/src/api/apiClient.ts +++ b/frontend/src/api/apiClient.ts @@ -3,6 +3,7 @@ import axios from 'axios'; import { AnomalyAnalysisResponse, ConversationAnalysisResponse, + ConversationFlowDetailResponse, DiscoveryAnalysisResponse, HostIntelligenceAnalysisResponse, InterfaceHostAnalysisResponse, @@ -235,6 +236,46 @@ export const fetchConversationAnalysis = async ( return res.data; }; +export const fetchConversationFlowDetail = async ({ + flowId, + srcIpAddress, + srcMacAddress, + dstIpAddress, + dstMacAddress, + srcPort, + dstPort, + protocol, + sinceMinutes = null, + limitPackets = 1500, +}: { + flowId?: string | null; + srcIpAddress?: string | null; + srcMacAddress?: string | null; + dstIpAddress?: string | null; + dstMacAddress?: string | null; + srcPort?: number | null; + dstPort?: number | null; + protocol?: string | null; + sinceMinutes?: number | null; + limitPackets?: number; +}): Promise => { + const res = await api.get('/analysis/conversation-flow-detail', { + params: { + flow_id: flowId ?? undefined, + src_ip_address: srcIpAddress ?? undefined, + src_mac_address: srcMacAddress ?? undefined, + dst_ip_address: dstIpAddress ?? undefined, + dst_mac_address: dstMacAddress ?? undefined, + src_port: srcPort ?? undefined, + dst_port: dstPort ?? undefined, + protocol: protocol ?? undefined, + since_minutes: sinceMinutes ?? undefined, + limit_packets: limitPackets, + }, + }); + return res.data; +}; + export const fetchHostIntelligenceAnalysis = async ( sinceMinutes: number | null = null, limitHosts = 40, diff --git a/frontend/src/pages/Analysis.tsx b/frontend/src/pages/Analysis.tsx index 255e728..5b58cdc 100644 --- a/frontend/src/pages/Analysis.tsx +++ b/frontend/src/pages/Analysis.tsx @@ -31,6 +31,7 @@ import { ReactElement, useCallback, useEffect, useMemo, useRef, useState } from import { fetchAnomalyAnalysis, fetchConversationAnalysis, + fetchConversationFlowDetail, fetchDiscoveryAnalysis, fetchHostIntelligenceAnalysis, fetchInterfaceHostProtocolAnalysis, @@ -41,6 +42,9 @@ import type { BeaconCandidateEvidence, ConversationAnalysisResponse, ConversationEvidence, + ConversationFlowDetailResponse, + ConversationFlowEventEvidence, + ConversationFlowSummaryEvidence, DiscoveryActivityEvidence, DiscoveryAnalysisResponse, DropHeavyPathEvidence, @@ -56,6 +60,7 @@ import type { ResetHeavyPathEvidence, ScanCandidateEvidence, } from '../types/analysis'; +import type { PacketRow } from '../types/packets'; const { Title, Text, Paragraph } = Typography; @@ -150,10 +155,101 @@ function formatBytes(value?: number | null) { return `${(amount / 1024 ** 3).toFixed(1)} GB`; } +function formatDurationMs(value?: number | null) { + const duration = Number(value ?? 0); + if (!Number.isFinite(duration) || duration <= 0) return '0 ms'; + if (duration < 1000) return `${duration} ms`; + const seconds = duration / 1000; + if (seconds < 60) return `${seconds.toFixed(2)} s`; + const minutes = Math.floor(seconds / 60); + const remainingSeconds = seconds % 60; + if (minutes < 60) return `${minutes}m ${remainingSeconds.toFixed(1)}s`; + const hours = Math.floor(minutes / 60); + const remainingMinutes = minutes % 60; + return `${hours}h ${remainingMinutes}m`; +} + function endpointText(ipAddress?: string | null, macAddress?: string | null) { return ipAddress ?? macAddress ?? 'unknown endpoint'; } +function conversationRowKey(row: ConversationEvidence) { + return [ + row.src_ip_address, + row.src_mac_address, + row.src_port, + row.dst_ip_address, + row.dst_mac_address, + row.dst_port, + row.protocol, + ].join('|'); +} + +function asRecord(value: unknown): Record | null { + if (value == null || typeof value !== 'object' || Array.isArray(value)) { + return null; + } + return value as Record; +} + +function packetEventLabel(packet: PacketRow) { + const dpiMetadata = asRecord(packet.dpi_metadata); + const httpMeta = asRecord(dpiMetadata?.http); + const tcpMeta = asRecord(dpiMetadata?.tcp); + const method = typeof httpMeta?.method === 'string' ? httpMeta.method : null; + const uri = + typeof httpMeta?.uri === 'string' + ? httpMeta.uri + : typeof httpMeta?.path === 'string' + ? httpMeta.path + : null; + if (method) { + return `${method} ${uri ?? ''}`.trim(); + } + const responseCode = httpMeta?.response_code; + const responsePhrase = typeof httpMeta?.response_phrase === 'string' ? httpMeta.response_phrase : ''; + if (typeof responseCode === 'number' || typeof responseCode === 'string') { + return `${responseCode} ${responsePhrase}`.trim(); + } + const packetType = typeof tcpMeta?.packet_type === 'string' ? tcpMeta.packet_type : null; + if (packetType) { + return packetType; + } + return String(packet.app_protocol ?? packet.ip_proto ?? packet.eth_type ?? 'Packet'); +} + +function endpointMatches( + packetIp: string | null | undefined, + packetMac: string | null | undefined, + targetIp: string | null | undefined, + targetMac: string | null | undefined, +) { + if (targetIp == null && targetMac == null) { + return false; + } + const ipMatches = targetIp == null || packetIp === targetIp; + const macMatches = targetMac == null || packetMac === targetMac; + return ipMatches && macMatches; +} + +function packetDirection(packet: PacketRow, conversation: ConversationEvidence) { + const forward = + endpointMatches(packet.src_ip, packet.src_mac, conversation.src_ip_address, conversation.src_mac_address) && + endpointMatches(packet.dst_ip, packet.dst_mac, conversation.dst_ip_address, conversation.dst_mac_address) && + (conversation.src_port == null || packet.src_port === conversation.src_port) && + (conversation.dst_port == null || packet.dst_port === conversation.dst_port); + if (forward) return 'forward'; + + const reverse = + endpointMatches(packet.src_ip, packet.src_mac, conversation.dst_ip_address, conversation.dst_mac_address) && + endpointMatches(packet.dst_ip, packet.dst_mac, conversation.src_ip_address, conversation.src_mac_address) && + (conversation.src_port == null || packet.dst_port === conversation.src_port) && + (conversation.dst_port == null || packet.src_port === conversation.dst_port); + if (reverse) return 'reverse'; + + return 'unknown'; +} + function renderLabelTags(values: string[], color = 'default') { if (values.length === 0) return '—'; return ( @@ -1350,6 +1446,904 @@ function ConversationMatrix({ conversations }: { conversations: ConversationEvid return ; } +function ConversationTimeline({ + conversations, + selectedKey, + onSelect, +}: { + conversations: ConversationEvidence[]; + selectedKey?: string | null; + onSelect: (conversation: ConversationEvidence) => void; +}) { + const svgRef = useRef(null); + + const timelineRows = useMemo( + () => + [...conversations] + .sort((left, right) => { + const leftPackets = left.packet_count ?? 0; + const rightPackets = right.packet_count ?? 0; + if (rightPackets !== leftPackets) return rightPackets - leftPackets; + return String(left.first_seen).localeCompare(String(right.first_seen)); + }) + .slice(0, 28), + [conversations], + ); + + useEffect(() => { + if (!svgRef.current) return; + + const svg = d3.select(svgRef.current); + svg.selectAll('*').remove(); + + if (timelineRows.length === 0) { + return; + } + + const parsedTimes = timelineRows + .flatMap((conversation) => [new Date(conversation.first_seen), new Date(conversation.last_seen)]) + .filter((value) => !Number.isNaN(value.getTime())); + if (parsedTimes.length === 0) { + return; + } + + const minTime = d3.min(parsedTimes) ?? new Date(); + const maxTime = d3.max(parsedTimes) ?? new Date(minTime.getTime() + 1000); + const adjustedMaxTime = maxTime.getTime() === minTime.getTime() ? new Date(maxTime.getTime() + 1000) : maxTime; + + const margin = { top: 28, right: 36, bottom: 40, left: 320 }; + const width = 1180; + const height = clamp(timelineRows.length * 34 + margin.top + margin.bottom, 260, 1160); + svg.attr('viewBox', `0 0 ${width} ${height}`); + + svg + .append('rect') + .attr('x', 0) + .attr('y', 0) + .attr('width', width) + .attr('height', height) + .attr('rx', 18) + .attr('fill', '#fbfcfe'); + + const x = d3.scaleTime().domain([minTime, adjustedMaxTime]).range([margin.left, width - margin.right]); + const y = d3 + .scaleBand() + .domain(timelineRows.map(conversationRowKey)) + .range([margin.top, height - margin.bottom]) + .paddingInner(0.28); + + svg + .append('g') + .attr('transform', `translate(0,${height - margin.bottom})`) + .call(d3.axisBottom(x).ticks(6)) + .call((group) => group.selectAll('text').attr('fill', '#41566d')) + .call((group) => group.selectAll('line,path').attr('stroke', '#c8d5e2')); + + svg + .append('g') + .selectAll('line.row-guide') + .data(timelineRows) + .join('line') + .attr('x1', margin.left) + .attr('x2', width - margin.right) + .attr('y1', (conversation) => (y(conversationRowKey(conversation)) ?? 0) + y.bandwidth() / 2) + .attr('y2', (conversation) => (y(conversationRowKey(conversation)) ?? 0) + y.bandwidth() / 2) + .attr('stroke', '#edf2f7') + .attr('stroke-width', 1); + + const bars = svg + .append('g') + .selectAll('g.timeline-row') + .data(timelineRows) + .join('g') + .attr('class', 'timeline-row') + .style('cursor', 'pointer') + .on('click', (_, conversation) => onSelect(conversation)); + + bars + .append('rect') + .attr('x', (conversation) => x(new Date(conversation.first_seen))) + .attr('y', (conversation) => y(conversationRowKey(conversation)) ?? 0) + .attr('width', (conversation) => + Math.max( + 6, + x(new Date(conversation.last_seen)) - x(new Date(conversation.first_seen)), + ), + ) + .attr('height', y.bandwidth()) + .attr('rx', 8) + .attr('fill', (conversation) => protocolColor(conversation.protocol)) + .attr('fill-opacity', (conversation) => + conversationRowKey(conversation) === selectedKey ? 0.9 : clamp(0.4 + Math.log10(conversation.packet_count + 1) * 0.18, 0.4, 0.85), + ) + .attr('stroke', (conversation) => + conversationRowKey(conversation) === selectedKey ? '#10243a' : 'rgba(16, 36, 58, 0.15)', + ) + .attr('stroke-width', (conversation) => (conversationRowKey(conversation) === selectedKey ? 2.4 : 1)); + + bars + .append('circle') + .attr('cx', (conversation) => x(new Date(conversation.first_seen))) + .attr('cy', (conversation) => (y(conversationRowKey(conversation)) ?? 0) + y.bandwidth() / 2) + .attr('r', 4.5) + .attr('fill', '#ffffff') + .attr('stroke', (conversation) => protocolColor(conversation.protocol)) + .attr('stroke-width', 2); + + bars + .append('text') + .attr('x', margin.left - 12) + .attr('y', (conversation) => (y(conversationRowKey(conversation)) ?? 0) + y.bandwidth() / 2 + 4) + .attr('text-anchor', 'end') + .attr('font-size', 11) + .attr('fill', '#29445d') + .text((conversation) => { + const source = endpointText(conversation.src_ip_address, conversation.src_mac_address); + const destination = endpointText(conversation.dst_ip_address, conversation.dst_mac_address); + return `${source} -> ${destination}`; + }); + + bars + .append('title') + .text((conversation) => + [ + `${endpointText(conversation.src_ip_address, conversation.src_mac_address)} -> ${endpointText(conversation.dst_ip_address, conversation.dst_mac_address)}`, + `Ports: ${conversation.src_port ?? '—'} -> ${conversation.dst_port ?? '—'}`, + `Protocol: ${conversation.protocol}`, + `Packets: ${conversation.packet_count}`, + `Bytes: ${formatBytes(conversation.byte_count)}`, + `Duration: ${formatDurationMs(conversation.duration_ms)}`, + `Flows: ${conversation.flow_count}`, + `First seen: ${formatTimestamp(conversation.first_seen)}`, + `Last seen: ${formatTimestamp(conversation.last_seen)}`, + ].join('\n'), + ); + }, [timelineRows, onSelect, selectedKey]); + + if (conversations.length === 0) { + return ; + } + + return ; +} + +function ConversationSequenceDiagram({ + conversation, + packets, +}: { + conversation: ConversationEvidence; + packets: PacketRow[]; +}) { + const svgRef = useRef(null); + + useEffect(() => { + if (!svgRef.current) return; + + const svg = d3.select(svgRef.current); + svg.selectAll('*').remove(); + + if (packets.length === 0) { + return; + } + + const width = 1120; + const height = clamp(packets.length * 18 + 170, 320, 1080); + const margin = { top: 44, right: 50, bottom: 30, left: 50 }; + svg.attr('viewBox', `0 0 ${width} ${height}`); + + const lanes = [ + { id: 'source', x: 120, label: endpointText(conversation.src_ip_address, conversation.src_mac_address) }, + { id: 'ingress', x: 380, label: conversation.ingress_interface ?? 'Unknown ingress' }, + { id: 'egress', x: 740, label: conversation.egress_interface ?? 'Unknown egress' }, + { id: 'destination', x: 1000, label: endpointText(conversation.dst_ip_address, conversation.dst_mac_address) }, + ]; + + svg + .append('rect') + .attr('x', 0) + .attr('y', 0) + .attr('width', width) + .attr('height', height) + .attr('rx', 18) + .attr('fill', '#fbfcfe'); + + const parsedTimes = packets + .map((packet) => new Date(packet.timestamp ?? '')) + .filter((timestamp) => !Number.isNaN(timestamp.getTime())); + if (parsedTimes.length === 0) { + return; + } + const minTime = d3.min(parsedTimes) ?? new Date(); + const maxTime = d3.max(parsedTimes) ?? new Date(minTime.getTime() + 1000); + const adjustedMaxTime = maxTime.getTime() === minTime.getTime() ? new Date(maxTime.getTime() + 1000) : maxTime; + const y = d3.scaleTime().domain([minTime, adjustedMaxTime]).range([margin.top + 28, height - margin.bottom]); + + svg + .append('defs') + .append('marker') + .attr('id', 'conversation-arrow') + .attr('viewBox', '0 -5 10 10') + .attr('refX', 9) + .attr('refY', 0) + .attr('markerWidth', 7) + .attr('markerHeight', 7) + .attr('orient', 'auto') + .append('path') + .attr('d', 'M0,-5L10,0L0,5') + .attr('fill', '#3f6282'); + + lanes.forEach((lane) => { + svg + .append('line') + .attr('x1', lane.x) + .attr('x2', lane.x) + .attr('y1', margin.top) + .attr('y2', height - margin.bottom) + .attr('stroke', '#d9e3ec') + .attr('stroke-width', 2); + + svg + .append('text') + .attr('x', lane.x) + .attr('y', margin.top - 14) + .attr('text-anchor', 'middle') + .attr('font-size', 12) + .attr('font-weight', 700) + .attr('fill', '#42586f') + .text(lane.label); + }); + + svg + .append('g') + .attr('transform', `translate(${margin.left},0)`) + .call(d3.axisLeft(y).ticks(6)) + .call((group) => group.selectAll('text').attr('fill', '#41566d')) + .call((group) => group.selectAll('line,path').attr('stroke', '#c8d5e2')); + + const packetLayer = svg.append('g'); + for (const packet of packets) { + const packetTime = packet.timestamp ? new Date(packet.timestamp) : null; + if (packetTime == null || Number.isNaN(packetTime.getTime())) continue; + const direction = packetDirection(packet, conversation); + const yValue = y(packetTime); + const pathPoints = + direction === 'reverse' + ? [lanes[3], lanes[2], lanes[1], lanes[0]] + : direction === 'forward' + ? [lanes[0], lanes[1], lanes[2], lanes[3]] + : [lanes[0], lanes[3]]; + const lineGenerator = d3 + .line<{ x: number; y: number }>() + .x((point) => point.x) + .y((point) => point.y) + .curve(d3.curveMonotoneX); + const color = direction === 'reverse' ? '#d95f0e' : direction === 'forward' ? '#2f6fed' : '#7a8ea4'; + packetLayer + .append('path') + .attr( + 'd', + lineGenerator(pathPoints.map((point) => ({ x: point.x, y: yValue }))) ?? '', + ) + .attr('fill', 'none') + .attr('stroke', color) + .attr('stroke-width', 2.4) + .attr('stroke-opacity', 0.75) + .attr('marker-end', 'url(#conversation-arrow)') + .append('title') + .text( + [ + formatTimestamp(packet.timestamp), + `${endpointText(packet.src_ip, packet.src_mac)}:${packet.src_port ?? '—'} -> ${endpointText(packet.dst_ip, packet.dst_mac)}:${packet.dst_port ?? '—'}`, + packetEventLabel(packet), + `Length: ${packet.length ?? 0} B`, + `Verdict: ${packet.verdict ?? 'unknown'}`, + `Flow: ${packet.flow_id ?? '—'}`, + ].join('\n'), + ); + + packetLayer + .append('circle') + .attr('cx', pathPoints[pathPoints.length - 1].x) + .attr('cy', yValue) + .attr('r', 3.5) + .attr('fill', color) + .attr('opacity', 0.95); + } + }, [conversation, packets]); + + if (packets.length === 0) { + return ; + } + + return ; +} + +function ConversationFlowDrawer({ + conversation, + detail, + open, + loading, + onClose, +}: { + conversation: ConversationEvidence | null; + detail: ConversationFlowDetailResponse | null; + open: boolean; + loading: boolean; + onClose: () => void; +}) { + const flowColumns = useMemo>( + () => [ + { title: 'Flow', dataIndex: 'flow_id', key: 'flow_id', ellipsis: true }, + { + title: 'Protocol', + dataIndex: 'protocol', + key: 'protocol', + width: 120, + render: (value: string) => {value}, + }, + { 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: 'Requests', dataIndex: 'request_count', key: 'request_count', width: 90 }, + { title: 'Responses', dataIndex: 'response_count', key: 'response_count', width: 100 }, + { + title: 'Window', + key: 'window', + render: (_, row) => `${formatTimestamp(row.first_seen)} → ${formatTimestamp(row.last_seen)}`, + }, + ], + [], + ); + + const eventColumns = useMemo>( + () => [ + { + title: 'Timestamp', + dataIndex: 'timestamp', + key: 'timestamp', + width: 220, + render: (value: string) => formatTimestamp(value), + }, + { + title: 'Kind', + dataIndex: 'kind', + key: 'kind', + width: 110, + render: (value: string) => {value}, + }, + { title: 'Label', dataIndex: 'label', key: 'label' }, + { + title: 'Direction', + key: 'direction', + render: (_, row) => `${row.src_label} -> ${row.dst_label}`, + }, + { title: 'Flow', dataIndex: 'flow_id', key: 'flow_id', ellipsis: true }, + ], + [], + ); + + const packetColumns = useMemo>( + () => [ + { + title: 'Timestamp', + dataIndex: 'timestamp', + key: 'timestamp', + width: 220, + render: (value?: string) => formatTimestamp(value), + }, + { + title: 'Direction', + key: 'direction', + render: (_, row) => + `${endpointText(row.src_ip, row.src_mac)}:${row.src_port ?? '—'} -> ${endpointText(row.dst_ip, row.dst_mac)}:${row.dst_port ?? '—'}`, + }, + { + title: 'Type', + key: 'type', + width: 220, + render: (_, row) => packetEventLabel(row), + }, + { + title: 'Length', + dataIndex: 'length', + key: 'length', + width: 90, + render: (value?: number | null) => `${value ?? 0} B`, + }, + { + title: 'Verdict', + dataIndex: 'verdict', + key: 'verdict', + width: 110, + render: (value?: string | null) => value ?? 'unknown', + }, + { title: 'Flow', dataIndex: 'flow_id', key: 'flow_id', ellipsis: true }, + ], + [], + ); + + return ( + ${endpointText(conversation.dst_ip_address, conversation.dst_mac_address)}` : 'Conversation detail'} + placement="right" + width={1220} + open={open} + onClose={onClose} + > + {conversation == null ? null : ( + + + + + + {conversation.protocol} + + {conversation.src_port ?? '—'} + {' -> '} + {conversation.dst_port ?? '—'} + + {conversation.packet_count} packets + {formatBytes(conversation.byte_count)} + {formatDurationMs(conversation.duration_ms)} + {conversation.flow_count} flow{conversation.flow_count === 1 ? '' : 's'} + + + {formatTimestamp(conversation.first_seen)} + {' -> '} + {formatTimestamp(conversation.last_seen)} + + + Path: {conversation.ingress_interface ?? 'Unknown ingress'} + {' -> '} + {conversation.egress_interface ?? 'Unknown egress'} + +
{renderLabelTags(conversation.hostnames, 'geekblue')}
+
+
+ + + + Ordered packets across source, ingress, egress, and destination lanes. Blue lines follow the selected direction, orange lines are the reverse path. + + + + + `${row.timestamp}|${row.flow_id}|${row.packet_id ?? row.label}`} + columns={eventColumns} + dataSource={detail?.events ?? []} + size="small" + bordered + pagination={{ pageSize: 10 }} + locale={{ emptyText: loading ? 'Loading…' : 'No derived events found for this conversation.' }} + /> + ), + }, + { + key: 'flows', + label: 'Subflows', + children: ( + + ), + }, + { + key: 'packets', + label: 'Packets', + children: ( +
String(row.id ?? `${row.timestamp}|${row.packet_id ?? row.flow_id ?? 'packet'}`)} + columns={packetColumns} + dataSource={detail?.packets ?? []} + size="small" + bordered + pagination={{ pageSize: 15 }} + locale={{ emptyText: loading ? 'Loading…' : 'No packets available for this conversation yet.' }} + /> + ), + }, + ]} + /> + + + )} + + ); +} + +function DiscoveryTimeline({ activities }: { activities: DiscoveryActivityEvidence[] }) { + const svgRef = useRef(null); + + const timelineData = useMemo(() => { + const categories = Array.from(new Set(activities.map((activity) => activity.category))).sort(); + const points = new Map>(); + + for (const activity of activities) { + const date = new Date(activity.last_seen); + if (Number.isNaN(date.getTime())) continue; + const bucket = new Date(date); + bucket.setSeconds(0, 0); + const bucketKey = bucket.toISOString(); + const categoryMap = points.get(bucketKey) ?? new Map(); + categoryMap.set(activity.category, (categoryMap.get(activity.category) ?? 0) + activity.packet_count); + points.set(bucketKey, categoryMap); + } + + const buckets = Array.from(points.entries()) + .sort((left, right) => left[0].localeCompare(right[0])) + .slice(-24) + .map(([bucket, counts]) => ({ + bucket, + counts, + total: Array.from(counts.values()).reduce((sum, value) => sum + value, 0), + })); + + return { categories, buckets }; + }, [activities]); + + useEffect(() => { + if (!svgRef.current) return; + const svg = d3.select(svgRef.current); + svg.selectAll('*').remove(); + + if (timelineData.buckets.length === 0) return; + + const width = 1120; + const height = 360; + const margin = { top: 30, right: 20, bottom: 70, left: 56 }; + svg.attr('viewBox', `0 0 ${width} ${height}`); + svg.append('rect').attr('x', 0).attr('y', 0).attr('width', width).attr('height', height).attr('rx', 18).attr('fill', '#fbfcfe'); + + const x = d3 + .scaleBand() + .domain(timelineData.buckets.map((bucket) => bucket.bucket)) + .range([margin.left, width - margin.right]) + .paddingInner(0.18); + const y = d3 + .scaleLinear() + .domain([0, d3.max(timelineData.buckets, (bucket) => bucket.total) ?? 1]) + .nice() + .range([height - margin.bottom, margin.top]); + + const stack = d3.stack<{ bucket: string; [key: string]: string | number }>().keys(timelineData.categories); + const stackedInput = timelineData.buckets.map((bucket) => { + const row: { bucket: string; [key: string]: string | number } = { bucket: bucket.bucket }; + for (const category of timelineData.categories) { + row[category] = bucket.counts.get(category) ?? 0; + } + return row; + }); + + const series = stack(stackedInput); + const palette = d3.scaleOrdinal(d3.schemeTableau10).domain(timelineData.categories); + + svg + .append('g') + .selectAll('g') + .data(series) + .join('g') + .attr('fill', (seriesItem) => palette(seriesItem.key)) + .selectAll('rect') + .data((seriesItem) => seriesItem.map((entry) => ({ entry, key: seriesItem.key }))) + .join('rect') + .attr('x', (d) => x((d.entry.data as { bucket: string }).bucket) ?? 0) + .attr('y', (d) => y(d.entry[1])) + .attr('width', x.bandwidth()) + .attr('height', (d) => Math.max(0, y(d.entry[0]) - y(d.entry[1]))) + .append('title') + .text((d) => `${d.key}\n${formatTimestamp((d.entry.data as { bucket: string }).bucket)}\nPackets: ${d.entry[1] - d.entry[0]}`); + + svg + .append('g') + .attr('transform', `translate(0,${height - margin.bottom})`) + .call( + d3 + .axisBottom(x) + .tickFormat((value) => formatTimestamp(value).slice(11, 16)) + .tickValues(timelineData.buckets.map((bucket) => bucket.bucket).filter((_, index, array) => index % Math.max(1, Math.ceil(array.length / 8)) === 0)), + ) + .call((group) => group.selectAll('text').attr('fill', '#41566d')) + .call((group) => group.selectAll('line,path').attr('stroke', '#c8d5e2')); + + svg + .append('g') + .attr('transform', `translate(${margin.left},0)`) + .call(d3.axisLeft(y).ticks(5)) + .call((group) => group.selectAll('text').attr('fill', '#41566d')) + .call((group) => group.selectAll('line,path').attr('stroke', '#c8d5e2')); + + const legend = svg.append('g').attr('transform', `translate(${margin.left},${height - 28})`); + timelineData.categories.forEach((category, index) => { + const group = legend.append('g').attr('transform', `translate(${index * 140},0)`); + group.append('rect').attr('width', 12).attr('height', 12).attr('rx', 3).attr('fill', palette(category)); + group.append('text').attr('x', 18).attr('y', 10).attr('font-size', 11).attr('fill', '#41566d').text(category); + }); + }, [timelineData]); + + if (activities.length === 0) { + return ; + } + + return ; +} + +function ScanScatterplot({ candidates }: { candidates: ScanCandidateEvidence[] }) { + const svgRef = useRef(null); + + useEffect(() => { + if (!svgRef.current) return; + const svg = d3.select(svgRef.current); + svg.selectAll('*').remove(); + + if (candidates.length === 0) return; + + const width = 980; + const height = 420; + const margin = { top: 24, right: 24, bottom: 56, left: 56 }; + svg.attr('viewBox', `0 0 ${width} ${height}`); + svg.append('rect').attr('x', 0).attr('y', 0).attr('width', width).attr('height', height).attr('rx', 18).attr('fill', '#fbfcfe'); + + const x = d3 + .scaleLinear() + .domain([0, d3.max(candidates, (candidate) => candidate.target_host_count) ?? 1]) + .nice() + .range([margin.left, width - margin.right]); + const y = d3 + .scaleLinear() + .domain([0, d3.max(candidates, (candidate) => candidate.target_port_count) ?? 1]) + .nice() + .range([height - margin.bottom, margin.top]); + const radius = d3 + .scaleSqrt() + .domain([0, d3.max(candidates, (candidate) => candidate.packet_count) ?? 1]) + .range([6, 24]); + + svg + .append('g') + .attr('transform', `translate(0,${height - margin.bottom})`) + .call(d3.axisBottom(x).ticks(6)) + .call((group) => group.selectAll('text').attr('fill', '#41566d')) + .call((group) => group.selectAll('line,path').attr('stroke', '#c8d5e2')); + svg + .append('g') + .attr('transform', `translate(${margin.left},0)`) + .call(d3.axisLeft(y).ticks(6)) + .call((group) => group.selectAll('text').attr('fill', '#41566d')) + .call((group) => group.selectAll('line,path').attr('stroke', '#c8d5e2')); + + svg + .append('text') + .attr('x', width / 2) + .attr('y', height - 16) + .attr('text-anchor', 'middle') + .attr('font-size', 12) + .attr('fill', '#41566d') + .text('Distinct target hosts'); + svg + .append('text') + .attr('transform', `translate(18, ${height / 2}) rotate(-90)`) + .attr('text-anchor', 'middle') + .attr('font-size', 12) + .attr('fill', '#41566d') + .text('Distinct target ports'); + + svg + .append('g') + .selectAll('circle') + .data(candidates) + .join('circle') + .attr('cx', (candidate) => x(candidate.target_host_count)) + .attr('cy', (candidate) => y(candidate.target_port_count)) + .attr('r', (candidate) => radius(candidate.packet_count)) + .attr('fill', '#d95f0e') + .attr('fill-opacity', 0.28) + .attr('stroke', '#a34000') + .attr('stroke-width', 1.5) + .append('title') + .text((candidate) => + [ + endpointText(candidate.src_ip_address, candidate.src_mac_address), + `Target hosts: ${candidate.target_host_count}`, + `Target ports: ${candidate.target_port_count}`, + `Packets: ${candidate.packet_count}`, + `Last seen: ${formatTimestamp(candidate.last_seen)}`, + ].join('\n'), + ); + }, [candidates]); + + if (candidates.length === 0) { + return ; + } + + return ; +} + +function HostDetailDrawer({ + host, + open, + onClose, +}: { + host: HostIntelligenceEvidence | null; + open: boolean; + onClose: () => void; +}) { + return ( + + {host == null ? null : ( + + + + IP: {host.ip_address ?? '—'} + MAC: {host.mac_address ?? '—'} + Interfaces: {host.interfaces.join(', ') || '—'} + + First seen: {formatTimestamp(host.first_seen)} | Last seen: {formatTimestamp(host.last_seen)} + +
{renderLabelTags(host.hostnames, 'geekblue')}
+
+
+ + + + {host.top_protocols.length === 0 ? ( + No protocol profile available yet. + ) : ( + host.top_protocols.map((protocol) => { + const maxCount = Math.max(...host.top_protocols.map((item) => item.packet_count), 1); + const widthPct = (protocol.packet_count / maxCount) * 100; + return ( +
+ + {protocol.label} + {protocol.packet_count} + +
+
+
+
+ ); + }) + )} + + + + + + {host.peers.length === 0 ? ( + No peer details available yet. + ) : ( + host.peers.map((peer) => { + const maxCount = Math.max(...host.peers.map((item) => item.packet_count), 1); + const widthPct = (peer.packet_count / maxCount) * 100; + return ( +
+ + {endpointText(peer.ip_address, peer.mac_address)} + + {peer.packet_count} packets · {formatBytes(peer.byte_count)} · {formatTimestamp(peer.last_seen)} + +
+
+
+
{renderLabelTags(peer.protocols, 'purple')}
+ +
+ ); + }) + )} +
+ + + + + {host.services.length === 0 ? ( + No service evidence inferred yet. + ) : ( + host.services.map((service) => ( +
+ + Port {service.port ?? '—'} · {service.protocol} · {service.packet_count} packets ·{' '} + {formatBytes(service.byte_count)} + +
{renderLabelTags(service.hostnames, 'geekblue')}
+
+ )) + )} +
+
+ + )} + + ); +} + +function BeaconSparklinePanel({ candidates }: { candidates: BeaconCandidateEvidence[] }) { + if (candidates.length === 0) { + return ; + } + + return ( + + {candidates.slice(0, 12).map((candidate) => { + const samples = candidate.interval_samples.length > 0 ? candidate.interval_samples : [candidate.avg_interval_seconds]; + const maxSample = Math.max(...samples, 1); + const points = samples + .map((sample, index) => { + const x = samples.length === 1 ? 0 : (index / (samples.length - 1)) * 240; + const y = 48 - (sample / maxSample) * 40; + return `${x},${y}`; + }) + .join(' '); + + return ( + + +
+ + {endpointText(candidate.src_ip_address, candidate.src_mac_address)} + {' -> '} + {endpointText(candidate.dst_ip_address, candidate.dst_mac_address)} + +
+ + {candidate.protocol} + Port {candidate.dst_port ?? '—'} + {candidate.packet_count} packets + avg {candidate.avg_interval_seconds}s + jitter {candidate.jitter_ratio} + + + + + +
+
+ ); + })} +
+ ); +} + export default function Analysis(): ReactElement { const [sinceMinutes, setSinceMinutes] = useState(null); const [limitPerInterface, setLimitPerInterface] = useState(50); @@ -1367,6 +2361,10 @@ export default function Analysis(): ReactElement { const [hostIntelligenceData, setHostIntelligenceData] = useState(null); const [discoveryData, setDiscoveryData] = useState(null); const [anomalyData, setAnomalyData] = useState(null); + const [selectedHost, setSelectedHost] = useState(null); + const [selectedConversation, setSelectedConversation] = useState(null); + const [conversationDetail, setConversationDetail] = useState(null); + const [conversationDetailLoading, setConversationDetailLoading] = useState(false); const [loading, setLoading] = useState(false); const loadData = useCallback(async () => { @@ -1403,6 +2401,35 @@ export default function Analysis(): ReactElement { limitAnomalies, ]); + 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]); @@ -1503,6 +2530,7 @@ export default function Analysis(): ReactElement { 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', @@ -1510,6 +2538,13 @@ export default function Analysis(): ReactElement { 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', @@ -1967,6 +3002,18 @@ export default function Analysis(): ReactElement { label: 'Communication', children: ( + + + Time-ordered view of the busiest conversations. Click a bar to inspect the full packet sequence, + subflows, and derived request/response events. + + + + Source-to-destination adjacency matrix for the busiest conversations. Cell color and value reflect @@ -1978,23 +3025,18 @@ export default function Analysis(): ReactElement { Directional conversations grouped by source, destination, ports, protocol, and verdict outcome. + Click a row for packet-level drill-down.
- [ - row.src_ip_address, - row.src_mac_address, - row.src_port, - row.dst_ip_address, - row.dst_mac_address, - row.dst_port, - row.protocol, - ].join('|') - } + 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.' }} /> @@ -2021,7 +3063,7 @@ export default function Analysis(): ReactElement { Asset-focused view combining interfaces, hostname hints, dominant protocols, likely services, and - peer relationships. + peer relationships. Click a row to open a focused host detail drawer.
`${row.ip_address ?? 'no-ip'}|${row.mac_address ?? 'no-mac'}`} @@ -2029,58 +3071,10 @@ export default function Analysis(): ReactElement { dataSource={hostIntelligenceData?.hosts ?? []} size="small" bordered - expandable={{ - expandedRowRender: (row) => ( - -
- Known hostnames -
{renderLabelTags(row.hostnames, 'geekblue')}
-
-
- Top peers -
- {row.peers.length === 0 ? ( - No peer details available yet. - ) : ( - - {row.peers.map((peer) => ( -
- - {endpointText(peer.ip_address, peer.mac_address)} · {peer.packet_count} packets - · {formatBytes(peer.byte_count)} - -
{renderLabelTags(peer.protocols, 'purple')}
-
- ))} -
- )} -
-
-
- Likely services -
- {row.services.length === 0 ? ( - No service evidence inferred yet. - ) : ( - - {row.services.map((service) => ( -
- - Port {service.port ?? '—'} · {service.protocol} · {service.packet_count} packets - · {formatBytes(service.byte_count)} - -
- {renderLabelTags(service.hostnames, 'geekblue')} -
-
- ))} -
- )} -
-
-
- ), - }} + onRow={(row) => ({ + onClick: () => setSelectedHost(row), + style: { cursor: 'pointer' }, + })} pagination={{ pageSize: 15 }} locale={{ emptyText: loading ? 'Loading…' : 'No host intelligence available yet.' }} /> @@ -2092,6 +3086,14 @@ export default function Analysis(): ReactElement { label: 'Discovery & Risk', children: ( + + + Time-bucketed discovery activity that helps reveal bursts of ARP, DHCP, name resolution, and + service advertisement traffic. + + + + Local discovery, naming, and service advertisement traffic such as ARP, DHCP, mDNS, SSDP, @@ -2125,39 +3127,45 @@ export default function Analysis(): ReactElement { key: 'scans', label: 'Scan Candidates', children: ( -
`${row.src_ip_address ?? 'no-ip'}|${row.src_mac_address ?? 'no-mac'}`} - columns={scanColumns} - dataSource={anomalyData?.scan_candidates ?? []} - size="small" - bordered - pagination={{ pageSize: 10 }} - locale={{ emptyText: loading ? 'Loading…' : 'No scan candidates found yet.' }} - /> + + +
`${row.src_ip_address ?? 'no-ip'}|${row.src_mac_address ?? 'no-mac'}`} + columns={scanColumns} + dataSource={anomalyData?.scan_candidates ?? []} + size="small" + bordered + pagination={{ pageSize: 10 }} + locale={{ emptyText: loading ? 'Loading…' : 'No scan candidates found yet.' }} + /> + ), }, { key: 'beacons', label: 'Beaconing', children: ( -
- [ - row.src_ip_address, - row.src_mac_address, - row.dst_ip_address, - row.dst_mac_address, - row.dst_port, - row.protocol, - ].join('|') - } - columns={beaconColumns} - dataSource={anomalyData?.beacon_candidates ?? []} - size="small" - bordered - pagination={{ pageSize: 10 }} - locale={{ emptyText: loading ? 'Loading…' : 'No beacon candidates found yet.' }} - /> + + +
+ [ + row.src_ip_address, + row.src_mac_address, + row.dst_ip_address, + row.dst_mac_address, + row.dst_port, + row.protocol, + ].join('|') + } + columns={beaconColumns} + dataSource={anomalyData?.beacon_candidates ?? []} + size="small" + bordered + pagination={{ pageSize: 10 }} + locale={{ emptyText: loading ? 'Loading…' : 'No beacon candidates found yet.' }} + /> + ), }, { @@ -2237,6 +3245,18 @@ export default function Analysis(): ReactElement { ]} /> + setSelectedHost(null)} /> + { + setSelectedConversation(null); + setConversationDetail(null); + setConversationDetailLoading(false); + }} + /> ); } diff --git a/frontend/src/types/analysis.ts b/frontend/src/types/analysis.ts index dee5c72..12a5426 100644 --- a/frontend/src/types/analysis.ts +++ b/frontend/src/types/analysis.ts @@ -1,3 +1,5 @@ +import type { PacketRow } from './packets'; + export interface InterfaceHostEvidence { ip_address?: string | null; mac_address?: string | null; @@ -94,8 +96,11 @@ export interface ConversationEvidence { ethernet_protocol?: string | null; ip_protocol?: string | null; hostnames: string[]; + flow_ids: string[]; + flow_count: number; packet_count: number; byte_count: number; + duration_ms: number; first_seen: string; last_seen: string; accept_count: number; @@ -110,6 +115,37 @@ export interface ConversationAnalysisResponse { notes: string[]; } +export interface ConversationFlowSummaryEvidence { + flow_id: string; + protocol: string; + packet_count: number; + byte_count: number; + first_seen: string; + last_seen: string; + client_label: string; + server_label: string; + request_count: number; + response_count: number; +} + +export interface ConversationFlowEventEvidence { + flow_id: string; + timestamp: string; + kind: string; + label: string; + src_label: string; + dst_label: string; + packet_id?: string | null; +} + +export interface ConversationFlowDetailResponse { + since?: string | null; + packets: PacketRow[]; + flows: ConversationFlowSummaryEvidence[]; + events: ConversationFlowEventEvidence[]; + notes: string[]; +} + export interface LabelCountEvidence { label: string; packet_count: number;