210 lines
8.0 KiB
Python
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()
|