first commit
This commit is contained in:
@@ -0,0 +1,209 @@
|
||||
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()
|
||||
Reference in New Issue
Block a user