build 4.6 Demo

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