#!/usr/bin/env python3 """OT Network Security Assessment - Automated Discovery and Analysis. This script performs passive OT network discovery from pcap captures, classifies assets by Purdue level, maps industrial protocol usage, and identifies security findings. Usage: python process.py --pcap [--firewall-rules ] python process.py --live-capture --interface --duration """ import argparse import csv import json import sys from collections import defaultdict from dataclasses import dataclass, field, asdict from datetime import datetime from ipaddress import ip_address, ip_network from typing import Optional try: from scapy.all import rdpcap, sniff, IP, TCP, UDP, Ether, ARP except ImportError: print("[ERROR] scapy is required: pip install scapy") sys.exit(1) # ── Industrial Protocol Definitions ────────────────────────────────── OT_PROTOCOLS = { 502: {"name": "Modbus/TCP", "vendor": "Open", "risk": "high", "auth": False}, 102: {"name": "S7comm", "vendor": "Siemens", "risk": "high", "auth": False}, 44818: {"name": "EtherNet/IP", "vendor": "ODVA/Rockwell", "risk": "high", "auth": False}, 2222: {"name": "EtherNet/IP Implicit", "vendor": "ODVA", "risk": "medium", "auth": False}, 4840: {"name": "OPC UA", "vendor": "OPC Foundation", "risk": "low", "auth": True}, 20000: {"name": "DNP3", "vendor": "IEEE", "risk": "high", "auth": False}, 47808: {"name": "BACnet/IP", "vendor": "ASHRAE", "risk": "high", "auth": False}, 1911: {"name": "Niagara Fox", "vendor": "Tridium", "risk": "high", "auth": False}, 2404: {"name": "IEC 60870-5-104", "vendor": "IEC", "risk": "high", "auth": False}, 18245: {"name": "GE SRTP", "vendor": "GE", "risk": "high", "auth": False}, 5094: {"name": "HART-IP", "vendor": "FieldComm", "risk": "medium", "auth": False}, 789: {"name": "Crimson v3", "vendor": "Red Lion", "risk": "high", "auth": False}, 1089: {"name": "FF HSE", "vendor": "Fieldbus Foundation", "risk": "medium", "auth": False}, 9600: {"name": "OMRON FINS", "vendor": "OMRON", "risk": "high", "auth": False}, 5007: {"name": "Mitsubishi MELSEC", "vendor": "Mitsubishi", "risk": "high", "auth": False}, } # Modbus function codes with write capability MODBUS_WRITE_FUNCS = {5, 6, 15, 16, 22, 23} MODBUS_DIAGNOSTIC_FUNCS = {8, 17, 43} # ── Purdue Level Classification ───────────────────────────────────── @dataclass class PurdueConfig: """Configuration for Purdue level subnet mappings.""" level_0_1: list = field(default_factory=lambda: ["10.10.0.0/16", "192.168.10.0/24"]) level_2: list = field(default_factory=lambda: ["10.20.0.0/16", "192.168.20.0/24"]) level_3: list = field(default_factory=lambda: ["10.30.0.0/16", "192.168.30.0/24"]) level_3_5: list = field(default_factory=lambda: ["172.16.0.0/16"]) level_4: list = field(default_factory=lambda: ["10.0.0.0/8"]) def classify_purdue_level(ip_str, config=None): """Classify IP to Purdue level based on subnet mappings.""" if config is None: config = PurdueConfig() try: addr = ip_address(ip_str) except ValueError: return "Unknown" level_map = [ ("Level 0-1", config.level_0_1), ("Level 2", config.level_2), ("Level 3", config.level_3), ("Level 3.5", config.level_3_5), ("Level 4", config.level_4), ] for level_name, subnets in level_map: for subnet in subnets: if addr in ip_network(subnet, strict=False): return level_name return "Unknown" # ── Asset and Finding Models ───────────────────────────────────────── @dataclass class OTAsset: ip: str mac: str = "" purdue_level: str = "" protocols: list = field(default_factory=list) roles: list = field(default_factory=list) vendor_hint: str = "" first_seen: str = "" last_seen: str = "" ports_open: list = field(default_factory=list) @dataclass class Finding: finding_id: str severity: str # critical, high, medium, low title: str description: str affected_assets: list = field(default_factory=list) iec_62443_ref: str = "" nist_800_82_ref: str = "" remediation: str = "" # ── Passive Discovery Engine ───────────────────────────────────────── class OTNetworkDiscovery: """Passive OT network discovery and analysis engine.""" def __init__(self, purdue_config=None): self.config = purdue_config or PurdueConfig() self.assets = {} self.connections = defaultdict(lambda: { "count": 0, "protocols": set(), "ports": set(), "first_seen": None, "last_seen": None }) self.protocol_stats = defaultdict(int) self.findings = [] self.modbus_writes = [] self.cross_zone_flows = [] def process_packet(self, pkt): """Process a single packet for OT asset and protocol discovery.""" if not pkt.haslayer(IP): return src_ip = pkt[IP].src dst_ip = pkt[IP].dst timestamp = str(datetime.fromtimestamp(float(pkt.time))) # Discover or update assets for ip_addr in (src_ip, dst_ip): if ip_addr not in self.assets: mac = "" if pkt.haslayer(Ether): mac = pkt[Ether].src if ip_addr == src_ip else pkt[Ether].dst self.assets[ip_addr] = OTAsset( ip=ip_addr, mac=mac, purdue_level=classify_purdue_level(ip_addr, self.config), first_seen=timestamp, last_seen=timestamp, ) self.assets[ip_addr].last_seen = timestamp # Extract port information dst_port = src_port = None if pkt.haslayer(TCP): dst_port = pkt[TCP].dport src_port = pkt[TCP].sport elif pkt.haslayer(UDP): dst_port = pkt[UDP].dport src_port = pkt[UDP].sport # Identify OT protocols if dst_port in OT_PROTOCOLS: proto_info = OT_PROTOCOLS[dst_port] proto_name = proto_info["name"] self.protocol_stats[proto_name] += 1 asset_src = self.assets[src_ip] asset_dst = self.assets[dst_ip] if proto_name not in asset_src.protocols: asset_src.protocols.append(proto_name) if proto_name not in asset_dst.protocols: asset_dst.protocols.append(proto_name) if "master/client" not in asset_src.roles: asset_src.roles.append("master/client") if "slave/server" not in asset_dst.roles: asset_dst.roles.append("slave/server") if dst_port not in asset_dst.ports_open: asset_dst.ports_open.append(dst_port) if proto_info["vendor"] != "Open": asset_dst.vendor_hint = proto_info["vendor"] # Track Modbus write operations if dst_port == 502 and pkt.haslayer(TCP): payload = bytes(pkt[TCP].payload) if len(payload) > 7: func_code = payload[7] if func_code in MODBUS_WRITE_FUNCS: self.modbus_writes.append({ "timestamp": timestamp, "src": src_ip, "dst": dst_ip, "function_code": func_code, }) # Track connections conn_key = f"{src_ip}->{dst_ip}" conn = self.connections[conn_key] conn["count"] += 1 if conn["first_seen"] is None: conn["first_seen"] = timestamp conn["last_seen"] = timestamp if dst_port: conn["ports"].add(dst_port) if dst_port in OT_PROTOCOLS: conn["protocols"].add(OT_PROTOCOLS[dst_port]["name"]) # Detect cross-zone communication src_level = self.assets[src_ip].purdue_level dst_level = self.assets[dst_ip].purdue_level if src_level != dst_level and "Unknown" not in (src_level, dst_level): self.cross_zone_flows.append({ "src": src_ip, "src_level": src_level, "dst": dst_ip, "dst_level": dst_level, "port": dst_port, "protocol": OT_PROTOCOLS.get(dst_port, {}).get("name", f"port/{dst_port}"), "timestamp": timestamp, }) def analyze_pcap(self, pcap_file): """Analyze a pcap file for OT network discovery.""" print(f"[*] Loading pcap: {pcap_file}") packets = rdpcap(pcap_file) print(f"[*] Processing {len(packets)} packets...") for pkt in packets: self.process_packet(pkt) print(f"[*] Discovery complete: {len(self.assets)} assets found") def live_capture(self, interface, duration): """Perform live passive capture on a network interface.""" print(f"[*] Starting live capture on {interface} for {duration}s...") sniff(iface=interface, timeout=duration, prn=self.process_packet, store=0) print(f"[*] Capture complete: {len(self.assets)} assets found") def generate_findings(self): """Analyze discovered data and generate security findings.""" finding_counter = 1 # Finding: Direct enterprise-to-field communication for flow in self.cross_zone_flows: if "Level 4" in flow["src_level"] and "Level 0-1" in flow["dst_level"]: self.findings.append(Finding( finding_id=f"OT-{finding_counter:03d}", severity="critical", title="Direct Enterprise-to-Field Device Communication", description=( f"Traffic observed from {flow['src']} ({flow['src_level']}) " f"to {flow['dst']} ({flow['dst_level']}) via {flow['protocol']}. " "This bypasses all intermediate security zones." ), affected_assets=[flow["src"], flow["dst"]], iec_62443_ref="IEC 62443-3-3 SR 5.1 - Network Segmentation", nist_800_82_ref="NIST SP 800-82r3 Section 5.3 - Network Architecture", remediation="Block direct L4-to-L0/1 traffic. Route through DMZ and OT firewall.", )) finding_counter += 1 # Finding: Enterprise bypassing DMZ to reach control for flow in self.cross_zone_flows: if "Level 4" in flow["src_level"] and "Level 2" in flow["dst_level"]: self.findings.append(Finding( finding_id=f"OT-{finding_counter:03d}", severity="critical", title="Enterprise-to-Control Bypass of DMZ", description=( f"Traffic from {flow['src']} ({flow['src_level']}) reaches " f"{flow['dst']} ({flow['dst_level']}) without traversing DMZ." ), affected_assets=[flow["src"], flow["dst"]], iec_62443_ref="IEC 62443-3-3 SR 5.2 - Zone Boundary Protection", nist_800_82_ref="NIST SP 800-82r3 Section 5.4 - DMZ Architecture", remediation="Deploy DMZ between enterprise and control zones with bidirectional firewall.", )) finding_counter += 1 # Finding: Unauthenticated Modbus write operations if self.modbus_writes: unique_targets = set(w["dst"] for w in self.modbus_writes) self.findings.append(Finding( finding_id=f"OT-{finding_counter:03d}", severity="critical", title="Unauthenticated Modbus/TCP Write Commands Detected", description=( f"{len(self.modbus_writes)} Modbus write operations observed " f"targeting {len(unique_targets)} devices. Modbus/TCP has no " "native authentication; any network-connected device can modify registers." ), affected_assets=list(unique_targets), iec_62443_ref="IEC 62443-3-3 SR 1.1 - Human User Identification and Authentication", nist_800_82_ref="NIST SP 800-82r3 Section 6.2 - Access Control", remediation=( "Deploy Modbus-aware firewall (e.g., Tofino, Claroty Edge) to restrict " "write-capable source addresses. Implement allowlisting for Modbus function codes." ), )) finding_counter += 1 # Finding: Unauthenticated industrial protocols for port, info in OT_PROTOCOLS.items(): if not info["auth"] and info["name"] in self.protocol_stats: exposed_assets = [ ip for ip, asset in self.assets.items() if port in asset.ports_open ] if exposed_assets: self.findings.append(Finding( finding_id=f"OT-{finding_counter:03d}", severity="high", title=f"Unauthenticated {info['name']} Protocol in Use", description=( f"{len(exposed_assets)} devices expose {info['name']} (port {port}) " f"which lacks native authentication. Vendor: {info['vendor']}." ), affected_assets=exposed_assets, iec_62443_ref="IEC 62443-3-3 SR 1.2 - Software Process Identification", nist_800_82_ref="NIST SP 800-82r3 Section 6.2.1 - Protocol Security", remediation=f"Deploy protocol-aware firewall for {info['name']} traffic inspection.", )) finding_counter += 1 return self.findings def export_report(self, output_file): """Export full assessment results to JSON.""" report = { "assessment_date": datetime.now().isoformat(), "summary": { "total_assets": len(self.assets), "total_connections": len(self.connections), "protocols_detected": dict(self.protocol_stats), "cross_zone_flows": len(self.cross_zone_flows), "modbus_write_operations": len(self.modbus_writes), "findings_critical": sum(1 for f in self.findings if f.severity == "critical"), "findings_high": sum(1 for f in self.findings if f.severity == "high"), "findings_medium": sum(1 for f in self.findings if f.severity == "medium"), "findings_low": sum(1 for f in self.findings if f.severity == "low"), }, "assets": {ip: asdict(asset) for ip, asset in self.assets.items()}, "findings": [asdict(f) for f in self.findings], "cross_zone_flows": self.cross_zone_flows[:100], } with open(output_file, "w") as f: json.dump(report, f, indent=2, default=str) print(f"[*] Report saved to: {output_file}") def print_summary(self): """Print assessment summary to console.""" print("\n" + "=" * 70) print("OT NETWORK SECURITY ASSESSMENT - SUMMARY") print("=" * 70) print(f"\nAssets Discovered: {len(self.assets)}") level_counts = defaultdict(int) for asset in self.assets.values(): level_counts[asset.purdue_level] += 1 for level in sorted(level_counts.keys()): print(f" {level}: {level_counts[level]} devices") print(f"\nProtocol Distribution:") for proto, count in sorted(self.protocol_stats.items(), key=lambda x: -x[1]): print(f" {proto}: {count} packets") print(f"\nCross-Zone Flows: {len(self.cross_zone_flows)}") print(f"Modbus Write Operations: {len(self.modbus_writes)}") print(f"\nFindings:") severity_counts = defaultdict(int) for f in self.findings: severity_counts[f.severity] += 1 for sev in ["critical", "high", "medium", "low"]: if severity_counts[sev]: print(f" {sev.upper()}: {severity_counts[sev]}") if self.findings: print(f"\nTop Findings:") for f in self.findings[:10]: print(f" [{f.finding_id}] [{f.severity.upper()}] {f.title}") def main(): parser = argparse.ArgumentParser(description="OT Network Security Assessment Tool") parser.add_argument("--pcap", help="Path to pcap file for analysis") parser.add_argument("--live-capture", action="store_true", help="Perform live capture") parser.add_argument("--interface", help="Network interface for live capture") parser.add_argument("--duration", type=int, default=300, help="Capture duration in seconds") parser.add_argument("--output", default="ot_assessment_report.json", help="Output report file") args = parser.parse_args() discovery = OTNetworkDiscovery() if args.pcap: discovery.analyze_pcap(args.pcap) elif args.live_capture and args.interface: discovery.live_capture(args.interface, args.duration) else: parser.print_help() sys.exit(1) discovery.generate_findings() discovery.print_summary() discovery.export_report(args.output) if __name__ == "__main__": main()