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.
- pcaptriage-0.1.0/LICENSE +21 -0
- pcaptriage-0.1.0/PKG-INFO +123 -0
- pcaptriage-0.1.0/README.md +99 -0
- pcaptriage-0.1.0/pcaptriage/__init__.py +7 -0
- pcaptriage-0.1.0/pcaptriage/__main__.py +4 -0
- pcaptriage-0.1.0/pcaptriage/cli.py +125 -0
- pcaptriage-0.1.0/pcaptriage/detections/__init__.py +13 -0
- pcaptriage-0.1.0/pcaptriage/detections/base.py +47 -0
- pcaptriage-0.1.0/pcaptriage/detections/beaconing.py +71 -0
- pcaptriage-0.1.0/pcaptriage/detections/cleartext_creds.py +84 -0
- pcaptriage-0.1.0/pcaptriage/detections/dns_tunneling.py +85 -0
- pcaptriage-0.1.0/pcaptriage/detections/port_scan.py +74 -0
- pcaptriage-0.1.0/pcaptriage/detections/suspicious_tls.py +65 -0
- pcaptriage-0.1.0/pcaptriage/loader.py +32 -0
- pcaptriage-0.1.0/pcaptriage/mitre.py +28 -0
- pcaptriage-0.1.0/pcaptriage/report.py +199 -0
- pcaptriage-0.1.0/pcaptriage/zeek_runner.py +79 -0
- pcaptriage-0.1.0/pcaptriage.egg-info/PKG-INFO +123 -0
- pcaptriage-0.1.0/pcaptriage.egg-info/SOURCES.txt +22 -0
- pcaptriage-0.1.0/pcaptriage.egg-info/dependency_links.txt +1 -0
- pcaptriage-0.1.0/pcaptriage.egg-info/entry_points.txt +2 -0
- pcaptriage-0.1.0/pcaptriage.egg-info/top_level.txt +1 -0
- pcaptriage-0.1.0/pyproject.toml +49 -0
- pcaptriage-0.1.0/setup.cfg +4 -0
pcaptriage-0.1.0/LICENSE
ADDED
|
@@ -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,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
|