Files

214 lines
7.5 KiB
Python

#!/usr/bin/env python3
"""Purdue model OT network segmentation audit agent.
Audits OT/ICS network segmentation against the Purdue Enterprise
Reference Architecture by testing connectivity between network zones,
verifying firewall rules, and mapping discovered hosts to Purdue levels.
"""
import argparse
import json
import os
import socket
import subprocess
import sys
from datetime import datetime, timezone
PURDUE_LEVELS = {
0: {"name": "Process", "description": "Sensors, actuators, field devices"},
1: {"name": "Basic Control", "description": "PLCs, RTUs, safety systems"},
2: {"name": "Area Supervisory", "description": "HMIs, SCADA, historian"},
3: {"name": "Site Operations", "description": "Patch mgmt, AV, file servers"},
3.5: {"name": "DMZ", "description": "Industrial DMZ between IT and OT"},
4: {"name": "Site Business", "description": "ERP, email, corporate apps"},
5: {"name": "Enterprise", "description": "Internet, cloud, remote access"},
}
PROHIBITED_FLOWS = [
{"from_level": 5, "to_level": 0, "description": "Internet to Process (critical violation)"},
{"from_level": 5, "to_level": 1, "description": "Internet to Basic Control"},
{"from_level": 5, "to_level": 2, "description": "Internet to SCADA"},
{"from_level": 4, "to_level": 0, "description": "Corporate to Process"},
{"from_level": 4, "to_level": 1, "description": "Corporate to PLC/RTU"},
]
def test_connectivity(source_ip, target_ip, ports, timeout=3):
"""Test TCP connectivity between two hosts on specified ports."""
results = []
for port in ports:
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(timeout)
result = sock.connect_ex((target_ip, port))
sock.close()
reachable = result == 0
results.append({
"target": target_ip,
"port": port,
"reachable": reachable,
})
except (socket.error, OSError):
results.append({"target": target_ip, "port": port, "reachable": False})
return results
def audit_zone_separation(zone_map):
"""Audit network segmentation between Purdue zones."""
findings = []
print("[*] Auditing zone separation...")
for flow in PROHIBITED_FLOWS:
from_level = flow["from_level"]
to_level = flow["to_level"]
from_hosts = zone_map.get(str(from_level), [])
to_hosts = zone_map.get(str(to_level), [])
for src in from_hosts[:3]:
for dst in to_hosts[:3]:
common_ports = [22, 80, 443, 502, 102, 44818, 47808, 20000]
results = test_connectivity(src, dst, common_ports)
open_ports = [r for r in results if r["reachable"]]
if open_ports:
findings.append({
"check": f"Zone {from_level} -> Zone {to_level}",
"severity": "CRITICAL",
"source": src,
"destination": dst,
"open_ports": [r["port"] for r in open_ports],
"detail": flow["description"],
"recommendation": "Block traffic between these zones via firewall",
})
if not findings:
findings.append({
"check": "Prohibited zone flows",
"severity": "INFO",
"detail": "No prohibited cross-zone connectivity detected",
})
return findings
def audit_ot_protocols(target_ips):
"""Check for exposed OT/ICS protocols on target hosts."""
findings = []
ot_ports = {
502: "Modbus TCP",
102: "S7comm (Siemens)",
44818: "EtherNet/IP",
47808: "BACnet",
20000: "DNP3",
4840: "OPC UA",
2222: "EtherCAT",
1911: "Niagara Fox",
9600: "OMRON FINS",
}
print(f"[*] Scanning {len(target_ips)} hosts for exposed OT protocols...")
for ip in target_ips:
for port, protocol in ot_ports.items():
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(2)
result = sock.connect_ex((ip, port))
sock.close()
if result == 0:
findings.append({
"check": f"Exposed OT protocol: {protocol}",
"severity": "HIGH",
"host": ip,
"port": port,
"protocol": protocol,
"detail": f"{protocol} on {ip}:{port} is accessible",
})
except (socket.error, OSError):
pass
print(f"[+] Found {len(findings)} exposed OT protocols")
return findings
def load_zone_map(config_path):
"""Load zone-to-host mapping from config file."""
with open(config_path, "r") as f:
return json.load(f)
def format_summary(zone_findings, protocol_findings, zone_map):
"""Print audit summary."""
all_findings = zone_findings + protocol_findings
print(f"\n{'='*60}")
print(f" Purdue Model Network Segmentation Audit")
print(f"{'='*60}")
for level, info in sorted(PURDUE_LEVELS.items()):
host_count = len(zone_map.get(str(level), []))
print(f" Level {level}: {info['name']:20s} ({host_count} hosts) - {info['description']}")
print(f"\n Zone Separation Findings : {len(zone_findings)}")
print(f" Protocol Exposure Findings: {len(protocol_findings)}")
severity_counts = {}
for f in all_findings:
sev = f.get("severity", "INFO")
severity_counts[sev] = severity_counts.get(sev, 0) + 1
if all_findings:
print(f"\n Critical/High Issues:")
for f in all_findings:
if f["severity"] in ("CRITICAL", "HIGH"):
print(f" [{f['severity']:8s}] {f['check']}: {f.get('detail', '')[:50]}")
return severity_counts
def main():
parser = argparse.ArgumentParser(
description="Purdue model OT network segmentation audit agent"
)
parser.add_argument("--zone-map", required=True,
help="JSON file mapping Purdue levels to host IPs")
parser.add_argument("--scan-protocols", action="store_true",
help="Scan for exposed OT protocols")
parser.add_argument("--output", "-o", help="Output JSON report")
parser.add_argument("--verbose", "-v", action="store_true")
args = parser.parse_args()
zone_map = load_zone_map(args.zone_map)
zone_findings = audit_zone_separation(zone_map)
protocol_findings = []
if args.scan_protocols:
all_hosts = []
for level_hosts in zone_map.values():
all_hosts.extend(level_hosts)
protocol_findings = audit_ot_protocols(list(set(all_hosts)))
severity_counts = format_summary(zone_findings, protocol_findings, zone_map)
report = {
"timestamp": datetime.now(timezone.utc).isoformat(),
"tool": "Purdue Model Audit",
"zone_map": zone_map,
"zone_findings": zone_findings,
"protocol_findings": protocol_findings,
"severity_counts": severity_counts,
"risk_level": (
"CRITICAL" if severity_counts.get("CRITICAL", 0) > 0
else "HIGH" if severity_counts.get("HIGH", 0) > 0
else "LOW"
),
}
if args.output:
with open(args.output, "w") as f:
json.dump(report, f, indent=2)
print(f"\n[+] Report saved to {args.output}")
elif args.verbose:
print(json.dumps(report, indent=2))
if __name__ == "__main__":
main()