Files
librenet-scanner/src/librenet_scanner/models.py
T
2026-08-23 09:52:32 +02:00

210 lines
8.0 KiB
Python

from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime, timezone
@dataclass(slots=True)
class PortInfo:
port: int
protocol: str = "tcp"
state: str = "open"
service: str = ""
product: str = ""
version: str = ""
@property
def label(self) -> str:
base = f"{self.port}/{self.protocol}"
if self.service:
base += f" ({self.service})"
return base
@property
def details(self) -> str:
parts = [self.label]
product = " ".join(part for part in (self.product, self.version) if part).strip()
if product:
parts.append(product)
return " — ".join(parts)
@dataclass(slots=True)
class Host:
ip: str
hostname: str = ""
mac: str = ""
vendor: str = ""
status: str = "up"
os_name: str = ""
os_accuracy: int | None = None
latency_ms: float | None = None
ports: list[PortInfo] = field(default_factory=list)
last_seen: str = field(default_factory=lambda: datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds"))
device_type: str = "Hôte"
change_status: str = ""
change_detail: str = ""
previous_ip: str = ""
is_local: bool = False
# V0.4.9 : la meilleure identification connue est volontairement séparée
# des données du scan courant. Ainsi un scan Standard peut afficher un OS
# découvert précédemment en Approfondi sans prétendre l'avoir redétecté.
remembered_os_name: str = ""
remembered_os_accuracy: int | None = None
remembered_device_type: str = ""
remembered_os_source: str = ""
remembered_type_source: str = ""
remembered_os_seen_at: str = ""
remembered_type_seen_at: str = ""
remembered_match_score: int = 0
remembered_match_reason: str = ""
remembered_identity_kind: str = ""
def merge(self, other: "Host") -> "Host":
if other.hostname:
self.hostname = other.hostname
if other.mac:
self.mac = other.mac.upper()
if other.vendor:
self.vendor = other.vendor
if other.os_name:
self.os_name = other.os_name
if other.os_accuracy is not None:
self.os_accuracy = other.os_accuracy
if other.latency_ms is not None:
self.latency_ms = other.latency_ms
if other.ports:
known = {(p.port, p.protocol): p for p in self.ports}
for port in other.ports:
known[(port.port, port.protocol)] = port
self.ports = sorted(known.values(), key=lambda p: (p.protocol, p.port))
if other.device_type and other.device_type != "Hôte":
self.device_type = other.device_type
self.is_local = self.is_local or other.is_local
self.status = other.status or self.status
self.last_seen = other.last_seen or self.last_seen
# Les informations mémorisées peuvent arriver avant ou après les données
# Nmap du scan courant. On les fusionne sans jamais écraser une valeur déjà
# plus complète portée par l'objet courant.
if other.remembered_os_name:
self.remembered_os_name = other.remembered_os_name
self.remembered_os_accuracy = other.remembered_os_accuracy
self.remembered_os_source = other.remembered_os_source
self.remembered_os_seen_at = other.remembered_os_seen_at
if other.remembered_device_type:
self.remembered_device_type = other.remembered_device_type
self.remembered_type_source = other.remembered_type_source
self.remembered_type_seen_at = other.remembered_type_seen_at
if other.remembered_match_score:
self.remembered_match_score = other.remembered_match_score
self.remembered_match_reason = other.remembered_match_reason
self.remembered_identity_kind = other.remembered_identity_kind
return self
@staticmethod
def _os_family(value: str) -> str:
folded = (value or "").casefold()
if "android" in folded:
return "android"
if any(token in folded for token in (
"macos", "mac os x", "darwin", "iphone os", "apple ios",
"ipados", "apple tv", "tvos",
)):
return "apple"
for token, family in (
("openwrt", "linux"), ("proxmox", "linux"), ("synology", "linux"),
("debian", "linux"), ("ubuntu", "linux"), ("fedora", "linux"),
("centos", "linux"), ("red hat", "linux"), ("linux", "linux"),
("opnsense", "freebsd"), ("pfsense", "freebsd"), ("freebsd", "freebsd"),
("openbsd", "openbsd"), ("netbsd", "netbsd"), ("windows", "windows"),
("routeros", "routeros"), ("vmware", "vmware"), ("esxi", "vmware"),
("fortios", "fortios"), ("junos", "junos"), ("ios xe", "iosxe"),
("cisco ios", "ios"),
):
if token in folded:
return family
return ""
@property
def effective_os_name(self) -> str:
"""OS affiché : le courant gagne en cas de contradiction, sinon le plus précis."""
current = (self.os_name or "").strip()
remembered = (self.remembered_os_name or "").strip()
if not current:
return remembered
if not remembered:
return current
current_family = self._os_family(current)
remembered_family = self._os_family(remembered)
if current_family and remembered_family and current_family != remembered_family:
return current
# Un scan Standard peut seulement redonner "Linux" alors qu'un ancien
# Approfondi avait identifié OpenWrt/Debian/etc. Dans la même famille, on
# garde la description la plus informative.
if remembered_family and current_family == remembered_family and len(remembered) > len(current):
return remembered
return current
@property
def effective_device_type(self) -> str:
"""Type à afficher sans laisser un scan léger dégrader une identification riche."""
if self.is_local:
return "Ce poste"
if self.remembered_device_type and self.device_type in {
"", "Hôte", "Hôte Linux", "Serveur SSH", "Serveur / appliance", "Équipement réseau", "Appliance Web"
}:
return self.remembered_device_type
return self.device_type or self.remembered_device_type or "Hôte"
@property
def os_is_estimated(self) -> bool:
"""True quand Nmap a fourni une correspondance OS non exacte (< 100 %)."""
return bool(self.os_name and self.os_accuracy is not None and self.os_accuracy < 100)
@property
def effective_os_accuracy(self) -> int | None:
if self.os_is_remembered:
return self.remembered_os_accuracy
return self.os_accuracy
@property
def os_is_remembered(self) -> bool:
return bool(self.remembered_os_name and self.effective_os_name == self.remembered_os_name and self.os_name != self.remembered_os_name)
@property
def type_is_remembered(self) -> bool:
return bool(
self.remembered_device_type
and self.effective_device_type == self.remembered_device_type
and self.device_type != self.remembered_device_type
)
@property
def ports_summary(self) -> str:
return ", ".join(p.label for p in self.ports if p.state == "open")
@property
def ports_details(self) -> str:
return "\n".join(p.details for p in self.ports if p.state == "open")
@property
def searchable_text(self) -> str:
values = (
self.status,
self.change_status,
self.change_detail,
self.device_type,
self.effective_device_type,
self.hostname,
self.ip,
self.previous_ip,
self.mac,
self.vendor,
self.ports_summary,
self.os_name,
self.effective_os_name,
)
return " ".join(v for v in values if v).casefold()