test visualization with d3js
All checks were successful
Build and Deploy MITM Webserver / traffic_target (push) Successful in 1s
Build and Deploy MITM Webserver / build (push) Successful in 11s

This commit is contained in:
2026-03-30 21:34:40 +02:00
parent f758901f83
commit 0463075782
10 changed files with 1842 additions and 6 deletions

View File

@@ -20,11 +20,30 @@ class InterfaceHostEvidence(BaseModel):
destination_on_egress_count: int = Field(..., description="Packets where this endpoint appeared as the destination on egress.")
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.")
last_seen: datetime = Field(..., description="Most recent packet timestamp supporting this protocol mapping.")
accept_count: int = Field(0, description="Packets with verdict=accept for this protocol.")
drop_count: int = Field(0, description="Packets with verdict=drop for this protocol.")
reject_count: int = Field(0, description="Packets with verdict=reject for this protocol.")
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.")
class InterfaceAttachment(BaseModel):
interface: str = Field(..., description="MITM machine interface name.")
hosts: List[InterfaceHostEvidence] = Field(default_factory=list, description="Endpoints inferred to be attached to this interface.")
class InterfaceProtocolAttachment(BaseModel):
interface: str = Field(..., description="MITM machine interface name.")
hosts: List[InterfaceHostProtocolEvidence] = Field(default_factory=list, description="Endpoints inferred to be attached to this interface, with protocol breakdown.")
class InterfaceHostAnalysisResponse(BaseModel):
since: Optional[datetime] = Field(None, description="Only packets at or after this timestamp were analyzed.")
interfaces: List[InterfaceAttachment] = Field(default_factory=list)
@@ -37,6 +56,18 @@ class InterfaceHostAnalysisResponse(BaseModel):
)
class InterfaceHostProtocolAnalysisResponse(BaseModel):
since: Optional[datetime] = Field(None, description="Only packets at or after this timestamp were analyzed.")
interfaces: List[InterfaceProtocolAttachment] = Field(default_factory=list)
notes: List[str] = Field(
default_factory=lambda: [
"This is an inference from observed packet direction, not a kernel neighbor-table lookup.",
"Each host can carry multiple protocols; protocols prefer app_protocol and fall back to lower-layer protocol names.",
"Verdict counts are packet counts grouped per interface, host, and protocol.",
]
)
@router.get("/interface-hosts", response_model=InterfaceHostAnalysisResponse)
async def analysis_interface_hosts(
since_minutes: Optional[int] = Query(
@@ -68,3 +99,46 @@ async def analysis_interface_hosts(
interfaces = [InterfaceAttachment(**row) for row in rows]
return InterfaceHostAnalysisResponse(since=since, interfaces=interfaces)
@router.get("/interface-host-protocols", response_model=InterfaceHostProtocolAnalysisResponse)
async def analysis_interface_host_protocols(
since_minutes: Optional[int] = Query(
60,
ge=1,
le=60 * 24 * 30,
description="Analyze only packets seen within the last N minutes. Set to a large value to cover more history.",
),
limit_per_interface: int = Query(
50,
ge=1,
le=1000,
description="Maximum number of inferred hosts returned per interface.",
),
limit_protocols_per_host: int = Query(
12,
ge=1,
le=100,
description="Maximum number of top protocols returned per inferred host.",
),
) -> InterfaceHostProtocolAnalysisResponse:
"""Infer interface-host attachment and break observed traffic down by protocol."""
db = shared.db
if db is None:
raise HTTPException(status_code=503, detail="Database not available")
since: Optional[datetime] = None
if since_minutes is not None:
since = datetime.now(timezone.utc) - timedelta(minutes=since_minutes)
try:
rows = await db.infer_interface_host_protocols(
since=since,
limit_per_interface=limit_per_interface,
limit_protocols_per_host=limit_protocols_per_host,
)
except Exception as exc:
raise HTTPException(status_code=500, detail="Failed to infer interface host protocol mapping") from exc
interfaces = [InterfaceProtocolAttachment(**row) for row in rows]
return InterfaceHostProtocolAnalysisResponse(since=since, interfaces=interfaces)

View File

@@ -733,6 +733,197 @@ class DatabasePool:
for iface, hosts in sorted(grouped.items())
]
async def infer_interface_host_protocols(
self,
*,
since: Optional[datetime] = None,
limit_per_interface: int = 50,
limit_protocols_per_host: int = 12,
) -> List[Dict[str, Any]]:
"""Infer interface-host attachment and aggregate observed protocols and verdicts."""
if self._pool is None:
await self.init_pool()
async with self._pool.acquire() as conn:
rows = await conn.fetch(
"""
WITH observations AS (
SELECT
ingress_if AS iface,
src_ip::text AS ip_address,
src_mac::text AS mac_address,
COALESCE(NULLIF(app_protocol, ''), NULLIF(app_master_protocol, ''), NULLIF(ip_proto, ''), NULLIF(eth_type, ''), 'UNKNOWN') AS protocol_name,
COALESCE(NULLIF(verdict, ''), 'unknown') AS verdict_name,
timestamp,
'source_on_ingress' AS evidence
FROM packets
WHERE ingress_if IS NOT NULL
AND (src_ip IS NOT NULL OR src_mac IS NOT NULL)
AND ($1::timestamptz IS NULL OR timestamp >= $1)
UNION ALL
SELECT
egress_if AS iface,
dst_ip::text AS ip_address,
dst_mac::text AS mac_address,
COALESCE(NULLIF(app_protocol, ''), NULLIF(app_master_protocol, ''), NULLIF(ip_proto, ''), NULLIF(eth_type, ''), 'UNKNOWN') AS protocol_name,
COALESCE(NULLIF(verdict, ''), 'unknown') AS verdict_name,
timestamp,
'destination_on_egress' AS evidence
FROM packets
WHERE egress_if IS NOT NULL
AND (dst_ip IS NOT NULL OR dst_mac IS NOT NULL)
AND ($1::timestamptz IS NULL OR timestamp >= $1)
),
filtered AS (
SELECT *
FROM observations
WHERE iface IS NOT NULL
AND COALESCE(mac_address, '') <> 'ff:ff:ff:ff:ff:ff'
AND (
COALESCE(ip_address, '') <> ''
OR COALESCE(mac_address, '') <> ''
)
),
host_aggregated AS (
SELECT
iface,
ip_address,
mac_address,
COUNT(*) AS packet_count,
MAX(timestamp) AS last_seen,
SUM(CASE WHEN evidence = 'source_on_ingress' THEN 1 ELSE 0 END) AS source_on_ingress_count,
SUM(CASE WHEN evidence = 'destination_on_egress' THEN 1 ELSE 0 END) AS destination_on_egress_count
FROM filtered
GROUP BY iface, ip_address, mac_address
),
selected_hosts AS (
SELECT *
FROM (
SELECT
*,
ROW_NUMBER() OVER (
PARTITION BY iface
ORDER BY packet_count DESC, last_seen DESC, ip_address, mac_address
) AS row_num
FROM host_aggregated
) ranked_hosts
WHERE row_num <= $2
),
protocol_aggregated AS (
SELECT
filtered.iface,
filtered.ip_address,
filtered.mac_address,
filtered.protocol_name,
COUNT(*) AS packet_count,
MAX(filtered.timestamp) AS last_seen,
SUM(CASE WHEN filtered.verdict_name = 'accept' THEN 1 ELSE 0 END) AS accept_count,
SUM(CASE WHEN filtered.verdict_name = 'drop' THEN 1 ELSE 0 END) AS drop_count,
SUM(CASE WHEN filtered.verdict_name = 'reject' THEN 1 ELSE 0 END) AS reject_count,
SUM(CASE WHEN filtered.verdict_name NOT IN ('accept', 'drop', 'reject') THEN 1 ELSE 0 END) AS unknown_count
FROM filtered
INNER JOIN selected_hosts
ON selected_hosts.iface = filtered.iface
AND selected_hosts.ip_address IS NOT DISTINCT FROM filtered.ip_address
AND selected_hosts.mac_address IS NOT DISTINCT FROM filtered.mac_address
GROUP BY filtered.iface, filtered.ip_address, filtered.mac_address, filtered.protocol_name
),
ranked_protocols AS (
SELECT *
FROM (
SELECT
*,
ROW_NUMBER() OVER (
PARTITION BY iface, ip_address, mac_address
ORDER BY packet_count DESC, last_seen DESC, protocol_name
) AS row_num
FROM protocol_aggregated
) ranked
WHERE row_num <= $3
)
SELECT
selected_hosts.iface,
selected_hosts.ip_address,
selected_hosts.mac_address,
selected_hosts.packet_count AS host_packet_count,
selected_hosts.last_seen AS host_last_seen,
selected_hosts.source_on_ingress_count,
selected_hosts.destination_on_egress_count,
ranked_protocols.protocol_name,
ranked_protocols.packet_count AS protocol_packet_count,
ranked_protocols.last_seen AS protocol_last_seen,
ranked_protocols.accept_count,
ranked_protocols.drop_count,
ranked_protocols.reject_count,
ranked_protocols.unknown_count
FROM selected_hosts
LEFT JOIN ranked_protocols
ON ranked_protocols.iface = selected_hosts.iface
AND ranked_protocols.ip_address IS NOT DISTINCT FROM selected_hosts.ip_address
AND ranked_protocols.mac_address IS NOT DISTINCT FROM selected_hosts.mac_address
ORDER BY
selected_hosts.iface,
selected_hosts.packet_count DESC,
selected_hosts.last_seen DESC,
selected_hosts.ip_address,
selected_hosts.mac_address,
ranked_protocols.packet_count DESC NULLS LAST,
ranked_protocols.last_seen DESC NULLS LAST,
ranked_protocols.protocol_name
""",
since,
limit_per_interface,
limit_protocols_per_host,
)
grouped: Dict[str, Dict[str, Dict[str, Any]]] = {}
for row in rows:
record = dict(row)
iface = str(record["iface"])
host_key = f"{record.get('ip_address') or 'no-ip'}|{record.get('mac_address') or 'no-mac'}"
iface_hosts = grouped.setdefault(iface, {})
host_record = iface_hosts.get(host_key)
if host_record is None:
host_record = {
"ip_address": record.get("ip_address"),
"mac_address": record.get("mac_address"),
"packet_count": int(record.get("host_packet_count") or 0),
"last_seen": record["host_last_seen"].isoformat() if hasattr(record.get("host_last_seen"), "isoformat") else record.get("host_last_seen"),
"source_on_ingress_count": int(record.get("source_on_ingress_count") or 0),
"destination_on_egress_count": int(record.get("destination_on_egress_count") or 0),
"protocols": [],
}
iface_hosts[host_key] = host_record
protocol_name = record.get("protocol_name")
if protocol_name not in (None, ""):
host_record["protocols"].append(
{
"protocol": str(protocol_name),
"packet_count": int(record.get("protocol_packet_count") or 0),
"last_seen": record["protocol_last_seen"].isoformat()
if hasattr(record.get("protocol_last_seen"), "isoformat")
else record.get("protocol_last_seen"),
"accept_count": int(record.get("accept_count") or 0),
"drop_count": int(record.get("drop_count") or 0),
"reject_count": int(record.get("reject_count") or 0),
"unknown_count": int(record.get("unknown_count") or 0),
}
)
result: List[Dict[str, Any]] = []
for iface, hosts in sorted(grouped.items()):
sorted_hosts = sorted(
hosts.values(),
key=lambda item: (-int(item.get("packet_count") or 0), str(item.get("last_seen") or ""), str(item.get("ip_address") or ""), str(item.get("mac_address") or "")),
)
result.append({"interface": iface, "hosts": sorted_hosts})
return result
async def clear_all_packets(self, reset_identity: bool = True) -> bool:
"""Truncate the packet table and optionally reset identity counters."""
if self._pool is None: