add analysis base api
This commit is contained in:
70
backend/src/api/analysis_api.py
Normal file
70
backend/src/api/analysis_api.py
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
"""Analysis endpoints derived from captured packet history."""
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Query
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
import src.shared_objects as shared
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
class InterfaceHostEvidence(BaseModel):
|
||||||
|
ip_address: Optional[str] = Field(None, description="Observed IP address for the host.")
|
||||||
|
mac_address: Optional[str] = Field(None, description="Observed MAC address for the host.")
|
||||||
|
packet_count: int = Field(..., description="How many packet observations supported this mapping.")
|
||||||
|
last_seen: datetime = Field(..., description="Most recent packet timestamp supporting this mapping.")
|
||||||
|
source_on_ingress_count: int = Field(..., description="Packets where this endpoint appeared as the source on ingress.")
|
||||||
|
destination_on_egress_count: int = Field(..., description="Packets where this endpoint appeared as the destination on egress.")
|
||||||
|
|
||||||
|
|
||||||
|
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 InterfaceHostAnalysisResponse(BaseModel):
|
||||||
|
since: Optional[datetime] = Field(None, description="Only packets at or after this timestamp were analyzed.")
|
||||||
|
interfaces: List[InterfaceAttachment] = 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.",
|
||||||
|
"A host is inferred on an interface when it appears as source on ingress or as destination on egress on that interface.",
|
||||||
|
"Broadcast and obviously incomplete endpoint records are ignored.",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/interface-hosts", response_model=InterfaceHostAnalysisResponse)
|
||||||
|
async def analysis_interface_hosts(
|
||||||
|
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(
|
||||||
|
100,
|
||||||
|
ge=1,
|
||||||
|
le=1000,
|
||||||
|
description="Maximum number of inferred hosts returned per interface.",
|
||||||
|
),
|
||||||
|
) -> InterfaceHostAnalysisResponse:
|
||||||
|
"""Infer which IP/MAC endpoints are likely attached to each MITM-side interface."""
|
||||||
|
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_hosts(since=since, limit_per_interface=limit_per_interface)
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to infer interface host mapping") from exc
|
||||||
|
|
||||||
|
interfaces = [InterfaceAttachment(**row) for row in rows]
|
||||||
|
return InterfaceHostAnalysisResponse(since=since, interfaces=interfaces)
|
||||||
@@ -11,6 +11,7 @@ import src.api.network_api as network_api
|
|||||||
import src.api.sniffer_api as sniffer_api
|
import src.api.sniffer_api as sniffer_api
|
||||||
import src.shared_objects as shared_objects
|
import src.shared_objects as shared_objects
|
||||||
from src.api import nft_manager
|
from src.api import nft_manager
|
||||||
|
from src.api import analysis_api
|
||||||
from src.api import packet_api
|
from src.api import packet_api
|
||||||
from src.api import packet_scripting_api
|
from src.api import packet_scripting_api
|
||||||
from src.config import settings
|
from src.config import settings
|
||||||
@@ -154,5 +155,6 @@ def versions() -> dict[str, str]:
|
|||||||
app.include_router(network_api.router, prefix="/network", tags=["network"])
|
app.include_router(network_api.router, prefix="/network", tags=["network"])
|
||||||
app.include_router(sniffer_api.router, prefix="/sniffer", tags=["sniffer"])
|
app.include_router(sniffer_api.router, prefix="/sniffer", tags=["sniffer"])
|
||||||
app.include_router(packet_api.router, prefix="/packets", tags=["packets"])
|
app.include_router(packet_api.router, prefix="/packets", tags=["packets"])
|
||||||
|
app.include_router(analysis_api.router, prefix="/analysis", tags=["analysis"])
|
||||||
app.include_router(nft_manager.router, tags=["firewall"])
|
app.include_router(nft_manager.router, tags=["firewall"])
|
||||||
app.include_router(packet_scripting_api.router, prefix="/scripts", tags=["scripts"])
|
app.include_router(packet_scripting_api.router, prefix="/scripts", tags=["scripts"])
|
||||||
|
|||||||
@@ -632,6 +632,107 @@ class DatabasePool:
|
|||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
async def infer_interface_hosts(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
since: Optional[datetime] = None,
|
||||||
|
limit_per_interface: int = 100,
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""Infer which IP/MAC endpoints are attached to each observed interface."""
|
||||||
|
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,
|
||||||
|
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,
|
||||||
|
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, '') <> ''
|
||||||
|
)
|
||||||
|
),
|
||||||
|
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
|
||||||
|
),
|
||||||
|
ranked AS (
|
||||||
|
SELECT
|
||||||
|
*,
|
||||||
|
ROW_NUMBER() OVER (
|
||||||
|
PARTITION BY iface
|
||||||
|
ORDER BY packet_count DESC, last_seen DESC, ip_address, mac_address
|
||||||
|
) AS row_num
|
||||||
|
FROM aggregated
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
iface,
|
||||||
|
ip_address,
|
||||||
|
mac_address,
|
||||||
|
packet_count,
|
||||||
|
last_seen,
|
||||||
|
source_on_ingress_count,
|
||||||
|
destination_on_egress_count
|
||||||
|
FROM ranked
|
||||||
|
WHERE row_num <= $2
|
||||||
|
ORDER BY iface, packet_count DESC, last_seen DESC, ip_address, mac_address
|
||||||
|
""",
|
||||||
|
since,
|
||||||
|
limit_per_interface,
|
||||||
|
)
|
||||||
|
|
||||||
|
grouped: Dict[str, List[Dict[str, Any]]] = {}
|
||||||
|
for row in rows:
|
||||||
|
record = dict(row)
|
||||||
|
iface = str(record.pop("iface"))
|
||||||
|
if hasattr(record.get("last_seen"), "isoformat"):
|
||||||
|
record["last_seen"] = record["last_seen"].isoformat()
|
||||||
|
grouped.setdefault(iface, []).append(record)
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"interface": iface,
|
||||||
|
"hosts": hosts,
|
||||||
|
}
|
||||||
|
for iface, hosts in sorted(grouped.items())
|
||||||
|
]
|
||||||
|
|
||||||
async def clear_all_packets(self, reset_identity: bool = True) -> bool:
|
async def clear_all_packets(self, reset_identity: bool = True) -> bool:
|
||||||
"""Truncate the packet table and optionally reset identity counters."""
|
"""Truncate the packet table and optionally reset identity counters."""
|
||||||
if self._pool is None:
|
if self._pool is None:
|
||||||
|
|||||||
Reference in New Issue
Block a user