251 lines
5.4 KiB
Python
251 lines
5.4 KiB
Python
"""
|
|
©AngelaMos | 2026
|
|
protocols.py
|
|
"""
|
|
|
|
|
|
import socket
|
|
from dataclasses import dataclass, field
|
|
|
|
import structlog
|
|
|
|
|
|
log = structlog.get_logger()
|
|
|
|
HTTP_METHODS: frozenset[bytes] = frozenset(
|
|
{
|
|
b"GET",
|
|
b"POST",
|
|
b"PUT",
|
|
b"DELETE",
|
|
b"HEAD",
|
|
b"OPTIONS",
|
|
b"PATCH",
|
|
}
|
|
)
|
|
|
|
HTTP_RESPONSE_PREFIX: bytes = b"HTTP/"
|
|
TLS_RECORD_PREFIX: bytes = b"\x16\x03"
|
|
SSH_PREFIX: bytes = b"SSH-"
|
|
SMTP_BANNER_PREFIX: bytes = b"220 "
|
|
DNS_PORT: int = 53
|
|
|
|
DNS_QTYPES: dict[int,
|
|
str] = {
|
|
1: "A",
|
|
2: "NS",
|
|
5: "CNAME",
|
|
6: "SOA",
|
|
12: "PTR",
|
|
15: "MX",
|
|
16: "TXT",
|
|
28: "AAAA",
|
|
33: "SRV",
|
|
255: "ANY",
|
|
}
|
|
|
|
|
|
@dataclass(frozen = True, slots = True)
|
|
class HttpMessage:
|
|
"""
|
|
Parsed HTTP request or response
|
|
"""
|
|
|
|
method: str
|
|
uri: str
|
|
version: str
|
|
headers: dict[str, str]
|
|
body: str
|
|
is_request: bool
|
|
|
|
|
|
@dataclass(frozen = True, slots = True)
|
|
class DnsQuery:
|
|
"""
|
|
A single DNS query entry
|
|
"""
|
|
|
|
name: str
|
|
query_type: str
|
|
query_class: str
|
|
|
|
|
|
@dataclass(frozen = True, slots = True)
|
|
class DnsRecord:
|
|
"""
|
|
Parsed DNS message with queries and answers
|
|
"""
|
|
|
|
queries: list[DnsQuery] = field(default_factory = list)
|
|
answers: list[str] = field(default_factory = list)
|
|
is_response: bool = False
|
|
transaction_id: int = 0
|
|
|
|
|
|
def parse_http(
|
|
payload: bytes,
|
|
) -> HttpMessage | None:
|
|
"""
|
|
Parse HTTP request or response from raw payload
|
|
"""
|
|
import dpkt
|
|
|
|
try:
|
|
if _is_http_request(payload):
|
|
req = dpkt.http.Request(payload)
|
|
headers = dict(req.headers)
|
|
body = _decode_body(req.body)
|
|
return HttpMessage(
|
|
method = req.method,
|
|
uri = req.uri,
|
|
version = req.version,
|
|
headers = headers,
|
|
body = body,
|
|
is_request = True,
|
|
)
|
|
|
|
if payload.startswith(HTTP_RESPONSE_PREFIX):
|
|
resp = dpkt.http.Response(payload)
|
|
headers = dict(resp.headers)
|
|
body = _decode_body(resp.body)
|
|
return HttpMessage(
|
|
method = "",
|
|
uri = "",
|
|
version = resp.version,
|
|
headers = headers,
|
|
body = body,
|
|
is_request = False,
|
|
)
|
|
except (dpkt.NeedData, dpkt.UnpackError):
|
|
return None
|
|
|
|
return None
|
|
|
|
|
|
def parse_dns(
|
|
payload: bytes,
|
|
) -> DnsRecord | None:
|
|
"""
|
|
Parse DNS message from raw UDP payload
|
|
"""
|
|
import dpkt
|
|
|
|
try:
|
|
dns = dpkt.dns.DNS(payload)
|
|
except (dpkt.NeedData, dpkt.UnpackError):
|
|
return None
|
|
|
|
queries: list[DnsQuery] = []
|
|
for qd in dns.qd:
|
|
qtype = DNS_QTYPES.get(qd.type, str(qd.type))
|
|
queries.append(
|
|
DnsQuery(
|
|
name = qd.name,
|
|
query_type = qtype,
|
|
query_class = str(qd.cls),
|
|
)
|
|
)
|
|
|
|
answers: list[str] = []
|
|
for an in dns.an:
|
|
_parse_answer(an, answers)
|
|
|
|
return DnsRecord(
|
|
queries = queries,
|
|
answers = answers,
|
|
is_response = bool(dns.qr),
|
|
transaction_id = dns.id,
|
|
)
|
|
|
|
|
|
def identify_protocol(
|
|
payload: bytes,
|
|
) -> str:
|
|
"""
|
|
Identify application-layer protocol via DPI
|
|
"""
|
|
if not payload:
|
|
return "unknown"
|
|
|
|
if _is_http_request(payload):
|
|
return "http"
|
|
|
|
if payload.startswith(HTTP_RESPONSE_PREFIX):
|
|
return "http"
|
|
|
|
if (len(payload) > 2 and payload[: 2] == TLS_RECORD_PREFIX):
|
|
return "tls"
|
|
|
|
if payload.startswith(SSH_PREFIX):
|
|
return "ssh"
|
|
|
|
if payload.startswith(SMTP_BANNER_PREFIX):
|
|
return "smtp"
|
|
|
|
return "unknown"
|
|
|
|
|
|
def _is_http_request(payload: bytes) -> bool:
|
|
"""
|
|
Check if payload starts with an HTTP method
|
|
"""
|
|
first_space = payload.find(b" ")
|
|
if first_space < 3 or first_space > 7:
|
|
return False
|
|
return payload[: first_space] in HTTP_METHODS
|
|
|
|
|
|
def _decode_body(body: bytes | str) -> str:
|
|
"""
|
|
Decode HTTP body bytes to string
|
|
"""
|
|
if isinstance(body, str):
|
|
return body
|
|
if not body:
|
|
return ""
|
|
try:
|
|
return body.decode("utf-8", errors = "replace")
|
|
except Exception:
|
|
return ""
|
|
|
|
|
|
def _parse_answer(
|
|
an: object,
|
|
answers: list[str],
|
|
) -> None:
|
|
"""
|
|
Parse a single DNS answer record
|
|
"""
|
|
try:
|
|
an_type = getattr(an, "type", 0)
|
|
rdata = getattr(an, "rdata", b"")
|
|
|
|
if an_type == 1 and len(rdata) == 4:
|
|
answers.append(socket.inet_ntoa(rdata))
|
|
elif an_type == 16 and rdata:
|
|
answers.append(_parse_txt_rdata(rdata))
|
|
elif hasattr(an, "cname") and an.cname:
|
|
answers.append(an.cname)
|
|
elif hasattr(an, "name") and an.name:
|
|
answers.append(an.name)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def _parse_txt_rdata(rdata: bytes) -> str:
|
|
"""
|
|
Parse TXT record rdata (length-prefixed strings)
|
|
"""
|
|
parts: list[str] = []
|
|
i = 0
|
|
while i < len(rdata):
|
|
length = rdata[i]
|
|
i += 1
|
|
if i + length <= len(rdata):
|
|
chunk = rdata[i : i + length]
|
|
parts.append(chunk.decode("utf-8", errors = "replace"))
|
|
i += length
|
|
else:
|
|
break
|
|
return " ".join(parts)
|