import sqlite3 import tempfile import unittest from pathlib import Path from datetime import datetime, timedelta, timezone from librenet_scanner.comparison import compare_hosts from librenet_scanner.identity import ( is_known_virtual_mac, mac_identity_kind, shared_macs, ) from librenet_scanner.models import Host, PortInfo from librenet_scanner.parsers import parse_nmap_xml from librenet_scanner.network import NetworkInterface, scan_identity_scope from librenet_scanner.storage import HistoryStore def ports(*values: int) -> list[PortInfo]: return [PortInfo(port=value, service="test") for value in values] class Identity049Tests(unittest.TestCase): def setUp(self): self.tmp = tempfile.TemporaryDirectory() self.store = HistoryStore(Path(self.tmp.name) / "history.sqlite3") def tearDown(self): self.tmp.cleanup() def test_dhcp_ip_reuse_with_new_mac_does_not_inherit_identification(self): old = Host( ip="192.168.10.50", mac="00:11:22:33:44:55", hostname="oldpc.local", os_name="Windows 11", os_accuracy=100, ports=ports(135, 445), ) self.store.remember_identifications([old], "Approfondi") new = Host( ip="192.168.10.50", mac="00:11:22:AA:BB:CC", hostname="newpc.local", ports=ports(22, 80), ) self.store.apply_identifications([new]) self.assertEqual(new.remembered_os_name, "") self.assertEqual(new.remembered_device_type, "") def test_global_mac_follows_dhcp_ip_change(self): old = Host( ip="192.168.10.50", mac="00:11:22:33:44:55", hostname="server.local", os_name="Debian 13", os_accuracy=100, ports=ports(22, 80, 443), ) self.store.remember_identifications([old], "Approfondi") current = Host( ip="192.168.10.73", mac="00:11:22:33:44:55", hostname="server.local", ports=ports(22, 80), ) self.store.apply_identifications([current]) self.assertEqual(current.remembered_os_name, "Debian 13") self.assertGreaterEqual(current.remembered_match_score, 95) def test_stable_laa_can_follow_ip_change_only_with_supporting_fingerprint(self): old = Host( ip="192.168.10.250", mac="A6:2B:B0:A5:49:A7", os_name="OpenWrt 21.02 (Linux 5.4)", os_accuracy=98, ports=ports(22, 53, 80, 443), ) self.store.remember_identifications([old], "Approfondi") current = Host( ip="192.168.10.249", mac="A6:2B:B0:A5:49:A7", ports=ports(22, 53, 80, 443), ) self.store.apply_identifications([current]) self.assertEqual(current.remembered_os_name, "OpenWrt 21.02 (Linux 5.4)") self.assertEqual(current.remembered_identity_kind, "laa") self.assertGreaterEqual(current.remembered_match_score, 85) def test_laa_alone_is_not_enough_after_ip_change(self): old = Host( ip="192.168.10.20", mac="A6:00:00:00:00:01", os_name="Android", ports=[], ) self.store.remember_identifications([old], "Approfondi") current = Host(ip="192.168.10.21", mac="A6:00:00:00:00:01") self.store.apply_identifications([current]) self.assertEqual(current.remembered_os_name, "") def test_changed_randomized_laa_does_not_link_by_hostname(self): old = Host( ip="192.168.10.20", mac="A6:00:00:00:00:01", hostname="phone.local", os_name="Linux", ports=ports(1234, 5678), ) self.store.remember_identifications([old], "Approfondi") current = Host( ip="192.168.10.21", mac="B2:00:00:00:00:02", hostname="phone.local", ports=ports(1234, 5678), ) self.store.apply_identifications([current]) self.assertEqual(current.remembered_os_name, "") def test_no_mac_never_inherits_from_mac_record_using_ip_only(self): old = Host( ip="192.168.20.5", mac="00:AA:BB:CC:DD:EE", hostname="router.local", os_name="OpenWrt 24.10", ports=ports(22, 80), ) self.store.remember_identifications([old], "Approfondi") current = Host(ip="192.168.20.5", ports=ports(22, 80)) self.store.apply_identifications([current]) self.assertEqual(current.remembered_os_name, "") def test_proxy_arp_or_shared_mac_is_scoped_per_ip(self): mac = "00:11:22:33:44:55" a = Host(ip="192.168.10.20", mac=mac, hostname="a.local", os_name="Debian 13", ports=ports(22, 80)) b = Host(ip="192.168.10.21", mac=mac, hostname="b.local", os_name="OpenWrt 24.10", ports=ports(22, 443)) self.assertEqual(shared_macs([a, b]), {mac}) self.store.remember_identifications([a, b], "Approfondi") cur_a = Host(ip="192.168.10.20", mac=mac, hostname="a.local", ports=ports(22, 80)) cur_b = Host(ip="192.168.10.21", mac=mac, hostname="b.local", ports=ports(22, 443)) self.store.apply_identifications([cur_a, cur_b]) self.assertEqual(cur_a.remembered_os_name, "Debian 13") self.assertEqual(cur_b.remembered_os_name, "OpenWrt 24.10") self.assertEqual(cur_a.remembered_identity_kind, "shared") def test_vrrp_carp_and_hsrp_macs_are_marked_virtual(self): self.assertTrue(is_known_virtual_mac("00:00:5E:00:01:42")) self.assertTrue(is_known_virtual_mac("00:00:5E:00:02:42")) self.assertTrue(is_known_virtual_mac("00:00:0C:07:AC:01")) self.assertTrue(is_known_virtual_mac("00:00:0C:9F:F1:23")) self.assertEqual(mac_identity_kind("00:00:5E:00:01:42"), "virtual") def test_virtual_mac_does_not_follow_to_another_ip(self): old = Host( ip="192.168.10.1", mac="00:00:5E:00:01:01", hostname="gateway.local", os_name="OPNsense 26", ports=ports(53, 443), ) self.store.remember_identifications([old], "Approfondi") current = Host( ip="192.168.10.2", mac="00:00:5E:00:01:01", hostname="gateway.local", ports=ports(53, 443), ) self.store.apply_identifications([current]) self.assertEqual(current.remembered_os_name, "") def test_reinstall_or_upgrade_replaces_equally_strong_old_os(self): host = Host( ip="192.168.10.20", mac="00:11:22:33:44:55", os_name="Debian 12", os_accuracy=100, ports=ports(22, 80), ) self.store.remember_identifications([host], "Approfondi") host.os_name = "Debian 13" host.os_accuracy = 100 self.store.remember_identifications([host], "Approfondi") current = Host(ip="192.168.10.20", mac=host.mac, ports=ports(22, 80)) self.store.apply_identifications([current]) self.assertEqual(current.remembered_os_name, "Debian 13") def test_bond_or_bridge_mac_change_is_not_auto_merged_by_hostname(self): old = Host( ip="192.168.10.30", mac="00:11:22:33:44:55", hostname="node.local", os_name="Debian 13", ports=ports(22, 8006), ) self.store.remember_identifications([old], "Approfondi") current = Host( ip="192.168.10.30", mac="00:11:22:33:44:66", hostname="node.local", ports=ports(22, 8006), ) self.store.apply_identifications([current]) self.assertEqual(current.remembered_os_name, "") def test_local_identity_survives_interface_mac_change(self): old = Host( ip="192.168.10.1", mac="00:11:22:33:44:55", is_local=True, os_name="Debian 13", ports=ports(22), ) self.store.remember_identifications([old], "Approfondi") current = Host(ip="192.168.10.2", mac="00:11:22:AA:BB:CC", is_local=True) self.store.apply_identifications([current]) self.assertEqual(current.remembered_os_name, "Debian 13") self.assertEqual(current.remembered_identity_kind, "local") def test_metadata_is_not_transferred_when_dhcp_reuses_ip_with_new_mac(self): old = Host(ip="192.168.10.50", mac="00:11:22:33:44:55", hostname="old.local") self.store.save_host_metadata(old, favorite=True, note="Ancienne machine") new = Host(ip="192.168.10.50", mac="00:11:22:AA:BB:CC", hostname="new.local") metadata = self.store.host_metadata(new) self.assertFalse(metadata["favorite"]) self.assertEqual(metadata["note"], "") def test_shared_mac_metadata_can_be_scoped_per_ip(self): mac = "00:11:22:33:44:55" a = Host(ip="192.168.10.20", mac=mac) b = Host(ip="192.168.10.21", mac=mac) self.store.save_host_metadata(a, note="A", shared_mac=True) self.store.save_host_metadata(b, note="B", shared_mac=True) self.assertEqual(self.store.host_metadata(a, shared_mac=True)["note"], "A") self.assertEqual(self.store.host_metadata(b, shared_mac=True)["note"], "B") def test_historically_shared_mac_remains_scoped_if_only_one_ip_answers_later(self): mac = "00:11:22:33:44:55" a = Host(ip="192.168.10.20", mac=mac, hostname="a.local", os_name="Debian 13", ports=ports(22, 80)) b = Host(ip="192.168.10.21", mac=mac, hostname="b.local", os_name="OpenWrt 24.10", ports=ports(22, 443)) self.store.remember_identifications([a, b], "Approfondi") current = Host(ip="192.168.10.20", mac=mac, hostname="a.local", ports=ports(22, 80)) self.store.apply_identifications([current]) self.assertEqual(current.remembered_os_name, "Debian 13") self.assertEqual(current.remembered_identity_kind, "shared") def test_no_mac_even_hostname_ip_ports_is_never_auto_applied(self): old = Host(ip="10.20.30.40", hostname="router.example", os_name="OpenWrt 24.10", ports=ports(22, 80, 443)) self.store.remember_identifications([old], "Approfondi") current = Host(ip="10.20.30.40", hostname="router.example", os_name="Linux", ports=ports(22, 80, 443)) self.store.apply_identifications([current]) self.assertEqual(current.remembered_os_name, "") def test_same_mac_in_different_network_scopes_does_not_cross_identify(self): mac = "00:11:22:33:44:55" lab = Host(ip="192.168.10.20", mac=mac, os_name="Debian 13", ports=ports(22)) prod = Host(ip="192.168.20.20", mac=mac, os_name="OpenWrt 24.10", ports=ports(22, 80)) self.store.remember_identifications([lab], "Approfondi", scope="ipv4:192.168.10.0/24") self.store.remember_identifications([prod], "Approfondi", scope="ipv4:192.168.20.0/24") cur_lab = Host(ip="192.168.10.33", mac=mac, ports=ports(22)) self.store.apply_identifications([cur_lab], scope="ipv4:192.168.10.0/24") self.assertEqual(cur_lab.remembered_os_name, "Debian 13") cur_prod = Host(ip="192.168.20.33", mac=mac, ports=ports(22, 80)) self.store.apply_identifications([cur_prod], scope="ipv4:192.168.20.0/24") self.assertEqual(cur_prod.remembered_os_name, "OpenWrt 24.10") def test_stale_global_mac_move_requires_current_corroboration(self): mac = "00:11:22:33:44:55" old = Host(ip="192.168.10.20", mac=mac, os_name="Debian 13", ports=ports(22, 443)) self.store.remember_identifications([old], "Approfondi") stale = (datetime.now(timezone.utc) - timedelta(days=365)).astimezone().isoformat(timespec="seconds") with self.store._connect() as conn: conn.execute("UPDATE endpoint_identification SET os_seen_at = ?, updated_at = ?", (stale, stale)) # MAC seule, mais déplacement après un historique très ancien : prudence. current = Host(ip="192.168.10.99", mac=mac) self.store.apply_identifications([current]) self.assertEqual(current.remembered_os_name, "") # La même MAC + une signature de services concordante suffit à revalider. corroborated = Host(ip="192.168.10.99", mac=mac, ports=ports(22, 443)) self.store.apply_identifications([corroborated]) self.assertEqual(corroborated.remembered_os_name, "Debian 13") class Comparison049Tests(unittest.TestCase): def test_same_ip_new_mac_is_replaced_not_same_host(self): previous = [Host(ip="192.168.10.50", mac="00:11:22:33:44:55", ports=ports(445))] current = [Host(ip="192.168.10.50", mac="00:11:22:AA:BB:CC", ports=ports(22))] result = compare_hosts(current, previous) self.assertEqual(result[0].change_status, "MAC modifiée") self.assertIn("MAC différente", result[0].change_detail) def test_duplicate_mac_does_not_create_false_ip_move(self): mac = "00:11:22:33:44:55" previous = [ Host(ip="192.168.10.20", mac=mac), Host(ip="192.168.10.21", mac=mac), ] current = [Host(ip="192.168.10.22", mac=mac)] result = compare_hosts(current, previous) self.assertEqual(result[0].change_status, "Nouveau") def test_laa_same_mac_can_track_ip_between_consecutive_scans(self): mac = "A6:2B:B0:A5:49:A7" previous = [Host(ip="192.168.10.20", mac=mac)] current = [Host(ip="192.168.10.21", mac=mac)] result = compare_hosts(current, previous) self.assertEqual(result[0].change_status, "IP modifiée") def test_rotated_laa_on_same_ip_is_identity_uncertain(self): previous = [Host(ip="192.168.10.20", mac="A6:00:00:00:00:01")] current = [Host(ip="192.168.10.20", mac="B2:00:00:00:00:02")] result = compare_hosts(current, previous) self.assertEqual(result[0].change_status, "Identité incertaine") def test_nmap_accuracy_is_not_artificially_inflated(self): self.assertEqual(HistoryStore._os_quality("OpenWrt 24.10", "Approfondi", 82), 82) class NetworkScope049Tests(unittest.TestCase): def test_local_target_uses_interface_network_as_scope(self): iface = NetworkInterface("eth0", "192.168.10.15", 24, "192.168.10.0/24") self.assertEqual( scan_identity_scope("192.168.10.1-254", iface), "ipv4:192.168.10.0/24", ) def test_routed_range_gets_its_own_24_scope(self): iface = NetworkInterface("eth0", "192.168.10.15", 24, "192.168.10.0/24") self.assertEqual( scan_identity_scope("192.168.5.1-254", iface), "ipv4:192.168.5.0/24", ) class Parser049Tests(unittest.TestCase): def test_nmap_os_accuracy_is_kept(self): xml = """
""" host = parse_nmap_xml(xml)[0] self.assertEqual(host.os_accuracy, 97) if __name__ == "__main__": unittest.main()