314 lines
14 KiB
Python
314 lines
14 KiB
Python
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 = """<nmaprun><host><status state='up'/><address addr='192.168.1.2' addrtype='ipv4'/>
|
|
<os><osmatch name='OpenWrt 24.10' accuracy='97'/></os></host></nmaprun>"""
|
|
host = parse_nmap_xml(xml)[0]
|
|
self.assertEqual(host.os_accuracy, 97)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|