#!/usr/bin/env python3 """OAuth token theft detection agent. Analyzes sign-in logs for impossible travel, new device sign-ins, token replay from unusual IPs, and anomalous scope requests. """ import argparse import json import math import sys import datetime import collections try: import requests HAS_REQUESTS = True except ImportError: HAS_REQUESTS = False EARTH_RADIUS_KM = 6371 def haversine(lat1, lon1, lat2, lon2): """Calculate great-circle distance between two points in km.""" lat1, lon1, lat2, lon2 = map(math.radians, [lat1, lon1, lat2, lon2]) dlat = lat2 - lat1 dlon = lon2 - lon1 a = math.sin(dlat / 2) ** 2 + math.cos(lat1) * math.cos(lat2) * math.sin(dlon / 2) ** 2 return 2 * EARTH_RADIUS_KM * math.asin(math.sqrt(a)) def detect_impossible_travel(sign_ins, max_speed_kmh=900): """Detect impossible travel based on geo and time between logins.""" alerts = [] by_user = collections.defaultdict(list) for event in sign_ins: by_user[event.get("user", "")].append(event) for user, events in by_user.items(): sorted_events = sorted(events, key=lambda e: e.get("timestamp", "")) for i in range(1, len(sorted_events)): prev, curr = sorted_events[i - 1], sorted_events[i] if not all(k in prev for k in ("lat", "lon")) or not all(k in curr for k in ("lat", "lon")): continue dist = haversine(prev["lat"], prev["lon"], curr["lat"], curr["lon"]) try: t1 = datetime.datetime.fromisoformat(prev["timestamp"].replace("Z", "+00:00")) t2 = datetime.datetime.fromisoformat(curr["timestamp"].replace("Z", "+00:00")) hours = max((t2 - t1).total_seconds() / 3600, 0.001) except (ValueError, KeyError): continue speed = dist / hours if speed > max_speed_kmh and dist > 100: alerts.append({ "type": "impossible_travel", "user": user, "from_ip": prev.get("ip", ""), "to_ip": curr.get("ip", ""), "distance_km": round(dist, 1), "time_hours": round(hours, 2), "speed_kmh": round(speed, 1), "severity": "HIGH", }) return alerts def detect_token_replay(sign_ins): """Detect token replay from multiple IPs in short timeframe.""" alerts = [] by_user = collections.defaultdict(list) for event in sign_ins: by_user[event.get("user", "")].append(event) for user, events in by_user.items(): sorted_events = sorted(events, key=lambda e: e.get("timestamp", "")) window = [] for event in sorted_events: try: ts = datetime.datetime.fromisoformat(event["timestamp"].replace("Z", "+00:00")) except (ValueError, KeyError): continue window = [e for e in window if (ts - datetime.datetime.fromisoformat( e["timestamp"].replace("Z", "+00:00"))).total_seconds() < 300] window.append(event) unique_ips = set(e.get("ip") for e in window if e.get("ip")) if len(unique_ips) >= 3: alerts.append({ "type": "token_replay", "user": user, "ips": list(unique_ips), "window_seconds": 300, "severity": "CRITICAL", }) return alerts def detect_new_device(sign_ins, known_devices=None): """Detect sign-ins from previously unseen devices.""" known = set(known_devices or []) alerts = [] for event in sign_ins: device_id = event.get("device_id", event.get("user_agent", "")) if device_id and device_id not in known: alerts.append({ "type": "new_device", "user": event.get("user", ""), "device": device_id, "ip": event.get("ip", ""), "timestamp": event.get("timestamp", ""), "severity": "MEDIUM", }) known.add(device_id) return alerts def detect_suspicious_scopes(sign_ins): """Detect OAuth requests with overly broad or sensitive scopes.""" sensitive_scopes = { "Mail.ReadWrite", "Mail.Send", "Files.ReadWrite.All", "Directory.ReadWrite.All", "User.ReadWrite.All", "Application.ReadWrite.All", "RoleManagement.ReadWrite.Directory", } alerts = [] for event in sign_ins: scopes = set(event.get("scopes", [])) dangerous = scopes & sensitive_scopes if len(dangerous) >= 2: alerts.append({ "type": "suspicious_scopes", "user": event.get("user", ""), "scopes": list(dangerous), "app": event.get("app_name", ""), "severity": "HIGH", }) return alerts def main(): parser = argparse.ArgumentParser(description="OAuth token theft detection agent") parser.add_argument("--log-file", help="JSON file with sign-in events") parser.add_argument("--max-speed", type=int, default=900, help="Max travel speed km/h (default: 900)") parser.add_argument("--output", "-o", help="Output JSON report path") args = parser.parse_args() print("[*] OAuth Token Theft Detection Agent") report = {"timestamp": datetime.datetime.utcnow().isoformat() + "Z", "alerts": []} if args.log_file: with open(args.log_file) as f: sign_ins = json.load(f) else: sign_ins = [ {"user": "alice@corp.com", "ip": "203.0.113.10", "lat": 40.7128, "lon": -74.0060, "timestamp": "2025-06-15T10:00:00Z", "device_id": "device-A"}, {"user": "alice@corp.com", "ip": "198.51.100.50", "lat": 51.5074, "lon": -0.1278, "timestamp": "2025-06-15T10:30:00Z", "device_id": "device-B"}, {"user": "bob@corp.com", "ip": "10.0.0.1", "lat": 37.7749, "lon": -122.4194, "timestamp": "2025-06-15T09:00:00Z", "device_id": "device-C", "scopes": ["Mail.ReadWrite", "Mail.Send", "Files.ReadWrite.All"]}, ] print("[DEMO] Using sample sign-in events") report["alerts"].extend(detect_impossible_travel(sign_ins, args.max_speed)) report["alerts"].extend(detect_token_replay(sign_ins)) report["alerts"].extend(detect_new_device(sign_ins)) report["alerts"].extend(detect_suspicious_scopes(sign_ins)) by_type = collections.Counter(a["type"] for a in report["alerts"]) print(f"[*] Total alerts: {len(report['alerts'])}") for alert_type, count in by_type.items(): print(f" {alert_type}: {count}") for a in report["alerts"]: print(f" [{a['severity']}] {a['type']}: {a.get('user', '')} - {a.get('distance_km', a.get('ips', a.get('device', '')))}") if args.output: with open(args.output, "w") as f: json.dump(report, f, indent=2) print(json.dumps({"total_alerts": len(report["alerts"]), "by_type": dict(by_type)}, indent=2)) if __name__ == "__main__": main()