analyze performance upgrade
This commit is contained in:
@@ -5,15 +5,43 @@ from __future__ import annotations
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
import traceback
|
||||
|
||||
from django.conf import settings
|
||||
from analyzer import AnalysisEngine, extract_urls, extract_hashes
|
||||
from analyzer.extract_pyshark import build_context_from_pcap
|
||||
from django.db import connection
|
||||
from analyzer import AnalysisEngine, extract_urls, extract_hashes, __version__ as analyzer_version
|
||||
from analyzer.extract_tshark import build_context_from_pcap
|
||||
from analyzer.findings import Severity
|
||||
from analyzer import logutil
|
||||
from IoTAnalyzer.models import FindingReport, UrlRaw, HashRaw
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def analysis_log_path(filename: str) -> str | None:
|
||||
"""Per-capture log written next to the upload."""
|
||||
path = resolve_capture_path(filename)
|
||||
if not path:
|
||||
return None
|
||||
return path + ".analysis.log"
|
||||
|
||||
|
||||
def _error_summary(exc: BaseException) -> str:
|
||||
"""Short, user-facing description of a failure with the failing location."""
|
||||
tb = traceback.extract_tb(exc.__traceback__)
|
||||
where = f" ({tb[-1].filename.rsplit('/', 1)[-1]}:{tb[-1].lineno})" if tb else ""
|
||||
return f"{type(exc).__name__}: {exc}{where}"[:1000]
|
||||
|
||||
|
||||
def _set_error(file_id: int, message: str | None) -> None:
|
||||
from IoTAnalyzer.models import File
|
||||
try:
|
||||
File.objects.filter(id=file_id).update(error_message=message)
|
||||
except Exception as exc:
|
||||
logger.warning("Could not store error message for file=%s: %s", file_id, exc)
|
||||
|
||||
_CANDIDATE_DIRS = [
|
||||
str(settings.CAPTURE_UPLOAD_DIR),
|
||||
]
|
||||
@@ -109,38 +137,103 @@ def _persist_artifacts(ctx, raw_dataset_id: int) -> None:
|
||||
logger.exception("Hash persistence failed: %s", exc)
|
||||
|
||||
|
||||
def analyze_capture(filename: str, file_id: int, raw_dataset_id: int | None = None):
|
||||
"""Analyze a capture once and persist findings plus extracted artifacts."""
|
||||
def analyze_capture(filename: str, file_id: int, raw_dataset_id: int | None = None,
|
||||
packet_limit: int | None = None, device_ip: str | None = None):
|
||||
"""Analyze a capture once and persist findings plus extracted artifacts.
|
||||
|
||||
``device_ip`` is the address entered at upload time. It focuses the report on
|
||||
the device under test; when omitted it is looked up from the File record.
|
||||
Raises on failure so the caller can record the reason.
|
||||
"""
|
||||
path = resolve_capture_path(filename)
|
||||
if not path:
|
||||
logger.warning("Capture file not found: %s", filename)
|
||||
return None
|
||||
logger.error("Capture file not found: %s (searched: %s)", filename, _CANDIDATE_DIRS)
|
||||
raise FileNotFoundError(f"Capture file {filename} not found in {_CANDIDATE_DIRS}")
|
||||
|
||||
try:
|
||||
ctx = build_context_from_pcap(path)
|
||||
result = AnalysisEngine().analyze_context(ctx)
|
||||
except Exception as exc:
|
||||
logger.exception("Security analysis failed for %s: %s", filename, exc)
|
||||
return None
|
||||
if packet_limit is None:
|
||||
packet_limit = getattr(settings, "CAPTURE_PACKET_LIMIT", 0)
|
||||
|
||||
try:
|
||||
# Reanalysis replaces the previous report atomically at the file level.
|
||||
FindingReport.objects.filter(file_id=file_id).delete()
|
||||
FindingReport.objects.create(
|
||||
file_id=file_id,
|
||||
result_json=json.dumps(result.to_dict()),
|
||||
finding_count=len(result.findings),
|
||||
max_severity=_max_severity(result),
|
||||
)
|
||||
logger.info("Stored finding report: file_id=%s, findings=%s",
|
||||
file_id, len(result.findings))
|
||||
except Exception as exc:
|
||||
logger.exception("Finding report persistence failed: %s", exc)
|
||||
if device_ip is None:
|
||||
try:
|
||||
from IoTAnalyzer.models import File
|
||||
device_ip = File.objects.filter(id=file_id).values_list("ip_address", flat=True).first()
|
||||
except Exception:
|
||||
device_ip = None
|
||||
|
||||
started = time.time()
|
||||
logger.info("Capture analysis start: file_id=%s path=%s device_ip=%s packet_limit=%s analyzer=%s",
|
||||
file_id, path, device_ip, packet_limit or "unlimited", analyzer_version)
|
||||
ctx = build_context_from_pcap(path, packet_limit=packet_limit or None, device_ip=device_ip)
|
||||
if ctx.truncated:
|
||||
logger.warning("Capture %s analyzed partially: %s", file_id, ctx.truncation_reason)
|
||||
result = AnalysisEngine().analyze_context(ctx)
|
||||
|
||||
# Reanalysis replaces the previous report atomically at the file level.
|
||||
payload = json.dumps(result.to_dict())
|
||||
FindingReport.objects.filter(file_id=file_id).delete()
|
||||
FindingReport.objects.create(
|
||||
file_id=file_id,
|
||||
result_json=payload,
|
||||
finding_count=len(result.findings),
|
||||
max_severity=_max_severity(result),
|
||||
)
|
||||
logger.info("Stored finding report: file_id=%s findings=%s max_severity=%s json=%d bytes",
|
||||
file_id, len(result.findings), _max_severity(result), len(payload))
|
||||
|
||||
_persist_artifacts(ctx, raw_dataset_id)
|
||||
|
||||
logger.info("Capture analysis finished: file_id=%s in %.1fs", file_id, time.time() - started)
|
||||
return result
|
||||
|
||||
|
||||
def run_security_analysis(filename: str, file_id: int):
|
||||
return analyze_capture(filename, file_id, raw_dataset_id=None)
|
||||
try:
|
||||
return analyze_capture(filename, file_id, raw_dataset_id=None)
|
||||
except Exception as exc:
|
||||
logger.exception("Security analysis failed for %s: %s", filename, exc)
|
||||
return None
|
||||
|
||||
|
||||
def start_capture_analysis(file_id: int, filename: str, raw_dataset_id: int | None = None,
|
||||
synchronous: bool = False) -> None:
|
||||
"""Analyze a capture off the request thread and keep File.status current.
|
||||
|
||||
A multi-hundred-megabyte capture takes far longer than any sane HTTP
|
||||
timeout, so the upload response must not wait for the analysis.
|
||||
"""
|
||||
if synchronous:
|
||||
_run_capture_analysis(file_id, filename, raw_dataset_id, close_conn=False)
|
||||
return
|
||||
thread = threading.Thread(
|
||||
target=_run_capture_analysis,
|
||||
args=(file_id, filename, raw_dataset_id),
|
||||
daemon=True,
|
||||
name=f"capture-analysis-{file_id}",
|
||||
)
|
||||
thread.start()
|
||||
|
||||
|
||||
def _run_capture_analysis(file_id: int, filename: str, raw_dataset_id: int | None = None,
|
||||
close_conn: bool = True) -> None:
|
||||
from IoTAnalyzer.models import File
|
||||
|
||||
status = "analysis_failed"
|
||||
error = None
|
||||
log_path = analysis_log_path(filename)
|
||||
try:
|
||||
with logutil.run_log(log_path):
|
||||
_set_error(file_id, None)
|
||||
result = analyze_capture(filename, file_id, raw_dataset_id)
|
||||
status = "analyzed" if result is not None else "analysis_failed"
|
||||
except Exception as exc:
|
||||
error = _error_summary(exc)
|
||||
logger.exception("Capture analysis failed for file=%s: %s", file_id, error)
|
||||
finally:
|
||||
try:
|
||||
File.objects.filter(id=file_id).update(status=status, error_message=error)
|
||||
logger.info("Capture %s completed with status %s%s", file_id, status,
|
||||
f" (log: {log_path})" if log_path else "")
|
||||
except Exception as exc:
|
||||
logger.exception("Could not persist status for file=%s: %s", file_id, exc)
|
||||
# Close only the worker thread's connection, never the request connection.
|
||||
if close_conn:
|
||||
connection.close()
|
||||
|
||||
@@ -84,7 +84,7 @@ def create_capture(
|
||||
filename: str,
|
||||
ip_address: str,
|
||||
) -> File:
|
||||
"""Create capture metadata, analyze the stored file, and update its status."""
|
||||
"""Create capture metadata and hand the analysis to a background worker."""
|
||||
firmware_version = _required_text(firmware_version, "Firmware version", 100)
|
||||
filename = os.path.basename(_required_text(filename, "Capture filename", 255))
|
||||
if os.path.splitext(filename)[1].lower() not in CAPTURE_EXTENSIONS:
|
||||
@@ -113,10 +113,10 @@ def create_capture(
|
||||
raise ValidationError("This capture is already registered for the device.")
|
||||
raw_dataset = RawDataset.objects.create(file=file_entry)
|
||||
|
||||
from .analyze.security_analysis import analyze_capture
|
||||
from .analyze.security_analysis import start_capture_analysis
|
||||
|
||||
result = analyze_capture(filename, file_entry.id, raw_dataset.id)
|
||||
file_entry.status = "analyzed" if result is not None else "analysis_failed"
|
||||
file_entry.save(update_fields=["status"])
|
||||
logger.info("Capture %s completed with status %s", file_entry.id, file_entry.status)
|
||||
# The analysis reads the whole capture with tshark and can run for many
|
||||
# minutes, so it must not block the upload response.
|
||||
start_capture_analysis(file_entry.id, filename, raw_dataset.id)
|
||||
logger.info("Capture %s registered, analysis queued", file_entry.id)
|
||||
return file_entry
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Request logging for the API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
|
||||
logger = logging.getLogger("IoTAnalyzer.request")
|
||||
|
||||
|
||||
class RequestLogMiddleware:
|
||||
"""Log one line per API request with duration, status, user and a request id."""
|
||||
|
||||
def __init__(self, get_response):
|
||||
self.get_response = get_response
|
||||
|
||||
def __call__(self, request):
|
||||
started = time.time()
|
||||
request_id = request.headers.get("X-Request-ID") or uuid.uuid4().hex[:12]
|
||||
request.request_id = request_id
|
||||
try:
|
||||
response = self.get_response(request)
|
||||
except Exception:
|
||||
logger.exception("%s %s failed after %.0f ms [rid=%s]", request.method,
|
||||
request.get_full_path(), (time.time() - started) * 1000, request_id)
|
||||
raise
|
||||
response["X-Request-ID"] = request_id
|
||||
if request.path.startswith("/api/") and request.path != "/api/health":
|
||||
user = getattr(getattr(request, "user", None), "username", None) or "-"
|
||||
level = logging.WARNING if response.status_code >= 500 else \
|
||||
logging.INFO if response.status_code >= 400 else logging.DEBUG
|
||||
logger.log(level, "%s %s -> %s in %.0f ms user=%s [rid=%s]", request.method,
|
||||
request.get_full_path(), response.status_code,
|
||||
(time.time() - started) * 1000, user, request_id)
|
||||
return response
|
||||
@@ -0,0 +1,16 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("IoTAnalyzer", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="file",
|
||||
name="error_message",
|
||||
field=models.TextField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
@@ -128,6 +128,8 @@ class File(models.Model):
|
||||
filename = models.CharField(max_length=255)
|
||||
ip_address = models.GenericIPAddressField()
|
||||
status = models.CharField(max_length=50, default="pending")
|
||||
# Last analysis failure, shown in the UI so nobody has to dig through logs first.
|
||||
error_message = models.TextField(null=True, blank=True)
|
||||
upload_time = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
def to_json(self):
|
||||
@@ -137,6 +139,7 @@ class File(models.Model):
|
||||
"ip_address": self.ip_address,
|
||||
"firmware_version": self.firmware.version,
|
||||
"status": self.status,
|
||||
"error_message": self.error_message,
|
||||
"upload_time": self.upload_time.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
@@ -28,5 +28,6 @@ urlpatterns = [
|
||||
path("api/get_cloud_results/<int:file_id>", views.CloudResultsView.as_view(), name="cloud_results"),
|
||||
path("api/start_analysis/<int:file_id>", views.StartAnalysisView.as_view(), name="start_analysis"),
|
||||
path("api/get_analysis_status/<int:file_id>", views.AnalysisStatusView.as_view(), name="analysis_status"),
|
||||
path("api/get_analysis_log/<int:file_id>", views.AnalysisLogView.as_view(), name="analysis_log"),
|
||||
path("api/get_report/<int:file_id>/<str:fmt>", views.ReportView.as_view(), name="report"),
|
||||
]
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
"""REST endpoints exposed by IoT PreTester 4.6 Demo."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
@@ -16,7 +20,7 @@ from rest_framework.permissions import AllowAny, IsAdminUser
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from .analyze.security_analysis import collect_cloud_results
|
||||
from .analyze.security_analysis import analysis_log_path, collect_cloud_results
|
||||
from .db_create import (
|
||||
create_capture,
|
||||
create_device_type,
|
||||
@@ -48,16 +52,56 @@ from .user_funcs import (
|
||||
)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _validation_error(exc: ValidationError) -> Response:
|
||||
messages = getattr(exc, "messages", [str(exc)])
|
||||
return Response({"error": messages[0]}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
def _tool_version(name: str, args=("-v",)) -> str | None:
|
||||
"""First line of a tool's version output, or None when it is missing."""
|
||||
path = shutil.which(name)
|
||||
if not path:
|
||||
return None
|
||||
try:
|
||||
out = subprocess.run([path, *args], capture_output=True, text=True, timeout=15)
|
||||
first = (out.stdout or out.stderr or "").strip().splitlines()
|
||||
return first[0][:120] if first else path
|
||||
except Exception as exc:
|
||||
return f"{path} (version check failed: {exc})"
|
||||
|
||||
|
||||
class HealthView(APIView):
|
||||
"""Service status including the external tools the analysis depends on."""
|
||||
|
||||
permission_classes = [AllowAny]
|
||||
|
||||
def get(self, request):
|
||||
return Response({"status": "ok", "service": "IoT PreTester 4.6 Demo"})
|
||||
from analyzer import __version__ as analyzer_version
|
||||
|
||||
tshark = _tool_version(os.environ.get("TSHARK_PATH") or "tshark")
|
||||
upload_dir = str(settings.CAPTURE_UPLOAD_DIR)
|
||||
data = {
|
||||
"status": "ok" if tshark else "degraded",
|
||||
"service": "IoT PreTester 4.6 Demo",
|
||||
"analyzer_version": analyzer_version,
|
||||
"tools": {
|
||||
"tshark": tshark,
|
||||
"hashcat": _tool_version("hashcat", ("--version",)),
|
||||
"nmap": _tool_version("nmap", ("--version",)),
|
||||
},
|
||||
"upload_dir": upload_dir,
|
||||
"upload_dir_writable": os.access(upload_dir, os.W_OK),
|
||||
"log_dir": str(getattr(settings, "LOG_DIR", "")),
|
||||
"log_level": getattr(settings, "LOG_LEVEL", "INFO"),
|
||||
"packet_limit": settings.CAPTURE_PACKET_LIMIT or "unlimited",
|
||||
}
|
||||
if not tshark:
|
||||
data["error"] = "tshark is not installed; capture analysis cannot run."
|
||||
logger.error("Health check: tshark not found on PATH")
|
||||
return Response(data)
|
||||
|
||||
|
||||
class LoginView(APIView):
|
||||
@@ -293,15 +337,30 @@ class DeleteDeviceTypeView(APIView):
|
||||
return Response({"status": "success"})
|
||||
|
||||
|
||||
# Capture status -> payload shown while no finding report exists yet.
|
||||
_PENDING_CAPTURE_STATES = {
|
||||
"analyzing": ("analyzing", "The security analysis is still running."),
|
||||
"pending": ("analyzing", "The security analysis is queued."),
|
||||
"analysis_failed": ("failed", "The security analysis failed. The error and the analysis log are shown below."),
|
||||
}
|
||||
|
||||
|
||||
class FindingsView(APIView):
|
||||
def get(self, request, file_id):
|
||||
get_object_or_404(File, id=file_id)
|
||||
capture = get_object_or_404(File, id=file_id)
|
||||
report = FindingReport.objects.filter(file_id=file_id).first()
|
||||
if report is None:
|
||||
return Response(
|
||||
state, message = _PENDING_CAPTURE_STATES.get(
|
||||
capture.status,
|
||||
("empty", "No security analysis is available for this capture."),
|
||||
)
|
||||
response = Response(
|
||||
{
|
||||
"status": "empty",
|
||||
"message": "No security analysis is available for this capture.",
|
||||
"status": state,
|
||||
"capture_status": capture.status,
|
||||
"message": message,
|
||||
"error": capture.error_message,
|
||||
"log_available": _log_exists(capture),
|
||||
"summary": {
|
||||
"total": 0,
|
||||
"by_severity": {},
|
||||
@@ -312,7 +371,47 @@ class FindingsView(APIView):
|
||||
"findings": [],
|
||||
}
|
||||
)
|
||||
return Response(json.loads(report.result_json))
|
||||
response["Cache-Control"] = "no-store"
|
||||
return response
|
||||
payload = json.loads(report.result_json)
|
||||
payload["status"] = "ready"
|
||||
payload["capture_status"] = capture.status
|
||||
payload["error"] = capture.error_message
|
||||
payload["log_available"] = _log_exists(capture)
|
||||
response = Response(payload)
|
||||
response["Cache-Control"] = "no-store"
|
||||
return response
|
||||
|
||||
|
||||
def _log_exists(capture: File) -> bool:
|
||||
path = analysis_log_path(capture.filename)
|
||||
return bool(path and os.path.exists(path))
|
||||
|
||||
|
||||
class AnalysisLogView(APIView):
|
||||
"""Tail of the per-capture analysis log for troubleshooting."""
|
||||
|
||||
def get(self, request, file_id):
|
||||
from analyzer.logutil import tail
|
||||
|
||||
capture = get_object_or_404(File, id=file_id)
|
||||
path = analysis_log_path(capture.filename)
|
||||
try:
|
||||
lines = max(20, min(int(request.GET.get("lines", 400)), 5000))
|
||||
except (TypeError, ValueError):
|
||||
lines = 400
|
||||
if not path or not os.path.exists(path):
|
||||
return Response(
|
||||
{"file_id": capture.id, "status": capture.status, "error": capture.error_message,
|
||||
"log": "", "available": False,
|
||||
"message": "No analysis log exists for this capture yet."},
|
||||
)
|
||||
response = Response({
|
||||
"file_id": capture.id, "status": capture.status, "error": capture.error_message,
|
||||
"available": True, "path": os.path.basename(path), "log": tail(path, lines),
|
||||
})
|
||||
response["Cache-Control"] = "no-store"
|
||||
return response
|
||||
|
||||
|
||||
class CloudResultsView(APIView):
|
||||
|
||||
@@ -47,6 +47,7 @@ MIDDLEWARE = [
|
||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||
"django.contrib.messages.middleware.MessageMiddleware",
|
||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||
"IoTAnalyzer.middleware.RequestLogMiddleware",
|
||||
]
|
||||
|
||||
ROOT_URLCONF = "IoTPreTester.urls"
|
||||
@@ -118,6 +119,71 @@ STATIC_ROOT = BASE_DIR / "staticfiles"
|
||||
CAPTURE_UPLOAD_DIR = Path(
|
||||
os.environ.get("CAPTURE_UPLOAD_DIR", BASE_DIR.parent / "uploads")
|
||||
)
|
||||
|
||||
|
||||
def env_int(name: str, default: int) -> int:
|
||||
try:
|
||||
return int(os.environ.get(name, default))
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
# Bounds for a single capture analysis pass. 0 disables the packet limit and
|
||||
# reads the capture completely; large captures then take correspondingly longer.
|
||||
CAPTURE_PACKET_LIMIT = env_int("CAPTURE_PACKET_LIMIT", 0)
|
||||
CAPTURE_MAX_TEXT_PAYLOADS = env_int("CAPTURE_MAX_TEXT_PAYLOADS", 20000)
|
||||
CAPTURE_MAX_PAYLOAD_CHARS = env_int("CAPTURE_MAX_PAYLOAD_CHARS", 16384)
|
||||
|
||||
# --- Logging -----------------------------------------------------------------
|
||||
# Console output goes to the container log; backend.log keeps a rotating copy.
|
||||
# Every capture analysis additionally writes <capture>.analysis.log next to
|
||||
# the upload, which the API exposes for troubleshooting.
|
||||
LOG_DIR = Path(os.environ.get("LOG_DIR", BASE_DIR.parent / "logs"))
|
||||
LOG_LEVEL = os.environ.get("LOG_LEVEL", "info").upper()
|
||||
try:
|
||||
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
_FILE_LOGGING = True
|
||||
except OSError:
|
||||
_FILE_LOGGING = False
|
||||
|
||||
LOGGING = {
|
||||
"version": 1,
|
||||
"disable_existing_loggers": False,
|
||||
"formatters": {
|
||||
"standard": {
|
||||
"format": "%(asctime)s %(levelname)-7s [%(name)s] %(message)s",
|
||||
"datefmt": "%Y-%m-%d %H:%M:%S",
|
||||
},
|
||||
},
|
||||
"handlers": {
|
||||
"console": {
|
||||
"class": "logging.StreamHandler",
|
||||
"formatter": "standard",
|
||||
"stream": "ext://sys.stdout",
|
||||
},
|
||||
**({
|
||||
"file": {
|
||||
"class": "logging.handlers.RotatingFileHandler",
|
||||
"formatter": "standard",
|
||||
"filename": str(LOG_DIR / "backend.log"),
|
||||
"maxBytes": 10 * 1024 * 1024,
|
||||
"backupCount": 5,
|
||||
"encoding": "utf-8",
|
||||
},
|
||||
} if _FILE_LOGGING else {}),
|
||||
},
|
||||
"root": {
|
||||
"handlers": ["console"] + (["file"] if _FILE_LOGGING else []),
|
||||
"level": LOG_LEVEL,
|
||||
},
|
||||
"loggers": {
|
||||
"django": {"level": "INFO", "propagate": True},
|
||||
"django.request": {"level": "WARNING", "propagate": True},
|
||||
"django.server": {"level": "INFO", "propagate": True},
|
||||
"IoTAnalyzer": {"level": LOG_LEVEL, "propagate": True},
|
||||
"analyzer": {"level": LOG_LEVEL, "propagate": True},
|
||||
},
|
||||
}
|
||||
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
||||
APPEND_SLASH = False
|
||||
|
||||
|
||||
@@ -5,7 +5,10 @@
|
||||
## Main modules
|
||||
|
||||
- `context.py` defines the normalized `CaptureContext` records.
|
||||
- `extract_pyshark.py` builds the context through tshark/pyshark.
|
||||
- `extract_tshark.py` builds the context by streaming `tshark -T ek` output (two passes: metadata, then clear-text payloads).
|
||||
- `tls_tables.py` maps tshark's numeric cipher-suite and version values to names.
|
||||
- `allowlist.py` lists expected provider domains used to lower severity.
|
||||
- `inventory.py` builds the complete observation inventory.
|
||||
- `detectors/` contains one detection concern per module.
|
||||
- `engine.py` runs detectors and assembles the final result.
|
||||
- `scoring.py` deduplicates and prioritizes findings.
|
||||
@@ -21,7 +24,9 @@ From `django_project/`:
|
||||
```bash
|
||||
python -m analyzer.cli ../demo_files/demo_capture.pcapng
|
||||
python -m analyzer.cli capture.pcapng -f json -o report.json
|
||||
python -m analyzer.cli capture.pcapng -f txt --packet-limit 100000
|
||||
python -m analyzer.cli capture.pcapng -f txt --device-ip 192.168.1.50
|
||||
python -m analyzer.cli capture.pcapng -f json --packet-limit 100000
|
||||
python -m analyzer.cli capture.pcapng -v --log-file analysis.log # debug log on stderr and in a file
|
||||
```
|
||||
|
||||
Exit codes are `0` for no high/critical findings, `2` when at least one high/critical finding exists, and `1` for execution errors.
|
||||
@@ -32,8 +37,9 @@ Exit codes are `0` for no high/critical findings, `2` when at least one high/cri
|
||||
from analyzer import AnalysisEngine, render
|
||||
from analyzer.context import CaptureContext
|
||||
|
||||
result = AnalysisEngine().analyze_pcap("capture.pcapng")
|
||||
result = AnalysisEngine().analyze_pcap("capture.pcapng", device_ip="192.168.1.50")
|
||||
print(render(result, "md"))
|
||||
print(result.inventory["device"])
|
||||
|
||||
# Detector logic can also be tested without tshark.
|
||||
synthetic_result = AnalysisEngine().analyze_context(CaptureContext())
|
||||
@@ -42,10 +48,20 @@ synthetic_result = AnalysisEngine().analyze_context(CaptureContext())
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
python -m unittest analyzer.tests.test_analyzer -v
|
||||
python -m unittest analyzer.tests.test_analyzer analyzer.tests.test_regressions -v
|
||||
```
|
||||
|
||||
The unit suite uses synthetic contexts and does not require tshark.
|
||||
The unit suite uses synthetic contexts and does not require tshark. `test_regressions.py` also runs the real extractor against `demo_files/demo_capture.pcapng` when tshark is installed (set `ANALYZER_DEMO_DIR` to point at another demo directory).
|
||||
|
||||
## Configuration
|
||||
|
||||
| Variable | Default | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `TSHARK_PATH` | `tshark` on PATH | tshark binary used for extraction |
|
||||
| `CAPTURE_PACKET_LIMIT` | `0` | Packets to read; `0` reads the whole capture |
|
||||
| `CAPTURE_MAX_TEXT_PAYLOADS` | `50000` | Retained clear-text payload entries |
|
||||
| `CAPTURE_MAX_PAYLOAD_CHARS` | `16384` | Characters kept per payload entry |
|
||||
| `ANALYZER_BENIGN_DOMAINS` | empty | Extra provider domain suffixes for `allowlist.py` |
|
||||
|
||||
## Adding a detector
|
||||
|
||||
|
||||
@@ -1,17 +1,7 @@
|
||||
"""Framework-independent analysis core for IoT PreTester 4.6 Demo.
|
||||
|
||||
The package is independent of Django and supports both backend and CLI use.
|
||||
The pyshark/tshark extractor is loaded only when a capture is analyzed.
|
||||
|
||||
Headless example:
|
||||
from analyzer import AnalysisEngine, render
|
||||
result = AnalysisEngine().analyze_pcap("capture.pcapng")
|
||||
print(render(result, "md"))
|
||||
|
||||
Synthetic-context example without tshark:
|
||||
from analyzer import AnalysisEngine
|
||||
from analyzer.context import CaptureContext
|
||||
result = AnalysisEngine().analyze_context(CaptureContext(...))
|
||||
Usable from Django and the CLI; tshark is only invoked when a capture is read.
|
||||
See README.md for examples.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -27,7 +17,7 @@ from .artifacts import (
|
||||
# Importing the package registers every detector.
|
||||
from . import detectors # noqa: F401
|
||||
|
||||
__version__ = "4.6.0-demo.0"
|
||||
__version__ = "4.6.2-demo.0"
|
||||
|
||||
__all__ = [
|
||||
"Finding", "Category", "Severity", "Confidence",
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Expected provider domains (cloud, CDN, OS, PKI).
|
||||
|
||||
Used to lower severity, never to hide an observation. Extend with the
|
||||
ANALYZER_BENIGN_DOMAINS environment variable (comma-separated suffixes).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
BENIGN_SUFFIXES: set[str] = {
|
||||
# Apple
|
||||
"apple.com", "aaplimg.com", "icloud.com", "apple-dns.net", "cdn-apple.com",
|
||||
"mzstatic.com", "itunes-apple.com", "apple.map.fastly.net", "push.apple.com",
|
||||
"ls-apple.com.akadns.net", "edge.apple", "apple-cloudkit.com",
|
||||
# Akamai
|
||||
"akadns.net", "akamai.net", "akamaiedge.net", "akamai.com", "akamaized.net",
|
||||
"akamaihd.net",
|
||||
# AWS
|
||||
"amazonaws.com", "cloudfront.net", "awsstatic.com", "aws.dev", "amazon.com",
|
||||
# Google / Firebase
|
||||
"googleapis.com", "google.com", "gstatic.com", "ggpht.com", "youtube.com",
|
||||
"crashlytics.com", "firebaseio.com", "firebase.com", "app-measurement.com",
|
||||
"googleusercontent.com", "google-analytics.com", "doubleclick.net", "1e100.net",
|
||||
"googlevideo.com", "gvt1.com", "gvt2.com",
|
||||
# Apple analytics endpoint used by many apps
|
||||
"app-analytics-services.com",
|
||||
# CDNs
|
||||
"fastly.net", "fbcdn.net", "cloudflare.com", "cloudflare.net",
|
||||
"cloudflare-dns.com", "edgekey.net", "edgesuite.net", "llnwd.net", "cdn77.org",
|
||||
"azureedge.net", "aliyuncs.com", "alicdn.com",
|
||||
# Microsoft
|
||||
"microsoft.com", "windows.net", "azure.com", "windowsupdate.com", "office.com",
|
||||
"msftncsi.com", "live.com", "msftconnecttest.com", "office365.com",
|
||||
# PKI / time
|
||||
"digicert.com", "sectigo.com", "letsencrypt.org", "globalsign.com", "godaddy.com",
|
||||
"ntp.org", "pool.ntp.org", "time.apple.com", "time.google.com", "time.windows.com",
|
||||
# Reverse-DNS / special-use
|
||||
"in-addr.arpa", "ip6.arpa", "resolver.arpa", "arpa", "local", "home.arpa",
|
||||
}
|
||||
|
||||
|
||||
def _load_extra() -> set[str]:
|
||||
raw = os.environ.get("ANALYZER_BENIGN_DOMAINS", "")
|
||||
return {s.strip().lower().lstrip(".") for s in raw.split(",") if s.strip()}
|
||||
|
||||
|
||||
_EXTRA = _load_extra()
|
||||
|
||||
|
||||
def is_benign(name: Optional[str]) -> bool:
|
||||
"""Whether a host name belongs to an expected provider."""
|
||||
if not name:
|
||||
return False
|
||||
n = str(name).rstrip(".").lower()
|
||||
return any(n == s or n.endswith("." + s) for s in BENIGN_SUFFIXES | _EXTRA)
|
||||
|
||||
|
||||
def benign_name_for_ip(ctx, ip: Optional[str]) -> Optional[str]:
|
||||
"""Return a benign provider name that resolved to the address, if any."""
|
||||
for name in sorted(ctx.names_for_ip(ip)):
|
||||
if is_benign(name):
|
||||
return name
|
||||
return None
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Extract endpoint and crackable hash artifacts from a capture context.
|
||||
|
||||
Extraction is independent of Django, pyshark, and I/O. Active endpoint probing
|
||||
Extraction is independent of Django, tshark, and I/O. Active endpoint probing
|
||||
and hash cracking remain isolated in ``analyzer.enrich``.
|
||||
"""
|
||||
|
||||
@@ -143,6 +143,38 @@ _URL_RE = re.compile(
|
||||
)
|
||||
|
||||
|
||||
# Endpoints are only trustworthy from cleartext application data. Scanning
|
||||
# encrypted/binary payloads (TLS, QUIC, SSH, raw TCP) with a loose domain regex
|
||||
# invents host-shaped noise from random bytes, so payload extraction is limited
|
||||
# to protocols whose bytes are actually text.
|
||||
_CLEARTEXT_PROTOS = {
|
||||
"http", "http2", "telnet", "ftp", "ftp-data", "ftp_data", "smtp", "pop",
|
||||
"pop3", "imap", "snmp", "tftp", "ldap", "sip", "irc", "syslog", "rtsp",
|
||||
"xmpp", "mqtt", "urlencoded-form", "json", "xml", "data-text-lines",
|
||||
}
|
||||
|
||||
_IPV4_RE = re.compile(r"^\d{1,3}(?:\.\d{1,3}){3}$")
|
||||
|
||||
|
||||
def _looks_like_host(value: str) -> bool:
|
||||
"""Reject byte noise that the URL regex mistakes for a bare domain."""
|
||||
v = value.split("://")[-1].split("/")[0]
|
||||
if _IPV4_RE.match(v):
|
||||
return all(0 <= int(o) <= 255 for o in v.split("."))
|
||||
if len(v) < 4 or ".." in v:
|
||||
return False
|
||||
labels = v.split(".")
|
||||
if len(labels) < 2:
|
||||
return False
|
||||
tld = labels[-1]
|
||||
if not tld.isalpha() or not (2 <= len(tld) <= 24):
|
||||
return False
|
||||
# At least one substantive label — kills noise like "4v.b.di" / "q.p.fh".
|
||||
if not any(len(lbl) >= 3 for lbl in labels[:-1]):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _normalize_host(value: Optional[str]) -> Optional[str]:
|
||||
if not value:
|
||||
return None
|
||||
@@ -187,8 +219,12 @@ def extract_urls(ctx: CaptureContext) -> list[UrlArtifact]:
|
||||
art.observed_ports.add(r.dst_port)
|
||||
|
||||
for p in ctx.text_payloads:
|
||||
if (p.get("protocol") or "").lower() not in _CLEARTEXT_PROTOS:
|
||||
continue
|
||||
text = p.get("text") or ""
|
||||
for m in _URL_RE.findall(text):
|
||||
if not _looks_like_host(m):
|
||||
continue
|
||||
add(m, "payload", src_ip=p.get("src_ip"), dst_ip=p.get("dst_ip"),
|
||||
timestamp=p.get("timestamp"), packet_number=p.get("packet_number"))
|
||||
|
||||
@@ -226,7 +262,11 @@ def extract_hashes(ctx: CaptureContext) -> list[HashArtifact]:
|
||||
timestamp=am.timestamp, packet_number=am.packet_number)
|
||||
|
||||
# Raw 32–64 character hex values use a mode only when the length is known.
|
||||
# Only cleartext payloads are scanned; TLS/QUIC ciphertext is full of 32–64
|
||||
# char hex runs that are not hashes.
|
||||
for p in ctx.text_payloads:
|
||||
if (p.get("protocol") or "").lower() not in _CLEARTEXT_PROTOS:
|
||||
continue
|
||||
text = p.get("text") or ""
|
||||
for m in _HASH_HEX_RE.findall(text):
|
||||
mode, fmt = identify_hash_mode(m)
|
||||
|
||||
@@ -1,25 +1,19 @@
|
||||
"""Headless CLI for IoT PreTester 4.6 Demo.
|
||||
|
||||
Examples:
|
||||
python -m analyzer.cli capture.pcapng
|
||||
python -m analyzer.cli capture.pcapng --format json -o report.json
|
||||
python -m analyzer.cli capture.pcapng --format txt --packet-limit 50000
|
||||
|
||||
Exit codes:
|
||||
0 Analysis completed without high or critical findings
|
||||
2 Analysis completed with at least one high or critical finding
|
||||
1 Capture or runtime dependency error
|
||||
Exit codes: 0 no high/critical findings, 2 at least one, 1 runtime error.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import sys
|
||||
|
||||
from . import __version__
|
||||
from .engine import AnalysisEngine
|
||||
from .findings import Severity
|
||||
from .reporting import render, FORMATS
|
||||
from . import logutil
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
@@ -34,6 +28,10 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
help="Output file (default: standard output)")
|
||||
p.add_argument("--packet-limit", type=int, default=None,
|
||||
help="Maximum packets to read from a large capture")
|
||||
p.add_argument("--device-ip", default=None,
|
||||
help="IP address of the device under test (focuses and attributes findings)")
|
||||
p.add_argument("-v", "--verbose", action="store_true", help="Debug logging on stderr")
|
||||
p.add_argument("--log-file", default=None, help="Write the analysis log to this file")
|
||||
p.add_argument(
|
||||
"--version",
|
||||
action="version",
|
||||
@@ -44,15 +42,22 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
|
||||
def main(argv=None) -> int:
|
||||
args = build_parser().parse_args(argv)
|
||||
logutil.configure_console("DEBUG" if args.verbose else "WARNING")
|
||||
|
||||
try:
|
||||
result = AnalysisEngine().analyze_pcap(args.capture, packet_limit=args.packet_limit)
|
||||
with logutil.run_log(args.log_file):
|
||||
result = AnalysisEngine().analyze_pcap(args.capture, packet_limit=args.packet_limit,
|
||||
device_ip=args.device_ip)
|
||||
except FileNotFoundError as exc:
|
||||
print(f"Error: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
except RuntimeError as exc:
|
||||
print(f"Error: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
except Exception as exc:
|
||||
logging.getLogger("analyzer.cli").exception("Unexpected failure")
|
||||
print(f"Error: {exc!r}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
report = render(result, args.format)
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Normalized capture data consumed by every detector.
|
||||
|
||||
The module is independent of Django and pyshark so detector logic can be tested
|
||||
with synthetic data. ``extract_pyshark.py`` performs the actual PCAP extraction.
|
||||
The module is independent of Django and tshark so detector logic can be tested
|
||||
with synthetic data. ``extract_tshark.py`` performs the actual PCAP extraction.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -19,16 +19,28 @@ from typing import Optional
|
||||
class Host:
|
||||
ip: str
|
||||
macs: set[str] = field(default_factory=set)
|
||||
hostnames: set[str] = field(default_factory=set)
|
||||
hostnames: set[str] = field(default_factory=set) # DHCP, mDNS, reverse DNS names
|
||||
bytes_sent: int = 0
|
||||
bytes_received: int = 0
|
||||
ports_offered: set[int] = field(default_factory=set) # Ports on which the host responds
|
||||
ports_contacted: set[int] = field(default_factory=set) # Ports contacted by the host
|
||||
packets_sent: int = 0
|
||||
packets_received: int = 0
|
||||
first_seen: Optional[float] = None
|
||||
last_seen: Optional[float] = None
|
||||
ports_offered: set[int] = field(default_factory=set) # Ports the host answered on (server role)
|
||||
ports_contacted: set[int] = field(default_factory=set) # Destination ports the host contacted
|
||||
dhcp_vendor_class: Optional[str] = None
|
||||
user_agents: set[str] = field(default_factory=set)
|
||||
# Application protocols seen with this host as an endpoint (from frame.protocols).
|
||||
app_protocols: set[str] = field(default_factory=set)
|
||||
|
||||
@property
|
||||
def is_private(self) -> bool:
|
||||
return _is_private(self.ip)
|
||||
|
||||
@property
|
||||
def packets(self) -> int:
|
||||
return self.packets_sent + self.packets_received
|
||||
|
||||
|
||||
@dataclass
|
||||
class Conversation:
|
||||
@@ -43,23 +55,56 @@ class Conversation:
|
||||
bytes: int = 0
|
||||
bytes_src_to_dst: int = 0
|
||||
bytes_dst_to_src: int = 0
|
||||
packets_src_to_dst: int = 0
|
||||
packets_dst_to_src: int = 0
|
||||
payload_bytes: int = 0 # Application payload only (no headers)
|
||||
first_seen: Optional[float] = None
|
||||
last_seen: Optional[float] = None
|
||||
stream_id: Optional[int] = None
|
||||
timestamps: list[float] = field(default_factory=list) # Used to detect beaconing
|
||||
packet_numbers: list[int] = field(default_factory=list) # Bounded evidence packet list
|
||||
# TCP state summary.
|
||||
syn_count: int = 0 # SYNs sent by src (connection attempts)
|
||||
synack_count: int = 0 # SYN/ACKs sent back by dst (service answered)
|
||||
rst_count: int = 0
|
||||
fin_count: int = 0
|
||||
retransmissions: int = 0
|
||||
# Application protocols dissected on this flow, e.g. {"tls", "http"}.
|
||||
app_protocols: set[str] = field(default_factory=set)
|
||||
|
||||
@property
|
||||
def answered(self) -> bool:
|
||||
"""Whether the destination visibly responded (TCP handshake or return traffic)."""
|
||||
if self.protocol == "tcp":
|
||||
return self.synack_count > 0 or self.packets_dst_to_src > 0
|
||||
return self.packets_dst_to_src > 0
|
||||
|
||||
@property
|
||||
def is_encrypted(self) -> bool:
|
||||
return bool(self.app_protocols & ENCRYPTED_PROTOCOLS)
|
||||
|
||||
|
||||
# Protocols whose payload is ciphertext and must never be scanned as text.
|
||||
ENCRYPTED_PROTOCOLS = {
|
||||
"tls", "ssl", "dtls", "quic", "ssh", "esp", "ipsec", "wireguard", "openvpn",
|
||||
"ikev2", "isakmp", "kerberos", "ntlmssp",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class DnsQuery:
|
||||
query_name: str
|
||||
qtype: Optional[str] = None
|
||||
is_response: bool = False
|
||||
response_ips: list[str] = field(default_factory=list)
|
||||
cnames: list[str] = field(default_factory=list)
|
||||
txt: list[str] = field(default_factory=list)
|
||||
rcode: Optional[str] = None # For example, "NOERROR" or "NXDOMAIN"
|
||||
timestamp: Optional[float] = None
|
||||
src_ip: Optional[str] = None
|
||||
dst_ip: Optional[str] = None
|
||||
resolver: Optional[str] = None
|
||||
transport: Optional[str] = None # "dns", "mdns", "llmnr", "nbns"
|
||||
packet_number: Optional[int] = None
|
||||
|
||||
|
||||
@@ -73,6 +118,7 @@ class HttpRequest:
|
||||
cookies: Optional[str] = None
|
||||
headers: dict[str, str] = field(default_factory=dict)
|
||||
body: Optional[str] = None
|
||||
content_type: Optional[str] = None
|
||||
request_bytes: int = 0
|
||||
is_tls: bool = False # True when only TLS metadata is visible
|
||||
src_ip: Optional[str] = None
|
||||
@@ -88,8 +134,14 @@ class HttpResponse:
|
||||
status_code: Optional[int] = None
|
||||
content_type: Optional[str] = None
|
||||
server: Optional[str] = None
|
||||
content_disposition: Optional[str] = None
|
||||
content_length: Optional[int] = None
|
||||
set_cookie: list[str] = field(default_factory=list)
|
||||
www_authenticate: Optional[str] = None
|
||||
request_uri: Optional[str] = None
|
||||
src_ip: Optional[str] = None
|
||||
dst_ip: Optional[str] = None
|
||||
src_port: Optional[int] = None
|
||||
timestamp: Optional[float] = None
|
||||
stream_id: Optional[int] = None
|
||||
packet_number: Optional[int] = None
|
||||
@@ -97,20 +149,39 @@ class HttpResponse:
|
||||
|
||||
@dataclass
|
||||
class TlsSession:
|
||||
"""One TLS handshake, aggregated per TCP stream (or QUIC connection)."""
|
||||
|
||||
src_ip: Optional[str] = None
|
||||
dst_ip: Optional[str] = None
|
||||
dst_port: Optional[int] = 443
|
||||
version: Optional[str] = None # For example, "TLS 1.2" or "TLS 1.0"
|
||||
cipher: Optional[str] = None # Cipher-suite name
|
||||
src_port: Optional[int] = None
|
||||
transport: str = "tcp" # "tcp" or "quic"
|
||||
version: Optional[str] = None # Negotiated version from the ServerHello
|
||||
cipher: Optional[str] = None # Negotiated cipher-suite name from the ServerHello
|
||||
sni: Optional[str] = None
|
||||
alpn: list[str] = field(default_factory=list)
|
||||
client_version: Optional[str] = None # Highest version the client offered
|
||||
client_offered_versions: list[str] = field(default_factory=list)
|
||||
client_offered_ciphers: list[str] = field(default_factory=list)
|
||||
client_hello_seen: bool = False
|
||||
server_hello_seen: bool = False
|
||||
cert_subject_cn: Optional[str] = None
|
||||
cert_issuer_cn: Optional[str] = None
|
||||
cert_san: list[str] = field(default_factory=list)
|
||||
cert_not_before: Optional[str] = None
|
||||
cert_not_after: Optional[str] = None
|
||||
cert_self_signed: Optional[bool] = None
|
||||
cert_expired: Optional[bool] = None
|
||||
cert_sig_algorithm: Optional[str] = None
|
||||
cert_key_type: Optional[str] = None
|
||||
cert_key_bits: Optional[int] = None
|
||||
cert_seen: bool = False
|
||||
client_cert_seen: bool = False # Client presented a certificate (mutual TLS)
|
||||
alerts: list[str] = field(default_factory=list)
|
||||
timestamp: Optional[float] = None
|
||||
stream_id: Optional[int] = None
|
||||
packet_number: Optional[int] = None
|
||||
packet_numbers: list[int] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -147,6 +218,52 @@ class ArpEvent:
|
||||
packet_number: Optional[int] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class DhcpEvent:
|
||||
message_type: Optional[str] = None # "discover", "offer", "request", "ack", ...
|
||||
client_mac: Optional[str] = None
|
||||
client_ip: Optional[str] = None # Assigned/requested address when known
|
||||
hostname: Optional[str] = None
|
||||
vendor_class: Optional[str] = None
|
||||
server_id: Optional[str] = None
|
||||
src_ip: Optional[str] = None
|
||||
timestamp: Optional[float] = None
|
||||
packet_number: Optional[int] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class MqttEvent:
|
||||
"""MQTT control packet visible in clear text (port 1883 or any unencrypted flow)."""
|
||||
|
||||
msg_type: Optional[str] = None # "connect", "connack", "publish", "subscribe", ...
|
||||
client_id: Optional[str] = None
|
||||
username: Optional[str] = None
|
||||
password: Optional[str] = None
|
||||
topic: Optional[str] = None
|
||||
return_code: Optional[int] = None # CONNACK return code
|
||||
src_ip: Optional[str] = None
|
||||
dst_ip: Optional[str] = None
|
||||
dst_port: Optional[int] = None
|
||||
timestamp: Optional[float] = None
|
||||
stream_id: Optional[int] = None
|
||||
packet_number: Optional[int] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class ServiceEvent:
|
||||
"""Generic clear-text protocol observation (SNMP community, SSH banner, ...)."""
|
||||
|
||||
protocol: str
|
||||
kind: str # "snmp_community", "ssh_banner", "coap_request", ...
|
||||
value: Optional[str] = None
|
||||
detail: Optional[str] = None
|
||||
src_ip: Optional[str] = None
|
||||
dst_ip: Optional[str] = None
|
||||
dst_port: Optional[int] = None
|
||||
timestamp: Optional[float] = None
|
||||
packet_number: Optional[int] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class FileTransfer:
|
||||
filename: Optional[str] = None
|
||||
@@ -157,6 +274,8 @@ class FileTransfer:
|
||||
src_ip: Optional[str] = None
|
||||
dst_ip: Optional[str] = None
|
||||
direction: Optional[str] = None # "download" / "upload"
|
||||
uri: Optional[str] = None
|
||||
host: Optional[str] = None
|
||||
timestamp: Optional[float] = None
|
||||
stream_id: Optional[int] = None
|
||||
packet_number: Optional[int] = None
|
||||
@@ -172,6 +291,23 @@ class PlaintextAuth:
|
||||
src_ip: Optional[str] = None
|
||||
dst_ip: Optional[str] = None
|
||||
dst_port: Optional[int] = None
|
||||
# "success", "failure" or None when the server response was not observed.
|
||||
outcome: Optional[str] = None
|
||||
timestamp: Optional[float] = None
|
||||
stream_id: Optional[int] = None
|
||||
packet_number: Optional[int] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class AuthAttempt:
|
||||
"""A single authentication attempt result used for brute-force detection."""
|
||||
|
||||
protocol: str
|
||||
success: Optional[bool]
|
||||
src_ip: Optional[str] = None # Client
|
||||
dst_ip: Optional[str] = None # Server
|
||||
dst_port: Optional[int] = None
|
||||
username: Optional[str] = None
|
||||
timestamp: Optional[float] = None
|
||||
stream_id: Optional[int] = None
|
||||
packet_number: Optional[int] = None
|
||||
@@ -187,8 +323,19 @@ class CaptureContext:
|
||||
|
||||
path: Optional[str] = None
|
||||
packet_count: int = 0
|
||||
# Set when limits stopped the extraction pass before the capture was fully read.
|
||||
truncated: bool = False
|
||||
truncation_reason: Optional[str] = None
|
||||
truncation_reasons: list[str] = field(default_factory=list)
|
||||
# Non-fatal coverage notes (protocols not dissected, undecoded ports, ...).
|
||||
coverage_notes: list[str] = field(default_factory=list)
|
||||
start_time: Optional[float] = None
|
||||
end_time: Optional[float] = None
|
||||
extractor: Optional[str] = None # e.g. "tshark 4.2.2"
|
||||
|
||||
# The device under test. When set, findings are attributed to it.
|
||||
device_ip: Optional[str] = None
|
||||
device_macs: set[str] = field(default_factory=set)
|
||||
|
||||
hosts: dict[str, Host] = field(default_factory=dict)
|
||||
conversations: list[Conversation] = field(default_factory=list)
|
||||
@@ -198,11 +345,19 @@ class CaptureContext:
|
||||
tls_sessions: list[TlsSession] = field(default_factory=list)
|
||||
auth_material: list[AuthMaterial] = field(default_factory=list)
|
||||
arp_events: list[ArpEvent] = field(default_factory=list)
|
||||
dhcp_events: list[DhcpEvent] = field(default_factory=list)
|
||||
mqtt_events: list[MqttEvent] = field(default_factory=list)
|
||||
service_events: list[ServiceEvent] = field(default_factory=list)
|
||||
file_transfers: list[FileTransfer] = field(default_factory=list)
|
||||
plaintext_auth: list[PlaintextAuth] = field(default_factory=list)
|
||||
auth_attempts: list[AuthAttempt] = field(default_factory=list)
|
||||
|
||||
# protocol_stats: protocol-name -> packet count
|
||||
# protocol_stats: highest-layer protocol name -> packet count (legacy, kept for detectors)
|
||||
protocol_stats: dict[str, int] = field(default_factory=dict)
|
||||
# app_protocol_stats: every protocol in frame.protocols -> packet count
|
||||
app_protocol_stats: dict[str, int] = field(default_factory=dict)
|
||||
icmp_stats: dict[str, int] = field(default_factory=dict)
|
||||
vlan_ids: set[int] = field(default_factory=set)
|
||||
# Generic text payloads with packet metadata for secret detection.
|
||||
text_payloads: list[dict] = field(default_factory=list)
|
||||
|
||||
@@ -225,14 +380,50 @@ class CaptureContext:
|
||||
return False
|
||||
return not _is_private(ip)
|
||||
|
||||
def is_device(self, ip: Optional[str]) -> bool:
|
||||
"""Whether the address belongs to the device under test."""
|
||||
return bool(ip) and self.device_ip is not None and ip == self.device_ip
|
||||
|
||||
def involves_device(self, *ips: Optional[str]) -> Optional[bool]:
|
||||
"""True/False when a device is configured, None when no device is known."""
|
||||
if self.device_ip is None:
|
||||
return None
|
||||
return any(self.is_device(ip) for ip in ips)
|
||||
|
||||
def protocol_count(self, *names: str) -> int:
|
||||
"""Return a case-insensitive packet total for the requested protocols."""
|
||||
"""Return a case-insensitive packet total for the requested protocols.
|
||||
|
||||
Both the highest-layer statistics and the complete protocol chain are
|
||||
consulted so that e.g. "smb" is found even when a higher layer sits on top.
|
||||
"""
|
||||
wanted = {n.lower() for n in names}
|
||||
total = sum(
|
||||
count for proto, count in self.app_protocol_stats.items()
|
||||
if str(proto).lower() in wanted
|
||||
)
|
||||
if total:
|
||||
return total
|
||||
return sum(
|
||||
count for proto, count in self.protocol_stats.items()
|
||||
if str(proto).lower() in wanted
|
||||
)
|
||||
|
||||
def names_for_ip(self, ip: Optional[str]) -> set[str]:
|
||||
"""DNS names that resolved to an address, plus SNI/Host names used with it."""
|
||||
names: set[str] = set()
|
||||
if not ip:
|
||||
return names
|
||||
for q in self.dns_queries:
|
||||
if ip in q.response_ips and q.query_name:
|
||||
names.add(q.query_name.rstrip(".").lower())
|
||||
for t in self.tls_sessions:
|
||||
if t.dst_ip == ip and t.sni:
|
||||
names.add(t.sni.lower())
|
||||
for r in self.http_requests:
|
||||
if r.dst_ip == ip and r.host:
|
||||
names.add(str(r.host).split(":")[0].lower())
|
||||
return names
|
||||
|
||||
|
||||
def _is_private(ip: str) -> bool:
|
||||
try:
|
||||
@@ -247,3 +438,16 @@ def _is_private(ip: str) -> bool:
|
||||
or addr.is_reserved
|
||||
or addr.is_unspecified
|
||||
)
|
||||
|
||||
|
||||
def is_special(ip: Optional[str]) -> bool:
|
||||
"""Broadcast, multicast, unspecified and link-scope addresses that never identify one host."""
|
||||
if not ip:
|
||||
return True
|
||||
if ip in ("0.0.0.0", "255.255.255.255", "::"):
|
||||
return True
|
||||
try:
|
||||
addr = ipaddress.ip_address(ip)
|
||||
except ValueError:
|
||||
return True
|
||||
return addr.is_multicast or addr.is_unspecified or addr.is_loopback
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Detect brute force, scanning, and suspicious file transfers."""
|
||||
"""Detect scanning, repeated authentication failures, and risky file transfers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -10,145 +10,240 @@ from ..context import CaptureContext
|
||||
from ..findings import Finding, Category, Severity, Confidence
|
||||
from .base import BaseDetector, register
|
||||
|
||||
# File extensions that warrant closer inspection.
|
||||
_SUSPICIOUS_EXT = {
|
||||
".exe", ".dll", ".ps1", ".vbs", ".js", ".jar", ".bat", ".cmd", ".scr",
|
||||
".hta", ".lnk", ".docm", ".xlsm", ".pptm", ".zip", ".rar", ".7z", ".iso", ".elf",
|
||||
# Executable or script content that warrants closer inspection.
|
||||
_EXECUTABLE_EXT = {
|
||||
".exe", ".dll", ".ps1", ".vbs", ".jar", ".bat", ".cmd", ".scr", ".hta", ".lnk",
|
||||
".docm", ".xlsm", ".pptm", ".msi", ".apk", ".elf", ".sh", ".py", ".pl",
|
||||
}
|
||||
# Firmware and update images: integrity matters more than malware here.
|
||||
_FIRMWARE_EXT = {
|
||||
".bin", ".img", ".fw", ".ota", ".upd", ".hex", ".rom", ".swu", ".ubi", ".squashfs",
|
||||
".pkg", ".tgz", ".tar", ".gz", ".xz", ".bz2", ".zip", ".7z", ".rar", ".iso",
|
||||
}
|
||||
_EXECUTABLE_CT = ("x-msdownload", "x-dosexec", "x-executable", "portable-executable", "x-elf",
|
||||
"x-msi", "vnd.android.package-archive", "x-sh")
|
||||
_PORTSCAN_PORTS = 15
|
||||
_PORTSCAN_UNANSWERED = 8
|
||||
_HOSTSWEEP_HOSTS = 15
|
||||
_FAILED_LOGINS = 5
|
||||
_HTTP_401_MIN = 8
|
||||
|
||||
|
||||
@register
|
||||
class BruteforceScanFileTransferDetector(BaseDetector):
|
||||
name = "BruteforceScanFileTransferDetector"
|
||||
description = "Detects scans, repeated authentication failures, and risky downloads."
|
||||
description = "Detects scans, repeated authentication failures, and risky or unprotected file transfers."
|
||||
|
||||
def detect(self, ctx: CaptureContext) -> Iterable[Finding]:
|
||||
findings: list[Finding] = []
|
||||
findings.extend(self._scans(ctx))
|
||||
findings.extend(self._auth_failures(ctx))
|
||||
findings.extend(self._files(ctx))
|
||||
return self._tag(findings)
|
||||
|
||||
# ---- scanning --------------------------------------------------------- #
|
||||
|
||||
def _scans(self, ctx: CaptureContext) -> list[Finding]:
|
||||
findings: list[Finding] = []
|
||||
ports_per_pair: dict[tuple, set[int]] = defaultdict(set)
|
||||
hosts_per_src: dict[str, set[str]] = defaultdict(set)
|
||||
unanswered_per_pair: dict[tuple, set[int]] = defaultdict(set)
|
||||
packets_per_pair: dict[tuple, list] = defaultdict(list)
|
||||
hosts_per_src: dict[str, set[str]] = defaultdict(set)
|
||||
unanswered_hosts_per_src: dict[str, set[str]] = defaultdict(set)
|
||||
packets_per_src: dict[str, list] = defaultdict(list)
|
||||
|
||||
for conv in ctx.conversations:
|
||||
if conv.src_ip and conv.dst_ip:
|
||||
if conv.dst_port:
|
||||
ports_per_pair[(conv.src_ip, conv.dst_ip)].add(conv.dst_port)
|
||||
if not (conv.src_ip and conv.dst_ip and conv.dst_port):
|
||||
continue
|
||||
pair = (conv.src_ip, conv.dst_ip)
|
||||
ports_per_pair[pair].add(conv.dst_port)
|
||||
if conv.protocol == "tcp" and conv.syn_count and not conv.answered:
|
||||
unanswered_per_pair[pair].add(conv.dst_port)
|
||||
if conv.packet_numbers:
|
||||
packets_per_pair[pair].append(conv.packet_numbers[0])
|
||||
packets_per_src[conv.src_ip].append(conv.packet_numbers[0])
|
||||
# A host sweep scans the *local* network; fan-out to cloud endpoints is normal.
|
||||
if not ctx.is_external(conv.dst_ip) and conv.dst_ip != conv.src_ip:
|
||||
hosts_per_src[conv.src_ip].add(conv.dst_ip)
|
||||
if conv.packet_numbers:
|
||||
packets_per_pair[(conv.src_ip, conv.dst_ip)].append(conv.packet_numbers[0])
|
||||
packets_per_src[conv.src_ip].append(conv.packet_numbers[0])
|
||||
if not conv.answered:
|
||||
unanswered_hosts_per_src[conv.src_ip].add(conv.dst_ip)
|
||||
|
||||
for (src, dst), ports in ports_per_pair.items():
|
||||
if len(ports) >= _PORTSCAN_PORTS:
|
||||
unanswered = unanswered_per_pair.get((src, dst), set())
|
||||
if len(ports) >= _PORTSCAN_PORTS or len(unanswered) >= _PORTSCAN_UNANSWERED:
|
||||
conf = Confidence.HIGH if len(unanswered) >= _PORTSCAN_UNANSWERED else Confidence.MEDIUM
|
||||
findings.append(Finding(
|
||||
title="Port scan detected",
|
||||
category=Category.BRUTEFORCE_SCAN_FILETRANSFER,
|
||||
severity=Severity.MEDIUM, confidence=Confidence.MEDIUM,
|
||||
source_ip=src, destination_ip=dst,
|
||||
severity=Severity.MEDIUM, confidence=conf,
|
||||
source_ip=src, destination_ip=dst, protocol="tcp",
|
||||
risk_description=(
|
||||
"One host contacted many ports on a target, which is characteristic "
|
||||
"of network reconnaissance."
|
||||
"One host contacted many ports on a target, most of them without an answer, "
|
||||
"which is characteristic of network reconnaissance."
|
||||
),
|
||||
recommendation="Review the source and block unauthorized external scanners.",
|
||||
evidence={"distinct_ports": len(ports), "dedup_key": f"portscan:{src}:{dst}"},
|
||||
recommendation="Review the source and block unauthorized scanners.",
|
||||
evidence={"distinct_ports": len(ports), "unanswered_ports": len(unanswered),
|
||||
"sample_ports": sorted(ports)[:12], "target_is_device": ctx.is_device(dst),
|
||||
"dedup_key": f"portscan:{src}:{dst}"},
|
||||
related_packets=sorted(set(packets_per_pair.get((src, dst), [])))[:8],
|
||||
))
|
||||
|
||||
for src, dsts in hosts_per_src.items():
|
||||
if len(dsts) >= _HOSTSWEEP_HOSTS:
|
||||
unanswered = unanswered_hosts_per_src.get(src, set())
|
||||
if len(unanswered) < len(dsts) * 0.5:
|
||||
continue # Talking to many answering hosts is normal for a gateway or controller.
|
||||
findings.append(Finding(
|
||||
title="Host sweep detected",
|
||||
category=Category.BRUTEFORCE_SCAN_FILETRANSFER,
|
||||
severity=Severity.MEDIUM, confidence=Confidence.LOW,
|
||||
severity=Severity.MEDIUM, confidence=Confidence.MEDIUM,
|
||||
source_ip=src,
|
||||
risk_description=(
|
||||
"One host contacted many different targets, which may indicate a "
|
||||
"network sweep or worm behavior."
|
||||
"One host probed many internal addresses, most of which did not answer, "
|
||||
"which indicates a network sweep or worm-like behaviour."
|
||||
),
|
||||
recommendation="Review the source and investigate internal hosts for compromise.",
|
||||
evidence={"distinct_hosts": len(dsts), "dedup_key": f"sweep:{src}"},
|
||||
recommendation="Review the source and investigate it for compromise.",
|
||||
evidence={"distinct_hosts": len(dsts), "unanswered_hosts": len(unanswered),
|
||||
"dedup_key": f"sweep:{src}"},
|
||||
related_packets=sorted(set(packets_per_src.get(src, [])))[:8],
|
||||
))
|
||||
|
||||
# Repeated HTTP authorization failures are a brute-force indicator.
|
||||
http_401: dict[tuple, int] = defaultdict(int)
|
||||
http_401_pkts: dict[tuple, list] = defaultdict(list)
|
||||
echo = ctx.icmp_stats.get("echo_request", 0)
|
||||
if echo >= 50:
|
||||
findings.append(Finding(
|
||||
title="High number of ICMP echo requests",
|
||||
category=Category.BRUTEFORCE_SCAN_FILETRANSFER,
|
||||
severity=Severity.LOW, confidence=Confidence.LOW, protocol="icmp",
|
||||
risk_description="Many ICMP echo requests can indicate a ping sweep or a monitoring probe.",
|
||||
recommendation="Verify the source of the echo requests.",
|
||||
evidence={"echo_requests": echo, "dedup_key": "icmp_sweep"},
|
||||
))
|
||||
return findings
|
||||
|
||||
# ---- authentication failures ----------------------------------------- #
|
||||
|
||||
def _auth_failures(self, ctx: CaptureContext) -> list[Finding]:
|
||||
findings: list[Finding] = []
|
||||
failures: dict[tuple, list] = defaultdict(list)
|
||||
for a in ctx.auth_attempts:
|
||||
if a.success is False:
|
||||
failures[(a.protocol, a.src_ip, a.dst_ip)].append(a)
|
||||
for (proto, client, server), attempts in failures.items():
|
||||
if len(attempts) < _FAILED_LOGINS:
|
||||
continue
|
||||
users = sorted({a.username for a in attempts if a.username})
|
||||
findings.append(Finding(
|
||||
title=f"Repeated failed {proto.upper()} logins",
|
||||
category=Category.BRUTEFORCE_SCAN_FILETRANSFER,
|
||||
severity=Severity.HIGH, confidence=Confidence.HIGH,
|
||||
source_ip=client, destination_ip=server, protocol=proto, port=attempts[0].dst_port,
|
||||
timestamp_first_seen=attempts[0].timestamp, timestamp_last_seen=attempts[-1].timestamp,
|
||||
risk_description=(
|
||||
f"{len(attempts)} rejected {proto.upper()} login attempts from one client indicate "
|
||||
"password guessing."
|
||||
),
|
||||
recommendation="Enable rate limiting or lockout controls, review the source, and enforce strong credentials.",
|
||||
evidence={"failed_attempts": len(attempts), "usernames": users[:10],
|
||||
"target_is_device": ctx.is_device(server),
|
||||
"dedup_key": f"bruteforce:{proto}:{client}:{server}"},
|
||||
related_packets=sorted({a.packet_number for a in attempts if a.packet_number})[:8],
|
||||
))
|
||||
|
||||
# HTTP: only 401 counts (403 is routine for blocked resources).
|
||||
http_401: dict[tuple, list] = defaultdict(list)
|
||||
for resp in ctx.http_responses:
|
||||
if resp.status_code in (401, 403):
|
||||
http_401[(resp.dst_ip, resp.src_ip)] += 1
|
||||
if resp.packet_number:
|
||||
http_401_pkts[(resp.dst_ip, resp.src_ip)].append(resp.packet_number)
|
||||
for (client, server), count in http_401.items():
|
||||
if count >= _FAILED_LOGINS:
|
||||
if resp.status_code == 401:
|
||||
http_401[(resp.dst_ip, resp.src_ip)].append(resp)
|
||||
for (client, server), resps in http_401.items():
|
||||
if len(resps) >= _HTTP_401_MIN:
|
||||
findings.append(Finding(
|
||||
title="Repeated failed HTTP authentication",
|
||||
category=Category.BRUTEFORCE_SCAN_FILETRANSFER,
|
||||
severity=Severity.MEDIUM, confidence=Confidence.MEDIUM,
|
||||
source_ip=client, destination_ip=server, protocol="http",
|
||||
risk_description=(
|
||||
"Many HTTP 401 or 403 responses may indicate a password guessing attack."
|
||||
),
|
||||
timestamp_first_seen=resps[0].timestamp, timestamp_last_seen=resps[-1].timestamp,
|
||||
risk_description="Many HTTP 401 responses to one client may indicate password guessing.",
|
||||
recommendation="Enable rate limiting or lockout controls and review the source.",
|
||||
evidence={"failed_responses": count, "dedup_key": f"bruteforce:{client}:{server}"},
|
||||
related_packets=sorted(set(http_401_pkts.get((client, server), [])))[:8],
|
||||
evidence={"failed_responses": len(resps),
|
||||
"uris": sorted({r.request_uri for r in resps if r.request_uri})[:5],
|
||||
"target_is_device": ctx.is_device(server),
|
||||
"dedup_key": f"bruteforce:http:{client}:{server}"},
|
||||
related_packets=sorted({r.packet_number for r in resps if r.packet_number})[:8],
|
||||
))
|
||||
return findings
|
||||
|
||||
seen_files: set[str] = set()
|
||||
for req in ctx.http_requests:
|
||||
uri = (req.uri or "").split("?")[0]
|
||||
ext = os.path.splitext(uri)[1].lower()
|
||||
if ext in _SUSPICIOUS_EXT:
|
||||
key = f"file:{req.host}:{uri}"
|
||||
if key in seen_files:
|
||||
continue
|
||||
seen_files.add(key)
|
||||
findings.append(Finding(
|
||||
title=f"Potentially harmful file download ({ext})",
|
||||
category=Category.BRUTEFORCE_SCAN_FILETRANSFER,
|
||||
severity=Severity.HIGH, confidence=Confidence.MEDIUM,
|
||||
source_ip=req.src_ip, destination_ip=req.dst_ip, protocol="http", port=req.dst_port,
|
||||
timestamp_first_seen=req.timestamp, timestamp_last_seen=req.timestamp,
|
||||
risk_description=(
|
||||
"An executable or script-capable file was transferred over HTTP and "
|
||||
"may represent a malware download."
|
||||
),
|
||||
recommendation="Analyze the file in a sandbox and check its hash against threat intelligence.",
|
||||
evidence={"host": req.host, "uri": uri, "extension": ext, "dedup_key": key},
|
||||
related_packets=[p for p in [req.packet_number] if p],
|
||||
))
|
||||
# ---- file transfers --------------------------------------------------- #
|
||||
|
||||
def _files(self, ctx: CaptureContext) -> list[Finding]:
|
||||
findings: list[Finding] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
def emit(key, **kw):
|
||||
if key in seen:
|
||||
return
|
||||
seen.add(key)
|
||||
kw["evidence"] = dict(kw.get("evidence") or {}, dedup_key=key)
|
||||
kw.setdefault("category", Category.BRUTEFORCE_SCAN_FILETRANSFER)
|
||||
findings.append(Finding(**kw))
|
||||
|
||||
# Response metadata can reveal risky files absent from the request URI.
|
||||
for ft in ctx.file_transfers:
|
||||
ext = (ft.extension or "").lower()
|
||||
ct = (ft.content_type or "").lower()
|
||||
is_executable_ct = any(
|
||||
m in ct for m in ("x-msdownload", "x-dosexec", "x-executable",
|
||||
"portable-executable", "x-elf", "x-msi")
|
||||
)
|
||||
if ext not in _SUSPICIOUS_EXT and not is_executable_ct:
|
||||
continue
|
||||
key = f"ft:{ft.dst_ip}:{ft.filename or ct}"
|
||||
if key in seen_files:
|
||||
continue
|
||||
seen_files.add(key)
|
||||
label = ext or ct or "binary file"
|
||||
findings.append(Finding(
|
||||
title=f"Potentially harmful file download ({label})",
|
||||
category=Category.BRUTEFORCE_SCAN_FILETRANSFER,
|
||||
severity=Severity.HIGH, confidence=Confidence.MEDIUM,
|
||||
source_ip=ft.dst_ip, destination_ip=ft.src_ip, protocol=ft.protocol,
|
||||
timestamp_first_seen=ft.timestamp, timestamp_last_seen=ft.timestamp,
|
||||
risk_description=(
|
||||
"An executable or script-capable file was transferred and may represent "
|
||||
"a malware download."
|
||||
),
|
||||
recommendation="Analyze the file in a sandbox and check its hash against threat intelligence.",
|
||||
evidence={"filename": ft.filename, "content_type": ft.content_type,
|
||||
"extension": ext or None, "dedup_key": key},
|
||||
related_packets=[p for p in [ft.packet_number] if p],
|
||||
))
|
||||
name = ft.filename or ft.uri or ct or "binary"
|
||||
executable = ext in _EXECUTABLE_EXT or any(m in ct for m in _EXECUTABLE_CT)
|
||||
firmware = ext in _FIRMWARE_EXT or (not ext and "octet-stream" in ct and (ft.size or 0) > 64 * 1024)
|
||||
cleartext = (ft.protocol or "") in ("http", "tftp", "ftp")
|
||||
downloader = ft.dst_ip if ft.direction == "download" else ft.src_ip
|
||||
server = ft.src_ip if ft.direction == "download" else ft.dst_ip
|
||||
device_involved = ctx.is_device(downloader) or ctx.is_device(server)
|
||||
common = dict(source_ip=downloader, destination_ip=server, protocol=ft.protocol,
|
||||
timestamp_first_seen=ft.timestamp, timestamp_last_seen=ft.timestamp,
|
||||
related_packets=[p for p in [ft.packet_number] if p],
|
||||
related_streams=[s for s in [ft.stream_id] if s is not None])
|
||||
evidence = {"filename": ft.filename, "uri": ft.uri, "host": ft.host,
|
||||
"content_type": ft.content_type, "size": ft.size, "extension": ext or None,
|
||||
"direction": ft.direction}
|
||||
|
||||
return self._tag(findings)
|
||||
if executable and cleartext:
|
||||
emit(f"exec:{server}:{name}",
|
||||
title=f"Executable or script transferred over clear-text {ft.protocol.upper()} ({ext or ct})",
|
||||
severity=Severity.HIGH, confidence=Confidence.MEDIUM,
|
||||
risk_description=("Executable content was transferred without transport protection. It can be "
|
||||
"modified on the path, and executable downloads to a device are a common "
|
||||
"malware delivery vector."),
|
||||
recommendation="Serve executables only over TLS with signature verification; analyze the file in a sandbox and check its hash against threat intelligence.",
|
||||
evidence=evidence, **common)
|
||||
elif firmware and cleartext:
|
||||
emit(f"fw:{server}:{name}",
|
||||
title=f"Firmware or update image transferred over clear-text {ft.protocol.upper()}",
|
||||
severity=Severity.HIGH if device_involved else Severity.MEDIUM,
|
||||
confidence=Confidence.MEDIUM,
|
||||
risk_description=("An update or archive image was transferred without transport protection. "
|
||||
"Unless the image is cryptographically signed and verified by the device, an "
|
||||
"attacker on the path can replace it."),
|
||||
recommendation="Deliver updates over TLS with certificate validation and verify a vendor signature before installing.",
|
||||
evidence=evidence, **common)
|
||||
elif executable:
|
||||
emit(f"exec:{server}:{name}",
|
||||
title=f"Executable or script transferred ({ext or ct})",
|
||||
severity=Severity.MEDIUM, confidence=Confidence.LOW,
|
||||
risk_description="Executable content was transferred; verify that the source is trusted.",
|
||||
recommendation="Check the file hash against threat intelligence.",
|
||||
evidence=evidence, **common)
|
||||
|
||||
# Requests for executables that produced no dissected response.
|
||||
for req in ctx.http_requests:
|
||||
uri = (req.uri or "").split("?")[0]
|
||||
ext = os.path.splitext(uri)[1].lower()
|
||||
if ext in _EXECUTABLE_EXT:
|
||||
emit(f"exec:{req.dst_ip}:{uri}",
|
||||
title=f"Executable or script requested over clear-text HTTP ({ext})",
|
||||
severity=Severity.HIGH, confidence=Confidence.MEDIUM,
|
||||
source_ip=req.src_ip, destination_ip=req.dst_ip, protocol="http", port=req.dst_port,
|
||||
timestamp_first_seen=req.timestamp, timestamp_last_seen=req.timestamp,
|
||||
risk_description=("A script or executable was requested over unencrypted HTTP and can be "
|
||||
"replaced on the path."),
|
||||
recommendation="Serve executables only over TLS with signature verification.",
|
||||
evidence={"host": req.host, "uri": uri, "extension": ext},
|
||||
related_packets=[p for p in [req.packet_number] if p])
|
||||
return findings
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
"""Detect anomalous DNS activity and potential DNS tunneling."""
|
||||
"""Detect anomalous DNS activity and potential DNS tunneling.
|
||||
|
||||
DGA and tunneling are judged by volume and diversity of names, never by a
|
||||
single random-looking host name. Provider domains come from ``allowlist``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -8,22 +12,41 @@ from typing import Iterable
|
||||
|
||||
from ..context import CaptureContext
|
||||
from ..findings import Finding, Category, Severity, Confidence
|
||||
from .. import allowlist
|
||||
from .base import BaseDetector, register
|
||||
|
||||
_LONG_LABEL = 40
|
||||
_ENTROPY_MIN_LEN = 12
|
||||
_HIGH_ENTROPY = 3.2
|
||||
_NXDOMAIN_RATIO = 0.3
|
||||
_MANY_SUBDOMAINS = 30
|
||||
_RANDOM_MIN_LEN = 16
|
||||
_RANDOM_ENTROPY = 3.2
|
||||
_DGA_MIN_DISTINCT = 8
|
||||
_NXDOMAIN_RATIO = 0.35
|
||||
_NXDOMAIN_MIN_RESPONSES = 20
|
||||
_MANY_SUBDOMAINS = 40
|
||||
_MANY_SUBDOMAINS_RANDOM_SHARE = 0.3
|
||||
_TUNNEL_QTYPES = {"TXT", "NULL"}
|
||||
_TXT_TUNNEL_MIN = 20
|
||||
_TXT_TUNNEL_RATIO = 0.5
|
||||
|
||||
# Public resolvers commonly hard-coded in IoT firmware.
|
||||
_PUBLIC_RESOLVERS = {"8.8.8.8", "8.8.4.4", "1.1.1.1", "1.0.0.1", "9.9.9.9", "149.112.112.112",
|
||||
"208.67.222.222", "208.67.220.220", "114.114.114.114", "223.5.5.5", "223.6.6.6"}
|
||||
|
||||
|
||||
def _is_random_label(label: str) -> bool:
|
||||
"""Whether one DNS label looks algorithmically generated or data-encoding."""
|
||||
if len(label) >= _LONG_LABEL:
|
||||
return True
|
||||
if len(label) >= _RANDOM_MIN_LEN and _entropy(label) >= _RANDOM_ENTROPY:
|
||||
has_digit = any(c.isdigit() for c in label)
|
||||
has_alpha = any(c.isalpha() for c in label)
|
||||
return has_digit and has_alpha
|
||||
return False
|
||||
|
||||
|
||||
@register
|
||||
class DnsAnomalyDetector(BaseDetector):
|
||||
name = "DnsAnomalyDetector"
|
||||
description = "Detects DNS tunneling, DGA-like names, and high NXDOMAIN rates."
|
||||
description = "Detects DNS tunneling, DGA-like names, high NXDOMAIN rates and resolver bypass."
|
||||
|
||||
def detect(self, ctx: CaptureContext) -> Iterable[Finding]:
|
||||
findings: list[Finding] = []
|
||||
@@ -31,82 +54,158 @@ class DnsAnomalyDetector(BaseDetector):
|
||||
return findings
|
||||
|
||||
nx = 0
|
||||
total = 0
|
||||
responses = 0
|
||||
queries = 0
|
||||
txt_like = 0
|
||||
subdomains_per_domain: dict[str, set[str]] = defaultdict(set)
|
||||
packets_per_domain: dict[str, list] = defaultdict(list)
|
||||
random_subs_per_domain: dict[str, set[str]] = defaultdict(set)
|
||||
random_meta: dict[str, dict] = {}
|
||||
long_label_hits: dict[str, dict] = {}
|
||||
nx_packets: list = []
|
||||
nx_clients: dict[str, int] = defaultdict(int)
|
||||
txt_packets: list = []
|
||||
flagged_random: set[str] = set()
|
||||
resolver_use: dict[tuple, dict] = {}
|
||||
|
||||
for q in ctx.dns_queries:
|
||||
name = (q.query_name or "").rstrip(".").lower()
|
||||
if not name:
|
||||
if not name or q.transport in ("mdns", "llmnr"):
|
||||
continue
|
||||
total += 1
|
||||
if (q.rcode or "").upper() == "NXDOMAIN":
|
||||
nx += 1
|
||||
if q.packet_number:
|
||||
nx_packets.append(q.packet_number)
|
||||
if q.is_response:
|
||||
responses += 1
|
||||
if (q.rcode or "").upper() == "NXDOMAIN":
|
||||
nx += 1
|
||||
if q.packet_number:
|
||||
nx_packets.append(q.packet_number)
|
||||
if q.dst_ip:
|
||||
nx_clients[q.dst_ip] += 1
|
||||
continue
|
||||
|
||||
queries += 1
|
||||
if (q.qtype or "").upper() in _TUNNEL_QTYPES:
|
||||
txt_like += 1
|
||||
if q.packet_number:
|
||||
txt_packets.append(q.packet_number)
|
||||
if q.dst_ip and q.src_ip and not ctx.is_external(q.src_ip):
|
||||
key = (q.src_ip, q.dst_ip)
|
||||
e = resolver_use.setdefault(key, {"count": 0, "first": q.timestamp, "pkt": q.packet_number})
|
||||
e["count"] += 1
|
||||
|
||||
labels = name.split(".")
|
||||
reg_domain = ".".join(labels[-2:]) if len(labels) >= 2 else name
|
||||
if len(labels) > 2:
|
||||
subdomains_per_domain[reg_domain].add(".".join(labels[:-2]))
|
||||
if q.packet_number:
|
||||
packets_per_domain[reg_domain].append(q.packet_number)
|
||||
if allowlist.is_benign(name):
|
||||
continue
|
||||
sub_labels = labels[:-2]
|
||||
if not sub_labels:
|
||||
continue
|
||||
subdomain = ".".join(sub_labels)
|
||||
subdomains_per_domain[reg_domain].add(subdomain)
|
||||
if q.packet_number:
|
||||
packets_per_domain[reg_domain].append(q.packet_number)
|
||||
longest = max(sub_labels, key=len)
|
||||
if len(longest) >= _LONG_LABEL and reg_domain not in long_label_hits:
|
||||
long_label_hits[reg_domain] = {
|
||||
"query_name": name, "label_len": len(longest),
|
||||
"packet": q.packet_number, "src": q.src_ip, "dst": q.dst_ip, "ts": q.timestamp,
|
||||
}
|
||||
if any(_is_random_label(lbl) for lbl in sub_labels):
|
||||
random_subs_per_domain[reg_domain].add(subdomain)
|
||||
random_meta.setdefault(reg_domain, {"src": q.src_ip, "dst": q.dst_ip, "ts": q.timestamp})
|
||||
|
||||
# Long or high-entropy labels may indicate a DGA or tunneling channel.
|
||||
for label in labels[:-2] or labels:
|
||||
if len(label) >= _LONG_LABEL or (
|
||||
len(label) >= _ENTROPY_MIN_LEN and _entropy(label) >= _HIGH_ENTROPY
|
||||
):
|
||||
if reg_domain not in flagged_random:
|
||||
flagged_random.add(reg_domain)
|
||||
findings.append(Finding(
|
||||
title="DNS query suspicious for DGA or tunneling activity",
|
||||
category=Category.DNS_ANOMALY,
|
||||
severity=Severity.HIGH, confidence=Confidence.MEDIUM,
|
||||
source_ip=q.src_ip, destination_ip=q.dst_ip, protocol="dns", port=53,
|
||||
timestamp_first_seen=q.timestamp, timestamp_last_seen=q.timestamp,
|
||||
risk_description=(
|
||||
"Long or random-looking subdomains are characteristic of DNS "
|
||||
"tunneling and malware domain-generation algorithms."
|
||||
),
|
||||
recommendation=(
|
||||
"Check the domain against threat intelligence and enable DNS "
|
||||
"tunneling detection and egress controls."
|
||||
),
|
||||
evidence={"query_name": name, "registered_domain": reg_domain,
|
||||
"label_entropy": round(_entropy(label), 2),
|
||||
"dedup_key": f"dga:{reg_domain}"},
|
||||
related_packets=[p for p in [q.packet_number] if p],
|
||||
))
|
||||
break
|
||||
|
||||
for domain, subs in subdomains_per_domain.items():
|
||||
if len(subs) >= _MANY_SUBDOMAINS:
|
||||
# 1) DGA/tunneling: many distinct random subdomains under one domain.
|
||||
for reg_domain, subs in random_subs_per_domain.items():
|
||||
meta = random_meta.get(reg_domain, {})
|
||||
if len(subs) < _DGA_MIN_DISTINCT:
|
||||
if reg_domain in long_label_hits:
|
||||
continue
|
||||
findings.append(Finding(
|
||||
title=f"Unusually many subdomains under {domain}",
|
||||
title="Randomly-structured DNS name observed",
|
||||
category=Category.DNS_ANOMALY,
|
||||
severity=Severity.HIGH, confidence=Confidence.MEDIUM,
|
||||
severity=Severity.LOW, confidence=Confidence.LOW,
|
||||
source_ip=meta.get("src"), destination_ip=meta.get("dst"),
|
||||
protocol="dns", port=53,
|
||||
timestamp_first_seen=meta.get("ts"), timestamp_last_seen=meta.get("ts"),
|
||||
risk_description=(
|
||||
"A large number of unique subdomains under one domain is a strong "
|
||||
"indicator of DNS tunneling or exfiltration."
|
||||
"A DNS name with a random-looking label was queried under "
|
||||
f"{reg_domain}. In isolation this is a weak indicator; it becomes "
|
||||
"significant only alongside many such names or other anomalies."
|
||||
),
|
||||
recommendation="Review or block the domain and enable tunneling detection.",
|
||||
evidence={"registered_domain": domain, "unique_subdomains": len(subs),
|
||||
"dedup_key": f"subtunnel:{domain}"},
|
||||
related_packets=sorted(set(packets_per_domain.get(domain, [])))[:8],
|
||||
recommendation="Confirm the domain is a legitimate service; otherwise check it against threat intelligence.",
|
||||
evidence={"registered_domain": reg_domain,
|
||||
"distinct_random_subdomains": len(subs),
|
||||
"examples": sorted(subs)[:5],
|
||||
"dedup_key": f"randname:{reg_domain}"},
|
||||
related_packets=sorted(set(packets_per_domain.get(reg_domain, [])))[:8],
|
||||
))
|
||||
continue
|
||||
findings.append(Finding(
|
||||
title="DNS queries consistent with DGA or tunneling activity",
|
||||
category=Category.DNS_ANOMALY,
|
||||
severity=Severity.HIGH, confidence=Confidence.MEDIUM,
|
||||
source_ip=meta.get("src"), destination_ip=meta.get("dst"),
|
||||
protocol="dns", port=53,
|
||||
timestamp_first_seen=meta.get("ts"), timestamp_last_seen=meta.get("ts"),
|
||||
risk_description=(
|
||||
f"{len(subs)} distinct random-looking subdomains were queried under "
|
||||
f"{reg_domain}. A high number of algorithmically generated names is a "
|
||||
"strong indicator of a domain-generation algorithm or DNS tunneling."
|
||||
),
|
||||
recommendation="Check the domain against threat intelligence and enable DNS tunneling detection and egress controls.",
|
||||
evidence={"registered_domain": reg_domain,
|
||||
"distinct_random_subdomains": len(subs),
|
||||
"examples": sorted(subs)[:5],
|
||||
"dedup_key": f"dga:{reg_domain}"},
|
||||
related_packets=sorted(set(packets_per_domain.get(reg_domain, [])))[:8],
|
||||
))
|
||||
|
||||
# TXT and NULL records can carry the large payloads used by DNS tunnels.
|
||||
if txt_like >= _TXT_TUNNEL_MIN and total and (txt_like / total) >= _TXT_TUNNEL_RATIO:
|
||||
# 2) Long-label tunneling.
|
||||
for reg_domain, hit in long_label_hits.items():
|
||||
if len(random_subs_per_domain.get(reg_domain, ())) >= _DGA_MIN_DISTINCT:
|
||||
continue
|
||||
findings.append(Finding(
|
||||
title="Overlong DNS label suggests tunneling",
|
||||
category=Category.DNS_ANOMALY,
|
||||
severity=Severity.MEDIUM, confidence=Confidence.LOW,
|
||||
source_ip=hit.get("src"), destination_ip=hit.get("dst"),
|
||||
protocol="dns", port=53,
|
||||
timestamp_first_seen=hit.get("ts"), timestamp_last_seen=hit.get("ts"),
|
||||
risk_description=(
|
||||
f"A DNS label of {hit['label_len']} characters was observed under "
|
||||
f"{reg_domain}. Overlong labels are used to encode data in DNS tunnels."
|
||||
),
|
||||
recommendation="Review the destination domain and enable tunneling detection.",
|
||||
evidence={"registered_domain": reg_domain,
|
||||
"query_name": hit["query_name"], "label_length": hit["label_len"],
|
||||
"dedup_key": f"longlabel:{reg_domain}"},
|
||||
related_packets=[p for p in [hit.get("packet")] if p],
|
||||
))
|
||||
|
||||
# 3) Many unique subdomains; HIGH only when many of them look generated.
|
||||
for domain, subs in subdomains_per_domain.items():
|
||||
if len(subs) < _MANY_SUBDOMAINS:
|
||||
continue
|
||||
random_share = len(random_subs_per_domain.get(domain, ())) / len(subs)
|
||||
strong = random_share >= _MANY_SUBDOMAINS_RANDOM_SHARE
|
||||
findings.append(Finding(
|
||||
title=f"Unusually many subdomains under {domain}",
|
||||
category=Category.DNS_ANOMALY,
|
||||
severity=Severity.HIGH if strong else Severity.LOW,
|
||||
confidence=Confidence.MEDIUM if strong else Confidence.LOW,
|
||||
protocol="dns", port=53,
|
||||
risk_description=(
|
||||
f"{len(subs)} unique subdomains were queried under {domain}"
|
||||
+ (f", {round(random_share * 100)} % of them random-looking, a strong indicator of DNS tunneling or exfiltration."
|
||||
if strong else ". Large services use many host names; this is only suspicious if the domain is unexpected for the device.")
|
||||
),
|
||||
recommendation="Review the domain; block it and enable tunneling detection if it is not an expected service.",
|
||||
evidence={"registered_domain": domain, "unique_subdomains": len(subs),
|
||||
"random_share": round(random_share, 2), "examples": sorted(subs)[:5],
|
||||
"dedup_key": f"subtunnel:{domain}"},
|
||||
related_packets=sorted(set(packets_per_domain.get(domain, [])))[:8],
|
||||
))
|
||||
|
||||
# 4) TXT/NULL abuse.
|
||||
if txt_like >= _TXT_TUNNEL_MIN and queries and (txt_like / queries) >= _TXT_TUNNEL_RATIO:
|
||||
findings.append(Finding(
|
||||
title="Unusually many TXT or NULL DNS queries",
|
||||
category=Category.DNS_ANOMALY,
|
||||
@@ -116,31 +215,50 @@ class DnsAnomalyDetector(BaseDetector):
|
||||
"TXT and NULL records can carry large payloads and are commonly abused "
|
||||
"for DNS tunneling or data exfiltration."
|
||||
),
|
||||
recommendation=(
|
||||
"Review the requesting source and destination domain, then enforce DNS "
|
||||
"tunneling detection and egress controls."
|
||||
),
|
||||
evidence={"txt_null_queries": txt_like, "total_queries": total,
|
||||
"ratio": round(txt_like / total, 2), "dedup_key": "txt_tunnel"},
|
||||
recommendation="Review the requesting source and destination domain, then enforce DNS tunneling detection and egress controls.",
|
||||
evidence={"txt_null_queries": txt_like, "total_queries": queries,
|
||||
"ratio": round(txt_like / queries, 2), "dedup_key": "txt_tunnel"},
|
||||
related_packets=sorted(set(txt_packets))[:8],
|
||||
))
|
||||
|
||||
if total >= 20 and (nx / total) >= _NXDOMAIN_RATIO:
|
||||
# 5) High NXDOMAIN rate, measured over responses only.
|
||||
if responses >= _NXDOMAIN_MIN_RESPONSES and (nx / responses) >= _NXDOMAIN_RATIO:
|
||||
top_client = max(nx_clients.items(), key=lambda kv: kv[1])[0] if nx_clients else None
|
||||
findings.append(Finding(
|
||||
title="High NXDOMAIN rate",
|
||||
category=Category.DNS_ANOMALY,
|
||||
severity=Severity.MEDIUM, confidence=Confidence.MEDIUM,
|
||||
source_ip=top_client,
|
||||
protocol="dns", port=53,
|
||||
risk_description=(
|
||||
"Repeated NXDOMAIN responses may indicate malware testing generated "
|
||||
"command-and-control domains."
|
||||
"A large share of DNS responses were NXDOMAIN. Malware probing generated "
|
||||
"command-and-control domains produces this pattern; so do stale configurations."
|
||||
),
|
||||
recommendation="Review the requesting source and investigate it for malware.",
|
||||
evidence={"nxdomain": nx, "total_queries": total,
|
||||
"ratio": round(nx / total, 2), "dedup_key": "nxdomain_rate"},
|
||||
recommendation="Review the requesting source and the failed names.",
|
||||
evidence={"nxdomain": nx, "responses": responses,
|
||||
"ratio": round(nx / responses, 2), "dedup_key": "nxdomain_rate"},
|
||||
related_packets=sorted(set(nx_packets))[:8],
|
||||
))
|
||||
|
||||
# 6) Direct use of public resolvers (bypasses the local resolver and its policies).
|
||||
for (client, resolver), e in resolver_use.items():
|
||||
if resolver in _PUBLIC_RESOLVERS or (ctx.is_external(resolver) and ctx.is_device(client)):
|
||||
findings.append(Finding(
|
||||
title=f"DNS sent directly to an external resolver ({resolver})",
|
||||
category=Category.DNS_ANOMALY,
|
||||
severity=Severity.LOW, confidence=Confidence.HIGH,
|
||||
source_ip=client, destination_ip=resolver, protocol="dns", port=53,
|
||||
timestamp_first_seen=e["first"], timestamp_last_seen=e["first"],
|
||||
risk_description=(
|
||||
"The host resolves names through a hard-coded external resolver instead of the "
|
||||
"one assigned by the network. Local DNS filtering and monitoring are bypassed and "
|
||||
"the queries are visible to a third party."
|
||||
),
|
||||
recommendation="Use the DHCP-assigned resolver, or DNS over TLS/HTTPS if privacy is required.",
|
||||
evidence={"queries": e["count"], "dedup_key": f"extresolver:{client}:{resolver}"},
|
||||
related_packets=[p for p in [e["pkt"]] if p],
|
||||
))
|
||||
|
||||
return self._tag(findings)
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Detect potential data exfiltration."""
|
||||
"""Detect unusual outbound data volumes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -7,13 +7,15 @@ from typing import Iterable
|
||||
|
||||
from ..context import CaptureContext
|
||||
from ..findings import Finding, Category, Severity, Confidence
|
||||
from .. import allowlist
|
||||
from .base import BaseDetector, register
|
||||
|
||||
# Conservative heuristic thresholds.
|
||||
_LARGE_UPLOAD_BYTES = 5 * 1024 * 1024
|
||||
_AGG_UPLOAD_BYTES = 20 * 1024 * 1024
|
||||
_MANY_SMALL_TRANSFERS = 50
|
||||
_DNS_TXT_VOLUME = 100
|
||||
# Uploads must clearly dominate the flow; browsing and streaming are download-heavy.
|
||||
_UPLOAD_RATIO = 3.0
|
||||
|
||||
|
||||
@register
|
||||
@@ -37,7 +39,8 @@ class ExfiltrationDetector(BaseDetector):
|
||||
if not (conv.src_ip and ctx.is_external(conv.dst_ip)
|
||||
and not ctx.is_external(conv.src_ip)):
|
||||
continue
|
||||
up = conv.bytes_src_to_dst or conv.bytes
|
||||
up = conv.bytes_src_to_dst
|
||||
down = conv.bytes_dst_to_src
|
||||
per_dest_bytes[conv.dst_ip] += up
|
||||
per_dest_count[conv.dst_ip] += 1
|
||||
src_for_dest[conv.dst_ip] = conv.src_ip
|
||||
@@ -50,76 +53,69 @@ class ExfiltrationDetector(BaseDetector):
|
||||
if conv.last_seen is not None:
|
||||
last_seen[conv.dst_ip] = max(last_seen.get(conv.dst_ip, conv.last_seen), conv.last_seen)
|
||||
|
||||
if up >= _LARGE_UPLOAD_BYTES:
|
||||
if up >= _LARGE_UPLOAD_BYTES and up >= _UPLOAD_RATIO * max(down, 1):
|
||||
benign = allowlist.benign_name_for_ip(ctx, conv.dst_ip)
|
||||
names = sorted(ctx.names_for_ip(conv.dst_ip))[:5]
|
||||
findings.append(Finding(
|
||||
title="Large outbound transfer to an external target",
|
||||
category=Category.EXFILTRATION,
|
||||
severity=Severity.HIGH, confidence=Confidence.MEDIUM,
|
||||
severity=Severity.MEDIUM if benign else Severity.HIGH,
|
||||
confidence=Confidence.LOW if benign else Confidence.MEDIUM,
|
||||
source_ip=conv.src_ip, destination_ip=conv.dst_ip,
|
||||
protocol=conv.protocol, port=conv.dst_port,
|
||||
timestamp_first_seen=conv.first_seen, timestamp_last_seen=conv.last_seen,
|
||||
risk_description=(
|
||||
"A large amount of data left the device for an external target, "
|
||||
"which may indicate data loss."
|
||||
"A large amount of data left the network for an external target in one "
|
||||
"upload-dominated flow. "
|
||||
+ (f"The target belongs to an expected provider ({benign}), e.g. a backup or sync service."
|
||||
if benign else "Verify what was sent and whether the destination is expected.")
|
||||
),
|
||||
recommendation="Validate the destination, content, and authorization.",
|
||||
evidence={"uploaded_bytes": up,
|
||||
evidence={"uploaded_bytes": up, "downloaded_bytes": down,
|
||||
"encrypted": conv.is_encrypted, "target_names": names,
|
||||
"dedup_key": f"bigup:{conv.src_ip}:{conv.dst_ip}:{conv.dst_port}"},
|
||||
related_packets=list(conv.packet_numbers),
|
||||
related_streams=[s for s in [conv.stream_id] if s is not None],
|
||||
))
|
||||
|
||||
for dest, total in per_dest_bytes.items():
|
||||
benign = allowlist.benign_name_for_ip(ctx, dest)
|
||||
names = sorted(ctx.names_for_ip(dest))[:5]
|
||||
if total >= _AGG_UPLOAD_BYTES:
|
||||
findings.append(Finding(
|
||||
title="High aggregate upload volume to an external target",
|
||||
category=Category.EXFILTRATION,
|
||||
severity=Severity.HIGH, confidence=Confidence.MEDIUM,
|
||||
severity=Severity.MEDIUM if benign else Severity.HIGH,
|
||||
confidence=Confidence.LOW if benign else Confidence.MEDIUM,
|
||||
source_ip=src_for_dest.get(dest), destination_ip=dest,
|
||||
timestamp_first_seen=first_seen.get(dest),
|
||||
timestamp_last_seen=last_seen.get(dest),
|
||||
risk_description=(
|
||||
"A high total volume was sent to one external destination across "
|
||||
"multiple connections, which may indicate gradual exfiltration."
|
||||
"multiple connections, which may indicate gradual data loss."
|
||||
+ (f" The destination resolves to {benign}." if benign else "")
|
||||
),
|
||||
recommendation="Review the destination and enforce egress filtering or DLP.",
|
||||
evidence={"uploaded_bytes_total": total,
|
||||
recommendation="Review the destination and enforce egress filtering.",
|
||||
evidence={"uploaded_bytes_total": total, "target_names": names,
|
||||
"dedup_key": f"aggup:{dest}"},
|
||||
related_packets=sorted(set(packets_for_dest.get(dest, []))),
|
||||
related_streams=sorted(set(streams_for_dest.get(dest, []))),
|
||||
))
|
||||
elif per_dest_count[dest] >= _MANY_SMALL_TRANSFERS:
|
||||
elif per_dest_count[dest] >= _MANY_SMALL_TRANSFERS and not benign:
|
||||
findings.append(Finding(
|
||||
title="Many small outbound transfers to one target",
|
||||
title="Many small outbound connections to one target",
|
||||
category=Category.EXFILTRATION,
|
||||
severity=Severity.MEDIUM, confidence=Confidence.LOW,
|
||||
severity=Severity.LOW, confidence=Confidence.LOW,
|
||||
source_ip=src_for_dest.get(dest), destination_ip=dest,
|
||||
risk_description=(
|
||||
"Many small outbound connections to one target may indicate "
|
||||
"segmented exfiltration or beaconing."
|
||||
"Many short outbound connections to one target may indicate "
|
||||
"segmented uploads or polling."
|
||||
),
|
||||
recommendation="Investigate the connection pattern and destination.",
|
||||
evidence={"transfer_count": per_dest_count[dest],
|
||||
evidence={"transfer_count": per_dest_count[dest], "target_names": names,
|
||||
"dedup_key": f"manyup:{dest}"},
|
||||
related_packets=sorted(set(packets_for_dest.get(dest, []))),
|
||||
related_streams=sorted(set(streams_for_dest.get(dest, []))),
|
||||
))
|
||||
|
||||
# DNS volume is a weak signal; the DNS detector provides deeper evidence.
|
||||
if len(ctx.dns_queries) >= _DNS_TXT_VOLUME:
|
||||
findings.append(Finding(
|
||||
title="Very high DNS query volume",
|
||||
category=Category.EXFILTRATION,
|
||||
severity=Severity.MEDIUM, confidence=Confidence.LOW,
|
||||
protocol="dns",
|
||||
risk_description=(
|
||||
"Unusually high DNS volume may indicate DNS tunneling or exfiltration."
|
||||
),
|
||||
recommendation="Review DNS logs and correlated DNS anomaly findings.",
|
||||
evidence={"dns_query_count": len(ctx.dns_queries), "dedup_key": "dns_volume"},
|
||||
related_packets=sorted(
|
||||
{q.packet_number for q in ctx.dns_queries if q.packet_number}
|
||||
)[:8],
|
||||
))
|
||||
|
||||
return self._tag(findings)
|
||||
|
||||
@@ -1,91 +1,149 @@
|
||||
"""Detect insecure or unexpected legacy protocols."""
|
||||
"""Detect clear-text, legacy or unexpectedly exposed protocols.
|
||||
|
||||
Based on what tshark dissected on a flow; port rules are only a fallback.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from typing import Iterable
|
||||
|
||||
from ..context import CaptureContext
|
||||
from ..context import CaptureContext, Conversation
|
||||
from ..findings import Finding, Category, Severity, Confidence
|
||||
from .base import BaseDetector, register
|
||||
|
||||
# Port to protocol name, severity, risk, and recommendation.
|
||||
_RISKY_PORTS = {
|
||||
21: ("FTP", Severity.HIGH, "FTP transmits data and credentials in clear text.",
|
||||
"Replace FTP with FTPS or SFTP."),
|
||||
23: ("Telnet", Severity.CRITICAL, "Telnet transmits all content in clear text.",
|
||||
"Disable Telnet and use SSH."),
|
||||
69: ("TFTP", Severity.HIGH, "TFTP has no authentication or encryption.",
|
||||
"Avoid TFTP or restrict it to an isolated network."),
|
||||
80: ("HTTP", Severity.MEDIUM, "HTTP content is unencrypted and observable.",
|
||||
"Require HTTPS."),
|
||||
110: ("POP3", Severity.HIGH, "POP3 without TLS exposes messages and passwords.",
|
||||
"Use POP3S or STARTTLS."),
|
||||
143: ("IMAP", Severity.HIGH, "IMAP without TLS exposes messages and passwords.",
|
||||
"Use IMAPS or STARTTLS."),
|
||||
161: ("SNMP", Severity.MEDIUM, "SNMP v1/v2c uses clear-text community strings.",
|
||||
"Use SNMPv3 with authentication and privacy."),
|
||||
389: ("LDAP", Severity.HIGH, "LDAP without TLS exposes directory credentials.",
|
||||
"Use LDAPS or StartTLS."),
|
||||
512: ("rexec", Severity.HIGH, "Remote shell services are insecure and unencrypted.",
|
||||
"Disable the service and use SSH."),
|
||||
513: ("rlogin", Severity.HIGH, "Remote login services are insecure and unencrypted.",
|
||||
"Disable the service and use SSH."),
|
||||
1433: ("MSSQL", Severity.MEDIUM, "The database service may be exposed without TLS.",
|
||||
"Require TLS and restrict access."),
|
||||
3306: ("MySQL", Severity.MEDIUM, "The database service may be exposed without TLS.",
|
||||
"Require TLS and restrict access."),
|
||||
6379: ("Redis", Severity.HIGH, "Redis may be exposed without authentication or encryption.",
|
||||
"Enable authentication and restrict network exposure."),
|
||||
11211: ("Memcached", Severity.HIGH, "Memcached can be abused for amplification.",
|
||||
"Bind locally and restrict access."),
|
||||
27017: ("MongoDB", Severity.HIGH, "MongoDB without authentication or TLS exposes data.",
|
||||
"Enable authentication and TLS, then restrict access."),
|
||||
# Dissected protocol -> display name, severity, risk, recommendation.
|
||||
_CLEARTEXT_PROTOCOLS = {
|
||||
"telnet": ("Telnet", Severity.CRITICAL, "Telnet transmits commands and credentials in clear text.",
|
||||
"Disable Telnet and use SSH."),
|
||||
"ftp": ("FTP", Severity.HIGH, "FTP transmits data and credentials in clear text.",
|
||||
"Replace FTP with FTPS or SFTP."),
|
||||
"tftp": ("TFTP", Severity.HIGH, "TFTP has no authentication or encryption; it is often used for firmware or configuration transfer.",
|
||||
"Avoid TFTP or restrict it to an isolated provisioning network."),
|
||||
"http": ("HTTP", Severity.MEDIUM, "HTTP content is unencrypted and can be read or modified on the path.",
|
||||
"Require HTTPS for all device and cloud communication."),
|
||||
"pop": ("POP3", Severity.HIGH, "POP3 without TLS exposes messages and passwords.",
|
||||
"Use POP3S or STARTTLS."),
|
||||
"imap": ("IMAP", Severity.HIGH, "IMAP without TLS exposes messages and passwords.",
|
||||
"Use IMAPS or STARTTLS."),
|
||||
"smtp": ("SMTP", Severity.MEDIUM, "SMTP without TLS exposes message content and possibly credentials.",
|
||||
"Use STARTTLS or submission over TLS."),
|
||||
"snmp": ("SNMP", Severity.MEDIUM, "SNMP v1/v2c uses clear-text community strings.",
|
||||
"Use SNMPv3 with authentication and privacy."),
|
||||
"ldap": ("LDAP", Severity.HIGH, "LDAP without TLS exposes directory credentials.",
|
||||
"Use LDAPS or StartTLS."),
|
||||
"mqtt": ("MQTT", Severity.HIGH, "MQTT without TLS exposes topics, payloads and any username/password to observers and allows message injection.",
|
||||
"Use MQTT over TLS (8883) with server verification and per-device credentials or certificates."),
|
||||
"coap": ("CoAP", Severity.HIGH, "CoAP without DTLS has no confidentiality or integrity protection.",
|
||||
"Use CoAP over DTLS (coaps) or OSCORE."),
|
||||
"rtsp": ("RTSP", Severity.MEDIUM, "RTSP control and the media stream are unencrypted; video and credentials can be intercepted.",
|
||||
"Tunnel RTSP through TLS or a VPN and require authentication."),
|
||||
"mbtcp": ("Modbus/TCP", Severity.HIGH, "Modbus/TCP has no authentication; any host on the network can read or write registers.",
|
||||
"Isolate the control network and use Modbus/TCP Security or a gateway with authentication."),
|
||||
"sip": ("SIP", Severity.MEDIUM, "SIP signalling without TLS exposes call metadata and digest challenges.",
|
||||
"Use SIP over TLS and SRTP."),
|
||||
"vnc": ("VNC", Severity.HIGH, "VNC sessions are unencrypted and often weakly authenticated.",
|
||||
"Tunnel VNC through SSH or a VPN."),
|
||||
"xmpp": ("XMPP", Severity.MEDIUM, "XMPP without TLS exposes messages.",
|
||||
"Require STARTTLS."),
|
||||
"irc": ("IRC", Severity.MEDIUM, "IRC without TLS exposes messages; IRC is also a classic botnet control channel.",
|
||||
"Verify the purpose of the connection and require TLS."),
|
||||
"nbns": ("NetBIOS name service", Severity.LOW, "NetBIOS name resolution can be answered by any host and is used for credential relay attacks.",
|
||||
"Disable NetBIOS over TCP/IP where it is not required."),
|
||||
"llmnr": ("LLMNR", Severity.LOW, "LLMNR queries can be answered by any host and are used for credential relay attacks.",
|
||||
"Disable LLMNR."),
|
||||
"syslog": ("Syslog", Severity.LOW, "Syslog over UDP is unencrypted and unauthenticated.",
|
||||
"Use syslog over TLS."),
|
||||
}
|
||||
|
||||
# Port fallback for flows carrying data that no dissector recognised.
|
||||
_RISKY_PORTS = {
|
||||
23: ("Telnet (port 23)", Severity.CRITICAL, "Traffic on the Telnet port was not dissected but carries application data.",
|
||||
"Disable Telnet and use SSH."),
|
||||
512: ("rexec", Severity.HIGH, "Remote shell services are insecure and unencrypted.", "Disable the service and use SSH."),
|
||||
513: ("rlogin", Severity.HIGH, "Remote login services are insecure and unencrypted.", "Disable the service and use SSH."),
|
||||
514: ("rsh", Severity.HIGH, "Remote shell services are insecure and unencrypted.", "Disable the service and use SSH."),
|
||||
1433: ("MSSQL", Severity.MEDIUM, "The database service may be exposed without TLS.", "Require TLS and restrict access."),
|
||||
3306: ("MySQL", Severity.MEDIUM, "The database service may be exposed without TLS.", "Require TLS and restrict access."),
|
||||
5432: ("PostgreSQL", Severity.MEDIUM, "The database service may be exposed without TLS.", "Require TLS and restrict access."),
|
||||
6379: ("Redis", Severity.HIGH, "Redis may be exposed without authentication or encryption.", "Enable authentication and restrict network exposure."),
|
||||
11211: ("Memcached", Severity.HIGH, "Memcached can be abused for amplification and exposes cached data.", "Bind locally and restrict access."),
|
||||
27017: ("MongoDB", Severity.HIGH, "MongoDB without authentication or TLS exposes data.", "Enable authentication and TLS, then restrict access."),
|
||||
5900: ("VNC (port 5900)", Severity.HIGH, "VNC sessions are unencrypted and often weakly authenticated.", "Tunnel VNC through SSH or a VPN."),
|
||||
1883: ("MQTT (port 1883)", Severity.HIGH, "Traffic on the MQTT port was not dissected; unencrypted MQTT is likely.", "Use MQTT over TLS (8883)."),
|
||||
502: ("Modbus/TCP (port 502)", Severity.HIGH, "Traffic on the Modbus port was not dissected; unauthenticated control traffic is likely.", "Isolate the control network."),
|
||||
}
|
||||
|
||||
_ENCRYPTED_MARKERS = {"tls", "ssl", "dtls", "quic", "ssh"}
|
||||
|
||||
|
||||
@register
|
||||
class InsecureProtocolDetector(BaseDetector):
|
||||
name = "InsecureProtocolDetector"
|
||||
description = "Detects clear-text and legacy protocols on known service ports."
|
||||
description = "Detects clear-text and legacy protocols based on dissected traffic, plus exposed device services."
|
||||
|
||||
def detect(self, ctx: CaptureContext) -> Iterable[Finding]:
|
||||
findings: list[Finding] = []
|
||||
|
||||
# Aggregate observed use of known risky service ports.
|
||||
used: dict[int, dict] = defaultdict(lambda: {"src": None, "dst": None, "proto": None,
|
||||
"first": None, "last": None, "packets": 0,
|
||||
"pkts": [], "streams": set()})
|
||||
for conv in ctx.conversations:
|
||||
for port in (conv.dst_port, conv.src_port):
|
||||
if port in _RISKY_PORTS:
|
||||
e = used[port]
|
||||
e["src"] = conv.src_ip
|
||||
e["dst"] = conv.dst_ip
|
||||
e["proto"] = conv.protocol
|
||||
e["packets"] += conv.packets
|
||||
if conv.packet_numbers:
|
||||
e["pkts"].extend(conv.packet_numbers[:4])
|
||||
if conv.stream_id is not None:
|
||||
e["streams"].add(conv.stream_id)
|
||||
if conv.first_seen is not None:
|
||||
e["first"] = conv.first_seen if e["first"] is None else min(e["first"], conv.first_seen)
|
||||
if conv.last_seen is not None:
|
||||
e["last"] = conv.last_seen if e["last"] is None else max(e["last"], conv.last_seen)
|
||||
# Aggregate per (protocol, server, port) so every exposed service is listed once.
|
||||
used: dict[tuple, dict] = {}
|
||||
|
||||
for port, e in used.items():
|
||||
name, sev, risk, rec = _RISKY_PORTS[port]
|
||||
def note(key, conv: Conversation, client, server):
|
||||
e = used.setdefault(key, {"clients": set(), "packets": 0, "bytes": 0, "first": None,
|
||||
"last": None, "pkts": [], "streams": set(), "server": server})
|
||||
e["clients"].add(client)
|
||||
e["packets"] += conv.packets
|
||||
e["bytes"] += conv.payload_bytes
|
||||
e["pkts"].extend(conv.packet_numbers[:4])
|
||||
if conv.stream_id is not None:
|
||||
e["streams"].add(conv.stream_id)
|
||||
if conv.first_seen is not None:
|
||||
e["first"] = conv.first_seen if e["first"] is None else min(e["first"], conv.first_seen)
|
||||
if conv.last_seen is not None:
|
||||
e["last"] = conv.last_seen if e["last"] is None else max(e["last"], conv.last_seen)
|
||||
|
||||
for conv in ctx.conversations:
|
||||
if conv.payload_bytes <= 0:
|
||||
continue
|
||||
if conv.app_protocols & _ENCRYPTED_MARKERS:
|
||||
continue # STARTTLS upgraded flows or TLS on a non-standard port.
|
||||
matched = False
|
||||
for proto in conv.app_protocols:
|
||||
if proto in _CLEARTEXT_PROTOCOLS:
|
||||
if proto in ("nbns", "llmnr") and not conv.answered:
|
||||
continue
|
||||
note((proto, conv.dst_ip, conv.dst_port), conv, conv.src_ip, conv.dst_ip)
|
||||
matched = True
|
||||
if not matched and not conv.app_protocols and conv.dst_port in _RISKY_PORTS and conv.answered:
|
||||
note((f"port:{conv.dst_port}", conv.dst_ip, conv.dst_port), conv, conv.src_ip, conv.dst_ip)
|
||||
|
||||
for (proto, server, port), e in used.items():
|
||||
if proto.startswith("port:"):
|
||||
name, sev, risk, rec = _RISKY_PORTS[int(proto.split(":")[1])]
|
||||
proto_label = name.split(" ")[0].lower()
|
||||
else:
|
||||
name, sev, risk, rec = _CLEARTEXT_PROTOCOLS[proto]
|
||||
proto_label = proto
|
||||
clients = sorted(c for c in e["clients"] if c)
|
||||
device_is_server = ctx.is_device(server)
|
||||
device_is_client = any(ctx.is_device(c) for c in clients)
|
||||
role = "offered by the device" if device_is_server else "used by the device" if device_is_client else None
|
||||
title = f"Clear-text protocol: {name} on {server}:{port}"
|
||||
if role:
|
||||
title += f" ({role})"
|
||||
findings.append(Finding(
|
||||
title=f"Insecure protocol: {name} (port {port})",
|
||||
title=title,
|
||||
category=Category.INSECURE_PROTOCOL,
|
||||
severity=sev, confidence=Confidence.HIGH,
|
||||
source_ip=e["src"], destination_ip=e["dst"],
|
||||
protocol=name.lower(), port=port,
|
||||
source_ip=clients[0] if clients else None, destination_ip=server,
|
||||
protocol=proto_label, port=port,
|
||||
timestamp_first_seen=e["first"], timestamp_last_seen=e["last"],
|
||||
risk_description=risk,
|
||||
recommendation=rec,
|
||||
evidence={"port": port, "packets": e["packets"],
|
||||
"dedup_key": f"insecure:{port}:{e['dst']}"},
|
||||
evidence={"port": port, "packets": e["packets"], "payload_bytes": e["bytes"],
|
||||
"clients": clients[:10], "client_count": len(clients),
|
||||
"external_server": ctx.is_external(server),
|
||||
"dedup_key": f"insecure:{proto}:{server}:{port}"},
|
||||
related_packets=sorted(set(e["pkts"]))[:8],
|
||||
related_streams=sorted(e["streams"])[:8],
|
||||
))
|
||||
@@ -104,4 +162,61 @@ class InsecureProtocolDetector(BaseDetector):
|
||||
evidence={"smb1_packets": smb1, "dedup_key": "smbv1"},
|
||||
))
|
||||
|
||||
# SSH protocol version 1 banners.
|
||||
for s in ctx.service_events:
|
||||
if s.kind == "ssh_banner" and str(s.value or "").upper().startswith("SSH-1."):
|
||||
findings.append(Finding(
|
||||
title="SSH protocol version 1 offered",
|
||||
category=Category.INSECURE_PROTOCOL,
|
||||
severity=Severity.HIGH, confidence=Confidence.HIGH,
|
||||
source_ip=s.src_ip, destination_ip=s.dst_ip, protocol="ssh", port=s.dst_port,
|
||||
timestamp_first_seen=s.timestamp, timestamp_last_seen=s.timestamp,
|
||||
risk_description="SSH-1 has known cryptographic weaknesses and must not be used.",
|
||||
recommendation="Disable SSH-1 and allow only SSH-2 with modern algorithms.",
|
||||
evidence={"banner": s.value, "dedup_key": f"ssh1:{s.src_ip}"},
|
||||
related_packets=[p for p in [s.packet_number] if p],
|
||||
))
|
||||
|
||||
# Services the device under test exposes on the network (completeness, INFO).
|
||||
if ctx.device_ip:
|
||||
offered: dict[int, set[str]] = defaultdict(set)
|
||||
for conv in ctx.conversations:
|
||||
if conv.dst_ip == ctx.device_ip and conv.answered and conv.dst_port:
|
||||
offered[conv.dst_port] |= conv.app_protocols
|
||||
for port in sorted(offered):
|
||||
if port in (67, 68, 5353, 1900, 5355, 137, 138):
|
||||
continue # Broadcast/multicast infrastructure, not a listening service.
|
||||
protos = sorted(p for p in offered[port] if p not in ("x509ce", "x509sat", "pkix1implicit", "tcp.segments"))
|
||||
findings.append(Finding(
|
||||
title=f"Device offers a network service on port {port}",
|
||||
category=Category.INSECURE_PROTOCOL,
|
||||
severity=Severity.INFO, confidence=Confidence.HIGH,
|
||||
destination_ip=ctx.device_ip, port=port,
|
||||
protocol=(protos[0] if protos else None),
|
||||
risk_description=(
|
||||
"The device accepted connections on this port. Every listening service "
|
||||
"is attack surface and should be known and justified."
|
||||
),
|
||||
recommendation="Confirm the service is required, authenticated and encrypted; disable it otherwise.",
|
||||
evidence={"port": port, "protocols": protos, "dedup_key": f"devsvc:{port}"},
|
||||
))
|
||||
# UPnP/SSDP announcements from the device.
|
||||
ssdp = [s for s in ctx.service_events if s.protocol == "ssdp" and s.src_ip == ctx.device_ip]
|
||||
if ssdp:
|
||||
findings.append(Finding(
|
||||
title="Device announces itself via UPnP/SSDP",
|
||||
category=Category.INSECURE_PROTOCOL,
|
||||
severity=Severity.LOW, confidence=Confidence.HIGH,
|
||||
source_ip=ctx.device_ip, protocol="ssdp", port=1900,
|
||||
timestamp_first_seen=ssdp[0].timestamp, timestamp_last_seen=ssdp[-1].timestamp,
|
||||
risk_description=(
|
||||
"UPnP exposes a device description and control endpoints to every host on "
|
||||
"the network; UPnP control interfaces are frequently unauthenticated."
|
||||
),
|
||||
recommendation="Disable UPnP if not required, or restrict it to trusted hosts.",
|
||||
evidence={"announcements": len(ssdp), "location": ssdp[0].value, "server": ssdp[0].detail,
|
||||
"dedup_key": "device_ssdp"},
|
||||
related_packets=sorted({s.packet_number for s in ssdp if s.packet_number})[:8],
|
||||
))
|
||||
|
||||
return self._tag(findings)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Detect malware, command-and-control, and beaconing behavior."""
|
||||
"""Detect command-and-control-like beaconing and suspicious user agents."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -8,6 +8,7 @@ from typing import Iterable, Optional
|
||||
|
||||
from ..context import CaptureContext
|
||||
from ..findings import Finding, Category, Severity, Confidence
|
||||
from .. import allowlist
|
||||
from .base import BaseDetector, register
|
||||
|
||||
# Scanner and attack-tool user agents provide a strong signal.
|
||||
@@ -19,10 +20,16 @@ _SCANNER_UA = (
|
||||
_SCRIPTING_UA = (
|
||||
"curl", "wget", "python-requests", "powershell", "libwww",
|
||||
)
|
||||
# Common mobile and embedded clients are intentionally excluded to limit false positives.
|
||||
|
||||
_MIN_BEACONS = 5
|
||||
_MAX_CV = 0.25
|
||||
# Beacons repeat on a human time scale; sub-second pacing is just bulk traffic.
|
||||
_MIN_INTERVAL_S = 5.0
|
||||
_MAX_INTERVAL_S = 6 * 3600
|
||||
# Infrastructure protocols poll periodically by design.
|
||||
_PERIODIC_PORTS = {123, 53, 67, 68, 1900, 5353, 5355}
|
||||
# Bulk flows are downloads or streams, not beacons.
|
||||
_BULK_BYTES = 2 * 1024 * 1024
|
||||
|
||||
|
||||
@register
|
||||
@@ -32,10 +39,8 @@ class MalwareBeaconingDetector(BaseDetector):
|
||||
|
||||
def detect(self, ctx: CaptureContext) -> Iterable[Finding]:
|
||||
findings: list[Finding] = []
|
||||
|
||||
findings.extend(self._detect_beaconing(ctx))
|
||||
findings.extend(self._detect_suspicious_ua(ctx))
|
||||
|
||||
return self._tag(findings)
|
||||
|
||||
def _detect_beaconing(self, ctx: CaptureContext) -> list[Finding]:
|
||||
@@ -43,18 +48,20 @@ class MalwareBeaconingDetector(BaseDetector):
|
||||
findings: list[Finding] = []
|
||||
reported: set[tuple] = set()
|
||||
|
||||
# First evaluate multiple timestamps captured within a single flow.
|
||||
# First evaluate client-side activity inside a single long-lived flow.
|
||||
for conv in ctx.conversations:
|
||||
ext_ip = _external_of(ctx, conv.src_ip, conv.dst_ip)
|
||||
if not ext_ip:
|
||||
if not ext_ip or conv.bytes >= _BULK_BYTES:
|
||||
continue
|
||||
port = conv.dst_port if ext_ip == conv.dst_ip else conv.src_port
|
||||
if port in _PERIODIC_PORTS:
|
||||
continue
|
||||
ts = sorted(t for t in conv.timestamps if t is not None)
|
||||
if len(ts) < _MIN_BEACONS:
|
||||
continue
|
||||
internal = conv.dst_ip if ext_ip == conv.src_ip else conv.src_ip
|
||||
port = conv.src_port if ext_ip == conv.src_ip else conv.dst_port
|
||||
internal = conv.src_ip if ext_ip == conv.dst_ip else conv.dst_ip
|
||||
f = self._beacon_finding(
|
||||
ts, src=internal, dst=ext_ip, protocol=conv.protocol, port=port,
|
||||
ctx, _cluster_bursts(ts), src=internal, dst=ext_ip, protocol=conv.protocol, port=port,
|
||||
streams=[conv.stream_id] if conv.stream_id is not None else [],
|
||||
packets=list(conv.packet_numbers),
|
||||
)
|
||||
@@ -68,18 +75,15 @@ class MalwareBeaconingDetector(BaseDetector):
|
||||
for conv in ctx.conversations:
|
||||
if ctx.is_external(conv.dst_ip) and not ctx.is_external(conv.src_ip):
|
||||
internal, external, port = conv.src_ip, conv.dst_ip, conv.dst_port
|
||||
elif ctx.is_external(conv.src_ip) and not ctx.is_external(conv.dst_ip):
|
||||
internal, external, port = conv.dst_ip, conv.src_ip, conv.src_port
|
||||
else:
|
||||
continue
|
||||
if not (internal and external) or conv.first_seen is None:
|
||||
if not (internal and external) or conv.first_seen is None or port in _PERIODIC_PORTS:
|
||||
continue
|
||||
key = (internal, external, port)
|
||||
events[key].append(conv.first_seen)
|
||||
m = meta.setdefault(key, {"protocol": conv.protocol, "streams": set(), "packets": []})
|
||||
if conv.stream_id is not None:
|
||||
m["streams"].add(conv.stream_id)
|
||||
# One representative packet per connection keeps evidence compact.
|
||||
if conv.packet_numbers:
|
||||
m["packets"].append(conv.packet_numbers[0])
|
||||
|
||||
@@ -88,7 +92,7 @@ class MalwareBeaconingDetector(BaseDetector):
|
||||
continue
|
||||
internal, external, port = key
|
||||
f = self._beacon_finding(
|
||||
sorted(first_seens), src=internal, dst=external,
|
||||
ctx, sorted(first_seens), src=internal, dst=external,
|
||||
protocol=meta[key]["protocol"], port=port,
|
||||
streams=sorted(meta[key]["streams"]),
|
||||
packets=meta[key]["packets"],
|
||||
@@ -99,39 +103,55 @@ class MalwareBeaconingDetector(BaseDetector):
|
||||
|
||||
return findings
|
||||
|
||||
def _beacon_finding(self, ts: list[float], *, src, dst, protocol, port,
|
||||
def _beacon_finding(self, ctx: CaptureContext, ts: list[float], *, src, dst, protocol, port,
|
||||
streams, packets=None, connection_based: bool = False) -> Optional[Finding]:
|
||||
intervals = [b - a for a, b in zip(ts, ts[1:]) if (b - a) > 0]
|
||||
if len(intervals) < _MIN_BEACONS - 1:
|
||||
return None
|
||||
mean = statistics.mean(intervals)
|
||||
if mean <= 0:
|
||||
if mean < _MIN_INTERVAL_S or mean > _MAX_INTERVAL_S:
|
||||
return None
|
||||
cv = statistics.pstdev(intervals) / mean
|
||||
if cv > _MAX_CV:
|
||||
return None
|
||||
conf = Confidence.MEDIUM if cv > 0.1 else Confidence.HIGH
|
||||
|
||||
names = sorted(ctx.names_for_ip(dst))
|
||||
benign = allowlist.benign_name_for_ip(ctx, dst)
|
||||
# Known provider: expected keep-alive. Unnamed address: worth a look.
|
||||
if benign:
|
||||
sev, conf = Severity.LOW, Confidence.LOW
|
||||
note = f"The target resolves to an expected provider ({benign}); this is most likely keep-alive or telemetry traffic."
|
||||
elif names:
|
||||
sev = Severity.MEDIUM
|
||||
note = "The target has a DNS name; verify that the service is expected for this device."
|
||||
else:
|
||||
sev = Severity.HIGH
|
||||
note = "The target was contacted by IP address without a DNS lookup, which is unusual for legitimate cloud services."
|
||||
|
||||
evidence = {
|
||||
"beacon_count": len(ts),
|
||||
"interval_seconds_mean": round(mean, 2),
|
||||
"interval_cv": round(cv, 3),
|
||||
"signal": "distinct_connections" if connection_based else "single_flow",
|
||||
"target_names": names[:5],
|
||||
"dedup_key": f"beacon:{src}:{dst}:{port}",
|
||||
}
|
||||
return Finding(
|
||||
title="Periodic beaconing to an external target",
|
||||
title="Periodic connections to an external target",
|
||||
category=Category.MALWARE_BEACONING,
|
||||
severity=Severity.HIGH, confidence=conf,
|
||||
severity=sev, confidence=conf,
|
||||
source_ip=src, destination_ip=dst,
|
||||
protocol=protocol, port=port,
|
||||
timestamp_first_seen=ts[0], timestamp_last_seen=ts[-1],
|
||||
risk_description=(
|
||||
"The device contacts an external target at highly regular intervals, "
|
||||
"which is characteristic of command-and-control beaconing."
|
||||
"The host contacts an external target at highly regular intervals "
|
||||
f"(about every {round(mean)} s). Regular check-ins are characteristic of "
|
||||
"command-and-control beaconing, but also of legitimate telemetry. " + note
|
||||
),
|
||||
recommendation=(
|
||||
"Check the target against threat intelligence, isolate the device, and "
|
||||
"investigate it for compromise."
|
||||
"Confirm the target belongs to the device vendor or an expected service. "
|
||||
"If unknown, check it against threat intelligence and isolate the device."
|
||||
),
|
||||
evidence=evidence,
|
||||
related_packets=sorted({p for p in (packets or []) if p is not None}),
|
||||
@@ -182,6 +202,17 @@ class MalwareBeaconingDetector(BaseDetector):
|
||||
return findings
|
||||
|
||||
|
||||
def _cluster_bursts(ts: list[float], gap: float = 2.0) -> list[float]:
|
||||
"""Collapse packet bursts into one event so packet pacing is not mistaken for periodicity."""
|
||||
if not ts:
|
||||
return ts
|
||||
out = [ts[0]]
|
||||
for t in ts[1:]:
|
||||
if t - out[-1] >= gap:
|
||||
out.append(t)
|
||||
return out
|
||||
|
||||
|
||||
def _external_of(ctx: CaptureContext, src_ip, dst_ip) -> Optional[str]:
|
||||
"""Return the external address in a pair, if present."""
|
||||
if ctx.is_external(dst_ip):
|
||||
|
||||
@@ -5,74 +5,177 @@ from __future__ import annotations
|
||||
from collections import defaultdict
|
||||
from typing import Iterable
|
||||
|
||||
from ..context import CaptureContext
|
||||
from ..context import CaptureContext, is_special
|
||||
from ..findings import Finding, Category, Severity, Confidence
|
||||
from .base import BaseDetector, register
|
||||
|
||||
_ARP_REPLY_RATE_PER_MIN = 60
|
||||
_MIN_ARP_REPLIES = 100
|
||||
|
||||
|
||||
@register
|
||||
class MitmDetector(BaseDetector):
|
||||
name = "MitmDetector"
|
||||
description = "Detects ARP spoofing, duplicate IP mappings, and ARP floods."
|
||||
description = "Detects ARP conflicts, ARP floods, rogue DHCP servers and ICMP redirects."
|
||||
|
||||
def detect(self, ctx: CaptureContext) -> Iterable[Finding]:
|
||||
findings: list[Finding] = []
|
||||
|
||||
# Map each IP address to MAC addresses observed in ARP responses.
|
||||
ip_to_macs: dict[str, set[str]] = defaultdict(set)
|
||||
first_seen: dict[str, float] = {}
|
||||
last_seen: dict[str, float] = {}
|
||||
# Map each IP address to MAC addresses that claimed it, in time order.
|
||||
ip_to_macs: dict[str, dict[str, float]] = defaultdict(dict)
|
||||
packets_for_ip: dict[str, list[int]] = defaultdict(list)
|
||||
|
||||
def claim(ip, mac, ts, pnum):
|
||||
if not ip or not mac or is_special(ip):
|
||||
return
|
||||
mac = mac.lower()
|
||||
if mac in ("ff:ff:ff:ff:ff:ff", "00:00:00:00:00:00") or mac.startswith(("01:00:5e", "33:33")):
|
||||
return
|
||||
first = ip_to_macs[ip].get(mac)
|
||||
if first is None or (ts is not None and ts < first):
|
||||
ip_to_macs[ip][mac] = ts if ts is not None else float("inf")
|
||||
if pnum:
|
||||
packets_for_ip[ip].append(pnum)
|
||||
|
||||
for ev in ctx.arp_events:
|
||||
if ev.sender_ip and ev.sender_mac:
|
||||
ip_to_macs[ev.sender_ip].add(ev.sender_mac)
|
||||
if ev.timestamp is not None:
|
||||
first_seen[ev.sender_ip] = min(first_seen.get(ev.sender_ip, ev.timestamp), ev.timestamp)
|
||||
last_seen[ev.sender_ip] = max(last_seen.get(ev.sender_ip, ev.timestamp), ev.timestamp)
|
||||
if ev.packet_number:
|
||||
packets_for_ip[ev.sender_ip].append(ev.packet_number)
|
||||
if ev.opcode in (1, 2):
|
||||
claim(ev.sender_ip, ev.sender_mac, ev.timestamp, ev.packet_number)
|
||||
|
||||
for ip, host in ctx.hosts.items():
|
||||
if host.macs:
|
||||
ip_to_macs[ip].update(host.macs)
|
||||
if host.is_private and not is_special(ip):
|
||||
for mac in host.macs:
|
||||
claim(ip, mac, host.first_seen, None)
|
||||
|
||||
for ip, macs in ip_to_macs.items():
|
||||
if len(macs) > 1:
|
||||
ordered = sorted(macs.items(), key=lambda kv: kv[1])
|
||||
times = [t for _, t in ordered if t != float("inf")]
|
||||
findings.append(Finding(
|
||||
title="Multiple MAC addresses associated with one IP",
|
||||
category=Category.MITM,
|
||||
severity=Severity.HIGH, confidence=Confidence.MEDIUM,
|
||||
source_ip=ip,
|
||||
protocol="arp",
|
||||
timestamp_first_seen=first_seen.get(ip),
|
||||
timestamp_last_seen=last_seen.get(ip),
|
||||
timestamp_first_seen=times[0] if times else None,
|
||||
timestamp_last_seen=times[-1] if times else None,
|
||||
risk_description=(
|
||||
"One IP address was advertised by multiple MAC addresses, a common "
|
||||
"indicator of ARP spoofing or traffic interception."
|
||||
"One IP address was claimed by multiple MAC addresses. This happens with "
|
||||
"ARP spoofing and traffic interception, but also with a DHCP lease that "
|
||||
"moved between devices or a router fail-over."
|
||||
),
|
||||
recommendation=(
|
||||
"Review ARP tables, enable Dynamic ARP Inspection and port security, "
|
||||
"and identify the conflicting host."
|
||||
"Identify both hosts by MAC address, review ARP tables, and enable "
|
||||
"Dynamic ARP Inspection or port security."
|
||||
),
|
||||
evidence={"ip": ip, "macs": sorted(macs), "dedup_key": f"arpdup:{ip}"},
|
||||
evidence={"ip": ip, "macs": [m for m, _ in ordered],
|
||||
"device": ctx.is_device(ip), "dedup_key": f"arpdup:{ip}"},
|
||||
related_packets=sorted(packets_for_ip.get(ip, []))[:10],
|
||||
))
|
||||
|
||||
# An ARP reply flood can accompany active cache poisoning.
|
||||
grat = [e for e in ctx.arp_events if e.opcode == 2]
|
||||
if len(grat) >= 50:
|
||||
# ARP reply floods: judge by rate, not by the absolute count in a long capture.
|
||||
replies = [e for e in ctx.arp_events if e.opcode == 2]
|
||||
requests = [e for e in ctx.arp_events if e.opcode == 1]
|
||||
minutes = max(ctx.duration / 60.0, 1.0)
|
||||
if len(replies) >= _MIN_ARP_REPLIES and len(replies) / minutes >= _ARP_REPLY_RATE_PER_MIN \
|
||||
and len(replies) > 2 * max(len(requests), 1):
|
||||
by_sender: dict[str, int] = defaultdict(int)
|
||||
for e in replies:
|
||||
by_sender[e.sender_mac or "?"] += 1
|
||||
top = sorted(by_sender.items(), key=lambda kv: -kv[1])[:3]
|
||||
findings.append(Finding(
|
||||
title="Unusually high number of ARP replies",
|
||||
title="Unusually high ARP reply rate",
|
||||
category=Category.MITM,
|
||||
severity=Severity.MEDIUM, confidence=Confidence.LOW,
|
||||
severity=Severity.MEDIUM, confidence=Confidence.MEDIUM,
|
||||
protocol="arp",
|
||||
timestamp_first_seen=replies[0].timestamp, timestamp_last_seen=replies[-1].timestamp,
|
||||
risk_description=(
|
||||
"A large number of ARP replies may indicate active ARP cache poisoning."
|
||||
"Far more ARP replies than requests were seen at a high rate, which is "
|
||||
"characteristic of active ARP cache poisoning."
|
||||
),
|
||||
recommendation="Inspect the reply source and investigate possible interception.",
|
||||
evidence={"arp_reply_count": len(grat), "dedup_key": "arp_reply_flood"},
|
||||
related_packets=sorted({e.packet_number for e in grat if e.packet_number})[:8],
|
||||
evidence={"arp_replies": len(replies), "arp_requests": len(requests),
|
||||
"replies_per_minute": round(len(replies) / minutes, 1),
|
||||
"top_senders": [f"{m} ({n})" for m, n in top],
|
||||
"dedup_key": "arp_reply_flood"},
|
||||
related_packets=sorted({e.packet_number for e in replies if e.packet_number})[:8],
|
||||
))
|
||||
|
||||
# Gratuitous ARP claiming an address that already belongs to another MAC.
|
||||
seen_owner: dict[str, str] = {}
|
||||
for e in sorted(ctx.arp_events, key=lambda e: e.timestamp or 0):
|
||||
if not e.sender_ip or not e.sender_mac or is_special(e.sender_ip):
|
||||
continue
|
||||
owner = seen_owner.setdefault(e.sender_ip, e.sender_mac)
|
||||
if e.is_gratuitous and owner != e.sender_mac:
|
||||
findings.append(Finding(
|
||||
title="Gratuitous ARP overriding an existing address owner",
|
||||
category=Category.MITM,
|
||||
severity=Severity.HIGH, confidence=Confidence.MEDIUM,
|
||||
source_ip=e.sender_ip, protocol="arp",
|
||||
timestamp_first_seen=e.timestamp, timestamp_last_seen=e.timestamp,
|
||||
risk_description=(
|
||||
"A host announced an IP address that another MAC address had been using, "
|
||||
"which redirects traffic for that address to the announcing host."
|
||||
),
|
||||
recommendation="Identify the announcing host and check for interception.",
|
||||
evidence={"ip": e.sender_ip, "previous_mac": owner, "new_mac": e.sender_mac,
|
||||
"dedup_key": f"garp:{e.sender_ip}:{e.sender_mac}"},
|
||||
related_packets=[p for p in [e.packet_number] if p],
|
||||
))
|
||||
|
||||
# More than one DHCP server answering on the segment.
|
||||
servers: dict[str, list] = defaultdict(list)
|
||||
for d in ctx.dhcp_events:
|
||||
if d.message_type in ("offer", "ack") and (d.server_id or d.src_ip):
|
||||
servers[d.server_id or d.src_ip].append(d)
|
||||
if len(servers) > 1:
|
||||
findings.append(Finding(
|
||||
title="Multiple DHCP servers observed",
|
||||
category=Category.MITM,
|
||||
severity=Severity.MEDIUM, confidence=Confidence.MEDIUM,
|
||||
protocol="dhcp", port=67,
|
||||
risk_description=(
|
||||
"Offers or acknowledgements came from more than one DHCP server. A rogue "
|
||||
"DHCP server can hand out a malicious gateway or DNS resolver."
|
||||
),
|
||||
recommendation="Verify every DHCP server on the segment and enable DHCP snooping.",
|
||||
evidence={"servers": sorted(servers), "dedup_key": "dhcp_multi_server"},
|
||||
related_packets=sorted({d.packet_number for evs in servers.values() for d in evs if d.packet_number})[:8],
|
||||
))
|
||||
|
||||
# ICMP redirects and IPv6 router advertisements from unexpected sources.
|
||||
redirects = [s for s in ctx.service_events if s.kind == "redirect"]
|
||||
if redirects:
|
||||
srcs = sorted({s.src_ip for s in redirects if s.src_ip})
|
||||
findings.append(Finding(
|
||||
title="ICMP redirect messages observed",
|
||||
category=Category.MITM,
|
||||
severity=Severity.MEDIUM, confidence=Confidence.MEDIUM,
|
||||
source_ip=srcs[0] if srcs else None, protocol="icmp",
|
||||
timestamp_first_seen=redirects[0].timestamp, timestamp_last_seen=redirects[-1].timestamp,
|
||||
risk_description=(
|
||||
"ICMP redirects change a host's routing table and are a known technique to "
|
||||
"insert a device into the traffic path."
|
||||
),
|
||||
recommendation="Disable acceptance of ICMP redirects on hosts and verify the sender.",
|
||||
evidence={"count": len(redirects), "sources": srcs[:5], "dedup_key": "icmp_redirect"},
|
||||
related_packets=sorted({s.packet_number for s in redirects if s.packet_number})[:8],
|
||||
))
|
||||
ras = [s for s in ctx.service_events if s.kind == "router_advertisement"]
|
||||
ra_sources = sorted({s.src_ip for s in ras if s.src_ip})
|
||||
if len(ra_sources) > 1:
|
||||
findings.append(Finding(
|
||||
title="IPv6 router advertisements from multiple sources",
|
||||
category=Category.MITM,
|
||||
severity=Severity.MEDIUM, confidence=Confidence.LOW,
|
||||
protocol="icmpv6",
|
||||
risk_description=(
|
||||
"More than one host advertises itself as an IPv6 router. A rogue "
|
||||
"advertisement can redirect all IPv6 traffic through an attacker."
|
||||
),
|
||||
recommendation="Verify the legitimate router and enable RA Guard on switches.",
|
||||
evidence={"sources": ra_sources[:5], "dedup_key": "rogue_ra"},
|
||||
related_packets=sorted({s.packet_number for s in ras if s.packet_number})[:8],
|
||||
))
|
||||
|
||||
return self._tag(findings)
|
||||
|
||||
@@ -3,54 +3,77 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import re
|
||||
from typing import Iterable
|
||||
from typing import Iterable, Optional
|
||||
from urllib.parse import parse_qsl, urlsplit
|
||||
|
||||
from ..context import CaptureContext
|
||||
from ..findings import Finding, Category, Severity, Confidence
|
||||
from .base import BaseDetector, register
|
||||
|
||||
# Common form fields used for credentials.
|
||||
_USER_FIELDS = ("user", "username", "uname", "login", "email", "uid")
|
||||
_PASS_FIELDS = ("pass", "passwd", "password", "pwd", "pw")
|
||||
_FORM_RE = re.compile(r"([A-Za-z0-9_\-\.]+)=([^&\s]+)")
|
||||
# Common form and JSON fields used for credentials.
|
||||
_USER_FIELDS = ("user", "username", "uname", "login", "email", "uid", "account", "usr", "name")
|
||||
_PASS_FIELDS = ("pass", "passwd", "password", "pwd", "pw", "passcode", "pin", "secret")
|
||||
_FORM_RE = re.compile(r"([A-Za-z0-9_\-\.\[\]]+)=([^&\s]*)")
|
||||
# "Authorization: Basic" in HTTP-like clear-text protocols (RTSP, SIP, ...).
|
||||
_BASIC_HEADER_RE = re.compile(r"(?im)^(?:Proxy-)?Authorization:\s*Basic\s+([A-Za-z0-9+/=]+)")
|
||||
|
||||
_PROTOCOL_ALTERNATIVES = {
|
||||
"ftp": "SFTP or FTPS", "telnet": "SSH", "pop3": "POP3S or STARTTLS",
|
||||
"imap": "IMAPS or STARTTLS", "smtp": "SMTP with STARTTLS or submission over TLS",
|
||||
"mqtt": "MQTT over TLS (port 8883) with client certificates",
|
||||
"snmp": "SNMPv3 with authentication and privacy", "http": "HTTPS",
|
||||
"rtsp": "RTSPS or a VPN", "sip": "SIP over TLS",
|
||||
}
|
||||
|
||||
|
||||
@register
|
||||
class PlaintextCredentialDetector(BaseDetector):
|
||||
name = "PlaintextCredentialDetector"
|
||||
description = "Detects usernames and passwords transmitted without encryption."
|
||||
description = "Detects usernames, passwords and community strings transmitted without encryption."
|
||||
|
||||
def detect(self, ctx: CaptureContext) -> Iterable[Finding]:
|
||||
findings: list[Finding] = []
|
||||
|
||||
for pa in ctx.plaintext_auth:
|
||||
if pa.username or pa.password:
|
||||
findings.append(Finding(
|
||||
title=f"Clear-text credentials over {pa.protocol.upper()}",
|
||||
category=Category.PLAINTEXT_CREDENTIALS,
|
||||
severity=Severity.CRITICAL,
|
||||
confidence=Confidence.HIGH,
|
||||
source_ip=pa.src_ip, destination_ip=pa.dst_ip,
|
||||
protocol=pa.protocol, port=pa.dst_port,
|
||||
timestamp_first_seen=pa.timestamp, timestamp_last_seen=pa.timestamp,
|
||||
risk_description=(
|
||||
"A username or password was transmitted without encryption. "
|
||||
"A network observer could take over the account."
|
||||
),
|
||||
recommendation=(
|
||||
f"Replace {pa.protocol.upper()} with an encrypted alternative "
|
||||
"such as SFTP, IMAPS, POP3S, or SMTP with STARTTLS, then rotate "
|
||||
"the affected password."
|
||||
),
|
||||
evidence={
|
||||
"username": pa.username,
|
||||
"password": _mask(pa.password),
|
||||
"dedup_key": f"plain:{pa.protocol}:{pa.src_ip}:{pa.dst_ip}:{pa.username}",
|
||||
},
|
||||
related_packets=[p for p in [pa.packet_number] if p],
|
||||
related_streams=[s for s in [pa.stream_id] if s],
|
||||
))
|
||||
if not (pa.username or pa.password):
|
||||
continue
|
||||
proto = pa.protocol.lower()
|
||||
if proto == "snmp":
|
||||
title = "Clear-text SNMP community string"
|
||||
risk = ("An SNMPv1/v2c community string was observed. It acts as a password and "
|
||||
"allows anyone on the path to read or change device settings.")
|
||||
else:
|
||||
title = f"Clear-text credentials over {proto.upper()}"
|
||||
risk = ("A username or password was transmitted without encryption. "
|
||||
"A network observer could take over the account.")
|
||||
if pa.outcome == "failure":
|
||||
title += " (login rejected)"
|
||||
evidence = {
|
||||
"username": pa.username,
|
||||
"password": _mask(pa.password),
|
||||
"password_length": len(pa.password) if pa.password else None,
|
||||
"outcome": pa.outcome,
|
||||
"dedup_key": f"plain:{proto}:{pa.src_ip}:{pa.dst_ip}:{pa.username}:{_mask(pa.password)}",
|
||||
}
|
||||
findings.append(Finding(
|
||||
title=title,
|
||||
category=Category.PLAINTEXT_CREDENTIALS,
|
||||
severity=Severity.CRITICAL if proto != "snmp" else Severity.HIGH,
|
||||
confidence=Confidence.HIGH,
|
||||
source_ip=pa.src_ip, destination_ip=pa.dst_ip,
|
||||
protocol=proto, port=pa.dst_port,
|
||||
timestamp_first_seen=pa.timestamp, timestamp_last_seen=pa.timestamp,
|
||||
risk_description=risk,
|
||||
recommendation=(
|
||||
f"Replace {proto.upper()} with {_PROTOCOL_ALTERNATIVES.get(proto, 'an encrypted alternative')}, "
|
||||
"then rotate the affected credential."
|
||||
),
|
||||
evidence=evidence,
|
||||
related_packets=[p for p in [pa.packet_number] if p],
|
||||
related_streams=[s for s in [pa.stream_id] if s is not None],
|
||||
))
|
||||
|
||||
for req in ctx.http_requests:
|
||||
if req.is_tls:
|
||||
@@ -58,59 +81,139 @@ class PlaintextCredentialDetector(BaseDetector):
|
||||
auth = (req.authorization or "").strip()
|
||||
if auth.lower().startswith("basic "):
|
||||
user, pwd = _decode_basic(auth[6:])
|
||||
findings.append(self._basic_finding(
|
||||
req.src_ip, req.dst_ip, req.dst_port, req.timestamp, req.packet_number,
|
||||
user, pwd, host=req.host, uri=req.uri, protocol="http"))
|
||||
|
||||
# Login forms: URL-encoded or JSON bodies, and credentials in query strings.
|
||||
creds = None
|
||||
if req.method and req.method.upper() in ("POST", "PUT", "PATCH") and req.body:
|
||||
creds = _credentials_in_body(req.body, req.content_type)
|
||||
if creds is None and req.uri and "?" in req.uri:
|
||||
creds = _credentials_in_query(req.uri)
|
||||
if creds:
|
||||
user_val, pass_val, where = creds
|
||||
findings.append(Finding(
|
||||
title="Clear-text HTTP Basic authentication",
|
||||
title="Login form submitted over clear-text HTTP",
|
||||
category=Category.PLAINTEXT_CREDENTIALS,
|
||||
severity=Severity.CRITICAL,
|
||||
confidence=Confidence.HIGH,
|
||||
confidence=Confidence.HIGH if user_val else Confidence.MEDIUM,
|
||||
source_ip=req.src_ip, destination_ip=req.dst_ip,
|
||||
protocol="http", port=req.dst_port,
|
||||
timestamp_first_seen=req.timestamp, timestamp_last_seen=req.timestamp,
|
||||
risk_description=(
|
||||
"HTTP Basic authentication sends credentials with Base64 encoding, "
|
||||
"not encryption, over clear-text HTTP."
|
||||
f"A login was submitted in a clear-text HTTP request ({where}). "
|
||||
"The credentials are visible to network observers."
|
||||
),
|
||||
recommendation="Require HTTPS and rotate the affected password.",
|
||||
recommendation="Serve the login only over HTTPS and rotate the password.",
|
||||
evidence={
|
||||
"host": req.host, "uri": req.uri,
|
||||
"username": user, "password": _mask(pwd),
|
||||
"dedup_key": f"basic:{req.src_ip}:{req.dst_ip}:{user}",
|
||||
"username": user_val, "password": _mask(pass_val),
|
||||
"location": where,
|
||||
"dedup_key": f"form:{req.src_ip}:{req.dst_ip}:{req.uri}:{user_val}",
|
||||
},
|
||||
related_packets=[p for p in [req.packet_number] if p],
|
||||
))
|
||||
|
||||
# Detect likely login forms in clear-text HTTP request bodies.
|
||||
if req.method and req.method.upper() == "POST" and req.body:
|
||||
fields = dict(_FORM_RE.findall(req.body))
|
||||
lowered = {k.lower(): v for k, v in fields.items()}
|
||||
has_user = any(k in lowered for k in _USER_FIELDS)
|
||||
has_pass = any(k in lowered for k in _PASS_FIELDS)
|
||||
if has_pass and (has_user or len(fields) <= 4):
|
||||
user_val = next((lowered[k] for k in _USER_FIELDS if k in lowered), None)
|
||||
pass_val = next((lowered[k] for k in _PASS_FIELDS if k in lowered), None)
|
||||
findings.append(Finding(
|
||||
title="Login form submitted over clear-text HTTP",
|
||||
category=Category.PLAINTEXT_CREDENTIALS,
|
||||
severity=Severity.CRITICAL,
|
||||
confidence=Confidence.MEDIUM,
|
||||
source_ip=req.src_ip, destination_ip=req.dst_ip,
|
||||
protocol="http", port=req.dst_port,
|
||||
timestamp_first_seen=req.timestamp, timestamp_last_seen=req.timestamp,
|
||||
risk_description=(
|
||||
"A login form was submitted in a clear-text HTTP request. "
|
||||
"The credentials are visible to network observers."
|
||||
),
|
||||
recommendation="Serve the form only over HTTPS and rotate the password.",
|
||||
evidence={
|
||||
"host": req.host, "uri": req.uri,
|
||||
"username": user_val, "password": _mask(pass_val),
|
||||
"dedup_key": f"form:{req.src_ip}:{req.dst_ip}:{user_val}",
|
||||
},
|
||||
related_packets=[p for p in [req.packet_number] if p],
|
||||
))
|
||||
# Basic authentication in other clear-text protocols with HTTP-like headers.
|
||||
seen_basic: set[str] = set()
|
||||
for payload in ctx.text_payloads:
|
||||
proto = (payload.get("protocol") or "").lower()
|
||||
if proto == "http":
|
||||
continue # Covered via parsed HTTP requests.
|
||||
for token in _BASIC_HEADER_RE.findall(payload.get("text") or ""):
|
||||
key = f"{proto}:{payload.get('src_ip')}:{payload.get('dst_ip')}:{token[:32]}"
|
||||
if key in seen_basic:
|
||||
continue
|
||||
seen_basic.add(key)
|
||||
user, pwd = _decode_basic(token)
|
||||
findings.append(self._basic_finding(
|
||||
payload.get("src_ip"), payload.get("dst_ip"), None, payload.get("timestamp"),
|
||||
payload.get("packet_number"), user, pwd, protocol=proto or "tcp"))
|
||||
|
||||
return self._tag(findings)
|
||||
|
||||
def _basic_finding(self, src, dst, port, ts, pkt, user, pwd, *, protocol, host=None, uri=None) -> Finding:
|
||||
return Finding(
|
||||
title=f"Clear-text Basic authentication over {protocol.upper()}",
|
||||
category=Category.PLAINTEXT_CREDENTIALS,
|
||||
severity=Severity.CRITICAL,
|
||||
confidence=Confidence.HIGH,
|
||||
source_ip=src, destination_ip=dst,
|
||||
protocol=protocol, port=port,
|
||||
timestamp_first_seen=ts, timestamp_last_seen=ts,
|
||||
risk_description=(
|
||||
"Basic authentication only Base64-encodes the credentials; over an unencrypted "
|
||||
"transport they are readable by anyone on the path."
|
||||
),
|
||||
recommendation=f"Require {_PROTOCOL_ALTERNATIVES.get(protocol, 'an encrypted transport')} and rotate the affected password.",
|
||||
evidence={
|
||||
"host": host, "uri": uri,
|
||||
"username": user, "password": _mask(pwd),
|
||||
"dedup_key": f"basic:{protocol}:{src}:{dst}:{user}",
|
||||
},
|
||||
related_packets=[p for p in [pkt] if p],
|
||||
)
|
||||
|
||||
|
||||
def _credentials_in_body(body: str, content_type: Optional[str]):
|
||||
text = body.strip()
|
||||
ct = (content_type or "").lower()
|
||||
# JSON bodies used by most device web interfaces and mobile apps.
|
||||
if text.startswith("{") or "json" in ct:
|
||||
try:
|
||||
data = json.loads(text)
|
||||
except ValueError:
|
||||
data = None
|
||||
if isinstance(data, dict):
|
||||
flat = _flatten_json(data)
|
||||
found = _pick(flat)
|
||||
if found:
|
||||
return found[0], found[1], "JSON body"
|
||||
if "=" in text and (not text.startswith("{")):
|
||||
fields = {k.lower(): v for k, v in _FORM_RE.findall(text)}
|
||||
found = _pick(fields)
|
||||
if found:
|
||||
return found[0], found[1], "form body"
|
||||
return None
|
||||
|
||||
|
||||
def _credentials_in_query(uri: str):
|
||||
try:
|
||||
query = urlsplit(uri).query
|
||||
except ValueError:
|
||||
return None
|
||||
if not query:
|
||||
return None
|
||||
fields = {k.lower(): v for k, v in parse_qsl(query, keep_blank_values=True)}
|
||||
found = _pick(fields)
|
||||
if found:
|
||||
return found[0], found[1], "URL query string"
|
||||
return None
|
||||
|
||||
|
||||
def _flatten_json(data, prefix: str = "", out: Optional[dict] = None) -> dict:
|
||||
out = out if out is not None else {}
|
||||
if isinstance(data, dict):
|
||||
for k, v in data.items():
|
||||
_flatten_json(v, str(k).lower(), out)
|
||||
elif isinstance(data, list):
|
||||
for v in data[:20]:
|
||||
_flatten_json(v, prefix, out)
|
||||
else:
|
||||
if prefix and prefix not in out:
|
||||
out[prefix] = "" if data is None else str(data)
|
||||
return out
|
||||
|
||||
|
||||
def _pick(fields: dict):
|
||||
"""Return (username, password) when a password-like field carries a value."""
|
||||
pass_key = next((k for k in fields if k in _PASS_FIELDS or k.endswith("password") or k.endswith("passwd")), None)
|
||||
if pass_key is None or not fields.get(pass_key):
|
||||
return None
|
||||
user_key = next((k for k in fields if k in _USER_FIELDS or k.endswith("username") or k.endswith("user")), None)
|
||||
return (fields.get(user_key) if user_key else None), fields[pass_key]
|
||||
|
||||
|
||||
def _decode_basic(b64: str):
|
||||
try:
|
||||
|
||||
@@ -1,93 +1,160 @@
|
||||
"""Detect weak, obsolete, or suspicious TLS connections."""
|
||||
"""Detect weak, obsolete, or suspicious TLS configurations.
|
||||
|
||||
Negotiated parameters (ServerHello) are judged strictly; what a client merely
|
||||
offers is reported at lower severity.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from typing import Iterable
|
||||
|
||||
from ..context import CaptureContext
|
||||
from ..context import CaptureContext, TlsSession
|
||||
from ..findings import Finding, Category, Severity, Confidence
|
||||
from .. import tls_tables
|
||||
from .base import BaseDetector, register
|
||||
|
||||
_WEAK_VERSIONS = {"SSL 2.0", "SSL 3.0", "TLS 1.0", "TLS 1.1"}
|
||||
# Weak cipher markers matched against normalized suite names.
|
||||
_WEAK_CIPHER_MARKERS = ("RC4", "NULL", "EXPORT", "_DES_", "DES_CBC", "3DES", "MD5", "ANON")
|
||||
_FAILURE_ALERTS = {"handshake_failure", "bad_certificate", "unknown_ca", "certificate_expired",
|
||||
"certificate_unknown", "unsupported_certificate", "protocol_version",
|
||||
"insufficient_security", "certificate_revoked", "decrypt_error"}
|
||||
|
||||
|
||||
@register
|
||||
class TlsWeaknessDetector(BaseDetector):
|
||||
name = "TlsWeaknessDetector"
|
||||
description = "Detects obsolete TLS versions, weak ciphers, and certificate anomalies."
|
||||
description = "Detects obsolete TLS versions, weak or legacy cipher suites, certificate problems and handshake failures."
|
||||
|
||||
def detect(self, ctx: CaptureContext) -> Iterable[Finding]:
|
||||
findings: list[Finding] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
def emit(key, **kw):
|
||||
if key in seen:
|
||||
return
|
||||
seen.add(key)
|
||||
kw.setdefault("category", Category.TLS_WEAKNESS)
|
||||
kw.setdefault("protocol", "tls")
|
||||
kw["evidence"] = dict(kw.get("evidence") or {}, dedup_key=key)
|
||||
findings.append(Finding(**kw))
|
||||
|
||||
for s in ctx.tls_sessions:
|
||||
target = s.sni or s.dst_ip
|
||||
base_pkt = [p for p in [s.packet_number] if p]
|
||||
pkts = [p for p in [s.packet_number] if p]
|
||||
common = dict(source_ip=s.src_ip, destination_ip=s.dst_ip, port=s.dst_port,
|
||||
timestamp_first_seen=s.timestamp, timestamp_last_seen=s.timestamp,
|
||||
related_packets=pkts)
|
||||
|
||||
if s.version and s.version in _WEAK_VERSIONS:
|
||||
key = f"tlsver:{target}:{s.version}"
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
findings.append(Finding(
|
||||
title=f"Obsolete TLS version: {s.version}",
|
||||
category=Category.TLS_WEAKNESS,
|
||||
severity=Severity.MEDIUM, confidence=Confidence.HIGH,
|
||||
source_ip=s.src_ip, destination_ip=s.dst_ip, protocol="tls", port=s.dst_port,
|
||||
timestamp_first_seen=s.timestamp, timestamp_last_seen=s.timestamp,
|
||||
risk_description=(
|
||||
"An obsolete TLS or SSL version is in use and is vulnerable to "
|
||||
"known protocol attacks."
|
||||
),
|
||||
recommendation="Require at least TLS 1.2 and prefer TLS 1.3.",
|
||||
evidence={"version": s.version, "target": target, "dedup_key": key},
|
||||
related_packets=base_pkt,
|
||||
))
|
||||
# 1) Negotiated version and cipher (ServerHello seen).
|
||||
if s.server_hello_seen and s.version in _WEAK_VERSIONS:
|
||||
emit(f"tlsver:{s.src_ip}:{target}:{s.version}",
|
||||
title=f"Obsolete TLS version negotiated: {s.version}",
|
||||
severity=Severity.HIGH, confidence=Confidence.HIGH,
|
||||
risk_description=("The connection was established with an obsolete protocol version "
|
||||
"that is vulnerable to known attacks."),
|
||||
recommendation="Require at least TLS 1.2 and prefer TLS 1.3 on both sides.",
|
||||
evidence={"version": s.version, "target": target, "cipher": s.cipher}, **common)
|
||||
if s.server_hello_seen and s.cipher:
|
||||
weak = tls_tables.cipher_is_weak(s.cipher)
|
||||
legacy = tls_tables.cipher_is_legacy(s.cipher)
|
||||
if weak:
|
||||
emit(f"tlscipher:{s.src_ip}:{target}:{s.cipher}",
|
||||
title=f"Weak TLS cipher suite negotiated ({weak})",
|
||||
severity=Severity.HIGH, confidence=Confidence.HIGH,
|
||||
risk_description=f"The negotiated cipher suite {s.cipher} is considered broken: {weak}.",
|
||||
recommendation="Allow only AEAD suites (AES-GCM, ChaCha20-Poly1305) with ECDHE key exchange.",
|
||||
evidence={"cipher": s.cipher, "target": target, "version": s.version}, **common)
|
||||
elif legacy:
|
||||
emit(f"tlslegacy:{s.src_ip}:{target}:{s.cipher}",
|
||||
title="Legacy TLS cipher suite negotiated",
|
||||
severity=Severity.LOW, confidence=Confidence.HIGH,
|
||||
risk_description=f"The negotiated cipher suite {s.cipher} is outdated: {legacy}.",
|
||||
recommendation="Prefer TLS 1.3 or TLS 1.2 with ECDHE and AEAD cipher suites.",
|
||||
evidence={"cipher": s.cipher, "target": target, "version": s.version}, **common)
|
||||
|
||||
if s.cipher:
|
||||
up = str(s.cipher).upper()
|
||||
if any(m in up for m in _WEAK_CIPHER_MARKERS):
|
||||
key = f"tlscipher:{target}:{s.cipher}"
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
findings.append(Finding(
|
||||
title="Weak TLS cipher suite",
|
||||
category=Category.TLS_WEAKNESS,
|
||||
severity=Severity.MEDIUM, confidence=Confidence.MEDIUM,
|
||||
source_ip=s.src_ip, destination_ip=s.dst_ip, protocol="tls", port=s.dst_port,
|
||||
timestamp_first_seen=s.timestamp, timestamp_last_seen=s.timestamp,
|
||||
risk_description=(
|
||||
"A weak or obsolete cipher suite reduces transport protection."
|
||||
),
|
||||
recommendation="Allow only modern AEAD ciphers such as AES-GCM or ChaCha20.",
|
||||
evidence={"cipher": s.cipher, "target": target, "dedup_key": key},
|
||||
related_packets=base_pkt,
|
||||
))
|
||||
# 2) Client capabilities (what the device would accept).
|
||||
if s.client_hello_seen:
|
||||
if s.client_version in _WEAK_VERSIONS:
|
||||
emit(f"clientver:{s.src_ip}:{s.client_version}",
|
||||
title=f"Client supports at most {s.client_version}",
|
||||
severity=Severity.MEDIUM, confidence=Confidence.HIGH,
|
||||
risk_description=("The client's highest offered protocol version is obsolete, so every "
|
||||
"connection it makes uses a version with known weaknesses."),
|
||||
recommendation="Update the device TLS stack to support TLS 1.2 or TLS 1.3.",
|
||||
evidence={"client_max_version": s.client_version, "offered": s.client_offered_versions,
|
||||
"target": target}, **common)
|
||||
elif s.client_version == "TLS 1.2" and ctx.is_device(s.src_ip):
|
||||
emit(f"clientno13:{s.src_ip}",
|
||||
title="Device does not offer TLS 1.3",
|
||||
severity=Severity.INFO, confidence=Confidence.HIGH,
|
||||
risk_description="The device negotiates at most TLS 1.2. This is acceptable today but limits forward compatibility.",
|
||||
recommendation="Consider adding TLS 1.3 support in a future firmware.",
|
||||
evidence={"client_max_version": s.client_version, "target": target}, **common)
|
||||
weak_offers = [(c, tls_tables.cipher_is_weak(c)) for c in s.client_offered_ciphers]
|
||||
weak_offers = [(c, r) for c, r in weak_offers if r]
|
||||
if weak_offers:
|
||||
emit(f"clientweak:{s.src_ip}",
|
||||
title="Client offers broken cipher suites",
|
||||
severity=Severity.MEDIUM if ctx.is_device(s.src_ip) else Severity.LOW,
|
||||
confidence=Confidence.HIGH,
|
||||
risk_description=("The client advertises cipher suites with known weaknesses. A "
|
||||
"downgrade or a misconfigured server could select one of them."),
|
||||
recommendation="Remove obsolete cipher suites from the client's TLS configuration.",
|
||||
evidence={"weak_ciphers": [f"{c} ({r})" for c, r in weak_offers][:8],
|
||||
"offered_total": len(s.client_offered_ciphers), "target": target}, **common)
|
||||
if ctx.is_external(s.dst_ip) and not s.sni and s.transport == "tcp":
|
||||
emit(f"nosni:{s.src_ip}:{s.dst_ip}",
|
||||
title="TLS connection to an external host without SNI",
|
||||
severity=Severity.LOW, confidence=Confidence.MEDIUM,
|
||||
risk_description=("The client did not send a server name. Connections by raw IP address "
|
||||
"often skip hostname verification of the server certificate."),
|
||||
recommendation="Connect by host name and validate the certificate against it.",
|
||||
evidence={"target": s.dst_ip, "port": s.dst_port}, **common)
|
||||
|
||||
if s.cert_self_signed or s.cert_expired:
|
||||
problem = []
|
||||
# 3) Certificate properties (visible in TLS 1.2 and earlier).
|
||||
if s.cert_seen:
|
||||
problems = []
|
||||
if s.cert_self_signed:
|
||||
problem.append("self-signed")
|
||||
problems.append("self-signed")
|
||||
if s.cert_expired:
|
||||
problem.append("expired")
|
||||
key = f"tlscert:{target}:{'-'.join(problem)}"
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
findings.append(Finding(
|
||||
title=f"Suspicious TLS certificate ({', '.join(problem)})",
|
||||
category=Category.TLS_WEAKNESS,
|
||||
severity=Severity.MEDIUM, confidence=Confidence.MEDIUM,
|
||||
source_ip=s.src_ip, destination_ip=s.dst_ip, protocol="tls", port=s.dst_port,
|
||||
timestamp_first_seen=s.timestamp, timestamp_last_seen=s.timestamp,
|
||||
risk_description=(
|
||||
"A self-signed or expired certificate may indicate endpoint "
|
||||
"misconfiguration or TLS interception."
|
||||
),
|
||||
recommendation="Review the certificate chain and deploy a valid CA certificate.",
|
||||
evidence={"subject_cn": s.cert_subject_cn, "issuer_cn": s.cert_issuer_cn,
|
||||
"not_after": s.cert_not_after, "dedup_key": key},
|
||||
related_packets=base_pkt,
|
||||
))
|
||||
problems.append("expired")
|
||||
sig = (s.cert_sig_algorithm or "").lower()
|
||||
if sig in ("sha1", "md5", "md2"):
|
||||
problems.append(f"{sig.upper()} signature")
|
||||
if (s.cert_key_type or "").startswith("RSA") and s.cert_key_bits and s.cert_key_bits < 2048:
|
||||
problems.append(f"RSA {s.cert_key_bits}-bit key")
|
||||
if problems:
|
||||
sev = Severity.MEDIUM
|
||||
if s.cert_expired or "self-signed" in problems and ctx.is_external(s.dst_ip):
|
||||
sev = Severity.HIGH if ctx.is_external(s.dst_ip) else Severity.MEDIUM
|
||||
emit(f"tlscert:{target}:{'-'.join(problems)}",
|
||||
title=f"Weak or untrusted server certificate ({', '.join(problems)})",
|
||||
severity=sev, confidence=Confidence.HIGH,
|
||||
risk_description=("The server certificate has properties that prevent proper validation "
|
||||
"or rely on weak cryptography. Clients that still connect probably do not "
|
||||
"validate certificates, which enables interception."),
|
||||
recommendation="Deploy a certificate from a trusted CA with SHA-256 and at least RSA 2048 or ECDSA P-256, and make clients validate it.",
|
||||
evidence={"subject_cn": s.cert_subject_cn, "issuer_cn": s.cert_issuer_cn,
|
||||
"not_after": s.cert_not_after, "signature": s.cert_sig_algorithm,
|
||||
"key": f"{s.cert_key_type} {s.cert_key_bits}" if s.cert_key_type else None,
|
||||
"target": target}, **common)
|
||||
if s.client_cert_seen and ctx.is_device(s.src_ip):
|
||||
emit(f"mtls:{s.src_ip}:{target}",
|
||||
title="Device authenticates with a client certificate (mutual TLS)",
|
||||
severity=Severity.INFO, confidence=Confidence.HIGH,
|
||||
risk_description="The device presented its own certificate during the handshake. This is a strong authentication mechanism and worth documenting.",
|
||||
recommendation="No action required; ensure the device key is stored in protected hardware where possible.",
|
||||
evidence={"target": target, "version": s.version, "cipher": s.cipher}, **common)
|
||||
|
||||
# 4) Handshake failures / alerts.
|
||||
failed = [a for a in s.alerts if a in _FAILURE_ALERTS]
|
||||
if failed:
|
||||
emit(f"tlsalert:{s.src_ip}:{target}:{failed[0]}",
|
||||
title=f"TLS handshake failure ({failed[0]})",
|
||||
severity=Severity.LOW, confidence=Confidence.MEDIUM,
|
||||
risk_description=("A TLS alert aborted the handshake. Repeated failures can indicate "
|
||||
"incompatible configurations, certificate problems or interception attempts."),
|
||||
recommendation="Check certificate validity and the supported cipher/version overlap between client and server.",
|
||||
evidence={"alerts": s.alerts, "target": target}, **common)
|
||||
|
||||
return self._tag(findings)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Detect session cookies, bearer tokens, and API keys."""
|
||||
"""Detect session cookies, bearer tokens, API keys and key material in clear text."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -10,23 +10,34 @@ from ..findings import Finding, Category, Severity, Confidence
|
||||
from .base import BaseDetector, register
|
||||
|
||||
# JWTs consist of three base64url segments and typically start with ``eyJ``.
|
||||
_JWT_RE = re.compile(r"\beyJ[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+")
|
||||
_BEARER_RE = re.compile(r"\bBearer\s+([A-Za-z0-9_\-\.=]+)", re.IGNORECASE)
|
||||
_JWT_RE = re.compile(r"\beyJ[A-Za-z0-9_\-]{8,}\.[A-Za-z0-9_\-]{8,}\.[A-Za-z0-9_\-]{8,}")
|
||||
_BEARER_RE = re.compile(r"\bBearer\s+([A-Za-z0-9_\-\.=]{16,})", re.IGNORECASE)
|
||||
# Generic API key patterns in URLs, bodies, and headers.
|
||||
_APIKEY_RE = re.compile(
|
||||
r"(?i)(api[_\-]?key|apikey|access[_\-]?token|client[_\-]?secret|x[_\-]api[_\-]key)"
|
||||
r"[=:\"']?\s*([A-Za-z0-9_\-]{16,})"
|
||||
r"(?i)\b(api[_\-]?key|apikey|access[_\-]?token|auth[_\-]?token|client[_\-]?secret|"
|
||||
r"x[_\-]api[_\-]key|x[_\-]auth[_\-]token|x[_\-]access[_\-]token|private[_\-]?key|app[_\-]?secret)"
|
||||
r"\"?\s*[=:]\s*\"?([A-Za-z0-9_\-\.]{16,})"
|
||||
)
|
||||
_SESSION_COOKIE_RE = re.compile(
|
||||
r"(?i)\b(sessionid|session|sid|phpsessid|jsessionid|asp\.net_sessionid|auth|token)="
|
||||
r"(?i)\b(sessionid|session|sid|phpsessid|jsessionid|asp\.net_sessionid|auth|token|"
|
||||
r"remember_token|access_token|connect\.sid)="
|
||||
r"([A-Za-z0-9%_\-\.]{8,})"
|
||||
)
|
||||
_PRIVATE_KEY_RE = re.compile(r"-----BEGIN (?:RSA |EC |DSA |OPENSSH |ENCRYPTED )?PRIVATE KEY-----")
|
||||
# Well-known provider key shapes.
|
||||
_PROVIDER_KEYS = (
|
||||
("aws_access_key", re.compile(r"\b(AKIA|ASIA)[0-9A-Z]{16}\b")),
|
||||
("github_token", re.compile(r"\bgh[pousr]_[A-Za-z0-9]{36,}\b")),
|
||||
("slack_token", re.compile(r"\bxox[abpr]-[A-Za-z0-9\-]{10,}\b")),
|
||||
("google_api_key", re.compile(r"\bAIza[0-9A-Za-z_\-]{35}\b")),
|
||||
("stripe_key", re.compile(r"\b[sr]k_(?:live|test)_[A-Za-z0-9]{16,}\b")),
|
||||
)
|
||||
|
||||
|
||||
@register
|
||||
class TokenSecretDetector(BaseDetector):
|
||||
name = "TokenSecretDetector"
|
||||
description = "Detects JWTs, bearer tokens, API keys, and session cookies."
|
||||
description = "Detects JWTs, bearer tokens, API keys, private keys and session cookies."
|
||||
|
||||
def detect(self, ctx: CaptureContext) -> Iterable[Finding]:
|
||||
findings: list[Finding] = []
|
||||
@@ -34,30 +45,50 @@ class TokenSecretDetector(BaseDetector):
|
||||
|
||||
for payload in ctx.text_payloads:
|
||||
text = payload.get("text") or ""
|
||||
over_http = payload.get("protocol") == "http"
|
||||
proto = (payload.get("protocol") or "").lower()
|
||||
over_http = proto == "http"
|
||||
|
||||
for m in _JWT_RE.findall(text):
|
||||
findings.append(self._mk(
|
||||
payload, "Clear-text JWT (JSON Web Token)",
|
||||
"A JWT was transmitted in clear text and may grant passwordless access "
|
||||
"until it expires.",
|
||||
"Revoke and rotate the token, shorten its lifetime, and require HTTPS.",
|
||||
"Revoke and rotate the token, shorten its lifetime, and require TLS.",
|
||||
secret=m, conf=Confidence.HIGH, seen=seen, kind="jwt"))
|
||||
|
||||
for m in _BEARER_RE.findall(text):
|
||||
findings.append(self._mk(
|
||||
payload, "Clear-text bearer or OAuth token",
|
||||
"A bearer token was transmitted and may grant access without a password.",
|
||||
"Rotate the token, shorten its lifetime, and require HTTPS.",
|
||||
"Rotate the token, shorten its lifetime, and require TLS.",
|
||||
secret=m, conf=Confidence.HIGH, seen=seen, kind="bearer"))
|
||||
|
||||
for _, m in _APIKEY_RE.findall(text):
|
||||
findings.append(self._mk(
|
||||
payload, "Clear-text API key or client secret",
|
||||
"An API key or client secret was transmitted in clear text.",
|
||||
"Rotate the secret, store it securely, and require HTTPS.",
|
||||
"Rotate the secret, store it securely, and require TLS.",
|
||||
secret=m, conf=Confidence.MEDIUM, seen=seen, kind="apikey"))
|
||||
|
||||
for kind, rx in _PROVIDER_KEYS:
|
||||
for m in rx.findall(text):
|
||||
value = m if isinstance(m, str) else m[0]
|
||||
findings.append(self._mk(
|
||||
payload, f"Clear-text provider credential ({kind.replace('_', ' ')})",
|
||||
"A cloud or service provider credential was transmitted in clear text.",
|
||||
"Revoke the credential at the provider and issue a new one.",
|
||||
secret=value, conf=Confidence.HIGH, seen=seen, kind=kind,
|
||||
severity=Severity.CRITICAL))
|
||||
|
||||
if _PRIVATE_KEY_RE.search(text):
|
||||
findings.append(self._mk(
|
||||
payload, "Private key material transmitted in clear text",
|
||||
"A PEM-encoded private key was observed in an unencrypted flow.",
|
||||
"Treat the key as compromised: revoke it, generate a new key pair and "
|
||||
"provision keys only over an authenticated, encrypted channel.",
|
||||
secret="PRIVATE KEY", conf=Confidence.HIGH, seen=seen, kind="private_key",
|
||||
severity=Severity.CRITICAL))
|
||||
|
||||
# Report session cookies only when the transport is unencrypted.
|
||||
if over_http:
|
||||
for _, m in _SESSION_COOKIE_RE.findall(text):
|
||||
@@ -69,7 +100,8 @@ class TokenSecretDetector(BaseDetector):
|
||||
|
||||
return self._tag([f for f in findings if f is not None])
|
||||
|
||||
def _mk(self, payload, title, risk, rec, *, secret, conf, seen, kind):
|
||||
def _mk(self, payload, title, risk, rec, *, secret, conf, seen, kind,
|
||||
severity: Severity = Severity.HIGH):
|
||||
key = f"{kind}:{secret[:24]}"
|
||||
if key in seen:
|
||||
return None
|
||||
@@ -77,7 +109,7 @@ class TokenSecretDetector(BaseDetector):
|
||||
return Finding(
|
||||
title=title,
|
||||
category=Category.TOKENS_SECRETS,
|
||||
severity=Severity.HIGH, confidence=conf,
|
||||
severity=severity, confidence=conf,
|
||||
source_ip=payload.get("src_ip"), destination_ip=payload.get("dst_ip"),
|
||||
protocol=payload.get("protocol"),
|
||||
timestamp_first_seen=payload.get("timestamp"),
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
@@ -11,6 +12,9 @@ from .findings import Finding
|
||||
from . import scoring
|
||||
from . import provenance
|
||||
from . import detectors as detectors_pkg
|
||||
from . import inventory as inventory_mod
|
||||
|
||||
logger = logging.getLogger("analyzer.engine")
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -18,12 +22,15 @@ class AnalysisResult:
|
||||
findings: list[Finding] = field(default_factory=list)
|
||||
summary: dict = field(default_factory=dict)
|
||||
meta: dict = field(default_factory=dict)
|
||||
# Complete observation inventory (hosts, endpoints, protocols, TLS posture, ...).
|
||||
inventory: dict = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"meta": self.meta,
|
||||
"summary": _summary_to_dict(self.summary),
|
||||
"findings": [f.to_dict() for f in self.findings],
|
||||
"inventory": self.inventory,
|
||||
}
|
||||
|
||||
|
||||
@@ -36,49 +43,93 @@ class AnalysisEngine:
|
||||
started = time.time()
|
||||
raw: list[Finding] = []
|
||||
errors: dict[str, str] = {}
|
||||
timings: dict[str, float] = {}
|
||||
logger.info("Analysis start: %d detectors, %d packets, device_ip=%s",
|
||||
len(self.detectors), ctx.packet_count, ctx.device_ip)
|
||||
|
||||
for det in self.detectors:
|
||||
t0 = time.time()
|
||||
try:
|
||||
raw.extend(list(det.detect(ctx)))
|
||||
found = list(det.detect(ctx))
|
||||
raw.extend(found)
|
||||
logger.debug("%s: %d findings in %.3fs", det.name, len(found), time.time() - t0)
|
||||
except Exception as exc: # One detector must not abort the full analysis.
|
||||
errors[det.name] = repr(exc)
|
||||
logger.exception("%s failed: %r", det.name, exc)
|
||||
timings[det.name] = round(time.time() - t0, 3)
|
||||
|
||||
deduped = scoring.dedupe(raw)
|
||||
# Attribute findings to the device under test before prioritizing.
|
||||
for f in deduped:
|
||||
_attribute_device(f, ctx)
|
||||
ordered = scoring.sort_by_priority(deduped)
|
||||
# Attach packet-level provenance before the summary is generated.
|
||||
capture_file = _basename(ctx.path)
|
||||
for f in ordered:
|
||||
_enrich_provenance(f, capture_file)
|
||||
summary = scoring.summarize(ordered)
|
||||
try:
|
||||
inventory = inventory_mod.build_inventory(ctx)
|
||||
except Exception as exc: # The inventory must never break the analysis.
|
||||
logger.exception("Inventory build failed: %r", exc)
|
||||
inventory = {"error": repr(exc)}
|
||||
logger.info("Analysis done: %d raw -> %d findings (%s) in %.2fs; detector errors: %s",
|
||||
len(raw), len(ordered), summary.get("by_severity"), time.time() - started,
|
||||
errors or "none")
|
||||
|
||||
return AnalysisResult(
|
||||
findings=ordered,
|
||||
summary=summary,
|
||||
inventory=inventory,
|
||||
meta={
|
||||
"capture_path": ctx.path,
|
||||
"packet_count": ctx.packet_count,
|
||||
"duration_seconds": round(ctx.duration, 3),
|
||||
"capture_start": ctx.start_time,
|
||||
"capture_end": ctx.end_time,
|
||||
"device_ip": ctx.device_ip,
|
||||
"device_macs": sorted(ctx.device_macs),
|
||||
"extractor": ctx.extractor,
|
||||
"coverage_notes": list(ctx.coverage_notes),
|
||||
"detectors_run": [d.name for d in self.detectors],
|
||||
"detector_errors": errors,
|
||||
"detector_timings": timings,
|
||||
"analysis_time_seconds": round(time.time() - started, 3),
|
||||
"raw_finding_count": len(raw),
|
||||
"finding_count": len(ordered),
|
||||
# A partial pass must never be mistaken for a complete one.
|
||||
"truncated": bool(getattr(ctx, "truncated", False)),
|
||||
"truncation_reason": getattr(ctx, "truncation_reason", None),
|
||||
},
|
||||
)
|
||||
|
||||
def analyze_pcap(self, path: str, packet_limit: Optional[int] = None) -> AnalysisResult:
|
||||
# Keep context-only analysis usable without pyshark or tshark.
|
||||
from .extract_pyshark import build_context_from_pcap
|
||||
ctx = build_context_from_pcap(path, packet_limit=packet_limit)
|
||||
def analyze_pcap(self, path: str, packet_limit: Optional[int] = None,
|
||||
device_ip: Optional[str] = None) -> AnalysisResult:
|
||||
# Keep context-only analysis usable without tshark.
|
||||
from .extract_tshark import build_context_from_pcap
|
||||
ctx = build_context_from_pcap(path, packet_limit=packet_limit, device_ip=device_ip)
|
||||
return self.analyze_context(ctx)
|
||||
|
||||
|
||||
def result_from_dict(data: dict) -> AnalysisResult:
|
||||
"""Reconstruct a result and recalculate its summary from stored findings."""
|
||||
"""Rebuild a result from stored JSON; the summary is recalculated."""
|
||||
findings = [Finding.from_dict(f) for f in data.get("findings", [])]
|
||||
summary = scoring.summarize(findings)
|
||||
meta = data.get("meta", {})
|
||||
return AnalysisResult(findings=findings, summary=summary, meta=meta)
|
||||
return AnalysisResult(findings=findings, summary=summary, meta=meta,
|
||||
inventory=data.get("inventory") or {})
|
||||
|
||||
|
||||
def _attribute_device(f: Finding, ctx: CaptureContext) -> None:
|
||||
"""Set affected_device when the finding touches the device under test."""
|
||||
if ctx.device_ip is None:
|
||||
return
|
||||
involved = ctx.involves_device(f.source_ip, f.destination_ip)
|
||||
if involved is None:
|
||||
return
|
||||
f.evidence["device_related"] = bool(involved)
|
||||
if involved and not f.affected_device:
|
||||
f.affected_device = ctx.device_ip
|
||||
|
||||
|
||||
def _summary_to_dict(summary: dict) -> dict:
|
||||
|
||||
@@ -1,763 +0,0 @@
|
||||
"""Build a normalized capture context with tshark through pyshark.
|
||||
|
||||
pyshark is loaded lazily, and individual packet handlers are isolated so one
|
||||
unexpected packet cannot abort the complete analysis.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
from .context import (
|
||||
CaptureContext,
|
||||
Conversation,
|
||||
DnsQuery,
|
||||
HttpRequest,
|
||||
HttpResponse,
|
||||
TlsSession,
|
||||
ArpEvent,
|
||||
PlaintextAuth,
|
||||
AuthMaterial,
|
||||
FileTransfer,
|
||||
)
|
||||
from . import crackformats
|
||||
|
||||
# Known clear-text protocols used by extraction heuristics.
|
||||
CLEARTEXT_PORTS = {
|
||||
21: "ftp",
|
||||
23: "telnet",
|
||||
25: "smtp",
|
||||
80: "http",
|
||||
110: "pop3",
|
||||
143: "imap",
|
||||
389: "ldap",
|
||||
161: "snmp",
|
||||
69: "tftp",
|
||||
}
|
||||
|
||||
|
||||
def build_context_from_pcap(path: str, packet_limit: Optional[int] = None) -> CaptureContext:
|
||||
"""Read a capture and return its normalized context."""
|
||||
if not os.path.exists(path):
|
||||
raise FileNotFoundError(f"Capture not found: {path}")
|
||||
|
||||
try:
|
||||
import pyshark # lazy import
|
||||
except ImportError as exc: # pragma: no cover
|
||||
raise RuntimeError(
|
||||
"pyshark or tshark is unavailable. Install tshark or use the backend container."
|
||||
) from exc
|
||||
|
||||
ctx = CaptureContext(path=path)
|
||||
cap = pyshark.FileCapture(path, keep_packets=False)
|
||||
|
||||
conv_index: dict[tuple, Conversation] = {}
|
||||
# Transient state correlates NTLM and SMTP authentication across packets.
|
||||
state: dict = {"ntlm_chal": {}, "smtp_auth": {}}
|
||||
n = 0
|
||||
try:
|
||||
for pkt in cap:
|
||||
n += 1
|
||||
if packet_limit and n > packet_limit:
|
||||
break
|
||||
try:
|
||||
_handle_packet(ctx, pkt, conv_index, state)
|
||||
except Exception:
|
||||
# Skip isolated malformed or unexpected packets.
|
||||
continue
|
||||
except Exception as exc:
|
||||
if exc.__class__.__name__ == "TSharkNotFoundException":
|
||||
raise RuntimeError(
|
||||
"tshark was not found. Install Wireshark/tshark or use the backend container."
|
||||
) from exc
|
||||
raise
|
||||
finally:
|
||||
try:
|
||||
cap.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
ctx.packet_count = n
|
||||
ctx.conversations = list(conv_index.values())
|
||||
return ctx
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Packet handlers
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def _ts(pkt) -> Optional[float]:
|
||||
try:
|
||||
return float(pkt.sniff_timestamp)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _num(pkt) -> Optional[int]:
|
||||
try:
|
||||
return int(pkt.number)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _handle_packet(ctx: CaptureContext, pkt, conv_index: dict, state: Optional[dict] = None) -> None:
|
||||
if state is None:
|
||||
state = {"ntlm_chal": {}, "smtp_auth": {}}
|
||||
ts = _ts(pkt)
|
||||
if ts is not None:
|
||||
ctx.start_time = ts if ctx.start_time is None else min(ctx.start_time, ts)
|
||||
ctx.end_time = ts if ctx.end_time is None else max(ctx.end_time, ts)
|
||||
|
||||
# Count packets by the highest decoded protocol layer.
|
||||
try:
|
||||
top = pkt.highest_layer
|
||||
ctx.protocol_stats[top] = ctx.protocol_stats.get(top, 0) + 1
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
layers = {l.layer_name for l in pkt.layers}
|
||||
|
||||
src_ip = dst_ip = None
|
||||
length = 0
|
||||
try:
|
||||
length = int(pkt.length)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if "ip" in layers:
|
||||
try:
|
||||
src_ip = pkt.ip.src
|
||||
dst_ip = pkt.ip.dst
|
||||
except Exception:
|
||||
pass
|
||||
elif "ipv6" in layers:
|
||||
try:
|
||||
src_ip = pkt.ipv6.src
|
||||
dst_ip = pkt.ipv6.dst
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# MAC-Adressen / Hosts
|
||||
src_mac = dst_mac = None
|
||||
if "eth" in layers:
|
||||
try:
|
||||
src_mac, dst_mac = pkt.eth.src, pkt.eth.dst
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
src_port = dst_port = None
|
||||
transport = None
|
||||
if "tcp" in layers:
|
||||
transport = "tcp"
|
||||
try:
|
||||
src_port = int(pkt.tcp.srcport)
|
||||
dst_port = int(pkt.tcp.dstport)
|
||||
except Exception:
|
||||
pass
|
||||
elif "udp" in layers:
|
||||
transport = "udp"
|
||||
try:
|
||||
src_port = int(pkt.udp.srcport)
|
||||
dst_port = int(pkt.udp.dstport)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if src_ip:
|
||||
h = ctx.host(src_ip)
|
||||
h.bytes_sent += length
|
||||
if src_mac:
|
||||
h.macs.add(src_mac)
|
||||
if dst_port:
|
||||
h.ports_contacted.add(dst_port)
|
||||
if dst_ip:
|
||||
h = ctx.host(dst_ip)
|
||||
h.bytes_received += length
|
||||
if dst_port:
|
||||
h.ports_offered.add(dst_port)
|
||||
|
||||
# Conversation aggregieren
|
||||
if src_ip and dst_ip and transport:
|
||||
key = tuple(sorted([(src_ip, src_port), (dst_ip, dst_port)]) + [transport])
|
||||
conv = conv_index.get(key)
|
||||
if conv is None:
|
||||
conv = Conversation(
|
||||
src_ip=src_ip, dst_ip=dst_ip, src_port=src_port, dst_port=dst_port,
|
||||
protocol=transport, first_seen=ts, last_seen=ts,
|
||||
)
|
||||
try:
|
||||
conv.stream_id = int(pkt.tcp.stream) if transport == "tcp" else None
|
||||
except Exception:
|
||||
conv.stream_id = None
|
||||
conv_index[key] = conv
|
||||
conv.packets += 1
|
||||
conv.bytes += length
|
||||
pnum = _num(pkt)
|
||||
if pnum is not None and len(conv.packet_numbers) < 20:
|
||||
conv.packet_numbers.append(pnum)
|
||||
if ts is not None:
|
||||
conv.timestamps.append(ts)
|
||||
conv.last_seen = ts if conv.last_seen is None else max(conv.last_seen, ts)
|
||||
conv.first_seen = ts if conv.first_seen is None else min(conv.first_seen, ts)
|
||||
if src_ip == conv.src_ip:
|
||||
conv.bytes_src_to_dst += length
|
||||
else:
|
||||
conv.bytes_dst_to_src += length
|
||||
|
||||
# TCP stream IDs support cross-packet authentication correlation.
|
||||
stream_id = None
|
||||
if transport == "tcp":
|
||||
try:
|
||||
stream_id = int(pkt.tcp.stream)
|
||||
except Exception:
|
||||
stream_id = None
|
||||
|
||||
# Protocol-specific extraction.
|
||||
if "arp" in layers:
|
||||
_handle_arp(ctx, pkt, ts)
|
||||
if "dns" in layers:
|
||||
_handle_dns(ctx, pkt, src_ip, dst_ip, ts)
|
||||
if "http" in layers:
|
||||
_handle_http(ctx, pkt, src_ip, dst_ip, dst_port, ts)
|
||||
if "tls" in layers or "ssl" in layers:
|
||||
_handle_tls(ctx, pkt, src_ip, dst_ip, dst_port, ts)
|
||||
if "ftp" in layers:
|
||||
_handle_ftp(ctx, pkt, src_ip, dst_ip, dst_port, ts)
|
||||
if "telnet" in layers:
|
||||
_handle_telnet(ctx, pkt, src_ip, dst_ip, dst_port, ts)
|
||||
if "pop" in layers:
|
||||
_handle_pop3(ctx, pkt, src_ip, dst_ip, dst_port, ts)
|
||||
if "imap" in layers:
|
||||
_handle_imap(ctx, pkt, src_ip, dst_ip, dst_port, ts)
|
||||
if "smtp" in layers:
|
||||
_handle_smtp(ctx, pkt, src_ip, dst_ip, dst_port, ts, stream_id, state)
|
||||
if "ntlmssp" in layers:
|
||||
_handle_ntlm(ctx, pkt, src_ip, dst_ip, ts, stream_id, state)
|
||||
if "kerberos" in layers:
|
||||
_handle_kerberos(ctx, pkt, src_ip, dst_ip, ts)
|
||||
|
||||
# Generic payload extraction finds artifacts that protocol fields may omit.
|
||||
_handle_raw_payload(ctx, pkt, layers, src_ip, dst_ip, ts)
|
||||
|
||||
|
||||
def _handle_arp(ctx, pkt, ts):
|
||||
try:
|
||||
a = pkt.arp
|
||||
ctx.arp_events.append(ArpEvent(
|
||||
sender_ip=getattr(a, "src_proto_ipv4", None),
|
||||
sender_mac=getattr(a, "src_hw_mac", None),
|
||||
target_ip=getattr(a, "dst_proto_ipv4", None),
|
||||
opcode=int(getattr(a, "opcode", 0) or 0),
|
||||
timestamp=ts,
|
||||
packet_number=_num(pkt),
|
||||
))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _handle_dns(ctx, pkt, src_ip, dst_ip, ts):
|
||||
try:
|
||||
d = pkt.dns
|
||||
# Ignore DNS records without a query name.
|
||||
qname = getattr(d, "qry_name", None)
|
||||
if not qname:
|
||||
return
|
||||
rcode = getattr(d, "flags_rcode", None)
|
||||
rcode_map = {"0": "NOERROR", "3": "NXDOMAIN"}
|
||||
response_ips = []
|
||||
for attr in ("a", "aaaa"):
|
||||
response_ips.extend(_all_field_values(d, attr))
|
||||
ctx.dns_queries.append(DnsQuery(
|
||||
query_name=qname,
|
||||
qtype=_dns_qtype_name(getattr(d, "qry_type", None)),
|
||||
response_ips=response_ips,
|
||||
rcode=rcode_map.get(str(rcode), rcode),
|
||||
timestamp=ts,
|
||||
src_ip=src_ip,
|
||||
dst_ip=dst_ip,
|
||||
packet_number=_num(pkt),
|
||||
))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _handle_http(ctx, pkt, src_ip, dst_ip, dst_port, ts):
|
||||
try:
|
||||
h = pkt.http
|
||||
if hasattr(h, "request_method") or hasattr(h, "request_uri"):
|
||||
req = HttpRequest(
|
||||
method=getattr(h, "request_method", None),
|
||||
host=getattr(h, "host", None),
|
||||
uri=getattr(h, "request_uri", None),
|
||||
user_agent=getattr(h, "user_agent", None),
|
||||
authorization=getattr(h, "authorization", None),
|
||||
cookies=getattr(h, "cookie", None),
|
||||
body=getattr(h, "file_data", None),
|
||||
src_ip=src_ip, dst_ip=dst_ip, dst_port=dst_port, timestamp=ts,
|
||||
packet_number=_num(pkt),
|
||||
)
|
||||
ctx.http_requests.append(req)
|
||||
# Preserve visible HTTP text for artifact detection.
|
||||
for piece in (req.uri, req.cookies, req.authorization, req.body):
|
||||
if piece:
|
||||
ctx.text_payloads.append({
|
||||
"text": str(piece), "protocol": "http",
|
||||
"src_ip": src_ip, "dst_ip": dst_ip,
|
||||
"packet_number": _num(pkt), "timestamp": ts,
|
||||
})
|
||||
if hasattr(h, "response_code"):
|
||||
ct = getattr(h, "content_type", None)
|
||||
ctx.http_responses.append(HttpResponse(
|
||||
status_code=_safe_int(getattr(h, "response_code", None)),
|
||||
content_type=ct,
|
||||
server=getattr(h, "server", None),
|
||||
src_ip=src_ip, dst_ip=dst_ip, timestamp=ts, packet_number=_num(pkt),
|
||||
))
|
||||
# Record only responses that look like file downloads.
|
||||
disposition = getattr(h, "content_disposition", None)
|
||||
filename = _filename_from_disposition(disposition)
|
||||
if filename or _is_download_content_type(ct):
|
||||
ext = os.path.splitext(filename or "")[1].lower() or None
|
||||
ctx.file_transfers.append(FileTransfer(
|
||||
filename=filename, extension=ext, content_type=ct,
|
||||
protocol="http", src_ip=src_ip, dst_ip=dst_ip,
|
||||
direction="download", timestamp=ts, packet_number=_num(pkt),
|
||||
))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _handle_tls(ctx, pkt, src_ip, dst_ip, dst_port, ts):
|
||||
try:
|
||||
layer = getattr(pkt, "tls", None) or getattr(pkt, "ssl", None)
|
||||
if layer is None:
|
||||
return
|
||||
|
||||
# TLS 1.3 reports its real version in the supported-versions extension.
|
||||
version = (
|
||||
getattr(layer, "handshake_extensions_supported_version", None)
|
||||
or getattr(layer, "handshake_version", None)
|
||||
or getattr(layer, "record_version", None)
|
||||
)
|
||||
|
||||
subject_cn = _first(
|
||||
getattr(layer, "x509sat_printablestring", None),
|
||||
getattr(layer, "x509ce_dnsname", None),
|
||||
)
|
||||
issuer_cn = getattr(layer, "x509sat_printablestring_1", None)
|
||||
not_after = (
|
||||
getattr(layer, "x509af_utctime", None)
|
||||
or getattr(layer, "x509af_notafter", None)
|
||||
or getattr(layer, "x509af_utctime_1", None)
|
||||
)
|
||||
|
||||
ctx.tls_sessions.append(TlsSession(
|
||||
src_ip=src_ip, dst_ip=dst_ip, dst_port=dst_port or 443,
|
||||
version=_tls_version_name(version),
|
||||
cipher=getattr(layer, "handshake_ciphersuite", None),
|
||||
sni=getattr(layer, "handshake_extensions_server_name", None),
|
||||
cert_subject_cn=subject_cn,
|
||||
cert_issuer_cn=issuer_cn,
|
||||
cert_not_after=not_after,
|
||||
cert_self_signed=_cert_is_self_signed(subject_cn, issuer_cn),
|
||||
cert_expired=_cert_is_expired(not_after, ctx.end_time or ts),
|
||||
timestamp=ts, packet_number=_num(pkt),
|
||||
))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _handle_ftp(ctx, pkt, src_ip, dst_ip, dst_port, ts):
|
||||
try:
|
||||
f = pkt.ftp
|
||||
cmd = getattr(f, "request_command", None)
|
||||
arg = getattr(f, "request_arg", None)
|
||||
if cmd and cmd.upper() in ("USER", "PASS"):
|
||||
pa = _last_plaintext(ctx, "ftp", src_ip, dst_ip, dst_port)
|
||||
if cmd.upper() == "USER":
|
||||
pa.username = arg
|
||||
else:
|
||||
pa.password = arg
|
||||
pa.timestamp = ts
|
||||
pa.packet_number = _num(pkt)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _handle_telnet(ctx, pkt, src_ip, dst_ip, dst_port, ts):
|
||||
try:
|
||||
data = getattr(pkt.telnet, "data", None)
|
||||
if data:
|
||||
ctx.text_payloads.append({
|
||||
"text": str(data), "protocol": "telnet",
|
||||
"src_ip": src_ip, "dst_ip": dst_ip,
|
||||
"packet_number": _num(pkt), "timestamp": ts,
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _handle_pop3(ctx, pkt, src_ip, dst_ip, dst_port, ts):
|
||||
"""POP3 USER/PASS im Klartext."""
|
||||
try:
|
||||
p = pkt.pop
|
||||
cmd = (getattr(p, "request_command", None) or "").upper()
|
||||
param = getattr(p, "request_parameter", None)
|
||||
if cmd in ("USER", "PASS") and param:
|
||||
pa = _last_plaintext(ctx, "pop3", src_ip, dst_ip, dst_port)
|
||||
if cmd == "USER":
|
||||
pa.username = param
|
||||
else:
|
||||
pa.password = param
|
||||
pa.timestamp = ts
|
||||
pa.packet_number = _num(pkt)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _handle_imap(ctx, pkt, src_ip, dst_ip, dst_port, ts):
|
||||
"""Extract clear-text IMAP LOGIN credentials."""
|
||||
try:
|
||||
i = pkt.imap
|
||||
line = getattr(i, "request", None) or getattr(i, "request_command", None)
|
||||
if not line:
|
||||
return
|
||||
parts = str(line).split()
|
||||
# Accept both tagged and untagged IMAP LOGIN forms.
|
||||
idx = next((k for k, tok in enumerate(parts) if tok.upper() == "LOGIN"), None)
|
||||
if idx is not None and len(parts) >= idx + 3:
|
||||
user = parts[idx + 1].strip('"')
|
||||
pw = parts[idx + 2].strip('"')
|
||||
pa = _last_plaintext(ctx, "imap", src_ip, dst_ip, dst_port)
|
||||
pa.username, pa.password = user, pw
|
||||
pa.timestamp = ts
|
||||
pa.packet_number = _num(pkt)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _handle_smtp(ctx, pkt, src_ip, dst_ip, dst_port, ts, stream_id, state):
|
||||
"""Extract Base64-encoded SMTP AUTH LOGIN or PLAIN credentials."""
|
||||
try:
|
||||
s = pkt.smtp
|
||||
param = getattr(s, "req_parameter", None) or getattr(s, "request_parameter", None)
|
||||
cmd = (getattr(s, "req_command", None) or getattr(s, "request_command", None) or "").upper()
|
||||
if not param and not cmd:
|
||||
return
|
||||
pending = state["smtp_auth"].setdefault(stream_id, {"mech": None, "tokens": []})
|
||||
|
||||
text = f"{cmd} {param}".strip()
|
||||
upper = text.upper()
|
||||
if "AUTH LOGIN" in upper:
|
||||
pending["mech"] = "login"
|
||||
return
|
||||
if "AUTH PLAIN" in upper:
|
||||
pending["mech"] = "plain"
|
||||
# AUTH PLAIN may carry its token on the command line.
|
||||
tail = text.split()
|
||||
if len(tail) >= 3:
|
||||
creds = crackformats.decode_smtp_auth_plain(tail[2])
|
||||
_emit_smtp(ctx, creds, src_ip, dst_ip, dst_port, ts, pkt)
|
||||
state["smtp_auth"].pop(stream_id, None)
|
||||
return
|
||||
|
||||
# AUTH LOGIN sends username and password in subsequent lines.
|
||||
if pending["mech"] == "login" and param:
|
||||
pending["tokens"].append(param)
|
||||
if len(pending["tokens"]) >= 2:
|
||||
user, pw = crackformats.decode_smtp_auth_login(
|
||||
pending["tokens"][0], pending["tokens"][1])
|
||||
_emit_smtp(ctx, (user, pw), src_ip, dst_ip, dst_port, ts, pkt)
|
||||
state["smtp_auth"].pop(stream_id, None)
|
||||
elif pending["mech"] == "plain" and param:
|
||||
creds = crackformats.decode_smtp_auth_plain(param)
|
||||
_emit_smtp(ctx, creds, src_ip, dst_ip, dst_port, ts, pkt)
|
||||
state["smtp_auth"].pop(stream_id, None)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _emit_smtp(ctx, creds, src_ip, dst_ip, dst_port, ts, pkt):
|
||||
if not creds:
|
||||
return
|
||||
user, pw = creds
|
||||
if not (user or pw):
|
||||
return
|
||||
ctx.plaintext_auth.append(PlaintextAuth(
|
||||
protocol="smtp", username=user, password=pw,
|
||||
src_ip=src_ip, dst_ip=dst_ip, dst_port=dst_port,
|
||||
timestamp=ts, packet_number=_num(pkt),
|
||||
))
|
||||
|
||||
|
||||
def _handle_ntlm(ctx, pkt, src_ip, dst_ip, ts, stream_id, state):
|
||||
"""Correlate NTLM challenge and response packets into hashcat material."""
|
||||
try:
|
||||
n = pkt.ntlmssp
|
||||
msg_type = str(getattr(n, "messagetype", "") or "").lower()
|
||||
|
||||
# Retain the type-2 server challenge for the matching type-3 response.
|
||||
server_chal = getattr(n, "ntlmserverchallenge", None)
|
||||
if server_chal and ("0x00000002" in msg_type or msg_type.endswith("2")):
|
||||
state["ntlm_chal"][stream_id] = str(server_chal)
|
||||
|
||||
# A type-3 message provides the account and NT response.
|
||||
account = getattr(n, "auth_username", None)
|
||||
domain = getattr(n, "auth_domain", None)
|
||||
nt_resp = (getattr(n, "ntlmv2_response", None)
|
||||
or getattr(n, "auth_ntresponse", None))
|
||||
|
||||
if account or nt_resp:
|
||||
chal = state["ntlm_chal"].get(stream_id)
|
||||
am = AuthMaterial(
|
||||
kind="ntlmssp", account=account, domain=domain,
|
||||
src_ip=src_ip, dst_ip=dst_ip, protocol="ntlm",
|
||||
timestamp=ts, packet_number=_num(pkt),
|
||||
server_challenge=chal, nt_response=str(nt_resp) if nt_resp else None,
|
||||
)
|
||||
built = crackformats.build_netntlmv2(account, domain, chal, nt_resp)
|
||||
if built:
|
||||
am.kind = "netntlmv2"
|
||||
am.hashcat = built["hashcat"]
|
||||
am.hashcat_mode = built["hashcat_mode"]
|
||||
am.hash_format = built["format"]
|
||||
ctx.auth_material.append(am)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# Bound retained payload text for large captures.
|
||||
_MAX_TEXT_PAYLOADS = 20000
|
||||
|
||||
|
||||
def _handle_raw_payload(ctx, pkt, layers, src_ip, dst_ip, ts):
|
||||
"""Retain bounded TCP or UDP text for generic artifact detection."""
|
||||
if len(ctx.text_payloads) >= _MAX_TEXT_PAYLOADS:
|
||||
return
|
||||
raw = None
|
||||
try:
|
||||
if "tcp" in layers and hasattr(pkt.tcp, "payload"):
|
||||
raw = pkt.tcp.payload
|
||||
elif "udp" in layers and hasattr(pkt.udp, "payload"):
|
||||
raw = pkt.udp.payload
|
||||
elif "data" in layers:
|
||||
raw = getattr(pkt.data, "data", None)
|
||||
except Exception:
|
||||
raw = None
|
||||
if not raw:
|
||||
return
|
||||
|
||||
text = _bytes_from_hexish(str(raw))
|
||||
if not text or not text.strip():
|
||||
return
|
||||
try:
|
||||
proto = (pkt.highest_layer or "raw").lower()
|
||||
except Exception:
|
||||
proto = "raw"
|
||||
ctx.text_payloads.append({
|
||||
"text": text[:16384], "protocol": proto,
|
||||
"src_ip": src_ip, "dst_ip": dst_ip,
|
||||
"packet_number": _num(pkt), "timestamp": ts,
|
||||
})
|
||||
|
||||
|
||||
def _bytes_from_hexish(raw: str) -> Optional[str]:
|
||||
"""Convert a hex-formatted pyshark payload to text when possible."""
|
||||
s = str(raw).strip()
|
||||
compact = s.replace(":", "").replace(".", "").replace(" ", "")
|
||||
if compact and len(compact) % 2 == 0 and all(c in "0123456789abcdefABCDEF" for c in compact):
|
||||
try:
|
||||
return bytes.fromhex(compact).decode("utf-8", "ignore")
|
||||
except Exception:
|
||||
return None
|
||||
# Some dissectors already expose decoded text.
|
||||
return s
|
||||
|
||||
|
||||
def _handle_kerberos(ctx, pkt, src_ip, dst_ip, ts):
|
||||
"""Extract Kerberos AS-REP and TGS-REP material for offline analysis."""
|
||||
try:
|
||||
k = pkt.kerberos
|
||||
msg_type = str(getattr(k, "msg_type", "") or "")
|
||||
account = getattr(k, "cnamestring", None) or getattr(k, "cnamestring_1", None)
|
||||
realm = getattr(k, "realm", None) or getattr(k, "crealm", None)
|
||||
etype = getattr(k, "etype", None)
|
||||
cipher = getattr(k, "cipher", None) or getattr(k, "enc_part_cipher", None)
|
||||
|
||||
am = AuthMaterial(
|
||||
kind=f"kerberos_{msg_type}" if msg_type else "kerberos",
|
||||
account=account, domain=realm,
|
||||
src_ip=src_ip, dst_ip=dst_ip, protocol="kerberos",
|
||||
timestamp=ts, packet_number=_num(pkt),
|
||||
etype=str(etype) if etype is not None else None,
|
||||
)
|
||||
# Kerberos message types 11 and 13 are AS-REP and TGS-REP.
|
||||
built = None
|
||||
if msg_type == "11":
|
||||
built = crackformats.build_krb5_asrep(account, realm, etype, cipher)
|
||||
elif msg_type == "13":
|
||||
spn = getattr(k, "snamestring", None)
|
||||
built = crackformats.build_krb5_tgsrep(account, realm, spn, etype, cipher)
|
||||
if built:
|
||||
am.hashcat = built["hashcat"]
|
||||
am.hashcat_mode = built["hashcat_mode"]
|
||||
am.hash_format = built["format"]
|
||||
ctx.auth_material.append(am)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# kleine Helfer
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def _last_plaintext(ctx, protocol, src_ip, dst_ip, dst_port) -> PlaintextAuth:
|
||||
"""Find or create the authentication record that combines USER and PASS."""
|
||||
for pa in reversed(ctx.plaintext_auth):
|
||||
if pa.protocol == protocol and pa.src_ip == src_ip and pa.dst_ip == dst_ip:
|
||||
if pa.password is None or pa.username is None:
|
||||
return pa
|
||||
pa = PlaintextAuth(protocol=protocol, src_ip=src_ip, dst_ip=dst_ip, dst_port=dst_port)
|
||||
ctx.plaintext_auth.append(pa)
|
||||
return pa
|
||||
|
||||
|
||||
def _safe_int(v):
|
||||
try:
|
||||
return int(v)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _tls_version_name(raw) -> Optional[str]:
|
||||
mapping = {
|
||||
"0x0300": "SSL 3.0", "0x0301": "TLS 1.0", "0x0302": "TLS 1.1",
|
||||
"0x0303": "TLS 1.2", "0x0304": "TLS 1.3",
|
||||
"768": "SSL 3.0", "769": "TLS 1.0", "770": "TLS 1.1",
|
||||
"771": "TLS 1.2", "772": "TLS 1.3",
|
||||
}
|
||||
if raw is None:
|
||||
return None
|
||||
return mapping.get(str(raw).lower(), str(raw))
|
||||
|
||||
|
||||
def _first(*vals):
|
||||
"""Return the first non-empty dissector field value."""
|
||||
for v in vals:
|
||||
if v:
|
||||
return v
|
||||
return None
|
||||
|
||||
|
||||
def _all_field_values(layer, name) -> list[str]:
|
||||
"""Return every occurrence of a repeated pyshark field."""
|
||||
out: list[str] = []
|
||||
try:
|
||||
field = getattr(layer, name, None)
|
||||
if field is None:
|
||||
return out
|
||||
allf = getattr(field, "all_fields", None)
|
||||
if allf:
|
||||
for f in allf:
|
||||
v = getattr(f, "show", None) or getattr(f, "showname_value", None)
|
||||
if v:
|
||||
out.append(str(v))
|
||||
else:
|
||||
out.append(str(field))
|
||||
except Exception:
|
||||
pass
|
||||
return out
|
||||
|
||||
|
||||
# tshark often exposes DNS query types as numeric values.
|
||||
_DNS_QTYPES = {
|
||||
"1": "A", "2": "NS", "5": "CNAME", "6": "SOA", "12": "PTR", "15": "MX",
|
||||
"16": "TXT", "28": "AAAA", "33": "SRV", "255": "ANY", "10": "NULL",
|
||||
"43": "DS", "48": "DNSKEY", "257": "CAA", "65": "HTTPS", "64": "SVCB",
|
||||
}
|
||||
|
||||
|
||||
def _dns_qtype_name(raw) -> Optional[str]:
|
||||
if raw is None:
|
||||
return None
|
||||
s = str(raw).strip()
|
||||
return _DNS_QTYPES.get(s, s.upper())
|
||||
|
||||
|
||||
# Content types that indicate a file rather than ordinary web content.
|
||||
_DOWNLOAD_CT_MARKERS = (
|
||||
"application/octet-stream", "application/x-msdownload", "application/x-dosexec",
|
||||
"application/x-executable", "application/vnd.microsoft.portable-executable",
|
||||
"application/zip", "application/x-rar", "application/x-7z", "application/x-tar",
|
||||
"application/gzip", "application/x-msi", "application/x-sh",
|
||||
"application/java-archive", "application/x-elf",
|
||||
)
|
||||
|
||||
|
||||
def _is_download_content_type(ct) -> bool:
|
||||
if not ct:
|
||||
return False
|
||||
low = str(ct).lower()
|
||||
return any(m in low for m in _DOWNLOAD_CT_MARKERS)
|
||||
|
||||
|
||||
def _filename_from_disposition(disposition) -> Optional[str]:
|
||||
"""Extract a filename from a Content-Disposition header."""
|
||||
if not disposition:
|
||||
return None
|
||||
import re as _re
|
||||
m = _re.search(r'filename\*?=(?:"([^"]+)"|([^;]+))', str(disposition), _re.IGNORECASE)
|
||||
if not m:
|
||||
return None
|
||||
name = (m.group(1) or m.group(2) or "").strip().strip('"')
|
||||
# Remove an optional RFC 5987 character-set prefix.
|
||||
if "''" in name:
|
||||
name = name.split("''", 1)[1]
|
||||
return name or None
|
||||
|
||||
|
||||
def _cert_is_self_signed(subject_cn, issuer_cn) -> Optional[bool]:
|
||||
"""Return whether subject and issuer match, or None if data is incomplete."""
|
||||
if not subject_cn or not issuer_cn:
|
||||
return None
|
||||
return str(subject_cn).strip().lower() == str(issuer_cn).strip().lower()
|
||||
|
||||
|
||||
def _cert_is_expired(not_after, now_epoch) -> Optional[bool]:
|
||||
"""Compare a parsed certificate expiry with the capture timestamp."""
|
||||
if not not_after or now_epoch is None:
|
||||
return None
|
||||
exp = _parse_cert_time(not_after)
|
||||
if exp is None:
|
||||
return None
|
||||
try:
|
||||
return float(now_epoch) > exp
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _parse_cert_time(raw) -> Optional[float]:
|
||||
"""Convert common X.509 time formats to a UTC epoch timestamp."""
|
||||
import datetime as _dt
|
||||
|
||||
s = str(raw).strip()
|
||||
# tshark may already provide an epoch value.
|
||||
try:
|
||||
return float(s)
|
||||
except ValueError:
|
||||
pass
|
||||
fmts = (
|
||||
"%Y-%m-%d %H:%M:%S", # 2020-01-01 00:00:00
|
||||
"%b %d %H:%M:%S %Y GMT", # Jan 1 00:00:00 2020 GMT
|
||||
"%y%m%d%H%M%SZ", # UTCTime 200101000000Z
|
||||
"%Y%m%d%H%M%SZ", # GeneralizedTime
|
||||
"%Y-%m-%dT%H:%M:%SZ", # ISO 8601
|
||||
)
|
||||
for fmt in fmts:
|
||||
try:
|
||||
dt = _dt.datetime.strptime(s, fmt).replace(tzinfo=_dt.timezone.utc)
|
||||
return dt.timestamp()
|
||||
except ValueError:
|
||||
continue
|
||||
return None
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,438 @@
|
||||
"""Build a complete observation inventory from the capture context.
|
||||
|
||||
Findings say what is wrong; the inventory says what was there.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from typing import Optional
|
||||
|
||||
from .context import CaptureContext, Host, is_special
|
||||
|
||||
_MAX_HOSTS = 200
|
||||
_MAX_ENDPOINTS = 400
|
||||
_MAX_DNS_NAMES = 500
|
||||
_MAX_TLS = 400
|
||||
|
||||
# Well-known service names for ports without a dissected protocol.
|
||||
_PORT_NAMES = {
|
||||
20: "ftp-data", 21: "ftp", 22: "ssh", 23: "telnet", 25: "smtp", 53: "dns", 67: "dhcp",
|
||||
68: "dhcp", 69: "tftp", 80: "http", 110: "pop3", 123: "ntp", 137: "netbios-ns",
|
||||
138: "netbios-dgm", 139: "netbios-ssn", 143: "imap", 161: "snmp", 162: "snmp-trap",
|
||||
389: "ldap", 443: "https", 445: "smb", 465: "smtps", 500: "ike", 502: "modbus",
|
||||
514: "syslog", 554: "rtsp", 587: "submission", 631: "ipp", 636: "ldaps", 853: "dot",
|
||||
873: "rsync", 993: "imaps", 995: "pop3s", 1080: "socks", 1194: "openvpn",
|
||||
1433: "mssql", 1521: "oracle", 1883: "mqtt", 1900: "ssdp", 2049: "nfs",
|
||||
3306: "mysql", 3389: "rdp", 4500: "ipsec-nat-t", 5000: "upnp/http-alt", 5060: "sip",
|
||||
5061: "sips", 5222: "xmpp", 5223: "apns", 5228: "gcm", 5353: "mdns", 5355: "llmnr",
|
||||
5432: "postgresql", 5683: "coap", 5684: "coaps", 5900: "vnc", 6379: "redis",
|
||||
6667: "irc", 8000: "http-alt", 8080: "http-alt", 8443: "https-alt", 8883: "mqtts",
|
||||
8888: "http-alt", 9000: "http-alt", 9100: "jetdirect", 11211: "memcached",
|
||||
27017: "mongodb", 49152: "upnp",
|
||||
}
|
||||
|
||||
# Dissector helper names that never describe a service by themselves.
|
||||
_NOISE_PROTOS = {"x509ce", "x509sat", "x509af", "x509if", "pkix1implicit", "pkix1explicit",
|
||||
"pkcs-1", "ber", "tcp.segments", "data", "data-text-lines", "mime_multipart",
|
||||
"image-jfif", "image-gif", "png", "json", "xml", "urlencoded-form", "media"}
|
||||
|
||||
|
||||
def _clean(protos) -> list[str]:
|
||||
return sorted(p for p in protos if p not in _NOISE_PROTOS)
|
||||
|
||||
|
||||
_CLEARTEXT_APP = {"http", "ftp", "telnet", "pop", "imap", "smtp", "snmp", "tftp", "mqtt",
|
||||
"coap", "rtsp", "sip", "ldap", "mbtcp", "modbus", "nbns", "smb", "smb2",
|
||||
"syslog", "irc", "xmpp", "vnc", "rdp"}
|
||||
|
||||
|
||||
def build_inventory(ctx: CaptureContext) -> dict:
|
||||
inv: dict = {}
|
||||
inv["capture"] = _capture_section(ctx)
|
||||
inv["device"] = _device_section(ctx)
|
||||
inv["hosts"] = _hosts_section(ctx)
|
||||
inv["external_endpoints"] = _endpoints_section(ctx)
|
||||
inv["protocols"] = _protocols_section(ctx)
|
||||
inv["services"] = _services_section(ctx)
|
||||
inv["tls"] = _tls_section(ctx)
|
||||
inv["dns"] = _dns_section(ctx)
|
||||
inv["http"] = _http_section(ctx)
|
||||
inv["cleartext"] = _cleartext_section(ctx)
|
||||
inv["coverage"] = _coverage_section(ctx)
|
||||
return inv
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def _capture_section(ctx: CaptureContext) -> dict:
|
||||
return {
|
||||
"packets": ctx.packet_count,
|
||||
"duration_seconds": round(ctx.duration, 3),
|
||||
"start": ctx.start_time, "end": ctx.end_time,
|
||||
"vlans": sorted(ctx.vlan_ids),
|
||||
"hosts_total": len([h for h in ctx.hosts.values() if not is_special(h.ip)]),
|
||||
"internal_hosts": len([h for h in ctx.hosts.values() if h.is_private and not is_special(h.ip)]),
|
||||
"external_hosts": len([h for h in ctx.hosts.values() if not h.is_private]),
|
||||
"conversations": len(ctx.conversations),
|
||||
"extractor": ctx.extractor,
|
||||
}
|
||||
|
||||
|
||||
def _host_dict(ctx: CaptureContext, h: Host) -> dict:
|
||||
ext = {c.dst_ip for c in ctx.conversations if c.src_ip == h.ip and ctx.is_external(c.dst_ip)}
|
||||
ext |= {c.src_ip for c in ctx.conversations if c.dst_ip == h.ip and ctx.is_external(c.src_ip)}
|
||||
return {
|
||||
"ip": h.ip,
|
||||
"macs": sorted(h.macs),
|
||||
"names": sorted(h.hostnames),
|
||||
"dhcp_vendor_class": h.dhcp_vendor_class,
|
||||
"is_device": ctx.is_device(h.ip),
|
||||
"is_private": h.is_private,
|
||||
"packets": h.packets,
|
||||
"bytes_sent": h.bytes_sent, "bytes_received": h.bytes_received,
|
||||
"first_seen": h.first_seen, "last_seen": h.last_seen,
|
||||
"ports_offered": sorted(p for p in h.ports_offered if p is not None)[:60],
|
||||
"ports_contacted": sorted(p for p in h.ports_contacted if p is not None)[:60],
|
||||
"protocols": _clean(h.app_protocols)[:40],
|
||||
"external_endpoints": len(ext),
|
||||
"user_agents": sorted(h.user_agents)[:10],
|
||||
}
|
||||
|
||||
|
||||
def _device_section(ctx: CaptureContext) -> Optional[dict]:
|
||||
if not ctx.device_ip:
|
||||
# Suggest likely devices so the operator can re-run with --device-ip.
|
||||
cands = []
|
||||
for h in ctx.hosts.values():
|
||||
if not h.is_private or is_special(h.ip):
|
||||
continue
|
||||
score = h.packets
|
||||
if h.hostnames:
|
||||
score *= 2
|
||||
cands.append((score, h))
|
||||
cands.sort(key=lambda t: -t[0])
|
||||
return {
|
||||
"configured": False,
|
||||
"candidates": [
|
||||
{"ip": h.ip, "names": sorted(h.hostnames), "macs": sorted(h.macs), "packets": h.packets}
|
||||
for _, h in cands[:5]
|
||||
],
|
||||
}
|
||||
h = ctx.hosts.get(ctx.device_ip)
|
||||
if h is None:
|
||||
return {"configured": True, "ip": ctx.device_ip, "seen": False,
|
||||
"note": "The configured device address does not appear in the capture."}
|
||||
d = _host_dict(ctx, h)
|
||||
d.update({"configured": True, "seen": True})
|
||||
# Everything the device talked to.
|
||||
peers: dict[str, dict] = {}
|
||||
for c in ctx.conversations:
|
||||
if c.src_ip == ctx.device_ip:
|
||||
peer, port = c.dst_ip, c.dst_port
|
||||
elif c.dst_ip == ctx.device_ip:
|
||||
peer, port = c.src_ip, c.src_port
|
||||
else:
|
||||
continue
|
||||
e = peers.setdefault(peer, {"ip": peer, "external": ctx.is_external(peer), "ports": set(),
|
||||
"flows": 0, "bytes_to_peer": 0, "bytes_from_peer": 0,
|
||||
"protocols": set(), "names": set()})
|
||||
e["flows"] += 1
|
||||
if port:
|
||||
e["ports"].add(port)
|
||||
if c.src_ip == ctx.device_ip:
|
||||
e["bytes_to_peer"] += c.bytes_src_to_dst
|
||||
e["bytes_from_peer"] += c.bytes_dst_to_src
|
||||
else:
|
||||
e["bytes_to_peer"] += c.bytes_dst_to_src
|
||||
e["bytes_from_peer"] += c.bytes_src_to_dst
|
||||
e["protocols"] |= c.app_protocols
|
||||
for peer, e in peers.items():
|
||||
e["names"] = sorted(ctx.names_for_ip(peer))[:8]
|
||||
e["ports"] = sorted(e["ports"])[:20]
|
||||
e["protocols"] = _clean(e["protocols"])[:15]
|
||||
d["peers"] = sorted(peers.values(), key=lambda e: -(e["bytes_to_peer"] + e["bytes_from_peer"]))[:100]
|
||||
d["dns_queries"] = sorted({q.query_name.rstrip(".").lower() for q in ctx.dns_queries
|
||||
if q.src_ip == ctx.device_ip and not q.is_response and q.query_name})[:100]
|
||||
d["tls_sessions"] = len([t for t in ctx.tls_sessions if t.src_ip == ctx.device_ip])
|
||||
d["cleartext_flows"] = len([c for c in ctx.conversations
|
||||
if ctx.device_ip in (c.src_ip, c.dst_ip)
|
||||
and c.payload_bytes > 0 and not c.is_encrypted])
|
||||
return d
|
||||
|
||||
|
||||
def _hosts_section(ctx: CaptureContext) -> list[dict]:
|
||||
hosts = [h for h in ctx.hosts.values() if not is_special(h.ip) and h.is_private]
|
||||
hosts.sort(key=lambda h: (-int(ctx.is_device(h.ip)), -h.packets))
|
||||
return [_host_dict(ctx, h) for h in hosts[:_MAX_HOSTS]]
|
||||
|
||||
|
||||
def _endpoints_section(ctx: CaptureContext) -> list[dict]:
|
||||
eps: dict[str, dict] = {}
|
||||
for c in ctx.conversations:
|
||||
if ctx.is_external(c.dst_ip) and not ctx.is_external(c.src_ip):
|
||||
ext, internal, port = c.dst_ip, c.src_ip, c.dst_port
|
||||
up, down = c.bytes_src_to_dst, c.bytes_dst_to_src
|
||||
elif ctx.is_external(c.src_ip) and not ctx.is_external(c.dst_ip):
|
||||
ext, internal, port = c.src_ip, c.dst_ip, c.src_port
|
||||
up, down = c.bytes_dst_to_src, c.bytes_src_to_dst
|
||||
else:
|
||||
continue
|
||||
e = eps.setdefault(ext, {"ip": ext, "ports": set(), "protocols": set(), "flows": 0,
|
||||
"bytes_out": 0, "bytes_in": 0, "internal_hosts": set(),
|
||||
"first_seen": c.first_seen, "last_seen": c.last_seen,
|
||||
"device": False})
|
||||
e["flows"] += 1
|
||||
e["bytes_out"] += up
|
||||
e["bytes_in"] += down
|
||||
if port:
|
||||
e["ports"].add(port)
|
||||
e["protocols"] |= c.app_protocols
|
||||
e["internal_hosts"].add(internal)
|
||||
if ctx.is_device(internal):
|
||||
e["device"] = True
|
||||
if c.first_seen is not None:
|
||||
e["first_seen"] = c.first_seen if e["first_seen"] is None else min(e["first_seen"], c.first_seen)
|
||||
if c.last_seen is not None:
|
||||
e["last_seen"] = c.last_seen if e["last_seen"] is None else max(e["last_seen"], c.last_seen)
|
||||
out = []
|
||||
for ip, e in eps.items():
|
||||
e["names"] = sorted(ctx.names_for_ip(ip))[:8]
|
||||
e["ports"] = sorted(e["ports"])[:20]
|
||||
e["protocols"] = _clean(e["protocols"])[:15]
|
||||
e["internal_hosts"] = sorted(e["internal_hosts"])[:10]
|
||||
e["encrypted"] = bool(set(e["protocols"]) & {"tls", "quic", "ssh", "dtls"})
|
||||
out.append(e)
|
||||
out.sort(key=lambda e: (-int(e["device"]), -(e["bytes_out"] + e["bytes_in"])))
|
||||
return out[:_MAX_ENDPOINTS]
|
||||
|
||||
|
||||
def _protocols_section(ctx: CaptureContext) -> dict:
|
||||
skip = {"eth", "ethertype", "vlan", "frame", "sll", "llc", "tcp.segments", "data",
|
||||
"data-text-lines", "x509ce", "x509sat", "x509af", "x509if", "pkix1implicit",
|
||||
"pkix1explicit", "pkcs-1", "ber", "mime_multipart"}
|
||||
app = {k: v for k, v in ctx.app_protocol_stats.items() if k not in skip}
|
||||
return {
|
||||
"by_protocol": dict(sorted(app.items(), key=lambda kv: -kv[1])),
|
||||
"highest_layer": dict(sorted(ctx.protocol_stats.items(), key=lambda kv: -kv[1])[:30]),
|
||||
"icmp": dict(ctx.icmp_stats),
|
||||
}
|
||||
|
||||
|
||||
def _services_section(ctx: CaptureContext) -> list[dict]:
|
||||
"""Server-side services observed: who answered on which port with which protocol."""
|
||||
svc: dict[tuple, dict] = {}
|
||||
for c in ctx.conversations:
|
||||
if not c.answered or not c.dst_port:
|
||||
continue
|
||||
key = (c.dst_ip, c.dst_port, c.protocol)
|
||||
e = svc.setdefault(key, {"ip": c.dst_ip, "port": c.dst_port, "transport": c.protocol,
|
||||
"protocols": set(), "clients": set(), "flows": 0,
|
||||
"external": ctx.is_external(c.dst_ip), "device": ctx.is_device(c.dst_ip)})
|
||||
e["flows"] += 1
|
||||
e["clients"].add(c.src_ip)
|
||||
e["protocols"] |= c.app_protocols
|
||||
out = []
|
||||
for e in svc.values():
|
||||
protos = _clean(e["protocols"])
|
||||
e["protocols"] = protos
|
||||
e["service"] = _service_name(e["port"], protos)
|
||||
e["encrypted"] = bool(set(protos) & {"tls", "quic", "ssh", "dtls"})
|
||||
e["cleartext_app"] = bool(set(protos) & _CLEARTEXT_APP) and not e["encrypted"]
|
||||
e["clients"] = sorted(e["clients"])[:10]
|
||||
out.append(e)
|
||||
out.sort(key=lambda e: (-int(e["device"]), int(e["external"]), e["ip"], e["port"]))
|
||||
return out[:_MAX_ENDPOINTS]
|
||||
|
||||
|
||||
def _service_name(port: int, protos: list[str]) -> str:
|
||||
for p in ("mqtt", "coap", "http", "rtsp", "ssh", "telnet", "ftp", "smtp", "pop", "imap",
|
||||
"snmp", "dns", "mdns", "ssdp", "dhcp", "ntp", "tftp", "sip", "smb2", "smb", "ldap",
|
||||
"mbtcp", "vnc", "rdp", "xmpp", "quic", "tls"):
|
||||
if p in protos:
|
||||
base = {"pop": "pop3", "mbtcp": "modbus"}.get(p, p)
|
||||
if base == "tls":
|
||||
return _PORT_NAMES.get(port, f"tls/{port}")
|
||||
return base
|
||||
return _PORT_NAMES.get(port, f"port {port}")
|
||||
|
||||
|
||||
def _tls_section(ctx: CaptureContext) -> dict:
|
||||
sessions = []
|
||||
for t in ctx.tls_sessions:
|
||||
sessions.append({
|
||||
"client": t.src_ip, "server": t.dst_ip, "port": t.dst_port, "transport": t.transport,
|
||||
"sni": t.sni, "version": t.version, "cipher": t.cipher,
|
||||
"client_max_version": t.client_version,
|
||||
"client_offered_ciphers": len(t.client_offered_ciphers),
|
||||
"alpn": t.alpn[:4], "handshake_complete": t.server_hello_seen,
|
||||
"certificate": {
|
||||
"subject": t.cert_subject_cn, "issuer": t.cert_issuer_cn, "san": t.cert_san[:5],
|
||||
"not_after": t.cert_not_after, "self_signed": t.cert_self_signed,
|
||||
"expired": t.cert_expired, "signature": t.cert_sig_algorithm,
|
||||
"key": f"{t.cert_key_type} {t.cert_key_bits}" if t.cert_key_type else None,
|
||||
} if t.cert_seen else None,
|
||||
"mutual_tls": t.client_cert_seen,
|
||||
"alerts": t.alerts,
|
||||
"device": ctx.is_device(t.src_ip) or ctx.is_device(t.dst_ip),
|
||||
"packet": t.packet_number,
|
||||
})
|
||||
sessions.sort(key=lambda s: (-int(s["device"]), s["client"] or "", s["server"] or ""))
|
||||
versions: dict[str, int] = defaultdict(int)
|
||||
ciphers: dict[str, int] = defaultdict(int)
|
||||
for t in ctx.tls_sessions:
|
||||
if t.version:
|
||||
versions[t.version] += 1
|
||||
if t.cipher:
|
||||
ciphers[t.cipher] += 1
|
||||
return {
|
||||
"sessions_total": len(ctx.tls_sessions),
|
||||
"versions": dict(versions), "ciphers": dict(ciphers),
|
||||
"sessions": sessions[:_MAX_TLS],
|
||||
}
|
||||
|
||||
|
||||
def _dns_section(ctx: CaptureContext) -> dict:
|
||||
names: dict[str, dict] = {}
|
||||
rcodes: dict[str, int] = defaultdict(int)
|
||||
qtypes: dict[str, int] = defaultdict(int)
|
||||
resolvers: dict[str, int] = defaultdict(int)
|
||||
for q in ctx.dns_queries:
|
||||
name = (q.query_name or "").rstrip(".").lower()
|
||||
if not name:
|
||||
continue
|
||||
e = names.setdefault(name, {"name": name, "queries": 0, "clients": set(), "answers": set(),
|
||||
"rcodes": set(), "types": set(), "transport": q.transport})
|
||||
if q.is_response:
|
||||
if q.rcode:
|
||||
rcodes[q.rcode] += 1
|
||||
e["rcodes"].add(q.rcode)
|
||||
e["answers"].update(q.response_ips[:6])
|
||||
if q.resolver:
|
||||
resolvers[q.resolver] += 1
|
||||
else:
|
||||
e["queries"] += 1
|
||||
if q.src_ip:
|
||||
e["clients"].add(q.src_ip)
|
||||
if q.qtype:
|
||||
qtypes[q.qtype] += 1
|
||||
e["types"].add(q.qtype)
|
||||
out = []
|
||||
for e in names.values():
|
||||
e["clients"] = sorted(e["clients"])[:6]
|
||||
e["answers"] = sorted(e["answers"])[:6]
|
||||
e["rcodes"] = sorted(e["rcodes"])
|
||||
e["types"] = sorted(e["types"])
|
||||
e["device"] = ctx.device_ip in e["clients"] if ctx.device_ip else False
|
||||
out.append(e)
|
||||
out.sort(key=lambda e: (-int(e["device"]), -e["queries"], e["name"]))
|
||||
return {
|
||||
"unique_names": len(names),
|
||||
"queries": sum(1 for q in ctx.dns_queries if not q.is_response),
|
||||
"responses": sum(1 for q in ctx.dns_queries if q.is_response),
|
||||
"rcodes": dict(rcodes), "query_types": dict(qtypes), "resolvers": dict(resolvers),
|
||||
"names": out[:_MAX_DNS_NAMES],
|
||||
}
|
||||
|
||||
|
||||
def _http_section(ctx: CaptureContext) -> dict:
|
||||
hosts: dict[str, dict] = {}
|
||||
for r in ctx.http_requests:
|
||||
key = str(r.host or r.dst_ip or "?")
|
||||
e = hosts.setdefault(key, {"host": key, "server_ip": r.dst_ip, "port": r.dst_port,
|
||||
"requests": 0, "methods": set(), "uris": [], "clients": set(),
|
||||
"user_agents": set(), "auth": False})
|
||||
e["requests"] += 1
|
||||
if r.method:
|
||||
e["methods"].add(r.method)
|
||||
if r.uri and len(e["uris"]) < 15 and r.uri not in e["uris"]:
|
||||
e["uris"].append(r.uri)
|
||||
if r.src_ip:
|
||||
e["clients"].add(r.src_ip)
|
||||
if r.user_agent:
|
||||
e["user_agents"].add(r.user_agent[:100])
|
||||
if r.authorization:
|
||||
e["auth"] = True
|
||||
out = []
|
||||
for e in hosts.values():
|
||||
e["methods"] = sorted(e["methods"])
|
||||
e["clients"] = sorted(e["clients"])[:6]
|
||||
e["user_agents"] = sorted(e["user_agents"])[:5]
|
||||
e["device"] = bool(ctx.device_ip and (ctx.device_ip in e["clients"] or e["server_ip"] == ctx.device_ip))
|
||||
out.append(e)
|
||||
out.sort(key=lambda e: (-int(e["device"]), -e["requests"]))
|
||||
status: dict[str, int] = defaultdict(int)
|
||||
for resp in ctx.http_responses:
|
||||
if resp.status_code is not None:
|
||||
status[str(resp.status_code)] += 1
|
||||
return {"requests": len(ctx.http_requests), "responses": len(ctx.http_responses),
|
||||
"status_codes": dict(status), "hosts": out[:200],
|
||||
"file_transfers": [
|
||||
{"filename": f.filename, "extension": f.extension, "content_type": f.content_type,
|
||||
"size": f.size, "protocol": f.protocol, "direction": f.direction,
|
||||
"src_ip": f.src_ip, "dst_ip": f.dst_ip, "uri": f.uri, "packet": f.packet_number}
|
||||
for f in ctx.file_transfers[:100]
|
||||
]}
|
||||
|
||||
|
||||
def _cleartext_section(ctx: CaptureContext) -> dict:
|
||||
"""Unencrypted application flows and clear-text services grouped by protocol."""
|
||||
flows: dict[str, dict] = {}
|
||||
for c in ctx.conversations:
|
||||
if c.payload_bytes <= 0 or c.is_encrypted:
|
||||
continue
|
||||
protos = sorted(c.app_protocols & _CLEARTEXT_APP) or ([_PORT_NAMES[c.dst_port]] if c.dst_port in _PORT_NAMES and c.dst_port not in (53, 123, 67, 68, 5353, 1900, 137, 138) else [])
|
||||
if not protos:
|
||||
continue
|
||||
for p in protos:
|
||||
e = flows.setdefault(p, {"protocol": p, "flows": 0, "bytes": 0, "pairs": set(), "device": False})
|
||||
e["flows"] += 1
|
||||
e["bytes"] += c.payload_bytes
|
||||
e["pairs"].add(f"{c.src_ip} -> {c.dst_ip}:{c.dst_port}")
|
||||
if ctx.is_device(c.src_ip) or ctx.is_device(c.dst_ip):
|
||||
e["device"] = True
|
||||
out = []
|
||||
for e in flows.values():
|
||||
e["pairs"] = sorted(e["pairs"])[:20]
|
||||
out.append(e)
|
||||
out.sort(key=lambda e: (-int(e["device"]), -e["bytes"]))
|
||||
services = [
|
||||
{"protocol": s.protocol, "kind": s.kind, "value": s.value, "detail": s.detail,
|
||||
"src_ip": s.src_ip, "dst_ip": s.dst_ip, "port": s.dst_port, "packet": s.packet_number}
|
||||
for s in ctx.service_events[:200]
|
||||
]
|
||||
return {"flows": out, "service_events": services,
|
||||
"mqtt_events": len(ctx.mqtt_events),
|
||||
"dhcp": [
|
||||
{"type": d.message_type, "client_mac": d.client_mac, "client_ip": d.client_ip,
|
||||
"hostname": d.hostname, "vendor_class": d.vendor_class, "server": d.server_id}
|
||||
for d in ctx.dhcp_events[:60]
|
||||
]}
|
||||
|
||||
|
||||
def _coverage_section(ctx: CaptureContext) -> dict:
|
||||
notes = list(ctx.coverage_notes)
|
||||
if ctx.truncated:
|
||||
notes.insert(0, ctx.truncation_reason or "Extraction was truncated.")
|
||||
encrypted_flows = sum(1 for c in ctx.conversations if c.is_encrypted)
|
||||
payload_flows = sum(1 for c in ctx.conversations if c.payload_bytes > 0)
|
||||
undecoded = [c for c in ctx.conversations if c.payload_bytes > 0 and not c.app_protocols]
|
||||
undecoded_ports: dict[int, int] = defaultdict(int)
|
||||
for c in undecoded:
|
||||
if c.dst_port:
|
||||
undecoded_ports[c.dst_port] += 1
|
||||
if not ctx.device_ip:
|
||||
notes.append("No device address was configured; findings are not attributed to a device under test.")
|
||||
tls13 = sum(1 for t in ctx.tls_sessions if t.version == "TLS 1.3")
|
||||
if tls13:
|
||||
notes.append(f"{tls13} TLS 1.3 sessions: server certificates are encrypted and could not be inspected.")
|
||||
return {
|
||||
"complete": not ctx.truncated,
|
||||
"notes": notes,
|
||||
"flows_total": len(ctx.conversations),
|
||||
"flows_with_payload": payload_flows,
|
||||
"flows_encrypted": encrypted_flows,
|
||||
"flows_undecoded": len(undecoded),
|
||||
"undecoded_ports": dict(sorted(undecoded_ports.items(), key=lambda kv: -kv[1])[:15]),
|
||||
"text_payloads_scanned": len(ctx.text_payloads),
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Logging helpers: per-run log files and consistent formatting."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import logging
|
||||
import threading
|
||||
from typing import Iterator, Optional
|
||||
|
||||
LOG_FORMAT = "%(asctime)s %(levelname)-7s [%(name)s] %(message)s"
|
||||
DATE_FORMAT = "%Y-%m-%d %H:%M:%S"
|
||||
|
||||
# Loggers that describe a capture analysis end to end.
|
||||
ANALYSIS_LOGGERS = ("analyzer", "IoTAnalyzer.analyze")
|
||||
|
||||
|
||||
class _ThreadFilter(logging.Filter):
|
||||
"""Only pass records emitted by the thread that opened the run log."""
|
||||
|
||||
def __init__(self, thread_id: int):
|
||||
super().__init__()
|
||||
self.thread_id = thread_id
|
||||
|
||||
def filter(self, record: logging.LogRecord) -> bool:
|
||||
return record.thread == self.thread_id
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def run_log(path: Optional[str], level: int = logging.DEBUG) -> Iterator[Optional[logging.Handler]]:
|
||||
"""Write everything this thread logs about the analysis to ``path``.
|
||||
|
||||
Concurrent analyses in other threads do not leak into the file.
|
||||
"""
|
||||
if not path:
|
||||
yield None
|
||||
return
|
||||
handler: Optional[logging.FileHandler] = None
|
||||
try:
|
||||
handler = logging.FileHandler(path, mode="w", encoding="utf-8")
|
||||
except OSError as exc:
|
||||
logging.getLogger("analyzer").warning("Run log %s could not be opened: %s", path, exc)
|
||||
yield None
|
||||
return
|
||||
handler.setLevel(level)
|
||||
handler.setFormatter(logging.Formatter(LOG_FORMAT, DATE_FORMAT))
|
||||
handler.addFilter(_ThreadFilter(threading.get_ident()))
|
||||
loggers = [logging.getLogger(n) for n in ANALYSIS_LOGGERS]
|
||||
previous = [(lg, lg.level) for lg in loggers]
|
||||
for lg in loggers:
|
||||
lg.addHandler(handler)
|
||||
if lg.level == logging.NOTSET or lg.level > level:
|
||||
lg.setLevel(level)
|
||||
try:
|
||||
yield handler
|
||||
finally:
|
||||
for lg, lvl in previous:
|
||||
lg.removeHandler(handler)
|
||||
lg.setLevel(lvl)
|
||||
handler.close()
|
||||
|
||||
|
||||
def configure_console(level: str = "INFO") -> None:
|
||||
"""Console logging for CLI use."""
|
||||
logging.basicConfig(level=getattr(logging, level.upper(), logging.INFO),
|
||||
format=LOG_FORMAT, datefmt=DATE_FORMAT)
|
||||
|
||||
|
||||
def tail(path: str, lines: int = 400, max_bytes: int = 256 * 1024) -> str:
|
||||
"""Return the last ``lines`` lines of a text file."""
|
||||
try:
|
||||
with open(path, "rb") as fh:
|
||||
fh.seek(0, 2)
|
||||
size = fh.tell()
|
||||
fh.seek(max(0, size - max_bytes))
|
||||
data = fh.read().decode("utf-8", "replace")
|
||||
except OSError:
|
||||
return ""
|
||||
return "\n".join(data.splitlines()[-lines:])
|
||||
@@ -105,3 +105,182 @@ def evidence_line(f: Finding) -> str:
|
||||
kv = ", ".join(f"{k}={v}" for k, v in list(extras.items())[:6])
|
||||
parts.append(kv)
|
||||
return " | ".join(parts)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Inventory rendering shared by the Markdown and text renderers
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def _kb(n) -> str:
|
||||
try:
|
||||
n = int(n or 0)
|
||||
except (TypeError, ValueError):
|
||||
return "—"
|
||||
if n >= 1024 * 1024:
|
||||
return f"{n / (1024 * 1024):.1f} MB"
|
||||
if n >= 1024:
|
||||
return f"{n / 1024:.1f} kB"
|
||||
return f"{n} B"
|
||||
|
||||
|
||||
def inventory_lines(inv: dict, *, md: bool = True) -> list[str]:
|
||||
"""Render the observation inventory as a list of lines.
|
||||
|
||||
``md`` selects Markdown formatting; otherwise plain text is produced.
|
||||
"""
|
||||
if not inv:
|
||||
return []
|
||||
h2 = (lambda t: f"## {t}") if md else (lambda t: t.upper())
|
||||
h3 = (lambda t: f"### {t}") if md else (lambda t: f" {t}")
|
||||
b = (lambda t: f"- {t}") if md else (lambda t: f" - {t}")
|
||||
code = (lambda t: f"`{t}`") if md else (lambda t: str(t))
|
||||
out: list[str] = []
|
||||
|
||||
cap = inv.get("capture") or {}
|
||||
out.append(h2("Observation Inventory"))
|
||||
out.append("")
|
||||
out.append(b(f"Capture: {cap.get('packets', 0)} packets, {cap.get('duration_seconds', 0)} s, "
|
||||
f"{cap.get('internal_hosts', 0)} internal and {cap.get('external_hosts', 0)} external hosts, "
|
||||
f"{cap.get('conversations', 0)} flows"
|
||||
+ (f", VLANs {', '.join(map(str, cap['vlans']))}" if cap.get("vlans") else "")
|
||||
+ (f" · extractor {cap['extractor']}" if cap.get("extractor") else "")))
|
||||
|
||||
dev = inv.get("device") or {}
|
||||
out.append("")
|
||||
out.append(h3("Device under test"))
|
||||
if not dev.get("configured"):
|
||||
out.append(b("No device address configured."))
|
||||
for c in dev.get("candidates", [])[:5]:
|
||||
names = ", ".join(c.get("names") or []) or "—"
|
||||
out.append(b(f"Candidate {code(c['ip'])} ({names}; {', '.join(c.get('macs') or []) or 'no MAC'}; {c.get('packets')} packets)"))
|
||||
elif not dev.get("seen"):
|
||||
out.append(b(f"{code(dev.get('ip'))}: {dev.get('note')}"))
|
||||
else:
|
||||
out.append(b(f"{code(dev['ip'])} · MAC {', '.join(dev.get('macs') or []) or '—'} · "
|
||||
f"names {', '.join(dev.get('names') or []) or '—'}"
|
||||
+ (f" · DHCP vendor class {dev['dhcp_vendor_class']}" if dev.get("dhcp_vendor_class") else "")))
|
||||
out.append(b(f"{dev.get('packets')} packets · sent {_kb(dev.get('bytes_sent'))} · received {_kb(dev.get('bytes_received'))} · "
|
||||
f"first seen {fmt_ts(dev.get('first_seen'))} · last seen {fmt_ts(dev.get('last_seen'))}"))
|
||||
out.append(b(f"Services offered: {', '.join(map(str, dev.get('ports_offered') or [])) or 'none observed'} · "
|
||||
f"ports contacted: {', '.join(map(str, dev.get('ports_contacted') or [])) or '—'}"))
|
||||
out.append(b(f"Protocols: {', '.join(dev.get('protocols') or []) or '—'} · TLS sessions: {dev.get('tls_sessions', 0)} · "
|
||||
f"clear-text flows: {dev.get('cleartext_flows', 0)}"))
|
||||
if dev.get("dns_queries"):
|
||||
out.append(b("DNS names queried: " + ", ".join(dev["dns_queries"][:30])))
|
||||
peers = dev.get("peers") or []
|
||||
if peers:
|
||||
out.append(b(f"Peers ({len(peers)}):"))
|
||||
for p in peers[:40]:
|
||||
names = ", ".join(p.get("names") or [])
|
||||
out.append((" " if md else " ") + b(
|
||||
f"{code(p['ip'])}{' (' + names + ')' if names else ''} · "
|
||||
f"{'external' if p.get('external') else 'internal'} · ports {', '.join(map(str, p.get('ports') or []))} · "
|
||||
f"{', '.join(p.get('protocols') or []) or 'undecoded'} · to peer {_kb(p.get('bytes_to_peer'))} / from peer {_kb(p.get('bytes_from_peer'))}"
|
||||
).lstrip())
|
||||
|
||||
hosts = inv.get("hosts") or []
|
||||
if hosts:
|
||||
out.append("")
|
||||
out.append(h3(f"Internal hosts ({len(hosts)})"))
|
||||
for hst in hosts[:40]:
|
||||
names = ", ".join(hst.get("names") or [])
|
||||
out.append(b(f"{code(hst['ip'])}{' (' + names + ')' if names else ''}"
|
||||
f"{' ← device' if hst.get('is_device') else ''} · MAC {', '.join(hst.get('macs') or []) or '—'} · "
|
||||
f"{hst.get('packets')} packets · offers {', '.join(map(str, hst.get('ports_offered') or [])) or '—'} · "
|
||||
f"{hst.get('external_endpoints', 0)} external endpoints"))
|
||||
|
||||
eps = inv.get("external_endpoints") or []
|
||||
if eps:
|
||||
out.append("")
|
||||
out.append(h3(f"External endpoints ({len(eps)})"))
|
||||
for e in eps[:60]:
|
||||
names = ", ".join(e.get("names") or [])
|
||||
out.append(b(f"{code(e['ip'])}{' (' + names + ')' if names else ''}{' ← device' if e.get('device') else ''} · "
|
||||
f"ports {', '.join(map(str, e.get('ports') or []))} · {', '.join(e.get('protocols') or []) or 'undecoded'} · "
|
||||
f"{'encrypted' if e.get('encrypted') else 'clear-text or unknown'} · out {_kb(e.get('bytes_out'))} / in {_kb(e.get('bytes_in'))}"))
|
||||
if len(eps) > 60:
|
||||
out.append(b(f"… and {len(eps) - 60} more (see JSON report)"))
|
||||
|
||||
svcs = inv.get("services") or []
|
||||
if svcs:
|
||||
out.append("")
|
||||
out.append(h3(f"Services observed ({len(svcs)})"))
|
||||
for s in svcs[:60]:
|
||||
out.append(b(f"{code(str(s['ip']) + ':' + str(s['port']))} {s.get('service')} ({s.get('transport')})"
|
||||
f"{' ← device' if s.get('device') else ''} · {'encrypted' if s.get('encrypted') else 'clear-text' if s.get('cleartext_app') else 'unknown'} · "
|
||||
f"{s.get('flows')} flows from {len(s.get('clients') or [])} clients"))
|
||||
|
||||
protos = (inv.get("protocols") or {}).get("by_protocol") or {}
|
||||
if protos:
|
||||
out.append("")
|
||||
out.append(h3("Protocols (packets)"))
|
||||
out.append(b(", ".join(f"{k} {v}" for k, v in list(protos.items())[:30])))
|
||||
icmp = (inv.get("protocols") or {}).get("icmp") or {}
|
||||
if icmp:
|
||||
out.append(b("ICMP: " + ", ".join(f"{k} {v}" for k, v in icmp.items())))
|
||||
|
||||
tls = inv.get("tls") or {}
|
||||
if tls.get("sessions_total"):
|
||||
out.append("")
|
||||
out.append(h3(f"TLS posture ({tls['sessions_total']} handshakes)"))
|
||||
out.append(b("Versions: " + ", ".join(f"{k} ×{v}" for k, v in (tls.get("versions") or {}).items())))
|
||||
out.append(b("Cipher suites: " + ", ".join(f"{k} ×{v}" for k, v in (tls.get("ciphers") or {}).items())))
|
||||
for s in (tls.get("sessions") or [])[:30]:
|
||||
cert = s.get("certificate") or {}
|
||||
cert_txt = ""
|
||||
if cert:
|
||||
cert_txt = f" · cert {cert.get('subject')} by {cert.get('issuer')}" + (f", expires {cert.get('not_after')}" if cert.get("not_after") else "")
|
||||
out.append(b(f"{s.get('client')} → {code(str(s.get('server')) + ':' + str(s.get('port')))}"
|
||||
f"{' (' + s['sni'] + ')' if s.get('sni') else ''}{' ← device' if s.get('device') else ''} · "
|
||||
f"{s.get('version') or 'no ServerHello'} · {s.get('cipher') or '—'}"
|
||||
f"{' · client max ' + s['client_max_version'] if s.get('client_max_version') else ''}"
|
||||
f"{cert_txt}{' · mutual TLS' if s.get('mutual_tls') else ''}"
|
||||
f"{' · alerts ' + ', '.join(s['alerts']) if s.get('alerts') else ''}"))
|
||||
|
||||
dns = inv.get("dns") or {}
|
||||
if dns.get("unique_names"):
|
||||
out.append("")
|
||||
out.append(h3(f"DNS ({dns.get('queries')} queries, {dns.get('responses')} responses, {dns['unique_names']} names)"))
|
||||
out.append(b("Response codes: " + ", ".join(f"{k} {v}" for k, v in (dns.get("rcodes") or {}).items())
|
||||
+ " · resolvers: " + ", ".join(f"{k} ({v})" for k, v in (dns.get("resolvers") or {}).items())))
|
||||
for n in (dns.get("names") or [])[:60]:
|
||||
out.append(b(f"{code(n['name'])}{' ← device' if n.get('device') else ''} · {n.get('queries')}× · "
|
||||
f"{', '.join(n.get('types') or [])} · {', '.join(n.get('answers') or []) or 'no answer seen'}"))
|
||||
|
||||
http = inv.get("http") or {}
|
||||
if http.get("requests"):
|
||||
out.append("")
|
||||
out.append(h3(f"HTTP ({http['requests']} requests)"))
|
||||
for hh in (http.get("hosts") or [])[:30]:
|
||||
out.append(b(f"{code(hh['host'])}{' ← device' if hh.get('device') else ''} · {hh.get('requests')} requests · "
|
||||
f"{', '.join(hh.get('methods') or [])} · {', '.join(hh.get('uris') or [])[:200]}"
|
||||
f"{' · with Authorization header' if hh.get('auth') else ''}"))
|
||||
for ft in (http.get("file_transfers") or [])[:20]:
|
||||
out.append(b(f"File transfer: {ft.get('filename') or ft.get('uri') or ft.get('content_type')} "
|
||||
f"({ft.get('protocol')}, {ft.get('direction')}, {ft.get('src_ip')} → {ft.get('dst_ip')})"))
|
||||
|
||||
ct = inv.get("cleartext") or {}
|
||||
if ct.get("flows") or ct.get("service_events") or ct.get("dhcp"):
|
||||
out.append("")
|
||||
out.append(h3("Clear-text protocols and service events"))
|
||||
for f in ct.get("flows") or []:
|
||||
out.append(b(f"{f['protocol']}{' ← device' if f.get('device') else ''}: {f.get('flows')} flows, {_kb(f.get('bytes'))} · "
|
||||
+ "; ".join((f.get("pairs") or [])[:6])))
|
||||
for s in (ct.get("service_events") or [])[:30]:
|
||||
out.append(b(f"{s.get('protocol')} {s.get('kind')}: {s.get('value') or ''} {s.get('detail') or ''} "
|
||||
f"({s.get('src_ip')} → {s.get('dst_ip')})".strip()))
|
||||
for d in (ct.get("dhcp") or [])[:20]:
|
||||
out.append(b(f"DHCP {d.get('type')}: {d.get('client_mac')} {d.get('hostname') or ''} "
|
||||
f"{d.get('vendor_class') or ''} → {d.get('client_ip') or ''} (server {d.get('server') or '—'})".strip()))
|
||||
|
||||
cov = inv.get("coverage") or {}
|
||||
out.append("")
|
||||
out.append(h3("Coverage"))
|
||||
out.append(b(f"{'Complete' if cov.get('complete') else 'PARTIAL'} extraction · {cov.get('flows_total', 0)} flows, "
|
||||
f"{cov.get('flows_encrypted', 0)} encrypted, {cov.get('flows_undecoded', 0)} with undecoded payload · "
|
||||
f"{cov.get('text_payloads_scanned', 0)} clear-text payloads scanned"))
|
||||
if cov.get("undecoded_ports"):
|
||||
out.append(b("Undecoded ports (flows): " + ", ".join(f"{k}: {v}" for k, v in cov["undecoded_ports"].items())))
|
||||
for n in cov.get("notes") or []:
|
||||
out.append(b(n))
|
||||
return out
|
||||
|
||||
@@ -6,6 +6,7 @@ from ..engine import AnalysisResult
|
||||
from ..findings import Severity
|
||||
from ._common import (
|
||||
SEVERITY_LABEL, fmt_ts, affected_line, evidence_line, artifact_line, sources_list,
|
||||
inventory_lines,
|
||||
)
|
||||
|
||||
|
||||
@@ -20,6 +21,12 @@ def render(result: AnalysisResult, extra: dict | None = None) -> str:
|
||||
out.append(f"- **Packets:** {m.get('packet_count', 0)} · "
|
||||
f"**Duration:** {m.get('duration_seconds', 0)} s · "
|
||||
f"**Findings:** {m.get('finding_count', 0)}")
|
||||
if m.get("device_ip"):
|
||||
out.append(f"- **Device under test:** `{m['device_ip']}`"
|
||||
+ (f" (MAC {', '.join(m.get('device_macs') or [])})" if m.get("device_macs") else "")
|
||||
+ f" · device-related findings: {s.get('device_related', 0)}, other hosts: {s.get('other_hosts', 0)}")
|
||||
if m.get("truncated"):
|
||||
out.append(f"- **⚠️ Partial analysis:** {m.get('truncation_reason')}")
|
||||
out.append("")
|
||||
|
||||
bysev = s.get("by_severity", {})
|
||||
@@ -62,12 +69,18 @@ def render(result: AnalysisResult, extra: dict | None = None) -> str:
|
||||
for source in srcs:
|
||||
out.append(f" - {source}")
|
||||
out.append(f"- **Risk:** {f.risk_description}")
|
||||
out.append(f"- **Affected:** {affected_line(f)}")
|
||||
out.append(f"- **Affected:** {affected_line(f)}"
|
||||
+ (" · **involves device under test**" if f.evidence.get("device_related") else ""))
|
||||
out.append(f"- **Additional evidence:** {evidence_line(f)}")
|
||||
out.append(f"- **Recommendation:** {f.recommendation}")
|
||||
out.append(f"- **Finding ID:** `{f.finding_id}`")
|
||||
out.append("")
|
||||
|
||||
inv_lines = inventory_lines(result.inventory, md=True)
|
||||
if inv_lines:
|
||||
out.append("")
|
||||
out.extend(inv_lines)
|
||||
|
||||
# Optional URL and hash enrichment results.
|
||||
if extra:
|
||||
hashes = extra.get("hashes") or []
|
||||
|
||||
@@ -6,6 +6,7 @@ from ..engine import AnalysisResult
|
||||
from ..findings import Severity
|
||||
from ._common import (
|
||||
SEVERITY_LABEL, affected_line, evidence_line, artifact_line, sources_list,
|
||||
inventory_lines,
|
||||
)
|
||||
|
||||
|
||||
@@ -21,6 +22,11 @@ def render(result: AnalysisResult, extra: dict | None = None) -> str:
|
||||
out.append(f"Capture : {m.get('capture_path', '-')}")
|
||||
out.append(f"Packets : {m.get('packet_count', 0)} Duration: {m.get('duration_seconds', 0)}s")
|
||||
out.append(f"Findings: {m.get('finding_count', 0)}")
|
||||
if m.get("device_ip"):
|
||||
out.append(f"Device : {m['device_ip']} (device-related findings: {s.get('device_related', 0)}, "
|
||||
f"other hosts: {s.get('other_hosts', 0)})")
|
||||
if m.get("truncated"):
|
||||
out.append(f"WARNING : partial analysis - {m.get('truncation_reason')}")
|
||||
out.append("")
|
||||
|
||||
bysev = s.get("by_severity", {})
|
||||
@@ -62,6 +68,12 @@ def render(result: AnalysisResult, extra: dict | None = None) -> str:
|
||||
out.append(f" Finding ID : {f.finding_id}")
|
||||
out.append("")
|
||||
|
||||
inv_lines = inventory_lines(result.inventory, md=False)
|
||||
if inv_lines:
|
||||
out.append("-" * 70)
|
||||
out.extend(inv_lines)
|
||||
out.append("")
|
||||
|
||||
if extra:
|
||||
hashes = extra.get("hashes") or []
|
||||
urls = extra.get("urls") or []
|
||||
|
||||
@@ -34,14 +34,23 @@ def dedupe(findings: list[Finding]) -> list[Finding]:
|
||||
|
||||
|
||||
def sort_by_priority(findings: list[Finding]) -> list[Finding]:
|
||||
"""Sort by severity, confidence, and stable category order."""
|
||||
"""Sort by severity, device relevance, confidence, and stable category order."""
|
||||
return sorted(
|
||||
findings,
|
||||
key=lambda f: (f.severity.rank, f.confidence.rank, -_category_order(f.category)),
|
||||
key=lambda f: (f.severity.rank, _device_rank(f), f.confidence.rank,
|
||||
-_category_order(f.category)),
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
|
||||
def _device_rank(f: Finding) -> int:
|
||||
"""Findings that involve the device under test outrank equally severe others."""
|
||||
rel = f.evidence.get("device_related") if f.evidence else None
|
||||
if rel is True:
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
def summarize(findings: list[Finding]) -> dict:
|
||||
"""Build the prioritized summary consumed by report renderers."""
|
||||
by_sev = {s.value: 0 for s in Severity}
|
||||
@@ -56,10 +65,15 @@ def summarize(findings: list[Finding]) -> dict:
|
||||
and f.severity.rank >= Severity.HIGH.rank
|
||||
]
|
||||
|
||||
device_related = sum(1 for f in findings if f.evidence.get("device_related") is True)
|
||||
other_hosts = sum(1 for f in findings if f.evidence.get("device_related") is False)
|
||||
|
||||
return {
|
||||
"total": len(findings),
|
||||
"by_severity": by_sev,
|
||||
"by_category": by_cat,
|
||||
"device_related": device_related,
|
||||
"other_hosts": other_hosts,
|
||||
"highlights": highlights,
|
||||
"top": sort_by_priority(findings)[:10],
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ class PlaintextCredentialTests(unittest.TestCase):
|
||||
authorization=f"Basic {token}", src_ip="10.0.0.5", dst_ip="93.184.216.34",
|
||||
dst_port=80))
|
||||
res = run(ctx)
|
||||
f = next(f for f in res.findings if "HTTP Basic" in f.title)
|
||||
f = next(f for f in res.findings if "Basic authentication over HTTP" in f.title)
|
||||
self.assertEqual(f.evidence["username"], "bob")
|
||||
|
||||
|
||||
@@ -76,7 +76,8 @@ class InsecureProtocolTests(unittest.TestCase):
|
||||
ctx = CaptureContext()
|
||||
ctx.conversations.append(Conversation(
|
||||
src_ip="192.168.0.2", dst_ip="192.168.0.3", src_port=50000, dst_port=23,
|
||||
protocol="tcp", packets=10, bytes=500))
|
||||
protocol="tcp", packets=10, bytes=500, payload_bytes=200, synack_count=1,
|
||||
app_protocols={"telnet"}))
|
||||
res = run(ctx)
|
||||
f = next(f for f in res.findings if f.category == Category.INSECURE_PROTOCOL)
|
||||
self.assertEqual(f.severity, Severity.CRITICAL)
|
||||
@@ -88,7 +89,8 @@ class TlsWeaknessTests(unittest.TestCase):
|
||||
ctx = CaptureContext()
|
||||
ctx.tls_sessions.append(TlsSession(
|
||||
src_ip="10.0.0.1", dst_ip="1.2.3.4", dst_port=443,
|
||||
version="TLS 1.0", cipher="TLS_RSA_WITH_RC4_128_SHA", sni="legacy.example"))
|
||||
version="TLS 1.0", cipher="TLS_RSA_WITH_RC4_128_SHA", sni="legacy.example",
|
||||
server_hello_seen=True))
|
||||
res = run(ctx)
|
||||
titles = [f.title for f in res.findings if f.category == Category.TLS_WEAKNESS]
|
||||
self.assertTrue(any("Obsolete TLS" in t for t in titles))
|
||||
@@ -337,7 +339,7 @@ class TlsCertTests(unittest.TestCase):
|
||||
src_ip="10.0.0.1", dst_ip="1.2.3.4", dst_port=443,
|
||||
version="TLS 1.2", sni="iot.local",
|
||||
cert_subject_cn="iot.local", cert_issuer_cn="iot.local",
|
||||
cert_self_signed=True))
|
||||
cert_self_signed=True, cert_seen=True))
|
||||
res = run(ctx)
|
||||
self.assertTrue(any("self-signed" in f.title for f in res.findings))
|
||||
|
||||
@@ -345,20 +347,20 @@ class TlsCertTests(unittest.TestCase):
|
||||
ctx = CaptureContext()
|
||||
ctx.tls_sessions.append(TlsSession(
|
||||
src_ip="10.0.0.1", dst_ip="1.2.3.4", dst_port=443,
|
||||
version="TLS 1.2", cert_expired=True))
|
||||
version="TLS 1.2", cert_expired=True, cert_seen=True))
|
||||
res = run(ctx)
|
||||
self.assertTrue(any("expired" in f.title for f in res.findings))
|
||||
|
||||
|
||||
class CertHelperTests(unittest.TestCase):
|
||||
def test_self_signed_helper(self):
|
||||
from analyzer.extract_pyshark import _cert_is_self_signed
|
||||
from analyzer.extract_tshark import _cert_is_self_signed
|
||||
self.assertTrue(_cert_is_self_signed("device.local", "device.local"))
|
||||
self.assertFalse(_cert_is_self_signed("device.local", "DigiCert CA"))
|
||||
self.assertIsNone(_cert_is_self_signed("device.local", None))
|
||||
|
||||
def test_expired_helper(self):
|
||||
from analyzer.extract_pyshark import _cert_is_expired
|
||||
from analyzer.extract_tshark import _cert_is_expired
|
||||
# The certificate expired before the 2025 capture timestamp.
|
||||
now = 1735689600.0 # 2025-01-01 UTC
|
||||
self.assertTrue(_cert_is_expired("2020-01-01 00:00:00", now))
|
||||
@@ -366,7 +368,7 @@ class CertHelperTests(unittest.TestCase):
|
||||
self.assertIsNone(_cert_is_expired(None, now))
|
||||
|
||||
def test_parse_cert_time_formats(self):
|
||||
from analyzer.extract_pyshark import _parse_cert_time
|
||||
from analyzer.extract_tshark import _parse_cert_time
|
||||
self.assertIsNotNone(_parse_cert_time("2020-01-01 00:00:00"))
|
||||
self.assertIsNotNone(_parse_cert_time("Jan 1 00:00:00 2020 GMT"))
|
||||
self.assertIsNotNone(_parse_cert_time("200101000000Z"))
|
||||
@@ -490,7 +492,7 @@ class UserAgentTests(unittest.TestCase):
|
||||
|
||||
class DnsQtypeTests(unittest.TestCase):
|
||||
def test_qtype_name_mapping(self):
|
||||
from analyzer.extract_pyshark import _dns_qtype_name
|
||||
from analyzer.extract_tshark import _dns_qtype_name
|
||||
self.assertEqual(_dns_qtype_name("16"), "TXT")
|
||||
self.assertEqual(_dns_qtype_name("1"), "A")
|
||||
self.assertEqual(_dns_qtype_name("txt"), "TXT")
|
||||
@@ -516,14 +518,14 @@ class DnsQtypeTests(unittest.TestCase):
|
||||
|
||||
class DownloadHelperTests(unittest.TestCase):
|
||||
def test_is_download_content_type(self):
|
||||
from analyzer.extract_pyshark import _is_download_content_type
|
||||
from analyzer.extract_tshark import _is_download_content_type
|
||||
self.assertTrue(_is_download_content_type("application/octet-stream"))
|
||||
self.assertTrue(_is_download_content_type("application/x-msdownload"))
|
||||
self.assertFalse(_is_download_content_type("text/html; charset=utf-8"))
|
||||
self.assertFalse(_is_download_content_type(None))
|
||||
|
||||
def test_filename_from_disposition(self):
|
||||
from analyzer.extract_pyshark import _filename_from_disposition
|
||||
from analyzer.extract_tshark import _filename_from_disposition
|
||||
self.assertEqual(_filename_from_disposition('attachment; filename="update.exe"'), "update.exe")
|
||||
self.assertEqual(_filename_from_disposition("attachment; filename=payload.bin"), "payload.bin")
|
||||
self.assertIsNone(_filename_from_disposition("inline"))
|
||||
@@ -537,7 +539,7 @@ class FileTransferDownloadTests(unittest.TestCase):
|
||||
filename=None, extension=None, content_type="application/x-msdownload",
|
||||
protocol="http", src_ip="1.2.3.4", dst_ip="10.0.0.5", direction="download"))
|
||||
res = run(ctx)
|
||||
self.assertTrue(any("harmful file" in f.title for f in res.findings))
|
||||
self.assertTrue(any("Executable" in f.title for f in res.findings))
|
||||
|
||||
def test_disposition_filename_extension_flagged(self):
|
||||
from analyzer.context import FileTransfer
|
||||
@@ -765,7 +767,7 @@ class DemoPayloadRegressionTests(unittest.TestCase):
|
||||
self.assertTrue(any("40.101.70.178" in u for u in urls))
|
||||
|
||||
def test_hexish_payload_decoder(self):
|
||||
from analyzer.extract_pyshark import _bytes_from_hexish
|
||||
from analyzer.extract_tshark import _bytes_from_hexish
|
||||
# pyshark commonly exposes payload bytes as colon-separated hex.
|
||||
self.assertEqual(_bytes_from_hexish("47:45:54"), "GET")
|
||||
self.assertEqual(_bytes_from_hexish("already text"), "already text")
|
||||
|
||||
@@ -0,0 +1,382 @@
|
||||
"""Regression tests for detector precision, device attribution and the inventory.
|
||||
|
||||
Every case here mirrors a false positive or a gap that was observed on real
|
||||
captures. Synthetic contexts keep the suite independent of tshark; the final
|
||||
class exercises the real extractor when tshark and a capture are available.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import unittest
|
||||
|
||||
from analyzer import AnalysisEngine, render, FORMATS
|
||||
from analyzer.context import (
|
||||
CaptureContext, Conversation, DnsQuery, HttpRequest, HttpResponse, TlsSession,
|
||||
ArpEvent, PlaintextAuth, AuthAttempt, FileTransfer, DhcpEvent, ServiceEvent,
|
||||
)
|
||||
from analyzer.findings import Category, Severity
|
||||
from analyzer import tls_tables
|
||||
|
||||
|
||||
def run(ctx):
|
||||
return AnalysisEngine().analyze_context(ctx)
|
||||
|
||||
|
||||
def titles(res):
|
||||
return [f.title for f in res.findings]
|
||||
|
||||
|
||||
class FalsePositiveRegressionTests(unittest.TestCase):
|
||||
def test_bulk_flow_is_not_beaconing(self):
|
||||
ctx = CaptureContext()
|
||||
conv = Conversation(src_ip="192.168.1.50", dst_ip="8.8.4.4", src_port=40000,
|
||||
dst_port=443, protocol="tcp", stream_id=3, packets=200,
|
||||
bytes=200 * 1400)
|
||||
conv.timestamps = [1000 + i * 0.01 for i in range(200)] # Line-rate pacing
|
||||
ctx.conversations.append(conv)
|
||||
res = run(ctx)
|
||||
self.assertNotIn(Category.MALWARE_BEACONING, {f.category for f in res.findings})
|
||||
|
||||
def test_beaconing_to_known_provider_is_low(self):
|
||||
ctx = CaptureContext()
|
||||
ctx.dns_queries.append(DnsQuery(query_name="courier.push.apple.com", is_response=True,
|
||||
response_ips=["17.57.146.22"], rcode="NOERROR"))
|
||||
for i in range(8):
|
||||
t = float(i * 60)
|
||||
ctx.conversations.append(Conversation(
|
||||
src_ip="192.168.1.77", dst_ip="17.57.146.22", src_port=40000 + i, dst_port=5223,
|
||||
protocol="tcp", packets=2, bytes=200, first_seen=t, last_seen=t + 1,
|
||||
stream_id=i, timestamps=[t, t + 1]))
|
||||
res = run(ctx)
|
||||
beacons = [f for f in res.findings if f.category == Category.MALWARE_BEACONING]
|
||||
self.assertTrue(beacons)
|
||||
self.assertEqual(beacons[0].severity, Severity.LOW)
|
||||
|
||||
def test_beaconing_to_unnamed_ip_is_high(self):
|
||||
ctx = CaptureContext()
|
||||
for i in range(8):
|
||||
t = float(i * 60)
|
||||
ctx.conversations.append(Conversation(
|
||||
src_ip="192.168.1.77", dst_ip="45.33.32.156", src_port=40000 + i, dst_port=4444,
|
||||
protocol="tcp", packets=2, bytes=200, first_seen=t, last_seen=t + 1,
|
||||
stream_id=i, timestamps=[t, t + 1]))
|
||||
res = run(ctx)
|
||||
beacons = [f for f in res.findings if f.category == Category.MALWARE_BEACONING]
|
||||
self.assertTrue(beacons)
|
||||
self.assertEqual(beacons[0].severity, Severity.HIGH)
|
||||
|
||||
def test_dhcp_source_address_is_not_arp_spoofing(self):
|
||||
ctx = CaptureContext()
|
||||
h = ctx.host("0.0.0.0")
|
||||
h.macs = {"aa:aa:aa:aa:aa:01", "aa:aa:aa:aa:aa:02"}
|
||||
res = run(ctx)
|
||||
self.assertNotIn(Category.MITM, {f.category for f in res.findings})
|
||||
|
||||
def test_javascript_asset_is_not_malware(self):
|
||||
ctx = CaptureContext()
|
||||
ctx.http_requests.append(HttpRequest(
|
||||
method="GET", host="192.168.1.50", uri="/static/app.js", src_ip="192.168.1.10",
|
||||
dst_ip="192.168.1.50", dst_port=80, packet_number=1))
|
||||
res = run(ctx)
|
||||
self.assertFalse(any("Executable" in t for t in titles(res)))
|
||||
|
||||
def test_normal_arp_replies_are_not_a_flood(self):
|
||||
ctx = CaptureContext()
|
||||
ctx.start_time, ctx.end_time = 0.0, 3600.0
|
||||
for i in range(60):
|
||||
ctx.arp_events.append(ArpEvent(sender_ip=f"192.168.1.{i % 20 + 1}",
|
||||
sender_mac=f"bb:bb:bb:bb:bb:{i % 20:02x}",
|
||||
opcode=2, timestamp=i * 60.0, packet_number=100 + i))
|
||||
res = run(ctx)
|
||||
self.assertFalse(any("ARP reply" in t for t in titles(res)))
|
||||
|
||||
def test_arp_flood_by_rate_is_flagged(self):
|
||||
ctx = CaptureContext()
|
||||
ctx.start_time, ctx.end_time = 0.0, 60.0
|
||||
for i in range(400):
|
||||
ctx.arp_events.append(ArpEvent(sender_ip="192.168.1.1", sender_mac="cc:cc:cc:cc:cc:cc",
|
||||
opcode=2, timestamp=i * 0.1, packet_number=i + 1))
|
||||
res = run(ctx)
|
||||
self.assertTrue(any("ARP reply" in t for t in titles(res)))
|
||||
|
||||
def test_nxdomain_ratio_counts_responses_only(self):
|
||||
ctx = CaptureContext()
|
||||
for i in range(30):
|
||||
ctx.dns_queries.append(DnsQuery(query_name=f"host{i}.example.net", is_response=False,
|
||||
src_ip="10.0.0.9", dst_ip="10.0.0.1"))
|
||||
ctx.dns_queries.append(DnsQuery(query_name=f"host{i}.example.net", is_response=True,
|
||||
rcode="NXDOMAIN" if i < 15 else "NOERROR",
|
||||
src_ip="10.0.0.1", dst_ip="10.0.0.9"))
|
||||
res = run(ctx)
|
||||
self.assertTrue(any("NXDOMAIN" in t for t in titles(res)))
|
||||
|
||||
def test_http_403_is_not_bruteforce(self):
|
||||
ctx = CaptureContext()
|
||||
for i in range(12):
|
||||
ctx.http_responses.append(HttpResponse(status_code=403, src_ip="10.0.0.1",
|
||||
dst_ip="10.0.0.9", packet_number=i + 1))
|
||||
res = run(ctx)
|
||||
self.assertFalse(any("failed HTTP" in t for t in titles(res)))
|
||||
|
||||
def test_cdn_with_many_named_subdomains_is_low(self):
|
||||
ctx = CaptureContext()
|
||||
for i in range(45):
|
||||
ctx.dns_queries.append(DnsQuery(query_name=f"edge-{i}.cdn-vendor.example",
|
||||
src_ip="10.0.0.9", dst_ip="10.0.0.1"))
|
||||
res = run(ctx)
|
||||
f = next(f for f in res.findings if "subdomains" in f.title)
|
||||
self.assertEqual(f.severity, Severity.LOW)
|
||||
|
||||
|
||||
class TlsPrecisionTests(unittest.TestCase):
|
||||
def test_cipher_name_mapping_from_tshark_values(self):
|
||||
self.assertEqual(tls_tables.cipher_name("0xc02f"), "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256")
|
||||
self.assertEqual(tls_tables.cipher_name("4866"), "TLS_AES_256_GCM_SHA384")
|
||||
self.assertEqual(tls_tables.version_name("0x0304"), "TLS 1.3")
|
||||
self.assertTrue(tls_tables.is_grease(0x9a9a))
|
||||
self.assertFalse(tls_tables.is_grease(0xc02f))
|
||||
|
||||
def test_negotiated_weak_cipher_is_high(self):
|
||||
ctx = CaptureContext()
|
||||
ctx.tls_sessions.append(TlsSession(src_ip="10.0.0.1", dst_ip="1.2.3.4", dst_port=443,
|
||||
version="TLS 1.2", cipher="TLS_RSA_WITH_RC4_128_MD5",
|
||||
server_hello_seen=True, sni="x.example"))
|
||||
res = run(ctx)
|
||||
f = next(f for f in res.findings if "Weak TLS cipher" in f.title)
|
||||
self.assertEqual(f.severity, Severity.HIGH)
|
||||
|
||||
def test_client_offered_weak_cipher_is_medium_for_device(self):
|
||||
ctx = CaptureContext(device_ip="10.0.0.1")
|
||||
ctx.tls_sessions.append(TlsSession(src_ip="10.0.0.1", dst_ip="1.2.3.4", dst_port=8883,
|
||||
client_hello_seen=True, client_version="TLS 1.2",
|
||||
client_offered_ciphers=["TLS_RSA_WITH_3DES_EDE_CBC_SHA",
|
||||
"TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256"]))
|
||||
res = run(ctx)
|
||||
f = next(f for f in res.findings if "offers broken cipher" in f.title)
|
||||
self.assertEqual(f.severity, Severity.MEDIUM)
|
||||
self.assertEqual(f.affected_device, "10.0.0.1")
|
||||
|
||||
def test_application_data_only_session_is_not_judged(self):
|
||||
ctx = CaptureContext()
|
||||
ctx.tls_sessions.append(TlsSession(src_ip="10.0.0.1", dst_ip="1.2.3.4", version="TLS 1.0"))
|
||||
res = run(ctx)
|
||||
self.assertFalse(any("Obsolete" in t for t in titles(res)))
|
||||
|
||||
def test_sha1_certificate_flagged(self):
|
||||
ctx = CaptureContext()
|
||||
ctx.tls_sessions.append(TlsSession(src_ip="10.0.0.1", dst_ip="1.2.3.4", version="TLS 1.2",
|
||||
cert_seen=True, cert_sig_algorithm="sha1",
|
||||
cert_key_type="RSA", cert_key_bits=1024))
|
||||
res = run(ctx)
|
||||
f = next(f for f in res.findings if "certificate" in f.title.lower())
|
||||
self.assertIn("SHA1", f.title)
|
||||
self.assertIn("1024", f.title)
|
||||
|
||||
|
||||
class NewSignalTests(unittest.TestCase):
|
||||
def test_json_login_over_http(self):
|
||||
ctx = CaptureContext()
|
||||
ctx.http_requests.append(HttpRequest(
|
||||
method="POST", host="192.168.1.50", uri="/api/login", content_type="application/json",
|
||||
body='{"username":"admin","password":"12345678"}', src_ip="192.168.1.10",
|
||||
dst_ip="192.168.1.50", dst_port=80, packet_number=4))
|
||||
res = run(ctx)
|
||||
f = next(f for f in res.findings if "Login form" in f.title)
|
||||
self.assertEqual(f.evidence["username"], "admin")
|
||||
self.assertNotEqual(f.evidence["password"], "12345678")
|
||||
|
||||
def test_query_string_credentials(self):
|
||||
ctx = CaptureContext()
|
||||
ctx.http_requests.append(HttpRequest(
|
||||
method="GET", host="cam.local", uri="/cgi-bin/login?user=root&pwd=toor",
|
||||
src_ip="192.168.1.10", dst_ip="192.168.1.50", dst_port=80))
|
||||
res = run(ctx)
|
||||
self.assertTrue(any("Login form" in t for t in titles(res)))
|
||||
|
||||
def test_mqtt_credentials(self):
|
||||
ctx = CaptureContext(device_ip="192.168.1.60")
|
||||
ctx.plaintext_auth.append(PlaintextAuth(protocol="mqtt", username="dev01", password="pw",
|
||||
src_ip="192.168.1.60", dst_ip="10.1.1.1", dst_port=1883))
|
||||
res = run(ctx)
|
||||
f = next(f for f in res.findings if "MQTT" in f.title)
|
||||
self.assertEqual(f.severity, Severity.CRITICAL)
|
||||
self.assertEqual(f.affected_device, "192.168.1.60")
|
||||
|
||||
def test_snmp_community(self):
|
||||
ctx = CaptureContext()
|
||||
ctx.plaintext_auth.append(PlaintextAuth(protocol="snmp", password="public",
|
||||
src_ip="10.0.0.2", dst_ip="10.0.0.3", dst_port=161))
|
||||
res = run(ctx)
|
||||
self.assertTrue(any("SNMP community" in t for t in titles(res)))
|
||||
|
||||
def test_failed_logins_from_auth_attempts(self):
|
||||
ctx = CaptureContext()
|
||||
for i in range(6):
|
||||
ctx.auth_attempts.append(AuthAttempt(protocol="ftp", success=False, src_ip="10.0.0.9",
|
||||
dst_ip="10.0.0.5", dst_port=21, username="admin",
|
||||
packet_number=i + 1))
|
||||
res = run(ctx)
|
||||
self.assertTrue(any("failed FTP" in t for t in titles(res)))
|
||||
|
||||
def test_firmware_over_http_to_device(self):
|
||||
ctx = CaptureContext(device_ip="192.168.1.60")
|
||||
ctx.file_transfers.append(FileTransfer(filename="fw_v2.bin", extension=".bin",
|
||||
content_type="application/octet-stream", protocol="http",
|
||||
src_ip="1.2.3.4", dst_ip="192.168.1.60", direction="download"))
|
||||
res = run(ctx)
|
||||
f = next(f for f in res.findings if "Firmware" in f.title)
|
||||
self.assertEqual(f.severity, Severity.HIGH)
|
||||
self.assertEqual(f.affected_device, "192.168.1.60")
|
||||
|
||||
def test_dissected_mqtt_flow_is_flagged_by_protocol_not_port(self):
|
||||
ctx = CaptureContext()
|
||||
ctx.conversations.append(Conversation(src_ip="192.168.1.60", dst_ip="10.1.1.1", src_port=5000,
|
||||
dst_port=4711, protocol="tcp", packets=20, bytes=2000,
|
||||
payload_bytes=800, synack_count=1, app_protocols={"mqtt"}))
|
||||
res = run(ctx)
|
||||
self.assertTrue(any("MQTT" in t and "4711" in t for t in titles(res)))
|
||||
|
||||
def test_encrypted_flow_on_port_80_not_flagged(self):
|
||||
ctx = CaptureContext()
|
||||
ctx.conversations.append(Conversation(src_ip="192.168.1.60", dst_ip="10.1.1.1", src_port=5000,
|
||||
dst_port=80, protocol="tcp", packets=20, bytes=2000,
|
||||
payload_bytes=800, synack_count=1, app_protocols={"tls"}))
|
||||
res = run(ctx)
|
||||
self.assertNotIn(Category.INSECURE_PROTOCOL, {f.category for f in res.findings})
|
||||
|
||||
def test_unanswered_syns_are_a_scan(self):
|
||||
ctx = CaptureContext()
|
||||
for port in range(20, 30):
|
||||
ctx.conversations.append(Conversation(src_ip="10.0.0.66", dst_ip="10.0.0.5", src_port=50000 + port,
|
||||
dst_port=port, protocol="tcp", packets=1, bytes=60,
|
||||
syn_count=1))
|
||||
res = run(ctx)
|
||||
f = next(f for f in res.findings if "Port scan" in f.title)
|
||||
self.assertEqual(f.evidence["unanswered_ports"], 10)
|
||||
|
||||
def test_multiple_dhcp_servers(self):
|
||||
ctx = CaptureContext()
|
||||
ctx.dhcp_events.append(DhcpEvent(message_type="offer", server_id="192.168.1.1", src_ip="192.168.1.1"))
|
||||
ctx.dhcp_events.append(DhcpEvent(message_type="offer", server_id="192.168.1.200", src_ip="192.168.1.200"))
|
||||
res = run(ctx)
|
||||
self.assertTrue(any("DHCP servers" in t for t in titles(res)))
|
||||
|
||||
def test_public_resolver_bypass(self):
|
||||
ctx = CaptureContext(device_ip="192.168.1.60")
|
||||
ctx.dns_queries.append(DnsQuery(query_name="api.vendor.example", src_ip="192.168.1.60", dst_ip="8.8.8.8"))
|
||||
res = run(ctx)
|
||||
self.assertTrue(any("external resolver" in t for t in titles(res)))
|
||||
|
||||
def test_device_services_listed(self):
|
||||
ctx = CaptureContext(device_ip="192.168.1.60")
|
||||
ctx.conversations.append(Conversation(src_ip="192.168.1.10", dst_ip="192.168.1.60", src_port=5000,
|
||||
dst_port=8080, protocol="tcp", packets=5, bytes=500,
|
||||
synack_count=1, app_protocols={"http"}, payload_bytes=100))
|
||||
res = run(ctx)
|
||||
self.assertTrue(any("offers a network service on port 8080" in t for t in titles(res)))
|
||||
|
||||
|
||||
class DeviceAttributionTests(unittest.TestCase):
|
||||
def test_device_findings_sorted_first_within_severity(self):
|
||||
ctx = CaptureContext(device_ip="192.168.1.60")
|
||||
ctx.plaintext_auth.append(PlaintextAuth(protocol="ftp", username="a", password="b",
|
||||
src_ip="192.168.1.10", dst_ip="10.0.0.5", dst_port=21))
|
||||
ctx.plaintext_auth.append(PlaintextAuth(protocol="ftp", username="c", password="d",
|
||||
src_ip="192.168.1.60", dst_ip="10.0.0.5", dst_port=21))
|
||||
res = run(ctx)
|
||||
self.assertEqual(res.findings[0].affected_device, "192.168.1.60")
|
||||
self.assertTrue(res.findings[0].evidence["device_related"])
|
||||
self.assertFalse(res.findings[1].evidence["device_related"])
|
||||
self.assertEqual(res.summary["device_related"], 1)
|
||||
self.assertEqual(res.summary["other_hosts"], 1)
|
||||
|
||||
def test_no_device_leaves_attribution_unset(self):
|
||||
ctx = CaptureContext()
|
||||
ctx.plaintext_auth.append(PlaintextAuth(protocol="ftp", username="a", password="b",
|
||||
src_ip="192.168.1.10", dst_ip="10.0.0.5", dst_port=21))
|
||||
res = run(ctx)
|
||||
self.assertNotIn("device_related", res.findings[0].evidence)
|
||||
self.assertFalse(res.inventory["device"]["configured"])
|
||||
|
||||
|
||||
class InventoryTests(unittest.TestCase):
|
||||
def _ctx(self):
|
||||
ctx = CaptureContext(path="x.pcap", device_ip="192.168.1.60")
|
||||
ctx.start_time, ctx.end_time = 0.0, 120.0
|
||||
h = ctx.host("192.168.1.60")
|
||||
h.macs.add("00:11:22:33:44:55")
|
||||
h.hostnames.add("sensor-1")
|
||||
ctx.host("45.33.32.157")
|
||||
ctx.conversations.append(Conversation(src_ip="192.168.1.60", dst_ip="45.33.32.157", src_port=5000,
|
||||
dst_port=8883, protocol="tcp", packets=50, bytes=5000,
|
||||
bytes_src_to_dst=3000, bytes_dst_to_src=2000, payload_bytes=4000,
|
||||
synack_count=1, app_protocols={"tls"}, first_seen=1.0, last_seen=100.0))
|
||||
ctx.dns_queries.append(DnsQuery(query_name="mqtt.vendor.example", src_ip="192.168.1.60", dst_ip="192.168.1.1"))
|
||||
ctx.dns_queries.append(DnsQuery(query_name="mqtt.vendor.example", is_response=True, rcode="NOERROR",
|
||||
response_ips=["45.33.32.157"], src_ip="192.168.1.1", dst_ip="192.168.1.60"))
|
||||
ctx.tls_sessions.append(TlsSession(src_ip="192.168.1.60", dst_ip="45.33.32.157", dst_port=8883,
|
||||
version="TLS 1.2", cipher="TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256",
|
||||
sni="mqtt.vendor.example", server_hello_seen=True, client_hello_seen=True,
|
||||
client_version="TLS 1.2", cert_seen=True, cert_subject_cn="*.vendor.example",
|
||||
cert_issuer_cn="Example CA", client_cert_seen=True))
|
||||
return ctx
|
||||
|
||||
def test_inventory_sections_and_device_focus(self):
|
||||
res = run(self._ctx())
|
||||
inv = res.inventory
|
||||
for key in ("capture", "device", "hosts", "external_endpoints", "protocols", "services",
|
||||
"tls", "dns", "http", "cleartext", "coverage"):
|
||||
self.assertIn(key, inv)
|
||||
self.assertTrue(inv["device"]["configured"])
|
||||
self.assertEqual(inv["device"]["names"], ["sensor-1"])
|
||||
self.assertEqual(inv["device"]["peers"][0]["names"], ["mqtt.vendor.example"])
|
||||
self.assertTrue(inv["external_endpoints"][0]["device"])
|
||||
self.assertEqual(inv["external_endpoints"][0]["names"], ["mqtt.vendor.example"])
|
||||
self.assertTrue(inv["tls"]["sessions"][0]["mutual_tls"])
|
||||
self.assertEqual(inv["services"][0]["service"], "mqtts")
|
||||
self.assertTrue(inv["coverage"]["complete"])
|
||||
|
||||
def test_inventory_survives_roundtrip_and_renders(self):
|
||||
import json
|
||||
from analyzer import result_from_dict
|
||||
res = run(self._ctx())
|
||||
rebuilt = result_from_dict(json.loads(json.dumps(res.to_dict())))
|
||||
self.assertEqual(rebuilt.inventory["device"]["ip"], "192.168.1.60")
|
||||
for fmt in FORMATS:
|
||||
out = render(rebuilt, fmt)
|
||||
self.assertIn("192.168.1.60", out)
|
||||
self.assertIn("mqtt.vendor.example", out)
|
||||
|
||||
|
||||
@unittest.skipUnless(shutil.which("tshark"), "tshark is not installed")
|
||||
class RealCaptureTests(unittest.TestCase):
|
||||
"""Golden checks against the bundled demo capture through the real extractor."""
|
||||
|
||||
def _capture(self, name):
|
||||
here = os.path.dirname(os.path.abspath(__file__))
|
||||
for base in (os.path.join(here, "..", "..", "..", "demo_files"),
|
||||
os.environ.get("ANALYZER_DEMO_DIR", "")):
|
||||
p = os.path.join(base, name) if base else None
|
||||
if p and os.path.exists(p):
|
||||
return p
|
||||
return None
|
||||
|
||||
def test_demo_capture_end_to_end(self):
|
||||
path = self._capture("demo_capture.pcapng")
|
||||
if not path:
|
||||
self.skipTest("demo capture not available")
|
||||
from analyzer.extract_tshark import build_context_from_pcap
|
||||
from analyzer.artifacts import extract_urls, extract_hashes
|
||||
ctx = build_context_from_pcap(path)
|
||||
self.assertEqual(ctx.packet_count, 2)
|
||||
self.assertFalse(ctx.truncated)
|
||||
self.assertEqual(len(ctx.http_requests), 1)
|
||||
self.assertTrue(ctx.http_requests[0].body.startswith("5f4dcc3b")) # Decoded, not hex
|
||||
urls = {u.value for u in extract_urls(ctx)}
|
||||
self.assertIn("eu.iot.dreame.tech", urls) # From the retransmitted, undissected segment
|
||||
self.assertGreaterEqual(len(extract_hashes(ctx)), 10)
|
||||
res = AnalysisEngine().analyze_context(ctx)
|
||||
self.assertTrue(any("HTTP" in f.title for f in res.findings))
|
||||
self.assertIn("http", res.inventory["cleartext"]["flows"][0]["protocol"])
|
||||
@@ -0,0 +1,503 @@
|
||||
"""TLS cipher-suite and version name tables, generated from ``tshark -G values``."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
CIPHER_SUITES: dict[int, str] = {
|
||||
0: 'TLS_NULL_WITH_NULL_NULL',
|
||||
1: 'TLS_RSA_WITH_NULL_MD5',
|
||||
2: 'TLS_RSA_WITH_NULL_SHA',
|
||||
3: 'TLS_RSA_EXPORT_WITH_RC4_40_MD5',
|
||||
4: 'TLS_RSA_WITH_RC4_128_MD5',
|
||||
5: 'TLS_RSA_WITH_RC4_128_SHA',
|
||||
6: 'TLS_RSA_EXPORT_WITH_RC2_CBC_40_MD5',
|
||||
7: 'TLS_RSA_WITH_IDEA_CBC_SHA',
|
||||
8: 'TLS_RSA_EXPORT_WITH_DES40_CBC_SHA',
|
||||
9: 'TLS_RSA_WITH_DES_CBC_SHA',
|
||||
10: 'TLS_RSA_WITH_3DES_EDE_CBC_SHA',
|
||||
11: 'TLS_DH_DSS_EXPORT_WITH_DES40_CBC_SHA',
|
||||
12: 'TLS_DH_DSS_WITH_DES_CBC_SHA',
|
||||
13: 'TLS_DH_DSS_WITH_3DES_EDE_CBC_SHA',
|
||||
14: 'TLS_DH_RSA_EXPORT_WITH_DES40_CBC_SHA',
|
||||
15: 'TLS_DH_RSA_WITH_DES_CBC_SHA',
|
||||
16: 'TLS_DH_RSA_WITH_3DES_EDE_CBC_SHA',
|
||||
17: 'TLS_DHE_DSS_EXPORT_WITH_DES40_CBC_SHA',
|
||||
18: 'TLS_DHE_DSS_WITH_DES_CBC_SHA',
|
||||
19: 'TLS_DHE_DSS_WITH_3DES_EDE_CBC_SHA',
|
||||
20: 'TLS_DHE_RSA_EXPORT_WITH_DES40_CBC_SHA',
|
||||
21: 'TLS_DHE_RSA_WITH_DES_CBC_SHA',
|
||||
22: 'TLS_DHE_RSA_WITH_3DES_EDE_CBC_SHA',
|
||||
23: 'TLS_DH_anon_EXPORT_WITH_RC4_40_MD5',
|
||||
24: 'TLS_DH_anon_WITH_RC4_128_MD5',
|
||||
25: 'TLS_DH_anon_EXPORT_WITH_DES40_CBC_SHA',
|
||||
26: 'TLS_DH_anon_WITH_DES_CBC_SHA',
|
||||
27: 'TLS_DH_anon_WITH_3DES_EDE_CBC_SHA',
|
||||
28: 'SSL_FORTEZZA_KEA_WITH_NULL_SHA',
|
||||
29: 'SSL_FORTEZZA_KEA_WITH_FORTEZZA_CBC_SHA',
|
||||
30: 'TLS_KRB5_WITH_DES_CBC_SHA',
|
||||
31: 'TLS_KRB5_WITH_3DES_EDE_CBC_SHA',
|
||||
32: 'TLS_KRB5_WITH_RC4_128_SHA',
|
||||
33: 'TLS_KRB5_WITH_IDEA_CBC_SHA',
|
||||
34: 'TLS_KRB5_WITH_DES_CBC_MD5',
|
||||
35: 'TLS_KRB5_WITH_3DES_EDE_CBC_MD5',
|
||||
36: 'TLS_KRB5_WITH_RC4_128_MD5',
|
||||
37: 'TLS_KRB5_WITH_IDEA_CBC_MD5',
|
||||
38: 'TLS_KRB5_EXPORT_WITH_DES_CBC_40_SHA',
|
||||
39: 'TLS_KRB5_EXPORT_WITH_RC2_CBC_40_SHA',
|
||||
40: 'TLS_KRB5_EXPORT_WITH_RC4_40_SHA',
|
||||
41: 'TLS_KRB5_EXPORT_WITH_DES_CBC_40_MD5',
|
||||
42: 'TLS_KRB5_EXPORT_WITH_RC2_CBC_40_MD5',
|
||||
43: 'TLS_KRB5_EXPORT_WITH_RC4_40_MD5',
|
||||
44: 'TLS_PSK_WITH_NULL_SHA',
|
||||
45: 'TLS_DHE_PSK_WITH_NULL_SHA',
|
||||
46: 'TLS_RSA_PSK_WITH_NULL_SHA',
|
||||
47: 'TLS_RSA_WITH_AES_128_CBC_SHA',
|
||||
48: 'TLS_DH_DSS_WITH_AES_128_CBC_SHA',
|
||||
49: 'TLS_DH_RSA_WITH_AES_128_CBC_SHA',
|
||||
50: 'TLS_DHE_DSS_WITH_AES_128_CBC_SHA',
|
||||
51: 'TLS_DHE_RSA_WITH_AES_128_CBC_SHA',
|
||||
52: 'TLS_DH_anon_WITH_AES_128_CBC_SHA',
|
||||
53: 'TLS_RSA_WITH_AES_256_CBC_SHA',
|
||||
54: 'TLS_DH_DSS_WITH_AES_256_CBC_SHA',
|
||||
55: 'TLS_DH_RSA_WITH_AES_256_CBC_SHA',
|
||||
56: 'TLS_DHE_DSS_WITH_AES_256_CBC_SHA',
|
||||
57: 'TLS_DHE_RSA_WITH_AES_256_CBC_SHA',
|
||||
58: 'TLS_DH_anon_WITH_AES_256_CBC_SHA',
|
||||
59: 'TLS_RSA_WITH_NULL_SHA256',
|
||||
60: 'TLS_RSA_WITH_AES_128_CBC_SHA256',
|
||||
61: 'TLS_RSA_WITH_AES_256_CBC_SHA256',
|
||||
62: 'TLS_DH_DSS_WITH_AES_128_CBC_SHA256',
|
||||
63: 'TLS_DH_RSA_WITH_AES_128_CBC_SHA256',
|
||||
64: 'TLS_DHE_DSS_WITH_AES_128_CBC_SHA256',
|
||||
65: 'TLS_RSA_WITH_CAMELLIA_128_CBC_SHA',
|
||||
66: 'TLS_DH_DSS_WITH_CAMELLIA_128_CBC_SHA',
|
||||
67: 'TLS_DH_RSA_WITH_CAMELLIA_128_CBC_SHA',
|
||||
68: 'TLS_DHE_DSS_WITH_CAMELLIA_128_CBC_SHA',
|
||||
69: 'TLS_DHE_RSA_WITH_CAMELLIA_128_CBC_SHA',
|
||||
70: 'TLS_DH_anon_WITH_CAMELLIA_128_CBC_SHA',
|
||||
96: 'TLS_RSA_EXPORT1024_WITH_RC4_56_MD5',
|
||||
97: 'TLS_RSA_EXPORT1024_WITH_RC2_CBC_56_MD5',
|
||||
98: 'TLS_RSA_EXPORT1024_WITH_DES_CBC_SHA',
|
||||
99: 'TLS_DHE_DSS_EXPORT1024_WITH_DES_CBC_SHA',
|
||||
100: 'TLS_RSA_EXPORT1024_WITH_RC4_56_SHA',
|
||||
101: 'TLS_DHE_DSS_EXPORT1024_WITH_RC4_56_SHA',
|
||||
102: 'TLS_DHE_DSS_WITH_RC4_128_SHA',
|
||||
103: 'TLS_DHE_RSA_WITH_AES_128_CBC_SHA256',
|
||||
104: 'TLS_DH_DSS_WITH_AES_256_CBC_SHA256',
|
||||
105: 'TLS_DH_RSA_WITH_AES_256_CBC_SHA256',
|
||||
106: 'TLS_DHE_DSS_WITH_AES_256_CBC_SHA256',
|
||||
107: 'TLS_DHE_RSA_WITH_AES_256_CBC_SHA256',
|
||||
108: 'TLS_DH_anon_WITH_AES_128_CBC_SHA256',
|
||||
109: 'TLS_DH_anon_WITH_AES_256_CBC_SHA256',
|
||||
128: 'TLS_GOSTR341094_WITH_28147_CNT_IMIT',
|
||||
129: 'TLS_GOSTR341001_WITH_28147_CNT_IMIT',
|
||||
130: 'TLS_GOSTR341094_WITH_NULL_GOSTR3411',
|
||||
131: 'TLS_GOSTR341001_WITH_NULL_GOSTR3411',
|
||||
132: 'TLS_RSA_WITH_CAMELLIA_256_CBC_SHA',
|
||||
133: 'TLS_DH_DSS_WITH_CAMELLIA_256_CBC_SHA',
|
||||
134: 'TLS_DH_RSA_WITH_CAMELLIA_256_CBC_SHA',
|
||||
135: 'TLS_DHE_DSS_WITH_CAMELLIA_256_CBC_SHA',
|
||||
136: 'TLS_DHE_RSA_WITH_CAMELLIA_256_CBC_SHA',
|
||||
137: 'TLS_DH_anon_WITH_CAMELLIA_256_CBC_SHA',
|
||||
138: 'TLS_PSK_WITH_RC4_128_SHA',
|
||||
139: 'TLS_PSK_WITH_3DES_EDE_CBC_SHA',
|
||||
140: 'TLS_PSK_WITH_AES_128_CBC_SHA',
|
||||
141: 'TLS_PSK_WITH_AES_256_CBC_SHA',
|
||||
142: 'TLS_DHE_PSK_WITH_RC4_128_SHA',
|
||||
143: 'TLS_DHE_PSK_WITH_3DES_EDE_CBC_SHA',
|
||||
144: 'TLS_DHE_PSK_WITH_AES_128_CBC_SHA',
|
||||
145: 'TLS_DHE_PSK_WITH_AES_256_CBC_SHA',
|
||||
146: 'TLS_RSA_PSK_WITH_RC4_128_SHA',
|
||||
147: 'TLS_RSA_PSK_WITH_3DES_EDE_CBC_SHA',
|
||||
148: 'TLS_RSA_PSK_WITH_AES_128_CBC_SHA',
|
||||
149: 'TLS_RSA_PSK_WITH_AES_256_CBC_SHA',
|
||||
150: 'TLS_RSA_WITH_SEED_CBC_SHA',
|
||||
151: 'TLS_DH_DSS_WITH_SEED_CBC_SHA',
|
||||
152: 'TLS_DH_RSA_WITH_SEED_CBC_SHA',
|
||||
153: 'TLS_DHE_DSS_WITH_SEED_CBC_SHA',
|
||||
154: 'TLS_DHE_RSA_WITH_SEED_CBC_SHA',
|
||||
155: 'TLS_DH_anon_WITH_SEED_CBC_SHA',
|
||||
156: 'TLS_RSA_WITH_AES_128_GCM_SHA256',
|
||||
157: 'TLS_RSA_WITH_AES_256_GCM_SHA384',
|
||||
158: 'TLS_DHE_RSA_WITH_AES_128_GCM_SHA256',
|
||||
159: 'TLS_DHE_RSA_WITH_AES_256_GCM_SHA384',
|
||||
160: 'TLS_DH_RSA_WITH_AES_128_GCM_SHA256',
|
||||
161: 'TLS_DH_RSA_WITH_AES_256_GCM_SHA384',
|
||||
162: 'TLS_DHE_DSS_WITH_AES_128_GCM_SHA256',
|
||||
163: 'TLS_DHE_DSS_WITH_AES_256_GCM_SHA384',
|
||||
164: 'TLS_DH_DSS_WITH_AES_128_GCM_SHA256',
|
||||
165: 'TLS_DH_DSS_WITH_AES_256_GCM_SHA384',
|
||||
166: 'TLS_DH_anon_WITH_AES_128_GCM_SHA256',
|
||||
167: 'TLS_DH_anon_WITH_AES_256_GCM_SHA384',
|
||||
168: 'TLS_PSK_WITH_AES_128_GCM_SHA256',
|
||||
169: 'TLS_PSK_WITH_AES_256_GCM_SHA384',
|
||||
170: 'TLS_DHE_PSK_WITH_AES_128_GCM_SHA256',
|
||||
171: 'TLS_DHE_PSK_WITH_AES_256_GCM_SHA384',
|
||||
172: 'TLS_RSA_PSK_WITH_AES_128_GCM_SHA256',
|
||||
173: 'TLS_RSA_PSK_WITH_AES_256_GCM_SHA384',
|
||||
174: 'TLS_PSK_WITH_AES_128_CBC_SHA256',
|
||||
175: 'TLS_PSK_WITH_AES_256_CBC_SHA384',
|
||||
176: 'TLS_PSK_WITH_NULL_SHA256',
|
||||
177: 'TLS_PSK_WITH_NULL_SHA384',
|
||||
178: 'TLS_DHE_PSK_WITH_AES_128_CBC_SHA256',
|
||||
179: 'TLS_DHE_PSK_WITH_AES_256_CBC_SHA384',
|
||||
180: 'TLS_DHE_PSK_WITH_NULL_SHA256',
|
||||
181: 'TLS_DHE_PSK_WITH_NULL_SHA384',
|
||||
182: 'TLS_RSA_PSK_WITH_AES_128_CBC_SHA256',
|
||||
183: 'TLS_RSA_PSK_WITH_AES_256_CBC_SHA384',
|
||||
184: 'TLS_RSA_PSK_WITH_NULL_SHA256',
|
||||
185: 'TLS_RSA_PSK_WITH_NULL_SHA384',
|
||||
186: 'TLS_RSA_WITH_CAMELLIA_128_CBC_SHA256',
|
||||
187: 'TLS_DH_DSS_WITH_CAMELLIA_128_CBC_SHA256',
|
||||
188: 'TLS_DH_RSA_WITH_CAMELLIA_128_CBC_SHA256',
|
||||
189: 'TLS_DHE_DSS_WITH_CAMELLIA_128_CBC_SHA256',
|
||||
190: 'TLS_DHE_RSA_WITH_CAMELLIA_128_CBC_SHA256',
|
||||
191: 'TLS_DH_anon_WITH_CAMELLIA_128_CBC_SHA256',
|
||||
192: 'TLS_RSA_WITH_CAMELLIA_256_CBC_SHA256',
|
||||
193: 'TLS_DH_DSS_WITH_CAMELLIA_256_CBC_SHA256',
|
||||
194: 'TLS_DH_RSA_WITH_CAMELLIA_256_CBC_SHA256',
|
||||
195: 'TLS_DHE_DSS_WITH_CAMELLIA_256_CBC_SHA256',
|
||||
196: 'TLS_DHE_RSA_WITH_CAMELLIA_256_CBC_SHA256',
|
||||
197: 'TLS_DH_anon_WITH_CAMELLIA_256_CBC_SHA256',
|
||||
198: 'TLS_SM4_GCM_SM3',
|
||||
199: 'TLS_SM4_CCM_SM3',
|
||||
255: 'TLS_EMPTY_RENEGOTIATION_INFO_SCSV',
|
||||
2570: 'Reserved (GREASE)',
|
||||
4865: 'TLS_AES_128_GCM_SHA256',
|
||||
4866: 'TLS_AES_256_GCM_SHA384',
|
||||
4867: 'TLS_CHACHA20_POLY1305_SHA256',
|
||||
4868: 'TLS_AES_128_CCM_SHA256',
|
||||
4869: 'TLS_AES_128_CCM_8_SHA256',
|
||||
6682: 'Reserved (GREASE)',
|
||||
10794: 'Reserved (GREASE)',
|
||||
14906: 'Reserved (GREASE)',
|
||||
19018: 'Reserved (GREASE)',
|
||||
22016: 'TLS_FALLBACK_SCSV',
|
||||
23130: 'Reserved (GREASE)',
|
||||
27242: 'Reserved (GREASE)',
|
||||
31354: 'Reserved (GREASE)',
|
||||
35466: 'Reserved (GREASE)',
|
||||
39578: 'Reserved (GREASE)',
|
||||
43690: 'Reserved (GREASE)',
|
||||
47802: 'Reserved (GREASE)',
|
||||
49153: 'TLS_ECDH_ECDSA_WITH_NULL_SHA',
|
||||
49154: 'TLS_ECDH_ECDSA_WITH_RC4_128_SHA',
|
||||
49155: 'TLS_ECDH_ECDSA_WITH_3DES_EDE_CBC_SHA',
|
||||
49156: 'TLS_ECDH_ECDSA_WITH_AES_128_CBC_SHA',
|
||||
49157: 'TLS_ECDH_ECDSA_WITH_AES_256_CBC_SHA',
|
||||
49158: 'TLS_ECDHE_ECDSA_WITH_NULL_SHA',
|
||||
49159: 'TLS_ECDHE_ECDSA_WITH_RC4_128_SHA',
|
||||
49160: 'TLS_ECDHE_ECDSA_WITH_3DES_EDE_CBC_SHA',
|
||||
49161: 'TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA',
|
||||
49162: 'TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA',
|
||||
49163: 'TLS_ECDH_RSA_WITH_NULL_SHA',
|
||||
49164: 'TLS_ECDH_RSA_WITH_RC4_128_SHA',
|
||||
49165: 'TLS_ECDH_RSA_WITH_3DES_EDE_CBC_SHA',
|
||||
49166: 'TLS_ECDH_RSA_WITH_AES_128_CBC_SHA',
|
||||
49167: 'TLS_ECDH_RSA_WITH_AES_256_CBC_SHA',
|
||||
49168: 'TLS_ECDHE_RSA_WITH_NULL_SHA',
|
||||
49169: 'TLS_ECDHE_RSA_WITH_RC4_128_SHA',
|
||||
49170: 'TLS_ECDHE_RSA_WITH_3DES_EDE_CBC_SHA',
|
||||
49171: 'TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA',
|
||||
49172: 'TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA',
|
||||
49173: 'TLS_ECDH_anon_WITH_NULL_SHA',
|
||||
49174: 'TLS_ECDH_anon_WITH_RC4_128_SHA',
|
||||
49175: 'TLS_ECDH_anon_WITH_3DES_EDE_CBC_SHA',
|
||||
49176: 'TLS_ECDH_anon_WITH_AES_128_CBC_SHA',
|
||||
49177: 'TLS_ECDH_anon_WITH_AES_256_CBC_SHA',
|
||||
49178: 'TLS_SRP_SHA_WITH_3DES_EDE_CBC_SHA',
|
||||
49179: 'TLS_SRP_SHA_RSA_WITH_3DES_EDE_CBC_SHA',
|
||||
49180: 'TLS_SRP_SHA_DSS_WITH_3DES_EDE_CBC_SHA',
|
||||
49181: 'TLS_SRP_SHA_WITH_AES_128_CBC_SHA',
|
||||
49182: 'TLS_SRP_SHA_RSA_WITH_AES_128_CBC_SHA',
|
||||
49183: 'TLS_SRP_SHA_DSS_WITH_AES_128_CBC_SHA',
|
||||
49184: 'TLS_SRP_SHA_WITH_AES_256_CBC_SHA',
|
||||
49185: 'TLS_SRP_SHA_RSA_WITH_AES_256_CBC_SHA',
|
||||
49186: 'TLS_SRP_SHA_DSS_WITH_AES_256_CBC_SHA',
|
||||
49187: 'TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA256',
|
||||
49188: 'TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA384',
|
||||
49189: 'TLS_ECDH_ECDSA_WITH_AES_128_CBC_SHA256',
|
||||
49190: 'TLS_ECDH_ECDSA_WITH_AES_256_CBC_SHA384',
|
||||
49191: 'TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256',
|
||||
49192: 'TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA384',
|
||||
49193: 'TLS_ECDH_RSA_WITH_AES_128_CBC_SHA256',
|
||||
49194: 'TLS_ECDH_RSA_WITH_AES_256_CBC_SHA384',
|
||||
49195: 'TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256',
|
||||
49196: 'TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384',
|
||||
49197: 'TLS_ECDH_ECDSA_WITH_AES_128_GCM_SHA256',
|
||||
49198: 'TLS_ECDH_ECDSA_WITH_AES_256_GCM_SHA384',
|
||||
49199: 'TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256',
|
||||
49200: 'TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384',
|
||||
49201: 'TLS_ECDH_RSA_WITH_AES_128_GCM_SHA256',
|
||||
49202: 'TLS_ECDH_RSA_WITH_AES_256_GCM_SHA384',
|
||||
49203: 'TLS_ECDHE_PSK_WITH_RC4_128_SHA',
|
||||
49204: 'TLS_ECDHE_PSK_WITH_3DES_EDE_CBC_SHA',
|
||||
49205: 'TLS_ECDHE_PSK_WITH_AES_128_CBC_SHA',
|
||||
49206: 'TLS_ECDHE_PSK_WITH_AES_256_CBC_SHA',
|
||||
49207: 'TLS_ECDHE_PSK_WITH_AES_128_CBC_SHA256',
|
||||
49208: 'TLS_ECDHE_PSK_WITH_AES_256_CBC_SHA384',
|
||||
49209: 'TLS_ECDHE_PSK_WITH_NULL_SHA',
|
||||
49210: 'TLS_ECDHE_PSK_WITH_NULL_SHA256',
|
||||
49211: 'TLS_ECDHE_PSK_WITH_NULL_SHA384',
|
||||
49212: 'TLS_RSA_WITH_ARIA_128_CBC_SHA256',
|
||||
49213: 'TLS_RSA_WITH_ARIA_256_CBC_SHA384',
|
||||
49214: 'TLS_DH_DSS_WITH_ARIA_128_CBC_SHA256',
|
||||
49215: 'TLS_DH_DSS_WITH_ARIA_256_CBC_SHA384',
|
||||
49216: 'TLS_DH_RSA_WITH_ARIA_128_CBC_SHA256',
|
||||
49217: 'TLS_DH_RSA_WITH_ARIA_256_CBC_SHA384',
|
||||
49218: 'TLS_DHE_DSS_WITH_ARIA_128_CBC_SHA256',
|
||||
49219: 'TLS_DHE_DSS_WITH_ARIA_256_CBC_SHA384',
|
||||
49220: 'TLS_DHE_RSA_WITH_ARIA_128_CBC_SHA256',
|
||||
49221: 'TLS_DHE_RSA_WITH_ARIA_256_CBC_SHA384',
|
||||
49222: 'TLS_DH_anon_WITH_ARIA_128_CBC_SHA256',
|
||||
49223: 'TLS_DH_anon_WITH_ARIA_256_CBC_SHA384',
|
||||
49224: 'TLS_ECDHE_ECDSA_WITH_ARIA_128_CBC_SHA256',
|
||||
49225: 'TLS_ECDHE_ECDSA_WITH_ARIA_256_CBC_SHA384',
|
||||
49226: 'TLS_ECDH_ECDSA_WITH_ARIA_128_CBC_SHA256',
|
||||
49227: 'TLS_ECDH_ECDSA_WITH_ARIA_256_CBC_SHA384',
|
||||
49228: 'TLS_ECDHE_RSA_WITH_ARIA_128_CBC_SHA256',
|
||||
49229: 'TLS_ECDHE_RSA_WITH_ARIA_256_CBC_SHA384',
|
||||
49230: 'TLS_ECDH_RSA_WITH_ARIA_128_CBC_SHA256',
|
||||
49231: 'TLS_ECDH_RSA_WITH_ARIA_256_CBC_SHA384',
|
||||
49232: 'TLS_RSA_WITH_ARIA_128_GCM_SHA256',
|
||||
49233: 'TLS_RSA_WITH_ARIA_256_GCM_SHA384',
|
||||
49234: 'TLS_DHE_RSA_WITH_ARIA_128_GCM_SHA256',
|
||||
49235: 'TLS_DHE_RSA_WITH_ARIA_256_GCM_SHA384',
|
||||
49236: 'TLS_DH_RSA_WITH_ARIA_128_GCM_SHA256',
|
||||
49237: 'TLS_DH_RSA_WITH_ARIA_256_GCM_SHA384',
|
||||
49238: 'TLS_DHE_DSS_WITH_ARIA_128_GCM_SHA256',
|
||||
49239: 'TLS_DHE_DSS_WITH_ARIA_256_GCM_SHA384',
|
||||
49240: 'TLS_DH_DSS_WITH_ARIA_128_GCM_SHA256',
|
||||
49241: 'TLS_DH_DSS_WITH_ARIA_256_GCM_SHA384',
|
||||
49242: 'TLS_DH_anon_WITH_ARIA_128_GCM_SHA256',
|
||||
49243: 'TLS_DH_anon_WITH_ARIA_256_GCM_SHA384',
|
||||
49244: 'TLS_ECDHE_ECDSA_WITH_ARIA_128_GCM_SHA256',
|
||||
49245: 'TLS_ECDHE_ECDSA_WITH_ARIA_256_GCM_SHA384',
|
||||
49246: 'TLS_ECDH_ECDSA_WITH_ARIA_128_GCM_SHA256',
|
||||
49247: 'TLS_ECDH_ECDSA_WITH_ARIA_256_GCM_SHA384',
|
||||
49248: 'TLS_ECDHE_RSA_WITH_ARIA_128_GCM_SHA256',
|
||||
49249: 'TLS_ECDHE_RSA_WITH_ARIA_256_GCM_SHA384',
|
||||
49250: 'TLS_ECDH_RSA_WITH_ARIA_128_GCM_SHA256',
|
||||
49251: 'TLS_ECDH_RSA_WITH_ARIA_256_GCM_SHA384',
|
||||
49252: 'TLS_PSK_WITH_ARIA_128_CBC_SHA256',
|
||||
49253: 'TLS_PSK_WITH_ARIA_256_CBC_SHA384',
|
||||
49254: 'TLS_DHE_PSK_WITH_ARIA_128_CBC_SHA256',
|
||||
49255: 'TLS_DHE_PSK_WITH_ARIA_256_CBC_SHA384',
|
||||
49256: 'TLS_RSA_PSK_WITH_ARIA_128_CBC_SHA256',
|
||||
49257: 'TLS_RSA_PSK_WITH_ARIA_256_CBC_SHA384',
|
||||
49258: 'TLS_PSK_WITH_ARIA_128_GCM_SHA256',
|
||||
49259: 'TLS_PSK_WITH_ARIA_256_GCM_SHA384',
|
||||
49260: 'TLS_DHE_PSK_WITH_ARIA_128_GCM_SHA256',
|
||||
49261: 'TLS_DHE_PSK_WITH_ARIA_256_GCM_SHA384',
|
||||
49262: 'TLS_RSA_PSK_WITH_ARIA_128_GCM_SHA256',
|
||||
49263: 'TLS_RSA_PSK_WITH_ARIA_256_GCM_SHA384',
|
||||
49264: 'TLS_ECDHE_PSK_WITH_ARIA_128_CBC_SHA256',
|
||||
49265: 'TLS_ECDHE_PSK_WITH_ARIA_256_CBC_SHA384',
|
||||
49266: 'TLS_ECDHE_ECDSA_WITH_CAMELLIA_128_CBC_SHA256',
|
||||
49267: 'TLS_ECDHE_ECDSA_WITH_CAMELLIA_256_CBC_SHA384',
|
||||
49268: 'TLS_ECDH_ECDSA_WITH_CAMELLIA_128_CBC_SHA256',
|
||||
49269: 'TLS_ECDH_ECDSA_WITH_CAMELLIA_256_CBC_SHA384',
|
||||
49270: 'TLS_ECDHE_RSA_WITH_CAMELLIA_128_CBC_SHA256',
|
||||
49271: 'TLS_ECDHE_RSA_WITH_CAMELLIA_256_CBC_SHA384',
|
||||
49272: 'TLS_ECDH_RSA_WITH_CAMELLIA_128_CBC_SHA256',
|
||||
49273: 'TLS_ECDH_RSA_WITH_CAMELLIA_256_CBC_SHA384',
|
||||
49274: 'TLS_RSA_WITH_CAMELLIA_128_GCM_SHA256',
|
||||
49275: 'TLS_RSA_WITH_CAMELLIA_256_GCM_SHA384',
|
||||
49276: 'TLS_DHE_RSA_WITH_CAMELLIA_128_GCM_SHA256',
|
||||
49277: 'TLS_DHE_RSA_WITH_CAMELLIA_256_GCM_SHA384',
|
||||
49278: 'TLS_DH_RSA_WITH_CAMELLIA_128_GCM_SHA256',
|
||||
49279: 'TLS_DH_RSA_WITH_CAMELLIA_256_GCM_SHA384',
|
||||
49280: 'TLS_DHE_DSS_WITH_CAMELLIA_128_GCM_SHA256',
|
||||
49281: 'TLS_DHE_DSS_WITH_CAMELLIA_256_GCM_SHA384',
|
||||
49282: 'TLS_DH_DSS_WITH_CAMELLIA_128_GCM_SHA256',
|
||||
49283: 'TLS_DH_DSS_WITH_CAMELLIA_256_GCM_SHA384',
|
||||
49284: 'TLS_DH_anon_WITH_CAMELLIA_128_GCM_SHA256',
|
||||
49285: 'TLS_DH_anon_WITH_CAMELLIA_256_GCM_SHA384',
|
||||
49286: 'TLS_ECDHE_ECDSA_WITH_CAMELLIA_128_GCM_SHA256',
|
||||
49287: 'TLS_ECDHE_ECDSA_WITH_CAMELLIA_256_GCM_SHA384',
|
||||
49288: 'TLS_ECDH_ECDSA_WITH_CAMELLIA_128_GCM_SHA256',
|
||||
49289: 'TLS_ECDH_ECDSA_WITH_CAMELLIA_256_GCM_SHA384',
|
||||
49290: 'TLS_ECDHE_RSA_WITH_CAMELLIA_128_GCM_SHA256',
|
||||
49291: 'TLS_ECDHE_RSA_WITH_CAMELLIA_256_GCM_SHA384',
|
||||
49292: 'TLS_ECDH_RSA_WITH_CAMELLIA_128_GCM_SHA256',
|
||||
49293: 'TLS_ECDH_RSA_WITH_CAMELLIA_256_GCM_SHA384',
|
||||
49294: 'TLS_PSK_WITH_CAMELLIA_128_GCM_SHA256',
|
||||
49295: 'TLS_PSK_WITH_CAMELLIA_256_GCM_SHA384',
|
||||
49296: 'TLS_DHE_PSK_WITH_CAMELLIA_128_GCM_SHA256',
|
||||
49297: 'TLS_DHE_PSK_WITH_CAMELLIA_256_GCM_SHA384',
|
||||
49298: 'TLS_RSA_PSK_WITH_CAMELLIA_128_GCM_SHA256',
|
||||
49299: 'TLS_RSA_PSK_WITH_CAMELLIA_256_GCM_SHA384',
|
||||
49300: 'TLS_PSK_WITH_CAMELLIA_128_CBC_SHA256',
|
||||
49301: 'TLS_PSK_WITH_CAMELLIA_256_CBC_SHA384',
|
||||
49302: 'TLS_DHE_PSK_WITH_CAMELLIA_128_CBC_SHA256',
|
||||
49303: 'TLS_DHE_PSK_WITH_CAMELLIA_256_CBC_SHA384',
|
||||
49304: 'TLS_RSA_PSK_WITH_CAMELLIA_128_CBC_SHA256',
|
||||
49305: 'TLS_RSA_PSK_WITH_CAMELLIA_256_CBC_SHA384',
|
||||
49306: 'TLS_ECDHE_PSK_WITH_CAMELLIA_128_CBC_SHA256',
|
||||
49307: 'TLS_ECDHE_PSK_WITH_CAMELLIA_256_CBC_SHA384',
|
||||
49308: 'TLS_RSA_WITH_AES_128_CCM',
|
||||
49309: 'TLS_RSA_WITH_AES_256_CCM',
|
||||
49310: 'TLS_DHE_RSA_WITH_AES_128_CCM',
|
||||
49311: 'TLS_DHE_RSA_WITH_AES_256_CCM',
|
||||
49312: 'TLS_RSA_WITH_AES_128_CCM_8',
|
||||
49313: 'TLS_RSA_WITH_AES_256_CCM_8',
|
||||
49314: 'TLS_DHE_RSA_WITH_AES_128_CCM_8',
|
||||
49315: 'TLS_DHE_RSA_WITH_AES_256_CCM_8',
|
||||
49316: 'TLS_PSK_WITH_AES_128_CCM',
|
||||
49317: 'TLS_PSK_WITH_AES_256_CCM',
|
||||
49318: 'TLS_DHE_PSK_WITH_AES_128_CCM',
|
||||
49319: 'TLS_DHE_PSK_WITH_AES_256_CCM',
|
||||
49320: 'TLS_PSK_WITH_AES_128_CCM_8',
|
||||
49321: 'TLS_PSK_WITH_AES_256_CCM_8',
|
||||
49322: 'TLS_PSK_DHE_WITH_AES_128_CCM_8',
|
||||
49323: 'TLS_PSK_DHE_WITH_AES_256_CCM_8',
|
||||
49324: 'TLS_ECDHE_ECDSA_WITH_AES_128_CCM',
|
||||
49325: 'TLS_ECDHE_ECDSA_WITH_AES_256_CCM',
|
||||
49326: 'TLS_ECDHE_ECDSA_WITH_AES_128_CCM_8',
|
||||
49327: 'TLS_ECDHE_ECDSA_WITH_AES_256_CCM_8',
|
||||
49328: 'TLS_ECCPWD_WITH_AES_128_GCM_SHA256',
|
||||
49329: 'TLS_ECCPWD_WITH_AES_256_GCM_SHA384',
|
||||
49330: 'TLS_ECCPWD_WITH_AES_128_CCM_SHA256',
|
||||
49331: 'TLS_ECCPWD_WITH_AES_256_CCM_SHA384',
|
||||
49332: 'TLS_SHA256_SHA256',
|
||||
49333: 'TLS_SHA384_SHA384',
|
||||
49407: 'TLS_ECJPAKE_WITH_AES_128_CCM_8',
|
||||
49408: 'TLS_GOSTR341112_256_WITH_KUZNYECHIK_CTR_OMAC',
|
||||
49409: 'TLS_GOSTR341112_256_WITH_MAGMA_CTR_OMAC',
|
||||
49410: 'TLS_GOSTR341112_256_WITH_28147_CNT_IMIT',
|
||||
49411: 'TLS_GOSTR341112_256_WITH_KUZNYECHIK_MGM_L',
|
||||
49412: 'TLS_GOSTR341112_256_WITH_MAGMA_MGM_L',
|
||||
49413: 'TLS_GOSTR341112_256_WITH_KUZNYECHIK_MGM_S',
|
||||
49414: 'TLS_GOSTR341112_256_WITH_MAGMA_MGM_S',
|
||||
51914: 'Reserved (GREASE)',
|
||||
52243: 'TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256',
|
||||
52244: 'TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256',
|
||||
52245: 'TLS_DHE_RSA_WITH_CHACHA20_POLY1305_SHA256',
|
||||
52392: 'TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256',
|
||||
52393: 'TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256',
|
||||
52394: 'TLS_DHE_RSA_WITH_CHACHA20_POLY1305_SHA256',
|
||||
52395: 'TLS_PSK_WITH_CHACHA20_POLY1305_SHA256',
|
||||
52396: 'TLS_ECDHE_PSK_WITH_CHACHA20_POLY1305_SHA256',
|
||||
52397: 'TLS_DHE_PSK_WITH_CHACHA20_POLY1305_SHA256',
|
||||
52398: 'TLS_RSA_PSK_WITH_CHACHA20_POLY1305_SHA256',
|
||||
53249: 'TLS_ECDHE_PSK_WITH_AES_128_GCM_SHA256',
|
||||
53250: 'TLS_ECDHE_PSK_WITH_AES_256_GCM_SHA384',
|
||||
53251: 'TLS_ECDHE_PSK_WITH_AES_128_CCM_8_SHA256',
|
||||
53253: 'TLS_ECDHE_PSK_WITH_AES_128_CCM_SHA256',
|
||||
56026: 'Reserved (GREASE)',
|
||||
57345: 'ECDHE_SM1_SM3',
|
||||
57347: 'ECC_SM1_SM3',
|
||||
57349: 'IBSDH_SM1_SM3',
|
||||
57351: 'IBC_SM1_SM3',
|
||||
57353: 'RSA_SM1_SM3',
|
||||
57354: 'RSA_SM1_SHA1',
|
||||
57361: 'ECDHE_SM4_CBC_SM3',
|
||||
57363: 'ECC_SM4_CBC_SM3',
|
||||
57365: 'IBSDH_SM4_CBC_SM3',
|
||||
57367: 'IBC_SM4_CBC_SM3',
|
||||
57369: 'RSA_SM4_CBC_SM3',
|
||||
57370: 'RSA_SM4_CBC_SHA1',
|
||||
57372: 'RSA_SM4_CBC_SHA256',
|
||||
57425: 'ECDHE_SM4_GCM_SM3',
|
||||
57427: 'ECC_SM4_GCM_SM3',
|
||||
57429: 'IBSDH_SM4_GCM_SM3',
|
||||
57431: 'IBC_SM4_GCM_SM3',
|
||||
57433: 'RSA_SM4_GCM_SM3',
|
||||
57434: 'RSA_SM4_GCM_SHA256',
|
||||
58384: 'TLS_RSA_WITH_ESTREAM_SALSA20_SHA1',
|
||||
58385: 'TLS_RSA_WITH_SALSA20_SHA1',
|
||||
58386: 'TLS_ECDHE_RSA_WITH_ESTREAM_SALSA20_SHA1',
|
||||
58387: 'TLS_ECDHE_RSA_WITH_SALSA20_SHA1',
|
||||
58388: 'TLS_ECDHE_ECDSA_WITH_ESTREAM_SALSA20_SHA1',
|
||||
58389: 'TLS_ECDHE_ECDSA_WITH_SALSA20_SHA1',
|
||||
58390: 'TLS_PSK_WITH_ESTREAM_SALSA20_SHA1',
|
||||
58391: 'TLS_PSK_WITH_SALSA20_SHA1',
|
||||
58392: 'TLS_ECDHE_PSK_WITH_ESTREAM_SALSA20_SHA1',
|
||||
58393: 'TLS_ECDHE_PSK_WITH_SALSA20_SHA1',
|
||||
58394: 'TLS_RSA_PSK_WITH_ESTREAM_SALSA20_SHA1',
|
||||
58395: 'TLS_RSA_PSK_WITH_SALSA20_SHA1',
|
||||
58396: 'TLS_DHE_PSK_WITH_ESTREAM_SALSA20_SHA1',
|
||||
58397: 'TLS_DHE_PSK_WITH_SALSA20_SHA1',
|
||||
58398: 'TLS_DHE_RSA_WITH_ESTREAM_SALSA20_SHA1',
|
||||
58399: 'TLS_DHE_RSA_WITH_SALSA20_SHA1',
|
||||
60138: 'Reserved (GREASE)',
|
||||
64250: 'Reserved (GREASE)',
|
||||
65278: 'SSL_RSA_FIPS_WITH_DES_CBC_SHA',
|
||||
65279: 'SSL_RSA_FIPS_WITH_3DES_EDE_CBC_SHA',
|
||||
65504: 'SSL_RSA_FIPS_WITH_3DES_EDE_CBC_SHA',
|
||||
65505: 'SSL_RSA_FIPS_WITH_DES_CBC_SHA',
|
||||
}
|
||||
|
||||
TLS_VERSIONS: dict[int, str] = {
|
||||
0x0002: "SSL 2.0", 0x0300: "SSL 3.0", 0x0301: "TLS 1.0", 0x0302: "TLS 1.1",
|
||||
0x0303: "TLS 1.2", 0x0304: "TLS 1.3",
|
||||
}
|
||||
|
||||
# GREASE values (RFC 8701) are random placeholders and never a real cipher or version.
|
||||
|
||||
def is_grease(value: int) -> bool:
|
||||
return (value & 0x0F0F) == 0x0A0A and ((value >> 8) & 0xFF) == (value & 0xFF)
|
||||
|
||||
def parse_int(raw) -> int | None:
|
||||
"""Parse tshark numeric output given as decimal or 0x-prefixed hex."""
|
||||
if raw is None:
|
||||
return None
|
||||
s = str(raw).strip()
|
||||
if not s:
|
||||
return None
|
||||
try:
|
||||
return int(s, 16) if s.lower().startswith("0x") else int(s)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
def cipher_name(raw) -> str | None:
|
||||
v = parse_int(raw)
|
||||
if v is None:
|
||||
return str(raw) if raw else None
|
||||
return CIPHER_SUITES.get(v, f"0x{v:04x}")
|
||||
|
||||
def version_name(raw) -> str | None:
|
||||
v = parse_int(raw)
|
||||
if v is None:
|
||||
return str(raw) if raw else None
|
||||
if 0x7F00 <= v <= 0x7FFF:
|
||||
return "TLS 1.3 (draft)"
|
||||
return TLS_VERSIONS.get(v, f"0x{v:04x}")
|
||||
|
||||
# Cipher-suite properties used by the TLS detector.
|
||||
|
||||
def cipher_is_weak(name: str) -> str | None:
|
||||
"""Return a reason when the suite is considered broken or obsolete, else None."""
|
||||
n = (name or "").upper()
|
||||
if not n or n.startswith("0X"):
|
||||
return None
|
||||
if "_NULL_" in n or n.endswith("_NULL") or "WITH_NULL" in n:
|
||||
return "no encryption (NULL cipher)"
|
||||
if "ANON" in n:
|
||||
return "anonymous key exchange (no authentication)"
|
||||
if "EXPORT" in n:
|
||||
return "export-grade key length"
|
||||
if "RC4" in n:
|
||||
return "RC4 stream cipher"
|
||||
if "_DES_" in n or "DES40" in n or "DES_CBC" in n and "3DES" not in n:
|
||||
return "single DES"
|
||||
if "3DES" in n:
|
||||
return "3DES (Sweet32)"
|
||||
if "IDEA" in n or "SEED" in n or "ARIA" in n and "GCM" not in n:
|
||||
return "legacy block cipher"
|
||||
if n.endswith("_MD5") or "_MD5" in n:
|
||||
return "MD5 MAC"
|
||||
return None
|
||||
|
||||
def cipher_is_legacy(name: str) -> str | None:
|
||||
"""Return a reason when the suite lacks forward secrecy or an AEAD mode."""
|
||||
n = (name or "").upper()
|
||||
if not n or n.startswith("0X") or n.startswith("TLS_AES_") or n.startswith("TLS_CHACHA20_"):
|
||||
return None
|
||||
if cipher_is_weak(n):
|
||||
return None
|
||||
reasons = []
|
||||
if not ("ECDHE" in n or "DHE" in n):
|
||||
reasons.append("no forward secrecy (static key exchange)")
|
||||
if "CBC" in n or n.endswith("_SHA") or n.endswith("_SHA256") and "GCM" not in n and "CCM" not in n and "CHACHA" not in n:
|
||||
reasons.append("CBC / non-AEAD mode")
|
||||
return "; ".join(reasons) or None
|
||||
Reference in New Issue
Block a user