diff --git a/payload/user/remote_access/pager-webui/server.py b/payload/user/remote_access/pager-webui/server.py index c4d1d32..70cadb7 100644 --- a/payload/user/remote_access/pager-webui/server.py +++ b/payload/user/remote_access/pager-webui/server.py @@ -1158,6 +1158,12 @@ OUI_VENDORS = { 'FC633E': 'Google', 'FCF080': 'Apple', } +OUI_DATA_PATHS = [ + '/usr/share/nmap/nmap-mac-prefixes', + '/usr/share/macchanger/OUI.list', +] +_oui_identity_cache = None + def _oui_prefix(mac): """'C8:9E:43:64:80:80' / 'C89E43648080' -> 'C89E43' (uppercase, no colons).""" @@ -1179,6 +1185,41 @@ def oui_vendor(mac): return OUI_VENDORS.get(prefix, 'Unknown') +def oui_identity(mac): + """Resolve a MAC to a JSON-safe manufacturer identity.""" + global _oui_identity_cache + prefix = _oui_prefix(mac) + if prefix is None: + return {'manufacturer': 'Unknown', 'model': None, 'oui': None, + 'source': 'unknown'} + if int(prefix[1], 16) & 2: + return {'manufacturer': 'Local/Randomized', 'model': None, 'oui': prefix, + 'source': 'local'} + + if _oui_identity_cache is None: + _oui_identity_cache = {} + for path, source in zip(OUI_DATA_PATHS, ('nmap', 'macchanger')): + try: + with open(path, 'r') as data_file: + for line in data_file: + match = re.match(r'^\s*([0-9A-Fa-f:.-]+)\s+(.+)$', line) + if not match: + continue + line_prefix = _oui_prefix(match.group(1)) + if line_prefix and line_prefix not in _oui_identity_cache: + name = match.group(2).strip() + if name: + _oui_identity_cache[line_prefix] = (name, source) + except (OSError, IOError): + continue + + manufacturer, source = _oui_identity_cache.get( + prefix, (OUI_VENDORS.get(prefix, 'Unknown'), + 'builtin' if prefix in OUI_VENDORS else 'unknown')) + return {'manufacturer': manufacturer, 'model': None, 'oui': prefix, + 'source': source} + + def band_of(freq): """Channel frequency (MHz) -> '2.4' | '5' | '6' | '--'.""" if freq is None: diff --git a/tests/test_recon.py b/tests/test_recon.py index 420d814..5cb3320 100644 --- a/tests/test_recon.py +++ b/tests/test_recon.py @@ -896,6 +896,58 @@ class OuiVendorTest(unittest.TestCase): self.assertEqual(server.oui_vendor(None), 'Unknown') self.assertEqual(server.oui_vendor('--'), 'Unknown') + def test_oui_identity_prefers_nmap_then_macchanger(self): + server._oui_identity_cache = None + files = { + '/nmap': 'C89E43 Apple Corporation\n', + '/mac': 'C89E43 fallback\n', + } + with mock.patch.object(server, 'OUI_DATA_PATHS', ['/nmap', '/mac']), \ + mock.patch('builtins.open', side_effect=lambda p, *a, **k: + mock.mock_open(read_data=files[p]).return_value): + value = server.oui_identity('C89E43648080') + self.assertEqual(value['manufacturer'], 'Apple Corporation') + self.assertEqual(value['source'], 'nmap') + self.assertIsNone(value['model']) + + def test_oui_identity_uses_macchanger_when_nmap_is_missing(self): + server._oui_identity_cache = None + files = {'/mac': 'C8-9E-43 fallback\n'} + + def open_file(path, *args, **kwargs): + if path not in files: + raise OSError('missing') + return mock.mock_open(read_data=files[path]).return_value + + with mock.patch.object(server, 'OUI_DATA_PATHS', ['/nmap', '/mac']), \ + mock.patch('builtins.open', side_effect=open_file): + value = server.oui_identity('C89E43648080') + self.assertEqual(value['manufacturer'], 'fallback') + self.assertEqual(value['source'], 'macchanger') + + def test_oui_identity_handles_missing_files_unknown_and_local(self): + server._oui_identity_cache = None + with mock.patch.object(server, 'OUI_DATA_PATHS', ['/missing']), \ + mock.patch('builtins.open', side_effect=OSError('missing')): + unknown = server.oui_identity('AC:BB:CC:00:00:01') + self.assertEqual(unknown['manufacturer'], 'Unknown') + self.assertEqual(unknown['source'], 'unknown') + self.assertEqual(unknown['oui'], 'ACBBCC') + self.assertIsNone(unknown['model']) + + local = server.oui_identity('02:11:22:33:44:55') + self.assertEqual(local['manufacturer'], 'Local/Randomized') + self.assertEqual(local['source'], 'local') + self.assertEqual(local['oui'], '021122') + + def test_oui_identity_falls_back_to_builtin_vendors(self): + server._oui_identity_cache = None + with mock.patch.object(server, 'OUI_DATA_PATHS', []): + value = server.oui_identity('B8:27:EB:00:00:00') + self.assertEqual(value['manufacturer'], 'Raspberry Pi') + self.assertEqual(value['source'], 'builtin') + self.assertIsNone(value['model']) + def test_band_of_frequencies(self): self.assertEqual(server.band_of(2412), '2.4') self.assertEqual(server.band_of(5200), '5')