#!/usr/bin/env python3 """Zeek network anomaly detection agent for log analysis and threat hunting.""" import json import os import subprocess import sys from collections import Counter, defaultdict from datetime import datetime from pathlib import Path ZEEK_BIN = os.environ.get("ZEEK_BIN", "/opt/zeek/bin/zeek") ZEEK_LOG_DIR = os.environ.get("ZEEK_LOG_DIR", "/opt/zeek/logs/current") def check_zeek_status(): """Check Zeek installation and running status.""" version = {"installed": False} try: result = subprocess.run([ZEEK_BIN, "--version"], capture_output=True, text=True, timeout=10) version = {"installed": True, "version": result.stdout.strip() or result.stderr.strip()} except FileNotFoundError: try: result = subprocess.run(["zeek", "--version"], capture_output=True, text=True, timeout=10) version = {"installed": True, "version": result.stdout.strip()} except FileNotFoundError: version = {"installed": False} running = False try: r = subprocess.run(["zeekctl", "status"], capture_output=True, text=True, timeout=10) running = "running" in r.stdout.lower() except FileNotFoundError: pass return {**version, "running": running} def parse_conn_log(log_path=None): """Parse Zeek conn.log for connection statistics and anomalies.""" log_path = log_path or os.path.join(ZEEK_LOG_DIR, "conn.log") if not os.path.exists(log_path): return {"error": f"conn.log not found: {log_path}"} total = 0 protocols = Counter() services = Counter() top_talkers = Counter() top_destinations = Counter() long_connections = [] with open(log_path, "r") as f: header = {} for line in f: if line.startswith("#fields"): fields_list = line.strip().split("\t")[1:] header = {name: i for i, name in enumerate(fields_list)} continue if line.startswith("#"): continue parts = line.strip().split("\t") total += 1 if not header: continue src = parts[header.get("id.orig_h", 2)] if len(parts) > header.get("id.orig_h", 2) else "" dst = parts[header.get("id.resp_h", 4)] if len(parts) > header.get("id.resp_h", 4) else "" proto = parts[header.get("proto", 6)] if len(parts) > header.get("proto", 6) else "" service = parts[header.get("service", 7)] if len(parts) > header.get("service", 7) else "-" duration = parts[header.get("duration", 8)] if len(parts) > header.get("duration", 8) else "-" protocols[proto] += 1 if service != "-": services[service] += 1 top_talkers[src] += 1 top_destinations[dst] += 1 if duration != "-": try: dur = float(duration) if dur > 3600: long_connections.append({"src": src, "dst": dst, "duration_sec": dur, "service": service}) except ValueError: pass return { "total_connections": total, "protocols": dict(protocols), "top_services": services.most_common(15), "top_sources": top_talkers.most_common(15), "top_destinations": top_destinations.most_common(15), "long_connections": sorted(long_connections, key=lambda x: x["duration_sec"], reverse=True)[:20], } def parse_dns_log(log_path=None): """Parse Zeek dns.log for DNS anomaly detection.""" log_path = log_path or os.path.join(ZEEK_LOG_DIR, "dns.log") if not os.path.exists(log_path): return {"error": f"dns.log not found: {log_path}"} queries = Counter() query_types = Counter() long_queries = [] nxdomain = [] with open(log_path, "r") as f: header = {} for line in f: if line.startswith("#fields"): fields_list = line.strip().split("\t")[1:] header = {name: i for i, name in enumerate(fields_list)} continue if line.startswith("#"): continue parts = line.strip().split("\t") if not header: continue query = parts[header.get("query", 9)] if len(parts) > header.get("query", 9) else "" qtype = parts[header.get("qtype_name", 13)] if len(parts) > header.get("qtype_name", 13) else "" rcode = parts[header.get("rcode_name", 15)] if len(parts) > header.get("rcode_name", 15) else "" src = parts[header.get("id.orig_h", 2)] if len(parts) > header.get("id.orig_h", 2) else "" queries[query] += 1 query_types[qtype] += 1 if len(query) > 60: long_queries.append({"source": src, "query": query, "length": len(query)}) if rcode == "NXDOMAIN": nxdomain.append({"source": src, "query": query}) return { "unique_queries": len(queries), "top_queries": queries.most_common(20), "query_types": dict(query_types), "long_queries_tunneling": long_queries[:20], "nxdomain_count": len(nxdomain), "nxdomain_samples": nxdomain[:20], } def parse_ssl_log(log_path=None): """Parse Zeek ssl.log for TLS anomalies and certificate issues.""" log_path = log_path or os.path.join(ZEEK_LOG_DIR, "ssl.log") if not os.path.exists(log_path): return {"error": f"ssl.log not found: {log_path}"} ja3_hashes = Counter() server_names = Counter() expired_certs = [] with open(log_path, "r") as f: header = {} for line in f: if line.startswith("#fields"): fields_list = line.strip().split("\t")[1:] header = {name: i for i, name in enumerate(fields_list)} continue if line.startswith("#"): continue parts = line.strip().split("\t") if not header: continue ja3 = parts[header.get("ja3", -1)] if header.get("ja3") and len(parts) > header["ja3"] else "-" sni = parts[header.get("server_name", -1)] if header.get("server_name") and len(parts) > header["server_name"] else "-" valid = parts[header.get("validation_status", -1)] if header.get("validation_status") and len(parts) > header["validation_status"] else "-" if ja3 != "-": ja3_hashes[ja3] += 1 if sni != "-": server_names[sni] += 1 if "expired" in valid.lower() if valid != "-" else False: expired_certs.append({"sni": sni, "validation": valid}) return { "unique_ja3": len(ja3_hashes), "top_ja3": ja3_hashes.most_common(20), "top_sni": server_names.most_common(20), "expired_certs": expired_certs[:20], } def detect_beaconing(log_path=None, interval_tolerance=0.15): """Detect C2 beaconing patterns from Zeek conn.log.""" log_path = log_path or os.path.join(ZEEK_LOG_DIR, "conn.log") if not os.path.exists(log_path): return {"error": f"conn.log not found: {log_path}"} pair_times = defaultdict(list) with open(log_path, "r") as f: header = {} for line in f: if line.startswith("#fields"): fields_list = line.strip().split("\t")[1:] header = {name: i for i, name in enumerate(fields_list)} continue if line.startswith("#"): continue parts = line.strip().split("\t") if not header: continue ts = parts[header.get("ts", 0)] if len(parts) > header.get("ts", 0) else "" src = parts[header.get("id.orig_h", 2)] if len(parts) > header.get("id.orig_h", 2) else "" dst = parts[header.get("id.resp_h", 4)] if len(parts) > header.get("id.resp_h", 4) else "" try: pair_times[f"{src}->{dst}"].append(float(ts)) except ValueError: pass beacons = [] for pair, times in pair_times.items(): if len(times) < 10: continue times.sort() intervals = [times[i+1] - times[i] for i in range(len(times)-1)] if not intervals: continue avg = sum(intervals) / len(intervals) if avg < 1: continue jitter = sum(abs(i - avg) for i in intervals) / len(intervals) / avg if avg > 0 else 1 if jitter < interval_tolerance: src, dst = pair.split("->") beacons.append({ "source": src, "destination": dst, "connections": len(times), "avg_interval_sec": round(avg, 1), "jitter_pct": round(jitter * 100, 1), }) beacons.sort(key=lambda x: x["connections"], reverse=True) return {"beacons_detected": len(beacons), "beacons": beacons[:20]} def analyze_pcap(pcap_path): """Analyze a PCAP file with Zeek to generate logs.""" if not os.path.exists(pcap_path): return {"error": f"PCAP not found: {pcap_path}"} import tempfile output_dir = os.path.join( os.environ.get("ZEEK_OUTPUT_DIR", tempfile.gettempdir()), f"zeek_analysis_{datetime.now().strftime('%Y%m%d_%H%M%S')}" ) os.makedirs(output_dir, exist_ok=True) try: result = subprocess.run( ["zeek", "-r", pcap_path, "-C"], capture_output=True, text=True, timeout=120, cwd=output_dir ) logs = list(Path(output_dir).glob("*.log")) return { "pcap": pcap_path, "output_dir": output_dir, "logs_generated": [l.name for l in logs], "exit_code": result.returncode, } except Exception as e: return {"error": str(e)} def generate_report(log_dir=None): """Generate comprehensive Zeek network analysis report.""" log_dir = log_dir or ZEEK_LOG_DIR return { "timestamp": datetime.utcnow().isoformat() + "Z", "status": check_zeek_status(), "connections": parse_conn_log(os.path.join(log_dir, "conn.log")), "dns": parse_dns_log(os.path.join(log_dir, "dns.log")), "tls": parse_ssl_log(os.path.join(log_dir, "ssl.log")), "beaconing": detect_beaconing(os.path.join(log_dir, "conn.log")), } if __name__ == "__main__": action = sys.argv[1] if len(sys.argv) > 1 else "report" log_dir = sys.argv[2] if len(sys.argv) > 2 else ZEEK_LOG_DIR if action == "report": print(json.dumps(generate_report(log_dir), indent=2, default=str)) elif action == "connections": print(json.dumps(parse_conn_log(os.path.join(log_dir, "conn.log")), indent=2)) elif action == "dns": print(json.dumps(parse_dns_log(os.path.join(log_dir, "dns.log")), indent=2)) elif action == "tls": print(json.dumps(parse_ssl_log(os.path.join(log_dir, "ssl.log")), indent=2)) elif action == "beaconing": print(json.dumps(detect_beaconing(os.path.join(log_dir, "conn.log")), indent=2)) elif action == "pcap" and len(sys.argv) > 2: print(json.dumps(analyze_pcap(sys.argv[2]), indent=2)) else: print("Usage: agent.py [report|connections|dns|tls|beaconing|pcap ] [log_dir]")