#!/usr/bin/env python3 """Historian server attack detection agent for ICS/SCADA environments.""" import json import os import sys import argparse import socket from datetime import datetime try: import requests except ImportError: print("Install: pip install requests") sys.exit(1) HISTORIAN_PORTS = { 5450: "OSIsoft PI AF", 5457: "OSIsoft PI Data Archive", 5459: "OSIsoft PI Web API", 1433: "SQL Server (Wonderware/FactoryTalk)", 3306: "MySQL (Ignition)", 8088: "Ignition Gateway", 443: "HTTPS (PI Web API / Ignition)", } def scan_historian_ports(host): """Scan for exposed historian service ports.""" results = [] for port, service in HISTORIAN_PORTS.items(): try: sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.settimeout(3) status = sock.connect_ex((host, port)) == 0 sock.close() result = {"host": host, "port": port, "service": service, "open": status} if status: result["finding"] = f"Historian port {port} ({service}) accessible" result["severity"] = "HIGH" results.append(result) except socket.error: pass return results def check_pi_web_api(host, username=None, password=None): """Check OSIsoft PI Web API for authentication and configuration issues.""" base = f"https://{host}/piwebapi" auth = (username, password) if username else None results = {"host": host, "checks": []} try: resp = requests.get(f"{base}/system", auth=auth, verify=not os.environ.get("SKIP_TLS_VERIFY", "").lower() == "true", timeout=10) # Set SKIP_TLS_VERIFY=true for self-signed certs in lab environments if resp.status_code == 200: data = resp.json() results["product_version"] = data.get("ProductTitle", "") results["checks"].append({ "check": "PI Web API accessible", "status": "PASS" if auth else "FAIL", "detail": "Anonymous access enabled" if not auth and resp.status_code == 200 else "", "severity": "CRITICAL" if not auth else "INFO", }) except requests.exceptions.ConnectionError: results["checks"].append({"check": "PI Web API", "status": "UNREACHABLE"}) except Exception as e: results["error"] = str(e) try: resp = requests.get(f"{base}/points", auth=auth, verify=not os.environ.get("SKIP_TLS_VERIFY", "").lower() == "true", params={"maxCount": 10}, timeout=10) if resp.status_code == 200: points = resp.json().get("Items", []) results["exposed_points"] = len(points) results["sample_points"] = [p.get("Name", "") for p in points[:5]] if not auth: results["checks"].append({ "check": "Point data accessible without auth", "status": "FAIL", "severity": "CRITICAL", }) except Exception: pass return results def check_ignition_gateway(host, port=8088): """Check Inductive Automation Ignition gateway status.""" results = {"host": host, "port": port} try: resp = requests.get(f"http://{host}:{port}/StatusPing", timeout=10) if resp.status_code == 200: results["gateway_accessible"] = True results["response"] = resp.text[:200] resp2 = requests.get(f"http://{host}:{port}/system/gwinfo", timeout=10) if resp2.status_code == 200: results["gateway_info_exposed"] = True results["finding"] = "Ignition gateway info page accessible" results["severity"] = "HIGH" except Exception as e: results["error"] = str(e) return results def analyze_historian_logs(log_entries): """Analyze historian access logs for attack indicators.""" findings = [] failed_logins = {} bulk_reads = {} for entry in log_entries: if entry.get("event_type") == "login_failed": src = entry.get("src_ip", "") failed_logins[src] = failed_logins.get(src, 0) + 1 if entry.get("event_type") == "data_read" and entry.get("point_count", 0) > 1000: src = entry.get("src_ip", "") bulk_reads[src] = bulk_reads.get(src, 0) + entry["point_count"] for ip, count in failed_logins.items(): if count > 5: findings.append({ "ip": ip, "issue": f"Brute force attempt: {count} failed logins", "severity": "HIGH", }) for ip, points in bulk_reads.items(): if points > 10000: findings.append({ "ip": ip, "issue": f"Bulk data exfiltration: {points} points read", "severity": "CRITICAL", }) return findings def run_audit(args): """Execute historian server attack detection audit.""" print(f"\n{'='*60}") print(f" HISTORIAN SERVER ATTACK DETECTION") print(f" Generated: {datetime.utcnow().isoformat()} UTC") print(f"{'='*60}\n") report = {} if args.host: port_scan = scan_historian_ports(args.host) open_ports = [p for p in port_scan if p.get("open")] report["port_scan"] = port_scan print(f"--- HISTORIAN PORT SCAN ({args.host}) ---") for p in open_ports: print(f" [{p.get('severity','INFO')}] Port {p['port']}: {p['service']}") if not open_ports: print(" No historian ports detected") if args.pi_host: pi = check_pi_web_api(args.pi_host, args.pi_user, args.pi_pass) report["pi_web_api"] = pi print(f"\n--- PI WEB API CHECK ---") for c in pi.get("checks", []): print(f" [{c.get('severity','INFO')}] {c['check']}: {c['status']}") if args.ignition_host: ign = check_ignition_gateway(args.ignition_host, args.ignition_port or 8088) report["ignition_gateway"] = ign print(f"\n--- IGNITION GATEWAY CHECK ---") print(f" Accessible: {ign.get('gateway_accessible', False)}") if ign.get("finding"): print(f" [{ign['severity']}] {ign['finding']}") return report def main(): parser = argparse.ArgumentParser(description="Historian Attack Detection Agent") parser.add_argument("--host", help="Historian server to scan") parser.add_argument("--pi-host", help="OSIsoft PI Web API host") parser.add_argument("--pi-user", help="PI username") parser.add_argument("--pi-pass", help="PI password") parser.add_argument("--ignition-host", help="Ignition gateway host") parser.add_argument("--ignition-port", type=int, default=8088) parser.add_argument("--output", help="Save report to JSON file") args = parser.parse_args() report = run_audit(args) if args.output: with open(args.output, "w") as f: json.dump(report, f, indent=2, default=str) print(f"\n[+] Report saved to {args.output}") if __name__ == "__main__": main()