pcaptriage 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Zakariya Elmansouri
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,123 @@
1
+ Metadata-Version: 2.4
2
+ Name: pcaptriage
3
+ Version: 0.1.0
4
+ Summary: Automated pcap triage on top of Zeek, with MITRE ATT&CK-mapped findings and an HTML report.
5
+ Author-email: Zakariya Elmansouri <zakariyaelmansouri40@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/zakeelm6/pcaptriage
8
+ Project-URL: Repository, https://github.com/zakeelm6/pcaptriage
9
+ Project-URL: Issues, https://github.com/zakeelm6/pcaptriage/issues
10
+ Keywords: pcap,zeek,network-security,mitre-attack,threat-detection,incident-response,dfir,soc,forensics,pcap-analysis,triage,blue-team
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Information Technology
14
+ Classifier: Topic :: Security
15
+ Classifier: Topic :: System :: Networking :: Monitoring
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3 :: Only
18
+ Classifier: Operating System :: POSIX :: Linux
19
+ Classifier: Operating System :: MacOS :: MacOS X
20
+ Requires-Python: >=3.9
21
+ Description-Content-Type: text/markdown
22
+ License-File: LICENSE
23
+ Dynamic: license-file
24
+
25
+ # pcaptriage
26
+
27
+ Triage automatique de captures reseau, construit au-dessus de **Zeek**.
28
+
29
+ Wireshark est manuel : tu ouvres un pcap et tu cherches toi-meme. pcaptriage
30
+ fait l'inverse. Il laisse Zeek parser les protocoles, puis il applique une
31
+ couche de triage : il detecte des comportements suspects, les mappe sur
32
+ **MITRE ATT&CK**, et sort un rapport HTML priorise plus un JSON automatisable.
33
+ Pensé pour traiter un pcap (ou un dossier entier) en une commande et rendre un
34
+ verdict lisible, pas pour remplacer le decodeur de Wireshark.
35
+
36
+ ## Ce qu'il detecte
37
+
38
+ | Detection | Signal | MITRE |
39
+ |---|---|---|
40
+ | Credentials en clair | FTP/HTTP basic auth, services non chiffres | T1552, T1040 |
41
+ | Scan de ports / hotes | une source touche beaucoup de ports/hotes, connexions non abouties | T1046 |
42
+ | DNS tunneling / exfiltration | fort volume + noms longs/haute entropie vers un domaine | T1071.004, T1048 |
43
+ | Beaconing / C2 | rappels a intervalle quasi constant vers une meme destination | T1071, T1095 |
44
+ | Certificats TLS suspects | certificats auto-signes, expires ou non valides | T1573 |
45
+
46
+ ## Prerequis
47
+
48
+ - **Python 3.9+** (teste sur 3.14). Aucune dependance pip, stdlib uniquement.
49
+ - **Zeek**, au choix :
50
+ - local : `zeek` dans le PATH ;
51
+ - **Docker** (recommande sur Kali, ou le paquet .deb casse la libc) :
52
+ ```bash
53
+ docker pull zeek/zeek:lts
54
+ ```
55
+
56
+ ## Installation
57
+
58
+ ```bash
59
+ # en tant que commande systeme (recommande, via pipx)
60
+ pipx install git+https://github.com/zakeelm6/pcaptriage.git
61
+
62
+ # ou avec pip, dans un venv
63
+ pip install git+https://github.com/zakeelm6/pcaptriage.git
64
+
65
+ # en developpement, depuis une copie locale
66
+ pip install -e .
67
+ ```
68
+
69
+ Une fois installe, la commande `pcaptriage` est disponible. Sans installation,
70
+ tout marche aussi via `python3 -m pcaptriage`.
71
+
72
+ ## Usage
73
+
74
+ ```bash
75
+ # un seul pcap, Zeek via Docker
76
+ pcaptriage capture.pcap --docker
77
+
78
+ # un dossier entier de captures
79
+ python3 -m pcaptriage ./captures/ --docker -o resultats/
80
+
81
+ # Zeek installe en local
82
+ python3 -m pcaptriage capture.pcap
83
+ ```
84
+
85
+ Sortie, par capture, dans le dossier `-o` (defaut `pcaptriage-report/`) :
86
+
87
+ ```
88
+ pcaptriage-report/
89
+ <nom-capture>/
90
+ report.html <- rapport lisible (clair/sombre), findings prioritises
91
+ findings.json <- meme contenu, automatisable (injectable dans un SIEM)
92
+ zeek-logs/ <- logs Zeek bruts (conn/dns/http/ssl/...)
93
+ ```
94
+
95
+ ## Architecture
96
+
97
+ ```
98
+ pcap --> Zeek (parsing) --> *.log JSON --> loader --> detections --> findings
99
+ | |
100
+ MITRE map report HTML + JSON
101
+ ```
102
+
103
+ - `zeek_runner.py` lance Zeek (local ou Docker) et produit les logs JSON.
104
+ - `loader.py` charge les logs en memoire.
105
+ - `detections/` : une detection = une fonction decoree `@register` qui prend
106
+ les logs et renvoie des `Finding`. Ajouter une detection = ajouter un fichier.
107
+ - `mitre.py` : table technique -> (nom, lien).
108
+ - `report.py` : resume de la capture + rendu HTML autonome.
109
+ - `cli.py` : orchestration, mode fichier ou dossier.
110
+
111
+ ## Feuille de route
112
+
113
+ - ARP spoofing / MITM, LLMNR/NBT-NS poisoning
114
+ - Extraction de fichiers (files.log) et de credentials additionnels
115
+ - Moteur de regles externes (YAML/Sigma-like) pour etendre sans coder
116
+ - Graphe de communication dans le rapport
117
+ - Sortie directe vers Elasticsearch (lien avec Mini-SOC)
118
+
119
+ ## Note
120
+
121
+ Outil d'analyse defensive / forensic sur des captures que tu es autorise a
122
+ analyser. Les logs peuvent contenir des donnees sensibles (identifiants,
123
+ hotes) : traite les sorties en consequence.
@@ -0,0 +1,99 @@
1
+ # pcaptriage
2
+
3
+ Triage automatique de captures reseau, construit au-dessus de **Zeek**.
4
+
5
+ Wireshark est manuel : tu ouvres un pcap et tu cherches toi-meme. pcaptriage
6
+ fait l'inverse. Il laisse Zeek parser les protocoles, puis il applique une
7
+ couche de triage : il detecte des comportements suspects, les mappe sur
8
+ **MITRE ATT&CK**, et sort un rapport HTML priorise plus un JSON automatisable.
9
+ Pensé pour traiter un pcap (ou un dossier entier) en une commande et rendre un
10
+ verdict lisible, pas pour remplacer le decodeur de Wireshark.
11
+
12
+ ## Ce qu'il detecte
13
+
14
+ | Detection | Signal | MITRE |
15
+ |---|---|---|
16
+ | Credentials en clair | FTP/HTTP basic auth, services non chiffres | T1552, T1040 |
17
+ | Scan de ports / hotes | une source touche beaucoup de ports/hotes, connexions non abouties | T1046 |
18
+ | DNS tunneling / exfiltration | fort volume + noms longs/haute entropie vers un domaine | T1071.004, T1048 |
19
+ | Beaconing / C2 | rappels a intervalle quasi constant vers une meme destination | T1071, T1095 |
20
+ | Certificats TLS suspects | certificats auto-signes, expires ou non valides | T1573 |
21
+
22
+ ## Prerequis
23
+
24
+ - **Python 3.9+** (teste sur 3.14). Aucune dependance pip, stdlib uniquement.
25
+ - **Zeek**, au choix :
26
+ - local : `zeek` dans le PATH ;
27
+ - **Docker** (recommande sur Kali, ou le paquet .deb casse la libc) :
28
+ ```bash
29
+ docker pull zeek/zeek:lts
30
+ ```
31
+
32
+ ## Installation
33
+
34
+ ```bash
35
+ # en tant que commande systeme (recommande, via pipx)
36
+ pipx install git+https://github.com/zakeelm6/pcaptriage.git
37
+
38
+ # ou avec pip, dans un venv
39
+ pip install git+https://github.com/zakeelm6/pcaptriage.git
40
+
41
+ # en developpement, depuis une copie locale
42
+ pip install -e .
43
+ ```
44
+
45
+ Une fois installe, la commande `pcaptriage` est disponible. Sans installation,
46
+ tout marche aussi via `python3 -m pcaptriage`.
47
+
48
+ ## Usage
49
+
50
+ ```bash
51
+ # un seul pcap, Zeek via Docker
52
+ pcaptriage capture.pcap --docker
53
+
54
+ # un dossier entier de captures
55
+ python3 -m pcaptriage ./captures/ --docker -o resultats/
56
+
57
+ # Zeek installe en local
58
+ python3 -m pcaptriage capture.pcap
59
+ ```
60
+
61
+ Sortie, par capture, dans le dossier `-o` (defaut `pcaptriage-report/`) :
62
+
63
+ ```
64
+ pcaptriage-report/
65
+ <nom-capture>/
66
+ report.html <- rapport lisible (clair/sombre), findings prioritises
67
+ findings.json <- meme contenu, automatisable (injectable dans un SIEM)
68
+ zeek-logs/ <- logs Zeek bruts (conn/dns/http/ssl/...)
69
+ ```
70
+
71
+ ## Architecture
72
+
73
+ ```
74
+ pcap --> Zeek (parsing) --> *.log JSON --> loader --> detections --> findings
75
+ | |
76
+ MITRE map report HTML + JSON
77
+ ```
78
+
79
+ - `zeek_runner.py` lance Zeek (local ou Docker) et produit les logs JSON.
80
+ - `loader.py` charge les logs en memoire.
81
+ - `detections/` : une detection = une fonction decoree `@register` qui prend
82
+ les logs et renvoie des `Finding`. Ajouter une detection = ajouter un fichier.
83
+ - `mitre.py` : table technique -> (nom, lien).
84
+ - `report.py` : resume de la capture + rendu HTML autonome.
85
+ - `cli.py` : orchestration, mode fichier ou dossier.
86
+
87
+ ## Feuille de route
88
+
89
+ - ARP spoofing / MITM, LLMNR/NBT-NS poisoning
90
+ - Extraction de fichiers (files.log) et de credentials additionnels
91
+ - Moteur de regles externes (YAML/Sigma-like) pour etendre sans coder
92
+ - Graphe de communication dans le rapport
93
+ - Sortie directe vers Elasticsearch (lien avec Mini-SOC)
94
+
95
+ ## Note
96
+
97
+ Outil d'analyse defensive / forensic sur des captures que tu es autorise a
98
+ analyser. Les logs peuvent contenir des donnees sensibles (identifiants,
99
+ hotes) : traite les sorties en consequence.
@@ -0,0 +1,7 @@
1
+ """pcaptriage - automated pcap triage on top of Zeek.
2
+
3
+ Zeek does the protocol parsing, this package adds the triage layer:
4
+ detections mapped to MITRE ATT&CK, artifact extraction and a report.
5
+ """
6
+
7
+ __version__ = "0.1.0"
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
@@ -0,0 +1,125 @@
1
+ """Command-line interface for pcaptriage."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from pathlib import Path
9
+ from typing import List
10
+
11
+ from . import __version__
12
+ from .detections import all_detections
13
+ from .loader import load_logs
14
+ from .report import build_summary, render_html
15
+ from .zeek_runner import ZeekError, run_zeek
16
+
17
+ PCAP_EXTS = {".pcap", ".pcapng", ".cap"}
18
+
19
+
20
+ def _analyze_one(pcap: Path, out_root: Path, args) -> int:
21
+ """Analyze a single pcap. Returns the number of findings."""
22
+ case_dir = out_root / pcap.stem
23
+ logs_dir = case_dir / "zeek-logs"
24
+ print(f"[*] {pcap.name}: running Zeek ({'docker' if args.docker else 'local'})...")
25
+ try:
26
+ run_zeek(
27
+ str(pcap),
28
+ str(logs_dir),
29
+ use_docker=args.docker,
30
+ zeek_cmd=args.zeek_cmd,
31
+ docker_image=args.docker_image,
32
+ )
33
+ except ZeekError as exc:
34
+ print(f"[!] {pcap.name}: {exc}", file=sys.stderr)
35
+ return -1
36
+
37
+ logs = load_logs(str(logs_dir))
38
+ findings = []
39
+ for detect in all_detections():
40
+ findings.extend(detect(logs))
41
+
42
+ summary = build_summary(logs)
43
+
44
+ report_path = case_dir / "report.html"
45
+ report_path.write_text(render_html(pcap.name, summary, findings), encoding="utf-8")
46
+
47
+ json_path = case_dir / "findings.json"
48
+ json_path.write_text(
49
+ json.dumps(
50
+ {
51
+ "pcap": pcap.name,
52
+ "summary": {k: v for k, v in summary.items()},
53
+ "findings": [f.to_dict() for f in findings],
54
+ },
55
+ indent=2,
56
+ ),
57
+ encoding="utf-8",
58
+ )
59
+
60
+ sev = {}
61
+ for f in findings:
62
+ sev[f.severity] = sev.get(f.severity, 0) + 1
63
+ sev_str = ", ".join(f"{v} {k}" for k, v in sorted(sev.items())) or "none"
64
+ print(f"[+] {pcap.name}: {len(findings)} finding(s) ({sev_str}) -> {report_path}")
65
+ return len(findings)
66
+
67
+
68
+ def _collect_pcaps(target: Path) -> List[Path]:
69
+ if target.is_file():
70
+ return [target]
71
+ if target.is_dir():
72
+ return sorted(p for p in target.rglob("*") if p.suffix.lower() in PCAP_EXTS)
73
+ return []
74
+
75
+
76
+ def main(argv=None) -> int:
77
+ parser = argparse.ArgumentParser(
78
+ prog="pcaptriage",
79
+ description="Automated pcap triage on top of Zeek, mapped to MITRE ATT&CK.",
80
+ )
81
+ parser.add_argument("target", help="a .pcap file or a directory of captures")
82
+ parser.add_argument(
83
+ "-o", "--output", default="pcaptriage-report",
84
+ help="output directory (default: ./pcaptriage-report)",
85
+ )
86
+ parser.add_argument(
87
+ "--docker", action="store_true",
88
+ help="run Zeek via Docker instead of a local install",
89
+ )
90
+ parser.add_argument(
91
+ "--zeek-cmd", default="zeek",
92
+ help="local Zeek binary name/path (default: zeek)",
93
+ )
94
+ parser.add_argument(
95
+ "--docker-image", default="zeek/zeek:lts",
96
+ help="Zeek Docker image (default: zeek/zeek:lts)",
97
+ )
98
+ parser.add_argument("--version", action="version", version=f"pcaptriage {__version__}")
99
+ args = parser.parse_args(argv)
100
+
101
+ target = Path(args.target)
102
+ pcaps = _collect_pcaps(target)
103
+ if not pcaps:
104
+ print(f"[!] no pcap found at: {target}", file=sys.stderr)
105
+ return 2
106
+
107
+ out_root = Path(args.output)
108
+ out_root.mkdir(parents=True, exist_ok=True)
109
+
110
+ total = 0
111
+ failures = 0
112
+ for pcap in pcaps:
113
+ n = _analyze_one(pcap, out_root, args)
114
+ if n < 0:
115
+ failures += 1
116
+ else:
117
+ total += n
118
+
119
+ print(f"\n[=] done: {len(pcaps) - failures}/{len(pcaps)} capture(s) analyzed, "
120
+ f"{total} finding(s) total. Reports in {out_root}/")
121
+ return 1 if failures and failures == len(pcaps) else 0
122
+
123
+
124
+ if __name__ == "__main__":
125
+ raise SystemExit(main())
@@ -0,0 +1,13 @@
1
+ """Detection package.
2
+
3
+ Importing this package registers all detections via the @register
4
+ decorator in each module.
5
+ """
6
+
7
+ from . import cleartext_creds # noqa: F401
8
+ from . import port_scan # noqa: F401
9
+ from . import dns_tunneling # noqa: F401
10
+ from . import beaconing # noqa: F401
11
+ from . import suspicious_tls # noqa: F401
12
+
13
+ from .base import all_detections, Finding # noqa: F401
@@ -0,0 +1,47 @@
1
+ """Finding model and detection registry."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field, asdict
6
+ from typing import Callable, Dict, List
7
+
8
+ SEVERITY_ORDER = {
9
+ "critical": 4,
10
+ "high": 3,
11
+ "medium": 2,
12
+ "low": 1,
13
+ "info": 0,
14
+ }
15
+
16
+
17
+ @dataclass
18
+ class Finding:
19
+ id: str # stable slug, e.g. "cleartext-creds"
20
+ title: str
21
+ severity: str # one of SEVERITY_ORDER keys
22
+ description: str
23
+ mitre: List[str] = field(default_factory=list) # technique ids, e.g. ["T1110"]
24
+ evidence: List[str] = field(default_factory=list)
25
+ source_log: str = "" # which Zeek log it came from
26
+
27
+ def severity_rank(self) -> int:
28
+ return SEVERITY_ORDER.get(self.severity, 0)
29
+
30
+ def to_dict(self) -> dict:
31
+ return asdict(self)
32
+
33
+
34
+ # A detection takes the loaded Zeek logs and returns zero or more findings.
35
+ Detection = Callable[[Dict[str, List[dict]]], List[Finding]]
36
+
37
+ _REGISTRY: List[Detection] = []
38
+
39
+
40
+ def register(fn: Detection) -> Detection:
41
+ """Decorator to register a detection function."""
42
+ _REGISTRY.append(fn)
43
+ return fn
44
+
45
+
46
+ def all_detections() -> List[Detection]:
47
+ return list(_REGISTRY)
@@ -0,0 +1,71 @@
1
+ """Detect regular beaconing to a destination (possible C2)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import defaultdict
6
+ from statistics import mean, pstdev
7
+ from typing import Dict, List
8
+
9
+ from .base import Finding, register
10
+
11
+ # Minimum connections to the same destination to judge regularity.
12
+ MIN_CONNECTIONS = 10
13
+ # Coefficient of variation of inter-arrival times below this = very regular.
14
+ CV_HIGH = 0.10
15
+ CV_MEDIUM = 0.25
16
+
17
+
18
+ @register
19
+ def detect_beaconing(logs: Dict[str, List[dict]]) -> List[Finding]:
20
+ conns = logs.get("conn", [])
21
+ if not conns:
22
+ return []
23
+
24
+ # Group connection start times per (src, dst, dst_port).
25
+ times: Dict[tuple, List[float]] = defaultdict(list)
26
+ for rec in conns:
27
+ src = rec.get("id.orig_h")
28
+ dst = rec.get("id.resp_h")
29
+ port = rec.get("id.resp_p")
30
+ ts = rec.get("ts")
31
+ if src and dst and isinstance(ts, (int, float)):
32
+ times[(src, dst, port)].append(ts)
33
+
34
+ findings: List[Finding] = []
35
+ for (src, dst, port), ts_list in times.items():
36
+ if len(ts_list) < MIN_CONNECTIONS:
37
+ continue
38
+ ts_list.sort()
39
+ deltas = [b - a for a, b in zip(ts_list, ts_list[1:]) if b - a > 0]
40
+ if len(deltas) < MIN_CONNECTIONS - 1:
41
+ continue
42
+ m = mean(deltas)
43
+ if m <= 0:
44
+ continue
45
+ cv = pstdev(deltas) / m
46
+ if cv > CV_MEDIUM:
47
+ continue
48
+ severity = "high" if cv <= CV_HIGH else "medium"
49
+ findings.append(
50
+ Finding(
51
+ id="beaconing",
52
+ title=f"Regular beaconing {src} -> {dst}:{port}",
53
+ severity=severity,
54
+ description=(
55
+ "A host contacted the same destination at a near-constant "
56
+ "interval. Regular, automated callbacks are a common sign of "
57
+ "command-and-control (C2) traffic."
58
+ ),
59
+ mitre=["T1071", "T1095"],
60
+ evidence=[
61
+ f"source: {src}",
62
+ f"destination: {dst}:{port}",
63
+ f"connections: {len(ts_list)}",
64
+ f"mean interval: {m:.1f} s",
65
+ f"interval regularity (CV): {cv:.3f} (lower = more regular)",
66
+ ],
67
+ source_log="conn",
68
+ )
69
+ )
70
+
71
+ return findings
@@ -0,0 +1,84 @@
1
+ """Detect credentials sent over cleartext protocols."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Dict, List
6
+
7
+ from .base import Finding, register
8
+
9
+ # Ports/services that carry credentials without encryption.
10
+ CLEARTEXT_SERVICES = {
11
+ 21: "FTP",
12
+ 23: "Telnet",
13
+ 80: "HTTP",
14
+ 110: "POP3",
15
+ 143: "IMAP",
16
+ 25: "SMTP",
17
+ }
18
+
19
+
20
+ @register
21
+ def detect_cleartext_creds(logs: Dict[str, List[dict]]) -> List[Finding]:
22
+ findings: List[Finding] = []
23
+ evidence: List[str] = []
24
+
25
+ # FTP: Zeek ftp.log carries 'user' and 'password'.
26
+ for rec in logs.get("ftp", []):
27
+ user = rec.get("user")
28
+ pw = rec.get("password")
29
+ if user:
30
+ src = rec.get("id.orig_h", "?")
31
+ dst = rec.get("id.resp_h", "?")
32
+ shown_pw = pw if pw else "(captured)"
33
+ evidence.append(f"FTP {src} -> {dst} login user={user} password={shown_pw}")
34
+
35
+ # HTTP basic auth: Zeek http.log exposes 'username' (and 'password').
36
+ for rec in logs.get("http", []):
37
+ user = rec.get("username")
38
+ if user:
39
+ src = rec.get("id.orig_h", "?")
40
+ host = rec.get("host", rec.get("id.resp_h", "?"))
41
+ uri = rec.get("uri", "")
42
+ evidence.append(f"HTTP basic auth {src} -> {host}{uri} user={user}")
43
+
44
+ # Any session on a cleartext service, from conn.log (coarse signal).
45
+ cleartext_sessions = 0
46
+ for rec in logs.get("conn", []):
47
+ port = rec.get("id.resp_p")
48
+ if port in CLEARTEXT_SERVICES:
49
+ cleartext_sessions += 1
50
+
51
+ if evidence:
52
+ findings.append(
53
+ Finding(
54
+ id="cleartext-creds",
55
+ title="Credentials transmitted in cleartext",
56
+ severity="high",
57
+ description=(
58
+ "Login credentials were observed over an unencrypted protocol. "
59
+ "Anyone on the path can capture them. Move the service to a "
60
+ "TLS-protected equivalent (FTPS/SFTP, HTTPS, IMAPS...)."
61
+ ),
62
+ mitre=["T1552", "T1040"],
63
+ evidence=evidence[:25],
64
+ source_log="ftp/http",
65
+ )
66
+ )
67
+ elif cleartext_sessions:
68
+ findings.append(
69
+ Finding(
70
+ id="cleartext-services",
71
+ title="Cleartext services in use",
72
+ severity="low",
73
+ description=(
74
+ f"{cleartext_sessions} session(s) used a cleartext protocol "
75
+ "(FTP/Telnet/HTTP/POP3/IMAP/SMTP). No credentials were parsed, "
76
+ "but traffic is exposed to sniffing."
77
+ ),
78
+ mitre=["T1040"],
79
+ evidence=[],
80
+ source_log="conn",
81
+ )
82
+ )
83
+
84
+ return findings
@@ -0,0 +1,85 @@
1
+ """Detect possible DNS tunneling / exfiltration from dns.log."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from collections import defaultdict
7
+ from typing import Dict, List
8
+
9
+ from .base import Finding, register
10
+
11
+ # A registered domain seen more than this many times is worth a look.
12
+ QUERY_COUNT_THRESHOLD = 100
13
+ # Average queried-name length above this suggests encoded data.
14
+ AVG_LEN_THRESHOLD = 40
15
+ # Shannon entropy above this suggests encoded/compressed subdomains.
16
+ ENTROPY_THRESHOLD = 3.5
17
+
18
+
19
+ def _entropy(s: str) -> float:
20
+ if not s:
21
+ return 0.0
22
+ counts: Dict[str, int] = {}
23
+ for ch in s:
24
+ counts[ch] = counts.get(ch, 0) + 1
25
+ n = len(s)
26
+ return -sum((c / n) * math.log2(c / n) for c in counts.values())
27
+
28
+
29
+ def _registered_domain(qname: str) -> str:
30
+ """Rough eTLD+1: last two labels. Good enough for triage."""
31
+ labels = qname.rstrip(".").split(".")
32
+ return ".".join(labels[-2:]) if len(labels) >= 2 else qname
33
+
34
+
35
+ @register
36
+ def detect_dns_tunneling(logs: Dict[str, List[dict]]) -> List[Finding]:
37
+ dns = logs.get("dns", [])
38
+ if not dns:
39
+ return []
40
+
41
+ count_by_domain: Dict[str, int] = defaultdict(int)
42
+ len_sum_by_domain: Dict[str, int] = defaultdict(int)
43
+ ent_sum_by_domain: Dict[str, float] = defaultdict(float)
44
+
45
+ for rec in dns:
46
+ qname = rec.get("query")
47
+ if not qname:
48
+ continue
49
+ dom = _registered_domain(qname)
50
+ # Measure the part below the registered domain (the subdomain).
51
+ sub = qname[: -len(dom)].rstrip(".") if qname.endswith(dom) else qname
52
+ count_by_domain[dom] += 1
53
+ len_sum_by_domain[dom] += len(qname)
54
+ ent_sum_by_domain[dom] += _entropy(sub)
55
+
56
+ findings: List[Finding] = []
57
+ for dom, count in sorted(count_by_domain.items(), key=lambda kv: kv[1], reverse=True):
58
+ if count < QUERY_COUNT_THRESHOLD:
59
+ continue
60
+ avg_len = len_sum_by_domain[dom] / count
61
+ avg_ent = ent_sum_by_domain[dom] / count
62
+ if avg_len >= AVG_LEN_THRESHOLD or avg_ent >= ENTROPY_THRESHOLD:
63
+ severity = "high" if (avg_len >= AVG_LEN_THRESHOLD and avg_ent >= ENTROPY_THRESHOLD) else "medium"
64
+ findings.append(
65
+ Finding(
66
+ id="dns-tunneling",
67
+ title=f"Possible DNS tunneling via {dom}",
68
+ severity=severity,
69
+ description=(
70
+ "A single domain received a high volume of queries with long "
71
+ "and/or high-entropy names. This pattern is consistent with "
72
+ "data encoded into DNS (tunneling or exfiltration)."
73
+ ),
74
+ mitre=["T1071.004", "T1048"],
75
+ evidence=[
76
+ f"domain: {dom}",
77
+ f"query count: {count}",
78
+ f"average query length: {avg_len:.1f}",
79
+ f"average subdomain entropy: {avg_ent:.2f}",
80
+ ],
81
+ source_log="dns",
82
+ )
83
+ )
84
+
85
+ return findings