analyze performance upgrade
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
"""Detect brute force, scanning, and suspicious file transfers."""
|
||||
"""Detect scanning, repeated authentication failures, and risky file transfers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -10,145 +10,240 @@ 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",
|
||||
# Executable or script content that warrants closer inspection.
|
||||
_EXECUTABLE_EXT = {
|
||||
".exe", ".dll", ".ps1", ".vbs", ".jar", ".bat", ".cmd", ".scr", ".hta", ".lnk",
|
||||
".docm", ".xlsm", ".pptm", ".msi", ".apk", ".elf", ".sh", ".py", ".pl",
|
||||
}
|
||||
# Firmware and update images: integrity matters more than malware here.
|
||||
_FIRMWARE_EXT = {
|
||||
".bin", ".img", ".fw", ".ota", ".upd", ".hex", ".rom", ".swu", ".ubi", ".squashfs",
|
||||
".pkg", ".tgz", ".tar", ".gz", ".xz", ".bz2", ".zip", ".7z", ".rar", ".iso",
|
||||
}
|
||||
_EXECUTABLE_CT = ("x-msdownload", "x-dosexec", "x-executable", "portable-executable", "x-elf",
|
||||
"x-msi", "vnd.android.package-archive", "x-sh")
|
||||
_PORTSCAN_PORTS = 15
|
||||
_PORTSCAN_UNANSWERED = 8
|
||||
_HOSTSWEEP_HOSTS = 15
|
||||
_FAILED_LOGINS = 5
|
||||
_HTTP_401_MIN = 8
|
||||
|
||||
|
||||
@register
|
||||
class BruteforceScanFileTransferDetector(BaseDetector):
|
||||
name = "BruteforceScanFileTransferDetector"
|
||||
description = "Detects scans, repeated authentication failures, and risky downloads."
|
||||
description = "Detects scans, repeated authentication failures, and risky or unprotected file transfers."
|
||||
|
||||
def detect(self, ctx: CaptureContext) -> Iterable[Finding]:
|
||||
findings: list[Finding] = []
|
||||
findings.extend(self._scans(ctx))
|
||||
findings.extend(self._auth_failures(ctx))
|
||||
findings.extend(self._files(ctx))
|
||||
return self._tag(findings)
|
||||
|
||||
# ---- scanning --------------------------------------------------------- #
|
||||
|
||||
def _scans(self, ctx: CaptureContext) -> list[Finding]:
|
||||
findings: list[Finding] = []
|
||||
ports_per_pair: dict[tuple, set[int]] = defaultdict(set)
|
||||
hosts_per_src: dict[str, set[str]] = defaultdict(set)
|
||||
unanswered_per_pair: dict[tuple, set[int]] = defaultdict(set)
|
||||
packets_per_pair: dict[tuple, list] = defaultdict(list)
|
||||
hosts_per_src: dict[str, set[str]] = defaultdict(set)
|
||||
unanswered_hosts_per_src: dict[str, set[str]] = defaultdict(set)
|
||||
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)
|
||||
if not (conv.src_ip and conv.dst_ip and conv.dst_port):
|
||||
continue
|
||||
pair = (conv.src_ip, conv.dst_ip)
|
||||
ports_per_pair[pair].add(conv.dst_port)
|
||||
if conv.protocol == "tcp" and conv.syn_count and not conv.answered:
|
||||
unanswered_per_pair[pair].add(conv.dst_port)
|
||||
if conv.packet_numbers:
|
||||
packets_per_pair[pair].append(conv.packet_numbers[0])
|
||||
packets_per_src[conv.src_ip].append(conv.packet_numbers[0])
|
||||
# A host sweep scans the *local* network; fan-out to cloud endpoints is normal.
|
||||
if not ctx.is_external(conv.dst_ip) and conv.dst_ip != conv.src_ip:
|
||||
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])
|
||||
if not conv.answered:
|
||||
unanswered_hosts_per_src[conv.src_ip].add(conv.dst_ip)
|
||||
|
||||
for (src, dst), ports in ports_per_pair.items():
|
||||
if len(ports) >= _PORTSCAN_PORTS:
|
||||
unanswered = unanswered_per_pair.get((src, dst), set())
|
||||
if len(ports) >= _PORTSCAN_PORTS or len(unanswered) >= _PORTSCAN_UNANSWERED:
|
||||
conf = Confidence.HIGH if len(unanswered) >= _PORTSCAN_UNANSWERED else Confidence.MEDIUM
|
||||
findings.append(Finding(
|
||||
title="Port scan detected",
|
||||
category=Category.BRUTEFORCE_SCAN_FILETRANSFER,
|
||||
severity=Severity.MEDIUM, confidence=Confidence.MEDIUM,
|
||||
source_ip=src, destination_ip=dst,
|
||||
severity=Severity.MEDIUM, confidence=conf,
|
||||
source_ip=src, destination_ip=dst, protocol="tcp",
|
||||
risk_description=(
|
||||
"One host contacted many ports on a target, which is characteristic "
|
||||
"of network reconnaissance."
|
||||
"One host contacted many ports on a target, most of them without an answer, "
|
||||
"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}"},
|
||||
recommendation="Review the source and block unauthorized scanners.",
|
||||
evidence={"distinct_ports": len(ports), "unanswered_ports": len(unanswered),
|
||||
"sample_ports": sorted(ports)[:12], "target_is_device": ctx.is_device(dst),
|
||||
"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:
|
||||
unanswered = unanswered_hosts_per_src.get(src, set())
|
||||
if len(unanswered) < len(dsts) * 0.5:
|
||||
continue # Talking to many answering hosts is normal for a gateway or controller.
|
||||
findings.append(Finding(
|
||||
title="Host sweep detected",
|
||||
category=Category.BRUTEFORCE_SCAN_FILETRANSFER,
|
||||
severity=Severity.MEDIUM, confidence=Confidence.LOW,
|
||||
severity=Severity.MEDIUM, confidence=Confidence.MEDIUM,
|
||||
source_ip=src,
|
||||
risk_description=(
|
||||
"One host contacted many different targets, which may indicate a "
|
||||
"network sweep or worm behavior."
|
||||
"One host probed many internal addresses, most of which did not answer, "
|
||||
"which indicates a network sweep or worm-like behaviour."
|
||||
),
|
||||
recommendation="Review the source and investigate internal hosts for compromise.",
|
||||
evidence={"distinct_hosts": len(dsts), "dedup_key": f"sweep:{src}"},
|
||||
recommendation="Review the source and investigate it for compromise.",
|
||||
evidence={"distinct_hosts": len(dsts), "unanswered_hosts": len(unanswered),
|
||||
"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)
|
||||
echo = ctx.icmp_stats.get("echo_request", 0)
|
||||
if echo >= 50:
|
||||
findings.append(Finding(
|
||||
title="High number of ICMP echo requests",
|
||||
category=Category.BRUTEFORCE_SCAN_FILETRANSFER,
|
||||
severity=Severity.LOW, confidence=Confidence.LOW, protocol="icmp",
|
||||
risk_description="Many ICMP echo requests can indicate a ping sweep or a monitoring probe.",
|
||||
recommendation="Verify the source of the echo requests.",
|
||||
evidence={"echo_requests": echo, "dedup_key": "icmp_sweep"},
|
||||
))
|
||||
return findings
|
||||
|
||||
# ---- authentication failures ----------------------------------------- #
|
||||
|
||||
def _auth_failures(self, ctx: CaptureContext) -> list[Finding]:
|
||||
findings: list[Finding] = []
|
||||
failures: dict[tuple, list] = defaultdict(list)
|
||||
for a in ctx.auth_attempts:
|
||||
if a.success is False:
|
||||
failures[(a.protocol, a.src_ip, a.dst_ip)].append(a)
|
||||
for (proto, client, server), attempts in failures.items():
|
||||
if len(attempts) < _FAILED_LOGINS:
|
||||
continue
|
||||
users = sorted({a.username for a in attempts if a.username})
|
||||
findings.append(Finding(
|
||||
title=f"Repeated failed {proto.upper()} logins",
|
||||
category=Category.BRUTEFORCE_SCAN_FILETRANSFER,
|
||||
severity=Severity.HIGH, confidence=Confidence.HIGH,
|
||||
source_ip=client, destination_ip=server, protocol=proto, port=attempts[0].dst_port,
|
||||
timestamp_first_seen=attempts[0].timestamp, timestamp_last_seen=attempts[-1].timestamp,
|
||||
risk_description=(
|
||||
f"{len(attempts)} rejected {proto.upper()} login attempts from one client indicate "
|
||||
"password guessing."
|
||||
),
|
||||
recommendation="Enable rate limiting or lockout controls, review the source, and enforce strong credentials.",
|
||||
evidence={"failed_attempts": len(attempts), "usernames": users[:10],
|
||||
"target_is_device": ctx.is_device(server),
|
||||
"dedup_key": f"bruteforce:{proto}:{client}:{server}"},
|
||||
related_packets=sorted({a.packet_number for a in attempts if a.packet_number})[:8],
|
||||
))
|
||||
|
||||
# HTTP: only 401 counts (403 is routine for blocked resources).
|
||||
http_401: 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:
|
||||
if resp.status_code == 401:
|
||||
http_401[(resp.dst_ip, resp.src_ip)].append(resp)
|
||||
for (client, server), resps in http_401.items():
|
||||
if len(resps) >= _HTTP_401_MIN:
|
||||
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."
|
||||
),
|
||||
timestamp_first_seen=resps[0].timestamp, timestamp_last_seen=resps[-1].timestamp,
|
||||
risk_description="Many HTTP 401 responses to one client may indicate password guessing.",
|
||||
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],
|
||||
evidence={"failed_responses": len(resps),
|
||||
"uris": sorted({r.request_uri for r in resps if r.request_uri})[:5],
|
||||
"target_is_device": ctx.is_device(server),
|
||||
"dedup_key": f"bruteforce:http:{client}:{server}"},
|
||||
related_packets=sorted({r.packet_number for r in resps if r.packet_number})[:8],
|
||||
))
|
||||
return findings
|
||||
|
||||
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],
|
||||
))
|
||||
# ---- file transfers --------------------------------------------------- #
|
||||
|
||||
def _files(self, ctx: CaptureContext) -> list[Finding]:
|
||||
findings: list[Finding] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
def emit(key, **kw):
|
||||
if key in seen:
|
||||
return
|
||||
seen.add(key)
|
||||
kw["evidence"] = dict(kw.get("evidence") or {}, dedup_key=key)
|
||||
kw.setdefault("category", Category.BRUTEFORCE_SCAN_FILETRANSFER)
|
||||
findings.append(Finding(**kw))
|
||||
|
||||
# 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],
|
||||
))
|
||||
name = ft.filename or ft.uri or ct or "binary"
|
||||
executable = ext in _EXECUTABLE_EXT or any(m in ct for m in _EXECUTABLE_CT)
|
||||
firmware = ext in _FIRMWARE_EXT or (not ext and "octet-stream" in ct and (ft.size or 0) > 64 * 1024)
|
||||
cleartext = (ft.protocol or "") in ("http", "tftp", "ftp")
|
||||
downloader = ft.dst_ip if ft.direction == "download" else ft.src_ip
|
||||
server = ft.src_ip if ft.direction == "download" else ft.dst_ip
|
||||
device_involved = ctx.is_device(downloader) or ctx.is_device(server)
|
||||
common = dict(source_ip=downloader, destination_ip=server, protocol=ft.protocol,
|
||||
timestamp_first_seen=ft.timestamp, timestamp_last_seen=ft.timestamp,
|
||||
related_packets=[p for p in [ft.packet_number] if p],
|
||||
related_streams=[s for s in [ft.stream_id] if s is not None])
|
||||
evidence = {"filename": ft.filename, "uri": ft.uri, "host": ft.host,
|
||||
"content_type": ft.content_type, "size": ft.size, "extension": ext or None,
|
||||
"direction": ft.direction}
|
||||
|
||||
return self._tag(findings)
|
||||
if executable and cleartext:
|
||||
emit(f"exec:{server}:{name}",
|
||||
title=f"Executable or script transferred over clear-text {ft.protocol.upper()} ({ext or ct})",
|
||||
severity=Severity.HIGH, confidence=Confidence.MEDIUM,
|
||||
risk_description=("Executable content was transferred without transport protection. It can be "
|
||||
"modified on the path, and executable downloads to a device are a common "
|
||||
"malware delivery vector."),
|
||||
recommendation="Serve executables only over TLS with signature verification; analyze the file in a sandbox and check its hash against threat intelligence.",
|
||||
evidence=evidence, **common)
|
||||
elif firmware and cleartext:
|
||||
emit(f"fw:{server}:{name}",
|
||||
title=f"Firmware or update image transferred over clear-text {ft.protocol.upper()}",
|
||||
severity=Severity.HIGH if device_involved else Severity.MEDIUM,
|
||||
confidence=Confidence.MEDIUM,
|
||||
risk_description=("An update or archive image was transferred without transport protection. "
|
||||
"Unless the image is cryptographically signed and verified by the device, an "
|
||||
"attacker on the path can replace it."),
|
||||
recommendation="Deliver updates over TLS with certificate validation and verify a vendor signature before installing.",
|
||||
evidence=evidence, **common)
|
||||
elif executable:
|
||||
emit(f"exec:{server}:{name}",
|
||||
title=f"Executable or script transferred ({ext or ct})",
|
||||
severity=Severity.MEDIUM, confidence=Confidence.LOW,
|
||||
risk_description="Executable content was transferred; verify that the source is trusted.",
|
||||
recommendation="Check the file hash against threat intelligence.",
|
||||
evidence=evidence, **common)
|
||||
|
||||
# Requests for executables that produced no dissected response.
|
||||
for req in ctx.http_requests:
|
||||
uri = (req.uri or "").split("?")[0]
|
||||
ext = os.path.splitext(uri)[1].lower()
|
||||
if ext in _EXECUTABLE_EXT:
|
||||
emit(f"exec:{req.dst_ip}:{uri}",
|
||||
title=f"Executable or script requested over clear-text HTTP ({ext})",
|
||||
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=("A script or executable was requested over unencrypted HTTP and can be "
|
||||
"replaced on the path."),
|
||||
recommendation="Serve executables only over TLS with signature verification.",
|
||||
evidence={"host": req.host, "uri": uri, "extension": ext},
|
||||
related_packets=[p for p in [req.packet_number] if p])
|
||||
return findings
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
"""Detect anomalous DNS activity and potential DNS tunneling."""
|
||||
"""Detect anomalous DNS activity and potential DNS tunneling.
|
||||
|
||||
DGA and tunneling are judged by volume and diversity of names, never by a
|
||||
single random-looking host name. Provider domains come from ``allowlist``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -8,22 +12,41 @@ from typing import Iterable
|
||||
|
||||
from ..context import CaptureContext
|
||||
from ..findings import Finding, Category, Severity, Confidence
|
||||
from .. import allowlist
|
||||
from .base import BaseDetector, register
|
||||
|
||||
_LONG_LABEL = 40
|
||||
_ENTROPY_MIN_LEN = 12
|
||||
_HIGH_ENTROPY = 3.2
|
||||
_NXDOMAIN_RATIO = 0.3
|
||||
_MANY_SUBDOMAINS = 30
|
||||
_RANDOM_MIN_LEN = 16
|
||||
_RANDOM_ENTROPY = 3.2
|
||||
_DGA_MIN_DISTINCT = 8
|
||||
_NXDOMAIN_RATIO = 0.35
|
||||
_NXDOMAIN_MIN_RESPONSES = 20
|
||||
_MANY_SUBDOMAINS = 40
|
||||
_MANY_SUBDOMAINS_RANDOM_SHARE = 0.3
|
||||
_TUNNEL_QTYPES = {"TXT", "NULL"}
|
||||
_TXT_TUNNEL_MIN = 20
|
||||
_TXT_TUNNEL_RATIO = 0.5
|
||||
|
||||
# Public resolvers commonly hard-coded in IoT firmware.
|
||||
_PUBLIC_RESOLVERS = {"8.8.8.8", "8.8.4.4", "1.1.1.1", "1.0.0.1", "9.9.9.9", "149.112.112.112",
|
||||
"208.67.222.222", "208.67.220.220", "114.114.114.114", "223.5.5.5", "223.6.6.6"}
|
||||
|
||||
|
||||
def _is_random_label(label: str) -> bool:
|
||||
"""Whether one DNS label looks algorithmically generated or data-encoding."""
|
||||
if len(label) >= _LONG_LABEL:
|
||||
return True
|
||||
if len(label) >= _RANDOM_MIN_LEN and _entropy(label) >= _RANDOM_ENTROPY:
|
||||
has_digit = any(c.isdigit() for c in label)
|
||||
has_alpha = any(c.isalpha() for c in label)
|
||||
return has_digit and has_alpha
|
||||
return False
|
||||
|
||||
|
||||
@register
|
||||
class DnsAnomalyDetector(BaseDetector):
|
||||
name = "DnsAnomalyDetector"
|
||||
description = "Detects DNS tunneling, DGA-like names, and high NXDOMAIN rates."
|
||||
description = "Detects DNS tunneling, DGA-like names, high NXDOMAIN rates and resolver bypass."
|
||||
|
||||
def detect(self, ctx: CaptureContext) -> Iterable[Finding]:
|
||||
findings: list[Finding] = []
|
||||
@@ -31,82 +54,158 @@ class DnsAnomalyDetector(BaseDetector):
|
||||
return findings
|
||||
|
||||
nx = 0
|
||||
total = 0
|
||||
responses = 0
|
||||
queries = 0
|
||||
txt_like = 0
|
||||
subdomains_per_domain: dict[str, set[str]] = defaultdict(set)
|
||||
packets_per_domain: dict[str, list] = defaultdict(list)
|
||||
random_subs_per_domain: dict[str, set[str]] = defaultdict(set)
|
||||
random_meta: dict[str, dict] = {}
|
||||
long_label_hits: dict[str, dict] = {}
|
||||
nx_packets: list = []
|
||||
nx_clients: dict[str, int] = defaultdict(int)
|
||||
txt_packets: list = []
|
||||
flagged_random: set[str] = set()
|
||||
resolver_use: dict[tuple, dict] = {}
|
||||
|
||||
for q in ctx.dns_queries:
|
||||
name = (q.query_name or "").rstrip(".").lower()
|
||||
if not name:
|
||||
if not name or q.transport in ("mdns", "llmnr"):
|
||||
continue
|
||||
total += 1
|
||||
if (q.rcode or "").upper() == "NXDOMAIN":
|
||||
nx += 1
|
||||
if q.packet_number:
|
||||
nx_packets.append(q.packet_number)
|
||||
if q.is_response:
|
||||
responses += 1
|
||||
if (q.rcode or "").upper() == "NXDOMAIN":
|
||||
nx += 1
|
||||
if q.packet_number:
|
||||
nx_packets.append(q.packet_number)
|
||||
if q.dst_ip:
|
||||
nx_clients[q.dst_ip] += 1
|
||||
continue
|
||||
|
||||
queries += 1
|
||||
if (q.qtype or "").upper() in _TUNNEL_QTYPES:
|
||||
txt_like += 1
|
||||
if q.packet_number:
|
||||
txt_packets.append(q.packet_number)
|
||||
if q.dst_ip and q.src_ip and not ctx.is_external(q.src_ip):
|
||||
key = (q.src_ip, q.dst_ip)
|
||||
e = resolver_use.setdefault(key, {"count": 0, "first": q.timestamp, "pkt": q.packet_number})
|
||||
e["count"] += 1
|
||||
|
||||
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)
|
||||
if allowlist.is_benign(name):
|
||||
continue
|
||||
sub_labels = labels[:-2]
|
||||
if not sub_labels:
|
||||
continue
|
||||
subdomain = ".".join(sub_labels)
|
||||
subdomains_per_domain[reg_domain].add(subdomain)
|
||||
if q.packet_number:
|
||||
packets_per_domain[reg_domain].append(q.packet_number)
|
||||
longest = max(sub_labels, key=len)
|
||||
if len(longest) >= _LONG_LABEL and reg_domain not in long_label_hits:
|
||||
long_label_hits[reg_domain] = {
|
||||
"query_name": name, "label_len": len(longest),
|
||||
"packet": q.packet_number, "src": q.src_ip, "dst": q.dst_ip, "ts": q.timestamp,
|
||||
}
|
||||
if any(_is_random_label(lbl) for lbl in sub_labels):
|
||||
random_subs_per_domain[reg_domain].add(subdomain)
|
||||
random_meta.setdefault(reg_domain, {"src": q.src_ip, "dst": q.dst_ip, "ts": q.timestamp})
|
||||
|
||||
# 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:
|
||||
# 1) DGA/tunneling: many distinct random subdomains under one domain.
|
||||
for reg_domain, subs in random_subs_per_domain.items():
|
||||
meta = random_meta.get(reg_domain, {})
|
||||
if len(subs) < _DGA_MIN_DISTINCT:
|
||||
if reg_domain in long_label_hits:
|
||||
continue
|
||||
findings.append(Finding(
|
||||
title=f"Unusually many subdomains under {domain}",
|
||||
title="Randomly-structured DNS name observed",
|
||||
category=Category.DNS_ANOMALY,
|
||||
severity=Severity.HIGH, confidence=Confidence.MEDIUM,
|
||||
severity=Severity.LOW, confidence=Confidence.LOW,
|
||||
source_ip=meta.get("src"), destination_ip=meta.get("dst"),
|
||||
protocol="dns", port=53,
|
||||
timestamp_first_seen=meta.get("ts"), timestamp_last_seen=meta.get("ts"),
|
||||
risk_description=(
|
||||
"A large number of unique subdomains under one domain is a strong "
|
||||
"indicator of DNS tunneling or exfiltration."
|
||||
"A DNS name with a random-looking label was queried under "
|
||||
f"{reg_domain}. In isolation this is a weak indicator; it becomes "
|
||||
"significant only alongside many such names or other anomalies."
|
||||
),
|
||||
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],
|
||||
recommendation="Confirm the domain is a legitimate service; otherwise check it against threat intelligence.",
|
||||
evidence={"registered_domain": reg_domain,
|
||||
"distinct_random_subdomains": len(subs),
|
||||
"examples": sorted(subs)[:5],
|
||||
"dedup_key": f"randname:{reg_domain}"},
|
||||
related_packets=sorted(set(packets_per_domain.get(reg_domain, [])))[:8],
|
||||
))
|
||||
continue
|
||||
findings.append(Finding(
|
||||
title="DNS queries consistent with DGA or tunneling activity",
|
||||
category=Category.DNS_ANOMALY,
|
||||
severity=Severity.HIGH, confidence=Confidence.MEDIUM,
|
||||
source_ip=meta.get("src"), destination_ip=meta.get("dst"),
|
||||
protocol="dns", port=53,
|
||||
timestamp_first_seen=meta.get("ts"), timestamp_last_seen=meta.get("ts"),
|
||||
risk_description=(
|
||||
f"{len(subs)} distinct random-looking subdomains were queried under "
|
||||
f"{reg_domain}. A high number of algorithmically generated names is a "
|
||||
"strong indicator of a domain-generation algorithm or DNS tunneling."
|
||||
),
|
||||
recommendation="Check the domain against threat intelligence and enable DNS tunneling detection and egress controls.",
|
||||
evidence={"registered_domain": reg_domain,
|
||||
"distinct_random_subdomains": len(subs),
|
||||
"examples": sorted(subs)[:5],
|
||||
"dedup_key": f"dga:{reg_domain}"},
|
||||
related_packets=sorted(set(packets_per_domain.get(reg_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:
|
||||
# 2) Long-label tunneling.
|
||||
for reg_domain, hit in long_label_hits.items():
|
||||
if len(random_subs_per_domain.get(reg_domain, ())) >= _DGA_MIN_DISTINCT:
|
||||
continue
|
||||
findings.append(Finding(
|
||||
title="Overlong DNS label suggests tunneling",
|
||||
category=Category.DNS_ANOMALY,
|
||||
severity=Severity.MEDIUM, confidence=Confidence.LOW,
|
||||
source_ip=hit.get("src"), destination_ip=hit.get("dst"),
|
||||
protocol="dns", port=53,
|
||||
timestamp_first_seen=hit.get("ts"), timestamp_last_seen=hit.get("ts"),
|
||||
risk_description=(
|
||||
f"A DNS label of {hit['label_len']} characters was observed under "
|
||||
f"{reg_domain}. Overlong labels are used to encode data in DNS tunnels."
|
||||
),
|
||||
recommendation="Review the destination domain and enable tunneling detection.",
|
||||
evidence={"registered_domain": reg_domain,
|
||||
"query_name": hit["query_name"], "label_length": hit["label_len"],
|
||||
"dedup_key": f"longlabel:{reg_domain}"},
|
||||
related_packets=[p for p in [hit.get("packet")] if p],
|
||||
))
|
||||
|
||||
# 3) Many unique subdomains; HIGH only when many of them look generated.
|
||||
for domain, subs in subdomains_per_domain.items():
|
||||
if len(subs) < _MANY_SUBDOMAINS:
|
||||
continue
|
||||
random_share = len(random_subs_per_domain.get(domain, ())) / len(subs)
|
||||
strong = random_share >= _MANY_SUBDOMAINS_RANDOM_SHARE
|
||||
findings.append(Finding(
|
||||
title=f"Unusually many subdomains under {domain}",
|
||||
category=Category.DNS_ANOMALY,
|
||||
severity=Severity.HIGH if strong else Severity.LOW,
|
||||
confidence=Confidence.MEDIUM if strong else Confidence.LOW,
|
||||
protocol="dns", port=53,
|
||||
risk_description=(
|
||||
f"{len(subs)} unique subdomains were queried under {domain}"
|
||||
+ (f", {round(random_share * 100)} % of them random-looking, a strong indicator of DNS tunneling or exfiltration."
|
||||
if strong else ". Large services use many host names; this is only suspicious if the domain is unexpected for the device.")
|
||||
),
|
||||
recommendation="Review the domain; block it and enable tunneling detection if it is not an expected service.",
|
||||
evidence={"registered_domain": domain, "unique_subdomains": len(subs),
|
||||
"random_share": round(random_share, 2), "examples": sorted(subs)[:5],
|
||||
"dedup_key": f"subtunnel:{domain}"},
|
||||
related_packets=sorted(set(packets_per_domain.get(domain, [])))[:8],
|
||||
))
|
||||
|
||||
# 4) TXT/NULL abuse.
|
||||
if txt_like >= _TXT_TUNNEL_MIN and queries and (txt_like / queries) >= _TXT_TUNNEL_RATIO:
|
||||
findings.append(Finding(
|
||||
title="Unusually many TXT or NULL DNS queries",
|
||||
category=Category.DNS_ANOMALY,
|
||||
@@ -116,31 +215,50 @@ class DnsAnomalyDetector(BaseDetector):
|
||||
"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"},
|
||||
recommendation="Review the requesting source and destination domain, then enforce DNS tunneling detection and egress controls.",
|
||||
evidence={"txt_null_queries": txt_like, "total_queries": queries,
|
||||
"ratio": round(txt_like / queries, 2), "dedup_key": "txt_tunnel"},
|
||||
related_packets=sorted(set(txt_packets))[:8],
|
||||
))
|
||||
|
||||
if total >= 20 and (nx / total) >= _NXDOMAIN_RATIO:
|
||||
# 5) High NXDOMAIN rate, measured over responses only.
|
||||
if responses >= _NXDOMAIN_MIN_RESPONSES and (nx / responses) >= _NXDOMAIN_RATIO:
|
||||
top_client = max(nx_clients.items(), key=lambda kv: kv[1])[0] if nx_clients else None
|
||||
findings.append(Finding(
|
||||
title="High NXDOMAIN rate",
|
||||
category=Category.DNS_ANOMALY,
|
||||
severity=Severity.MEDIUM, confidence=Confidence.MEDIUM,
|
||||
source_ip=top_client,
|
||||
protocol="dns", port=53,
|
||||
risk_description=(
|
||||
"Repeated NXDOMAIN responses may indicate malware testing generated "
|
||||
"command-and-control domains."
|
||||
"A large share of DNS responses were NXDOMAIN. Malware probing generated "
|
||||
"command-and-control domains produces this pattern; so do stale configurations."
|
||||
),
|
||||
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"},
|
||||
recommendation="Review the requesting source and the failed names.",
|
||||
evidence={"nxdomain": nx, "responses": responses,
|
||||
"ratio": round(nx / responses, 2), "dedup_key": "nxdomain_rate"},
|
||||
related_packets=sorted(set(nx_packets))[:8],
|
||||
))
|
||||
|
||||
# 6) Direct use of public resolvers (bypasses the local resolver and its policies).
|
||||
for (client, resolver), e in resolver_use.items():
|
||||
if resolver in _PUBLIC_RESOLVERS or (ctx.is_external(resolver) and ctx.is_device(client)):
|
||||
findings.append(Finding(
|
||||
title=f"DNS sent directly to an external resolver ({resolver})",
|
||||
category=Category.DNS_ANOMALY,
|
||||
severity=Severity.LOW, confidence=Confidence.HIGH,
|
||||
source_ip=client, destination_ip=resolver, protocol="dns", port=53,
|
||||
timestamp_first_seen=e["first"], timestamp_last_seen=e["first"],
|
||||
risk_description=(
|
||||
"The host resolves names through a hard-coded external resolver instead of the "
|
||||
"one assigned by the network. Local DNS filtering and monitoring are bypassed and "
|
||||
"the queries are visible to a third party."
|
||||
),
|
||||
recommendation="Use the DHCP-assigned resolver, or DNS over TLS/HTTPS if privacy is required.",
|
||||
evidence={"queries": e["count"], "dedup_key": f"extresolver:{client}:{resolver}"},
|
||||
related_packets=[p for p in [e["pkt"]] if p],
|
||||
))
|
||||
|
||||
return self._tag(findings)
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Detect potential data exfiltration."""
|
||||
"""Detect unusual outbound data volumes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -7,13 +7,15 @@ from typing import Iterable
|
||||
|
||||
from ..context import CaptureContext
|
||||
from ..findings import Finding, Category, Severity, Confidence
|
||||
from .. import allowlist
|
||||
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
|
||||
# Uploads must clearly dominate the flow; browsing and streaming are download-heavy.
|
||||
_UPLOAD_RATIO = 3.0
|
||||
|
||||
|
||||
@register
|
||||
@@ -37,7 +39,8 @@ class ExfiltrationDetector(BaseDetector):
|
||||
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
|
||||
up = conv.bytes_src_to_dst
|
||||
down = conv.bytes_dst_to_src
|
||||
per_dest_bytes[conv.dst_ip] += up
|
||||
per_dest_count[conv.dst_ip] += 1
|
||||
src_for_dest[conv.dst_ip] = conv.src_ip
|
||||
@@ -50,76 +53,69 @@ class ExfiltrationDetector(BaseDetector):
|
||||
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:
|
||||
if up >= _LARGE_UPLOAD_BYTES and up >= _UPLOAD_RATIO * max(down, 1):
|
||||
benign = allowlist.benign_name_for_ip(ctx, conv.dst_ip)
|
||||
names = sorted(ctx.names_for_ip(conv.dst_ip))[:5]
|
||||
findings.append(Finding(
|
||||
title="Large outbound transfer to an external target",
|
||||
category=Category.EXFILTRATION,
|
||||
severity=Severity.HIGH, confidence=Confidence.MEDIUM,
|
||||
severity=Severity.MEDIUM if benign else Severity.HIGH,
|
||||
confidence=Confidence.LOW if benign else 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."
|
||||
"A large amount of data left the network for an external target in one "
|
||||
"upload-dominated flow. "
|
||||
+ (f"The target belongs to an expected provider ({benign}), e.g. a backup or sync service."
|
||||
if benign else "Verify what was sent and whether the destination is expected.")
|
||||
),
|
||||
recommendation="Validate the destination, content, and authorization.",
|
||||
evidence={"uploaded_bytes": up,
|
||||
evidence={"uploaded_bytes": up, "downloaded_bytes": down,
|
||||
"encrypted": conv.is_encrypted, "target_names": names,
|
||||
"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():
|
||||
benign = allowlist.benign_name_for_ip(ctx, dest)
|
||||
names = sorted(ctx.names_for_ip(dest))[:5]
|
||||
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,
|
||||
severity=Severity.MEDIUM if benign else Severity.HIGH,
|
||||
confidence=Confidence.LOW if benign else 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."
|
||||
"multiple connections, which may indicate gradual data loss."
|
||||
+ (f" The destination resolves to {benign}." if benign else "")
|
||||
),
|
||||
recommendation="Review the destination and enforce egress filtering or DLP.",
|
||||
evidence={"uploaded_bytes_total": total,
|
||||
recommendation="Review the destination and enforce egress filtering.",
|
||||
evidence={"uploaded_bytes_total": total, "target_names": names,
|
||||
"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:
|
||||
elif per_dest_count[dest] >= _MANY_SMALL_TRANSFERS and not benign:
|
||||
findings.append(Finding(
|
||||
title="Many small outbound transfers to one target",
|
||||
title="Many small outbound connections to one target",
|
||||
category=Category.EXFILTRATION,
|
||||
severity=Severity.MEDIUM, confidence=Confidence.LOW,
|
||||
severity=Severity.LOW, 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."
|
||||
"Many short outbound connections to one target may indicate "
|
||||
"segmented uploads or polling."
|
||||
),
|
||||
recommendation="Investigate the connection pattern and destination.",
|
||||
evidence={"transfer_count": per_dest_count[dest],
|
||||
evidence={"transfer_count": per_dest_count[dest], "target_names": names,
|
||||
"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)
|
||||
|
||||
@@ -1,91 +1,149 @@
|
||||
"""Detect insecure or unexpected legacy protocols."""
|
||||
"""Detect clear-text, legacy or unexpectedly exposed protocols.
|
||||
|
||||
Based on what tshark dissected on a flow; port rules are only a fallback.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from typing import Iterable
|
||||
|
||||
from ..context import CaptureContext
|
||||
from ..context import CaptureContext, Conversation
|
||||
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."),
|
||||
# Dissected protocol -> display name, severity, risk, recommendation.
|
||||
_CLEARTEXT_PROTOCOLS = {
|
||||
"telnet": ("Telnet", Severity.CRITICAL, "Telnet transmits commands and credentials in clear text.",
|
||||
"Disable Telnet and use SSH."),
|
||||
"ftp": ("FTP", Severity.HIGH, "FTP transmits data and credentials in clear text.",
|
||||
"Replace FTP with FTPS or SFTP."),
|
||||
"tftp": ("TFTP", Severity.HIGH, "TFTP has no authentication or encryption; it is often used for firmware or configuration transfer.",
|
||||
"Avoid TFTP or restrict it to an isolated provisioning network."),
|
||||
"http": ("HTTP", Severity.MEDIUM, "HTTP content is unencrypted and can be read or modified on the path.",
|
||||
"Require HTTPS for all device and cloud communication."),
|
||||
"pop": ("POP3", Severity.HIGH, "POP3 without TLS exposes messages and passwords.",
|
||||
"Use POP3S or STARTTLS."),
|
||||
"imap": ("IMAP", Severity.HIGH, "IMAP without TLS exposes messages and passwords.",
|
||||
"Use IMAPS or STARTTLS."),
|
||||
"smtp": ("SMTP", Severity.MEDIUM, "SMTP without TLS exposes message content and possibly credentials.",
|
||||
"Use STARTTLS or submission over TLS."),
|
||||
"snmp": ("SNMP", Severity.MEDIUM, "SNMP v1/v2c uses clear-text community strings.",
|
||||
"Use SNMPv3 with authentication and privacy."),
|
||||
"ldap": ("LDAP", Severity.HIGH, "LDAP without TLS exposes directory credentials.",
|
||||
"Use LDAPS or StartTLS."),
|
||||
"mqtt": ("MQTT", Severity.HIGH, "MQTT without TLS exposes topics, payloads and any username/password to observers and allows message injection.",
|
||||
"Use MQTT over TLS (8883) with server verification and per-device credentials or certificates."),
|
||||
"coap": ("CoAP", Severity.HIGH, "CoAP without DTLS has no confidentiality or integrity protection.",
|
||||
"Use CoAP over DTLS (coaps) or OSCORE."),
|
||||
"rtsp": ("RTSP", Severity.MEDIUM, "RTSP control and the media stream are unencrypted; video and credentials can be intercepted.",
|
||||
"Tunnel RTSP through TLS or a VPN and require authentication."),
|
||||
"mbtcp": ("Modbus/TCP", Severity.HIGH, "Modbus/TCP has no authentication; any host on the network can read or write registers.",
|
||||
"Isolate the control network and use Modbus/TCP Security or a gateway with authentication."),
|
||||
"sip": ("SIP", Severity.MEDIUM, "SIP signalling without TLS exposes call metadata and digest challenges.",
|
||||
"Use SIP over TLS and SRTP."),
|
||||
"vnc": ("VNC", Severity.HIGH, "VNC sessions are unencrypted and often weakly authenticated.",
|
||||
"Tunnel VNC through SSH or a VPN."),
|
||||
"xmpp": ("XMPP", Severity.MEDIUM, "XMPP without TLS exposes messages.",
|
||||
"Require STARTTLS."),
|
||||
"irc": ("IRC", Severity.MEDIUM, "IRC without TLS exposes messages; IRC is also a classic botnet control channel.",
|
||||
"Verify the purpose of the connection and require TLS."),
|
||||
"nbns": ("NetBIOS name service", Severity.LOW, "NetBIOS name resolution can be answered by any host and is used for credential relay attacks.",
|
||||
"Disable NetBIOS over TCP/IP where it is not required."),
|
||||
"llmnr": ("LLMNR", Severity.LOW, "LLMNR queries can be answered by any host and are used for credential relay attacks.",
|
||||
"Disable LLMNR."),
|
||||
"syslog": ("Syslog", Severity.LOW, "Syslog over UDP is unencrypted and unauthenticated.",
|
||||
"Use syslog over TLS."),
|
||||
}
|
||||
|
||||
# Port fallback for flows carrying data that no dissector recognised.
|
||||
_RISKY_PORTS = {
|
||||
23: ("Telnet (port 23)", Severity.CRITICAL, "Traffic on the Telnet port was not dissected but carries application data.",
|
||||
"Disable Telnet and use SSH."),
|
||||
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."),
|
||||
514: ("rsh", Severity.HIGH, "Remote shell 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."),
|
||||
5432: ("PostgreSQL", 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 and exposes cached data.", "Bind locally and restrict access."),
|
||||
27017: ("MongoDB", Severity.HIGH, "MongoDB without authentication or TLS exposes data.", "Enable authentication and TLS, then restrict access."),
|
||||
5900: ("VNC (port 5900)", Severity.HIGH, "VNC sessions are unencrypted and often weakly authenticated.", "Tunnel VNC through SSH or a VPN."),
|
||||
1883: ("MQTT (port 1883)", Severity.HIGH, "Traffic on the MQTT port was not dissected; unencrypted MQTT is likely.", "Use MQTT over TLS (8883)."),
|
||||
502: ("Modbus/TCP (port 502)", Severity.HIGH, "Traffic on the Modbus port was not dissected; unauthenticated control traffic is likely.", "Isolate the control network."),
|
||||
}
|
||||
|
||||
_ENCRYPTED_MARKERS = {"tls", "ssl", "dtls", "quic", "ssh"}
|
||||
|
||||
|
||||
@register
|
||||
class InsecureProtocolDetector(BaseDetector):
|
||||
name = "InsecureProtocolDetector"
|
||||
description = "Detects clear-text and legacy protocols on known service ports."
|
||||
description = "Detects clear-text and legacy protocols based on dissected traffic, plus exposed device services."
|
||||
|
||||
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)
|
||||
# Aggregate per (protocol, server, port) so every exposed service is listed once.
|
||||
used: dict[tuple, dict] = {}
|
||||
|
||||
for port, e in used.items():
|
||||
name, sev, risk, rec = _RISKY_PORTS[port]
|
||||
def note(key, conv: Conversation, client, server):
|
||||
e = used.setdefault(key, {"clients": set(), "packets": 0, "bytes": 0, "first": None,
|
||||
"last": None, "pkts": [], "streams": set(), "server": server})
|
||||
e["clients"].add(client)
|
||||
e["packets"] += conv.packets
|
||||
e["bytes"] += conv.payload_bytes
|
||||
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 conv in ctx.conversations:
|
||||
if conv.payload_bytes <= 0:
|
||||
continue
|
||||
if conv.app_protocols & _ENCRYPTED_MARKERS:
|
||||
continue # STARTTLS upgraded flows or TLS on a non-standard port.
|
||||
matched = False
|
||||
for proto in conv.app_protocols:
|
||||
if proto in _CLEARTEXT_PROTOCOLS:
|
||||
if proto in ("nbns", "llmnr") and not conv.answered:
|
||||
continue
|
||||
note((proto, conv.dst_ip, conv.dst_port), conv, conv.src_ip, conv.dst_ip)
|
||||
matched = True
|
||||
if not matched and not conv.app_protocols and conv.dst_port in _RISKY_PORTS and conv.answered:
|
||||
note((f"port:{conv.dst_port}", conv.dst_ip, conv.dst_port), conv, conv.src_ip, conv.dst_ip)
|
||||
|
||||
for (proto, server, port), e in used.items():
|
||||
if proto.startswith("port:"):
|
||||
name, sev, risk, rec = _RISKY_PORTS[int(proto.split(":")[1])]
|
||||
proto_label = name.split(" ")[0].lower()
|
||||
else:
|
||||
name, sev, risk, rec = _CLEARTEXT_PROTOCOLS[proto]
|
||||
proto_label = proto
|
||||
clients = sorted(c for c in e["clients"] if c)
|
||||
device_is_server = ctx.is_device(server)
|
||||
device_is_client = any(ctx.is_device(c) for c in clients)
|
||||
role = "offered by the device" if device_is_server else "used by the device" if device_is_client else None
|
||||
title = f"Clear-text protocol: {name} on {server}:{port}"
|
||||
if role:
|
||||
title += f" ({role})"
|
||||
findings.append(Finding(
|
||||
title=f"Insecure protocol: {name} (port {port})",
|
||||
title=title,
|
||||
category=Category.INSECURE_PROTOCOL,
|
||||
severity=sev, confidence=Confidence.HIGH,
|
||||
source_ip=e["src"], destination_ip=e["dst"],
|
||||
protocol=name.lower(), port=port,
|
||||
source_ip=clients[0] if clients else None, destination_ip=server,
|
||||
protocol=proto_label, 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']}"},
|
||||
evidence={"port": port, "packets": e["packets"], "payload_bytes": e["bytes"],
|
||||
"clients": clients[:10], "client_count": len(clients),
|
||||
"external_server": ctx.is_external(server),
|
||||
"dedup_key": f"insecure:{proto}:{server}:{port}"},
|
||||
related_packets=sorted(set(e["pkts"]))[:8],
|
||||
related_streams=sorted(e["streams"])[:8],
|
||||
))
|
||||
@@ -104,4 +162,61 @@ class InsecureProtocolDetector(BaseDetector):
|
||||
evidence={"smb1_packets": smb1, "dedup_key": "smbv1"},
|
||||
))
|
||||
|
||||
# SSH protocol version 1 banners.
|
||||
for s in ctx.service_events:
|
||||
if s.kind == "ssh_banner" and str(s.value or "").upper().startswith("SSH-1."):
|
||||
findings.append(Finding(
|
||||
title="SSH protocol version 1 offered",
|
||||
category=Category.INSECURE_PROTOCOL,
|
||||
severity=Severity.HIGH, confidence=Confidence.HIGH,
|
||||
source_ip=s.src_ip, destination_ip=s.dst_ip, protocol="ssh", port=s.dst_port,
|
||||
timestamp_first_seen=s.timestamp, timestamp_last_seen=s.timestamp,
|
||||
risk_description="SSH-1 has known cryptographic weaknesses and must not be used.",
|
||||
recommendation="Disable SSH-1 and allow only SSH-2 with modern algorithms.",
|
||||
evidence={"banner": s.value, "dedup_key": f"ssh1:{s.src_ip}"},
|
||||
related_packets=[p for p in [s.packet_number] if p],
|
||||
))
|
||||
|
||||
# Services the device under test exposes on the network (completeness, INFO).
|
||||
if ctx.device_ip:
|
||||
offered: dict[int, set[str]] = defaultdict(set)
|
||||
for conv in ctx.conversations:
|
||||
if conv.dst_ip == ctx.device_ip and conv.answered and conv.dst_port:
|
||||
offered[conv.dst_port] |= conv.app_protocols
|
||||
for port in sorted(offered):
|
||||
if port in (67, 68, 5353, 1900, 5355, 137, 138):
|
||||
continue # Broadcast/multicast infrastructure, not a listening service.
|
||||
protos = sorted(p for p in offered[port] if p not in ("x509ce", "x509sat", "pkix1implicit", "tcp.segments"))
|
||||
findings.append(Finding(
|
||||
title=f"Device offers a network service on port {port}",
|
||||
category=Category.INSECURE_PROTOCOL,
|
||||
severity=Severity.INFO, confidence=Confidence.HIGH,
|
||||
destination_ip=ctx.device_ip, port=port,
|
||||
protocol=(protos[0] if protos else None),
|
||||
risk_description=(
|
||||
"The device accepted connections on this port. Every listening service "
|
||||
"is attack surface and should be known and justified."
|
||||
),
|
||||
recommendation="Confirm the service is required, authenticated and encrypted; disable it otherwise.",
|
||||
evidence={"port": port, "protocols": protos, "dedup_key": f"devsvc:{port}"},
|
||||
))
|
||||
# UPnP/SSDP announcements from the device.
|
||||
ssdp = [s for s in ctx.service_events if s.protocol == "ssdp" and s.src_ip == ctx.device_ip]
|
||||
if ssdp:
|
||||
findings.append(Finding(
|
||||
title="Device announces itself via UPnP/SSDP",
|
||||
category=Category.INSECURE_PROTOCOL,
|
||||
severity=Severity.LOW, confidence=Confidence.HIGH,
|
||||
source_ip=ctx.device_ip, protocol="ssdp", port=1900,
|
||||
timestamp_first_seen=ssdp[0].timestamp, timestamp_last_seen=ssdp[-1].timestamp,
|
||||
risk_description=(
|
||||
"UPnP exposes a device description and control endpoints to every host on "
|
||||
"the network; UPnP control interfaces are frequently unauthenticated."
|
||||
),
|
||||
recommendation="Disable UPnP if not required, or restrict it to trusted hosts.",
|
||||
evidence={"announcements": len(ssdp), "location": ssdp[0].value, "server": ssdp[0].detail,
|
||||
"dedup_key": "device_ssdp"},
|
||||
related_packets=sorted({s.packet_number for s in ssdp if s.packet_number})[:8],
|
||||
))
|
||||
|
||||
return self._tag(findings)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Detect malware, command-and-control, and beaconing behavior."""
|
||||
"""Detect command-and-control-like beaconing and suspicious user agents."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -8,6 +8,7 @@ from typing import Iterable, Optional
|
||||
|
||||
from ..context import CaptureContext
|
||||
from ..findings import Finding, Category, Severity, Confidence
|
||||
from .. import allowlist
|
||||
from .base import BaseDetector, register
|
||||
|
||||
# Scanner and attack-tool user agents provide a strong signal.
|
||||
@@ -19,10 +20,16 @@ _SCANNER_UA = (
|
||||
_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
|
||||
# Beacons repeat on a human time scale; sub-second pacing is just bulk traffic.
|
||||
_MIN_INTERVAL_S = 5.0
|
||||
_MAX_INTERVAL_S = 6 * 3600
|
||||
# Infrastructure protocols poll periodically by design.
|
||||
_PERIODIC_PORTS = {123, 53, 67, 68, 1900, 5353, 5355}
|
||||
# Bulk flows are downloads or streams, not beacons.
|
||||
_BULK_BYTES = 2 * 1024 * 1024
|
||||
|
||||
|
||||
@register
|
||||
@@ -32,10 +39,8 @@ class MalwareBeaconingDetector(BaseDetector):
|
||||
|
||||
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]:
|
||||
@@ -43,18 +48,20 @@ class MalwareBeaconingDetector(BaseDetector):
|
||||
findings: list[Finding] = []
|
||||
reported: set[tuple] = set()
|
||||
|
||||
# First evaluate multiple timestamps captured within a single flow.
|
||||
# First evaluate client-side activity inside a single long-lived flow.
|
||||
for conv in ctx.conversations:
|
||||
ext_ip = _external_of(ctx, conv.src_ip, conv.dst_ip)
|
||||
if not ext_ip:
|
||||
if not ext_ip or conv.bytes >= _BULK_BYTES:
|
||||
continue
|
||||
port = conv.dst_port if ext_ip == conv.dst_ip else conv.src_port
|
||||
if port in _PERIODIC_PORTS:
|
||||
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
|
||||
internal = conv.src_ip if ext_ip == conv.dst_ip else conv.dst_ip
|
||||
f = self._beacon_finding(
|
||||
ts, src=internal, dst=ext_ip, protocol=conv.protocol, port=port,
|
||||
ctx, _cluster_bursts(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),
|
||||
)
|
||||
@@ -68,18 +75,15 @@ class MalwareBeaconingDetector(BaseDetector):
|
||||
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:
|
||||
if not (internal and external) or conv.first_seen is None or port in _PERIODIC_PORTS:
|
||||
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])
|
||||
|
||||
@@ -88,7 +92,7 @@ class MalwareBeaconingDetector(BaseDetector):
|
||||
continue
|
||||
internal, external, port = key
|
||||
f = self._beacon_finding(
|
||||
sorted(first_seens), src=internal, dst=external,
|
||||
ctx, sorted(first_seens), src=internal, dst=external,
|
||||
protocol=meta[key]["protocol"], port=port,
|
||||
streams=sorted(meta[key]["streams"]),
|
||||
packets=meta[key]["packets"],
|
||||
@@ -99,39 +103,55 @@ class MalwareBeaconingDetector(BaseDetector):
|
||||
|
||||
return findings
|
||||
|
||||
def _beacon_finding(self, ts: list[float], *, src, dst, protocol, port,
|
||||
def _beacon_finding(self, ctx: CaptureContext, 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:
|
||||
if mean < _MIN_INTERVAL_S or mean > _MAX_INTERVAL_S:
|
||||
return None
|
||||
cv = statistics.pstdev(intervals) / mean
|
||||
if cv > _MAX_CV:
|
||||
return None
|
||||
conf = Confidence.MEDIUM if cv > 0.1 else Confidence.HIGH
|
||||
|
||||
names = sorted(ctx.names_for_ip(dst))
|
||||
benign = allowlist.benign_name_for_ip(ctx, dst)
|
||||
# Known provider: expected keep-alive. Unnamed address: worth a look.
|
||||
if benign:
|
||||
sev, conf = Severity.LOW, Confidence.LOW
|
||||
note = f"The target resolves to an expected provider ({benign}); this is most likely keep-alive or telemetry traffic."
|
||||
elif names:
|
||||
sev = Severity.MEDIUM
|
||||
note = "The target has a DNS name; verify that the service is expected for this device."
|
||||
else:
|
||||
sev = Severity.HIGH
|
||||
note = "The target was contacted by IP address without a DNS lookup, which is unusual for legitimate cloud services."
|
||||
|
||||
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",
|
||||
"target_names": names[:5],
|
||||
"dedup_key": f"beacon:{src}:{dst}:{port}",
|
||||
}
|
||||
return Finding(
|
||||
title="Periodic beaconing to an external target",
|
||||
title="Periodic connections to an external target",
|
||||
category=Category.MALWARE_BEACONING,
|
||||
severity=Severity.HIGH, confidence=conf,
|
||||
severity=sev, 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."
|
||||
"The host contacts an external target at highly regular intervals "
|
||||
f"(about every {round(mean)} s). Regular check-ins are characteristic of "
|
||||
"command-and-control beaconing, but also of legitimate telemetry. " + note
|
||||
),
|
||||
recommendation=(
|
||||
"Check the target against threat intelligence, isolate the device, and "
|
||||
"investigate it for compromise."
|
||||
"Confirm the target belongs to the device vendor or an expected service. "
|
||||
"If unknown, check it against threat intelligence and isolate the device."
|
||||
),
|
||||
evidence=evidence,
|
||||
related_packets=sorted({p for p in (packets or []) if p is not None}),
|
||||
@@ -182,6 +202,17 @@ class MalwareBeaconingDetector(BaseDetector):
|
||||
return findings
|
||||
|
||||
|
||||
def _cluster_bursts(ts: list[float], gap: float = 2.0) -> list[float]:
|
||||
"""Collapse packet bursts into one event so packet pacing is not mistaken for periodicity."""
|
||||
if not ts:
|
||||
return ts
|
||||
out = [ts[0]]
|
||||
for t in ts[1:]:
|
||||
if t - out[-1] >= gap:
|
||||
out.append(t)
|
||||
return out
|
||||
|
||||
|
||||
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):
|
||||
|
||||
@@ -5,74 +5,177 @@ from __future__ import annotations
|
||||
from collections import defaultdict
|
||||
from typing import Iterable
|
||||
|
||||
from ..context import CaptureContext
|
||||
from ..context import CaptureContext, is_special
|
||||
from ..findings import Finding, Category, Severity, Confidence
|
||||
from .base import BaseDetector, register
|
||||
|
||||
_ARP_REPLY_RATE_PER_MIN = 60
|
||||
_MIN_ARP_REPLIES = 100
|
||||
|
||||
|
||||
@register
|
||||
class MitmDetector(BaseDetector):
|
||||
name = "MitmDetector"
|
||||
description = "Detects ARP spoofing, duplicate IP mappings, and ARP floods."
|
||||
description = "Detects ARP conflicts, ARP floods, rogue DHCP servers and ICMP redirects."
|
||||
|
||||
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] = {}
|
||||
# Map each IP address to MAC addresses that claimed it, in time order.
|
||||
ip_to_macs: dict[str, dict[str, float]] = defaultdict(dict)
|
||||
packets_for_ip: dict[str, list[int]] = defaultdict(list)
|
||||
|
||||
def claim(ip, mac, ts, pnum):
|
||||
if not ip or not mac or is_special(ip):
|
||||
return
|
||||
mac = mac.lower()
|
||||
if mac in ("ff:ff:ff:ff:ff:ff", "00:00:00:00:00:00") or mac.startswith(("01:00:5e", "33:33")):
|
||||
return
|
||||
first = ip_to_macs[ip].get(mac)
|
||||
if first is None or (ts is not None and ts < first):
|
||||
ip_to_macs[ip][mac] = ts if ts is not None else float("inf")
|
||||
if pnum:
|
||||
packets_for_ip[ip].append(pnum)
|
||||
|
||||
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)
|
||||
if ev.opcode in (1, 2):
|
||||
claim(ev.sender_ip, ev.sender_mac, ev.timestamp, ev.packet_number)
|
||||
|
||||
for ip, host in ctx.hosts.items():
|
||||
if host.macs:
|
||||
ip_to_macs[ip].update(host.macs)
|
||||
if host.is_private and not is_special(ip):
|
||||
for mac in host.macs:
|
||||
claim(ip, mac, host.first_seen, None)
|
||||
|
||||
for ip, macs in ip_to_macs.items():
|
||||
if len(macs) > 1:
|
||||
ordered = sorted(macs.items(), key=lambda kv: kv[1])
|
||||
times = [t for _, t in ordered if t != float("inf")]
|
||||
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),
|
||||
timestamp_first_seen=times[0] if times else None,
|
||||
timestamp_last_seen=times[-1] if times else None,
|
||||
risk_description=(
|
||||
"One IP address was advertised by multiple MAC addresses, a common "
|
||||
"indicator of ARP spoofing or traffic interception."
|
||||
"One IP address was claimed by multiple MAC addresses. This happens with "
|
||||
"ARP spoofing and traffic interception, but also with a DHCP lease that "
|
||||
"moved between devices or a router fail-over."
|
||||
),
|
||||
recommendation=(
|
||||
"Review ARP tables, enable Dynamic ARP Inspection and port security, "
|
||||
"and identify the conflicting host."
|
||||
"Identify both hosts by MAC address, review ARP tables, and enable "
|
||||
"Dynamic ARP Inspection or port security."
|
||||
),
|
||||
evidence={"ip": ip, "macs": sorted(macs), "dedup_key": f"arpdup:{ip}"},
|
||||
evidence={"ip": ip, "macs": [m for m, _ in ordered],
|
||||
"device": ctx.is_device(ip), "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:
|
||||
# ARP reply floods: judge by rate, not by the absolute count in a long capture.
|
||||
replies = [e for e in ctx.arp_events if e.opcode == 2]
|
||||
requests = [e for e in ctx.arp_events if e.opcode == 1]
|
||||
minutes = max(ctx.duration / 60.0, 1.0)
|
||||
if len(replies) >= _MIN_ARP_REPLIES and len(replies) / minutes >= _ARP_REPLY_RATE_PER_MIN \
|
||||
and len(replies) > 2 * max(len(requests), 1):
|
||||
by_sender: dict[str, int] = defaultdict(int)
|
||||
for e in replies:
|
||||
by_sender[e.sender_mac or "?"] += 1
|
||||
top = sorted(by_sender.items(), key=lambda kv: -kv[1])[:3]
|
||||
findings.append(Finding(
|
||||
title="Unusually high number of ARP replies",
|
||||
title="Unusually high ARP reply rate",
|
||||
category=Category.MITM,
|
||||
severity=Severity.MEDIUM, confidence=Confidence.LOW,
|
||||
severity=Severity.MEDIUM, confidence=Confidence.MEDIUM,
|
||||
protocol="arp",
|
||||
timestamp_first_seen=replies[0].timestamp, timestamp_last_seen=replies[-1].timestamp,
|
||||
risk_description=(
|
||||
"A large number of ARP replies may indicate active ARP cache poisoning."
|
||||
"Far more ARP replies than requests were seen at a high rate, which is "
|
||||
"characteristic of 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],
|
||||
evidence={"arp_replies": len(replies), "arp_requests": len(requests),
|
||||
"replies_per_minute": round(len(replies) / minutes, 1),
|
||||
"top_senders": [f"{m} ({n})" for m, n in top],
|
||||
"dedup_key": "arp_reply_flood"},
|
||||
related_packets=sorted({e.packet_number for e in replies if e.packet_number})[:8],
|
||||
))
|
||||
|
||||
# Gratuitous ARP claiming an address that already belongs to another MAC.
|
||||
seen_owner: dict[str, str] = {}
|
||||
for e in sorted(ctx.arp_events, key=lambda e: e.timestamp or 0):
|
||||
if not e.sender_ip or not e.sender_mac or is_special(e.sender_ip):
|
||||
continue
|
||||
owner = seen_owner.setdefault(e.sender_ip, e.sender_mac)
|
||||
if e.is_gratuitous and owner != e.sender_mac:
|
||||
findings.append(Finding(
|
||||
title="Gratuitous ARP overriding an existing address owner",
|
||||
category=Category.MITM,
|
||||
severity=Severity.HIGH, confidence=Confidence.MEDIUM,
|
||||
source_ip=e.sender_ip, protocol="arp",
|
||||
timestamp_first_seen=e.timestamp, timestamp_last_seen=e.timestamp,
|
||||
risk_description=(
|
||||
"A host announced an IP address that another MAC address had been using, "
|
||||
"which redirects traffic for that address to the announcing host."
|
||||
),
|
||||
recommendation="Identify the announcing host and check for interception.",
|
||||
evidence={"ip": e.sender_ip, "previous_mac": owner, "new_mac": e.sender_mac,
|
||||
"dedup_key": f"garp:{e.sender_ip}:{e.sender_mac}"},
|
||||
related_packets=[p for p in [e.packet_number] if p],
|
||||
))
|
||||
|
||||
# More than one DHCP server answering on the segment.
|
||||
servers: dict[str, list] = defaultdict(list)
|
||||
for d in ctx.dhcp_events:
|
||||
if d.message_type in ("offer", "ack") and (d.server_id or d.src_ip):
|
||||
servers[d.server_id or d.src_ip].append(d)
|
||||
if len(servers) > 1:
|
||||
findings.append(Finding(
|
||||
title="Multiple DHCP servers observed",
|
||||
category=Category.MITM,
|
||||
severity=Severity.MEDIUM, confidence=Confidence.MEDIUM,
|
||||
protocol="dhcp", port=67,
|
||||
risk_description=(
|
||||
"Offers or acknowledgements came from more than one DHCP server. A rogue "
|
||||
"DHCP server can hand out a malicious gateway or DNS resolver."
|
||||
),
|
||||
recommendation="Verify every DHCP server on the segment and enable DHCP snooping.",
|
||||
evidence={"servers": sorted(servers), "dedup_key": "dhcp_multi_server"},
|
||||
related_packets=sorted({d.packet_number for evs in servers.values() for d in evs if d.packet_number})[:8],
|
||||
))
|
||||
|
||||
# ICMP redirects and IPv6 router advertisements from unexpected sources.
|
||||
redirects = [s for s in ctx.service_events if s.kind == "redirect"]
|
||||
if redirects:
|
||||
srcs = sorted({s.src_ip for s in redirects if s.src_ip})
|
||||
findings.append(Finding(
|
||||
title="ICMP redirect messages observed",
|
||||
category=Category.MITM,
|
||||
severity=Severity.MEDIUM, confidence=Confidence.MEDIUM,
|
||||
source_ip=srcs[0] if srcs else None, protocol="icmp",
|
||||
timestamp_first_seen=redirects[0].timestamp, timestamp_last_seen=redirects[-1].timestamp,
|
||||
risk_description=(
|
||||
"ICMP redirects change a host's routing table and are a known technique to "
|
||||
"insert a device into the traffic path."
|
||||
),
|
||||
recommendation="Disable acceptance of ICMP redirects on hosts and verify the sender.",
|
||||
evidence={"count": len(redirects), "sources": srcs[:5], "dedup_key": "icmp_redirect"},
|
||||
related_packets=sorted({s.packet_number for s in redirects if s.packet_number})[:8],
|
||||
))
|
||||
ras = [s for s in ctx.service_events if s.kind == "router_advertisement"]
|
||||
ra_sources = sorted({s.src_ip for s in ras if s.src_ip})
|
||||
if len(ra_sources) > 1:
|
||||
findings.append(Finding(
|
||||
title="IPv6 router advertisements from multiple sources",
|
||||
category=Category.MITM,
|
||||
severity=Severity.MEDIUM, confidence=Confidence.LOW,
|
||||
protocol="icmpv6",
|
||||
risk_description=(
|
||||
"More than one host advertises itself as an IPv6 router. A rogue "
|
||||
"advertisement can redirect all IPv6 traffic through an attacker."
|
||||
),
|
||||
recommendation="Verify the legitimate router and enable RA Guard on switches.",
|
||||
evidence={"sources": ra_sources[:5], "dedup_key": "rogue_ra"},
|
||||
related_packets=sorted({s.packet_number for s in ras if s.packet_number})[:8],
|
||||
))
|
||||
|
||||
return self._tag(findings)
|
||||
|
||||
@@ -3,54 +3,77 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import re
|
||||
from typing import Iterable
|
||||
from typing import Iterable, Optional
|
||||
from urllib.parse import parse_qsl, urlsplit
|
||||
|
||||
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]+)")
|
||||
# Common form and JSON fields used for credentials.
|
||||
_USER_FIELDS = ("user", "username", "uname", "login", "email", "uid", "account", "usr", "name")
|
||||
_PASS_FIELDS = ("pass", "passwd", "password", "pwd", "pw", "passcode", "pin", "secret")
|
||||
_FORM_RE = re.compile(r"([A-Za-z0-9_\-\.\[\]]+)=([^&\s]*)")
|
||||
# "Authorization: Basic" in HTTP-like clear-text protocols (RTSP, SIP, ...).
|
||||
_BASIC_HEADER_RE = re.compile(r"(?im)^(?:Proxy-)?Authorization:\s*Basic\s+([A-Za-z0-9+/=]+)")
|
||||
|
||||
_PROTOCOL_ALTERNATIVES = {
|
||||
"ftp": "SFTP or FTPS", "telnet": "SSH", "pop3": "POP3S or STARTTLS",
|
||||
"imap": "IMAPS or STARTTLS", "smtp": "SMTP with STARTTLS or submission over TLS",
|
||||
"mqtt": "MQTT over TLS (port 8883) with client certificates",
|
||||
"snmp": "SNMPv3 with authentication and privacy", "http": "HTTPS",
|
||||
"rtsp": "RTSPS or a VPN", "sip": "SIP over TLS",
|
||||
}
|
||||
|
||||
|
||||
@register
|
||||
class PlaintextCredentialDetector(BaseDetector):
|
||||
name = "PlaintextCredentialDetector"
|
||||
description = "Detects usernames and passwords transmitted without encryption."
|
||||
description = "Detects usernames, passwords and community strings 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],
|
||||
))
|
||||
if not (pa.username or pa.password):
|
||||
continue
|
||||
proto = pa.protocol.lower()
|
||||
if proto == "snmp":
|
||||
title = "Clear-text SNMP community string"
|
||||
risk = ("An SNMPv1/v2c community string was observed. It acts as a password and "
|
||||
"allows anyone on the path to read or change device settings.")
|
||||
else:
|
||||
title = f"Clear-text credentials over {proto.upper()}"
|
||||
risk = ("A username or password was transmitted without encryption. "
|
||||
"A network observer could take over the account.")
|
||||
if pa.outcome == "failure":
|
||||
title += " (login rejected)"
|
||||
evidence = {
|
||||
"username": pa.username,
|
||||
"password": _mask(pa.password),
|
||||
"password_length": len(pa.password) if pa.password else None,
|
||||
"outcome": pa.outcome,
|
||||
"dedup_key": f"plain:{proto}:{pa.src_ip}:{pa.dst_ip}:{pa.username}:{_mask(pa.password)}",
|
||||
}
|
||||
findings.append(Finding(
|
||||
title=title,
|
||||
category=Category.PLAINTEXT_CREDENTIALS,
|
||||
severity=Severity.CRITICAL if proto != "snmp" else Severity.HIGH,
|
||||
confidence=Confidence.HIGH,
|
||||
source_ip=pa.src_ip, destination_ip=pa.dst_ip,
|
||||
protocol=proto, port=pa.dst_port,
|
||||
timestamp_first_seen=pa.timestamp, timestamp_last_seen=pa.timestamp,
|
||||
risk_description=risk,
|
||||
recommendation=(
|
||||
f"Replace {proto.upper()} with {_PROTOCOL_ALTERNATIVES.get(proto, 'an encrypted alternative')}, "
|
||||
"then rotate the affected credential."
|
||||
),
|
||||
evidence=evidence,
|
||||
related_packets=[p for p in [pa.packet_number] if p],
|
||||
related_streams=[s for s in [pa.stream_id] if s is not None],
|
||||
))
|
||||
|
||||
for req in ctx.http_requests:
|
||||
if req.is_tls:
|
||||
@@ -58,59 +81,139 @@ class PlaintextCredentialDetector(BaseDetector):
|
||||
auth = (req.authorization or "").strip()
|
||||
if auth.lower().startswith("basic "):
|
||||
user, pwd = _decode_basic(auth[6:])
|
||||
findings.append(self._basic_finding(
|
||||
req.src_ip, req.dst_ip, req.dst_port, req.timestamp, req.packet_number,
|
||||
user, pwd, host=req.host, uri=req.uri, protocol="http"))
|
||||
|
||||
# Login forms: URL-encoded or JSON bodies, and credentials in query strings.
|
||||
creds = None
|
||||
if req.method and req.method.upper() in ("POST", "PUT", "PATCH") and req.body:
|
||||
creds = _credentials_in_body(req.body, req.content_type)
|
||||
if creds is None and req.uri and "?" in req.uri:
|
||||
creds = _credentials_in_query(req.uri)
|
||||
if creds:
|
||||
user_val, pass_val, where = creds
|
||||
findings.append(Finding(
|
||||
title="Clear-text HTTP Basic authentication",
|
||||
title="Login form submitted over clear-text HTTP",
|
||||
category=Category.PLAINTEXT_CREDENTIALS,
|
||||
severity=Severity.CRITICAL,
|
||||
confidence=Confidence.HIGH,
|
||||
confidence=Confidence.HIGH if user_val else 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=(
|
||||
"HTTP Basic authentication sends credentials with Base64 encoding, "
|
||||
"not encryption, over clear-text HTTP."
|
||||
f"A login was submitted in a clear-text HTTP request ({where}). "
|
||||
"The credentials are visible to network observers."
|
||||
),
|
||||
recommendation="Require HTTPS and rotate the affected password.",
|
||||
recommendation="Serve the login only over HTTPS and rotate the password.",
|
||||
evidence={
|
||||
"host": req.host, "uri": req.uri,
|
||||
"username": user, "password": _mask(pwd),
|
||||
"dedup_key": f"basic:{req.src_ip}:{req.dst_ip}:{user}",
|
||||
"username": user_val, "password": _mask(pass_val),
|
||||
"location": where,
|
||||
"dedup_key": f"form:{req.src_ip}:{req.dst_ip}:{req.uri}:{user_val}",
|
||||
},
|
||||
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],
|
||||
))
|
||||
# Basic authentication in other clear-text protocols with HTTP-like headers.
|
||||
seen_basic: set[str] = set()
|
||||
for payload in ctx.text_payloads:
|
||||
proto = (payload.get("protocol") or "").lower()
|
||||
if proto == "http":
|
||||
continue # Covered via parsed HTTP requests.
|
||||
for token in _BASIC_HEADER_RE.findall(payload.get("text") or ""):
|
||||
key = f"{proto}:{payload.get('src_ip')}:{payload.get('dst_ip')}:{token[:32]}"
|
||||
if key in seen_basic:
|
||||
continue
|
||||
seen_basic.add(key)
|
||||
user, pwd = _decode_basic(token)
|
||||
findings.append(self._basic_finding(
|
||||
payload.get("src_ip"), payload.get("dst_ip"), None, payload.get("timestamp"),
|
||||
payload.get("packet_number"), user, pwd, protocol=proto or "tcp"))
|
||||
|
||||
return self._tag(findings)
|
||||
|
||||
def _basic_finding(self, src, dst, port, ts, pkt, user, pwd, *, protocol, host=None, uri=None) -> Finding:
|
||||
return Finding(
|
||||
title=f"Clear-text Basic authentication over {protocol.upper()}",
|
||||
category=Category.PLAINTEXT_CREDENTIALS,
|
||||
severity=Severity.CRITICAL,
|
||||
confidence=Confidence.HIGH,
|
||||
source_ip=src, destination_ip=dst,
|
||||
protocol=protocol, port=port,
|
||||
timestamp_first_seen=ts, timestamp_last_seen=ts,
|
||||
risk_description=(
|
||||
"Basic authentication only Base64-encodes the credentials; over an unencrypted "
|
||||
"transport they are readable by anyone on the path."
|
||||
),
|
||||
recommendation=f"Require {_PROTOCOL_ALTERNATIVES.get(protocol, 'an encrypted transport')} and rotate the affected password.",
|
||||
evidence={
|
||||
"host": host, "uri": uri,
|
||||
"username": user, "password": _mask(pwd),
|
||||
"dedup_key": f"basic:{protocol}:{src}:{dst}:{user}",
|
||||
},
|
||||
related_packets=[p for p in [pkt] if p],
|
||||
)
|
||||
|
||||
|
||||
def _credentials_in_body(body: str, content_type: Optional[str]):
|
||||
text = body.strip()
|
||||
ct = (content_type or "").lower()
|
||||
# JSON bodies used by most device web interfaces and mobile apps.
|
||||
if text.startswith("{") or "json" in ct:
|
||||
try:
|
||||
data = json.loads(text)
|
||||
except ValueError:
|
||||
data = None
|
||||
if isinstance(data, dict):
|
||||
flat = _flatten_json(data)
|
||||
found = _pick(flat)
|
||||
if found:
|
||||
return found[0], found[1], "JSON body"
|
||||
if "=" in text and (not text.startswith("{")):
|
||||
fields = {k.lower(): v for k, v in _FORM_RE.findall(text)}
|
||||
found = _pick(fields)
|
||||
if found:
|
||||
return found[0], found[1], "form body"
|
||||
return None
|
||||
|
||||
|
||||
def _credentials_in_query(uri: str):
|
||||
try:
|
||||
query = urlsplit(uri).query
|
||||
except ValueError:
|
||||
return None
|
||||
if not query:
|
||||
return None
|
||||
fields = {k.lower(): v for k, v in parse_qsl(query, keep_blank_values=True)}
|
||||
found = _pick(fields)
|
||||
if found:
|
||||
return found[0], found[1], "URL query string"
|
||||
return None
|
||||
|
||||
|
||||
def _flatten_json(data, prefix: str = "", out: Optional[dict] = None) -> dict:
|
||||
out = out if out is not None else {}
|
||||
if isinstance(data, dict):
|
||||
for k, v in data.items():
|
||||
_flatten_json(v, str(k).lower(), out)
|
||||
elif isinstance(data, list):
|
||||
for v in data[:20]:
|
||||
_flatten_json(v, prefix, out)
|
||||
else:
|
||||
if prefix and prefix not in out:
|
||||
out[prefix] = "" if data is None else str(data)
|
||||
return out
|
||||
|
||||
|
||||
def _pick(fields: dict):
|
||||
"""Return (username, password) when a password-like field carries a value."""
|
||||
pass_key = next((k for k in fields if k in _PASS_FIELDS or k.endswith("password") or k.endswith("passwd")), None)
|
||||
if pass_key is None or not fields.get(pass_key):
|
||||
return None
|
||||
user_key = next((k for k in fields if k in _USER_FIELDS or k.endswith("username") or k.endswith("user")), None)
|
||||
return (fields.get(user_key) if user_key else None), fields[pass_key]
|
||||
|
||||
|
||||
def _decode_basic(b64: str):
|
||||
try:
|
||||
|
||||
@@ -1,93 +1,160 @@
|
||||
"""Detect weak, obsolete, or suspicious TLS connections."""
|
||||
"""Detect weak, obsolete, or suspicious TLS configurations.
|
||||
|
||||
Negotiated parameters (ServerHello) are judged strictly; what a client merely
|
||||
offers is reported at lower severity.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from typing import Iterable
|
||||
|
||||
from ..context import CaptureContext
|
||||
from ..context import CaptureContext, TlsSession
|
||||
from ..findings import Finding, Category, Severity, Confidence
|
||||
from .. import tls_tables
|
||||
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")
|
||||
_FAILURE_ALERTS = {"handshake_failure", "bad_certificate", "unknown_ca", "certificate_expired",
|
||||
"certificate_unknown", "unsupported_certificate", "protocol_version",
|
||||
"insufficient_security", "certificate_revoked", "decrypt_error"}
|
||||
|
||||
|
||||
@register
|
||||
class TlsWeaknessDetector(BaseDetector):
|
||||
name = "TlsWeaknessDetector"
|
||||
description = "Detects obsolete TLS versions, weak ciphers, and certificate anomalies."
|
||||
description = "Detects obsolete TLS versions, weak or legacy cipher suites, certificate problems and handshake failures."
|
||||
|
||||
def detect(self, ctx: CaptureContext) -> Iterable[Finding]:
|
||||
findings: list[Finding] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
def emit(key, **kw):
|
||||
if key in seen:
|
||||
return
|
||||
seen.add(key)
|
||||
kw.setdefault("category", Category.TLS_WEAKNESS)
|
||||
kw.setdefault("protocol", "tls")
|
||||
kw["evidence"] = dict(kw.get("evidence") or {}, dedup_key=key)
|
||||
findings.append(Finding(**kw))
|
||||
|
||||
for s in ctx.tls_sessions:
|
||||
target = s.sni or s.dst_ip
|
||||
base_pkt = [p for p in [s.packet_number] if p]
|
||||
pkts = [p for p in [s.packet_number] if p]
|
||||
common = dict(source_ip=s.src_ip, destination_ip=s.dst_ip, port=s.dst_port,
|
||||
timestamp_first_seen=s.timestamp, timestamp_last_seen=s.timestamp,
|
||||
related_packets=pkts)
|
||||
|
||||
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,
|
||||
))
|
||||
# 1) Negotiated version and cipher (ServerHello seen).
|
||||
if s.server_hello_seen and s.version in _WEAK_VERSIONS:
|
||||
emit(f"tlsver:{s.src_ip}:{target}:{s.version}",
|
||||
title=f"Obsolete TLS version negotiated: {s.version}",
|
||||
severity=Severity.HIGH, confidence=Confidence.HIGH,
|
||||
risk_description=("The connection was established with an obsolete protocol version "
|
||||
"that is vulnerable to known attacks."),
|
||||
recommendation="Require at least TLS 1.2 and prefer TLS 1.3 on both sides.",
|
||||
evidence={"version": s.version, "target": target, "cipher": s.cipher}, **common)
|
||||
if s.server_hello_seen and s.cipher:
|
||||
weak = tls_tables.cipher_is_weak(s.cipher)
|
||||
legacy = tls_tables.cipher_is_legacy(s.cipher)
|
||||
if weak:
|
||||
emit(f"tlscipher:{s.src_ip}:{target}:{s.cipher}",
|
||||
title=f"Weak TLS cipher suite negotiated ({weak})",
|
||||
severity=Severity.HIGH, confidence=Confidence.HIGH,
|
||||
risk_description=f"The negotiated cipher suite {s.cipher} is considered broken: {weak}.",
|
||||
recommendation="Allow only AEAD suites (AES-GCM, ChaCha20-Poly1305) with ECDHE key exchange.",
|
||||
evidence={"cipher": s.cipher, "target": target, "version": s.version}, **common)
|
||||
elif legacy:
|
||||
emit(f"tlslegacy:{s.src_ip}:{target}:{s.cipher}",
|
||||
title="Legacy TLS cipher suite negotiated",
|
||||
severity=Severity.LOW, confidence=Confidence.HIGH,
|
||||
risk_description=f"The negotiated cipher suite {s.cipher} is outdated: {legacy}.",
|
||||
recommendation="Prefer TLS 1.3 or TLS 1.2 with ECDHE and AEAD cipher suites.",
|
||||
evidence={"cipher": s.cipher, "target": target, "version": s.version}, **common)
|
||||
|
||||
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,
|
||||
))
|
||||
# 2) Client capabilities (what the device would accept).
|
||||
if s.client_hello_seen:
|
||||
if s.client_version in _WEAK_VERSIONS:
|
||||
emit(f"clientver:{s.src_ip}:{s.client_version}",
|
||||
title=f"Client supports at most {s.client_version}",
|
||||
severity=Severity.MEDIUM, confidence=Confidence.HIGH,
|
||||
risk_description=("The client's highest offered protocol version is obsolete, so every "
|
||||
"connection it makes uses a version with known weaknesses."),
|
||||
recommendation="Update the device TLS stack to support TLS 1.2 or TLS 1.3.",
|
||||
evidence={"client_max_version": s.client_version, "offered": s.client_offered_versions,
|
||||
"target": target}, **common)
|
||||
elif s.client_version == "TLS 1.2" and ctx.is_device(s.src_ip):
|
||||
emit(f"clientno13:{s.src_ip}",
|
||||
title="Device does not offer TLS 1.3",
|
||||
severity=Severity.INFO, confidence=Confidence.HIGH,
|
||||
risk_description="The device negotiates at most TLS 1.2. This is acceptable today but limits forward compatibility.",
|
||||
recommendation="Consider adding TLS 1.3 support in a future firmware.",
|
||||
evidence={"client_max_version": s.client_version, "target": target}, **common)
|
||||
weak_offers = [(c, tls_tables.cipher_is_weak(c)) for c in s.client_offered_ciphers]
|
||||
weak_offers = [(c, r) for c, r in weak_offers if r]
|
||||
if weak_offers:
|
||||
emit(f"clientweak:{s.src_ip}",
|
||||
title="Client offers broken cipher suites",
|
||||
severity=Severity.MEDIUM if ctx.is_device(s.src_ip) else Severity.LOW,
|
||||
confidence=Confidence.HIGH,
|
||||
risk_description=("The client advertises cipher suites with known weaknesses. A "
|
||||
"downgrade or a misconfigured server could select one of them."),
|
||||
recommendation="Remove obsolete cipher suites from the client's TLS configuration.",
|
||||
evidence={"weak_ciphers": [f"{c} ({r})" for c, r in weak_offers][:8],
|
||||
"offered_total": len(s.client_offered_ciphers), "target": target}, **common)
|
||||
if ctx.is_external(s.dst_ip) and not s.sni and s.transport == "tcp":
|
||||
emit(f"nosni:{s.src_ip}:{s.dst_ip}",
|
||||
title="TLS connection to an external host without SNI",
|
||||
severity=Severity.LOW, confidence=Confidence.MEDIUM,
|
||||
risk_description=("The client did not send a server name. Connections by raw IP address "
|
||||
"often skip hostname verification of the server certificate."),
|
||||
recommendation="Connect by host name and validate the certificate against it.",
|
||||
evidence={"target": s.dst_ip, "port": s.dst_port}, **common)
|
||||
|
||||
if s.cert_self_signed or s.cert_expired:
|
||||
problem = []
|
||||
# 3) Certificate properties (visible in TLS 1.2 and earlier).
|
||||
if s.cert_seen:
|
||||
problems = []
|
||||
if s.cert_self_signed:
|
||||
problem.append("self-signed")
|
||||
problems.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,
|
||||
))
|
||||
problems.append("expired")
|
||||
sig = (s.cert_sig_algorithm or "").lower()
|
||||
if sig in ("sha1", "md5", "md2"):
|
||||
problems.append(f"{sig.upper()} signature")
|
||||
if (s.cert_key_type or "").startswith("RSA") and s.cert_key_bits and s.cert_key_bits < 2048:
|
||||
problems.append(f"RSA {s.cert_key_bits}-bit key")
|
||||
if problems:
|
||||
sev = Severity.MEDIUM
|
||||
if s.cert_expired or "self-signed" in problems and ctx.is_external(s.dst_ip):
|
||||
sev = Severity.HIGH if ctx.is_external(s.dst_ip) else Severity.MEDIUM
|
||||
emit(f"tlscert:{target}:{'-'.join(problems)}",
|
||||
title=f"Weak or untrusted server certificate ({', '.join(problems)})",
|
||||
severity=sev, confidence=Confidence.HIGH,
|
||||
risk_description=("The server certificate has properties that prevent proper validation "
|
||||
"or rely on weak cryptography. Clients that still connect probably do not "
|
||||
"validate certificates, which enables interception."),
|
||||
recommendation="Deploy a certificate from a trusted CA with SHA-256 and at least RSA 2048 or ECDSA P-256, and make clients validate it.",
|
||||
evidence={"subject_cn": s.cert_subject_cn, "issuer_cn": s.cert_issuer_cn,
|
||||
"not_after": s.cert_not_after, "signature": s.cert_sig_algorithm,
|
||||
"key": f"{s.cert_key_type} {s.cert_key_bits}" if s.cert_key_type else None,
|
||||
"target": target}, **common)
|
||||
if s.client_cert_seen and ctx.is_device(s.src_ip):
|
||||
emit(f"mtls:{s.src_ip}:{target}",
|
||||
title="Device authenticates with a client certificate (mutual TLS)",
|
||||
severity=Severity.INFO, confidence=Confidence.HIGH,
|
||||
risk_description="The device presented its own certificate during the handshake. This is a strong authentication mechanism and worth documenting.",
|
||||
recommendation="No action required; ensure the device key is stored in protected hardware where possible.",
|
||||
evidence={"target": target, "version": s.version, "cipher": s.cipher}, **common)
|
||||
|
||||
# 4) Handshake failures / alerts.
|
||||
failed = [a for a in s.alerts if a in _FAILURE_ALERTS]
|
||||
if failed:
|
||||
emit(f"tlsalert:{s.src_ip}:{target}:{failed[0]}",
|
||||
title=f"TLS handshake failure ({failed[0]})",
|
||||
severity=Severity.LOW, confidence=Confidence.MEDIUM,
|
||||
risk_description=("A TLS alert aborted the handshake. Repeated failures can indicate "
|
||||
"incompatible configurations, certificate problems or interception attempts."),
|
||||
recommendation="Check certificate validity and the supported cipher/version overlap between client and server.",
|
||||
evidence={"alerts": s.alerts, "target": target}, **common)
|
||||
|
||||
return self._tag(findings)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Detect session cookies, bearer tokens, and API keys."""
|
||||
"""Detect session cookies, bearer tokens, API keys and key material in clear text."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -10,23 +10,34 @@ 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)
|
||||
_JWT_RE = re.compile(r"\beyJ[A-Za-z0-9_\-]{8,}\.[A-Za-z0-9_\-]{8,}\.[A-Za-z0-9_\-]{8,}")
|
||||
_BEARER_RE = re.compile(r"\bBearer\s+([A-Za-z0-9_\-\.=]{16,})", 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,})"
|
||||
r"(?i)\b(api[_\-]?key|apikey|access[_\-]?token|auth[_\-]?token|client[_\-]?secret|"
|
||||
r"x[_\-]api[_\-]key|x[_\-]auth[_\-]token|x[_\-]access[_\-]token|private[_\-]?key|app[_\-]?secret)"
|
||||
r"\"?\s*[=:]\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"(?i)\b(sessionid|session|sid|phpsessid|jsessionid|asp\.net_sessionid|auth|token|"
|
||||
r"remember_token|access_token|connect\.sid)="
|
||||
r"([A-Za-z0-9%_\-\.]{8,})"
|
||||
)
|
||||
_PRIVATE_KEY_RE = re.compile(r"-----BEGIN (?:RSA |EC |DSA |OPENSSH |ENCRYPTED )?PRIVATE KEY-----")
|
||||
# Well-known provider key shapes.
|
||||
_PROVIDER_KEYS = (
|
||||
("aws_access_key", re.compile(r"\b(AKIA|ASIA)[0-9A-Z]{16}\b")),
|
||||
("github_token", re.compile(r"\bgh[pousr]_[A-Za-z0-9]{36,}\b")),
|
||||
("slack_token", re.compile(r"\bxox[abpr]-[A-Za-z0-9\-]{10,}\b")),
|
||||
("google_api_key", re.compile(r"\bAIza[0-9A-Za-z_\-]{35}\b")),
|
||||
("stripe_key", re.compile(r"\b[sr]k_(?:live|test)_[A-Za-z0-9]{16,}\b")),
|
||||
)
|
||||
|
||||
|
||||
@register
|
||||
class TokenSecretDetector(BaseDetector):
|
||||
name = "TokenSecretDetector"
|
||||
description = "Detects JWTs, bearer tokens, API keys, and session cookies."
|
||||
description = "Detects JWTs, bearer tokens, API keys, private keys and session cookies."
|
||||
|
||||
def detect(self, ctx: CaptureContext) -> Iterable[Finding]:
|
||||
findings: list[Finding] = []
|
||||
@@ -34,30 +45,50 @@ class TokenSecretDetector(BaseDetector):
|
||||
|
||||
for payload in ctx.text_payloads:
|
||||
text = payload.get("text") or ""
|
||||
over_http = payload.get("protocol") == "http"
|
||||
proto = (payload.get("protocol") or "").lower()
|
||||
over_http = proto == "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.",
|
||||
"Revoke and rotate the token, shorten its lifetime, and require TLS.",
|
||||
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.",
|
||||
"Rotate the token, shorten its lifetime, and require TLS.",
|
||||
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.",
|
||||
"Rotate the secret, store it securely, and require TLS.",
|
||||
secret=m, conf=Confidence.MEDIUM, seen=seen, kind="apikey"))
|
||||
|
||||
for kind, rx in _PROVIDER_KEYS:
|
||||
for m in rx.findall(text):
|
||||
value = m if isinstance(m, str) else m[0]
|
||||
findings.append(self._mk(
|
||||
payload, f"Clear-text provider credential ({kind.replace('_', ' ')})",
|
||||
"A cloud or service provider credential was transmitted in clear text.",
|
||||
"Revoke the credential at the provider and issue a new one.",
|
||||
secret=value, conf=Confidence.HIGH, seen=seen, kind=kind,
|
||||
severity=Severity.CRITICAL))
|
||||
|
||||
if _PRIVATE_KEY_RE.search(text):
|
||||
findings.append(self._mk(
|
||||
payload, "Private key material transmitted in clear text",
|
||||
"A PEM-encoded private key was observed in an unencrypted flow.",
|
||||
"Treat the key as compromised: revoke it, generate a new key pair and "
|
||||
"provision keys only over an authenticated, encrypted channel.",
|
||||
secret="PRIVATE KEY", conf=Confidence.HIGH, seen=seen, kind="private_key",
|
||||
severity=Severity.CRITICAL))
|
||||
|
||||
# Report session cookies only when the transport is unencrypted.
|
||||
if over_http:
|
||||
for _, m in _SESSION_COOKIE_RE.findall(text):
|
||||
@@ -69,7 +100,8 @@ class TokenSecretDetector(BaseDetector):
|
||||
|
||||
return self._tag([f for f in findings if f is not None])
|
||||
|
||||
def _mk(self, payload, title, risk, rec, *, secret, conf, seen, kind):
|
||||
def _mk(self, payload, title, risk, rec, *, secret, conf, seen, kind,
|
||||
severity: Severity = Severity.HIGH):
|
||||
key = f"{kind}:{secret[:24]}"
|
||||
if key in seen:
|
||||
return None
|
||||
@@ -77,7 +109,7 @@ class TokenSecretDetector(BaseDetector):
|
||||
return Finding(
|
||||
title=title,
|
||||
category=Category.TOKENS_SECRETS,
|
||||
severity=Severity.HIGH, confidence=conf,
|
||||
severity=severity, confidence=conf,
|
||||
source_ip=payload.get("src_ip"), destination_ip=payload.get("dst_ip"),
|
||||
protocol=payload.get("protocol"),
|
||||
timestamp_first_seen=payload.get("timestamp"),
|
||||
|
||||
Reference in New Issue
Block a user