first commit
This commit is contained in:
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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 l’affichage", text)
|
||||
self.assertIn("Oublier les identifications", text)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user