ap_scanner/oui.py (6964B)
1 """OUI (Organizationally Unique Identifier) vendor lookup from MAC address."""
2
3 from pathlib import Path
4
5 # Common WiFi AP/router vendors - covers most consumer and enterprise APs
6 # Format: first 3 bytes of MAC (uppercase, colon-separated) -> vendor name
7 _BUILTIN_OUI: dict[str, str] = {
8 # TP-Link
9 "5C:A6:E6": "TP-Link", "E8:48:B8": "TP-Link", "50:C7:BF": "TP-Link",
10 "98:DA:C4": "TP-Link", "B0:BE:76": "TP-Link", "60:32:B1": "TP-Link",
11 "A4:2B:B0": "TP-Link", "30:DE:4B": "TP-Link", "C0:06:C3": "TP-Link",
12 "F4:EC:38": "TP-Link", "AC:84:C6": "TP-Link", "14:EB:B6": "TP-Link",
13 "EC:08:6B": "TP-Link", "78:8C:B5": "TP-Link", "B4:B0:24": "TP-Link",
14 # Netgear
15 "34:98:B5": "NETGEAR", "C4:04:15": "NETGEAR", "20:0C:C8": "NETGEAR",
16 "A0:40:A0": "NETGEAR", "E0:46:9A": "NETGEAR", "28:C6:8E": "NETGEAR",
17 "6C:B0:CE": "NETGEAR", "B0:7F:B9": "NETGEAR", "DC:EF:09": "NETGEAR",
18 # Cisco / Meraki
19 "00:1B:2A": "Cisco", "00:26:0B": "Cisco", "F8:C2:88": "Cisco Meraki",
20 "0C:8D:DB": "Cisco Meraki", "AC:17:C8": "Cisco Meraki", "34:56:FE": "Cisco",
21 "00:18:74": "Cisco", "00:1A:A1": "Cisco", "B0:AA:77": "Cisco",
22 # Aruba / HPE
23 "00:0B:86": "Aruba Networks", "24:DE:C6": "Aruba Networks",
24 "AC:A3:1E": "Aruba Networks", "D8:C7:C8": "Aruba Networks",
25 "9C:1C:12": "Aruba Networks", "70:3A:0E": "Aruba Networks",
26 # Ubiquiti
27 "24:A4:3C": "Ubiquiti", "FC:EC:DA": "Ubiquiti", "68:D7:9A": "Ubiquiti",
28 "80:2A:A8": "Ubiquiti", "F0:9F:C2": "Ubiquiti", "44:D9:E7": "Ubiquiti",
29 "78:8A:20": "Ubiquiti", "18:E8:29": "Ubiquiti", "74:83:C2": "Ubiquiti",
30 # Ruckus
31 "C4:10:8A": "Ruckus", "EC:58:EA": "Ruckus", "74:91:1A": "Ruckus",
32 "A4:12:42": "Ruckus", "58:B6:33": "Ruckus",
33 # Samsung
34 "A8:7C:01": "Samsung", "F4:42:8F": "Samsung", "C0:BD:D1": "Samsung",
35 "5C:3A:45": "Samsung", "B4:3A:28": "Samsung", "D0:22:BE": "Samsung",
36 "78:BD:BC": "Samsung", "CC:07:AB": "Samsung", "58:CB:52": "Samsung",
37 # Apple
38 "00:1C:B3": "Apple", "28:CF:E9": "Apple", "3C:06:30": "Apple",
39 "70:3E:AC": "Apple", "AC:BC:32": "Apple", "F0:D1:A9": "Apple",
40 "14:7D:DA": "Apple", "18:AF:61": "Apple", "D0:E1:40": "Apple",
41 "A8:8E:24": "Apple", "F8:FF:C2": "Apple", "DC:A9:04": "Apple",
42 # Intel
43 "8C:17:59": "Intel", "14:85:7F": "Intel", "A0:C5:89": "Intel",
44 "68:05:CA": "Intel", "34:13:E8": "Intel", "80:86:F2": "Intel",
45 "48:51:B7": "Intel", "7C:B2:7D": "Intel",
46 # Huawei / Honor
47 "00:E0:FC": "Huawei", "48:46:FB": "Huawei", "88:28:B3": "Huawei",
48 "C8:D1:5E": "Huawei", "04:F9:38": "Huawei", "4C:B1:6C": "Huawei",
49 "70:8C:B6": "Huawei", "AC:61:EA": "Huawei",
50 # ASUS
51 "F8:32:E4": "ASUS", "04:D9:F5": "ASUS", "2C:FD:A1": "ASUS",
52 "1C:87:2C": "ASUS", "38:D5:47": "ASUS", "60:45:CB": "ASUS",
53 "AC:22:0B": "ASUS", "B0:6E:BF": "ASUS",
54 # D-Link
55 "00:1C:F0": "D-Link", "1C:7E:E5": "D-Link", "28:10:7B": "D-Link",
56 "84:C9:B2": "D-Link", "C8:D3:A3": "D-Link", "FC:75:16": "D-Link",
57 "B8:A3:86": "D-Link",
58 # Linksys / Belkin
59 "C0:56:27": "Linksys", "20:AA:4B": "Linksys", "58:6D:8F": "Linksys",
60 "E8:9F:80": "Belkin", "94:44:52": "Belkin", "08:86:3B": "Belkin",
61 # EFM Networks (ipTIME)
62 "70:5D:CC": "EFM Networks", "58:86:94": "EFM Networks",
63 "88:36:6C": "EFM Networks", "00:08:9F": "EFM Networks",
64 "90:9F:33": "EFM Networks", "00:26:66": "EFM Networks",
65 "C8:3A:35": "EFM Networks", "04:BD:70": "EFM Networks",
66 # LG
67 "00:1E:75": "LG Electronics", "10:68:3F": "LG Electronics",
68 "64:99:68": "LG Electronics", "C4:36:6C": "LG Electronics",
69 "A8:23:FE": "LG Electronics",
70 # OHSUNG (LG IoT devices)
71 "1C:39:29": "OHSUNG",
72 # Fortinet
73 "E8:1C:BA": "Fortinet", "00:09:0F": "Fortinet", "08:5B:0E": "Fortinet",
74 # Xiaomi
75 "28:6C:07": "Xiaomi", "64:CC:2E": "Xiaomi", "78:11:DC": "Xiaomi",
76 "50:EC:50": "Xiaomi", "7C:1D:D9": "Xiaomi",
77 # Qualcomm / Mediatek (chipset vendors)
78 "00:03:7F": "Atheros", "00:1A:6B": "Atheros",
79 "00:0C:E7": "MediaTek", "00:0C:43": "Ralink",
80 # Realtek
81 "00:E0:4C": "Realtek", "48:5D:60": "Realtek", "52:54:00": "Realtek",
82 # Broadcom
83 "00:10:18": "Broadcom", "00:1A:2B": "Broadcom",
84 # Google
85 "F4:F5:D8": "Google", "54:60:09": "Google", "A4:77:33": "Google",
86 # Amazon (eero, Ring, etc.)
87 "F0:72:EA": "Amazon", "68:54:FD": "Amazon", "40:B4:CD": "Amazon",
88 # Seongji Industry
89 "88:57:1D": "Seongji Industry",
90 # SHENZHEN GONGJIN (ZTE ODM)
91 "80:CA:4B": "SHENZHEN GONGJIN ELECTRONICS",
92 # Alfa Network
93 "00:C0:CA": "Alfa Network",
94 }
95
96 # Loaded entries from system OUI file
97 _loaded_oui: dict[str, str] = {}
98 _oui_loaded = False
99
100
101 def _load_system_oui() -> None:
102 """Try to load the system OUI database (e.g., from aircrack-ng or wireshark)."""
103 global _oui_loaded, _loaded_oui
104 if _oui_loaded:
105 return
106 _oui_loaded = True
107
108 oui_paths = [
109 Path("/usr/share/ieee-data/oui.txt"),
110 Path("/usr/share/misc/oui.txt"),
111 Path("/usr/share/wireshark/manuf"),
112 Path("/usr/share/aircrack-ng/airodump-ng-oui.txt"),
113 Path("/etc/aircrack-ng/airodump-ng-oui.txt"),
114 ]
115
116 for oui_path in oui_paths:
117 if oui_path.exists():
118 try:
119 _parse_oui_file(oui_path)
120 return
121 except Exception:
122 continue
123
124
125 def _parse_oui_file(path: Path) -> None:
126 """Parse an OUI file (IEEE format or Wireshark manuf format)."""
127 with open(path, "r", encoding="utf-8", errors="replace") as f:
128 for line in f:
129 line = line.strip()
130 if not line or line.startswith("#"):
131 continue
132
133 # IEEE format: "AA-BB-CC (hex) Vendor Name"
134 if "(hex)" in line:
135 parts = line.split("(hex)")
136 if len(parts) == 2:
137 oui = parts[0].strip().replace("-", ":").upper()
138 vendor = parts[1].strip()
139 if len(oui) == 8 and vendor:
140 _loaded_oui[oui] = vendor
141
142 # Wireshark manuf format: "AA:BB:CC<tab>ShortName<tab>Long Name"
143 elif "\t" in line:
144 parts = line.split("\t")
145 if len(parts) >= 2:
146 oui = parts[0].strip().upper().replace("-", ":")
147 vendor = parts[-1].strip() if len(parts) >= 3 else parts[1].strip()
148 if len(oui) == 8 and vendor:
149 _loaded_oui[oui] = vendor
150
151
152 def lookup_vendor(mac: str) -> str:
153 """Look up vendor name from MAC address.
154
155 Args:
156 mac: MAC address in format "AA:BB:CC:DD:EE:FF"
157
158 Returns:
159 Vendor name or empty string if not found.
160 """
161 oui = mac[:8].upper()
162
163 # Check builtin first (curated, clean names)
164 if oui in _BUILTIN_OUI:
165 return _BUILTIN_OUI[oui]
166
167 # Try system OUI database
168 _load_system_oui()
169 if oui in _loaded_oui:
170 return _loaded_oui[oui]
171
172 return ""