build 4.6 Demo
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
"""Import and register the ordered default detector set."""
|
||||
|
||||
from .base import BaseDetector, register, build_all, all_detector_classes # noqa: F401
|
||||
|
||||
# Import order defines the stable detector order in reports.
|
||||
from . import plaintext_credentials # 1
|
||||
from . import password_hashes # 2
|
||||
from . import tokens_secrets # 3
|
||||
from . import malware_beaconing # 4
|
||||
from . import exfiltration # 5
|
||||
from . import mitm # 6
|
||||
from . import insecure_protocols # 7
|
||||
from . import tls_weakness # 8
|
||||
from . import dns_anomaly # 9
|
||||
from . import bruteforce_scan_filetransfer # 10
|
||||
|
||||
__all__ = ["BaseDetector", "register", "build_all", "all_detector_classes"]
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Detector base class and registration mechanism."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Iterable
|
||||
|
||||
from ..context import CaptureContext
|
||||
from ..findings import Finding
|
||||
|
||||
|
||||
class BaseDetector:
|
||||
#: Short unique name referenced by findings and reports.
|
||||
name: str = "BaseDetector"
|
||||
#: Human-readable description of the detector scope.
|
||||
description: str = ""
|
||||
|
||||
def detect(self, ctx: CaptureContext) -> Iterable[Finding]: # pragma: no cover
|
||||
raise NotImplementedError
|
||||
|
||||
def _tag(self, findings: Iterable[Finding]) -> list[Finding]:
|
||||
out = []
|
||||
for f in findings:
|
||||
f.detector = self.name
|
||||
out.append(f)
|
||||
return out
|
||||
|
||||
|
||||
# ---- Registry ------------------------------------------------------------- #
|
||||
|
||||
_REGISTRY: dict[str, type[BaseDetector]] = {}
|
||||
|
||||
|
||||
def register(cls: type[BaseDetector]) -> type[BaseDetector]:
|
||||
"""Register a detector class."""
|
||||
_REGISTRY[cls.name] = cls
|
||||
return cls
|
||||
|
||||
|
||||
def all_detector_classes() -> list[type[BaseDetector]]:
|
||||
return list(_REGISTRY.values())
|
||||
|
||||
|
||||
def build_all() -> list[BaseDetector]:
|
||||
"""Instantiate all registered detector classes."""
|
||||
return [cls() for cls in _REGISTRY.values()]
|
||||
@@ -0,0 +1,154 @@
|
||||
"""Detect brute force, scanning, and suspicious file transfers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from collections import defaultdict
|
||||
from typing import Iterable
|
||||
|
||||
from ..context import CaptureContext
|
||||
from ..findings import Finding, Category, Severity, Confidence
|
||||
from .base import BaseDetector, register
|
||||
|
||||
# File extensions that warrant closer inspection.
|
||||
_SUSPICIOUS_EXT = {
|
||||
".exe", ".dll", ".ps1", ".vbs", ".js", ".jar", ".bat", ".cmd", ".scr",
|
||||
".hta", ".lnk", ".docm", ".xlsm", ".pptm", ".zip", ".rar", ".7z", ".iso", ".elf",
|
||||
}
|
||||
_PORTSCAN_PORTS = 15
|
||||
_HOSTSWEEP_HOSTS = 15
|
||||
_FAILED_LOGINS = 5
|
||||
|
||||
|
||||
@register
|
||||
class BruteforceScanFileTransferDetector(BaseDetector):
|
||||
name = "BruteforceScanFileTransferDetector"
|
||||
description = "Detects scans, repeated authentication failures, and risky downloads."
|
||||
|
||||
def detect(self, ctx: CaptureContext) -> Iterable[Finding]:
|
||||
findings: list[Finding] = []
|
||||
|
||||
ports_per_pair: dict[tuple, set[int]] = defaultdict(set)
|
||||
hosts_per_src: dict[str, set[str]] = defaultdict(set)
|
||||
packets_per_pair: dict[tuple, list] = defaultdict(list)
|
||||
packets_per_src: dict[str, list] = defaultdict(list)
|
||||
for conv in ctx.conversations:
|
||||
if conv.src_ip and conv.dst_ip:
|
||||
if conv.dst_port:
|
||||
ports_per_pair[(conv.src_ip, conv.dst_ip)].add(conv.dst_port)
|
||||
hosts_per_src[conv.src_ip].add(conv.dst_ip)
|
||||
if conv.packet_numbers:
|
||||
packets_per_pair[(conv.src_ip, conv.dst_ip)].append(conv.packet_numbers[0])
|
||||
packets_per_src[conv.src_ip].append(conv.packet_numbers[0])
|
||||
|
||||
for (src, dst), ports in ports_per_pair.items():
|
||||
if len(ports) >= _PORTSCAN_PORTS:
|
||||
findings.append(Finding(
|
||||
title="Port scan detected",
|
||||
category=Category.BRUTEFORCE_SCAN_FILETRANSFER,
|
||||
severity=Severity.MEDIUM, confidence=Confidence.MEDIUM,
|
||||
source_ip=src, destination_ip=dst,
|
||||
risk_description=(
|
||||
"One host contacted many ports on a target, which is characteristic "
|
||||
"of network reconnaissance."
|
||||
),
|
||||
recommendation="Review the source and block unauthorized external scanners.",
|
||||
evidence={"distinct_ports": len(ports), "dedup_key": f"portscan:{src}:{dst}"},
|
||||
related_packets=sorted(set(packets_per_pair.get((src, dst), [])))[:8],
|
||||
))
|
||||
|
||||
for src, dsts in hosts_per_src.items():
|
||||
if len(dsts) >= _HOSTSWEEP_HOSTS:
|
||||
findings.append(Finding(
|
||||
title="Host sweep detected",
|
||||
category=Category.BRUTEFORCE_SCAN_FILETRANSFER,
|
||||
severity=Severity.MEDIUM, confidence=Confidence.LOW,
|
||||
source_ip=src,
|
||||
risk_description=(
|
||||
"One host contacted many different targets, which may indicate a "
|
||||
"network sweep or worm behavior."
|
||||
),
|
||||
recommendation="Review the source and investigate internal hosts for compromise.",
|
||||
evidence={"distinct_hosts": len(dsts), "dedup_key": f"sweep:{src}"},
|
||||
related_packets=sorted(set(packets_per_src.get(src, [])))[:8],
|
||||
))
|
||||
|
||||
# Repeated HTTP authorization failures are a brute-force indicator.
|
||||
http_401: dict[tuple, int] = defaultdict(int)
|
||||
http_401_pkts: dict[tuple, list] = defaultdict(list)
|
||||
for resp in ctx.http_responses:
|
||||
if resp.status_code in (401, 403):
|
||||
http_401[(resp.dst_ip, resp.src_ip)] += 1
|
||||
if resp.packet_number:
|
||||
http_401_pkts[(resp.dst_ip, resp.src_ip)].append(resp.packet_number)
|
||||
for (client, server), count in http_401.items():
|
||||
if count >= _FAILED_LOGINS:
|
||||
findings.append(Finding(
|
||||
title="Repeated failed HTTP authentication",
|
||||
category=Category.BRUTEFORCE_SCAN_FILETRANSFER,
|
||||
severity=Severity.MEDIUM, confidence=Confidence.MEDIUM,
|
||||
source_ip=client, destination_ip=server, protocol="http",
|
||||
risk_description=(
|
||||
"Many HTTP 401 or 403 responses may indicate a password guessing attack."
|
||||
),
|
||||
recommendation="Enable rate limiting or lockout controls and review the source.",
|
||||
evidence={"failed_responses": count, "dedup_key": f"bruteforce:{client}:{server}"},
|
||||
related_packets=sorted(set(http_401_pkts.get((client, server), [])))[:8],
|
||||
))
|
||||
|
||||
seen_files: set[str] = set()
|
||||
for req in ctx.http_requests:
|
||||
uri = (req.uri or "").split("?")[0]
|
||||
ext = os.path.splitext(uri)[1].lower()
|
||||
if ext in _SUSPICIOUS_EXT:
|
||||
key = f"file:{req.host}:{uri}"
|
||||
if key in seen_files:
|
||||
continue
|
||||
seen_files.add(key)
|
||||
findings.append(Finding(
|
||||
title=f"Potentially harmful file download ({ext})",
|
||||
category=Category.BRUTEFORCE_SCAN_FILETRANSFER,
|
||||
severity=Severity.HIGH, confidence=Confidence.MEDIUM,
|
||||
source_ip=req.src_ip, destination_ip=req.dst_ip, protocol="http", port=req.dst_port,
|
||||
timestamp_first_seen=req.timestamp, timestamp_last_seen=req.timestamp,
|
||||
risk_description=(
|
||||
"An executable or script-capable file was transferred over HTTP and "
|
||||
"may represent a malware download."
|
||||
),
|
||||
recommendation="Analyze the file in a sandbox and check its hash against threat intelligence.",
|
||||
evidence={"host": req.host, "uri": uri, "extension": ext, "dedup_key": key},
|
||||
related_packets=[p for p in [req.packet_number] if p],
|
||||
))
|
||||
|
||||
# Response metadata can reveal risky files absent from the request URI.
|
||||
for ft in ctx.file_transfers:
|
||||
ext = (ft.extension or "").lower()
|
||||
ct = (ft.content_type or "").lower()
|
||||
is_executable_ct = any(
|
||||
m in ct for m in ("x-msdownload", "x-dosexec", "x-executable",
|
||||
"portable-executable", "x-elf", "x-msi")
|
||||
)
|
||||
if ext not in _SUSPICIOUS_EXT and not is_executable_ct:
|
||||
continue
|
||||
key = f"ft:{ft.dst_ip}:{ft.filename or ct}"
|
||||
if key in seen_files:
|
||||
continue
|
||||
seen_files.add(key)
|
||||
label = ext or ct or "binary file"
|
||||
findings.append(Finding(
|
||||
title=f"Potentially harmful file download ({label})",
|
||||
category=Category.BRUTEFORCE_SCAN_FILETRANSFER,
|
||||
severity=Severity.HIGH, confidence=Confidence.MEDIUM,
|
||||
source_ip=ft.dst_ip, destination_ip=ft.src_ip, protocol=ft.protocol,
|
||||
timestamp_first_seen=ft.timestamp, timestamp_last_seen=ft.timestamp,
|
||||
risk_description=(
|
||||
"An executable or script-capable file was transferred and may represent "
|
||||
"a malware download."
|
||||
),
|
||||
recommendation="Analyze the file in a sandbox and check its hash against threat intelligence.",
|
||||
evidence={"filename": ft.filename, "content_type": ft.content_type,
|
||||
"extension": ext or None, "dedup_key": key},
|
||||
related_packets=[p for p in [ft.packet_number] if p],
|
||||
))
|
||||
|
||||
return self._tag(findings)
|
||||
@@ -0,0 +1,154 @@
|
||||
"""Detect anomalous DNS activity and potential DNS tunneling."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from collections import defaultdict
|
||||
from typing import Iterable
|
||||
|
||||
from ..context import CaptureContext
|
||||
from ..findings import Finding, Category, Severity, Confidence
|
||||
from .base import BaseDetector, register
|
||||
|
||||
_LONG_LABEL = 40
|
||||
_ENTROPY_MIN_LEN = 12
|
||||
_HIGH_ENTROPY = 3.2
|
||||
_NXDOMAIN_RATIO = 0.3
|
||||
_MANY_SUBDOMAINS = 30
|
||||
_TUNNEL_QTYPES = {"TXT", "NULL"}
|
||||
_TXT_TUNNEL_MIN = 20
|
||||
_TXT_TUNNEL_RATIO = 0.5
|
||||
|
||||
|
||||
@register
|
||||
class DnsAnomalyDetector(BaseDetector):
|
||||
name = "DnsAnomalyDetector"
|
||||
description = "Detects DNS tunneling, DGA-like names, and high NXDOMAIN rates."
|
||||
|
||||
def detect(self, ctx: CaptureContext) -> Iterable[Finding]:
|
||||
findings: list[Finding] = []
|
||||
if not ctx.dns_queries:
|
||||
return findings
|
||||
|
||||
nx = 0
|
||||
total = 0
|
||||
txt_like = 0
|
||||
subdomains_per_domain: dict[str, set[str]] = defaultdict(set)
|
||||
packets_per_domain: dict[str, list] = defaultdict(list)
|
||||
nx_packets: list = []
|
||||
txt_packets: list = []
|
||||
flagged_random: set[str] = set()
|
||||
|
||||
for q in ctx.dns_queries:
|
||||
name = (q.query_name or "").rstrip(".").lower()
|
||||
if not name:
|
||||
continue
|
||||
total += 1
|
||||
if (q.rcode or "").upper() == "NXDOMAIN":
|
||||
nx += 1
|
||||
if q.packet_number:
|
||||
nx_packets.append(q.packet_number)
|
||||
if (q.qtype or "").upper() in _TUNNEL_QTYPES:
|
||||
txt_like += 1
|
||||
if q.packet_number:
|
||||
txt_packets.append(q.packet_number)
|
||||
|
||||
labels = name.split(".")
|
||||
reg_domain = ".".join(labels[-2:]) if len(labels) >= 2 else name
|
||||
if len(labels) > 2:
|
||||
subdomains_per_domain[reg_domain].add(".".join(labels[:-2]))
|
||||
if q.packet_number:
|
||||
packets_per_domain[reg_domain].append(q.packet_number)
|
||||
|
||||
# Long or high-entropy labels may indicate a DGA or tunneling channel.
|
||||
for label in labels[:-2] or labels:
|
||||
if len(label) >= _LONG_LABEL or (
|
||||
len(label) >= _ENTROPY_MIN_LEN and _entropy(label) >= _HIGH_ENTROPY
|
||||
):
|
||||
if reg_domain not in flagged_random:
|
||||
flagged_random.add(reg_domain)
|
||||
findings.append(Finding(
|
||||
title="DNS query suspicious for DGA or tunneling activity",
|
||||
category=Category.DNS_ANOMALY,
|
||||
severity=Severity.HIGH, confidence=Confidence.MEDIUM,
|
||||
source_ip=q.src_ip, destination_ip=q.dst_ip, protocol="dns", port=53,
|
||||
timestamp_first_seen=q.timestamp, timestamp_last_seen=q.timestamp,
|
||||
risk_description=(
|
||||
"Long or random-looking subdomains are characteristic of DNS "
|
||||
"tunneling and malware domain-generation algorithms."
|
||||
),
|
||||
recommendation=(
|
||||
"Check the domain against threat intelligence and enable DNS "
|
||||
"tunneling detection and egress controls."
|
||||
),
|
||||
evidence={"query_name": name, "registered_domain": reg_domain,
|
||||
"label_entropy": round(_entropy(label), 2),
|
||||
"dedup_key": f"dga:{reg_domain}"},
|
||||
related_packets=[p for p in [q.packet_number] if p],
|
||||
))
|
||||
break
|
||||
|
||||
for domain, subs in subdomains_per_domain.items():
|
||||
if len(subs) >= _MANY_SUBDOMAINS:
|
||||
findings.append(Finding(
|
||||
title=f"Unusually many subdomains under {domain}",
|
||||
category=Category.DNS_ANOMALY,
|
||||
severity=Severity.HIGH, confidence=Confidence.MEDIUM,
|
||||
protocol="dns", port=53,
|
||||
risk_description=(
|
||||
"A large number of unique subdomains under one domain is a strong "
|
||||
"indicator of DNS tunneling or exfiltration."
|
||||
),
|
||||
recommendation="Review or block the domain and enable tunneling detection.",
|
||||
evidence={"registered_domain": domain, "unique_subdomains": len(subs),
|
||||
"dedup_key": f"subtunnel:{domain}"},
|
||||
related_packets=sorted(set(packets_per_domain.get(domain, [])))[:8],
|
||||
))
|
||||
|
||||
# TXT and NULL records can carry the large payloads used by DNS tunnels.
|
||||
if txt_like >= _TXT_TUNNEL_MIN and total and (txt_like / total) >= _TXT_TUNNEL_RATIO:
|
||||
findings.append(Finding(
|
||||
title="Unusually many TXT or NULL DNS queries",
|
||||
category=Category.DNS_ANOMALY,
|
||||
severity=Severity.HIGH, confidence=Confidence.MEDIUM,
|
||||
protocol="dns", port=53,
|
||||
risk_description=(
|
||||
"TXT and NULL records can carry large payloads and are commonly abused "
|
||||
"for DNS tunneling or data exfiltration."
|
||||
),
|
||||
recommendation=(
|
||||
"Review the requesting source and destination domain, then enforce DNS "
|
||||
"tunneling detection and egress controls."
|
||||
),
|
||||
evidence={"txt_null_queries": txt_like, "total_queries": total,
|
||||
"ratio": round(txt_like / total, 2), "dedup_key": "txt_tunnel"},
|
||||
related_packets=sorted(set(txt_packets))[:8],
|
||||
))
|
||||
|
||||
if total >= 20 and (nx / total) >= _NXDOMAIN_RATIO:
|
||||
findings.append(Finding(
|
||||
title="High NXDOMAIN rate",
|
||||
category=Category.DNS_ANOMALY,
|
||||
severity=Severity.MEDIUM, confidence=Confidence.MEDIUM,
|
||||
protocol="dns", port=53,
|
||||
risk_description=(
|
||||
"Repeated NXDOMAIN responses may indicate malware testing generated "
|
||||
"command-and-control domains."
|
||||
),
|
||||
recommendation="Review the requesting source and investigate it for malware.",
|
||||
evidence={"nxdomain": nx, "total_queries": total,
|
||||
"ratio": round(nx / total, 2), "dedup_key": "nxdomain_rate"},
|
||||
related_packets=sorted(set(nx_packets))[:8],
|
||||
))
|
||||
|
||||
return self._tag(findings)
|
||||
|
||||
|
||||
def _entropy(s: str) -> float:
|
||||
if not s:
|
||||
return 0.0
|
||||
counts = defaultdict(int)
|
||||
for ch in s:
|
||||
counts[ch] += 1
|
||||
n = len(s)
|
||||
return -sum((c / n) * math.log2(c / n) for c in counts.values())
|
||||
@@ -0,0 +1,125 @@
|
||||
"""Detect potential data exfiltration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from typing import Iterable
|
||||
|
||||
from ..context import CaptureContext
|
||||
from ..findings import Finding, Category, Severity, Confidence
|
||||
from .base import BaseDetector, register
|
||||
|
||||
# Conservative heuristic thresholds.
|
||||
_LARGE_UPLOAD_BYTES = 5 * 1024 * 1024
|
||||
_AGG_UPLOAD_BYTES = 20 * 1024 * 1024
|
||||
_MANY_SMALL_TRANSFERS = 50
|
||||
_DNS_TXT_VOLUME = 100
|
||||
|
||||
|
||||
@register
|
||||
class ExfiltrationDetector(BaseDetector):
|
||||
name = "ExfiltrationDetector"
|
||||
description = "Detects unusual outbound volumes and transfer patterns."
|
||||
|
||||
def detect(self, ctx: CaptureContext) -> Iterable[Finding]:
|
||||
findings: list[Finding] = []
|
||||
|
||||
per_dest_bytes: dict[str, int] = defaultdict(int)
|
||||
per_dest_count: dict[str, int] = defaultdict(int)
|
||||
first_seen: dict[str, float] = {}
|
||||
last_seen: dict[str, float] = {}
|
||||
src_for_dest: dict[str, str] = {}
|
||||
packets_for_dest: dict[str, list] = defaultdict(list)
|
||||
streams_for_dest: dict[str, list] = defaultdict(list)
|
||||
|
||||
for conv in ctx.conversations:
|
||||
# Evaluate outbound traffic from a private source to an external target.
|
||||
if not (conv.src_ip and ctx.is_external(conv.dst_ip)
|
||||
and not ctx.is_external(conv.src_ip)):
|
||||
continue
|
||||
up = conv.bytes_src_to_dst or conv.bytes
|
||||
per_dest_bytes[conv.dst_ip] += up
|
||||
per_dest_count[conv.dst_ip] += 1
|
||||
src_for_dest[conv.dst_ip] = conv.src_ip
|
||||
if conv.packet_numbers:
|
||||
packets_for_dest[conv.dst_ip].append(conv.packet_numbers[0])
|
||||
if conv.stream_id is not None:
|
||||
streams_for_dest[conv.dst_ip].append(conv.stream_id)
|
||||
if conv.first_seen is not None:
|
||||
first_seen[conv.dst_ip] = min(first_seen.get(conv.dst_ip, conv.first_seen), conv.first_seen)
|
||||
if conv.last_seen is not None:
|
||||
last_seen[conv.dst_ip] = max(last_seen.get(conv.dst_ip, conv.last_seen), conv.last_seen)
|
||||
|
||||
if up >= _LARGE_UPLOAD_BYTES:
|
||||
findings.append(Finding(
|
||||
title="Large outbound transfer to an external target",
|
||||
category=Category.EXFILTRATION,
|
||||
severity=Severity.HIGH, confidence=Confidence.MEDIUM,
|
||||
source_ip=conv.src_ip, destination_ip=conv.dst_ip,
|
||||
protocol=conv.protocol, port=conv.dst_port,
|
||||
timestamp_first_seen=conv.first_seen, timestamp_last_seen=conv.last_seen,
|
||||
risk_description=(
|
||||
"A large amount of data left the device for an external target, "
|
||||
"which may indicate data loss."
|
||||
),
|
||||
recommendation="Validate the destination, content, and authorization.",
|
||||
evidence={"uploaded_bytes": up,
|
||||
"dedup_key": f"bigup:{conv.src_ip}:{conv.dst_ip}:{conv.dst_port}"},
|
||||
related_packets=list(conv.packet_numbers),
|
||||
related_streams=[s for s in [conv.stream_id] if s is not None],
|
||||
))
|
||||
|
||||
for dest, total in per_dest_bytes.items():
|
||||
if total >= _AGG_UPLOAD_BYTES:
|
||||
findings.append(Finding(
|
||||
title="High aggregate upload volume to an external target",
|
||||
category=Category.EXFILTRATION,
|
||||
severity=Severity.HIGH, confidence=Confidence.MEDIUM,
|
||||
source_ip=src_for_dest.get(dest), destination_ip=dest,
|
||||
timestamp_first_seen=first_seen.get(dest),
|
||||
timestamp_last_seen=last_seen.get(dest),
|
||||
risk_description=(
|
||||
"A high total volume was sent to one external destination across "
|
||||
"multiple connections, which may indicate gradual exfiltration."
|
||||
),
|
||||
recommendation="Review the destination and enforce egress filtering or DLP.",
|
||||
evidence={"uploaded_bytes_total": total,
|
||||
"dedup_key": f"aggup:{dest}"},
|
||||
related_packets=sorted(set(packets_for_dest.get(dest, []))),
|
||||
related_streams=sorted(set(streams_for_dest.get(dest, []))),
|
||||
))
|
||||
elif per_dest_count[dest] >= _MANY_SMALL_TRANSFERS:
|
||||
findings.append(Finding(
|
||||
title="Many small outbound transfers to one target",
|
||||
category=Category.EXFILTRATION,
|
||||
severity=Severity.MEDIUM, confidence=Confidence.LOW,
|
||||
source_ip=src_for_dest.get(dest), destination_ip=dest,
|
||||
risk_description=(
|
||||
"Many small outbound connections to one target may indicate "
|
||||
"segmented exfiltration or beaconing."
|
||||
),
|
||||
recommendation="Investigate the connection pattern and destination.",
|
||||
evidence={"transfer_count": per_dest_count[dest],
|
||||
"dedup_key": f"manyup:{dest}"},
|
||||
related_packets=sorted(set(packets_for_dest.get(dest, []))),
|
||||
related_streams=sorted(set(streams_for_dest.get(dest, []))),
|
||||
))
|
||||
|
||||
# DNS volume is a weak signal; the DNS detector provides deeper evidence.
|
||||
if len(ctx.dns_queries) >= _DNS_TXT_VOLUME:
|
||||
findings.append(Finding(
|
||||
title="Very high DNS query volume",
|
||||
category=Category.EXFILTRATION,
|
||||
severity=Severity.MEDIUM, confidence=Confidence.LOW,
|
||||
protocol="dns",
|
||||
risk_description=(
|
||||
"Unusually high DNS volume may indicate DNS tunneling or exfiltration."
|
||||
),
|
||||
recommendation="Review DNS logs and correlated DNS anomaly findings.",
|
||||
evidence={"dns_query_count": len(ctx.dns_queries), "dedup_key": "dns_volume"},
|
||||
related_packets=sorted(
|
||||
{q.packet_number for q in ctx.dns_queries if q.packet_number}
|
||||
)[:8],
|
||||
))
|
||||
|
||||
return self._tag(findings)
|
||||
@@ -0,0 +1,107 @@
|
||||
"""Detect insecure or unexpected legacy protocols."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from typing import Iterable
|
||||
|
||||
from ..context import CaptureContext
|
||||
from ..findings import Finding, Category, Severity, Confidence
|
||||
from .base import BaseDetector, register
|
||||
|
||||
# Port to protocol name, severity, risk, and recommendation.
|
||||
_RISKY_PORTS = {
|
||||
21: ("FTP", Severity.HIGH, "FTP transmits data and credentials in clear text.",
|
||||
"Replace FTP with FTPS or SFTP."),
|
||||
23: ("Telnet", Severity.CRITICAL, "Telnet transmits all content in clear text.",
|
||||
"Disable Telnet and use SSH."),
|
||||
69: ("TFTP", Severity.HIGH, "TFTP has no authentication or encryption.",
|
||||
"Avoid TFTP or restrict it to an isolated network."),
|
||||
80: ("HTTP", Severity.MEDIUM, "HTTP content is unencrypted and observable.",
|
||||
"Require HTTPS."),
|
||||
110: ("POP3", Severity.HIGH, "POP3 without TLS exposes messages and passwords.",
|
||||
"Use POP3S or STARTTLS."),
|
||||
143: ("IMAP", Severity.HIGH, "IMAP without TLS exposes messages and passwords.",
|
||||
"Use IMAPS or STARTTLS."),
|
||||
161: ("SNMP", Severity.MEDIUM, "SNMP v1/v2c uses clear-text community strings.",
|
||||
"Use SNMPv3 with authentication and privacy."),
|
||||
389: ("LDAP", Severity.HIGH, "LDAP without TLS exposes directory credentials.",
|
||||
"Use LDAPS or StartTLS."),
|
||||
512: ("rexec", Severity.HIGH, "Remote shell services are insecure and unencrypted.",
|
||||
"Disable the service and use SSH."),
|
||||
513: ("rlogin", Severity.HIGH, "Remote login services are insecure and unencrypted.",
|
||||
"Disable the service and use SSH."),
|
||||
1433: ("MSSQL", Severity.MEDIUM, "The database service may be exposed without TLS.",
|
||||
"Require TLS and restrict access."),
|
||||
3306: ("MySQL", Severity.MEDIUM, "The database service may be exposed without TLS.",
|
||||
"Require TLS and restrict access."),
|
||||
6379: ("Redis", Severity.HIGH, "Redis may be exposed without authentication or encryption.",
|
||||
"Enable authentication and restrict network exposure."),
|
||||
11211: ("Memcached", Severity.HIGH, "Memcached can be abused for amplification.",
|
||||
"Bind locally and restrict access."),
|
||||
27017: ("MongoDB", Severity.HIGH, "MongoDB without authentication or TLS exposes data.",
|
||||
"Enable authentication and TLS, then restrict access."),
|
||||
}
|
||||
|
||||
|
||||
@register
|
||||
class InsecureProtocolDetector(BaseDetector):
|
||||
name = "InsecureProtocolDetector"
|
||||
description = "Detects clear-text and legacy protocols on known service ports."
|
||||
|
||||
def detect(self, ctx: CaptureContext) -> Iterable[Finding]:
|
||||
findings: list[Finding] = []
|
||||
|
||||
# Aggregate observed use of known risky service ports.
|
||||
used: dict[int, dict] = defaultdict(lambda: {"src": None, "dst": None, "proto": None,
|
||||
"first": None, "last": None, "packets": 0,
|
||||
"pkts": [], "streams": set()})
|
||||
for conv in ctx.conversations:
|
||||
for port in (conv.dst_port, conv.src_port):
|
||||
if port in _RISKY_PORTS:
|
||||
e = used[port]
|
||||
e["src"] = conv.src_ip
|
||||
e["dst"] = conv.dst_ip
|
||||
e["proto"] = conv.protocol
|
||||
e["packets"] += conv.packets
|
||||
if conv.packet_numbers:
|
||||
e["pkts"].extend(conv.packet_numbers[:4])
|
||||
if conv.stream_id is not None:
|
||||
e["streams"].add(conv.stream_id)
|
||||
if conv.first_seen is not None:
|
||||
e["first"] = conv.first_seen if e["first"] is None else min(e["first"], conv.first_seen)
|
||||
if conv.last_seen is not None:
|
||||
e["last"] = conv.last_seen if e["last"] is None else max(e["last"], conv.last_seen)
|
||||
|
||||
for port, e in used.items():
|
||||
name, sev, risk, rec = _RISKY_PORTS[port]
|
||||
findings.append(Finding(
|
||||
title=f"Insecure protocol: {name} (port {port})",
|
||||
category=Category.INSECURE_PROTOCOL,
|
||||
severity=sev, confidence=Confidence.HIGH,
|
||||
source_ip=e["src"], destination_ip=e["dst"],
|
||||
protocol=name.lower(), port=port,
|
||||
timestamp_first_seen=e["first"], timestamp_last_seen=e["last"],
|
||||
risk_description=risk,
|
||||
recommendation=rec,
|
||||
evidence={"port": port, "packets": e["packets"],
|
||||
"dedup_key": f"insecure:{port}:{e['dst']}"},
|
||||
related_packets=sorted(set(e["pkts"]))[:8],
|
||||
related_streams=sorted(e["streams"])[:8],
|
||||
))
|
||||
|
||||
smb1 = ctx.protocol_count("smb")
|
||||
if smb1 and not ctx.protocol_count("smb2", "smb3"):
|
||||
findings.append(Finding(
|
||||
title="SMBv1 in use",
|
||||
category=Category.INSECURE_PROTOCOL,
|
||||
severity=Severity.HIGH, confidence=Confidence.MEDIUM,
|
||||
protocol="smb",
|
||||
risk_description=(
|
||||
"SMBv1 is obsolete and vulnerable to well-known attacks such as EternalBlue."
|
||||
),
|
||||
recommendation="Disable SMBv1 and require SMBv2 or SMBv3.",
|
||||
evidence={"smb1_packets": smb1, "dedup_key": "smbv1"},
|
||||
))
|
||||
|
||||
return self._tag(findings)
|
||||
@@ -0,0 +1,191 @@
|
||||
"""Detect malware, command-and-control, and beaconing behavior."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import statistics
|
||||
from collections import defaultdict
|
||||
from typing import Iterable, Optional
|
||||
|
||||
from ..context import CaptureContext
|
||||
from ..findings import Finding, Category, Severity, Confidence
|
||||
from .base import BaseDetector, register
|
||||
|
||||
# Scanner and attack-tool user agents provide a strong signal.
|
||||
_SCANNER_UA = (
|
||||
"nikto", "masscan", "sqlmap", "nmap", "hydra", "zgrab", "nuclei",
|
||||
"dirbuster", "gobuster", "wpscan", "acunetix", "nessus",
|
||||
)
|
||||
# Scripting clients are a weak signal because IoT automation may use them legitimately.
|
||||
_SCRIPTING_UA = (
|
||||
"curl", "wget", "python-requests", "powershell", "libwww",
|
||||
)
|
||||
# Common mobile and embedded clients are intentionally excluded to limit false positives.
|
||||
|
||||
_MIN_BEACONS = 5
|
||||
_MAX_CV = 0.25
|
||||
|
||||
|
||||
@register
|
||||
class MalwareBeaconingDetector(BaseDetector):
|
||||
name = "MalwareBeaconingDetector"
|
||||
description = "Detects periodic external connections and suspicious user agents."
|
||||
|
||||
def detect(self, ctx: CaptureContext) -> Iterable[Finding]:
|
||||
findings: list[Finding] = []
|
||||
|
||||
findings.extend(self._detect_beaconing(ctx))
|
||||
findings.extend(self._detect_suspicious_ua(ctx))
|
||||
|
||||
return self._tag(findings)
|
||||
|
||||
def _detect_beaconing(self, ctx: CaptureContext) -> list[Finding]:
|
||||
"""Detect periodic traffic in long-lived flows and repeated short flows."""
|
||||
findings: list[Finding] = []
|
||||
reported: set[tuple] = set()
|
||||
|
||||
# First evaluate multiple timestamps captured within a single flow.
|
||||
for conv in ctx.conversations:
|
||||
ext_ip = _external_of(ctx, conv.src_ip, conv.dst_ip)
|
||||
if not ext_ip:
|
||||
continue
|
||||
ts = sorted(t for t in conv.timestamps if t is not None)
|
||||
if len(ts) < _MIN_BEACONS:
|
||||
continue
|
||||
internal = conv.dst_ip if ext_ip == conv.src_ip else conv.src_ip
|
||||
port = conv.src_port if ext_ip == conv.src_ip else conv.dst_port
|
||||
f = self._beacon_finding(
|
||||
ts, src=internal, dst=ext_ip, protocol=conv.protocol, port=port,
|
||||
streams=[conv.stream_id] if conv.stream_id is not None else [],
|
||||
packets=list(conv.packet_numbers),
|
||||
)
|
||||
if f:
|
||||
reported.add((internal, ext_ip, port))
|
||||
findings.append(f)
|
||||
|
||||
# Then aggregate repeated short-lived connections to the same target.
|
||||
events: dict[tuple, list[float]] = defaultdict(list)
|
||||
meta: dict[tuple, dict] = {}
|
||||
for conv in ctx.conversations:
|
||||
if ctx.is_external(conv.dst_ip) and not ctx.is_external(conv.src_ip):
|
||||
internal, external, port = conv.src_ip, conv.dst_ip, conv.dst_port
|
||||
elif ctx.is_external(conv.src_ip) and not ctx.is_external(conv.dst_ip):
|
||||
internal, external, port = conv.dst_ip, conv.src_ip, conv.src_port
|
||||
else:
|
||||
continue
|
||||
if not (internal and external) or conv.first_seen is None:
|
||||
continue
|
||||
key = (internal, external, port)
|
||||
events[key].append(conv.first_seen)
|
||||
m = meta.setdefault(key, {"protocol": conv.protocol, "streams": set(), "packets": []})
|
||||
if conv.stream_id is not None:
|
||||
m["streams"].add(conv.stream_id)
|
||||
# One representative packet per connection keeps evidence compact.
|
||||
if conv.packet_numbers:
|
||||
m["packets"].append(conv.packet_numbers[0])
|
||||
|
||||
for key, first_seens in events.items():
|
||||
if key in reported or len(first_seens) < _MIN_BEACONS:
|
||||
continue
|
||||
internal, external, port = key
|
||||
f = self._beacon_finding(
|
||||
sorted(first_seens), src=internal, dst=external,
|
||||
protocol=meta[key]["protocol"], port=port,
|
||||
streams=sorted(meta[key]["streams"]),
|
||||
packets=meta[key]["packets"],
|
||||
connection_based=True,
|
||||
)
|
||||
if f:
|
||||
findings.append(f)
|
||||
|
||||
return findings
|
||||
|
||||
def _beacon_finding(self, ts: list[float], *, src, dst, protocol, port,
|
||||
streams, packets=None, connection_based: bool = False) -> Optional[Finding]:
|
||||
intervals = [b - a for a, b in zip(ts, ts[1:]) if (b - a) > 0]
|
||||
if len(intervals) < _MIN_BEACONS - 1:
|
||||
return None
|
||||
mean = statistics.mean(intervals)
|
||||
if mean <= 0:
|
||||
return None
|
||||
cv = statistics.pstdev(intervals) / mean
|
||||
if cv > _MAX_CV:
|
||||
return None
|
||||
conf = Confidence.MEDIUM if cv > 0.1 else Confidence.HIGH
|
||||
evidence = {
|
||||
"beacon_count": len(ts),
|
||||
"interval_seconds_mean": round(mean, 2),
|
||||
"interval_cv": round(cv, 3),
|
||||
"signal": "distinct_connections" if connection_based else "single_flow",
|
||||
"dedup_key": f"beacon:{src}:{dst}:{port}",
|
||||
}
|
||||
return Finding(
|
||||
title="Periodic beaconing to an external target",
|
||||
category=Category.MALWARE_BEACONING,
|
||||
severity=Severity.HIGH, confidence=conf,
|
||||
source_ip=src, destination_ip=dst,
|
||||
protocol=protocol, port=port,
|
||||
timestamp_first_seen=ts[0], timestamp_last_seen=ts[-1],
|
||||
risk_description=(
|
||||
"The device contacts an external target at highly regular intervals, "
|
||||
"which is characteristic of command-and-control beaconing."
|
||||
),
|
||||
recommendation=(
|
||||
"Check the target against threat intelligence, isolate the device, and "
|
||||
"investigate it for compromise."
|
||||
),
|
||||
evidence=evidence,
|
||||
related_packets=sorted({p for p in (packets or []) if p is not None}),
|
||||
related_streams=[s for s in streams if s is not None],
|
||||
)
|
||||
|
||||
def _detect_suspicious_ua(self, ctx: CaptureContext) -> list[Finding]:
|
||||
findings: list[Finding] = []
|
||||
seen_ua: set[str] = set()
|
||||
for req in ctx.http_requests:
|
||||
ua = (req.user_agent or "").lower()
|
||||
if not ua:
|
||||
continue
|
||||
|
||||
marker = next((m for m in _SCANNER_UA if m in ua), None)
|
||||
if marker:
|
||||
tier, sev, conf = "scanner", Severity.HIGH, Confidence.MEDIUM
|
||||
risk = (
|
||||
"The user agent belongs to a known scanning or attack tool and may "
|
||||
"indicate active reconnaissance against the device."
|
||||
)
|
||||
else:
|
||||
marker = next((m for m in _SCRIPTING_UA if m in ua), None)
|
||||
if not marker:
|
||||
continue
|
||||
tier, sev, conf = "scripting", Severity.LOW, Confidence.LOW
|
||||
risk = (
|
||||
"An automated command-line client was used. This may be legitimate "
|
||||
"automation, but it can also indicate scripts or malware."
|
||||
)
|
||||
|
||||
if marker in seen_ua:
|
||||
continue
|
||||
seen_ua.add(marker)
|
||||
findings.append(Finding(
|
||||
title=f"Suspicious user agent: {marker}",
|
||||
category=Category.MALWARE_BEACONING,
|
||||
severity=sev, confidence=conf,
|
||||
source_ip=req.src_ip, destination_ip=req.dst_ip,
|
||||
protocol="http", port=req.dst_port,
|
||||
timestamp_first_seen=req.timestamp, timestamp_last_seen=req.timestamp,
|
||||
risk_description=risk,
|
||||
recommendation="Verify that the source and client are authorized.",
|
||||
evidence={"user_agent": req.user_agent, "host": req.host, "tier": tier,
|
||||
"dedup_key": f"ua:{marker}:{req.src_ip}"},
|
||||
related_packets=[p for p in [req.packet_number] if p],
|
||||
))
|
||||
return findings
|
||||
|
||||
|
||||
def _external_of(ctx: CaptureContext, src_ip, dst_ip) -> Optional[str]:
|
||||
"""Return the external address in a pair, if present."""
|
||||
if ctx.is_external(dst_ip):
|
||||
return dst_ip
|
||||
if ctx.is_external(src_ip):
|
||||
return src_ip
|
||||
return None
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Detect local network manipulation and man-in-the-middle indicators."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from typing import Iterable
|
||||
|
||||
from ..context import CaptureContext
|
||||
from ..findings import Finding, Category, Severity, Confidence
|
||||
from .base import BaseDetector, register
|
||||
|
||||
|
||||
@register
|
||||
class MitmDetector(BaseDetector):
|
||||
name = "MitmDetector"
|
||||
description = "Detects ARP spoofing, duplicate IP mappings, and ARP floods."
|
||||
|
||||
def detect(self, ctx: CaptureContext) -> Iterable[Finding]:
|
||||
findings: list[Finding] = []
|
||||
|
||||
# Map each IP address to MAC addresses observed in ARP responses.
|
||||
ip_to_macs: dict[str, set[str]] = defaultdict(set)
|
||||
first_seen: dict[str, float] = {}
|
||||
last_seen: dict[str, float] = {}
|
||||
packets_for_ip: dict[str, list[int]] = defaultdict(list)
|
||||
|
||||
for ev in ctx.arp_events:
|
||||
if ev.sender_ip and ev.sender_mac:
|
||||
ip_to_macs[ev.sender_ip].add(ev.sender_mac)
|
||||
if ev.timestamp is not None:
|
||||
first_seen[ev.sender_ip] = min(first_seen.get(ev.sender_ip, ev.timestamp), ev.timestamp)
|
||||
last_seen[ev.sender_ip] = max(last_seen.get(ev.sender_ip, ev.timestamp), ev.timestamp)
|
||||
if ev.packet_number:
|
||||
packets_for_ip[ev.sender_ip].append(ev.packet_number)
|
||||
|
||||
for ip, host in ctx.hosts.items():
|
||||
if host.macs:
|
||||
ip_to_macs[ip].update(host.macs)
|
||||
|
||||
for ip, macs in ip_to_macs.items():
|
||||
if len(macs) > 1:
|
||||
findings.append(Finding(
|
||||
title="Multiple MAC addresses associated with one IP",
|
||||
category=Category.MITM,
|
||||
severity=Severity.HIGH, confidence=Confidence.MEDIUM,
|
||||
source_ip=ip,
|
||||
protocol="arp",
|
||||
timestamp_first_seen=first_seen.get(ip),
|
||||
timestamp_last_seen=last_seen.get(ip),
|
||||
risk_description=(
|
||||
"One IP address was advertised by multiple MAC addresses, a common "
|
||||
"indicator of ARP spoofing or traffic interception."
|
||||
),
|
||||
recommendation=(
|
||||
"Review ARP tables, enable Dynamic ARP Inspection and port security, "
|
||||
"and identify the conflicting host."
|
||||
),
|
||||
evidence={"ip": ip, "macs": sorted(macs), "dedup_key": f"arpdup:{ip}"},
|
||||
related_packets=sorted(packets_for_ip.get(ip, []))[:10],
|
||||
))
|
||||
|
||||
# An ARP reply flood can accompany active cache poisoning.
|
||||
grat = [e for e in ctx.arp_events if e.opcode == 2]
|
||||
if len(grat) >= 50:
|
||||
findings.append(Finding(
|
||||
title="Unusually high number of ARP replies",
|
||||
category=Category.MITM,
|
||||
severity=Severity.MEDIUM, confidence=Confidence.LOW,
|
||||
protocol="arp",
|
||||
risk_description=(
|
||||
"A large number of ARP replies may indicate active ARP cache poisoning."
|
||||
),
|
||||
recommendation="Inspect the reply source and investigate possible interception.",
|
||||
evidence={"arp_reply_count": len(grat), "dedup_key": "arp_reply_flood"},
|
||||
related_packets=sorted({e.packet_number for e in grat if e.packet_number})[:8],
|
||||
))
|
||||
|
||||
return self._tag(findings)
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Detect password hashes, challenge-response material, and Kerberos tickets."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Iterable
|
||||
|
||||
from ..context import CaptureContext
|
||||
from ..findings import Finding, Category, Severity, Confidence
|
||||
from .base import BaseDetector, register
|
||||
|
||||
_DIGEST_RE = re.compile(r"\bDigest\b", re.IGNORECASE)
|
||||
|
||||
|
||||
@register
|
||||
class PasswordHashDetector(BaseDetector):
|
||||
name = "PasswordHashDetector"
|
||||
description = (
|
||||
"Detects NTLM, Kerberos, HTTP Digest, and wireless handshake material."
|
||||
)
|
||||
|
||||
def detect(self, ctx: CaptureContext) -> Iterable[Finding]:
|
||||
findings: list[Finding] = []
|
||||
|
||||
for am in ctx.auth_material:
|
||||
kind = am.kind.lower()
|
||||
if kind.startswith("kerberos"):
|
||||
sev, conf = Severity.HIGH, Confidence.MEDIUM
|
||||
title = "Kerberos authentication material in capture"
|
||||
risk = (
|
||||
"Kerberos AS-REP or TGS material may enable offline password attacks "
|
||||
"when weak credentials are used."
|
||||
)
|
||||
rec = "Enforce pre-authentication, strong passwords, SPN hardening, and AES."
|
||||
else:
|
||||
sev, conf = Severity.HIGH, Confidence.MEDIUM
|
||||
title = "NTLM authentication material in capture"
|
||||
risk = (
|
||||
"NTLM or NetNTLM material may enable offline cracking or relay attacks "
|
||||
"without exposing a clear-text password."
|
||||
)
|
||||
rec = "Enable SMB signing and EPA, restrict NTLM, and prefer Kerberos."
|
||||
|
||||
evidence = {
|
||||
"kind": am.kind, "account": am.account, "domain": am.domain,
|
||||
"dedup_key": f"auth:{am.kind}:{am.src_ip}:{am.dst_ip}:{am.account}",
|
||||
}
|
||||
# Fully reconstructed hashcat material raises confidence.
|
||||
if am.hashcat:
|
||||
evidence["hashcat"] = am.hashcat
|
||||
if am.hashcat_mode is not None:
|
||||
evidence["hashcat_mode"] = am.hashcat_mode
|
||||
if am.hash_format:
|
||||
evidence["format"] = am.hash_format
|
||||
conf = Confidence.HIGH
|
||||
title += " (crackable)"
|
||||
rec = (
|
||||
"Rotate the affected password immediately. " + rec +
|
||||
" The captured material is available in an offline-crackable format."
|
||||
)
|
||||
if am.etype:
|
||||
evidence["etype"] = am.etype
|
||||
|
||||
findings.append(Finding(
|
||||
title=title,
|
||||
category=Category.PASSWORD_HASHES,
|
||||
severity=sev, confidence=conf,
|
||||
source_ip=am.src_ip, destination_ip=am.dst_ip, protocol=am.protocol,
|
||||
timestamp_first_seen=am.timestamp, timestamp_last_seen=am.timestamp,
|
||||
risk_description=risk, recommendation=rec,
|
||||
evidence=evidence,
|
||||
related_packets=[p for p in [am.packet_number] if p],
|
||||
))
|
||||
|
||||
for req in ctx.http_requests:
|
||||
if req.authorization and _DIGEST_RE.search(req.authorization):
|
||||
findings.append(Finding(
|
||||
title="HTTP Digest Authentication (Challenge-Response)",
|
||||
category=Category.PASSWORD_HASHES,
|
||||
severity=Severity.MEDIUM, confidence=Confidence.HIGH,
|
||||
source_ip=req.src_ip, destination_ip=req.dst_ip,
|
||||
protocol="http", port=req.dst_port,
|
||||
timestamp_first_seen=req.timestamp, timestamp_last_seen=req.timestamp,
|
||||
risk_description=(
|
||||
"HTTP Digest transmits a response hash that may allow offline cracking "
|
||||
"when weak passwords are used."
|
||||
),
|
||||
recommendation="Require HTTPS and enforce strong passwords.",
|
||||
evidence={
|
||||
"host": req.host, "uri": req.uri,
|
||||
"dedup_key": f"digest:{req.src_ip}:{req.dst_ip}:{req.uri}",
|
||||
},
|
||||
related_packets=[p for p in [req.packet_number] if p],
|
||||
))
|
||||
|
||||
# EAPOL frames indicate potentially crackable wireless handshakes.
|
||||
eapol = ctx.protocol_count("eapol")
|
||||
if eapol:
|
||||
findings.append(Finding(
|
||||
title="WPA/WPA2 handshake material (EAPOL) detected",
|
||||
category=Category.PASSWORD_HASHES,
|
||||
severity=Severity.HIGH, confidence=Confidence.MEDIUM,
|
||||
protocol="eapol",
|
||||
risk_description=(
|
||||
"EAPOL frames from a four-way handshake may enable offline brute-force "
|
||||
"attacks against the wireless password."
|
||||
),
|
||||
recommendation="Use a strong WPA2/WPA3 passphrase and prefer WPA3.",
|
||||
evidence={"eapol_frames": eapol, "dedup_key": "eapol_handshake"},
|
||||
))
|
||||
|
||||
return self._tag(findings)
|
||||
@@ -0,0 +1,132 @@
|
||||
"""Detect clear-text credentials."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import re
|
||||
from typing import Iterable
|
||||
|
||||
from ..context import CaptureContext
|
||||
from ..findings import Finding, Category, Severity, Confidence
|
||||
from .base import BaseDetector, register
|
||||
|
||||
# Common form fields used for credentials.
|
||||
_USER_FIELDS = ("user", "username", "uname", "login", "email", "uid")
|
||||
_PASS_FIELDS = ("pass", "passwd", "password", "pwd", "pw")
|
||||
_FORM_RE = re.compile(r"([A-Za-z0-9_\-\.]+)=([^&\s]+)")
|
||||
|
||||
|
||||
@register
|
||||
class PlaintextCredentialDetector(BaseDetector):
|
||||
name = "PlaintextCredentialDetector"
|
||||
description = "Detects usernames and passwords transmitted without encryption."
|
||||
|
||||
def detect(self, ctx: CaptureContext) -> Iterable[Finding]:
|
||||
findings: list[Finding] = []
|
||||
|
||||
for pa in ctx.plaintext_auth:
|
||||
if pa.username or pa.password:
|
||||
findings.append(Finding(
|
||||
title=f"Clear-text credentials over {pa.protocol.upper()}",
|
||||
category=Category.PLAINTEXT_CREDENTIALS,
|
||||
severity=Severity.CRITICAL,
|
||||
confidence=Confidence.HIGH,
|
||||
source_ip=pa.src_ip, destination_ip=pa.dst_ip,
|
||||
protocol=pa.protocol, port=pa.dst_port,
|
||||
timestamp_first_seen=pa.timestamp, timestamp_last_seen=pa.timestamp,
|
||||
risk_description=(
|
||||
"A username or password was transmitted without encryption. "
|
||||
"A network observer could take over the account."
|
||||
),
|
||||
recommendation=(
|
||||
f"Replace {pa.protocol.upper()} with an encrypted alternative "
|
||||
"such as SFTP, IMAPS, POP3S, or SMTP with STARTTLS, then rotate "
|
||||
"the affected password."
|
||||
),
|
||||
evidence={
|
||||
"username": pa.username,
|
||||
"password": _mask(pa.password),
|
||||
"dedup_key": f"plain:{pa.protocol}:{pa.src_ip}:{pa.dst_ip}:{pa.username}",
|
||||
},
|
||||
related_packets=[p for p in [pa.packet_number] if p],
|
||||
related_streams=[s for s in [pa.stream_id] if s],
|
||||
))
|
||||
|
||||
for req in ctx.http_requests:
|
||||
if req.is_tls:
|
||||
continue
|
||||
auth = (req.authorization or "").strip()
|
||||
if auth.lower().startswith("basic "):
|
||||
user, pwd = _decode_basic(auth[6:])
|
||||
findings.append(Finding(
|
||||
title="Clear-text HTTP Basic authentication",
|
||||
category=Category.PLAINTEXT_CREDENTIALS,
|
||||
severity=Severity.CRITICAL,
|
||||
confidence=Confidence.HIGH,
|
||||
source_ip=req.src_ip, destination_ip=req.dst_ip,
|
||||
protocol="http", port=req.dst_port,
|
||||
timestamp_first_seen=req.timestamp, timestamp_last_seen=req.timestamp,
|
||||
risk_description=(
|
||||
"HTTP Basic authentication sends credentials with Base64 encoding, "
|
||||
"not encryption, over clear-text HTTP."
|
||||
),
|
||||
recommendation="Require HTTPS and rotate the affected password.",
|
||||
evidence={
|
||||
"host": req.host, "uri": req.uri,
|
||||
"username": user, "password": _mask(pwd),
|
||||
"dedup_key": f"basic:{req.src_ip}:{req.dst_ip}:{user}",
|
||||
},
|
||||
related_packets=[p for p in [req.packet_number] if p],
|
||||
))
|
||||
|
||||
# Detect likely login forms in clear-text HTTP request bodies.
|
||||
if req.method and req.method.upper() == "POST" and req.body:
|
||||
fields = dict(_FORM_RE.findall(req.body))
|
||||
lowered = {k.lower(): v for k, v in fields.items()}
|
||||
has_user = any(k in lowered for k in _USER_FIELDS)
|
||||
has_pass = any(k in lowered for k in _PASS_FIELDS)
|
||||
if has_pass and (has_user or len(fields) <= 4):
|
||||
user_val = next((lowered[k] for k in _USER_FIELDS if k in lowered), None)
|
||||
pass_val = next((lowered[k] for k in _PASS_FIELDS if k in lowered), None)
|
||||
findings.append(Finding(
|
||||
title="Login form submitted over clear-text HTTP",
|
||||
category=Category.PLAINTEXT_CREDENTIALS,
|
||||
severity=Severity.CRITICAL,
|
||||
confidence=Confidence.MEDIUM,
|
||||
source_ip=req.src_ip, destination_ip=req.dst_ip,
|
||||
protocol="http", port=req.dst_port,
|
||||
timestamp_first_seen=req.timestamp, timestamp_last_seen=req.timestamp,
|
||||
risk_description=(
|
||||
"A login form was submitted in a clear-text HTTP request. "
|
||||
"The credentials are visible to network observers."
|
||||
),
|
||||
recommendation="Serve the form only over HTTPS and rotate the password.",
|
||||
evidence={
|
||||
"host": req.host, "uri": req.uri,
|
||||
"username": user_val, "password": _mask(pass_val),
|
||||
"dedup_key": f"form:{req.src_ip}:{req.dst_ip}:{user_val}",
|
||||
},
|
||||
related_packets=[p for p in [req.packet_number] if p],
|
||||
))
|
||||
|
||||
return self._tag(findings)
|
||||
|
||||
|
||||
def _decode_basic(b64: str):
|
||||
try:
|
||||
raw = base64.b64decode(b64.strip() + "===").decode("utf-8", "ignore")
|
||||
if ":" in raw:
|
||||
u, p = raw.split(":", 1)
|
||||
return u, p
|
||||
return raw, None
|
||||
except Exception:
|
||||
return None, None
|
||||
|
||||
|
||||
def _mask(secret):
|
||||
if not secret:
|
||||
return None
|
||||
s = str(secret)
|
||||
if len(s) <= 2:
|
||||
return "*" * len(s)
|
||||
return s[0] + "*" * (len(s) - 2) + s[-1]
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Detect weak, obsolete, or suspicious TLS connections."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Iterable
|
||||
|
||||
from ..context import CaptureContext
|
||||
from ..findings import Finding, Category, Severity, Confidence
|
||||
from .base import BaseDetector, register
|
||||
|
||||
_WEAK_VERSIONS = {"SSL 2.0", "SSL 3.0", "TLS 1.0", "TLS 1.1"}
|
||||
# Weak cipher markers matched against normalized suite names.
|
||||
_WEAK_CIPHER_MARKERS = ("RC4", "NULL", "EXPORT", "_DES_", "DES_CBC", "3DES", "MD5", "ANON")
|
||||
|
||||
|
||||
@register
|
||||
class TlsWeaknessDetector(BaseDetector):
|
||||
name = "TlsWeaknessDetector"
|
||||
description = "Detects obsolete TLS versions, weak ciphers, and certificate anomalies."
|
||||
|
||||
def detect(self, ctx: CaptureContext) -> Iterable[Finding]:
|
||||
findings: list[Finding] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
for s in ctx.tls_sessions:
|
||||
target = s.sni or s.dst_ip
|
||||
base_pkt = [p for p in [s.packet_number] if p]
|
||||
|
||||
if s.version and s.version in _WEAK_VERSIONS:
|
||||
key = f"tlsver:{target}:{s.version}"
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
findings.append(Finding(
|
||||
title=f"Obsolete TLS version: {s.version}",
|
||||
category=Category.TLS_WEAKNESS,
|
||||
severity=Severity.MEDIUM, confidence=Confidence.HIGH,
|
||||
source_ip=s.src_ip, destination_ip=s.dst_ip, protocol="tls", port=s.dst_port,
|
||||
timestamp_first_seen=s.timestamp, timestamp_last_seen=s.timestamp,
|
||||
risk_description=(
|
||||
"An obsolete TLS or SSL version is in use and is vulnerable to "
|
||||
"known protocol attacks."
|
||||
),
|
||||
recommendation="Require at least TLS 1.2 and prefer TLS 1.3.",
|
||||
evidence={"version": s.version, "target": target, "dedup_key": key},
|
||||
related_packets=base_pkt,
|
||||
))
|
||||
|
||||
if s.cipher:
|
||||
up = str(s.cipher).upper()
|
||||
if any(m in up for m in _WEAK_CIPHER_MARKERS):
|
||||
key = f"tlscipher:{target}:{s.cipher}"
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
findings.append(Finding(
|
||||
title="Weak TLS cipher suite",
|
||||
category=Category.TLS_WEAKNESS,
|
||||
severity=Severity.MEDIUM, confidence=Confidence.MEDIUM,
|
||||
source_ip=s.src_ip, destination_ip=s.dst_ip, protocol="tls", port=s.dst_port,
|
||||
timestamp_first_seen=s.timestamp, timestamp_last_seen=s.timestamp,
|
||||
risk_description=(
|
||||
"A weak or obsolete cipher suite reduces transport protection."
|
||||
),
|
||||
recommendation="Allow only modern AEAD ciphers such as AES-GCM or ChaCha20.",
|
||||
evidence={"cipher": s.cipher, "target": target, "dedup_key": key},
|
||||
related_packets=base_pkt,
|
||||
))
|
||||
|
||||
if s.cert_self_signed or s.cert_expired:
|
||||
problem = []
|
||||
if s.cert_self_signed:
|
||||
problem.append("self-signed")
|
||||
if s.cert_expired:
|
||||
problem.append("expired")
|
||||
key = f"tlscert:{target}:{'-'.join(problem)}"
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
findings.append(Finding(
|
||||
title=f"Suspicious TLS certificate ({', '.join(problem)})",
|
||||
category=Category.TLS_WEAKNESS,
|
||||
severity=Severity.MEDIUM, confidence=Confidence.MEDIUM,
|
||||
source_ip=s.src_ip, destination_ip=s.dst_ip, protocol="tls", port=s.dst_port,
|
||||
timestamp_first_seen=s.timestamp, timestamp_last_seen=s.timestamp,
|
||||
risk_description=(
|
||||
"A self-signed or expired certificate may indicate endpoint "
|
||||
"misconfiguration or TLS interception."
|
||||
),
|
||||
recommendation="Review the certificate chain and deploy a valid CA certificate.",
|
||||
evidence={"subject_cn": s.cert_subject_cn, "issuer_cn": s.cert_issuer_cn,
|
||||
"not_after": s.cert_not_after, "dedup_key": key},
|
||||
related_packets=base_pkt,
|
||||
))
|
||||
|
||||
return self._tag(findings)
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Detect session cookies, bearer tokens, and API keys."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Iterable
|
||||
|
||||
from ..context import CaptureContext
|
||||
from ..findings import Finding, Category, Severity, Confidence
|
||||
from .base import BaseDetector, register
|
||||
|
||||
# JWTs consist of three base64url segments and typically start with ``eyJ``.
|
||||
_JWT_RE = re.compile(r"\beyJ[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+")
|
||||
_BEARER_RE = re.compile(r"\bBearer\s+([A-Za-z0-9_\-\.=]+)", re.IGNORECASE)
|
||||
# Generic API key patterns in URLs, bodies, and headers.
|
||||
_APIKEY_RE = re.compile(
|
||||
r"(?i)(api[_\-]?key|apikey|access[_\-]?token|client[_\-]?secret|x[_\-]api[_\-]key)"
|
||||
r"[=:\"']?\s*([A-Za-z0-9_\-]{16,})"
|
||||
)
|
||||
_SESSION_COOKIE_RE = re.compile(
|
||||
r"(?i)\b(sessionid|session|sid|phpsessid|jsessionid|asp\.net_sessionid|auth|token)="
|
||||
r"([A-Za-z0-9%_\-\.]{8,})"
|
||||
)
|
||||
|
||||
|
||||
@register
|
||||
class TokenSecretDetector(BaseDetector):
|
||||
name = "TokenSecretDetector"
|
||||
description = "Detects JWTs, bearer tokens, API keys, and session cookies."
|
||||
|
||||
def detect(self, ctx: CaptureContext) -> Iterable[Finding]:
|
||||
findings: list[Finding] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
for payload in ctx.text_payloads:
|
||||
text = payload.get("text") or ""
|
||||
over_http = payload.get("protocol") == "http"
|
||||
|
||||
for m in _JWT_RE.findall(text):
|
||||
findings.append(self._mk(
|
||||
payload, "Clear-text JWT (JSON Web Token)",
|
||||
"A JWT was transmitted in clear text and may grant passwordless access "
|
||||
"until it expires.",
|
||||
"Revoke and rotate the token, shorten its lifetime, and require HTTPS.",
|
||||
secret=m, conf=Confidence.HIGH, seen=seen, kind="jwt"))
|
||||
|
||||
for m in _BEARER_RE.findall(text):
|
||||
findings.append(self._mk(
|
||||
payload, "Clear-text bearer or OAuth token",
|
||||
"A bearer token was transmitted and may grant access without a password.",
|
||||
"Rotate the token, shorten its lifetime, and require HTTPS.",
|
||||
secret=m, conf=Confidence.HIGH, seen=seen, kind="bearer"))
|
||||
|
||||
for _, m in _APIKEY_RE.findall(text):
|
||||
findings.append(self._mk(
|
||||
payload, "Clear-text API key or client secret",
|
||||
"An API key or client secret was transmitted in clear text.",
|
||||
"Rotate the secret, store it securely, and require HTTPS.",
|
||||
secret=m, conf=Confidence.MEDIUM, seen=seen, kind="apikey"))
|
||||
|
||||
# Report session cookies only when the transport is unencrypted.
|
||||
if over_http:
|
||||
for _, m in _SESSION_COOKIE_RE.findall(text):
|
||||
findings.append(self._mk(
|
||||
payload, "Session cookie over clear-text HTTP",
|
||||
"An unencrypted session cookie may enable session hijacking.",
|
||||
"Set Secure and HttpOnly cookie flags and require HTTPS.",
|
||||
secret=m, conf=Confidence.MEDIUM, seen=seen, kind="cookie"))
|
||||
|
||||
return self._tag([f for f in findings if f is not None])
|
||||
|
||||
def _mk(self, payload, title, risk, rec, *, secret, conf, seen, kind):
|
||||
key = f"{kind}:{secret[:24]}"
|
||||
if key in seen:
|
||||
return None
|
||||
seen.add(key)
|
||||
return Finding(
|
||||
title=title,
|
||||
category=Category.TOKENS_SECRETS,
|
||||
severity=Severity.HIGH, confidence=conf,
|
||||
source_ip=payload.get("src_ip"), destination_ip=payload.get("dst_ip"),
|
||||
protocol=payload.get("protocol"),
|
||||
timestamp_first_seen=payload.get("timestamp"),
|
||||
timestamp_last_seen=payload.get("timestamp"),
|
||||
risk_description=risk, recommendation=rec,
|
||||
evidence={"secret_preview": _preview(secret), "type": kind, "dedup_key": key},
|
||||
related_packets=[p for p in [payload.get("packet_number")] if p],
|
||||
)
|
||||
|
||||
|
||||
def _preview(secret: str) -> str:
|
||||
s = str(secret)
|
||||
return s[:8] + "…" + s[-4:] if len(s) > 16 else s[:4] + "…"
|
||||
Reference in New Issue
Block a user