analyze performance upgrade

This commit is contained in:
DaStra3003
2026-09-23 08:01:49 +02:00
parent aabe35fead
commit 7f7b753350
51 changed files with 5827 additions and 1372 deletions
@@ -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
+188 -70
View File
@@ -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):
+133 -30
View File
@@ -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:
+132 -65
View File
@@ -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"),