Files

454 lines
16 KiB
Python

"""Normalized capture data consumed by every detector.
The module is independent of Django and tshark so detector logic can be tested
with synthetic data. ``extract_tshark.py`` performs the actual PCAP extraction.
"""
from __future__ import annotations
import ipaddress
from dataclasses import dataclass, field
from typing import Optional
# --------------------------------------------------------------------------- #
# Normalized records
# --------------------------------------------------------------------------- #
@dataclass
class Host:
ip: str
macs: set[str] = field(default_factory=set)
hostnames: set[str] = field(default_factory=set) # DHCP, mDNS, reverse DNS names
bytes_sent: int = 0
bytes_received: int = 0
packets_sent: int = 0
packets_received: int = 0
first_seen: Optional[float] = None
last_seen: Optional[float] = None
ports_offered: set[int] = field(default_factory=set) # Ports the host answered on (server role)
ports_contacted: set[int] = field(default_factory=set) # Destination ports the host contacted
dhcp_vendor_class: Optional[str] = None
user_agents: set[str] = field(default_factory=set)
# Application protocols seen with this host as an endpoint (from frame.protocols).
app_protocols: set[str] = field(default_factory=set)
@property
def is_private(self) -> bool:
return _is_private(self.ip)
@property
def packets(self) -> int:
return self.packets_sent + self.packets_received
@dataclass
class Conversation:
"""A five-tuple flow aggregated between two endpoints."""
src_ip: str
dst_ip: str
src_port: Optional[int]
dst_port: Optional[int]
protocol: str # For example, "tcp" or "udp"
packets: int = 0
bytes: int = 0
bytes_src_to_dst: int = 0
bytes_dst_to_src: int = 0
packets_src_to_dst: int = 0
packets_dst_to_src: int = 0
payload_bytes: int = 0 # Application payload only (no headers)
first_seen: Optional[float] = None
last_seen: Optional[float] = None
stream_id: Optional[int] = None
timestamps: list[float] = field(default_factory=list) # Used to detect beaconing
packet_numbers: list[int] = field(default_factory=list) # Bounded evidence packet list
# TCP state summary.
syn_count: int = 0 # SYNs sent by src (connection attempts)
synack_count: int = 0 # SYN/ACKs sent back by dst (service answered)
rst_count: int = 0
fin_count: int = 0
retransmissions: int = 0
# Application protocols dissected on this flow, e.g. {"tls", "http"}.
app_protocols: set[str] = field(default_factory=set)
@property
def answered(self) -> bool:
"""Whether the destination visibly responded (TCP handshake or return traffic)."""
if self.protocol == "tcp":
return self.synack_count > 0 or self.packets_dst_to_src > 0
return self.packets_dst_to_src > 0
@property
def is_encrypted(self) -> bool:
return bool(self.app_protocols & ENCRYPTED_PROTOCOLS)
# Protocols whose payload is ciphertext and must never be scanned as text.
ENCRYPTED_PROTOCOLS = {
"tls", "ssl", "dtls", "quic", "ssh", "esp", "ipsec", "wireguard", "openvpn",
"ikev2", "isakmp", "kerberos", "ntlmssp",
}
@dataclass
class DnsQuery:
query_name: str
qtype: Optional[str] = None
is_response: bool = False
response_ips: list[str] = field(default_factory=list)
cnames: list[str] = field(default_factory=list)
txt: list[str] = field(default_factory=list)
rcode: Optional[str] = None # For example, "NOERROR" or "NXDOMAIN"
timestamp: Optional[float] = None
src_ip: Optional[str] = None
dst_ip: Optional[str] = None
resolver: Optional[str] = None
transport: Optional[str] = None # "dns", "mdns", "llmnr", "nbns"
packet_number: Optional[int] = None
@dataclass
class HttpRequest:
method: Optional[str] = None
host: Optional[str] = None
uri: Optional[str] = None
user_agent: Optional[str] = None
authorization: Optional[str] = None # Authorization header value
cookies: Optional[str] = None
headers: dict[str, str] = field(default_factory=dict)
body: Optional[str] = None
content_type: Optional[str] = None
request_bytes: int = 0
is_tls: bool = False # True when only TLS metadata is visible
src_ip: Optional[str] = None
dst_ip: Optional[str] = None
dst_port: Optional[int] = None
timestamp: Optional[float] = None
stream_id: Optional[int] = None
packet_number: Optional[int] = None
@dataclass
class HttpResponse:
status_code: Optional[int] = None
content_type: Optional[str] = None
server: Optional[str] = None
content_disposition: Optional[str] = None
content_length: Optional[int] = None
set_cookie: list[str] = field(default_factory=list)
www_authenticate: Optional[str] = None
request_uri: Optional[str] = None
src_ip: Optional[str] = None
dst_ip: Optional[str] = None
src_port: Optional[int] = None
timestamp: Optional[float] = None
stream_id: Optional[int] = None
packet_number: Optional[int] = None
@dataclass
class TlsSession:
"""One TLS handshake, aggregated per TCP stream (or QUIC connection)."""
src_ip: Optional[str] = None
dst_ip: Optional[str] = None
dst_port: Optional[int] = 443
src_port: Optional[int] = None
transport: str = "tcp" # "tcp" or "quic"
version: Optional[str] = None # Negotiated version from the ServerHello
cipher: Optional[str] = None # Negotiated cipher-suite name from the ServerHello
sni: Optional[str] = None
alpn: list[str] = field(default_factory=list)
client_version: Optional[str] = None # Highest version the client offered
client_offered_versions: list[str] = field(default_factory=list)
client_offered_ciphers: list[str] = field(default_factory=list)
client_hello_seen: bool = False
server_hello_seen: bool = False
cert_subject_cn: Optional[str] = None
cert_issuer_cn: Optional[str] = None
cert_san: list[str] = field(default_factory=list)
cert_not_before: Optional[str] = None
cert_not_after: Optional[str] = None
cert_self_signed: Optional[bool] = None
cert_expired: Optional[bool] = None
cert_sig_algorithm: Optional[str] = None
cert_key_type: Optional[str] = None
cert_key_bits: Optional[int] = None
cert_seen: bool = False
client_cert_seen: bool = False # Client presented a certificate (mutual TLS)
alerts: list[str] = field(default_factory=list)
timestamp: Optional[float] = None
stream_id: Optional[int] = None
packet_number: Optional[int] = None
packet_numbers: list[int] = field(default_factory=list)
@dataclass
class AuthMaterial:
"""Hashes, challenge-response material, and authentication tickets."""
kind: str # "ntlmssp", "netntlmv2", "kerberos_asrep", ...
value: Optional[str] = None
account: Optional[str] = None
domain: Optional[str] = None
src_ip: Optional[str] = None
dst_ip: Optional[str] = None
protocol: Optional[str] = None
timestamp: Optional[float] = None
packet_number: Optional[int] = None
# Crackable material, when reconstruction is possible.
hashcat: Optional[str] = None # Complete hashcat input line
hashcat_mode: Optional[int] = None # For example, 5600 (NetNTLMv2)
hash_format: Optional[str] = None # Human-readable format name
server_challenge: Optional[str] = None # NTLMSSP server challenge (hex)
nt_response: Optional[str] = None # NTLMv2 response (hex)
etype: Optional[str] = None # Kerberos encryption type
@dataclass
class ArpEvent:
sender_ip: Optional[str] = None
sender_mac: Optional[str] = None
target_ip: Optional[str] = None
opcode: Optional[int] = None # 1=request, 2=reply
is_gratuitous: bool = False
timestamp: Optional[float] = None
packet_number: Optional[int] = None
@dataclass
class DhcpEvent:
message_type: Optional[str] = None # "discover", "offer", "request", "ack", ...
client_mac: Optional[str] = None
client_ip: Optional[str] = None # Assigned/requested address when known
hostname: Optional[str] = None
vendor_class: Optional[str] = None
server_id: Optional[str] = None
src_ip: Optional[str] = None
timestamp: Optional[float] = None
packet_number: Optional[int] = None
@dataclass
class MqttEvent:
"""MQTT control packet visible in clear text (port 1883 or any unencrypted flow)."""
msg_type: Optional[str] = None # "connect", "connack", "publish", "subscribe", ...
client_id: Optional[str] = None
username: Optional[str] = None
password: Optional[str] = None
topic: Optional[str] = None
return_code: Optional[int] = None # CONNACK return code
src_ip: Optional[str] = None
dst_ip: Optional[str] = None
dst_port: Optional[int] = None
timestamp: Optional[float] = None
stream_id: Optional[int] = None
packet_number: Optional[int] = None
@dataclass
class ServiceEvent:
"""Generic clear-text protocol observation (SNMP community, SSH banner, ...)."""
protocol: str
kind: str # "snmp_community", "ssh_banner", "coap_request", ...
value: Optional[str] = None
detail: Optional[str] = None
src_ip: Optional[str] = None
dst_ip: Optional[str] = None
dst_port: Optional[int] = None
timestamp: Optional[float] = None
packet_number: Optional[int] = None
@dataclass
class FileTransfer:
filename: Optional[str] = None
extension: Optional[str] = None
content_type: Optional[str] = None
size: Optional[int] = None
protocol: Optional[str] = None # "http", "ftp-data", "smb", "tftp"
src_ip: Optional[str] = None
dst_ip: Optional[str] = None
direction: Optional[str] = None # "download" / "upload"
uri: Optional[str] = None
host: Optional[str] = None
timestamp: Optional[float] = None
stream_id: Optional[int] = None
packet_number: Optional[int] = None
@dataclass
class PlaintextAuth:
"""Clear-text authentication observed in an application protocol."""
protocol: str
username: Optional[str] = None
password: Optional[str] = None
src_ip: Optional[str] = None
dst_ip: Optional[str] = None
dst_port: Optional[int] = None
# "success", "failure" or None when the server response was not observed.
outcome: Optional[str] = None
timestamp: Optional[float] = None
stream_id: Optional[int] = None
packet_number: Optional[int] = None
@dataclass
class AuthAttempt:
"""A single authentication attempt result used for brute-force detection."""
protocol: str
success: Optional[bool]
src_ip: Optional[str] = None # Client
dst_ip: Optional[str] = None # Server
dst_port: Optional[int] = None
username: Optional[str] = None
timestamp: Optional[float] = None
stream_id: Optional[int] = None
packet_number: Optional[int] = None
# --------------------------------------------------------------------------- #
# Central capture context
# --------------------------------------------------------------------------- #
@dataclass
class CaptureContext:
"""All detector input extracted during a single capture pass."""
path: Optional[str] = None
packet_count: int = 0
# Set when limits stopped the extraction pass before the capture was fully read.
truncated: bool = False
truncation_reason: Optional[str] = None
truncation_reasons: list[str] = field(default_factory=list)
# Non-fatal coverage notes (protocols not dissected, undecoded ports, ...).
coverage_notes: list[str] = field(default_factory=list)
start_time: Optional[float] = None
end_time: Optional[float] = None
extractor: Optional[str] = None # e.g. "tshark 4.2.2"
# The device under test. When set, findings are attributed to it.
device_ip: Optional[str] = None
device_macs: set[str] = field(default_factory=set)
hosts: dict[str, Host] = field(default_factory=dict)
conversations: list[Conversation] = field(default_factory=list)
dns_queries: list[DnsQuery] = field(default_factory=list)
http_requests: list[HttpRequest] = field(default_factory=list)
http_responses: list[HttpResponse] = field(default_factory=list)
tls_sessions: list[TlsSession] = field(default_factory=list)
auth_material: list[AuthMaterial] = field(default_factory=list)
arp_events: list[ArpEvent] = field(default_factory=list)
dhcp_events: list[DhcpEvent] = field(default_factory=list)
mqtt_events: list[MqttEvent] = field(default_factory=list)
service_events: list[ServiceEvent] = field(default_factory=list)
file_transfers: list[FileTransfer] = field(default_factory=list)
plaintext_auth: list[PlaintextAuth] = field(default_factory=list)
auth_attempts: list[AuthAttempt] = field(default_factory=list)
# protocol_stats: highest-layer protocol name -> packet count (legacy, kept for detectors)
protocol_stats: dict[str, int] = field(default_factory=dict)
# app_protocol_stats: every protocol in frame.protocols -> packet count
app_protocol_stats: dict[str, int] = field(default_factory=dict)
icmp_stats: dict[str, int] = field(default_factory=dict)
vlan_ids: set[int] = field(default_factory=set)
# Generic text payloads with packet metadata for secret detection.
text_payloads: list[dict] = field(default_factory=list)
@property
def duration(self) -> float:
if self.start_time is None or self.end_time is None:
return 0.0
return max(0.0, self.end_time - self.start_time)
def host(self, ip: str) -> Host:
h = self.hosts.get(ip)
if h is None:
h = Host(ip=ip)
self.hosts[ip] = h
return h
def is_external(self, ip: Optional[str]) -> bool:
"""Return whether an address is globally routable."""
if not ip:
return False
return not _is_private(ip)
def is_device(self, ip: Optional[str]) -> bool:
"""Whether the address belongs to the device under test."""
return bool(ip) and self.device_ip is not None and ip == self.device_ip
def involves_device(self, *ips: Optional[str]) -> Optional[bool]:
"""True/False when a device is configured, None when no device is known."""
if self.device_ip is None:
return None
return any(self.is_device(ip) for ip in ips)
def protocol_count(self, *names: str) -> int:
"""Return a case-insensitive packet total for the requested protocols.
Both the highest-layer statistics and the complete protocol chain are
consulted so that e.g. "smb" is found even when a higher layer sits on top.
"""
wanted = {n.lower() for n in names}
total = sum(
count for proto, count in self.app_protocol_stats.items()
if str(proto).lower() in wanted
)
if total:
return total
return sum(
count for proto, count in self.protocol_stats.items()
if str(proto).lower() in wanted
)
def names_for_ip(self, ip: Optional[str]) -> set[str]:
"""DNS names that resolved to an address, plus SNI/Host names used with it."""
names: set[str] = set()
if not ip:
return names
for q in self.dns_queries:
if ip in q.response_ips and q.query_name:
names.add(q.query_name.rstrip(".").lower())
for t in self.tls_sessions:
if t.dst_ip == ip and t.sni:
names.add(t.sni.lower())
for r in self.http_requests:
if r.dst_ip == ip and r.host:
names.add(str(r.host).split(":")[0].lower())
return names
def _is_private(ip: str) -> bool:
try:
addr = ipaddress.ip_address(ip)
except ValueError:
return False
return (
addr.is_private
or addr.is_loopback
or addr.is_link_local
or addr.is_multicast
or addr.is_reserved
or addr.is_unspecified
)
def is_special(ip: Optional[str]) -> bool:
"""Broadcast, multicast, unspecified and link-scope addresses that never identify one host."""
if not ip:
return True
if ip in ("0.0.0.0", "255.255.255.255", "::"):
return True
try:
addr = ipaddress.ip_address(ip)
except ValueError:
return True
return addr.is_multicast or addr.is_unspecified or addr.is_loopback