first commit

This commit is contained in:
2026-08-23 09:52:32 +02:00
commit 1a7ed8ef94
94 changed files with 10489 additions and 0 deletions
+52
View File
@@ -0,0 +1,52 @@
import unittest
from librenet_scanner.network import NetworkInterface, target_is_on_interface, validate_target
from librenet_scanner.parsers import parse_arp_scan, parse_nmap_xml
class ParserTests(unittest.TestCase):
def test_arp_scan_parser(self):
text = """Interface: eth0, type: EN10MB\n192.168.1.10\t00:11:22:33:44:55\tHewlett Packard\n192.168.1.20\taa:bb:cc:dd:ee:ff\t(Unknown)\n2 packets received\n"""
hosts = parse_arp_scan(text)
self.assertEqual(len(hosts), 2)
self.assertEqual(hosts[0].ip, "192.168.1.10")
self.assertEqual(hosts[0].mac, "00:11:22:33:44:55")
self.assertEqual(hosts[0].vendor, "Hewlett Packard")
self.assertEqual(hosts[1].vendor, "")
def test_nmap_parser(self):
xml = """<?xml version='1.0'?>
<nmaprun>
<host>
<status state='up'/>
<address addr='192.168.1.10' addrtype='ipv4'/>
<address addr='00:11:22:33:44:55' addrtype='mac' vendor='HP'/>
<hostnames><hostname name='pc-test.local'/></hostnames>
<ports>
<port protocol='tcp' portid='22'><state state='open'/><service name='ssh' product='OpenSSH' version='9.2'/></port>
</ports>
<os><osmatch name='Linux 6.x'/></os>
</host>
</nmaprun>"""
hosts = parse_nmap_xml(xml)
self.assertEqual(len(hosts), 1)
host = hosts[0]
self.assertEqual(host.hostname, "pc-test.local")
self.assertEqual(host.vendor, "HP")
self.assertEqual(host.os_name, "Linux 6.x")
self.assertEqual(host.ports[0].port, 22)
self.assertEqual(host.ports[0].service, "SSH")
def test_target_normalization(self):
self.assertEqual(validate_target("192.168.1.42/24"), "192.168.1.0/24")
self.assertEqual(validate_target("10.0.0.7"), "10.0.0.7")
def test_interface_targeting(self):
iface = NetworkInterface("eth0", "192.168.1.10", 24, "192.168.1.0/24", False)
self.assertTrue(target_is_on_interface("192.168.1.0/24", iface))
self.assertTrue(target_is_on_interface("192.168.1.128/25", iface))
self.assertFalse(target_is_on_interface("192.168.0.0/16", iface))
if __name__ == "__main__":
unittest.main()
+40
View File
@@ -0,0 +1,40 @@
import re
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
class StableRelease100Tests(unittest.TestCase):
def test_runtime_and_packaging_version_are_100(self):
init_py = (ROOT / "src/librenet_scanner/__init__.py").read_text(encoding="utf-8")
control = (ROOT / "packaging/debian/control").read_text(encoding="utf-8")
build = (ROOT / "packaging/build-deb.sh").read_text(encoding="utf-8")
pyproject = (ROOT / "pyproject.toml").read_text(encoding="utf-8")
self.assertIn('__version__ = "1.0.0"', init_py)
self.assertIn("Version: 1.0.0", control)
self.assertIn("VERSION=1.0.0", build)
self.assertIn('version = "1.0.0"', pyproject)
def test_readme_is_stable_release_only_and_branded(self):
readme = (ROOT / "README.md").read_text(encoding="utf-8")
self.assertIn("LibreNet Scanner 1.0.0", readme)
self.assertIn('assets/librenet-scanner.svg', readme)
self.assertIn('assets/badges/version.svg', readme)
self.assertIsNone(re.search(r"\b0\.\d+\.\d+\b", readme))
def test_local_badges_exist(self):
for name in ("version", "status", "platform", "ui", "license", "tests"):
path = ROOT / "assets/badges" / f"{name}.svg"
self.assertTrue(path.is_file(), str(path))
self.assertIn("<svg", path.read_text(encoding="utf-8"))
def test_debian_readme_assets_are_packaged(self):
build = (ROOT / "packaging/build-deb.sh").read_text(encoding="utf-8")
self.assertIn('usr/share/doc/librenet-scanner/assets/badges', build)
self.assertIn('assets/badges/"*.svg', build)
self.assertIn('assets/icons/"*.svg', build)
if __name__ == "__main__":
unittest.main()
+127
View File
@@ -0,0 +1,127 @@
import tempfile
import unittest
from pathlib import Path
from librenet_scanner.actions import normalize_mac
from librenet_scanner.comparison import compare_hosts
from librenet_scanner.intelligence import canonical_service_name, enrich_host
from librenet_scanner.models import Host, PortInfo
from librenet_scanner.storage import HistoryStore
class IntelligenceTests(unittest.TestCase):
def test_canonical_proxmox_service(self):
self.assertEqual(canonical_service_name(8006, "tcp", "wpl-analytics"), "Proxmox VE")
def test_proxmox_classification(self):
host = Host("192.168.5.1", hostname="pve01.local", ports=[PortInfo(8006, service="wpl-analytics")])
enrich_host(host)
self.assertEqual(host.device_type, "Hyperviseur Proxmox")
self.assertEqual(host.ports[0].service, "Proxmox VE")
def test_synology_classification(self):
host = Host("192.168.5.11", hostname="syno.maison.bro", ports=[PortInfo(5001)])
enrich_host(host)
self.assertEqual(host.device_type, "NAS Synology")
def test_pbs_classification(self):
host = Host("192.168.5.12", hostname="pbs01.local.raspot.in", ports=[PortInfo(8007, service="ajp13")])
enrich_host(host)
self.assertEqual(host.device_type, "Proxmox Backup Server")
self.assertEqual(host.ports[0].service, "Proxmox Backup Server")
def test_wol_mac_normalization(self):
self.assertEqual(normalize_mac("aa-bb-cc-dd-ee-ff"), "AA:BB:CC:DD:EE:FF")
with self.assertRaises(ValueError):
normalize_mac("not-a-mac")
class ComparisonTests(unittest.TestCase):
def test_new_modified_disappeared(self):
previous = [
Host("10.0.0.1", hostname="srv", ports=[PortInfo(22, service="SSH")]),
Host("10.0.0.2", hostname="old"),
]
current = [
Host("10.0.0.1", hostname="srv", ports=[PortInfo(22, service="SSH"), PortInfo(80, service="HTTP")]),
Host("10.0.0.3", hostname="new"),
]
result = compare_hosts(current, previous)
by_ip = {h.ip: h for h in result}
self.assertEqual(by_ip["10.0.0.1"].change_status, "Modifié")
self.assertIn("80/tcp", by_ip["10.0.0.1"].change_detail)
self.assertEqual(by_ip["10.0.0.3"].change_status, "Nouveau")
self.assertEqual(by_ip["10.0.0.2"].change_status, "Disparu")
self.assertEqual(by_ip["10.0.0.2"].status, "down")
def test_ip_move_by_mac(self):
previous = [Host("10.0.0.10", mac="AA:BB:CC:DD:EE:FF")]
current = [Host("10.0.0.20", mac="AA:BB:CC:DD:EE:FF")]
result = compare_hosts(current, previous)
self.assertEqual(len(result), 1)
self.assertEqual(result[0].change_status, "IP modifiée")
self.assertEqual(result[0].previous_ip, "10.0.0.10")
def test_first_scan_is_reference(self):
current = [Host("10.0.0.1")]
result = compare_hosts(current, None)
self.assertEqual(result[0].change_status, "")
class StorageTests(unittest.TestCase):
def test_history_roundtrip(self):
with tempfile.TemporaryDirectory() as tmp:
store = HistoryStore(Path(tmp) / "history.sqlite3")
host = Host(
"192.168.1.2",
hostname="pve01.local",
mac="AA:BB:CC:DD:EE:01",
ports=[PortInfo(22, service="SSH"), PortInfo(8006, service="Proxmox VE")],
)
scan_id = store.save_scan("192.168.1.0/24", "Standard", [host], "standard-test-v2")
latest = store.latest_scan("192.168.1.0/24", "Standard", "standard-test-v2")
self.assertIsNotNone(latest)
self.assertEqual(int(latest["id"]), scan_id)
loaded = store.load_scan_hosts(scan_id)
self.assertEqual(len(loaded), 1)
self.assertEqual(loaded[0].hostname, "pve01.local")
self.assertEqual(loaded[0].device_type, "Hyperviseur Proxmox")
def test_v01_database_is_migrated_without_becoming_v02_baseline(self):
import sqlite3
from contextlib import closing
with tempfile.TemporaryDirectory() as tmp:
db = Path(tmp) / "history.sqlite3"
with closing(sqlite3.connect(db)) as conn:
conn.executescript(
"""
CREATE TABLE scans (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at TEXT NOT NULL,
target TEXT NOT NULL,
profile TEXT NOT NULL,
host_count INTEGER NOT NULL
);
CREATE TABLE scan_hosts (
scan_id INTEGER NOT NULL,
ip TEXT NOT NULL,
hostname TEXT,
mac TEXT,
vendor TEXT,
os_name TEXT,
ports_json TEXT NOT NULL
);
INSERT INTO scans(created_at, target, profile, host_count)
VALUES ('2026-08-21T12:00:00+02:00', '192.168.5.0/24', 'Standard', 1);
INSERT INTO scan_hosts(scan_id, ip, hostname, mac, vendor, os_name, ports_json)
VALUES (1, '192.168.5.1', 'pve01.local', '', '', '', '[]');
"""
)
store = HistoryStore(db)
self.assertIsNone(store.latest_scan("192.168.5.0/24", "Standard", "standard-v2:test"))
self.assertEqual(len(store.recent_scans()), 1)
if __name__ == "__main__":
unittest.main()
+65
View File
@@ -0,0 +1,65 @@
import tempfile
import unittest
from pathlib import Path
from librenet_scanner.diagnostics import arp_scan_succeeded, parse_getcap_output
from librenet_scanner.network import parse_neighbor_json
from librenet_scanner.vendors import load_vendor_table, lookup_mac_vendor, parse_vendor_text
class NeighborTests(unittest.TestCase):
def test_neighbor_json_filters_unusable_entries(self):
payload = r'''[
{"dst":"192.168.10.2","lladdr":"aa:bb:cc:dd:ee:ff","state":["REACHABLE"]},
{"dst":"192.168.10.3","lladdr":"11:22:33:44:55:66","state":["STALE"]},
{"dst":"192.168.10.4","state":["INCOMPLETE"]},
{"dst":"192.168.10.5","lladdr":"bad-mac","state":["REACHABLE"]}
]'''
entries = parse_neighbor_json(payload)
self.assertEqual(len(entries), 2)
self.assertEqual(entries[0].ip, "192.168.10.2")
self.assertEqual(entries[0].mac, "AA:BB:CC:DD:EE:FF")
self.assertEqual(entries[1].state, "STALE")
def test_invalid_neighbor_json_is_empty(self):
self.assertEqual(parse_neighbor_json("not-json"), [])
class VendorTests(unittest.TestCase):
def test_parse_common_oui_formats(self):
text = '''
# comment
00-11-22 (hex) Example Networks
001122 (base 16) Example Networks Base
AA:BB:CC Another Vendor
AABBCCDDEE Precise Vendor
'''
table = parse_vendor_text(text)
self.assertEqual(table["001122"], "Example Networks Base")
self.assertEqual(table["AABBCC"], "Another Vendor")
self.assertEqual(table["AABBCCDDEE"], "Precise Vendor")
def test_load_vendor_table(self):
with tempfile.TemporaryDirectory() as tmp:
p1 = Path(tmp) / "oui.txt"
p2 = Path(tmp) / "mac-vendor.txt"
p1.write_text("AABBCC Vendor Generic\n")
p2.write_text("AABBCCDDEE Vendor Precise\n")
table = load_vendor_table((p1, p2))
self.assertEqual(table["AABBCC"], "Vendor Generic")
self.assertEqual(table["AABBCCDDEE"], "Vendor Precise")
class DiagnosticTests(unittest.TestCase):
def test_getcap_parser(self):
self.assertTrue(parse_getcap_output("/usr/sbin/arp-scan cap_net_raw=p\n"))
self.assertFalse(parse_getcap_output(""))
def test_arp_scan_exit_code(self):
self.assertTrue(arp_scan_succeeded(0))
self.assertFalse(arp_scan_succeeded(1))
self.assertFalse(arp_scan_succeeded(2))
if __name__ == "__main__":
unittest.main()
+42
View File
@@ -0,0 +1,42 @@
import unittest
from unittest.mock import patch
from librenet_scanner.privileged_helper import command_for, validate_target
class PrivilegedHelperTests(unittest.TestCase):
def test_authorize_exposes_no_command(self):
self.assertEqual(command_for("authorize", []), [])
with self.assertRaises(ValueError):
command_for("authorize", ["unexpected"])
def test_deep_scan_is_fixed_and_validated(self):
with patch("librenet_scanner.privileged_helper._trusted_binary", return_value="/usr/bin/nmap"):
cmd = command_for("nmap-deep", ["192.168.10.0/24"])
self.assertEqual(cmd[0], "/usr/bin/nmap")
self.assertIn("-sS", cmd)
self.assertIn("-O", cmd)
self.assertEqual(cmd[-1], "192.168.10.0/24")
def test_standard_scan_rejects_option_injection(self):
with self.assertRaises(ValueError):
command_for("nmap-standard", ["--script", "192.168.1.1"])
def test_unknown_operation_is_rejected(self):
with self.assertRaises(ValueError):
command_for("shell", ["/bin/sh"])
def test_target_limit(self):
self.assertEqual(validate_target("192.168.1.42/24"), "192.168.1.0/24")
with self.assertRaises(ValueError):
validate_target("10.0.0.0/8")
def test_arp_scan_uses_validated_interface_and_fixed_binary(self):
with patch("librenet_scanner.privileged_helper.validate_interface", return_value="enp42s0"), \
patch("librenet_scanner.privileged_helper._trusted_binary", return_value="/usr/sbin/arp-scan"):
cmd = command_for("arp-scan", ["enp42s0", "192.168.10.0/24"])
self.assertEqual(cmd, ["/usr/sbin/arp-scan", "--interface", "enp42s0", "192.168.10.0/24"])
if __name__ == "__main__":
unittest.main()
+62
View File
@@ -0,0 +1,62 @@
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from librenet_scanner.models import Host
from librenet_scanner.network import NetworkInterface, display_target, target_address_count, target_is_on_interface, validate_target
from librenet_scanner.privileged_helper import command_for
from librenet_scanner.storage import HistoryStore
class TargetRangeTests(unittest.TestCase):
def test_human_range_is_normalized_for_nmap(self):
target = validate_target("192.168.10.1 - 192.168.10.254")
self.assertEqual(target, "192.168.10.1-254")
self.assertEqual(display_target(target), "192.168.10.1 - 192.168.10.254")
self.assertEqual(target_address_count(target), 254)
def test_range_must_stay_in_same_24(self):
with self.assertRaises(ValueError):
validate_target("192.168.10.250 - 192.168.11.2")
def test_range_is_local_to_interface(self):
iface = NetworkInterface("enp42s0", "192.168.10.1", 24, "192.168.10.0/24")
self.assertTrue(target_is_on_interface("192.168.10.1-254", iface))
self.assertFalse(target_is_on_interface("192.168.11.1-254", iface))
class MetadataTests(unittest.TestCase):
def test_favorite_group_note_roundtrip(self):
with tempfile.TemporaryDirectory() as tmp:
store = HistoryStore(Path(tmp) / "db.sqlite3")
host = Host("192.168.10.20", hostname="pve01", mac="AA:BB:CC:DD:EE:FF")
store.save_host_metadata(host, favorite=True, group_name="Infrastructure", note="Cluster principal")
meta = store.host_metadata(host)
self.assertTrue(meta["favorite"])
self.assertEqual(meta["group_name"], "Infrastructure")
self.assertEqual(meta["note"], "Cluster principal")
self.assertEqual(store.known_groups(), ["Infrastructure"])
def test_metadata_follows_mac_after_ip_change(self):
with tempfile.TemporaryDirectory() as tmp:
store = HistoryStore(Path(tmp) / "db.sqlite3")
old = Host("192.168.10.20", mac="AA:BB:CC:DD:EE:FF")
store.save_host_metadata(old, favorite=True)
moved = Host("192.168.10.99", mac="AA:BB:CC:DD:EE:FF")
self.assertTrue(store.host_metadata(moved)["favorite"])
class V03PrivilegedHelperTests(unittest.TestCase):
def test_privileged_discovery_is_fixed(self):
with patch("librenet_scanner.privileged_helper._trusted_binary", return_value="/usr/bin/nmap"):
cmd = command_for("nmap-discover", ["192.168.10.1-254"])
self.assertEqual(cmd, ["/usr/bin/nmap", "-sn", "-n", "-T4", "--max-retries", "1", "-oX", "-", "192.168.10.1-254"])
def test_privileged_discovery_rejects_option_injection(self):
with self.assertRaises(ValueError):
command_for("nmap-discover", ["--script=evil"])
if __name__ == "__main__":
unittest.main()
+26
View File
@@ -0,0 +1,26 @@
import unittest
from librenet_scanner.ui_layout import balanced_column_widths, compact_warning
class UI041LayoutTests(unittest.TestCase):
def test_balanced_columns_fill_viewport(self):
widths = balanced_column_widths(1200, show_evolution=True)
self.assertEqual(len(widths), 4)
self.assertGreaterEqual(widths[0], 300)
self.assertGreater(widths[2], widths[1])
self.assertLessEqual(sum(widths), 1200)
self.assertGreaterEqual(sum(widths), 1180)
def test_evolution_can_be_hidden(self):
widths = balanced_column_widths(1000, show_evolution=False)
self.assertEqual(widths[3], 0)
self.assertGreater(widths[0], 300)
def test_arp_warning_is_shortened(self):
message = "arp-scan n'a pas les privilèges nécessaires pour le scan ARP. Les MAC seront récupérées via ip neigh."
self.assertEqual(compact_warning(message), "ARP limité : certaines adresses MAC peuvent être incomplètes.")
if __name__ == "__main__":
unittest.main()
+45
View File
@@ -0,0 +1,45 @@
import unittest
from unittest.mock import patch
from librenet_scanner.models import Host
from librenet_scanner.parsers import parse_nmap_xml
from librenet_scanner.privileged_helper import command_for
class OsFingerprint0410Tests(unittest.TestCase):
def test_deep_hosts_forces_guess_and_does_not_limit_os_scan(self):
with patch("librenet_scanner.privileged_helper._trusted_binary", return_value="/usr/bin/nmap"):
cmd = command_for("nmap-deep-hosts", ["192.168.10.254"])
self.assertIn("-O", cmd)
self.assertIn("--osscan-guess", cmd)
self.assertNotIn("--osscan-limit", cmd)
self.assertIn("--top-ports", cmd)
self.assertIn("1000", cmd)
self.assertNotIn("-p-", cmd)
def test_detailed_host_forces_guess_without_full_port_scan(self):
with patch("librenet_scanner.privileged_helper._trusted_binary", return_value="/usr/bin/nmap"):
cmd = command_for("nmap-host", ["192.168.10.254"])
self.assertIn("--osscan-guess", cmd)
self.assertNotIn("--osscan-limit", cmd)
self.assertNotIn("-p-", cmd)
def test_parser_keeps_best_os_guess(self):
xml = """<nmaprun><host><status state='up'/><address addr='192.168.10.254' addrtype='ipv4'/>
<os>
<osmatch name='FreeBSD 12.X' accuracy='87'/>
<osmatch name='FreeBSD 11.2-RELEASE' accuracy='93'/>
<osmatch name='Linux 5.X' accuracy='78'/>
</os></host></nmaprun>"""
host = parse_nmap_xml(xml)[0]
self.assertEqual(host.os_name, "FreeBSD 11.2-RELEASE")
self.assertEqual(host.os_accuracy, 93)
self.assertTrue(host.os_is_estimated)
def test_100_percent_os_match_is_not_marked_estimated(self):
host = Host(ip="192.168.1.1", os_name="Linux 6.x", os_accuracy=100)
self.assertFalse(host.os_is_estimated)
if __name__ == "__main__":
unittest.main()
+32
View File
@@ -0,0 +1,32 @@
import unittest
from librenet_scanner.models import Host
from librenet_scanner.visual_identity import device_icon_key_for_host, os_icon_key_for_host
class VisualIdentitySplitRegressionTests(unittest.TestCase):
"""0.4.12 deliberately replaces the product-specific 0.4.11 icon model."""
def test_openwrt_is_linux_os_but_network_equipment_type_is_independent(self):
host = Host("192.0.2.1", os_name="OpenWrt 23.05", device_type="Équipement réseau")
self.assertEqual(os_icon_key_for_host(host), "linux")
self.assertEqual(device_icon_key_for_host(host), "network-device")
def test_opnsense_is_bsd_os_and_firewall_equipment(self):
host = Host("192.0.2.3", hostname="opns01.local", os_name="FreeBSD 13", device_type="Pare-feu / routeur")
self.assertEqual(os_icon_key_for_host(host), "bsd")
self.assertEqual(device_icon_key_for_host(host), "firewall")
def test_proxmox_is_linux_os_and_hypervisor_equipment(self):
host = Host("192.0.2.4", os_name="Linux 6.x", device_type="Hyperviseur Proxmox")
self.assertEqual(os_icon_key_for_host(host), "linux")
self.assertEqual(device_icon_key_for_host(host), "hypervisor")
def test_windows_workstation_is_windows_os_and_workstation_equipment(self):
host = Host("192.0.2.2", os_name="Microsoft Windows 11", device_type="Poste / serveur Windows")
self.assertEqual(os_icon_key_for_host(host), "windows")
self.assertEqual(device_icon_key_for_host(host), "workstation")
if __name__ == "__main__":
unittest.main()
+69
View File
@@ -0,0 +1,69 @@
import unittest
from pathlib import Path
from librenet_scanner.models import Host
from librenet_scanner.visual_identity import (
DEVICE_THEME_CANDIDATES,
OS_ICON_FILES,
device_icon_key_for_host,
os_icon_key_for_host,
)
class GenericEquipmentIconTests(unittest.TestCase):
def test_server_uses_server_class_independent_from_linux(self):
host = Host("192.0.2.10", os_name="Debian GNU/Linux 13", device_type="Serveur Linux")
self.assertEqual(device_icon_key_for_host(host), "server")
self.assertEqual(os_icon_key_for_host(host), "linux")
def test_nas_is_nas_even_if_linux_underneath(self):
host = Host("192.0.2.20", os_name="Linux 6.x", device_type="NAS Synology")
self.assertEqual(device_icon_key_for_host(host), "nas")
self.assertEqual(os_icon_key_for_host(host), "linux")
def test_printer_is_printer(self):
self.assertEqual(device_icon_key_for_host(Host("192.0.2.30", device_type="Imprimante")), "printer")
def test_access_point_is_access_point(self):
self.assertEqual(device_icon_key_for_host(Host("192.0.2.40", device_type="Point d'accès Wi-Fi")), "access-point")
def test_switch_is_switch(self):
self.assertEqual(device_icon_key_for_host(Host("192.0.2.50", device_type="Switch")), "switch")
def test_local_host_is_workstation(self):
host = Host("192.0.2.60", os_name="Linux", device_type="Ce poste", is_local=True)
self.assertEqual(device_icon_key_for_host(host), "workstation")
class GenericOsIconTests(unittest.TestCase):
def test_linux_distributions_share_tux_family(self):
for name in ("Debian GNU/Linux 13", "Ubuntu 26.04", "OpenWrt 24.10", "Linux 6.12", "Proxmox Linux 6.x"):
with self.subTest(name=name):
self.assertEqual(os_icon_key_for_host(Host("192.0.2.1", os_name=name)), "linux")
def test_bsd_family_shares_daemon_icon(self):
for name in ("FreeBSD 14", "OpenBSD 7.6", "NetBSD 10", "OPNsense 25"):
with self.subTest(name=name):
self.assertEqual(os_icon_key_for_host(Host("192.0.2.1", os_name=name)), "bsd")
def test_unknown_os_uses_other(self):
self.assertEqual(os_icon_key_for_host(Host("192.0.2.1")), "other")
def test_only_generic_os_assets_are_declared(self):
self.assertTrue({"linux", "bsd", "windows", "other"}.issubset(set(OS_ICON_FILES)))
def test_theme_device_classes_have_fallbacks(self):
for key, names in DEVICE_THEME_CANDIDATES.items():
with self.subTest(key=key):
self.assertTrue(names)
self.assertTrue(all(names))
def test_os_assets_exist(self):
root = Path(__file__).resolve().parents[1] / "assets" / "icons"
for filename in OS_ICON_FILES.values():
with self.subTest(filename=filename):
self.assertTrue((root / filename).is_file())
if __name__ == "__main__":
unittest.main()
+62
View File
@@ -0,0 +1,62 @@
import unittest
from pathlib import Path
from librenet_scanner.models import Host
from librenet_scanner.visual_identity import (
EQUIPMENT_ICON_FILES,
OS_ICON_FILES,
device_icon_key_for_host,
os_icon_key_for_host,
)
class EquipmentIdentityTests(unittest.TestCase):
def test_local_host_is_workstation(self):
self.assertEqual(device_icon_key_for_host(Host("192.0.2.1", is_local=True, device_type="Ce poste")), "workstation")
def test_linux_server_is_server_not_workstation(self):
host = Host("192.0.2.2", device_type="Serveur Linux", os_name="Debian GNU/Linux 13")
self.assertEqual(device_icon_key_for_host(host), "server")
self.assertEqual(os_icon_key_for_host(host), "linux")
def test_openwrt_is_generic_network_device(self):
host = Host("192.0.2.3", device_type="Serveur Linux", os_name="OpenWrt 24.10")
self.assertEqual(device_icon_key_for_host(host), "network-device")
self.assertEqual(os_icon_key_for_host(host), "linux")
def test_opnsense_is_firewall_and_bsd(self):
host = Host("192.0.2.4", device_type="Serveur / appliance", os_name="OPNsense 25.1 (FreeBSD 14)")
self.assertEqual(device_icon_key_for_host(host), "firewall")
self.assertEqual(os_icon_key_for_host(host), "bsd")
def test_nas_switch_ap_printer_have_distinct_classes(self):
cases = {
"NAS Synology": "nas",
"Switch": "switch",
"Point d'accès Wi-Fi": "access-point",
"Imprimante": "printer",
}
for dtype, expected in cases.items():
with self.subTest(dtype=dtype):
self.assertEqual(device_icon_key_for_host(Host("192.0.2.10", device_type=dtype)), expected)
class IconAssetTests(unittest.TestCase):
def test_all_equipment_assets_exist(self):
root = Path(__file__).resolve().parents[1] / "assets" / "icons"
for filename in EQUIPMENT_ICON_FILES.values():
with self.subTest(filename=filename):
self.assertTrue((root / filename).is_file(), filename)
def test_all_os_assets_exist(self):
root = Path(__file__).resolve().parents[1] / "assets" / "icons"
for filename in OS_ICON_FILES.values():
with self.subTest(filename=filename):
self.assertTrue((root / filename).is_file(), filename)
def test_equipment_assets_are_not_os_assets(self):
self.assertTrue(set(EQUIPMENT_ICON_FILES.values()).isdisjoint(OS_ICON_FILES.values()))
if __name__ == "__main__":
unittest.main()
+45
View File
@@ -0,0 +1,45 @@
from pathlib import Path
import unittest
from librenet_scanner.models import Host
from librenet_scanner.visual_identity import device_icon_key_for_host, os_icon_key_for_host
class V0414IconTests(unittest.TestCase):
def test_equipment_categories_are_distinct(self):
cases = [
(Host(ip='192.0.2.1', device_type='Poste'), 'workstation'),
(Host(ip='192.0.2.2', device_type='Serveur Linux'), 'server'),
(Host(ip='192.0.2.3', device_type='NAS Synology'), 'nas'),
(Host(ip='192.0.2.4', device_type='Switch'), 'switch'),
(Host(ip='192.0.2.5', device_type="Point d'accès Wi-Fi"), 'access-point'),
(Host(ip='192.0.2.6', device_type='Imprimante'), 'printer'),
(Host(ip='192.0.2.7', device_type='Pare-feu / routeur'), 'firewall'),
]
for host, expected in cases:
with self.subTest(expected=expected):
self.assertEqual(device_icon_key_for_host(host), expected)
def test_os_families(self):
self.assertEqual(os_icon_key_for_host(Host(ip='1.1.1.1', os_name='Linux 6.1')), 'linux')
self.assertEqual(os_icon_key_for_host(Host(ip='1.1.1.2', os_name='FreeBSD 14.1')), 'bsd')
self.assertEqual(os_icon_key_for_host(Host(ip='1.1.1.3', os_name='Windows 11')), 'windows')
def test_fontawesome_assets_replaced_homemade_icons(self):
root = Path(__file__).resolve().parents[1]
icons = root / 'assets' / 'icons'
expected = {
'os-linux.svg', 'os-bsd.svg', 'os-windows.svg',
'equipment-workstation.svg', 'equipment-server.svg',
'equipment-firewall.svg', 'equipment-switch.svg',
'equipment-access-point.svg', 'equipment-printer.svg',
}
self.assertTrue(expected.issubset({p.name for p in icons.glob('*.svg')}))
# Homemade 0.4.13 BSD path included a tail/staff; FA FreeBSD glyph is larger and contains many curves.
bsd=(icons/'os-bsd.svg').read_text()
self.assertIn('Font Awesome', (root/'THIRD_PARTY_ASSETS.md').read_text())
self.assertGreater(len(bsd), 500)
if __name__ == '__main__':
unittest.main()
+50
View File
@@ -0,0 +1,50 @@
from pathlib import Path
import unittest
from librenet_scanner.identity import os_family
from librenet_scanner.models import Host
from librenet_scanner.visual_identity import OS_ICON_FILES, os_icon_key_for_host
class V0415OsIconsTests(unittest.TestCase):
def test_windows_still_supported(self):
self.assertEqual(os_icon_key_for_host(Host(ip="192.0.2.10", os_name="Microsoft Windows 11")), "windows")
def test_apple_platforms(self):
for value in (
"Apple macOS 14.5",
"Apple Mac OS X 10.15.7",
"Darwin 23.5.0",
"iPhone OS 17.6",
"iPadOS 18.0",
"Apple iOS 17.6",
"Apple TV tvOS 17",
):
with self.subTest(value=value):
self.assertEqual(os_icon_key_for_host(Host(ip="192.0.2.11", os_name=value)), "apple")
def test_android_wins_over_linux(self):
self.assertEqual(
os_icon_key_for_host(Host(ip="192.0.2.12", os_name="Linux 5.10 (Android 14)")),
"android",
)
def test_identity_families_match_visual_families(self):
self.assertEqual(os_family("Android 14 (Linux 5.10)"), "android")
self.assertEqual(os_family("Apple macOS 15"), "apple")
self.assertEqual(os_family("Windows 11"), "windows")
self.assertNotEqual(os_icon_key_for_host(Host(ip="192.0.2.13", os_name="Cisco IOS 15.2")), "apple")
self.assertEqual(os_family("Cisco IOS XE 17.9"), "iosxe")
def test_assets_are_packaged_sources(self):
root = Path(__file__).resolve().parents[1]
for key in ("apple", "android", "windows", "linux", "bsd"):
filename = OS_ICON_FILES[key]
icon = root / "assets" / "icons" / filename
self.assertTrue(icon.is_file(), f"missing {filename}")
self.assertGreater(icon.stat().st_size, 200)
self.assertIn("Font Awesome", (root / "THIRD_PARTY_ASSETS.md").read_text())
if __name__ == "__main__":
unittest.main()
+164
View File
@@ -0,0 +1,164 @@
import tempfile
import unittest
from datetime import datetime, timedelta, timezone
from pathlib import Path
from librenet_scanner.models import Host, PortInfo
from librenet_scanner.storage import HistoryStore
def ports(*values: int) -> list[PortInfo]:
return [PortInfo(port=value, service="test") for value in values]
class IdentificationFreshness0416Tests(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.store = HistoryStore(Path(self.tmp.name) / "history.sqlite3")
self.scope = "ipv4:192.168.10.0/24"
def tearDown(self):
self.tmp.cleanup()
def _row(self, identity_suffix: str = "mac:00:11:22:33:44:55"):
with self.store._connect() as conn:
return conn.execute(
"SELECT * FROM endpoint_identification WHERE identity LIKE ? ORDER BY identity LIMIT 1",
(f"%{identity_suffix}",),
).fetchone()
def test_standard_does_not_refresh_os_seen_at(self):
deep_time = "2025-01-02T03:04:05+00:00"
standard_time = "2026-08-22T12:00:00+00:00"
deep = Host(
ip="192.168.10.20", mac="00:11:22:33:44:55",
os_name="Debian 13", os_accuracy=97, ports=ports(22, 443),
)
self.store.remember_identifications([deep], "Approfondi", observed_at=deep_time, scope=self.scope)
standard = Host(
ip="192.168.10.20", mac=deep.mac, os_name="Linux 6.x", ports=ports(22, 443),
)
self.store.remember_identifications([standard], "Standard", observed_at=standard_time, scope=self.scope)
row = self._row()
self.assertIsNotNone(row)
self.assertEqual(row["os_name"], "Debian 13")
self.assertEqual(row["os_seen_at"], deep_time)
self.assertEqual(row["os_source"], "Approfondi")
self.assertEqual(row["updated_at"], standard_time)
def test_old_os_stays_old_even_when_endpoint_updated_at_is_recent(self):
old = Host(
ip="192.168.10.20", mac="00:11:22:33:44:55",
os_name="Debian 13", os_accuracy=99, ports=ports(22, 443),
)
self.store.remember_identifications([old], "Approfondi", scope=self.scope)
stale = (datetime.now(timezone.utc) - timedelta(days=365)).astimezone().isoformat(timespec="seconds")
fresh = datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds")
with self.store._connect() as conn:
conn.execute(
"UPDATE endpoint_identification SET os_seen_at = ?, updated_at = ?",
(stale, fresh),
)
moved = Host(ip="192.168.10.99", mac=old.mac)
self.store.apply_identifications([moved], scope=self.scope)
self.assertEqual(moved.remembered_os_name, "")
def test_unsafe_mac_move_does_not_overwrite_original_endpoint_before_matching(self):
old = Host(
ip="192.168.10.20", mac="00:11:22:33:44:55",
hostname="old.local", os_name="Debian 13", os_accuracy=99,
ports=ports(22, 443),
)
self.store.remember_identifications([old], "Approfondi", scope=self.scope)
moved = Host(ip="192.168.10.99", mac=old.mac, hostname="other.local")
# Même si une future régression écrit avant d'appliquer, l'ancien endpoint
# doit rester intact : l'observation ambiguë est scindée en macip.
self.store.remember_identifications([moved], "Standard", scope=self.scope)
with self.store._connect() as conn:
base = conn.execute(
"SELECT * FROM endpoint_identification WHERE identity = ?",
(f"{self.scope}::mac:{old.mac}",),
).fetchone()
split = conn.execute(
"SELECT * FROM endpoint_identification WHERE identity = ?",
(f"{self.scope}::macip:{old.mac}@192.168.10.99",),
).fetchone()
self.assertIsNotNone(base)
self.assertEqual(base["ip"], "192.168.10.20")
self.assertEqual(base["os_name"], "Debian 13")
self.assertIsNotNone(split)
class DataDeletion0416Tests(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.store = HistoryStore(Path(self.tmp.name) / "history.sqlite3")
self.scope = "ipv4:192.168.10.0/24"
self.host = Host(
ip="192.168.10.20", mac="00:11:22:33:44:55", hostname="srv.local",
os_name="Debian 13", os_accuracy=99, ports=ports(22),
)
def tearDown(self):
self.tmp.cleanup()
def test_clear_scan_history_does_not_delete_identification_or_metadata(self):
self.store.remember_identifications([self.host], "Approfondi", scope=self.scope)
self.store.save_host_metadata(self.host, favorite=True, note="Important")
self.store.save_scan("192.168.10.0/24", "Approfondi", [self.host], "deep-test")
self.assertEqual(self.store.clear_scan_history(), 1)
self.assertEqual(self.store.recent_scans(), [])
current = Host(ip=self.host.ip, mac=self.host.mac, ports=ports(22))
self.store.apply_identifications([current], scope=self.scope)
self.assertEqual(current.remembered_os_name, "Debian 13")
self.assertTrue(self.store.host_metadata(self.host)["favorite"])
def test_forget_selected_identification_keeps_metadata(self):
self.store.remember_identifications([self.host], "Approfondi", scope=self.scope)
self.store.save_host_metadata(self.host, favorite=True, group_name="Infra", note="Note")
deleted = self.store.forget_identification_for_host(self.host, scope=self.scope)
self.assertGreaterEqual(deleted, 1)
current = Host(ip=self.host.ip, mac=self.host.mac, ports=ports(22))
self.store.apply_identifications([current], scope=self.scope)
self.assertEqual(current.remembered_os_name, "")
metadata = self.store.host_metadata(self.host)
self.assertTrue(metadata["favorite"])
self.assertEqual(metadata["group_name"], "Infra")
def test_forget_scope_does_not_touch_other_network(self):
other_scope = "ipv4:192.168.20.0/24"
other = Host(ip="192.168.20.20", mac="00:11:22:AA:BB:CC", os_name="OpenWrt 24.10", ports=ports(22, 80))
self.store.remember_identifications([self.host], "Approfondi", scope=self.scope)
self.store.remember_identifications([other], "Approfondi", scope=other_scope)
self.assertEqual(self.store.forget_identifications_for_scope(self.scope), 1)
cur1 = Host(ip=self.host.ip, mac=self.host.mac, ports=ports(22))
cur2 = Host(ip=other.ip, mac=other.mac, ports=ports(22, 80))
self.store.apply_identifications([cur1], scope=self.scope)
self.store.apply_identifications([cur2], scope=other_scope)
self.assertEqual(cur1.remembered_os_name, "")
self.assertEqual(cur2.remembered_os_name, "OpenWrt 24.10")
def test_forget_all_identifications_leaves_scan_history(self):
self.store.remember_identifications([self.host], "Approfondi", scope=self.scope)
self.store.save_scan("192.168.10.0/24", "Approfondi", [self.host], "deep-test")
self.assertGreaterEqual(self.store.forget_all_identifications(), 1)
self.assertEqual(len(self.store.recent_scans()), 1)
current = Host(ip=self.host.ip, mac=self.host.mac, ports=ports(22))
self.store.apply_identifications([current], scope=self.scope)
self.assertEqual(current.remembered_os_name, "")
class SourceOrder0416Tests(unittest.TestCase):
def test_scan_finish_applies_memory_before_updating_endpoint_observation(self):
source = Path(__file__).parents[1] / "src" / "librenet_scanner" / "ui.py"
text = source.read_text(encoding="utf-8")
start = text.index("def _scan_finished")
end = text.index("# ---------- Constructeurs en ligne", start)
block = text[start:end]
self.assertLess(block.index("apply_identifications"), block.index("remember_identifications"))
self.assertIn("Effacer laffichage", text)
self.assertIn("Oublier les identifications", text)
if __name__ == "__main__":
unittest.main()
+27
View File
@@ -0,0 +1,27 @@
import ast
import unittest
from pathlib import Path
ROOT = Path(__file__).parents[1]
class StartupRegression0417Tests(unittest.TestCase):
def test_themed_icon_accepts_variadic_fallbacks(self):
source = (ROOT / "src/librenet_scanner/ui_icons.py").read_text(encoding="utf-8")
tree = ast.parse(source)
fn = next(node for node in tree.body if isinstance(node, ast.FunctionDef) and node.name == "themed_icon")
self.assertIsNotNone(fn.args.vararg, "themed_icon doit accepter plusieurs fallbacks")
self.assertEqual(fn.args.vararg.arg, "fallbacks")
def test_forget_identifications_menu_can_supply_three_theme_candidates(self):
source = (ROOT / "src/librenet_scanner/ui.py").read_text(encoding="utf-8")
self.assertIn('themed_icon("edit-delete", "edit-clear-history", "user-trash")', source)
# The regression in 0.4.16 was exactly a 3-positional-argument call.
# Variadic themed_icon must therefore remain compatible with it.
icon_source = (ROOT / "src/librenet_scanner/ui_icons.py").read_text(encoding="utf-8")
self.assertIn('def themed_icon(name: str, *fallbacks: str)', icon_source)
if __name__ == "__main__":
unittest.main()
+65
View File
@@ -0,0 +1,65 @@
import unittest
from pathlib import Path
from unittest.mock import patch
from librenet_scanner.fastscan import parse_naabu_json_line
from librenet_scanner.privileged_helper import command_for
ROOT = Path(__file__).resolve().parents[1]
class NaabuParser0418Tests(unittest.TestCase):
def test_jsonl_port_becomes_librenet_host(self):
host = parse_naabu_json_line('{"ip":"192.168.10.20","port":8006}')
self.assertIsNotNone(host)
self.assertEqual(host.ip, "192.168.10.20")
self.assertEqual(host.ports[0].port, 8006)
self.assertEqual(host.ports[0].service, "Proxmox VE")
self.assertEqual(host.device_type, "Hyperviseur Proxmox")
def test_invalid_json_is_ignored(self):
self.assertIsNone(parse_naabu_json_line("[INF] starting scan"))
self.assertIsNone(parse_naabu_json_line('{"ip":"not-an-ip","port":443}'))
self.assertIsNone(parse_naabu_json_line('{"ip":"192.168.1.2","port":70000}'))
class NaabuPrivilege0418Tests(unittest.TestCase):
def test_privileged_naabu_uses_fixed_syn_profile(self):
with patch("librenet_scanner.privileged_helper._trusted_binary", return_value="/usr/local/bin/naabu"):
cmd = command_for("naabu-standard", ["192.168.10.10", "192.168.10.11"])
self.assertEqual(cmd[0], "/usr/local/bin/naabu")
self.assertIn("-scan-type", cmd)
self.assertEqual(cmd[cmd.index("-scan-type") + 1], "s")
self.assertIn("-json", cmd)
self.assertIn("-disable-update-check", cmd)
self.assertEqual(cmd[cmd.index("-config") + 1], "/dev/null")
self.assertIn("192.168.10.10,192.168.10.11", cmd)
def test_privileged_naabu_rejects_network_in_host_list(self):
with patch("librenet_scanner.privileged_helper._trusted_binary", return_value="/usr/local/bin/naabu"):
with self.assertRaises(ValueError):
command_for("naabu-standard", ["192.168.10.0/24"])
class Pipeline0418Tests(unittest.TestCase):
def test_standard_and_deep_signatures_changed(self):
text = (ROOT / "src/librenet_scanner/scanner.py").read_text(encoding="utf-8")
self.assertIn('standard-v9:adaptive-nmap-small-naabu-large:', text)
self.assertIn('deep-v10:adaptive-standard-baseline-nmap-enrichment:', text)
def test_standard_uses_fast_engine_with_nmap_fallback(self):
text = (ROOT / "src/librenet_scanner/scanner.py").read_text(encoding="utf-8")
self.assertIn("if len(ips) < NAABU_ACTIVE_HOST_THRESHOLD", text)
self.assertIn("port_hosts = self._naabu_ports", text)
self.assertIn('args = privileged_command("nmap-standard", *ips)', text)
self.assertIn('"nmap", "-Pn", "-n", "-sT"', text)
def test_deep_keeps_nmap_service_and_os_enrichment(self):
text = (ROOT / "src/librenet_scanner/scanner.py").read_text(encoding="utf-8")
self.assertIn('privileged_command("nmap-deep-hosts", *ips)', text)
self.assertIn('"-sV"', text)
self.assertIn('"--top-ports", "1000"', text)
if __name__ == "__main__":
unittest.main()
+44
View File
@@ -0,0 +1,44 @@
import io
import sys
import time
import unittest
from pathlib import Path
from librenet_scanner.privileged_helper import run_supervised
ROOT = Path(__file__).resolve().parents[1]
class Cancellation0419Tests(unittest.TestCase):
def test_privileged_helper_stop_protocol_reaps_root_child(self):
start = time.monotonic()
code = run_supervised(
[sys.executable, "-c", "import time; time.sleep(30)"],
input_stream=io.StringIO("STOP\n"),
)
self.assertEqual(code, 130)
self.assertLess(time.monotonic() - start, 3.0)
def test_scan_processes_have_a_dedicated_process_group_and_stop_channel(self):
source = (ROOT / "src/librenet_scanner/scanner.py").read_text(encoding="utf-8")
self.assertIn("start_new_session=True", source)
self.assertIn('proc.stdin.write("STOP\\n")', source)
self.assertIn("self._kill_process_group(proc, signal.SIGTERM)", source)
def test_stop_button_reports_real_shutdown(self):
source = (ROOT / "src/librenet_scanner/ui.py").read_text(encoding="utf-8")
self.assertIn('self.activity_label.setText("Arrêt du scan en cours…")', source)
self.assertIn("self.stop_btn.setEnabled(False)", source)
class EngineStatus0419Tests(unittest.TestCase):
def test_discovery_and_port_engines_are_named_separately(self):
source = (ROOT / "src/librenet_scanner/scanner.py").read_text(encoding="utf-8")
self.assertIn("découverte rapide Nmap/ARP", source)
self.assertIn("ports Naabu SYN (Admin)", source)
self.assertIn("ports Nmap SYN (Admin)", source)
self.assertIn("REPLI Naabu", source)
if __name__ == "__main__":
unittest.main()
+23
View File
@@ -0,0 +1,23 @@
import unittest
from pathlib import Path
class UI042ScanMenuRegressionTests(unittest.TestCase):
def test_scan_menu_button_has_no_attached_qmenu_indicator(self):
ui = (Path(__file__).resolve().parents[1] / "src/librenet_scanner/ui.py").read_text()
start = ui.index("self.scan_menu_btn = QToolButton()")
end = ui.index("self.stop_btn = QPushButton", start)
block = ui[start:end]
self.assertIn("setArrowType(Qt.DownArrow)", block)
self.assertIn("clicked.connect(self._show_scan_menu)", block)
self.assertNotIn("setMenu(", block)
self.assertNotIn("InstantPopup", block)
def test_scan_profile_menu_is_stored_separately(self):
ui = (Path(__file__).resolve().parents[1] / "src/librenet_scanner/ui.py").read_text()
self.assertIn("self.scan_menu = QMenu(self)", ui)
self.assertIn("self.scan_menu.popup(pos)", ui)
if __name__ == "__main__":
unittest.main()
+61
View File
@@ -0,0 +1,61 @@
import unittest
from pathlib import Path
from unittest.mock import patch
from librenet_scanner.network import target_ipv4_hosts
from librenet_scanner.privileged_helper import command_for
ROOT = Path(__file__).resolve().parents[1]
class StandardPipeline0420Tests(unittest.TestCase):
def test_standard_uses_adaptive_baseline(self):
source = (ROOT / "src/librenet_scanner/scanner.py").read_text(encoding="utf-8")
start = source.index("def _standard_scan")
end = source.index("def _nmap_optional", start)
block = source[start:end]
self.assertIn("self._standard_baseline", block)
self.assertNotIn("target_ipv4_hosts", block)
def test_run_standard_branch_only_enters_standard_pipeline(self):
source = (ROOT / "src/librenet_scanner/scanner.py").read_text(encoding="utf-8")
branch = source[source.index('elif profile == "Standard"'):source.index('elif profile == "Approfondi"')]
self.assertIn("self._standard_scan()", branch)
self.assertNotIn("_discover_hosts", branch)
def test_user_naabu_is_connect_stream_and_skips_host_prefilter(self):
source = (ROOT / "src/librenet_scanner/scanner.py").read_text(encoding="utf-8")
start = source.index("def _naabu_ports")
end = source.index("def _nmap_standard_ports", start)
block = source[start:end]
self.assertIn('"-scan-type", "c"', block)
self.assertIn('"-Pn"', block)
self.assertIn('"-stream"', block)
self.assertNotIn('"-verify"', block)
self.assertNotIn('"-retries"', block)
def test_admin_naabu_is_syn_stream_and_bounded(self):
with patch("librenet_scanner.privileged_helper._trusted_binary", return_value="/usr/local/bin/naabu"):
cmd = command_for("naabu-standard", ["192.168.10.10", "192.168.10.11"])
self.assertEqual(cmd[cmd.index("-scan-type") + 1], "s")
self.assertIn("-Pn", cmd)
self.assertIn("-stream", cmd)
self.assertNotIn("-verify", cmd)
self.assertNotIn("-retries", cmd)
self.assertEqual(cmd[cmd.index("-timeout") + 1], "800ms")
def test_target_range_is_expanded(self):
self.assertEqual(
target_ipv4_hosts("192.168.10.10-12"),
["192.168.10.10", "192.168.10.11", "192.168.10.12"],
)
def test_target_cidr_is_expanded_to_hosts(self):
self.assertEqual(
target_ipv4_hosts("192.168.10.0/30"),
["192.168.10.1", "192.168.10.2"],
)
if __name__ == "__main__":
unittest.main()
+131
View File
@@ -0,0 +1,131 @@
import importlib
import sys
import types
import unittest
from pathlib import Path
from unittest.mock import Mock, patch
from librenet_scanner.fastscan import parse_naabu_host_json_line
from librenet_scanner.models import Host
from librenet_scanner.privileged_helper import command_for
ROOT = Path(__file__).resolve().parents[1]
if "PySide6.QtCore" not in sys.modules:
qtcore = types.ModuleType("PySide6.QtCore")
pyside = types.ModuleType("PySide6")
class DummySignal:
def __init__(self, *args, **kwargs):
self.calls = []
def emit(self, *args):
self.calls.append(args)
class DummyQThread:
def __init__(self, parent=None):
self._interrupted = False
def requestInterruption(self):
self._interrupted = True
def isInterruptionRequested(self):
return self._interrupted
qtcore.Signal = DummySignal
qtcore.QThread = DummyQThread
pyside.QtCore = qtcore
sys.modules["PySide6"] = pyside
sys.modules["PySide6.QtCore"] = qtcore
scanner = importlib.import_module("librenet_scanner.scanner")
class NaabuDiscovery0421Tests(unittest.TestCase):
def test_host_discovery_json_without_port_becomes_live_host(self):
host = parse_naabu_host_json_line('{"ip":"192.168.50.20"}')
self.assertIsNotNone(host)
self.assertEqual(host.ip, "192.168.50.20")
self.assertEqual(host.status, "up")
self.assertEqual(host.ports, [])
def test_legacy_admin_host_discovery_helper_remains_hardened(self):
with patch("librenet_scanner.privileged_helper._trusted_binary", return_value="/usr/local/bin/naabu"):
cmd = command_for("naabu-discover", ["192.168.50.10", "192.168.50.11"])
self.assertIn("-sn", cmd)
self.assertEqual(cmd[cmd.index("-config") + 1], "/dev/null")
self.assertIn("-auth=false", cmd)
class StandardBehavior0421Tests(unittest.TestCase):
def _worker(self, *, privileged=False):
req = scanner.ScanRequest(
target="192.168.50.0/30", profile="Standard", interface=None, privileged=privileged
)
return scanner.ScanWorker(req)
def test_standard_scan_uses_adaptive_baseline(self):
worker = self._worker()
worker._standard_baseline = Mock(return_value=({"192.168.50.1"}, "nmap"))
worker._set_progress = Mock()
worker._standard_scan()
worker._standard_baseline.assert_called_once()
self.assertIn("Nmap TCP (adaptatif)", worker._set_progress.call_args.args[1])
def test_small_active_set_uses_nmap_and_never_starts_naabu(self):
worker = self._worker()
worker._standard_discovery = Mock(return_value={"192.168.50.1"})
worker._naabu_ports = Mock(side_effect=AssertionError("Naabu ne doit pas démarrer pour un petit LAN"))
worker._nmap_standard_ports = Mock(return_value=[])
worker._neighbor_hosts = Mock(return_value=[])
worker._set_progress = Mock()
result, engine = worker._standard_baseline(start_percent=4, end_percent=96)
self.assertEqual(result, {"192.168.50.1"})
self.assertEqual(engine, "nmap")
worker._naabu_ports.assert_not_called()
self.assertFalse(worker._nmap_standard_ports.call_args.kwargs["fallback"])
def test_naabu_failure_falls_back_only_on_large_discovered_set(self):
worker = self._worker()
live = {f"192.168.50.{i}" for i in range(1, scanner.NAABU_ACTIVE_HOST_THRESHOLD + 1)}
worker._standard_discovery = Mock(return_value=live)
worker._naabu_ports = Mock(return_value=None)
worker._nmap_standard_ports = Mock(return_value=[])
worker._neighbor_hosts = Mock(return_value=[])
worker._set_progress = Mock()
result, engine = worker._standard_baseline(start_percent=4, end_percent=96)
self.assertEqual(engine, "nmap-fallback")
self.assertEqual(result, live)
fallback_ips = worker._nmap_standard_ports.call_args.args[0]
self.assertEqual(fallback_ips, sorted(live, key=scanner.ipaddress.ip_address))
self.assertTrue(worker._nmap_standard_ports.call_args.kwargs["fallback"])
def test_deep_reuses_adaptive_baseline_before_enrichment(self):
req = scanner.ScanRequest(
target="192.168.50.0/30", profile="Approfondi", interface=None, privileged=False
)
worker = scanner.ScanWorker(req)
worker._set_progress = Mock()
worker._standard_baseline = Mock(return_value=({"192.168.50.1"}, "nmap"))
worker._nmap = Mock(return_value=[])
worker._neighbor_hosts = Mock(return_value=[])
worker.run()
worker._standard_baseline.assert_called_once()
worker._nmap.assert_called_once()
args = worker._nmap.call_args.args[0]
self.assertIn("-sV", args)
self.assertIn("1000", args)
class Diagnostics0421Tests(unittest.TestCase):
def test_ui_distinguishes_user_and_admin_naabu(self):
source = (ROOT / "src/librenet_scanner/ui.py").read_text(encoding="utf-8")
self.assertIn("Naabu utilisateur", source)
self.assertIn("Naabu Admin SYN", source)
self.assertIn("naabu_diagnostic()", source)
def test_profile_signatures_identify_current_pipeline(self):
source = (ROOT / "src/librenet_scanner/scanner.py").read_text(encoding="utf-8")
self.assertIn("standard-v9:adaptive-nmap-small-naabu-large", source)
self.assertIn("deep-v10:adaptive-standard-baseline-nmap-enrichment", source)
if __name__ == "__main__":
unittest.main()
+262
View File
@@ -0,0 +1,262 @@
import hashlib
import importlib
import io
import os
import stat
import subprocess
import sys
import tempfile
import threading
import time
import types
import unittest
import zipfile
from pathlib import Path
from unittest.mock import Mock, patch
from librenet_scanner import naabu_runtime
from librenet_scanner.fastscan import BUNDLED_NAABU_PATH, SYSTEM_NAABU_CANDIDATES
from librenet_scanner.privileged_helper import command_for
ROOT = Path(__file__).resolve().parents[1]
# Surface Qt minimale pour pouvoir exécuter réellement ScanWorker dans le builder.
if "PySide6.QtCore" not in sys.modules:
qtcore = types.ModuleType("PySide6.QtCore")
pyside = types.ModuleType("PySide6")
class DummySignal:
def __init__(self, *args, **kwargs):
self.calls = []
def emit(self, *args):
self.calls.append(args)
class DummyQThread:
def __init__(self, parent=None):
self._interrupted = False
def requestInterruption(self):
self._interrupted = True
def isInterruptionRequested(self):
return self._interrupted
qtcore.Signal = DummySignal
qtcore.QThread = DummyQThread
pyside.QtCore = qtcore
sys.modules["PySide6"] = pyside
sys.modules["PySide6.QtCore"] = qtcore
scanner = importlib.import_module("librenet_scanner.scanner")
class NaabuProvisioning0422Tests(unittest.TestCase):
def _archive(self, payload: bytes) -> bytes:
stream = io.BytesIO()
with zipfile.ZipFile(stream, "w", zipfile.ZIP_DEFLATED) as zf:
zf.writestr("naabu", payload)
return stream.getvalue()
def test_runtime_constants_pin_official_261_amd64_release(self):
self.assertEqual(naabu_runtime.NAABU_VERSION, "2.6.1")
self.assertTrue(naabu_runtime.NAABU_AMD64_URL.endswith("/v2.6.1/naabu_2.6.1_linux_amd64.zip"))
self.assertEqual(
naabu_runtime.NAABU_AMD64_SHA256,
"018c4c9884dea971eda860435ede3021d1150732f34cfd245498c6726d8cab90",
)
self.assertEqual(BUNDLED_NAABU_PATH, "/usr/lib/librenet-scanner/bin/naabu")
self.assertEqual(SYSTEM_NAABU_CANDIDATES[0], BUNDLED_NAABU_PATH)
def test_offline_simulated_install_verifies_archive_and_installs_atomically(self):
fake_binary = b"\x7fELF" + b"LibreNet fake Naabu test payload\n"
archive = self._archive(fake_binary)
sha = hashlib.sha256(archive).hexdigest()
with tempfile.TemporaryDirectory() as td:
destination = str(Path(td) / "bin" / "naabu")
with patch.object(naabu_runtime, "_machine_is_amd64", return_value=True), \
patch.object(naabu_runtime, "naabu_version", return_value="2.6.1"):
installed = naabu_runtime.install_naabu(
destination=destination,
url="https://example.invalid/naabu.zip",
expected_sha256=sha,
download_func=lambda _url: archive,
require_root=False,
)
self.assertEqual(installed, destination)
self.assertEqual(Path(destination).read_bytes(), fake_binary)
self.assertTrue(os.stat(destination).st_mode & stat.S_IXUSR)
def test_checksum_mismatch_refuses_install(self):
archive = self._archive(b"\x7fELFbad")
with tempfile.TemporaryDirectory() as td:
destination = str(Path(td) / "naabu")
with patch.object(naabu_runtime, "_machine_is_amd64", return_value=True):
with self.assertRaises(naabu_runtime.NaabuProvisionError):
naabu_runtime.install_naabu(
destination=destination,
expected_sha256="0" * 64,
download_func=lambda _url: archive,
require_root=False,
)
self.assertFalse(Path(destination).exists())
def test_version_probe_isolated_from_user_config_cloud_and_updates(self):
completed = subprocess.CompletedProcess(args=[], returncode=0, stdout="Current Version: v2.6.1\n")
with patch("librenet_scanner.naabu_runtime.subprocess.run", return_value=completed) as run:
self.assertEqual(naabu_runtime.naabu_version("/tmp/naabu"), "2.6.1")
args = run.call_args.args[0]
self.assertIn("-disable-update-check", args)
self.assertEqual(args[args.index("-config") + 1], "/dev/null")
self.assertIn("-auth=false", args)
def test_runtime_ignores_external_path_and_requires_private_exact_engine(self):
fastscan = importlib.import_module("librenet_scanner.fastscan")
with tempfile.TemporaryDirectory() as td:
external = Path(td) / "naabu"
external.write_text("#!/bin/sh\necho Current Version: v2.6.1\n")
external.chmod(0o755)
with patch.object(fastscan, "BUNDLED_NAABU_PATH", str(Path(td) / "missing-private-naabu")), \
patch.dict(os.environ, {"PATH": td}, clear=False):
self.assertIsNone(fastscan.find_naabu())
def test_runtime_refuses_private_engine_with_wrong_version(self):
fastscan = importlib.import_module("librenet_scanner.fastscan")
with tempfile.TemporaryDirectory() as td:
private = Path(td) / "naabu"
private.write_text("fake")
private.chmod(0o755)
with patch.object(fastscan, "BUNDLED_NAABU_PATH", str(private)), \
patch.object(fastscan, "naabu_version", return_value="2.6.0"):
self.assertIsNone(fastscan.find_naabu())
class NaabuPipeline0422Tests(unittest.TestCase):
def test_standard_uses_discovery_then_fast_ports_in_user_or_admin_mode(self):
for privileged in (False, True):
request = scanner.ScanRequest(
target="192.168.10.1", profile="Standard", interface=None, privileged=privileged
)
worker = scanner.ScanWorker(request)
worker._standard_baseline = Mock(return_value=({"192.168.10.1"}, "nmap"))
worker._set_progress = Mock()
worker._standard_scan()
worker._standard_baseline.assert_called_once()
def test_user_naabu_port_command_is_connect_pn_and_not_nmap(self):
request = scanner.ScanRequest(
target="192.168.10.1", profile="Standard", interface=None, privileged=False
)
worker = scanner.ScanWorker(request)
worker._run_naabu_json = Mock(return_value=[])
with patch("librenet_scanner.scanner.find_naabu", return_value=BUNDLED_NAABU_PATH):
result = worker._naabu_ports(["192.168.10.1"], start_percent=20, end_percent=80)
self.assertEqual(result, [])
cmd = worker._run_naabu_json.call_args.args[0]
self.assertEqual(cmd[0], BUNDLED_NAABU_PATH)
self.assertEqual(cmd[cmd.index("-scan-type") + 1], "c")
self.assertIn("-Pn", cmd)
self.assertNotIn("nmap", " ".join(cmd).lower())
def test_nonprivileged_deep_command_can_be_stopped_while_silent(self):
request = scanner.ScanRequest(
target="192.168.10.1", profile="Approfondi", interface=None, privileged=False
)
worker = scanner.ScanWorker(request)
def request_stop():
time.sleep(0.25)
worker.stop()
threading.Thread(target=request_stop, daemon=True).start()
started = time.monotonic()
code, _out, _err = worker._run_command(
[sys.executable, "-c", "import time; time.sleep(30)"],
"Nmap approfondi silencieux", start_percent=1, end_percent=2,
)
elapsed = time.monotonic() - started
self.assertNotEqual(code, 0)
self.assertLess(elapsed, 4.0, f"annulation Nmap trop lente: {elapsed:.2f}s")
def test_admin_helper_prefers_librenet_runtime_and_keeps_port_prefilter_disabled(self):
with patch("librenet_scanner.privileged_helper._trusted_binary", side_effect=lambda *candidates: candidates[0]) as trusted:
cmd = command_for("naabu-standard", ["192.168.10.10"])
trusted.assert_called_once_with(BUNDLED_NAABU_PATH)
self.assertEqual(cmd[0], BUNDLED_NAABU_PATH)
self.assertIn("-Pn", cmd)
self.assertNotIn("-wn", cmd)
self.assertEqual(cmd[cmd.index("-scan-type") + 1], "s")
self.assertIn("-stream", cmd)
self.assertNotIn("-verify", cmd)
self.assertNotIn("-retries", cmd)
self.assertEqual(cmd[cmd.index("-warm-up-time") + 1], "0")
def test_standard_profile_signature_changes_for_provisioned_runtime(self):
self.assertIn("standard-v9:adaptive-nmap-small-naabu-large", scanner.PROFILE_SIGNATURES["Standard"])
self.assertIn("deep-v10:adaptive-standard-baseline", scanner.PROFILE_SIGNATURES["Approfondi"])
def test_silent_naabu_can_be_stopped_without_waiting_for_stdout(self):
request = scanner.ScanRequest(
target="192.168.10.1", profile="Standard", interface=None, privileged=False
)
worker = scanner.ScanWorker(request)
def request_stop():
time.sleep(0.25)
worker.stop()
stopper = threading.Thread(target=request_stop, daemon=True)
stopper.start()
started = time.monotonic()
result = worker._run_naabu_json(
[sys.executable, "-c", "import time; time.sleep(30)"],
"test Naabu silencieux",
lambda _line: None,
start_percent=1,
end_percent=2,
phase="le scan de ports",
)
elapsed = time.monotonic() - started
stopper.join(timeout=1)
self.assertEqual(result, [])
self.assertLess(elapsed, 4.0, f"annulation trop lente: {elapsed:.2f}s")
def test_detailed_worker_stop_terminates_its_process_group(self):
worker = scanner.HostScanWorker("127.0.0.1", privileged=False)
proc = subprocess.Popen(
[sys.executable, "-c", "import time; time.sleep(30)"],
start_new_session=True,
)
worker._proc = proc
started = time.monotonic()
worker.stop()
proc.wait(timeout=4)
self.assertLess(time.monotonic() - started, 4.0)
self.assertTrue(worker.isInterruptionRequested())
class Packaging0422Tests(unittest.TestCase):
def test_debian_metadata_is_amd64_and_installation_has_no_mandatory_network_access(self):
control = (ROOT / "packaging/debian/control").read_text(encoding="utf-8")
postinst = (ROOT / "packaging/debian/postinst").read_text(encoding="utf-8")
build = (ROOT / "packaging/build-deb.sh").read_text(encoding="utf-8")
self.assertIn("Version: 1.0.0", control)
self.assertIn("Architecture: amd64", control)
self.assertIn("set -eu", postinst)
self.assertNotIn("librenet-scanner-install-naabu --ensure", postinst)
self.assertIn("Aucune dépendance réseau", postinst)
self.assertIn("VERSION=1.0.0", build)
self.assertIn("ARCH=amd64", build)
def test_runtime_installer_is_packaged_and_postrm_removes_runtime(self):
build = (ROOT / "packaging/build-deb.sh").read_text(encoding="utf-8")
postrm = (ROOT / "packaging/debian/postrm").read_text(encoding="utf-8")
self.assertIn("librenet-scanner-install-naabu", build)
self.assertIn("/usr/lib/librenet-scanner/bin", build)
self.assertIn("NAABU-LICENSE.txt", build)
self.assertIn("/usr/lib/librenet-scanner/bin/naabu", postrm)
if __name__ == "__main__":
unittest.main()
+190
View File
@@ -0,0 +1,190 @@
import importlib
import sys
import threading
import time
import types
import unittest
from unittest.mock import Mock, patch
if "PySide6.QtCore" not in sys.modules:
qtcore = types.ModuleType("PySide6.QtCore")
pyside = types.ModuleType("PySide6")
class DummySignal:
def __init__(self, *args, **kwargs):
self.calls = []
def emit(self, *args):
self.calls.append(args)
class DummyQThread:
def __init__(self, parent=None):
self._interrupted = False
def requestInterruption(self):
self._interrupted = True
def isInterruptionRequested(self):
return self._interrupted
qtcore.Signal = DummySignal
qtcore.QThread = DummyQThread
pyside.QtCore = qtcore
sys.modules["PySide6"] = pyside
sys.modules["PySide6.QtCore"] = qtcore
scanner = importlib.import_module("librenet_scanner.scanner")
class PerformancePipeline0423Tests(unittest.TestCase):
def worker(self, privileged=False, target="192.168.10.0/24"):
return scanner.ScanWorker(scanner.ScanRequest(target, "Standard", None, privileged))
def test_user_case_four_live_hosts_uses_bounded_nmap_not_naabu(self):
worker = self.worker()
live = {"192.168.10.1", "192.168.10.22", "192.168.10.226", "192.168.10.254"}
worker._standard_discovery = Mock(return_value=live)
worker._naabu_ports = Mock(side_effect=AssertionError("Naabu ne doit pas démarrer pour 4 hôtes"))
worker._nmap_standard_ports = Mock(return_value=[])
worker._neighbor_hosts = Mock(return_value=[])
worker._set_progress = Mock()
result, engine = worker._standard_baseline(start_percent=4, end_percent=96)
self.assertEqual(result, live)
self.assertEqual(engine, "nmap")
worker._naabu_ports.assert_not_called()
scanned = worker._nmap_standard_ports.call_args.args[0]
self.assertEqual(scanned, sorted(live, key=scanner.ipaddress.ip_address))
self.assertFalse(worker._nmap_standard_ports.call_args.kwargs["fallback"])
def test_large_active_set_uses_naabu_only_on_discovered_hosts(self):
worker = self.worker()
live = {f"192.168.10.{i}" for i in range(1, scanner.NAABU_ACTIVE_HOST_THRESHOLD + 1)}
worker._standard_discovery = Mock(return_value=live)
worker._naabu_ports = Mock(return_value=[])
worker._nmap_standard_ports = Mock(side_effect=AssertionError("pas de Nmap ports nominal sur grand ensemble"))
worker._neighbor_hosts = Mock(return_value=[])
result, engine = worker._standard_baseline(start_percent=4, end_percent=96)
self.assertEqual(result, live)
self.assertEqual(engine, "naabu")
scanned = worker._naabu_ports.call_args.args[0]
self.assertEqual(scanned, sorted(live, key=scanner.ipaddress.ip_address))
self.assertEqual(len(scanned), scanner.NAABU_ACTIVE_HOST_THRESHOLD)
def test_naabu_is_split_into_small_bounded_batches(self):
worker = self.worker()
ips = [f"192.168.10.{i}" for i in range(1, 71)]
worker._run_naabu_json = Mock(return_value=[])
with patch("librenet_scanner.scanner.find_naabu", return_value="/usr/lib/librenet-scanner/bin/naabu"):
result = worker._naabu_ports(ips, start_percent=20, end_percent=80)
self.assertEqual(result, [])
self.assertEqual(worker._run_naabu_json.call_count, 3)
for call in worker._run_naabu_json.call_args_list:
cmd = call.args[0]
hosts = cmd[cmd.index("-host") + 1].split(",")
self.assertLessEqual(len(hosts), scanner.NAABU_BATCH_SIZE)
self.assertIn("-stream", cmd)
self.assertNotIn("-verify", cmd)
self.assertNotIn("-retries", cmd)
self.assertEqual(call.kwargs["timeout_seconds"], scanner.NAABU_BATCH_TIMEOUT_SECONDS)
def test_silent_naabu_batch_hits_wall_clock_timeout(self):
worker = self.worker(target="127.0.0.1")
started = time.monotonic()
result = worker._run_naabu_json(
[sys.executable, "-c", "import time; time.sleep(30)"],
"Naabu timeout test", lambda _line: None,
start_percent=1, end_percent=2, phase="le scan de ports", timeout_seconds=0.30,
)
elapsed = time.monotonic() - started
self.assertIsNone(result)
self.assertFalse(worker.isInterruptionRequested())
self.assertLess(elapsed, 4.0)
def test_generic_command_timeout_is_bounded(self):
worker = self.worker(target="127.0.0.1")
started = time.monotonic()
code, _out, err = worker._run_command(
[sys.executable, "-c", "import time; time.sleep(30)"],
"timeout test", timeout_seconds=0.30,
)
elapsed = time.monotonic() - started
self.assertEqual(code, 124)
self.assertIn("Délai maximal dépassé", err)
self.assertLess(elapsed, 4.0)
def test_arp_scan_itself_is_bounded(self):
worker = self.worker()
iface = types.SimpleNamespace(
name="enp42s0", address="192.168.10.1", prefixlen=24,
network="192.168.10.0/24", mac="",
)
worker.request.interface = iface
worker._run_command = Mock(return_value=(0, "", ""))
with patch("librenet_scanner.scanner.find_arp_scan", return_value="/usr/bin/arp-scan"):
worker._emit_arp(start_percent=5, end_percent=15)
self.assertEqual(worker._run_command.call_args.kwargs["timeout_seconds"], 8.0)
def test_standard_discovery_is_single_fast_nmap_pass_after_arp(self):
worker = self.worker()
worker._emit_local_host = Mock(return_value=[])
worker._emit_arp = Mock(return_value=[])
worker._neighbor_hosts = Mock(return_value=[])
worker._set_progress = Mock()
worker._nmap = Mock(return_value=[])
result = worker._standard_discovery(
start_percent=4, end_percent=35,
target_ips=[f"192.168.10.{i}" for i in range(1, 255)],
)
self.assertEqual(result, set())
worker._nmap.assert_called_once()
cmd = worker._nmap.call_args.args[0]
self.assertEqual(cmd[:6], ["nmap", "-sn", "-n", "-T4", "--max-retries", "1"])
self.assertIsNotNone(worker._nmap.call_args.kwargs["timeout_seconds"])
def test_naabu_failure_does_not_rescan_dead_addresses_with_nmap(self):
worker = self.worker()
live = {f"192.168.10.{i}" for i in range(1, scanner.NAABU_ACTIVE_HOST_THRESHOLD + 1)}
worker._standard_discovery = Mock(return_value=live)
worker._naabu_ports = Mock(return_value=None)
worker._nmap_standard_ports = Mock(return_value=[])
worker._neighbor_hosts = Mock(return_value=[])
worker._set_progress = Mock()
_known, engine = worker._standard_baseline(start_percent=4, end_percent=96)
self.assertEqual(engine, "nmap-fallback")
self.assertEqual(worker._nmap_standard_ports.call_args.args[0], sorted(live, key=scanner.ipaddress.ip_address))
self.assertTrue(worker._nmap_standard_ports.call_args.kwargs["fallback"])
def test_small_nmap_profile_is_bounded_and_only_receives_live_hosts(self):
worker = self.worker()
worker._nmap = Mock(return_value=[])
live = ["192.168.10.22", "192.168.10.226"]
result = worker._nmap_standard_ports(live, start_percent=20, end_percent=80, fallback=False)
self.assertEqual(result, [])
cmd = worker._nmap.call_args.args[0]
self.assertEqual(cmd[-2:], live)
self.assertIn("-n", cmd)
self.assertEqual(cmd[cmd.index("--max-retries") + 1], "1")
self.assertEqual(cmd[cmd.index("--host-timeout") + 1], "12s")
self.assertIsNotNone(worker._nmap.call_args.kwargs["timeout_seconds"])
def test_privileged_small_nmap_profile_disables_dns_and_bounds_hosts(self):
with patch("librenet_scanner.privileged_helper._trusted_binary", return_value="/usr/bin/nmap"):
from librenet_scanner.privileged_helper import command_for
cmd = command_for("nmap-standard", ["192.168.10.22", "192.168.10.226"])
self.assertIn("-n", cmd)
self.assertEqual(cmd[cmd.index("--max-retries") + 1], "1")
self.assertEqual(cmd[cmd.index("--host-timeout") + 1], "12s")
self.assertEqual(cmd[-2:], ["192.168.10.22", "192.168.10.226"])
def test_admin_naabu_command_is_fast_profile(self):
with patch("librenet_scanner.privileged_helper._trusted_binary", return_value="/usr/lib/librenet-scanner/bin/naabu"):
from librenet_scanner.privileged_helper import command_for
cmd = command_for("naabu-standard", ["192.168.10.22"])
self.assertIn("-stream", cmd)
self.assertEqual(cmd[cmd.index("-c") + 1], "100")
self.assertEqual(cmd[cmd.index("-rate") + 1], "2500")
self.assertEqual(cmd[cmd.index("-timeout") + 1], "800ms")
self.assertNotIn("-verify", cmd)
self.assertNotIn("-retries", cmd)
if __name__ == "__main__":
unittest.main()
+29
View File
@@ -0,0 +1,29 @@
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
class Branding043RegressionTests(unittest.TestCase):
def test_application_no_longer_uses_generic_network_wired_icon(self):
main = (ROOT / "src/librenet_scanner/main.py").read_text()
self.assertNotIn('QIcon.fromTheme("network-wired")', main)
self.assertIn('librenet-scanner.svg', main)
self.assertIn('QIcon.fromTheme(APP_ICON_NAME)', main)
def test_plasma_desktop_file_identity_is_declared(self):
main = (ROOT / "src/librenet_scanner/main.py").read_text()
desktop = (ROOT / "assets/librenet-scanner.desktop").read_text()
self.assertIn('setDesktopFileName(APP_DESKTOP_ID)', main)
self.assertIn('Icon=librenet-scanner', desktop)
self.assertIn('StartupWMClass=librenet-scanner', desktop)
def test_main_window_receives_same_application_icon(self):
main = (ROOT / "src/librenet_scanner/main.py").read_text()
self.assertIn('app.setWindowIcon(icon)', main)
self.assertIn('window.setWindowIcon(icon)', main)
if __name__ == "__main__":
unittest.main()
+24
View File
@@ -0,0 +1,24 @@
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
class Discovery044RegressionTests(unittest.TestCase):
def test_standard_discovers_before_port_scan(self):
scanner = (ROOT / 'src/librenet_scanner/scanner.py').read_text()
block = scanner[scanner.index('def _standard_baseline'):scanner.index('def _standard_scan')]
discovery = block.index('self._standard_discovery(')
ports = block.index('self._naabu_ports(')
self.assertLess(discovery, ports)
self.assertIn('ips = sorted(known_ips', block)
def test_standard_discovery_disables_dns_and_bounds_retries(self):
scanner = (ROOT / 'src/librenet_scanner/scanner.py').read_text()
block = scanner[scanner.index('def _standard_discovery'):scanner.index('def _standard_baseline')]
self.assertIn('"-sn", "-n", "-T4", "--max-retries", "1"', block)
self.assertIn('timeout_seconds=discovery_timeout', block)
if __name__ == '__main__':
unittest.main()
+39
View File
@@ -0,0 +1,39 @@
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
class UI045PolishTests(unittest.TestCase):
def test_type_column_stretches_to_fill_unused_space(self):
ui = (ROOT / "src/librenet_scanner/ui.py").read_text()
self.assertIn("header.setSectionResizeMode(2, QHeaderView.Stretch)", ui)
self.assertIn("self.tree.header().setSectionResizeMode(2, QHeaderView.Stretch)", ui)
def test_scan_progress_does_not_write_phase_to_statusbar(self):
ui = (ROOT / "src/librenet_scanner/ui.py").read_text()
start = ui.index("def _progress(self, message: str)")
end = ui.index("def _progress_state", start)
block = ui[start:end]
self.assertIn("self.activity_label.setText(message)", block)
self.assertNotIn("statusBar().showMessage", block)
finished = ui[ui.index("def _scan_finished"):ui.index("# ---------- Arbre", ui.index("def _scan_finished"))]
self.assertIn("self.statusBar().clearMessage()", finished)
self.assertIn('self.progress.setFormat("100%")', finished)
def test_worker_exposes_stage_aware_progress(self):
scanner = (ROOT / "src/librenet_scanner/scanner.py").read_text()
self.assertIn("progress_state = Signal(int, str)", scanner)
self.assertIn("self.progress_state.emit(-1, label)", scanner)
self.assertIn('self._set_progress(100, "Finalisation du scan…")', scanner)
self.assertTrue("start_percent=62, end_percent=96" in scanner or "start_percent=64, end_percent=96" in scanner)
def test_progress_bar_is_visible_and_readable(self):
ui = (ROOT / "src/librenet_scanner/ui.py").read_text()
self.assertIn("self.progress.setMinimumWidth(240)", ui)
self.assertIn("min-height: 16px", ui)
self.assertIn('self.progress.setFormat("%p%")', ui)
if __name__ == "__main__":
unittest.main()
+62
View File
@@ -0,0 +1,62 @@
import unittest
from pathlib import Path
from unittest.mock import patch
from librenet_scanner.intelligence import enrich_host
from librenet_scanner.models import Host
from librenet_scanner.network import (
NetworkInterface,
interface_mac_address,
normalize_interface_mac,
target_contains_ip,
)
ROOT = Path(__file__).resolve().parents[1]
class LocalHost046Tests(unittest.TestCase):
def test_mac_normalization(self):
self.assertEqual(normalize_interface_mac("aa:bb:cc:dd:ee:ff\n"), "AA:BB:CC:DD:EE:FF")
self.assertEqual(normalize_interface_mac("00:00:00:00:00:00"), "")
self.assertEqual(normalize_interface_mac("invalid"), "")
def test_target_contains_local_ip_for_cidr_and_range(self):
self.assertTrue(target_contains_ip("192.168.10.0/24", "192.168.10.1"))
self.assertTrue(target_contains_ip("192.168.10.1-254", "192.168.10.1"))
self.assertFalse(target_contains_ip("192.168.10.100-254", "192.168.10.1"))
self.assertFalse(target_contains_ip("192.168.5.0/24", "192.168.10.1"))
def test_sysfs_mac_is_preferred(self):
with patch("pathlib.Path.read_text", return_value="a6:2b:b0:a5:49:a7\n"):
self.assertEqual(interface_mac_address("enp42s0"), "A6:2B:B0:A5:49:A7")
def test_local_host_classification_has_priority(self):
host = Host("192.168.10.1", is_local=True)
enrich_host(host)
self.assertEqual(host.device_type, "Ce poste")
def test_local_flag_survives_merge(self):
current = Host("192.168.10.1", is_local=True, mac="AA:BB:CC:DD:EE:FF")
current.merge(Host("192.168.10.1", device_type="Serveur Linux"))
enrich_host(current)
self.assertTrue(current.is_local)
self.assertEqual(current.device_type, "Ce poste")
def test_interface_keeps_mac_without_breaking_old_positional_signature(self):
old_style = NetworkInterface("enp42s0", "192.168.10.1", 24, "192.168.10.0/24", False)
self.assertFalse(old_style.is_virtual)
self.assertEqual(old_style.mac, "")
new_style = NetworkInterface("enp42s0", "192.168.10.1", 24, "192.168.10.0/24", False, "AA:BB:CC:DD:EE:FF")
self.assertEqual(new_style.mac, "AA:BB:CC:DD:EE:FF")
def test_scanner_injects_local_host_additively(self):
scanner = (ROOT / "src/librenet_scanner/scanner.py").read_text()
block = scanner[scanner.index("def _discover_hosts"):scanner.index("def run(self)")]
self.assertIn("local_hosts = self._emit_local_host()", block)
self.assertIn("known_ips.update(union_host_ips(local_hosts))", block)
self.assertIn("interface_mac_address(iface.name)", scanner)
if __name__ == "__main__":
unittest.main()
+78
View File
@@ -0,0 +1,78 @@
import json
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from librenet_scanner.online_vendor import (
PROVIDER_MACLOOKUP,
PROVIDER_MACVENDORS,
is_locally_administered,
lookup_online_vendor,
normalize_mac,
)
from librenet_scanner.storage import HistoryStore
from librenet_scanner.parsers import parse_arp_scan
ROOT = Path(__file__).resolve().parents[1]
class OnlineVendor047Tests(unittest.TestCase):
def test_arp_scan_locally_administered_is_not_mistaken_for_vendor(self):
hosts = parse_arp_scan("192.168.10.250\ta6:2b:b0:a5:49:a7\t(Unknown: locally administered) (DUP: 2)")
self.assertEqual(len(hosts), 1)
self.assertEqual(hosts[0].vendor, "")
def test_mac_normalization_and_laa_detection(self):
self.assertEqual(normalize_mac("a6:2b:b0:a5:49:a7"), "A6:2B:B0:A5:49:A7")
self.assertTrue(is_locally_administered("a6:2b:b0:a5:49:a7"))
self.assertFalse(is_locally_administered("00:11:22:33:44:55"))
def test_maclookup_response_supports_modern_assignment_metadata(self):
payload = {
"success": True,
"found": True,
"company": "TP-Link Corporation Limited",
"blockType": "MA-M",
"isRand": False,
"isPrivate": False,
}
with patch("librenet_scanner.online_vendor._request", return_value=json.dumps(payload).encode()):
result = lookup_online_vendor("00:11:22:33:44:55", PROVIDER_MACLOOKUP)
self.assertTrue(result.found)
self.assertEqual(result.vendor, "TP-Link Corporation Limited")
self.assertEqual(result.block_type, "MA-M")
def test_macvendors_plain_text_response(self):
with patch("librenet_scanner.online_vendor._request", return_value=b"TP-Link Technologies Co., Ltd.\n"):
result = lookup_online_vendor("00:11:22:33:44:55", PROVIDER_MACVENDORS)
self.assertEqual(result.vendor, "TP-Link Technologies Co., Ltd.")
self.assertTrue(result.found)
def test_online_cache_roundtrip(self):
with tempfile.TemporaryDirectory() as tmp:
store = HistoryStore(Path(tmp) / "history.sqlite3")
store.save_online_vendor_cache(
"00:11:22:33:44:55",
PROVIDER_MACLOOKUP,
vendor="Example Vendor",
found=True,
block_type="MA-L",
)
cached = store.online_vendor_cache("00-11-22-33-44-55", PROVIDER_MACLOOKUP)
self.assertIsNotNone(cached)
self.assertEqual(cached["vendor"], "Example Vendor")
self.assertTrue(cached["from_cache"])
def test_ui_exposes_opt_in_privacy_setting_and_manual_lookup(self):
ui = (ROOT / "src/librenet_scanner/ui.py").read_text()
self.assertIn('privacy/onlineMacLookupEnabled', ui)
self.assertIn('Interroger automatiquement une base en ligne', ui)
self.assertIn('L\'option est désactivée par défaut', ui)
self.assertIn('Rechercher en ligne', ui)
self.assertIn('_start_automatic_online_vendor_lookup()', ui)
if __name__ == "__main__":
unittest.main()
+43
View File
@@ -0,0 +1,43 @@
import unittest
from pathlib import Path
from unittest.mock import patch
from librenet_scanner.privileged_helper import command_for
ROOT = Path(__file__).resolve().parents[1]
class DeepScan048RegressionTests(unittest.TestCase):
def test_privileged_deep_hosts_force_pn_and_scan_confirmed_ips(self):
with patch("librenet_scanner.privileged_helper._trusted_binary", return_value="/usr/bin/nmap"):
cmd = command_for("nmap-deep-hosts", ["192.168.10.1", "192.168.10.254"])
self.assertIn("-Pn", cmd)
self.assertIn("-sS", cmd)
self.assertIn("-sV", cmd)
self.assertIn("-O", cmd)
self.assertIn("1000", cmd)
self.assertEqual(cmd[-2:], ["192.168.10.1", "192.168.10.254"])
def test_privileged_deep_hosts_rejects_network_targets(self):
with patch("librenet_scanner.privileged_helper._trusted_binary", return_value="/usr/bin/nmap"):
with self.assertRaises(ValueError):
command_for("nmap-deep-hosts", ["192.168.10.0/24"])
def test_deep_profile_reuses_adaptive_standard_baseline(self):
scanner = (ROOT / "src/librenet_scanner/scanner.py").read_text()
start = scanner.index('elif profile == "Approfondi"')
end = scanner.index('else:\n raise RuntimeError', start)
block = scanner[start:end]
self.assertIn("self._standard_baseline(", block)
self.assertIn('privileged_command("nmap-deep-hosts", *ips)', block)
self.assertIn('"nmap", "-Pn", "-n", "-sT"', block)
self.assertIn('"--top-ports", "1000"', block)
self.assertNotIn('privileged_command("nmap-deep", target)', block)
def test_deep_profile_signature_tracks_current_pipeline(self):
scanner = (ROOT / "src/librenet_scanner/scanner.py").read_text()
self.assertIn('deep-v10:adaptive-standard-baseline-nmap-enrichment:', scanner)
if __name__ == "__main__":
unittest.main()
+313
View File
@@ -0,0 +1,313 @@
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()