analyze performance upgrade
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
# IoT PreTester 4.6 Demo - local configuration template
|
||||
# Copy to .env and set real values for SECRET_KEY and DJANGO_SUPERUSER_PASSWORD.
|
||||
# cp .env.example .env
|
||||
|
||||
# --- Django core -------------------------------------------------------------
|
||||
DEBUG=False
|
||||
# Required when DEBUG=False. Generate one with:
|
||||
# python3 -c "import secrets; print(secrets.token_urlsafe(64))"
|
||||
SECRET_KEY=CHANGE_ME
|
||||
ALLOWED_HOSTS=localhost,127.0.0.1
|
||||
|
||||
# --- Demo administrator ------------------------------------------------------
|
||||
DJANGO_SUPERUSER_USERNAME=admin
|
||||
DJANGO_SUPERUSER_EMAIL=admin@example.com
|
||||
DJANGO_SUPERUSER_PASSWORD=CHANGE_ME
|
||||
INIT_DATABASE=true
|
||||
|
||||
# --- CORS --------------------------------------------------------------------
|
||||
CORS_ALLOW_ALL_ORIGINS=False
|
||||
CORS_ALLOWED_ORIGINS=http://localhost:3000
|
||||
|
||||
# --- Ports -------------------------------------------------------------------
|
||||
BACKEND_PORT=8000
|
||||
FRONTEND_PORT=3000
|
||||
|
||||
# --- Capture analysis limits -------------------------------------------------
|
||||
# Packets to inspect per capture. 0 reads the capture completely; a 250 MB
|
||||
# capture takes well under a minute with the tshark-based extractor.
|
||||
CAPTURE_PACKET_LIMIT=0
|
||||
# Retained clear-text payload entries (encrypted flows are never retained).
|
||||
CAPTURE_MAX_TEXT_PAYLOADS=50000
|
||||
CAPTURE_MAX_PAYLOAD_CHARS=16384
|
||||
# Extra expected provider domains (comma-separated suffixes). Traffic to these
|
||||
# is reported at lower severity, never hidden. Example: vendor-cloud.com,iot.example
|
||||
ANALYZER_BENIGN_DOMAINS=
|
||||
|
||||
# --- Gunicorn / logging ------------------------------------------------------
|
||||
GUNICORN_WORKERS=4
|
||||
GUNICORN_TIMEOUT=600
|
||||
# debug shows every detector step and the tshark command lines in the logs.
|
||||
LOG_LEVEL=info
|
||||
|
||||
# --- Frontend ----------------------------------------------------------------
|
||||
NODE_ENV=production
|
||||
NEXT_PUBLIC_API_URL=http://localhost:8000/api
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
# Local configuration and secrets
|
||||
.env
|
||||
.env.local
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.egg-info/
|
||||
.venv/
|
||||
venv/
|
||||
|
||||
# Django runtime data
|
||||
django_project/staticfiles/
|
||||
django_project/db.sqlite3
|
||||
data/
|
||||
logs/
|
||||
files/uploads/
|
||||
uploads/
|
||||
|
||||
# Node / Next.js
|
||||
node_modules/
|
||||
frontend/.next/
|
||||
frontend/out/
|
||||
frontend/node_modules/
|
||||
|
||||
# OS / editor
|
||||
.DS_Store
|
||||
.idea/
|
||||
.vscode/
|
||||
+27
-6
@@ -12,16 +12,16 @@ IoT PreTester 4.6 Demo analyzes existing `.pcap` and `.pcapng` files and turns p
|
||||
| Django REST backend | API, validation, persistence, user/device administration, and integration with the analysis core |
|
||||
| `analyzer` package | Django-independent capture extraction, detection, scoring, provenance, and report rendering |
|
||||
| SQLite | Demo persistence for devices, files, findings, extracted artifacts, and analysis status |
|
||||
| tshark/pyshark | Protocol dissection and normalized capture extraction |
|
||||
| tshark | Protocol dissection; driven directly through two streaming `tshark -T ek` passes |
|
||||
| nmap/hashcat | Optional, explicitly initiated endpoint probing and offline hash recovery |
|
||||
|
||||
## Analysis flow
|
||||
|
||||
1. The frontend sends the capture and metadata to the authenticated Django API.
|
||||
2. Django validates the upload, stores it in a dedicated volume, and creates the database records.
|
||||
3. `extract_pyshark.py` parses the capture once into a normalized `CaptureContext`.
|
||||
3. `extract_tshark.py` reads the capture into a normalized `CaptureContext`: one metadata pass over every packet, then one payload pass restricted to unencrypted flows. The device IP entered at upload time is stored as `device_ip`.
|
||||
4. Ten independent detectors evaluate that shared context.
|
||||
5. The engine deduplicates and prioritizes findings, then attaches capture, packet, stream, and artifact provenance.
|
||||
5. The engine deduplicates findings, attributes them to the device under test (`affected_device`, `device_related`), prioritizes them, attaches capture, packet, stream, and artifact provenance, and builds the observation inventory.
|
||||
6. Django persists the result and extracted URL/hash artifacts.
|
||||
7. The frontend displays findings and can request Markdown, text, or JSON reports.
|
||||
8. Optional URL/hash enrichment runs only for artifacts selected by the user.
|
||||
@@ -31,7 +31,10 @@ IoT PreTester 4.6 Demo analyzes existing `.pcap` and `.pcapng` files and turns p
|
||||
```text
|
||||
django_project/analyzer/
|
||||
├── context.py Normalized capture data model
|
||||
├── extract_pyshark.py PCAP/PCAPNG extraction
|
||||
├── extract_tshark.py PCAP/PCAPNG extraction (tshark -T ek)
|
||||
├── tls_tables.py Cipher-suite and version names generated from tshark
|
||||
├── allowlist.py Expected provider domains (lowers severity, never hides)
|
||||
├── inventory.py Complete observation inventory for the report
|
||||
├── engine.py Detector orchestration and result assembly
|
||||
├── findings.py Shared finding taxonomy
|
||||
├── provenance.py Packet- and stream-level evidence references
|
||||
@@ -43,7 +46,9 @@ django_project/analyzer/
|
||||
└── tests/ Synthetic unit and regression tests
|
||||
```
|
||||
|
||||
The package does not import Django. pyshark and external tools are loaded only at the integration points that need them, so detector tests can run on synthetic contexts without tshark.
|
||||
The package does not import Django. tshark and external tools are invoked only at the integration points that need them, so detector tests run on synthetic contexts without tshark; `tests/test_regressions.py` additionally exercises the real extractor against `demo_files/` when tshark is available.
|
||||
|
||||
Certificates from TLS 1.2 handshakes are parsed from DER with `cryptography` (subject, issuer, validity, signature algorithm, key size, SAN). TLS 1.3 encrypts the certificate; the inventory states this explicitly.
|
||||
|
||||
## Finding model
|
||||
|
||||
@@ -51,6 +56,12 @@ Every detector produces the same core fields: ID, title, category, severity, con
|
||||
|
||||
Severity measures the potential impact if a finding is true. Confidence measures how strongly the capture supports it. Keeping these dimensions separate prevents low-confidence indicators from being presented as confirmed critical events.
|
||||
|
||||
When a device address is configured, every finding carries `evidence.device_related` and, if it touches the device, `affected_device`. Findings involving the device sort before equally severe findings about other hosts; the summary counts both groups.
|
||||
|
||||
## Observation inventory
|
||||
|
||||
Besides findings, every result contains an `inventory`: capture facts, the device under test with all its peers, internal hosts, external endpoints (with resolved names), observed services, TLS posture per handshake, DNS names, HTTP hosts and file transfers, clear-text flows and service events, and a coverage section (encrypted vs. undecoded flows, truncation, TLS 1.3 certificate visibility). It is rendered in all report formats and in the findings page so that an analyst can verify that nothing was overlooked.
|
||||
|
||||
## Detector set
|
||||
|
||||
1. Plaintext credentials
|
||||
@@ -66,6 +77,11 @@ Severity measures the potential impact if a finding is true. Confidence measures
|
||||
|
||||
## Design decisions
|
||||
|
||||
- Parse each capture with tshark's native field export instead of per-packet PDML; a 270 MB capture takes about 15 s instead of 10 minutes, and captures are read completely by default.
|
||||
- Retain payload text only from unencrypted flows so secret scanning is never exhausted by ciphertext.
|
||||
- Judge TLS by the negotiated ServerHello parameters; what a client merely offers is reported at lower severity.
|
||||
- Base clear-text protocol findings on what tshark dissected, not on port numbers alone.
|
||||
- Use the provider allowlist to lower severity of expected background traffic, never to suppress it.
|
||||
- Parse each capture once and share normalized records across all detectors.
|
||||
- Keep detector logic isolated from persistence, presentation, and external tools.
|
||||
- Preserve evidence provenance so every finding can be traced back to the capture.
|
||||
@@ -73,9 +89,14 @@ Severity measures the potential impact if a finding is true. Confidence measures
|
||||
- Use a small demo wordlist by default to keep distribution compact and behavior predictable.
|
||||
- Store complete analysis results as JSON while keeping extracted artifacts and job status queryable.
|
||||
|
||||
## Logging
|
||||
|
||||
Django logs to stdout (container log) and to a rotating `backend.log`; gunicorn's error output is routed to stdout as well. A request middleware logs every API call with duration, status, user and an `X-Request-ID`. Each capture analysis writes its own `<capture>.analysis.log` next to the upload (thread-filtered, so concurrent analyses do not mix), records the failure reason in `File.error_message`, and exposes both through the API and the findings page. The frontend keeps a ring buffer of client-side log entries with API timings and uncaught errors.
|
||||
|
||||
## Known demo limitations
|
||||
|
||||
- SQLite and in-process background threads are suitable for a controlled demo, not horizontal production scaling.
|
||||
- Capture quality and protocol encryption directly limit what can be detected.
|
||||
- Capture quality and protocol encryption directly limit what can be detected; TLS 1.3 hides server certificates, and encrypted payloads are never inspected.
|
||||
- Provider allowlisting is suffix-based; a device vendor's own cloud domains should be added via `ANALYZER_BENIGN_DOMAINS` to avoid low-severity noise.
|
||||
- URL probes observe the endpoint at probe time, which can differ from capture time.
|
||||
- The demo does not provide live packet capture, multi-tenant isolation, or production identity-provider integration.
|
||||
|
||||
@@ -5,7 +5,8 @@ IoT PreTester 4.6 Demo is a partner-ready demonstration platform for security an
|
||||
## Demo scope
|
||||
|
||||
- Upload and analyze `.pcap` and `.pcapng` captures.
|
||||
- Detect credential exposure, secrets, suspicious traffic patterns, weak protocols, TLS issues, DNS anomalies, scanning, and possible exfiltration.
|
||||
- Detect credential exposure (FTP, Telnet, POP3, IMAP, SMTP, HTTP forms/JSON/Basic, MQTT, SNMP), secrets, suspicious traffic patterns, clear-text and legacy protocols (incl. MQTT, CoAP, RTSP, Modbus, TFTP), TLS issues (negotiated version/cipher, client capabilities, certificates), DNS anomalies, scanning, brute force, unprotected firmware transfers, and possible exfiltration.
|
||||
- Present a complete observation inventory (device peers, hosts, endpoints, services, TLS posture, DNS, coverage) so nothing is overlooked even when no finding is raised.
|
||||
- Review affected endpoints, evidence, packet references, severity, confidence, and recommendations.
|
||||
- Export reports as Markdown, plain text, or JSON.
|
||||
- Optionally run user-selected URL probes and offline hash checks.
|
||||
@@ -19,7 +20,7 @@ Firmware binary analysis and historical prototype pages are intentionally not pa
|
||||
- At least 4 GB of free memory for the two application containers
|
||||
- A modern web browser
|
||||
|
||||
No local Python, Node.js, tshark, nmap, or hashcat installation is required for the Docker workflow.
|
||||
No local Python, Node.js, tshark, nmap, or hashcat installation is required for the Docker workflow. For headless use outside Docker, install tshark (Wireshark 4.0 or newer) and `pip install -r requirements.txt`.
|
||||
|
||||
## Quick start
|
||||
|
||||
@@ -80,10 +81,26 @@ The analysis core can also run without Django:
|
||||
```bash
|
||||
cd django_project
|
||||
python -m analyzer.cli ../demo_files/demo_capture.pcapng -f md -o report.md
|
||||
python -m analyzer.cli capture.pcapng -f json --device-ip 192.168.1.50 -o report.json
|
||||
```
|
||||
|
||||
`--device-ip` marks the device under test: findings that involve it are attributed and sorted first, and the report's observation inventory lists every peer, service, TLS handshake and DNS name of that device. The web UI passes the IP entered at upload time automatically.
|
||||
|
||||
Exit code `0` means no high/critical findings, `2` means at least one high/critical finding, and `1` indicates an execution error.
|
||||
|
||||
## Logs and troubleshooting
|
||||
|
||||
| Where | What |
|
||||
| --- | --- |
|
||||
| `docker compose logs -f backend` | Application log (requests, analysis phases, errors with tracebacks) |
|
||||
| `/app/logs/backend.log` in the backend container | Same log as a rotating file (10 MB × 5) |
|
||||
| `/app/files/uploads/<capture>.analysis.log` | Complete log of one capture analysis; shown in the UI when an analysis fails |
|
||||
| `GET /api/get_analysis_log/<file_id>` | Tail of that per-capture log |
|
||||
| `GET /api/health` | tshark/hashcat/nmap availability, analyzer version, upload directory, log level |
|
||||
| Browser console | Client log with API timings; `window.__iotpDumpLogs()` copies the last 300 entries |
|
||||
|
||||
Set `LOG_LEVEL=debug` in `.env` to log every detector step and the exact tshark command lines. Every API response carries an `X-Request-ID` header that also appears in the backend log line for that request. In the browser, `localStorage.setItem("iotp_log_level", "debug")` raises the client log level without a rebuild.
|
||||
|
||||
## Demo safety notes
|
||||
|
||||
- Only upload captures that you are authorized to inspect. Captures can contain credentials, tokens, personal data, and internal addresses.
|
||||
|
||||
Binary file not shown.
@@ -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
|
||||
@@ -22,6 +22,10 @@ services:
|
||||
DJANGO_SETTINGS_MODULE: IoTPreTester.settings
|
||||
DATABASE_PATH: /app/data/db.sqlite3
|
||||
CAPTURE_UPLOAD_DIR: /app/files/uploads
|
||||
CAPTURE_PACKET_LIMIT: ${CAPTURE_PACKET_LIMIT:-0}
|
||||
CAPTURE_MAX_TEXT_PAYLOADS: ${CAPTURE_MAX_TEXT_PAYLOADS:-50000}
|
||||
CAPTURE_MAX_PAYLOAD_CHARS: ${CAPTURE_MAX_PAYLOAD_CHARS:-16384}
|
||||
ANALYZER_BENIGN_DOMAINS: ${ANALYZER_BENIGN_DOMAINS:-}
|
||||
CORS_ALLOW_ALL_ORIGINS: ${CORS_ALLOW_ALL_ORIGINS:-False}
|
||||
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-http://localhost:3000}
|
||||
DJANGO_SUPERUSER_USERNAME: ${DJANGO_SUPERUSER_USERNAME:-admin}
|
||||
@@ -31,6 +35,7 @@ services:
|
||||
GUNICORN_WORKERS: ${GUNICORN_WORKERS:-4}
|
||||
GUNICORN_TIMEOUT: ${GUNICORN_TIMEOUT:-600}
|
||||
LOG_LEVEL: ${LOG_LEVEL:-info}
|
||||
LOG_DIR: /app/logs
|
||||
networks:
|
||||
- demo-network
|
||||
healthcheck:
|
||||
|
||||
Regular → Executable
+18
-2
@@ -1,7 +1,8 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
echo "Starting IoT PreTester 4.6 Demo backend"
|
||||
echo "Starting IoT PreTester 4.6 Demo backend (LOG_LEVEL=${LOG_LEVEL:-info})"
|
||||
echo "tshark: $(tshark -v 2>/dev/null | head -n 1 || echo 'NOT FOUND')"
|
||||
|
||||
mkdir -p /app/data /app/files/uploads /app/logs /app/django_project/staticfiles
|
||||
cd /app/django_project
|
||||
@@ -32,14 +33,29 @@ user.save()
|
||||
print("Demo administrator created" if created else "Demo administrator synchronized")
|
||||
PY
|
||||
|
||||
# A restart loses in-flight background analyses. Without this the frontend
|
||||
# would poll a capture that is stuck in "analyzing" forever.
|
||||
python manage.py shell <<'PY'
|
||||
from IoTAnalyzer.models import File
|
||||
|
||||
stale = File.objects.filter(status__in=["analyzing", "pending"])
|
||||
count = stale.count()
|
||||
if count:
|
||||
stale.update(status="analysis_failed")
|
||||
print(f"Reset {count} interrupted capture analyses")
|
||||
PY
|
||||
|
||||
if [ "${INIT_DATABASE:-true}" = "true" ]; then
|
||||
python create_models.py
|
||||
fi
|
||||
|
||||
# Errors and application logs go to stdout/stderr so `docker compose logs`
|
||||
# shows them; /app/logs keeps rotating copies (backend.log, access.log).
|
||||
exec gunicorn IoTPreTester.wsgi:application \
|
||||
--bind 0.0.0.0:8000 \
|
||||
--workers "${GUNICORN_WORKERS:-4}" \
|
||||
--timeout "${GUNICORN_TIMEOUT:-120}" \
|
||||
--access-logfile /app/logs/access.log \
|
||||
--error-logfile /app/logs/error.log \
|
||||
--error-logfile - \
|
||||
--capture-output \
|
||||
--log-level "${LOG_LEVEL:-info}"
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { getLogger, describeError } from "@/lib/logger"
|
||||
|
||||
const log = getLogger("route")
|
||||
|
||||
export default function RouteError({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
|
||||
useEffect(() => {
|
||||
log.error(`Render failure on ${typeof window !== "undefined" ? window.location.pathname : "?"}: ${describeError(error)}`,
|
||||
{ digest: error.digest, stack: error.stack })
|
||||
}, [error])
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl p-8">
|
||||
<h1 className="text-xl font-semibold">Something went wrong</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">{describeError(error)}</p>
|
||||
{error.digest && <p className="mt-1 text-xs text-muted-foreground">Digest: {error.digest}</p>}
|
||||
<p className="mt-3 text-xs text-muted-foreground">
|
||||
Details are in the browser console; run <code>window.__iotpDumpLogs()</code> to copy the client log.
|
||||
</p>
|
||||
<Button className="mt-4" onClick={() => reset()}>Try again</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -7,6 +7,11 @@ import { ArrowLeft, FileJson, FileText, FileCode, ShieldAlert, Globe, ChevronRig
|
||||
import { ProtectedRoute } from "@/components/protected-route"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
|
||||
import { InventoryPanel } from "@/components/inventory-panel"
|
||||
import { apiFetch, apiErrorMessage } from "@/lib/api"
|
||||
import { getLogger } from "@/lib/logger"
|
||||
|
||||
const log = getLogger("findings")
|
||||
|
||||
// API types mirror the backend finding schema.
|
||||
interface Finding {
|
||||
@@ -36,12 +41,30 @@ interface Summary {
|
||||
by_category: Record<string, number>
|
||||
highlights: Finding[]
|
||||
top: Finding[]
|
||||
device_related?: number
|
||||
other_hosts?: number
|
||||
}
|
||||
|
||||
interface AnalysisResult {
|
||||
meta?: Record<string, unknown>
|
||||
summary: Summary
|
||||
findings: Finding[]
|
||||
// Complete observation inventory (hosts, endpoints, TLS, DNS, coverage).
|
||||
inventory?: Record<string, unknown>
|
||||
// Present while the capture analysis has not produced a report yet.
|
||||
status?: "analyzing" | "failed" | "empty" | "ready"
|
||||
capture_status?: string
|
||||
message?: string
|
||||
// Failure reason recorded by the backend and whether a per-capture log exists.
|
||||
error?: string | null
|
||||
log_available?: boolean
|
||||
}
|
||||
|
||||
interface AnalysisLog {
|
||||
available: boolean
|
||||
log: string
|
||||
path?: string
|
||||
message?: string
|
||||
}
|
||||
|
||||
interface DeepStatus {
|
||||
@@ -122,6 +145,7 @@ function affectedLine(f: Finding): string {
|
||||
// Evidence rendered in dedicated sections is excluded from raw details.
|
||||
const EVIDENCE_SKIP = new Set([
|
||||
"dedup_key", "sources", "artifact", "capture_file", "source_packet_count", "occurrences",
|
||||
"device_related",
|
||||
])
|
||||
|
||||
type SourceRef = {
|
||||
@@ -172,47 +196,57 @@ function FindingsContent() {
|
||||
const [status, setStatus] = useState<DeepStatus | null>(null)
|
||||
const [cloud, setCloud] = useState<CloudResults | null>(null)
|
||||
const [analyzing, setAnalyzing] = useState(false)
|
||||
const [captureRunning, setCaptureRunning] = useState(false)
|
||||
const [selUrls, setSelUrls] = useState<Set<number>>(new Set())
|
||||
const [selHashes, setSelHashes] = useState<Set<number>>(new Set())
|
||||
const [backendLog, setBackendLog] = useState<AnalysisLog | null>(null)
|
||||
const [logLoading, setLogLoading] = useState(false)
|
||||
|
||||
const fetchCloud = async () => {
|
||||
if (!fileId) return
|
||||
try {
|
||||
const res = await fetch(getApiUrl(`get_cloud_results/${fileId}`), {
|
||||
cache: "no-store",
|
||||
credentials: "include",
|
||||
})
|
||||
if (res.ok) setCloud(await res.json())
|
||||
} catch { /* Optional enrichment data does not block the finding view. */ }
|
||||
setCloud(await apiFetch<CloudResults>(`get_cloud_results/${fileId}`))
|
||||
} catch (e) {
|
||||
log.debug(`Cloud results unavailable: ${apiErrorMessage(e)}`)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchBackendLog = async () => {
|
||||
if (!fileId) return
|
||||
setLogLoading(true)
|
||||
try {
|
||||
setBackendLog(await apiFetch<AnalysisLog>(`get_analysis_log/${fileId}?lines=400`))
|
||||
} catch (e) {
|
||||
setBackendLog({ available: false, log: "", message: apiErrorMessage(e) })
|
||||
} finally {
|
||||
setLogLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!fileId) { setError("No capture ID was provided."); setLoading(false); return }
|
||||
const load = async () => {
|
||||
try {
|
||||
const res = await fetch(getApiUrl(`get_findings/${fileId}`), {
|
||||
cache: "no-store",
|
||||
credentials: "include",
|
||||
})
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
setData(await res.json())
|
||||
const result = await apiFetch<AnalysisResult>(`get_findings/${fileId}`)
|
||||
setData(result)
|
||||
setCaptureRunning(result.status === "analyzing")
|
||||
log.info(`Capture ${fileId}: status=${result.status} findings=${result.findings?.length ?? 0}`,
|
||||
result.error ? { error: result.error } : undefined)
|
||||
if (result.status === "failed") fetchBackendLog()
|
||||
} catch (e) {
|
||||
setError(`Findings could not be loaded: ${e instanceof Error ? e.message : String(e)}`)
|
||||
log.error(`Findings for capture ${fileId} could not be loaded: ${apiErrorMessage(e)}`)
|
||||
setError(`Findings could not be loaded: ${apiErrorMessage(e)}`)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
await fetchCloud()
|
||||
try {
|
||||
const sres = await fetch(getApiUrl(`get_analysis_status/${fileId}`), {
|
||||
cache: "no-store",
|
||||
credentials: "include",
|
||||
})
|
||||
if (sres.ok) {
|
||||
const s: DeepStatus = await sres.json()
|
||||
setStatus(s)
|
||||
if (s.state === "running") setAnalyzing(true)
|
||||
}
|
||||
} catch { /* Status is optional until enrichment starts. */ }
|
||||
const s = await apiFetch<DeepStatus>(`get_analysis_status/${fileId}`)
|
||||
setStatus(s)
|
||||
if (s.state === "running") setAnalyzing(true)
|
||||
} catch (e) {
|
||||
log.debug(`Enrichment status unavailable: ${apiErrorMessage(e)}`)
|
||||
}
|
||||
}
|
||||
load()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -237,16 +271,15 @@ function FindingsContent() {
|
||||
let timer: ReturnType<typeof setInterval> | null = null
|
||||
const poll = async () => {
|
||||
try {
|
||||
const res = await fetch(getApiUrl(`get_analysis_status/${fileId}`), {
|
||||
cache: "no-store",
|
||||
credentials: "include",
|
||||
})
|
||||
if (res.ok) {
|
||||
const s: DeepStatus = await res.json()
|
||||
setStatus(s)
|
||||
if (s.state === "done" || s.state === "error") setAnalyzing(false)
|
||||
const s = await apiFetch<DeepStatus>(`get_analysis_status/${fileId}`)
|
||||
setStatus(s)
|
||||
if (s.state === "done" || s.state === "error") {
|
||||
setAnalyzing(false)
|
||||
log.info(`Enrichment for capture ${fileId} finished: ${s.state} (${s.message})`)
|
||||
}
|
||||
} catch { /* Retry on the next polling interval. */ }
|
||||
} catch (e) {
|
||||
log.warn(`Enrichment status poll failed: ${apiErrorMessage(e)}`)
|
||||
}
|
||||
await fetchCloud()
|
||||
}
|
||||
poll()
|
||||
@@ -255,20 +288,42 @@ function FindingsContent() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [fileId, analyzing])
|
||||
|
||||
// Poll while the capture analysis itself is still running.
|
||||
useEffect(() => {
|
||||
if (!fileId || !captureRunning) return
|
||||
let timer: ReturnType<typeof setInterval> | null = null
|
||||
const poll = async () => {
|
||||
try {
|
||||
const result = await apiFetch<AnalysisResult>(`get_findings/${fileId}`)
|
||||
setData(result)
|
||||
if (result.status !== "analyzing") {
|
||||
setCaptureRunning(false)
|
||||
log.info(`Capture ${fileId} analysis finished: ${result.status}`, result.error ? { error: result.error } : undefined)
|
||||
if (result.status === "failed") fetchBackendLog()
|
||||
await fetchCloud()
|
||||
}
|
||||
} catch (e) {
|
||||
log.warn(`Capture status poll failed: ${apiErrorMessage(e)}`)
|
||||
}
|
||||
}
|
||||
timer = setInterval(poll, 5000)
|
||||
return () => { if (timer) clearInterval(timer) }
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [fileId, captureRunning])
|
||||
|
||||
const startAnalysis = async () => {
|
||||
if (!fileId || (selUrls.size + selHashes.size) === 0) return
|
||||
try {
|
||||
const response = await fetch(getApiUrl(`start_analysis/${fileId}`), {
|
||||
await apiFetch(`start_analysis/${fileId}`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ url_ids: Array.from(selUrls), hash_ids: Array.from(selHashes) }),
|
||||
cache: "no-store",
|
||||
})
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
||||
log.info(`Enrichment started for capture ${fileId}: ${selUrls.size} URLs, ${selHashes.size} hashes`)
|
||||
setAnalyzing(true)
|
||||
} catch (e) {
|
||||
setError(`Enrichment could not be started: ${e instanceof Error ? e.message : String(e)}`)
|
||||
log.error(`Enrichment could not be started: ${apiErrorMessage(e)}`)
|
||||
setError(`Enrichment could not be started: ${apiErrorMessage(e)}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -333,6 +388,65 @@ function FindingsContent() {
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{/* Status of the capture analysis itself. */}
|
||||
{data?.status && data.status !== "ready" && (
|
||||
<div className={`mb-6 rounded-lg border p-4 ${
|
||||
data.status === "analyzing" ? "border-blue-300 bg-blue-50"
|
||||
: data.status === "failed" ? "border-red-300 bg-red-50"
|
||||
: "border-gray-300 bg-gray-50"
|
||||
}`}>
|
||||
<p className="font-medium">
|
||||
{data.status === "analyzing" && "⏳ The capture is being analyzed…"}
|
||||
{data.status === "failed" && "⚠️ The capture analysis failed"}
|
||||
{data.status === "empty" && "No security analysis is available for this capture."}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{data.status === "analyzing"
|
||||
? "Large captures take a moment. This page refreshes automatically."
|
||||
: data.message}
|
||||
</p>
|
||||
{data.status === "failed" && (
|
||||
<div className="mt-3 space-y-2">
|
||||
{data.error && (
|
||||
<div className="rounded-md border border-red-200 bg-white/70 p-2">
|
||||
<div className="text-xs font-medium text-red-800">Error</div>
|
||||
<code className="text-xs break-all whitespace-pre-wrap">{data.error}</code>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Button size="sm" variant="outline" onClick={fetchBackendLog} disabled={logLoading}>
|
||||
{logLoading ? "Loading log…" : backendLog ? "Refresh analysis log" : "Show analysis log"}
|
||||
</Button>
|
||||
{backendLog?.available && backendLog.path && (
|
||||
<span className="text-xs text-muted-foreground">Backend file: {backendLog.path}</span>
|
||||
)}
|
||||
</div>
|
||||
{backendLog && (
|
||||
backendLog.available ? (
|
||||
<pre className="max-h-96 overflow-auto rounded-md bg-slate-900 p-3 text-[11px] leading-snug text-slate-100 whitespace-pre-wrap break-all">{backendLog.log || "(empty log)"}</pre>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">{backendLog.message || "No analysis log is available."}</p>
|
||||
)
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Container log: <code>docker compose logs backend</code> · rotating file: <code>/app/logs/backend.log</code> ·
|
||||
client log: <code>window.__iotpDumpLogs()</code> in the browser console.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* A partial pass must be visible as such. */}
|
||||
{Boolean(data?.meta?.truncated) && (
|
||||
<div className="mb-6 rounded-lg border border-amber-300 bg-amber-50 p-4">
|
||||
<p className="font-medium">⚠️ Incomplete analysis</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{String(data?.meta?.truncation_reason ?? "Configured analysis limits were reached.")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status of optional URL and hash enrichment. */}
|
||||
{status && status.state !== "pending" && (
|
||||
<div className={`mb-6 rounded-lg border p-4 ${
|
||||
@@ -380,6 +494,20 @@ function FindingsContent() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Device under test */}
|
||||
{Boolean(data?.meta?.device_ip) && (
|
||||
<div className="mb-6 rounded-lg border border-emerald-300 bg-emerald-50 dark:bg-emerald-950/30 p-4 text-sm">
|
||||
<span className="font-medium">Device under test:</span>{" "}
|
||||
<code>{String(data?.meta?.device_ip)}</code>
|
||||
{Array.isArray(data?.meta?.device_macs) && (data?.meta?.device_macs as string[]).length > 0 && (
|
||||
<> (MAC {(data?.meta?.device_macs as string[]).join(", ")})</>
|
||||
)}
|
||||
<span className="text-muted-foreground">
|
||||
{" "}· device-related findings: {summary?.device_related ?? 0} · other hosts: {summary?.other_hosts ?? 0}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Prioritized highlights */}
|
||||
{summary && summary.highlights?.length > 0 && (
|
||||
<div className="mb-6 rounded-lg border border-red-300 bg-red-50 p-4">
|
||||
@@ -413,7 +541,12 @@ function FindingsContent() {
|
||||
<Card key={f.finding_id} className="border-l-4" style={{ borderLeftColor: sevBorder(f.severity) }}>
|
||||
<CardContent className="p-4 space-y-2">
|
||||
<div className="flex items-center justify-between gap-2 flex-wrap">
|
||||
<h4 className="font-semibold">{i + 1}. {f.title}</h4>
|
||||
<h4 className="font-semibold">
|
||||
{i + 1}. {f.title}
|
||||
{f.evidence?.device_related === true && (
|
||||
<span className="ml-2 rounded bg-emerald-600 px-1.5 py-0.5 text-[10px] font-semibold text-white align-middle">DEVICE</span>
|
||||
)}
|
||||
</h4>
|
||||
<div className="flex items-center gap-2">
|
||||
<Pill severity={f.severity} />
|
||||
<span className="text-xs text-muted-foreground">Confidence: {f.confidence}</span>
|
||||
@@ -470,6 +603,8 @@ function FindingsContent() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data?.status === "ready" && <InventoryPanel inventory={data?.inventory} />}
|
||||
|
||||
{cloud && (cloud.hashes.length > 0 || cloud.urls.length > 0) && (
|
||||
<h2 className="text-2xl font-bold mt-10 mb-3">Extracted artifacts</h2>
|
||||
)}
|
||||
|
||||
@@ -5,6 +5,9 @@ import * as React from "react"
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import axios from "axios"
|
||||
import { getLogger } from "@/lib/logger"
|
||||
|
||||
const log = getLogger("upload")
|
||||
import { FileIcon, UploadCloudIcon, XIcon, ScanIcon, ArrowLeft } from "lucide-react"
|
||||
import { useDropzone } from "react-dropzone"
|
||||
import type { FileRejection } from "react-dropzone"
|
||||
@@ -95,9 +98,10 @@ export function FileUpload({ onBack, deviceId }: FileUploadProps) {
|
||||
})
|
||||
|
||||
const fileId = response.data.data.id
|
||||
log.info(`Capture uploaded: file_id=${fileId} name=${file.name} size=${file.size} device=${deviceId} ip=${fileDetails.ipAddress}`)
|
||||
router.push(`/scan-results?fileId=${fileId}`)
|
||||
} catch (err) {
|
||||
console.error("Scan error:", err)
|
||||
log.error(`Upload of ${file.name} (${file.size} bytes) failed`, err)
|
||||
const apiError = axios.isAxiosError(err)
|
||||
? err.response?.data?.error || err.response?.data?.capture?.[0] || err.message
|
||||
: err instanceof Error
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
"use client"
|
||||
|
||||
// Observation inventory: everything the analysis saw, so that an analyst can
|
||||
// verify nothing was overlooked even when no finding was raised.
|
||||
|
||||
type Rec = Record<string, unknown>
|
||||
|
||||
interface Props {
|
||||
inventory?: Rec | null
|
||||
}
|
||||
|
||||
function kb(n: unknown): string {
|
||||
const v = typeof n === "number" ? n : Number(n ?? 0)
|
||||
if (!isFinite(v)) return "—"
|
||||
if (v >= 1024 * 1024) return `${(v / (1024 * 1024)).toFixed(1)} MB`
|
||||
if (v >= 1024) return `${(v / 1024).toFixed(1)} kB`
|
||||
return `${v} B`
|
||||
}
|
||||
|
||||
function list(v: unknown, max = 8): string {
|
||||
if (!Array.isArray(v) || v.length === 0) return "—"
|
||||
const s = v.slice(0, max).map(String).join(", ")
|
||||
return v.length > max ? `${s}, … (+${v.length - max})` : s
|
||||
}
|
||||
|
||||
function Section({ title, count, open, children }: { title: string; count?: number; open?: boolean; children: React.ReactNode }) {
|
||||
return (
|
||||
<details className="mt-2 rounded-lg border bg-card" open={open}>
|
||||
<summary className="cursor-pointer px-4 py-2 font-medium flex items-center justify-between">
|
||||
<span>{title}</span>
|
||||
{typeof count === "number" && <span className="text-xs text-muted-foreground">{count}</span>}
|
||||
</summary>
|
||||
<div className="px-4 pb-3 text-sm overflow-x-auto">{children}</div>
|
||||
</details>
|
||||
)
|
||||
}
|
||||
|
||||
function DeviceBadge({ on }: { on: unknown }) {
|
||||
return on ? <span className="ml-1 rounded bg-emerald-600 px-1.5 py-0.5 text-[10px] font-semibold text-white">DEVICE</span> : null
|
||||
}
|
||||
|
||||
export function InventoryPanel({ inventory }: Props) {
|
||||
if (!inventory || typeof inventory !== "object") return null
|
||||
const cap = (inventory.capture as Rec) || {}
|
||||
const dev = (inventory.device as Rec) || {}
|
||||
const hosts = (inventory.hosts as Rec[]) || []
|
||||
const eps = (inventory.external_endpoints as Rec[]) || []
|
||||
const svcs = (inventory.services as Rec[]) || []
|
||||
const tls = (inventory.tls as Rec) || {}
|
||||
const dns = (inventory.dns as Rec) || {}
|
||||
const http = (inventory.http as Rec) || {}
|
||||
const clear = (inventory.cleartext as Rec) || {}
|
||||
const cov = (inventory.coverage as Rec) || {}
|
||||
const protos = ((inventory.protocols as Rec) || {}).by_protocol as Record<string, number> | undefined
|
||||
|
||||
return (
|
||||
<div className="mt-10">
|
||||
<h2 className="text-2xl font-bold mb-1">Observation inventory</h2>
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
Complete list of what the capture contained — {String(cap.packets ?? 0)} packets, {String(cap.internal_hosts ?? 0)} internal
|
||||
and {String(cap.external_hosts ?? 0)} external hosts, {String(cap.conversations ?? 0)} flows
|
||||
{Array.isArray(cap.vlans) && cap.vlans.length > 0 && <> · VLANs {list(cap.vlans)}</>}
|
||||
{cap.extractor ? <> · {String(cap.extractor)}</> : null}.
|
||||
</p>
|
||||
|
||||
{/* Coverage first: an analyst must know what could not be inspected. */}
|
||||
<div className={`rounded-lg border p-3 text-sm ${cov.complete ? "border-emerald-300 bg-emerald-50 dark:bg-emerald-950/30" : "border-amber-300 bg-amber-50 dark:bg-amber-950/30"}`}>
|
||||
<div className="font-medium">{cov.complete ? "✅ Complete extraction" : "⚠️ Partial extraction"}</div>
|
||||
<div className="text-muted-foreground">
|
||||
{String(cov.flows_total ?? 0)} flows · {String(cov.flows_encrypted ?? 0)} encrypted · {String(cov.flows_undecoded ?? 0)} with undecoded payload ·
|
||||
{" "}{String(cov.text_payloads_scanned ?? 0)} clear-text payloads scanned
|
||||
</div>
|
||||
{Array.isArray(cov.notes) && cov.notes.length > 0 && (
|
||||
<ul className="mt-1 list-disc pl-5 text-muted-foreground">
|
||||
{(cov.notes as string[]).map((n, i) => <li key={i}>{n}</li>)}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Section title="Device under test" open>
|
||||
{!dev.configured ? (
|
||||
<div>
|
||||
<p className="text-muted-foreground">No device address was configured for this capture. Likely candidates:</p>
|
||||
<ul className="list-disc pl-5">
|
||||
{((dev.candidates as Rec[]) || []).map((c, i) => (
|
||||
<li key={i}><code>{String(c.ip)}</code> {list(c.names)} · {list(c.macs)} · {String(c.packets)} packets</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : !dev.seen ? (
|
||||
<p className="text-amber-700">{String(dev.note)}</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
<div><code>{String(dev.ip)}</code> · MAC {list(dev.macs)} · names {list(dev.names)}
|
||||
{dev.dhcp_vendor_class ? <> · DHCP vendor class {String(dev.dhcp_vendor_class)}</> : null}</div>
|
||||
<div className="text-muted-foreground">{String(dev.packets)} packets · sent {kb(dev.bytes_sent)} · received {kb(dev.bytes_received)}</div>
|
||||
<div>Services offered: {list(dev.ports_offered)} · ports contacted: {list(dev.ports_contacted, 20)}</div>
|
||||
<div>Protocols: {list(dev.protocols, 20)} · TLS handshakes: {String(dev.tls_sessions ?? 0)} · clear-text flows: {String(dev.cleartext_flows ?? 0)}</div>
|
||||
{Array.isArray(dev.dns_queries) && dev.dns_queries.length > 0 && <div>DNS names queried: {list(dev.dns_queries, 30)}</div>}
|
||||
{Array.isArray(dev.peers) && dev.peers.length > 0 && (
|
||||
<table className="mt-2 w-full text-xs">
|
||||
<thead><tr className="text-left text-muted-foreground"><th>Peer</th><th>Names</th><th>Scope</th><th>Ports</th><th>Protocols</th><th>To peer</th><th>From peer</th></tr></thead>
|
||||
<tbody>
|
||||
{(dev.peers as Rec[]).map((p, i) => (
|
||||
<tr key={i} className="border-t">
|
||||
<td className="font-mono">{String(p.ip)}</td><td>{list(p.names, 3)}</td>
|
||||
<td>{p.external ? "external" : "internal"}</td><td>{list(p.ports, 6)}</td>
|
||||
<td>{list(p.protocols, 5)}</td><td>{kb(p.bytes_to_peer)}</td><td>{kb(p.bytes_from_peer)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section title="Internal hosts" count={hosts.length}>
|
||||
<table className="w-full text-xs">
|
||||
<thead><tr className="text-left text-muted-foreground"><th>IP</th><th>Names</th><th>MAC</th><th>Packets</th><th>Offers</th><th>Ext. endpoints</th><th>Protocols</th></tr></thead>
|
||||
<tbody>
|
||||
{hosts.map((h, i) => (
|
||||
<tr key={i} className="border-t">
|
||||
<td className="font-mono">{String(h.ip)}<DeviceBadge on={h.is_device} /></td><td>{list(h.names, 3)}</td>
|
||||
<td className="font-mono">{list(h.macs, 2)}</td><td>{String(h.packets)}</td><td>{list(h.ports_offered, 8)}</td>
|
||||
<td>{String(h.external_endpoints ?? 0)}</td><td>{list(h.protocols, 6)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</Section>
|
||||
|
||||
<Section title="External endpoints" count={eps.length}>
|
||||
<table className="w-full text-xs">
|
||||
<thead><tr className="text-left text-muted-foreground"><th>IP</th><th>Names</th><th>Ports</th><th>Protocols</th><th>Encrypted</th><th>Out</th><th>In</th><th>Internal hosts</th></tr></thead>
|
||||
<tbody>
|
||||
{eps.map((e, i) => (
|
||||
<tr key={i} className="border-t">
|
||||
<td className="font-mono">{String(e.ip)}<DeviceBadge on={e.device} /></td><td>{list(e.names, 3)}</td>
|
||||
<td>{list(e.ports, 6)}</td><td>{list(e.protocols, 5)}</td>
|
||||
<td>{e.encrypted ? "yes" : <span className="text-amber-700">no / unknown</span>}</td>
|
||||
<td>{kb(e.bytes_out)}</td><td>{kb(e.bytes_in)}</td><td>{list(e.internal_hosts, 3)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</Section>
|
||||
|
||||
<Section title="Services observed" count={svcs.length}>
|
||||
<table className="w-full text-xs">
|
||||
<thead><tr className="text-left text-muted-foreground"><th>Endpoint</th><th>Service</th><th>Transport</th><th>Protocols</th><th>Encryption</th><th>Flows</th><th>Clients</th></tr></thead>
|
||||
<tbody>
|
||||
{svcs.map((s, i) => (
|
||||
<tr key={i} className="border-t">
|
||||
<td className="font-mono">{String(s.ip)}:{String(s.port)}<DeviceBadge on={s.device} /></td>
|
||||
<td>{String(s.service)}</td><td>{String(s.transport)}</td><td>{list(s.protocols, 4)}</td>
|
||||
<td>{s.encrypted ? "encrypted" : s.cleartext_app ? <span className="text-amber-700">clear-text</span> : "unknown"}</td>
|
||||
<td>{String(s.flows)}</td><td>{list(s.clients, 3)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</Section>
|
||||
|
||||
<Section title="TLS posture" count={Number(tls.sessions_total ?? 0)}>
|
||||
<div className="text-xs text-muted-foreground mb-2">
|
||||
Versions: {Object.entries((tls.versions as Record<string, number>) || {}).map(([k, v]) => `${k} ×${v}`).join(", ") || "—"} ·
|
||||
Ciphers: {Object.entries((tls.ciphers as Record<string, number>) || {}).map(([k, v]) => `${k} ×${v}`).join(", ") || "—"}
|
||||
</div>
|
||||
<table className="w-full text-xs">
|
||||
<thead><tr className="text-left text-muted-foreground"><th>Client → Server</th><th>SNI</th><th>Version</th><th>Cipher</th><th>Client max</th><th>Certificate</th><th>Notes</th></tr></thead>
|
||||
<tbody>
|
||||
{((tls.sessions as Rec[]) || []).map((s, i) => {
|
||||
const cert = (s.certificate as Rec) || null
|
||||
return (
|
||||
<tr key={i} className="border-t">
|
||||
<td className="font-mono">{String(s.client)} → {String(s.server)}:{String(s.port)}<DeviceBadge on={s.device} /></td>
|
||||
<td>{s.sni ? String(s.sni) : "—"}</td><td>{s.version ? String(s.version) : "no ServerHello"}</td>
|
||||
<td>{s.cipher ? String(s.cipher) : "—"}</td><td>{s.client_max_version ? String(s.client_max_version) : "—"}</td>
|
||||
<td>{cert ? `${String(cert.subject ?? "?")} by ${String(cert.issuer ?? "?")}${cert.not_after ? `, until ${String(cert.not_after)}` : ""}` : (s.version === "TLS 1.3" ? "encrypted (TLS 1.3)" : "—")}</td>
|
||||
<td>{[s.mutual_tls ? "mutual TLS" : null, Array.isArray(s.alerts) && s.alerts.length ? `alerts: ${list(s.alerts, 3)}` : null, Array.isArray(s.alpn) && s.alpn.length ? `ALPN ${list(s.alpn, 2)}` : null].filter(Boolean).join(" · ") || "—"}</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</Section>
|
||||
|
||||
<Section title="DNS" count={Number(dns.unique_names ?? 0)}>
|
||||
<div className="text-xs text-muted-foreground mb-2">
|
||||
{String(dns.queries ?? 0)} queries · {String(dns.responses ?? 0)} responses · codes {Object.entries((dns.rcodes as Record<string, number>) || {}).map(([k, v]) => `${k} ${v}`).join(", ") || "—"} ·
|
||||
resolvers {Object.entries((dns.resolvers as Record<string, number>) || {}).map(([k, v]) => `${k} (${v})`).join(", ") || "—"}
|
||||
</div>
|
||||
<table className="w-full text-xs">
|
||||
<thead><tr className="text-left text-muted-foreground"><th>Name</th><th>Queries</th><th>Types</th><th>Answers</th><th>Clients</th></tr></thead>
|
||||
<tbody>
|
||||
{((dns.names as Rec[]) || []).map((n, i) => (
|
||||
<tr key={i} className="border-t">
|
||||
<td className="font-mono">{String(n.name)}<DeviceBadge on={n.device} /></td><td>{String(n.queries)}</td>
|
||||
<td>{list(n.types, 4)}</td><td className="font-mono">{list(n.answers, 3)}</td><td>{list(n.clients, 3)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</Section>
|
||||
|
||||
{(Number(http.requests ?? 0) > 0 || (Array.isArray(http.file_transfers) && http.file_transfers.length > 0)) && (
|
||||
<Section title="HTTP" count={Number(http.requests ?? 0)}>
|
||||
<table className="w-full text-xs">
|
||||
<thead><tr className="text-left text-muted-foreground"><th>Host</th><th>Requests</th><th>Methods</th><th>URIs</th><th>Clients</th><th>User agents</th></tr></thead>
|
||||
<tbody>
|
||||
{((http.hosts as Rec[]) || []).map((h, i) => (
|
||||
<tr key={i} className="border-t">
|
||||
<td className="font-mono">{String(h.host)}<DeviceBadge on={h.device} /></td><td>{String(h.requests)}</td>
|
||||
<td>{list(h.methods, 4)}</td><td className="font-mono break-all">{list(h.uris, 5)}</td>
|
||||
<td>{list(h.clients, 3)}</td><td className="break-all">{list(h.user_agents, 2)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{Array.isArray(http.file_transfers) && http.file_transfers.length > 0 && (
|
||||
<ul className="mt-2 list-disc pl-5 text-xs">
|
||||
{(http.file_transfers as Rec[]).map((f, i) => (
|
||||
<li key={i}>File transfer: <code>{String(f.filename ?? f.uri ?? f.content_type)}</code> ({String(f.protocol)}, {String(f.direction)}, {String(f.src_ip)} → {String(f.dst_ip)}{f.size ? `, ${kb(f.size)}` : ""})</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
<Section title="Clear-text protocols, service events and DHCP">
|
||||
{Array.isArray(clear.flows) && clear.flows.length > 0 ? (
|
||||
<ul className="list-disc pl-5 text-xs">
|
||||
{(clear.flows as Rec[]).map((f, i) => (
|
||||
<li key={i}><span className="font-medium">{String(f.protocol)}</span><DeviceBadge on={f.device} /> · {String(f.flows)} flows, {kb(f.bytes)} · {list(f.pairs, 6)}</li>
|
||||
))}
|
||||
</ul>
|
||||
) : <p className="text-xs text-muted-foreground">No clear-text application flows were observed.</p>}
|
||||
{Array.isArray(clear.service_events) && clear.service_events.length > 0 && (
|
||||
<ul className="mt-2 list-disc pl-5 text-xs">
|
||||
{(clear.service_events as Rec[]).map((s, i) => (
|
||||
<li key={i}>{String(s.protocol)} {String(s.kind)}: {String(s.value ?? "")} {String(s.detail ?? "")} ({String(s.src_ip)} → {String(s.dst_ip)})</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{Array.isArray(clear.dhcp) && clear.dhcp.length > 0 && (
|
||||
<ul className="mt-2 list-disc pl-5 text-xs">
|
||||
{(clear.dhcp as Rec[]).map((d, i) => (
|
||||
<li key={i}>DHCP {String(d.type)}: <span className="font-mono">{String(d.client_mac)}</span> {String(d.hostname ?? "")} {String(d.vendor_class ?? "")} → {String(d.client_ip ?? "")} (server {String(d.server ?? "—")})</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{protos && (
|
||||
<Section title="Protocols (packets)">
|
||||
<div className="text-xs">{Object.entries(protos).slice(0, 40).map(([k, v]) => `${k} ${v}`).join(" · ")}</div>
|
||||
</Section>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
'use client'
|
||||
"use client"
|
||||
|
||||
import { AuthProvider } from '@/lib/auth-context'
|
||||
import { useEffect } from "react"
|
||||
import { AuthProvider } from "@/lib/auth-context"
|
||||
import { installGlobalErrorLogging } from "@/lib/logger"
|
||||
|
||||
export function Providers({ children }: { children: React.ReactNode }) {
|
||||
useEffect(() => { installGlobalErrorLogging() }, [])
|
||||
return <AuthProvider>{children}</AuthProvider>
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,36 @@
|
||||
import axios from "axios"
|
||||
import { getLogger } from "@/lib/logger"
|
||||
|
||||
export const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000/api"
|
||||
|
||||
// The Django API uses an authenticated session shared across localhost ports.
|
||||
axios.defaults.withCredentials = true
|
||||
|
||||
// Log every axios call with duration, status and the backend request id.
|
||||
const log = getLogger("axios")
|
||||
axios.interceptors.request.use((config) => {
|
||||
(config as typeof config & { metadata?: { started: number } }).metadata = { started: performance.now() }
|
||||
return config
|
||||
})
|
||||
axios.interceptors.response.use(
|
||||
(response) => {
|
||||
const started = (response.config as typeof response.config & { metadata?: { started: number } }).metadata?.started
|
||||
const ms = started ? Math.round(performance.now() - started) : -1
|
||||
log.debug(`${(response.config.method || "get").toUpperCase()} ${response.config.url} -> ${response.status} in ${ms} ms`,
|
||||
{ requestId: response.headers?.["x-request-id"] })
|
||||
return response
|
||||
},
|
||||
(error) => {
|
||||
const cfg = error?.config || {}
|
||||
const status = error?.response?.status
|
||||
const data = error?.response?.data
|
||||
const message = (data && typeof data === "object" && (data.error || data.detail)) || error?.message
|
||||
log.error(`${(cfg.method || "get").toUpperCase()} ${cfg.url} failed${status ? ` with ${status}` : ""}: ${message}`,
|
||||
{ requestId: error?.response?.headers?.["x-request-id"], data })
|
||||
return Promise.reject(error)
|
||||
},
|
||||
)
|
||||
|
||||
export const getApiUrl = (endpoint: string): string => {
|
||||
const path = endpoint.replace(/^\/+/, "")
|
||||
return `${API_BASE_URL.replace(/\/+$/, "")}/${path}`
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
// fetch wrapper for the Django API: logs every call and turns HTTP errors
|
||||
// into ApiError with the server's message and request id.
|
||||
|
||||
import { getApiUrl } from "@/config/api"
|
||||
import { getLogger, describeError } from "@/lib/logger"
|
||||
|
||||
const log = getLogger("api")
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number
|
||||
requestId: string | null
|
||||
body: unknown
|
||||
|
||||
constructor(message: string, status: number, requestId: string | null, body: unknown) {
|
||||
super(message)
|
||||
this.name = "ApiError"
|
||||
this.status = status
|
||||
this.requestId = requestId
|
||||
this.body = body
|
||||
}
|
||||
}
|
||||
|
||||
// Message shown to the user for a failed call.
|
||||
export function apiErrorMessage(err: unknown): string {
|
||||
if (err instanceof ApiError) {
|
||||
const rid = err.requestId ? ` (request ${err.requestId})` : ""
|
||||
return `${err.message}${rid}`
|
||||
}
|
||||
if (err instanceof TypeError) {
|
||||
return "The backend could not be reached. Is the API running and NEXT_PUBLIC_API_URL correct?"
|
||||
}
|
||||
return describeError(err)
|
||||
}
|
||||
|
||||
async function parseBody(res: Response): Promise<unknown> {
|
||||
const text = await res.text()
|
||||
if (!text) return null
|
||||
try { return JSON.parse(text) } catch { return text }
|
||||
}
|
||||
|
||||
function messageFromBody(body: unknown, status: number): string {
|
||||
if (body && typeof body === "object") {
|
||||
const b = body as Record<string, unknown>
|
||||
for (const key of ["error", "detail", "message"]) {
|
||||
if (typeof b[key] === "string") return b[key] as string
|
||||
}
|
||||
}
|
||||
if (typeof body === "string" && body.length < 200) return body
|
||||
return `HTTP ${status}`
|
||||
}
|
||||
|
||||
export async function apiFetch<T = unknown>(endpoint: string, init: RequestInit = {}): Promise<T> {
|
||||
const url = getApiUrl(endpoint)
|
||||
const method = (init.method || "GET").toUpperCase()
|
||||
const started = performance.now()
|
||||
let res: Response
|
||||
try {
|
||||
res = await fetch(url, { cache: "no-store", credentials: "include", ...init })
|
||||
} catch (err) {
|
||||
log.error(`${method} ${endpoint} network failure after ${Math.round(performance.now() - started)} ms: ${describeError(err)}`)
|
||||
throw err
|
||||
}
|
||||
const requestId = res.headers.get("X-Request-ID")
|
||||
const body = await parseBody(res)
|
||||
const ms = Math.round(performance.now() - started)
|
||||
if (!res.ok) {
|
||||
const message = messageFromBody(body, res.status)
|
||||
log.warn(`${method} ${endpoint} -> ${res.status} in ${ms} ms: ${message}`, { requestId, body })
|
||||
throw new ApiError(message, res.status, requestId, body)
|
||||
}
|
||||
log.debug(`${method} ${endpoint} -> ${res.status} in ${ms} ms`, { requestId })
|
||||
return body as T
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
// Browser-side logger with levels, timestamps and a ring buffer.
|
||||
//
|
||||
// Level: NEXT_PUBLIC_LOG_LEVEL at build time, overridable per browser with
|
||||
// localStorage.setItem("iotp_log_level", "debug"). The last 300 entries are
|
||||
// kept in window.__iotpLogs and can be copied with window.__iotpDumpLogs().
|
||||
|
||||
export type LogLevel = "debug" | "info" | "warn" | "error"
|
||||
|
||||
const ORDER: Record<LogLevel, number> = { debug: 10, info: 20, warn: 30, error: 40 }
|
||||
const BUFFER_SIZE = 300
|
||||
|
||||
interface LogEntry {
|
||||
ts: string
|
||||
level: LogLevel
|
||||
scope: string
|
||||
message: string
|
||||
data?: unknown
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__iotpLogs?: LogEntry[]
|
||||
__iotpDumpLogs?: () => string
|
||||
}
|
||||
}
|
||||
|
||||
function configuredLevel(): LogLevel {
|
||||
let level = (process.env.NEXT_PUBLIC_LOG_LEVEL || (process.env.NODE_ENV === "production" ? "info" : "debug")) as LogLevel
|
||||
if (typeof window !== "undefined") {
|
||||
try {
|
||||
const override = window.localStorage.getItem("iotp_log_level") as LogLevel | null
|
||||
if (override && override in ORDER) level = override
|
||||
} catch { /* storage may be unavailable */ }
|
||||
}
|
||||
return level in ORDER ? level : "info"
|
||||
}
|
||||
|
||||
function buffer(): LogEntry[] {
|
||||
if (typeof window === "undefined") return []
|
||||
if (!window.__iotpLogs) {
|
||||
window.__iotpLogs = []
|
||||
window.__iotpDumpLogs = () =>
|
||||
(window.__iotpLogs || [])
|
||||
.map((e) => `${e.ts} ${e.level.toUpperCase().padEnd(5)} [${e.scope}] ${e.message}${e.data !== undefined ? " " + safeJson(e.data) : ""}`)
|
||||
.join("\n")
|
||||
}
|
||||
return window.__iotpLogs
|
||||
}
|
||||
|
||||
function safeJson(v: unknown): string {
|
||||
try { return JSON.stringify(v) } catch { return String(v) }
|
||||
}
|
||||
|
||||
function emit(level: LogLevel, scope: string, message: string, data?: unknown) {
|
||||
if (ORDER[level] < ORDER[configuredLevel()]) return
|
||||
const ts = new Date().toISOString()
|
||||
const buf = buffer()
|
||||
buf.push({ ts, level, scope, message, data })
|
||||
if (buf.length > BUFFER_SIZE) buf.splice(0, buf.length - BUFFER_SIZE)
|
||||
const line = `[iotp ${ts.slice(11, 23)}] [${scope}] ${message}`
|
||||
const fn = level === "error" ? console.error : level === "warn" ? console.warn : level === "info" ? console.info : console.debug
|
||||
if (data !== undefined) fn(line, data)
|
||||
else fn(line)
|
||||
}
|
||||
|
||||
export function getLogger(scope: string) {
|
||||
return {
|
||||
debug: (message: string, data?: unknown) => emit("debug", scope, message, data),
|
||||
info: (message: string, data?: unknown) => emit("info", scope, message, data),
|
||||
warn: (message: string, data?: unknown) => emit("warn", scope, message, data),
|
||||
error: (message: string, data?: unknown) => emit("error", scope, message, data),
|
||||
}
|
||||
}
|
||||
|
||||
// Describe any thrown value for logs and user messages.
|
||||
export function describeError(err: unknown): string {
|
||||
if (err instanceof Error) return `${err.name}: ${err.message}`
|
||||
if (typeof err === "string") return err
|
||||
return safeJson(err)
|
||||
}
|
||||
|
||||
// Global handlers so uncaught browser errors land in the buffer too.
|
||||
export function installGlobalErrorLogging() {
|
||||
if (typeof window === "undefined") return
|
||||
const w = window as Window & { __iotpGlobalInstalled?: boolean }
|
||||
if (w.__iotpGlobalInstalled) return
|
||||
w.__iotpGlobalInstalled = true
|
||||
const log = getLogger("window")
|
||||
window.addEventListener("error", (ev) => log.error(`Uncaught: ${ev.message}`, { file: ev.filename, line: ev.lineno }))
|
||||
window.addEventListener("unhandledrejection", (ev) => log.error(`Unhandled rejection: ${describeError(ev.reason)}`))
|
||||
log.debug(`Logging active at level "${configuredLevel()}"; call window.__iotpDumpLogs() to copy the buffer`)
|
||||
}
|
||||
+1
-1
@@ -5,5 +5,5 @@ django-cors-headers==4.6.0
|
||||
djangorestframework==3.15.2
|
||||
dnspython==2.7.0
|
||||
gunicorn==23.0.0
|
||||
pyshark==0.6
|
||||
python-nmap==0.7.1
|
||||
cryptography>=42.0
|
||||
|
||||
Reference in New Issue
Block a user