pcaptriage 0.1.0__py3-none-any.whl

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/__init__.py ADDED
@@ -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"
pcaptriage/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
pcaptriage/cli.py ADDED
@@ -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
@@ -0,0 +1,74 @@
1
+ """Detect port/host scanning from conn.log."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import defaultdict
6
+ from typing import Dict, List, Set
7
+
8
+ from .base import Finding, register
9
+
10
+ # A source touching this many distinct destination ports looks like a scan.
11
+ PORT_THRESHOLD = 50
12
+ # A source touching this many distinct hosts looks like a sweep.
13
+ HOST_THRESHOLD = 25
14
+ # Connection states typical of scans (no payload / rejected / reset).
15
+ SCAN_STATES = {"S0", "REJ", "RSTR", "RSTOS0", "SH", "OTH"}
16
+
17
+
18
+ @register
19
+ def detect_port_scan(logs: Dict[str, List[dict]]) -> List[Finding]:
20
+ conns = logs.get("conn", [])
21
+ if not conns:
22
+ return []
23
+
24
+ ports_by_src: Dict[str, Set[int]] = defaultdict(set)
25
+ hosts_by_src: Dict[str, Set[str]] = defaultdict(set)
26
+ scanlike_by_src: Dict[str, int] = defaultdict(int)
27
+
28
+ for rec in conns:
29
+ src = rec.get("id.orig_h")
30
+ dst = rec.get("id.resp_h")
31
+ port = rec.get("id.resp_p")
32
+ state = rec.get("conn_state", "")
33
+ if src is None:
34
+ continue
35
+ if port is not None:
36
+ ports_by_src[src].add(port)
37
+ if dst is not None:
38
+ hosts_by_src[src].add(dst)
39
+ if state in SCAN_STATES:
40
+ scanlike_by_src[src] += 1
41
+
42
+ findings: List[Finding] = []
43
+ for src in sorted(ports_by_src, key=lambda s: len(ports_by_src[s]), reverse=True):
44
+ n_ports = len(ports_by_src[src])
45
+ n_hosts = len(hosts_by_src[src])
46
+ n_scanlike = scanlike_by_src.get(src, 0)
47
+
48
+ if n_ports >= PORT_THRESHOLD or n_hosts >= HOST_THRESHOLD:
49
+ evidence = [
50
+ f"source {src}",
51
+ f"distinct destination ports: {n_ports}",
52
+ f"distinct destination hosts: {n_hosts}",
53
+ f"scan-like connection states (S0/REJ/RST...): {n_scanlike}",
54
+ ]
55
+ severity = "medium"
56
+ if n_ports >= PORT_THRESHOLD and n_scanlike > n_ports // 2:
57
+ severity = "high"
58
+ findings.append(
59
+ Finding(
60
+ id="port-scan",
61
+ title=f"Port/host scan from {src}",
62
+ severity=severity,
63
+ description=(
64
+ "A single source contacted an unusually large number of "
65
+ "ports or hosts, with many connections never completing. "
66
+ "This is consistent with network/service discovery."
67
+ ),
68
+ mitre=["T1046"],
69
+ evidence=evidence,
70
+ source_log="conn",
71
+ )
72
+ )
73
+
74
+ return findings
@@ -0,0 +1,65 @@
1
+ """Flag suspicious TLS certificates from ssl.log."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import Counter
6
+ from typing import Dict, List
7
+
8
+ from .base import Finding, register
9
+
10
+ # Validation outcomes Zeek reports that are not a clean, trusted chain.
11
+ BAD_STATUSES = {
12
+ "self signed certificate",
13
+ "self signed certificate in certificate chain",
14
+ "unable to get local issuer certificate",
15
+ "certificate has expired",
16
+ "unable to verify the first certificate",
17
+ }
18
+
19
+
20
+ @register
21
+ def detect_suspicious_tls(logs: Dict[str, List[dict]]) -> List[Finding]:
22
+ ssl = logs.get("ssl", [])
23
+ if not ssl:
24
+ return []
25
+
26
+ evidence: List[str] = []
27
+ reasons: Counter = Counter()
28
+
29
+ for rec in ssl:
30
+ status = (rec.get("validation_status") or "").lower()
31
+ subject = rec.get("subject", "")
32
+ server = rec.get("server_name", "")
33
+ dst = rec.get("id.resp_h", "?")
34
+ dport = rec.get("id.resp_p", "?")
35
+ if status and status != "ok" and (status in BAD_STATUSES or "self signed" in status or "expired" in status):
36
+ reasons[status] += 1
37
+ label = server or subject or "(no SNI/subject)"
38
+ evidence.append(f"{dst}:{dport} {label} -> {status}")
39
+
40
+ if not evidence:
41
+ return []
42
+
43
+ # Self-signed on an unusual port is a stronger C2 signal than a plain
44
+ # expired cert on 443.
45
+ severity = "medium"
46
+ if any("self signed" in r for r in reasons):
47
+ severity = "high"
48
+
49
+ reason_summary = ", ".join(f"{n}x {r}" for r, n in reasons.most_common())
50
+ return [
51
+ Finding(
52
+ id="suspicious-tls",
53
+ title="Suspicious TLS certificate(s)",
54
+ severity=severity,
55
+ description=(
56
+ "One or more TLS sessions used certificates that do not validate "
57
+ "against a trusted chain (self-signed, expired, or unknown issuer). "
58
+ "Benign in internal/dev setups, but also typical of malware C2 and "
59
+ "interception. Summary: " + reason_summary
60
+ ),
61
+ mitre=["T1573"],
62
+ evidence=evidence[:25],
63
+ source_log="ssl",
64
+ )
65
+ ]
pcaptriage/loader.py ADDED
@@ -0,0 +1,32 @@
1
+ """Load Zeek JSON logs into memory."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Dict, List
8
+
9
+
10
+ def load_logs(logs_dir: str) -> Dict[str, List[dict]]:
11
+ """Read every *.log file in `logs_dir` as JSON lines.
12
+
13
+ Returns a mapping: log name without extension -> list of records.
14
+ e.g. {"conn": [...], "dns": [...], "http": [...]}.
15
+ Lines that fail to parse are skipped (Zeek can emit a non-JSON
16
+ header on some logs depending on version/config).
17
+ """
18
+ logs: Dict[str, List[dict]] = {}
19
+ for path in sorted(Path(logs_dir).glob("*.log")):
20
+ name = path.stem
21
+ records: List[dict] = []
22
+ with path.open("r", encoding="utf-8", errors="replace") as fh:
23
+ for line in fh:
24
+ line = line.strip()
25
+ if not line or line.startswith("#"):
26
+ continue
27
+ try:
28
+ records.append(json.loads(line))
29
+ except json.JSONDecodeError:
30
+ continue
31
+ logs[name] = records
32
+ return logs
pcaptriage/mitre.py ADDED
@@ -0,0 +1,28 @@
1
+ """Minimal MITRE ATT&CK technique lookup used by detections."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Dict, Tuple
6
+
7
+ # technique id -> (name, url)
8
+ TECHNIQUES: Dict[str, Tuple[str, str]] = {
9
+ "T1046": ("Network Service Discovery", "https://attack.mitre.org/techniques/T1046/"),
10
+ "T1040": ("Network Sniffing", "https://attack.mitre.org/techniques/T1040/"),
11
+ "T1110": ("Brute Force", "https://attack.mitre.org/techniques/T1110/"),
12
+ "T1552": ("Unsecured Credentials", "https://attack.mitre.org/techniques/T1552/"),
13
+ "T1071": ("Application Layer Protocol", "https://attack.mitre.org/techniques/T1071/"),
14
+ "T1071.004": ("Application Layer Protocol: DNS", "https://attack.mitre.org/techniques/T1071/004/"),
15
+ "T1095": ("Non-Application Layer Protocol", "https://attack.mitre.org/techniques/T1095/"),
16
+ "T1573": ("Encrypted Channel", "https://attack.mitre.org/techniques/T1573/"),
17
+ "T1048": ("Exfiltration Over Alternative Protocol", "https://attack.mitre.org/techniques/T1048/"),
18
+ "T1557.001": ("Adversary-in-the-Middle: LLMNR/NBT-NS Poisoning", "https://attack.mitre.org/techniques/T1557/001/"),
19
+ "T1557": ("Adversary-in-the-Middle", "https://attack.mitre.org/techniques/T1557/"),
20
+ }
21
+
22
+
23
+ def describe(technique_id: str) -> Tuple[str, str]:
24
+ """Return (name, url) for a technique id, with a safe fallback."""
25
+ return TECHNIQUES.get(
26
+ technique_id,
27
+ (technique_id, f"https://attack.mitre.org/techniques/{technique_id.replace('.', '/')}/"),
28
+ )
pcaptriage/report.py ADDED
@@ -0,0 +1,199 @@
1
+ """Build the summary and render the HTML report."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import html
6
+ from collections import Counter
7
+ from datetime import datetime, timezone
8
+ from typing import Dict, List
9
+
10
+ from .detections.base import Finding, SEVERITY_ORDER
11
+ from .mitre import describe
12
+
13
+ SEVERITY_COLORS = {
14
+ "critical": "#b4232a",
15
+ "high": "#d1495b",
16
+ "medium": "#e08a1e",
17
+ "low": "#3a7ca5",
18
+ "info": "#6c757d",
19
+ }
20
+
21
+
22
+ def build_summary(logs: Dict[str, List[dict]]) -> dict:
23
+ """Compute high-level stats from conn.log."""
24
+ conns = logs.get("conn", [])
25
+ talkers: Counter = Counter()
26
+ services: Counter = Counter()
27
+ hosts = set()
28
+ ts_values = []
29
+
30
+ for rec in conns:
31
+ src = rec.get("id.orig_h")
32
+ dst = rec.get("id.resp_h")
33
+ if src:
34
+ talkers[src] += 1
35
+ hosts.add(src)
36
+ if dst:
37
+ hosts.add(dst)
38
+ svc = rec.get("service") or f"port/{rec.get('id.resp_p')}"
39
+ services[svc] += 1
40
+ ts = rec.get("ts")
41
+ if isinstance(ts, (int, float)):
42
+ ts_values.append(ts)
43
+
44
+ span = ""
45
+ if ts_values:
46
+ start = datetime.fromtimestamp(min(ts_values), tz=timezone.utc)
47
+ end = datetime.fromtimestamp(max(ts_values), tz=timezone.utc)
48
+ span = f"{start.isoformat()} to {end.isoformat()}"
49
+
50
+ return {
51
+ "connections": len(conns),
52
+ "distinct_hosts": len(hosts),
53
+ "log_types": sorted(logs.keys()),
54
+ "top_talkers": talkers.most_common(10),
55
+ "top_services": services.most_common(10),
56
+ "time_span": span,
57
+ }
58
+
59
+
60
+ def _esc(x) -> str:
61
+ return html.escape(str(x))
62
+
63
+
64
+ def _finding_card(f: Finding) -> str:
65
+ color = SEVERITY_COLORS.get(f.severity, "#6c757d")
66
+ mitre_html = " ".join(
67
+ f'<a class="mitre" href="{_esc(describe(t)[1])}" target="_blank" rel="noopener">'
68
+ f"{_esc(t)} {_esc(describe(t)[0])}</a>"
69
+ for t in f.mitre
70
+ )
71
+ evidence_html = ""
72
+ if f.evidence:
73
+ items = "".join(f"<li><code>{_esc(e)}</code></li>" for e in f.evidence)
74
+ evidence_html = f"<details><summary>Evidence ({len(f.evidence)})</summary><ul>{items}</ul></details>"
75
+ return f"""
76
+ <article class="finding" style="--sev:{color}">
77
+ <header>
78
+ <span class="badge" style="background:{color}">{_esc(f.severity.upper())}</span>
79
+ <h3>{_esc(f.title)}</h3>
80
+ </header>
81
+ <p>{_esc(f.description)}</p>
82
+ <div class="mitre-row">{mitre_html}</div>
83
+ {evidence_html}
84
+ <footer>source: {_esc(f.source_log)}</footer>
85
+ </article>
86
+ """
87
+
88
+
89
+ def render_html(pcap_name: str, summary: dict, findings: List[Finding]) -> str:
90
+ findings = sorted(findings, key=lambda f: f.severity_rank(), reverse=True)
91
+ counts = Counter(f.severity for f in findings)
92
+
93
+ sev_tiles = "".join(
94
+ f'<div class="tile" style="border-color:{SEVERITY_COLORS[s]}">'
95
+ f'<span class="n">{counts.get(s, 0)}</span><span class="l">{s}</span></div>'
96
+ for s in ("critical", "high", "medium", "low", "info")
97
+ )
98
+
99
+ talkers_rows = "".join(
100
+ f"<tr><td><code>{_esc(h)}</code></td><td>{n}</td></tr>" for h, n in summary["top_talkers"]
101
+ )
102
+ services_rows = "".join(
103
+ f"<tr><td>{_esc(s)}</td><td>{n}</td></tr>" for s, n in summary["top_services"]
104
+ )
105
+ findings_html = "".join(_finding_card(f) for f in findings) or (
106
+ '<p class="empty">No findings. Either the capture is clean, or the '
107
+ "patterns are outside the current detection set.</p>"
108
+ )
109
+
110
+ generated = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
111
+
112
+ return f"""<!doctype html>
113
+ <html lang="en">
114
+ <head>
115
+ <meta charset="utf-8">
116
+ <meta name="viewport" content="width=device-width, initial-scale=1">
117
+ <title>pcaptriage report</title>
118
+ <style>
119
+ :root {{
120
+ --bg:#f6f7f9; --card:#ffffff; --fg:#1b1f24; --muted:#5a6672;
121
+ --line:#e2e6ea; --accent:#19375f;
122
+ }}
123
+ @media (prefers-color-scheme: dark) {{
124
+ :root {{
125
+ --bg:#14171c; --card:#1d222a; --fg:#e8ecf1; --muted:#9aa5b1;
126
+ --line:#2b323c; --accent:#7fa8d8;
127
+ }}
128
+ }}
129
+ * {{ box-sizing:border-box; }}
130
+ body {{ margin:0; background:var(--bg); color:var(--fg);
131
+ font:15px/1.5 system-ui,-apple-system,Segoe UI,Roboto,sans-serif; }}
132
+ .wrap {{ max-width:960px; margin:0 auto; padding:24px 16px 64px; }}
133
+ h1 {{ font-size:22px; margin:0 0 4px; }}
134
+ .sub {{ color:var(--muted); font-size:13px; margin-bottom:20px; }}
135
+ .tiles {{ display:flex; gap:10px; flex-wrap:wrap; margin:16px 0 28px; }}
136
+ .tile {{ background:var(--card); border:1px solid var(--line);
137
+ border-left-width:5px; border-radius:8px; padding:10px 16px; min-width:92px; }}
138
+ .tile .n {{ display:block; font-size:24px; font-weight:700; }}
139
+ .tile .l {{ display:block; font-size:11px; text-transform:uppercase;
140
+ letter-spacing:.06em; color:var(--muted); }}
141
+ section {{ margin:28px 0; }}
142
+ .grid {{ display:grid; grid-template-columns:1fr 1fr; gap:20px; }}
143
+ @media (max-width:640px) {{ .grid {{ grid-template-columns:1fr; }} }}
144
+ table {{ width:100%; border-collapse:collapse; background:var(--card);
145
+ border:1px solid var(--line); border-radius:8px; overflow:hidden; }}
146
+ th,td {{ text-align:left; padding:7px 12px; border-bottom:1px solid var(--line); font-size:13px; }}
147
+ th {{ color:var(--muted); font-weight:600; }}
148
+ tr:last-child td {{ border-bottom:none; }}
149
+ .finding {{ background:var(--card); border:1px solid var(--line);
150
+ border-left:5px solid var(--sev); border-radius:8px; padding:14px 16px; margin:14px 0; }}
151
+ .finding header {{ display:flex; align-items:center; gap:10px; }}
152
+ .finding h3 {{ margin:0; font-size:16px; }}
153
+ .badge {{ color:#fff; font-size:11px; font-weight:700; padding:2px 8px;
154
+ border-radius:4px; letter-spacing:.04em; }}
155
+ .mitre-row {{ margin:8px 0; display:flex; gap:8px; flex-wrap:wrap; }}
156
+ a.mitre {{ font-size:12px; text-decoration:none; color:var(--accent);
157
+ border:1px solid var(--accent); border-radius:4px; padding:1px 7px; }}
158
+ details {{ margin-top:8px; }}
159
+ summary {{ cursor:pointer; color:var(--muted); font-size:13px; }}
160
+ ul {{ margin:8px 0; padding-left:18px; }}
161
+ code {{ font:12px/1.4 ui-monospace,SFMono-Regular,Menlo,monospace;
162
+ background:rgba(127,127,127,.12); padding:1px 5px; border-radius:4px; word-break:break-all; }}
163
+ .finding footer {{ margin-top:8px; color:var(--muted); font-size:11px; }}
164
+ .empty {{ color:var(--muted); }}
165
+ </style>
166
+ </head>
167
+ <body>
168
+ <div class="wrap">
169
+ <h1>pcaptriage report</h1>
170
+ <div class="sub">capture: <code>{_esc(pcap_name)}</code> &middot; generated {generated}</div>
171
+
172
+ <div class="tiles">{sev_tiles}</div>
173
+
174
+ <section>
175
+ <h2>Findings</h2>
176
+ {findings_html}
177
+ </section>
178
+
179
+ <section>
180
+ <h2>Capture summary</h2>
181
+ <p class="sub">{summary['connections']} connections &middot;
182
+ {summary['distinct_hosts']} distinct hosts &middot;
183
+ logs: {_esc(', '.join(summary['log_types']))}<br>
184
+ {_esc(summary['time_span'])}</p>
185
+ <div class="grid">
186
+ <div>
187
+ <h3>Top talkers</h3>
188
+ <table><tr><th>Host</th><th>Conns</th></tr>{talkers_rows}</table>
189
+ </div>
190
+ <div>
191
+ <h3>Top services</h3>
192
+ <table><tr><th>Service</th><th>Conns</th></tr>{services_rows}</table>
193
+ </div>
194
+ </div>
195
+ </section>
196
+ </div>
197
+ </body>
198
+ </html>
199
+ """
@@ -0,0 +1,79 @@
1
+ """Run Zeek over a pcap and produce JSON logs.
2
+
3
+ Zeek is the parsing engine. It can run either from a local install or
4
+ inside Docker (default on systems where the distro package is broken,
5
+ e.g. Kali where the zeek .deb conflicts with a newer libc6).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import shutil
12
+ import subprocess
13
+ from pathlib import Path
14
+
15
+
16
+ class ZeekError(RuntimeError):
17
+ pass
18
+
19
+
20
+ def run_zeek(
21
+ pcap_path: str,
22
+ logs_dir: str,
23
+ use_docker: bool = False,
24
+ zeek_cmd: str = "zeek",
25
+ docker_image: str = "zeek/zeek:lts",
26
+ ) -> str:
27
+ """Run Zeek on `pcap_path`, writing JSON logs into `logs_dir`.
28
+
29
+ Returns the logs directory path. Raises ZeekError on failure.
30
+ """
31
+ pcap = Path(pcap_path).resolve()
32
+ if not pcap.is_file():
33
+ raise ZeekError(f"pcap not found: {pcap}")
34
+
35
+ out = Path(logs_dir).resolve()
36
+ out.mkdir(parents=True, exist_ok=True)
37
+
38
+ if use_docker:
39
+ # Mount the pcap's directory read-only and the logs dir as the
40
+ # working directory so Zeek writes its *.log files onto the host.
41
+ cmd = [
42
+ "docker", "run", "--rm",
43
+ "-v", f"{pcap.parent}:/pcap:ro",
44
+ "-v", f"{out}:/work",
45
+ "-w", "/work",
46
+ docker_image,
47
+ "zeek", "-C", "-r", f"/pcap/{pcap.name}", "LogAscii::use_json=T",
48
+ ]
49
+ else:
50
+ if shutil.which(zeek_cmd) is None:
51
+ raise ZeekError(
52
+ f"'{zeek_cmd}' not found on PATH. Install Zeek or use --docker."
53
+ )
54
+ cmd = [zeek_cmd, "-C", "-r", str(pcap), "LogAscii::use_json=T"]
55
+
56
+ try:
57
+ proc = subprocess.run(
58
+ cmd,
59
+ cwd=str(out) if not use_docker else None,
60
+ capture_output=True,
61
+ text=True,
62
+ timeout=600,
63
+ )
64
+ except FileNotFoundError as exc:
65
+ raise ZeekError(f"could not launch Zeek ({exc})") from exc
66
+ except subprocess.TimeoutExpired as exc:
67
+ raise ZeekError("Zeek timed out (capture too large?)") from exc
68
+
69
+ # Zeek prints parse warnings to stderr but still exits 0; only fail
70
+ # when no log was produced.
71
+ produced = list(out.glob("*.log"))
72
+ if proc.returncode != 0 and not produced:
73
+ raise ZeekError(
74
+ f"Zeek failed (exit {proc.returncode}).\n{proc.stderr.strip()}"
75
+ )
76
+ if not produced:
77
+ raise ZeekError("Zeek produced no logs (empty or unsupported capture).")
78
+
79
+ return str(out)
@@ -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,20 @@
1
+ pcaptriage/__init__.py,sha256=kwOZrV6tBULHrLOwrEMP3BfgsSr8JIMjPIhGVG8VD3c,219
2
+ pcaptriage/__main__.py,sha256=MHKZ_ae3fSLGTLUUMOx15fWdeOnJSHhq-zslRP5F5Lc,79
3
+ pcaptriage/cli.py,sha256=Hb60sa_oqATHYSSPB3Ads0L4OS5JQVQJNbB4DN0f3G8,3785
4
+ pcaptriage/loader.py,sha256=BS0zlhPk135MAPbPTnl0BfLdfLL2CSdrqTRsyZio9Dw,1072
5
+ pcaptriage/mitre.py,sha256=AwwCsENvgg66FrQ4jEiUAKH7xDnNkGC49y8lY66rRhM,1502
6
+ pcaptriage/report.py,sha256=nzGEUux7f18_vgaKoWGlVtexz97kcL83nLnsij1DTkk,7221
7
+ pcaptriage/zeek_runner.py,sha256=b5oh-_ZvUp2LBjAhAmT-rWVlqrOr1cYth6NmdVpXHag,2434
8
+ pcaptriage/detections/__init__.py,sha256=mon2By8YlfwinOw2CwGnD-oSJ68_IcmKzxL-VYEtoRI,397
9
+ pcaptriage/detections/base.py,sha256=UX7pXNQ9M7-DUuanbqz_KL7vO8hI_OS6eSBdkoZwx_8,1199
10
+ pcaptriage/detections/beaconing.py,sha256=dM5UxVwWY4rqhktG-I6RGtLezSYeKnEsAiYqN41WrKE,2367
11
+ pcaptriage/detections/cleartext_creds.py,sha256=yXt9HdWqxiypF_SgsjnNf5CpYTZ5RK8PMLLINokQUng,2780
12
+ pcaptriage/detections/dns_tunneling.py,sha256=9oQaTvm2-f_-DT9fcwuLPBNkXvYEp88h0ST8tmUrs6U,3083
13
+ pcaptriage/detections/port_scan.py,sha256=Kcua8frTyvnbBSIEGxfPJnfHPcIMK9-exG2BvQr9VxA,2621
14
+ pcaptriage/detections/suspicious_tls.py,sha256=8DBAddVp0TdgX3UfgVSAeXijSZ3ZLsRdtIUvvsgp-B4,2155
15
+ pcaptriage-0.1.0.dist-info/licenses/LICENSE,sha256=2FDx1J9xeI2Vul5jJXBDZWFVjhfnL6aySrIXEnqNp3E,1076
16
+ pcaptriage-0.1.0.dist-info/METADATA,sha256=JuuwF9hUBsDYnMzUSa5whqTm3Gc52zznVzw_l5MVPgE,4663
17
+ pcaptriage-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
18
+ pcaptriage-0.1.0.dist-info/entry_points.txt,sha256=CDZeVXDNXjZU8r_vCBRv1aFul_j00vPlTNxuy9nPJWU,51
19
+ pcaptriage-0.1.0.dist-info/top_level.txt,sha256=DUzRZv438urQOpUUq7HCfXF3flkcz47BwYkjJSOrM04,11
20
+ pcaptriage-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ pcaptriage = pcaptriage.cli:main
@@ -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 @@
1
+ pcaptriage