Merge branch 'master'

This commit is contained in:
2026-08-23 09:59:05 +02:00
90 changed files with 8885 additions and 376 deletions
+30 -13
View File
@@ -1,16 +1,33 @@
# Changelog
# LibreNet Scanner — Release notes
## 0.1.0 — 2026-08-21
## 1.0.0 — Stable
Première version Debian 13 / KDE :
LibreNet Scanner 1.0.0 constitue la version stable de référence.
- interface Qt 6 / PySide6 ;
- détection du réseau local ;
- profils Rapide, Standard et Approfondi ;
- intégration Nmap et arp-scan ;
- repli Nmap si arp-scan n'est pas autorisé pour l'utilisateur ;
- inventaire IP/MAC/constructeur/ports/services ;
- actions KDE (Konsole, Dolphin, navigateur, Remmina optionnel) ;
- export CSV/JSON ;
- historique SQLite ;
- paquet Debian `.deb` architecture `all`.
### Moteur réseau
- découverte LAN combinant poste local, `arp-scan`, Nmap et voisinage Linux ;
- moteur Standard adaptatif : Nmap borné sur les petits ensembles d'hôtes actifs, Naabu optionnel sur les ensembles importants ;
- aucun scan Standard de ports sur l'intégralité d'un `/24` après découverte ;
- délais maximaux explicites et repli Nmap limité aux hôtes actifs ;
- scan Approfondi avec services, versions et détection OS en mode privilégié.
### Stabilité
- arrêt supervisé des processus utilisateur et privilégiés ;
- annulation sans enregistrement d'un scan incomplet ;
- séparation entre affichage, historique, identifications et métadonnées utilisateur ;
- moteur d'identité robuste aux MAC virtuelles, partagées, clonées et localement administrées ;
- package Debian sans téléchargement réseau obligatoire à l'installation.
### Interface
- interface Qt6/KDE orientée équipements ;
- vues Compacte et Détaillée ;
- actions contextuelles Web, SSH, SMB, RDP, Ping, Traceroute et Wake-on-LAN ;
- icônes dédiées pour types d'équipements et familles de systèmes ;
- favoris, groupes, notes, recherche, historique et comparaison de scans.
### Validation
- 191 tests automatisés couvrant le moteur réseau, le helper privilégié, les timeouts, l'annulation, l'identité, la persistance, l'interface et le packaging.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2021 ProjectDiscovery, Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+267 -62
View File
@@ -1,103 +1,308 @@
# LibreNet Scanner 0.1.0
<p align="center">
<img src="assets/librenet-scanner.svg" width="132" height="132" alt="Logo LibreNet Scanner">
</p>
LibreNet Scanner est un scanner réseau graphique pour Linux, pensé comme une alternative libre et simple à Advanced IP Scanner.
<h1 align="center">LibreNet Scanner</h1>
Cette première version cible **Debian 13 (Trixie) + KDE Plasma** et utilise uniquement des composants disponibles dans les dépôts Debian.
<p align="center">
<strong>Scanner réseau graphique libre, rapide et orienté équipements pour Linux.</strong><br>
Découverte, inventaire, services, identification et diagnostic dans une interface Qt pensée pour KDE Plasma.
</p>
## Fonctionnalités V0.1
<p align="center">
<img src="assets/badges/version.svg" alt="Version 1.0.0">
<img src="assets/badges/status.svg" alt="Release stable">
<img src="assets/badges/platform.svg" alt="Debian 13">
<img src="assets/badges/ui.svg" alt="Qt 6 KDE">
<img src="assets/badges/license.svg" alt="GPLv3+">
<img src="assets/badges/tests.svg" alt="187 tests OK">
</p>
- détection automatique des interfaces IPv4 et du CIDR local ;
- filtrage par défaut des interfaces Docker/Podman/virbr/veth ;
- 3 profils de scan : Rapide, Standard et Approfondi ;
- découverte ARP avec `arp-scan` sur le LAN local ;
- découverte et scan TCP avec `nmap` ;
- affichage IP, nom DNS, MAC, constructeur, ports/services, OS si disponible, latence ;
- scan détaillé des 1000 ports principaux d'une machine par double-clic/clic droit ;
- actions KDE : HTTP/HTTPS, SSH et ping dans Konsole, SMB dans Dolphin, RDP avec Remmina si installé ;
- export CSV et JSON ;
- historique SQLite des scans ;
- interface non lancée en root ;
- limite de sécurité à 4096 adresses par scan dans cette V0.1.
<p align="center">
<img src="assets/icons/equipment-workstation.svg" width="34" alt="Poste">
&nbsp;&nbsp;
<img src="assets/icons/equipment-server.svg" width="34" alt="Serveur">
&nbsp;&nbsp;
<img src="assets/icons/equipment-switch.svg" width="34" alt="Switch">
&nbsp;&nbsp;
<img src="assets/icons/equipment-router.svg" width="34" alt="Routeur">
&nbsp;&nbsp;
<img src="assets/icons/equipment-access-point.svg" width="34" alt="Point d'accès">
&nbsp;&nbsp;
<img src="assets/icons/equipment-printer.svg" width="34" alt="Imprimante">
</p>
## Installation sur Debian 13
> **LibreNet Scanner 1.0.0 est la version stable de référence.** Le moteur Standard privilégie la rapidité et la prédictibilité sur les petits réseaux, tout en pouvant accélérer les grands ensembles d'hôtes avec Naabu.
Le plus simple est d'utiliser le paquet `.deb` fourni :
---
## ✨ Points forts
- **Découverte rapide du LAN** avec `arp-scan`, Nmap et la table de voisinage Linux.
- **Moteur Standard adaptatif** : Nmap sur les petits ensembles d'hôtes actifs, Naabu sur les ensembles importants lorsqu'il apporte un réel gain.
- **Scan approfondi** avec versions de services et estimation du système d'exploitation.
- **Mode Administrateur via Polkit** : la GUI reste non-root ; seul un helper strictement contrôlé reçoit les privilèges nécessaires.
- **Identification orientée équipements** : poste, serveur, hyperviseur, NAS, pare-feu, routeur, switch, point d'accès, imprimante…
- **Identification OS** avec pictogrammes Linux, BSD, Windows, Apple et Android.
- **Résultats progressifs et scan interruptible** avec terminaison propre des processus.
- **Historique local SQLite**, comparaison entre scans, favoris, groupes et notes.
- **Actions contextuelles** : Web, SSH, SMB, RDP, Ping, Traceroute, Wake-on-LAN, copie IP/MAC.
- **Interface compacte ou détaillée**, compatible Breeze clair/sombre sans thème graphique imposé.
## 🚀 Installation
### Paquet Debian 13
```bash
sudo apt install ./librenet-scanner_0.1.0_all.deb
sudo apt install ./librenet-scanner_1.0.0_amd64.deb
```
APT installera automatiquement les dépendances (`python3-pyside6.qtwidgets`, `nmap`, `arp-scan`, etc.).
Puis lancer :
Puis lance LibreNet Scanner depuis le menu KDE ou avec :
```bash
librenet-scanner
```
ou chercher **LibreNet Scanner** dans le menu KDE.
Le paquet cible **Debian 13 (Trixie) amd64** et installe les dépendances principales via APT.
## Dépendances
### Exécution depuis les sources
Obligatoires :
```bash
./run-from-source.sh
```
Le projet nécessite Python 3.11+ et PySide6/Qt6.
## 🧭 Les trois modes de scan
| Mode | Objectif | Moteur principal |
|---|---|---|
| ⚡ **Rapide** | Trouver les machines présentes | ARP + découverte Nmap |
| 🔎 **Standard** | Trouver les machines et leurs ports usuels | Moteur adaptatif Nmap / Naabu |
| 🧬 **Approfondi** | Enrichir services, versions et OS | Socle Standard + Nmap `-sV` / OS |
Le bouton principal **Scanner** lance le mode Standard. Le menu attenant permet de choisir Rapide ou Approfondi.
### Standard : moteur adaptatif
LibreNet commence par identifier les **hôtes réellement actifs**, puis ne scanne les ports que sur ceux-ci.
```text
Cible réseau
├── poste local
├── arp-scan
└── découverte Nmap courte
hôtes actifs
┌──────┴────────┐
│ │
< 32 hôtes ≥ 32 hôtes
│ │
Nmap borné Naabu par lots
│ │
└──────┬────────┘
ports ouverts
```
Pour un `/24` avec seulement quelques machines actives, LibreNet **ne lance pas un scan de ports sur les 254 adresses** : les hôtes sont d'abord découverts, puis seuls ceux qui répondent sont analysés.
#### Garde-fous de performance
- `arp-scan` est borné à **8 secondes** ;
- découverte Nmap sans DNS (`-n`) et avec un retry maximum ;
- scan Standard Nmap limité aux hôtes actifs et aux ports usuels ;
- Naabu réservé aux ensembles d'au moins **32 hôtes actifs** ;
- Naabu exécuté par lots de **32 hôtes**, avec plafond de temps par lot ;
- repli Nmap limité aux hôtes déjà découverts si Naabu est indisponible ou trop lent ;
- aucun résultat incomplet n'est enregistré comme scan réussi lorsqu'une phase essentielle échoue.
## 🛡️ Mode Administrateur
Le mode **Admin** s'appuie sur Polkit. LibreNet Scanner ne lance jamais toute l'interface en root.
Le helper privilégié est installé ici :
```text
/usr/libexec/librenet-scanner-helper
```
La politique Polkit est installée ici :
```text
/usr/share/polkit-1/actions/org.librenet.scanner.policy
```
Le mode Admin permet notamment :
- `arp-scan` privilégié ;
- scans TCP SYN (`-sS`) ;
- détection OS lors des scans Approfondi et Détaillé.
La découverte Standard reste volontairement cohérente entre le mode utilisateur et le mode Admin afin d'éviter que l'activation des privilèges modifie artificiellement la liste d'hôtes détectés.
## 🛑 Annulation propre
Le bouton **Arrêter** ne se contente pas d'interrompre l'interface : LibreNet supervise le processus réseau en cours.
- en mode utilisateur, le groupe de processus est terminé proprement ;
- en mode Admin, l'UI envoie un ordre `STOP` au helper privilégié ;
- le helper applique une escalade `TERM → KILL` si nécessaire ;
- un scan interrompu n'est pas enregistré comme un résultat complet.
## 🖥️ Vue équipements
### Vue compacte
```text
pve01.local 192.168.10.10 Hyperviseur Proxmox
nas01.local 192.168.10.20 NAS
printer01.local 192.168.10.30 Imprimante
```
### Vue détaillée
```text
pve01.local 192.168.10.10 Hyperviseur Proxmox
SSH 22/tcp OpenSSH
NFS 2049/tcp
Proxmox VE 8006/tcp
```
Un double-clic sur un service compatible peut ouvrir directement HTTP/HTTPS, SSH, SMB ou RDP.
## 🧠 Identification et mémoire locale
LibreNet sépare volontairement :
- **les résultats visibles** ;
- **l'historique des scans** ;
- **les identifications mémorisées** ;
- **les métadonnées utilisateur** : favoris, groupes et notes.
Quand une MAC fiable est disponible, les métadonnées peuvent suivre l'équipement lors d'un changement d'adresse IP. La logique d'identité évite autant que possible les fusions dangereuses liées aux MAC virtuelles, clonées, partagées ou localement administrées.
Les données sont conservées localement dans :
```text
~/.local/share/librenet-scanner/history.sqlite3
```
## 🌐 Constructeurs et confidentialité
Par défaut, LibreNet s'appuie sur les bases OUI locales.
Une recherche constructeur en ligne peut être activée dans :
**Paramètres → Identification des constructeurs…**
Cette fonction est **désactivée par défaut**, car une recherche distante transmet la MAC complète au fournisseur choisi. Les résultats peuvent être mis en cache localement pendant 30 jours.
## ⚙️ Naabu optionnel
LibreNet Scanner fonctionne sans Naabu. L'installation du `.deb` **n'effectue aucun téléchargement réseau obligatoire**.
Naabu 2.6.1 peut être ajouté comme accélérateur pour les grands ensembles d'hôtes :
```bash
sudo /usr/libexec/librenet-scanner-install-naabu --ensure
```
Le binaire validé est placé dans :
```text
/usr/lib/librenet-scanner/bin/naabu
```
LibreNet n'utilise pas arbitrairement un autre `naabu` trouvé dans le `$PATH`. L'installateur vérifie la version et le SHA-256 de l'archive officielle avant installation.
## 🧰 Dépendances
### Obligatoires
- Python 3
- PySide6 / Qt6
- Nmap
- arp-scan
- iproute2
- xdg-utils
- iputils-ping
- xdg-utils
- pkexec / Polkit
Recommandées :
### Recommandées ou optionnelles
- `polkit-kde-agent-1` sous KDE Plasma
- Konsole
- Remmina
- traceroute
- libcap2-bin
- Naabu 2.6.1 pour l'accélération des grands ensembles
## Profils
## ⌨️ Raccourcis utiles
### Rapide
| Raccourci | Action |
|---|---|
| `F5` | Scan Standard |
| `Ctrl+F5` | Scan Rapide |
| `Shift+F5` | Scan Approfondi |
| `Ctrl+F` | Recherche |
Sur le réseau directement connecté : `arp-scan`. Pour une cible distante : découverte `nmap -sn`.
## 🔐 Sécurité
### Standard
- aucune commande utilisateur n'est exécutée via un shell ;
- les cibles sont validées avant exécution ;
- la taille des scans est limitée ;
- le helper privilégié n'accepte que des opérations prédéfinies ;
- les moteurs privilégiés sont lancés avec des profils contrôlés ;
- les scans sont conçus pour être interrompus proprement.
Découverte des hôtes puis scan d'une liste de ports d'administration courants : 22, 23, 53, 80, 139, 443, 445, 3389, 5900, 8006, 8080, 8443 et 9100.
> Utilise LibreNet Scanner uniquement sur des réseaux que tu possèdes ou que tu es autorisé à analyser.
### Approfondi
## ✅ Validation de la release stable
Scan TCP des 100 ports les plus courants avec détection légère de version (`nmap -sT -sV --version-light`).
La base 1.0.0 est couverte par **191 tests automatisés** portant notamment sur :
Le double-clic sur une machine lance un scan des 1000 ports les plus courants avec détection de services.
- parsing Nmap / arp-scan / Naabu ;
- validation des cibles ;
- moteur adaptatif petit/grand réseau ;
- délais maximaux et annulation ;
- helper privilégié ;
- identité des équipements et systèmes ;
- historique et persistance ;
- non-régression de l'interface et du packaging.
## Sécurité
LibreNet Scanner n'exécute pas son interface graphique en root et transmet les cibles à Nmap/arp-scan sous forme d'arguments, sans passer par un shell.
Utilisez le programme uniquement sur des réseaux que vous êtes autorisé à scanner.
## Données locales
L'historique se trouve dans :
```text
~/.local/share/librenet-scanner/history.sqlite3
```
ou sous `$XDG_DATA_HOME/librenet-scanner/` si cette variable est définie.
## Licence
Le code de LibreNet Scanner est sous licence **GPL-3.0-or-later**.
Nmap et arp-scan sont des programmes externes installés séparément par Debian et conservent leurs propres licences.
### À propos de `arp-scan` sur Debian 13
Debian compile `arp-scan` avec le support des capabilities, mais le paquet indique qu'un utilisateur non privilégié peut devoir activer explicitement `CAP_NET_RAW` pour utiliser toutes les fonctions :
Lancer la suite :
```bash
sudo setcap cap_net_raw+p /usr/sbin/arp-scan
PYTHONPATH=src python3 -m unittest discover -s tests -v
```
Cette commande n'est **pas exécutée automatiquement** par LibreNet Scanner. Si `arp-scan` n'est pas utilisable par l'utilisateur courant, le profil Rapide se replie automatiquement sur `nmap -sn` et l'interface graphique reste non-root.
## 📁 Arborescence
```text
librenet-scanner-1.0.0/
├── assets/ logo, badges et icônes
├── packaging/ paquet Debian, helper et Polkit
├── src/librenet_scanner/ application Python
├── tests/ suite automatisée
├── README.md
├── LICENSE
└── THIRD_PARTY_ASSETS.md
```
## 📜 Licence
LibreNet Scanner est distribué sous **GPL-3.0-or-later**.
Nmap, arp-scan, Naabu et les pictogrammes tiers conservent leurs licences respectives. Les détails sont documentés dans [`THIRD_PARTY_ASSETS.md`](THIRD_PARTY_ASSETS.md) et [`NAABU-LICENSE.txt`](NAABU-LICENSE.txt).
---
<p align="center">
<img src="assets/librenet-scanner.svg" width="54" alt="LibreNet Scanner"><br>
<strong>LibreNet Scanner 1.0.0 — Stable</strong><br>
<sub>Voir le réseau. Comprendre les équipements. Garder le contrôle.</sub>
</p>
+47
View File
@@ -0,0 +1,47 @@
# Third-party assets
LibreNet Scanner est distribué sous GPLv3, mais certains pictogrammes graphiques embarqués ont leur propre licence.
## Font Awesome Free — pictogrammes UI
Les pictogrammes dOS et d’équipement dans `assets/icons/` sont des rendus SVG dérivés de glyphes **Font Awesome Free**.
Utilisations principales :
- Linux / Tux (`linux`)
- FreeBSD (`freebsd`)
- Windows (`windows`)
- Apple (`apple`)
- Android (`android`)
- poste (`desktop`)
- serveur (`server`)
- hyperviseur (`layer-group`)
- pare-feu (`shield-alt`)
- routeur (`ethernet`)
- NAS (`hdd`)
- switch (`network-wired`)
- point daccès (`wifi`)
- imprimante (`print`)
- équipement réseau (`sitemap`)
Font Awesome Free (assets utilisés jusqu’à la série 6.7.2) : Copyright Fonticons, Inc. / Font Awesome contributors.
Les icônes Font Awesome Free sont distribuées sous **CC BY 4.0**. Les fontes sont distribuées sous **SIL OFL 1.1** et le code Font Awesome sous licence MIT. Voir : https://fontawesome.com/license/free
Les marques et logos représentés (notamment FreeBSD, Linux et Windows) restent la propriété de leurs détenteurs respectifs et sont utilisés uniquement comme identifiants visuels de plateformes.
## BSD Daemon / Beastie
LibreNet Scanner **nembarque pas Beastie**, le BSD Daemon historique. Son image est protégée par des droits spécifiques. LibreNet Scanner utilise à la place le pictogramme FreeBSD de Font Awesome Free afin d’éviter dintroduire une licence/autorisation supplémentaire dans un dépôt public.
## Naabu — moteur réseau ProjectDiscovery
LibreNet Scanner peut utiliser **Naabu 2.6.1**, projet ProjectDiscovery distribué sous licence **MIT**, comme accélérateur de scan de ports lorsque le Standard a découvert un grand nombre d'hôtes. Il n'est pas requis pour le fonctionnement normal : sur les petits ensembles, Nmap est volontairement utilisé. Le binaire Naabu n'est pas versionné dans l'archive source LibreNet et l'installation du paquet n'effectue aucun téléchargement réseau. L'installateur optionnel place une version 2.6.1 vérifiée dans `/usr/lib/librenet-scanner/bin/naabu`. À lexécution, LibreNet nutilise pas directement un Naabu externe du `PATH`.
Le téléchargement officiel est vérifié avant extraction avec le SHA-256 :
```text
018c4c9884dea971eda860435ede3021d1150732f34cfd245498c6726d8cab90
```
Projet officiel : https://github.com/projectdiscovery/naabu
Licence Naabu : MIT. Le texte de licence est fourni dans `NAABU-LICENSE.txt`.
+12
View File
@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" width="140" height="28" viewBox="0 0 140 28" role="img" aria-label="license: GPLv3+">
<title>license: GPLv3+</title>
<defs><clipPath id="r"><rect width="140" height="28" rx="14"/></clipPath></defs>
<g clip-path="url(#r)">
<rect width="74" height="28" fill="#24292f"/>
<rect x="74" width="66" height="28" fill="#8250df"/>
</g>
<g fill="#fff" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="12" font-weight="600" text-anchor="middle">
<text x="37.0" y="18">license</text>
<text x="107.0" y="18">GPLv3+</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 601 B

+12
View File
@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" width="172" height="28" viewBox="0 0 172 28" role="img" aria-label="platform: Debian 13">
<title>platform: Debian 13</title>
<defs><clipPath id="r"><rect width="172" height="28" rx="14"/></clipPath></defs>
<g clip-path="url(#r)">
<rect width="82" height="28" fill="#24292f"/>
<rect x="82" width="90" height="28" fill="#a81d33"/>
</g>
<g fill="#fff" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="12" font-weight="600" text-anchor="middle">
<text x="41.0" y="18">platform</text>
<text x="127.0" y="18">Debian 13</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 613 B

+12
View File
@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" width="140" height="28" viewBox="0 0 140 28" role="img" aria-label="release: stable">
<title>release: stable</title>
<defs><clipPath id="r"><rect width="140" height="28" rx="14"/></clipPath></defs>
<g clip-path="url(#r)">
<rect width="74" height="28" fill="#24292f"/>
<rect x="74" width="66" height="28" fill="#2da44e"/>
</g>
<g fill="#fff" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="12" font-weight="600" text-anchor="middle">
<text x="37.0" y="18">release</text>
<text x="107.0" y="18">stable</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 601 B

+12
View File
@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" width="124" height="28" viewBox="0 0 124 28" role="img" aria-label="tests: 191 OK">
<title>tests: 191 OK</title>
<defs><clipPath id="r"><rect width="124" height="28" rx="14"/></clipPath></defs>
<g clip-path="url(#r)">
<rect width="58" height="28" fill="#24292f"/>
<rect x="58" width="66" height="28" fill="#2da44e"/>
</g>
<g fill="#fff" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="12" font-weight="600" text-anchor="middle">
<text x="29.0" y="18">tests</text>
<text x="91.0" y="18">191 OK</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 594 B

+12
View File
@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" width="188" height="28" viewBox="0 0 188 28" role="img" aria-label="interface: Qt 6 / KDE">
<title>interface: Qt 6 / KDE</title>
<defs><clipPath id="r"><rect width="188" height="28" rx="14"/></clipPath></defs>
<g clip-path="url(#r)">
<rect width="90" height="28" fill="#24292f"/>
<rect x="90" width="98" height="28" fill="#6f42c1"/>
</g>
<g fill="#fff" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="12" font-weight="600" text-anchor="middle">
<text x="45.0" y="18">interface</text>
<text x="139.0" y="18">Qt 6 / KDE</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 619 B

+12
View File
@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" width="132" height="28" viewBox="0 0 132 28" role="img" aria-label="version: 1.0.0">
<title>version: 1.0.0</title>
<defs><clipPath id="r"><rect width="132" height="28" rx="14"/></clipPath></defs>
<g clip-path="url(#r)">
<rect width="74" height="28" fill="#24292f"/>
<rect x="74" width="58" height="28" fill="#1f6feb"/>
</g>
<g fill="#fff" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="12" font-weight="600" text-anchor="middle">
<text x="37.0" y="18">version</text>
<text x="103.0" y="18">1.0.0</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 598 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1400.592 1024.063" role="img"><g transform="translate(75.296,887.032) scale(1,-1)"><path d="M1240 572C894 892 356 892 10 572C-3 560 -4 540 9 528L76 461C88 449 108 450 120 461C405 722 845 722 1130 461C1142 450 1162 449 1174 461L1241 528C1254 540 1253 560 1240 572ZM625 188C556 188 500 131 500 62C500 -7 556 -62 625 -62C694 -62 750 -7 750 62C750 131 694 188 625 188ZM1021 351C796 550 454 550 229 351C216 339 215 319 228 306L295 239C307 227 326 227 338 238C502 380 748 379 912 238C924 227 943 227 955 239L1022 306C1035 319 1034 339 1021 351Z" fill="#3889C9"/></g></svg>

After

Width:  |  Height:  |  Size: 620 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1058.060 1120.560" role="img"><g transform="translate(29.030,935.030) scale(1,-1)"><path d="M911 712 536 868C525 873 513 875 500 875C487 875 475 873 464 868L89 712C54 698 31 663 31 625C31 237 255 -31 464 -118C487 -128 513 -128 536 -118C703 -48 969 193 969 625C969 663 946 698 911 712ZM500 3V747L844 604C838 308 683 94 500 3Z" fill="#C44D58"/></g></svg>

After

Width:  |  Height:  |  Size: 406 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1120.000 1119.500" role="img"><g transform="translate(60.000,935.000) scale(1,-1)"><path d="M24 586 479 380C492 374 508 374 521 380L976 586C1008 601 1008 649 976 664L521 870C515 873 507 875 500 875C493 875 485 873 479 870L24 664C-8 649 -8 601 24 586ZM976 413 862 465 547 322C532 315 516 312 500 312C484 312 468 315 453 322L138 465L24 413C-8 398 -8 350 24 335L479 129C492 123 508 123 521 129L976 335C1008 350 1008 398 976 413ZM976 164 863 215 547 72C532 65 516 62 500 62C484 62 468 65 453 72L137 215L24 164C-8 149 -8 101 24 86L479 -120C492 -126 508 -126 521 -120L976 86C1008 101 1008 149 976 164Z" fill="#596C8A"/></g></svg>

After

Width:  |  Height:  |  Size: 677 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1260.000 885.000" role="img"><g transform="translate(67.500,817.500) scale(1,-1)"><path d="M1125 281C1125 333 1083 375 1031 375H94C42 375 0 333 0 281V94C0 42 42 0 94 0H1031C1083 0 1125 42 1125 94ZM1031 438C1052 438 1073 433 1091 425L903 708C886 733 858 750 825 750H300C267 750 239 733 222 708L34 425C52 433 73 437 94 437V438ZM938 250C973 250 1000 223 1000 188C1000 153 973 125 938 125C903 125 875 153 875 188C875 223 903 250 938 250ZM750 250C785 250 812 223 812 188C812 153 785 125 750 125C715 125 688 153 688 188C688 223 715 250 750 250Z" fill="#59636F"/></g></svg>

After

Width:  |  Height:  |  Size: 620 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1400.000 1150.000" role="img"><g transform="translate(75.000,950.000) scale(1,-1)"><path d="M250 188H62C27 188 0 160 0 125V-62C0 -97 27 -125 62 -125H250C285 -125 312 -97 312 -62V125C312 160 285 188 250 188ZM203 344H578V250H672V344H1047V250H1141V362C1141 403 1107 438 1066 438H672V562H750C785 562 812 590 812 625V812C812 847 785 875 750 875H500C465 875 438 847 438 812V625C438 590 465 562 500 562H578V438H184C143 438 109 403 109 362V250H203ZM719 188H531C496 188 469 160 469 125V-62C469 -97 496 -125 531 -125H719C754 -125 781 -97 781 -62V125C781 160 754 188 719 188ZM1188 188H1000C965 188 938 160 938 125V-62C938 -97 965 -125 1000 -125H1188C1223 -125 1250 -97 1250 -62V125C1250 160 1223 188 1188 188Z" fill="#4E7DA6"/></g></svg>

After

Width:  |  Height:  |  Size: 780 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1120.000 1120.000" role="img"><g transform="translate(60.000,935.000) scale(1,-1)"><path d="M875 500V724C875 741 869 756 857 768L768 857C756 869 741 875 724 875H188C153 875 125 847 125 812V500C56 500 0 444 0 375V156C0 139 14 125 31 125H125V-62C125 -97 153 -125 188 -125H812C847 -125 875 -97 875 -62V125H969C986 125 1000 139 1000 156V375C1000 444 944 500 875 500ZM750 0H250V188H750ZM750 438H250V750H625V656C625 639 639 625 656 625H750ZM844 297C818 297 797 318 797 344C797 370 818 391 844 391C870 391 891 370 891 344C891 318 870 297 844 297Z" fill="#59636F"/></g></svg>

After

Width:  |  Height:  |  Size: 621 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1120.000 870.000" role="img"><g transform="translate(60.000,810.000) scale(1,-1)"><path d="M969 500H875V594C875 611 861 625 844 625H750V719C750 736 736 750 719 750H281C264 750 250 736 250 719V625H156C139 625 125 611 125 594V500H31C14 500 0 486 0 469V31C0 14 14 0 31 0H188V250H250V0H375V250H438V0H562V250H625V0H750V250H812V0H969C986 0 1000 14 1000 31V469C1000 486 986 500 969 500Z" fill="#4E7DA6"/></g></svg>

After

Width:  |  Height:  |  Size: 461 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1120.000 994.000" role="img"><g transform="translate(60.000,872.000) scale(1,-1)"><path d="M938 562C973 562 1000 590 1000 625V750C1000 785 973 812 938 812H62C27 812 0 785 0 750V625C0 590 27 562 62 562ZM844 734C870 734 891 714 891 688C891 662 870 641 844 641C818 641 797 662 797 688C797 714 818 734 844 734ZM719 734C745 734 766 714 766 688C766 662 745 641 719 641C693 641 672 662 672 688C672 714 693 734 719 734ZM938 250C973 250 1000 277 1000 312V438C1000 473 973 500 938 500H62C27 500 0 473 0 438V312C0 277 27 250 62 250ZM844 422C870 422 891 401 891 375C891 349 870 328 844 328C818 328 797 349 797 375C797 401 818 422 844 422ZM719 422C745 422 766 401 766 375C766 349 745 328 719 328C693 328 672 349 672 375C672 401 693 422 719 422ZM938 -62C973 -62 1000 -35 1000 0V125C1000 160 973 188 938 188H62C27 188 0 160 0 125V0C0 -35 27 -62 62 -62ZM844 109C870 109 891 88 891 62C891 36 870 16 844 16C818 16 797 36 797 62C797 88 818 109 844 109ZM719 109C745 109 766 88 766 62C766 36 745 16 719 16C693 16 672 36 672 62C672 88 693 109 719 109Z" fill="#4B5563"/></g></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1400.000 1150.000" role="img"><g transform="translate(75.000,950.000) scale(1,-1)"><path d="M1250 359V391C1250 408 1236 422 1219 422H672V500H812C847 500 875 527 875 562V812C875 847 847 875 812 875H438C403 875 375 847 375 812V562C375 527 403 500 438 500H578V422H31C14 422 0 408 0 391V359C0 342 14 328 31 328H234V250H125C90 250 62 223 62 188V-62C62 -97 90 -125 125 -125H438C473 -125 500 -97 500 -62V188C500 223 473 250 438 250H328V328H922V250H812C777 250 750 223 750 188V-62C750 -97 777 -125 812 -125H1125C1160 -125 1188 -97 1188 -62V188C1188 223 1160 250 1125 250H1016V328H1219C1236 328 1250 342 1250 359ZM500 625V750H750V625ZM375 0H188V125H375ZM1062 0H875V125H1062Z" fill="#4E7DA6"/></g></svg>

After

Width:  |  Height:  |  Size: 747 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1084.160 1084.160" role="img"><g transform="translate(42.080,917.080) scale(1,-1)"><path d="M984 375C984 642 767 859 500 859C233 859 16 642 16 375C16 107 233 -109 500 -109C767 -109 984 107 984 375ZM513 699C623 699 741 613 741 500C741 350 578 348 578 292V289C578 276 568 266 555 266H445C432 266 422 276 422 289V297C422 377 483 409 529 435C568 457 592 472 592 501C592 540 543 566 503 566C450 566 426 540 391 496C383 486 368 484 358 492L291 543C281 551 278 565 285 575C338 655 407 699 513 699ZM500 215C550 215 590 175 590 125C590 75 550 35 500 35C450 35 410 75 410 125C410 175 450 215 500 215Z" fill="#7B8794"/></g></svg>

After

Width:  |  Height:  |  Size: 672 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1260.000 1135.000" role="img"><g transform="translate(67.500,942.500) scale(1,-1)"><path d="M1031 875H94C42 875 0 833 0 781V156C0 104 42 62 94 62H469L438 -31H297C271 -31 250 -52 250 -78C250 -104 271 -125 297 -125H828C854 -125 875 -104 875 -78C875 -52 854 -31 828 -31H688L656 62H1031C1083 62 1125 104 1125 156V781C1125 833 1083 875 1031 875ZM1000 188H125V750H1000Z" fill="#4B5563"/></g></svg>

After

Width:  |  Height:  |  Size: 445 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512"><!--! Font Awesome Free 6.7.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2024 Fonticons, Inc. --><path d="M420.55,301.93a24,24,0,1,1,24-24,24,24,0,0,1-24,24m-265.1,0a24,24,0,1,1,24-24,24,24,0,0,1-24,24m273.7-144.48,47.94-83a10,10,0,1,0-17.27-10h0l-48.54,84.07a301.25,301.25,0,0,0-246.56,0L116.18,64.45a10,10,0,1,0-17.27,10h0l47.94,83C64.53,202.22,8.24,285.55,0,384H576c-8.24-98.45-64.54-181.78-146.85-226.55"/></svg>

After

Width:  |  Height:  |  Size: 592 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512"><!--! Font Awesome Free 6.7.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2024 Fonticons, Inc. --><path d="M318.7 268.7c-.2-36.7 16.4-64.4 50-84.8-18.8-26.9-47.2-41.7-84.7-44.6-35.5-2.8-74.3 20.7-88.5 20.7-15 0-49.4-19.7-76.4-19.7C63.3 141.2 4 184.8 4 273.5q0 39.3 14.4 81.2c12.8 36.7 59 126.7 107.2 125.2 25.2-.6 43-17.9 75.8-17.9 31.8 0 48.3 17.9 76.4 17.9 48.6-.7 90.4-82.5 102.6-119.3-65.2-30.7-61.7-90-61.7-91.9zm-56.6-164.2c27.3-32.4 24.8-61.9 24-72.5-24.1 1.4-52 16.4-67.9 34.9-17.5 19.8-27.8 44.3-25.6 71.9 26.1 2 49.9-11.4 69.5-34.3z"/></svg>

After

Width:  |  Height:  |  Size: 726 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 979.726 966.839" role="img"><g transform="translate(52.341,858.354) scale(1,-1)"><path d="M593 687C571 665 592 610 638 564C684 518 739 497 761 519C783 541 911 745 865 791C819 837 615 709 593 687ZM215 742C144 782 43 827 11 795C-21 763 24 659 65 588C101 651 153 704 215 742ZM794 535C800 513 800 495 789 484C749 444 619 537 576 621C541 684 554 725 605 716C616 723 629 731 643 739C585 769 519 786 449 786C216 786 28 598 28 365C28 133 216 -56 449 -56C682 -56 870 132 870 365C870 440 850 511 816 572C808 558 800 546 794 535Z" fill="#D94343"/></g></svg>

After

Width:  |  Height:  |  Size: 600 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 946.257 1120.420" role="img"><g transform="translate(35.167,935.023) scale(1,-1)"><path d="M431 634C433 633 435 631 437 631C439 631 443 632 443 634C443 637 440 638 437 639C434 640 429 642 426 640C425 640 425 639 425 638C426 635 429 635 431 634ZM388 631C390 631 392 633 394 634C396 635 400 635 401 637C401 638 401 639 400 639C397 641 392 640 389 639C386 638 383 637 383 634C383 632 386 631 388 631ZM820 86C813 94 810 109 806 125C802 141 799 158 786 169C783 171 781 172 778 174C775 176 773 177 770 178C788 231 781 285 763 333C741 392 702 443 672 478C639 520 606 560 607 619C608 709 617 875 459 875C259 875 308 673 306 611C303 565 294 530 263 485C226 441 174 370 149 296C137 261 132 226 137 192C124 181 114 163 104 152C96 144 84 141 71 136C58 131 44 125 35 108C31 100 30 91 30 83C30 75 31 68 32 60C34 44 37 30 34 20C24 -8 22 -28 29 -42C36 -56 52 -62 69 -66C103 -73 148 -72 184 -91C223 -111 263 -118 294 -111C317 -106 335 -92 344 -71C368 -71 395 -61 438 -59C467 -57 504 -69 546 -67C547 -71 549 -76 551 -80C567 -113 598 -128 630 -125C662 -122 696 -103 724 -70C751 -38 794 -25 823 -7C837 2 850 12 851 28C852 44 842 62 820 86ZM437 704C456 747 504 747 523 705C536 677 529 645 514 626C511 628 503 632 490 636C492 638 495 641 497 645C506 668 497 697 480 698C466 699 453 677 457 653C449 657 438 660 431 662C429 675 431 690 437 704ZM357 727C377 727 398 700 395 662C388 660 381 657 375 653C377 670 368 692 356 691C340 690 337 649 353 636C355 634 356 636 341 625C311 654 320 727 357 727ZM331 608C343 617 357 628 358 629C367 638 385 657 413 657C427 657 443 652 463 639C475 631 486 631 508 621C524 614 534 603 528 586C523 572 507 557 484 550C462 543 445 519 409 521C401 521 395 523 390 525C374 532 366 545 351 554C334 563 326 574 323 584C320 594 323 602 331 608ZM337 -44C332 -113 251 -111 190 -79C132 -48 56 -66 41 -36C36 -27 36 -12 46 15V16C51 31 47 47 45 62C43 77 41 91 46 101C53 114 63 119 75 123C95 130 99 130 114 143C125 154 133 168 142 178C152 189 161 193 176 191C192 189 206 178 219 160L257 91C276 52 341 -4 337 -44ZM335 7C327 20 315 33 306 45C320 45 334 49 339 62C343 74 339 91 325 111C299 147 250 174 250 174C224 190 209 211 202 233C195 255 196 279 201 302C211 347 237 390 254 417C258 420 256 411 237 377C220 346 189 273 232 216C233 256 243 298 259 336C282 390 332 482 336 556C338 554 345 550 348 548C357 543 364 535 373 528C397 508 428 509 455 525C467 532 478 540 487 543C506 549 521 559 530 572C545 513 581 427 603 385C615 363 639 316 649 259C655 259 662 258 670 256C697 326 648 401 625 422C616 431 616 435 620 435C645 413 676 369 688 320C693 297 695 273 689 250C721 237 759 215 749 182H741C747 202 733 216 696 233C658 250 625 250 621 209C597 201 586 180 580 155C575 133 573 107 571 77C570 62 564 42 558 21C495 -24 408 -43 335 7ZM837 29C835 -4 757 -10 714 -62C688 -93 657 -110 629 -112C601 -114 577 -102 563 -74C554 -52 558 -29 565 -3C572 25 583 53 584 76C586 106 588 132 593 152C598 172 605 185 619 193C620 193 620 194 621 194C623 168 636 143 658 137C683 131 718 151 733 168C751 169 764 170 777 158C796 141 791 99 811 77C832 54 838 39 837 29ZM338 585C342 581 348 576 354 571C367 561 385 550 407 550C430 550 452 561 470 571C480 576 490 584 498 591C506 598 509 603 504 604C499 605 500 599 493 594C484 588 474 580 466 575C452 567 427 555 407 555C387 555 371 564 359 574C353 579 348 584 344 588C341 591 340 596 335 597C332 597 331 590 338 585Z" fill="#202124"/></g></svg>

After

Width:  |  Height:  |  Size: 3.3 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1399.440 1013.440" role="img"><g transform="translate(74.470,876.470) scale(1,-1)"><path d="M504 342C522 360 522 390 504 408L124 788C106 806 76 806 58 788L14 743C-4 725 -4 695 14 677L314 375L14 73C-4 55 -4 25 14 7L58 -38C76 -56 106 -56 124 -38ZM1250 -16V47C1250 73 1229 94 1203 94H609C583 94 562 73 562 47V-16C562 -42 583 -62 609 -62H1203C1229 -62 1250 -42 1250 -16Z" fill="#6B7280"/></g></svg>

After

Width:  |  Height:  |  Size: 448 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><!--! Font Awesome Free 6.7.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2024 Fonticons, Inc. --><path d="M0 93.7l183.6-25.3v177.4H0V93.7zm0 324.6l183.6 25.3V268.4H0v149.9zm203.8 28L448 480V268.4H203.8v177.9zm0-380.6v180.1H448V32L203.8 65.7z"/></svg>

After

Width:  |  Height:  |  Size: 426 B

+3 -2
View File
@@ -2,10 +2,11 @@
Type=Application
Name=LibreNet Scanner
GenericName=Scanner réseau
Comment=Découvrir les machines et services d'un réseau avec Nmap et arp-scan
Comment=Découvrir les machines et services d'un réseau avec arp-scan, Naabu et Nmap
Exec=librenet-scanner
Icon=librenet-scanner
Terminal=false
Categories=Network;System;Utility;
Keywords=network;scanner;nmap;arp;ip;lan;
Keywords=network;scanner;nmap;naabu;arp;ip;lan;inventory;
StartupNotify=true
StartupWMClass=librenet-scanner
+1 -1
View File
@@ -1,7 +1,7 @@
#!/bin/sh
set -eu
HERE=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
DEB="$HERE/dist/librenet-scanner_0.1.0_all.deb"
DEB="$HERE/dist/librenet-scanner_1.0.0_amd64.deb"
if [ ! -f "$DEB" ]; then
echo "Paquet .deb absent. Construction..."
"$HERE/packaging/build-deb.sh"
+29 -4
View File
@@ -1,7 +1,8 @@
#!/bin/sh
set -eu
ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
VERSION=0.1.0
VERSION=1.0.0
ARCH=amd64
PKGROOT="/tmp/librenet-scanner-debroot-$$"
trap 'rm -rf "$PKGROOT"' EXIT HUP INT TERM
OUT="$ROOT/dist"
@@ -11,9 +12,19 @@ mkdir -p "$PKGROOT/DEBIAN" \
"$PKGROOT/usr/bin" \
"$PKGROOT/usr/share/applications" \
"$PKGROOT/usr/share/icons/hicolor/scalable/apps" \
"$PKGROOT/usr/share/librenet-scanner/icons" \
"$PKGROOT/usr/share/doc/librenet-scanner" \
"$PKGROOT/usr/share/doc/librenet-scanner/assets" \
"$PKGROOT/usr/share/doc/librenet-scanner/assets/badges" \
"$PKGROOT/usr/share/doc/librenet-scanner/assets/icons" \
"$PKGROOT/usr/libexec" \
"$PKGROOT/usr/share/polkit-1/actions" \
"$PKGROOT/usr/lib/librenet-scanner/bin" \
"$OUT"
rm -f "$OUT"/librenet-scanner_*.deb
cp "$ROOT/packaging/debian/control" "$PKGROOT/DEBIAN/control"
cp "$ROOT/packaging/debian/postinst" "$PKGROOT/DEBIAN/postinst"
cp "$ROOT/packaging/debian/postrm" "$PKGROOT/DEBIAN/postrm"
cp "$ROOT/src/librenet_scanner/"*.py "$PKGROOT/usr/lib/python3/dist-packages/librenet_scanner/"
cat > "$PKGROOT/usr/bin/librenet-scanner" <<'EOS'
#!/bin/sh
@@ -22,10 +33,24 @@ EOS
chmod 0755 "$PKGROOT/usr/bin/librenet-scanner"
cp "$ROOT/assets/librenet-scanner.desktop" "$PKGROOT/usr/share/applications/"
cp "$ROOT/assets/librenet-scanner.svg" "$PKGROOT/usr/share/icons/hicolor/scalable/apps/"
cp "$ROOT/assets/icons/"*.svg "$PKGROOT/usr/share/librenet-scanner/icons/"
cp "$ROOT/packaging/librenet-scanner-helper" "$PKGROOT/usr/libexec/librenet-scanner-helper"
cp "$ROOT/packaging/librenet-scanner-install-naabu" "$PKGROOT/usr/libexec/librenet-scanner-install-naabu"
cp "$ROOT/packaging/org.librenet.scanner.policy" "$PKGROOT/usr/share/polkit-1/actions/org.librenet.scanner.policy"
cp "$ROOT/README.md" "$PKGROOT/usr/share/doc/librenet-scanner/README.md"
cp "$ROOT/CHANGELOG.md" "$PKGROOT/usr/share/doc/librenet-scanner/CHANGELOG.md"
cp "$ROOT/assets/librenet-scanner.svg" "$PKGROOT/usr/share/doc/librenet-scanner/assets/"
cp "$ROOT/assets/badges/"*.svg "$PKGROOT/usr/share/doc/librenet-scanner/assets/badges/"
cp "$ROOT/assets/icons/"*.svg "$PKGROOT/usr/share/doc/librenet-scanner/assets/icons/"
cp "$ROOT/LICENSE" "$PKGROOT/usr/share/doc/librenet-scanner/LICENSE"
cp "$ROOT/THIRD_PARTY_ASSETS.md" "$PKGROOT/usr/share/doc/librenet-scanner/THIRD_PARTY_ASSETS.md"
cp "$ROOT/NAABU-LICENSE.txt" "$PKGROOT/usr/share/doc/librenet-scanner/NAABU-LICENSE.txt"
find "$PKGROOT" -type d -exec chmod 0755 {} +
find "$PKGROOT" -type f -exec chmod 0644 {} +
chmod 0755 "$PKGROOT/usr/bin/librenet-scanner" "$PKGROOT/DEBIAN"
dpkg-deb --root-owner-group --build "$PKGROOT" "$OUT/librenet-scanner_${VERSION}_all.deb"
echo "$OUT/librenet-scanner_${VERSION}_all.deb"
chmod 0755 \
"$PKGROOT/usr/bin/librenet-scanner" \
"$PKGROOT/usr/libexec/librenet-scanner-helper" \
"$PKGROOT/usr/libexec/librenet-scanner-install-naabu" \
"$PKGROOT/DEBIAN/postinst" "$PKGROOT/DEBIAN/postrm"
dpkg-deb --root-owner-group --build "$PKGROOT" "$OUT/librenet-scanner_${VERSION}_${ARCH}.deb"
echo "$OUT/librenet-scanner_${VERSION}_${ARCH}.deb"
+10 -8
View File
@@ -1,12 +1,14 @@
Package: librenet-scanner
Version: 0.1.0
Version: 1.0.0
Section: net
Priority: optional
Architecture: all
Architecture: amd64
Maintainer: Local package <root@localhost>
Depends: python3, python3-pyside6.qtwidgets, nmap, arp-scan, iproute2, xdg-utils, iputils-ping
Suggests: konsole, remmina
Description: scanner reseau graphique libre pour Linux
LibreNet Scanner fournit une interface Qt simple pour decouvrir les hotes,
adresses MAC, constructeurs et services d'un reseau en utilisant Nmap et
arp-scan comme moteurs externes.
Depends: python3, python3-pyside6.qtwidgets, nmap, arp-scan, iproute2, xdg-utils, iputils-ping, pkexec
Recommends: polkit-kde-agent-1
Suggests: konsole, remmina, traceroute, libcap2-bin, ca-certificates, libpcap0.8t64
Description: scanner reseau graphique libre et adaptatif pour Linux
LibreNet Scanner fournit une interface Qt orientee equipements pour decouvrir,
identifier et inventorier les hotes et services d'un reseau. Le moteur combine
arp-scan et Nmap, avec Naabu 2.6.1 comme accelerateur optionnel pour les grands
ensembles d'hotes.
+5
View File
@@ -0,0 +1,5 @@
#!/bin/sh
set -eu
# Aucune dépendance réseau pendant l'installation : Naabu est un accélérateur
# optionnel. LibreNet reste pleinement fonctionnel avec Nmap seul.
exit 0
+12
View File
@@ -0,0 +1,12 @@
#!/bin/sh
set -eu
case "${1:-}" in
remove|purge)
rm -f /usr/lib/librenet-scanner/bin/naabu
rmdir /usr/lib/librenet-scanner/bin 2>/dev/null || true
rmdir /usr/lib/librenet-scanner 2>/dev/null || true
;;
esac
exit 0
+4
View File
@@ -0,0 +1,4 @@
#!/usr/bin/python3
from librenet_scanner.privileged_helper import main
raise SystemExit(main())
+4
View File
@@ -0,0 +1,4 @@
#!/usr/bin/python3
from librenet_scanner.naabu_runtime import main
raise SystemExit(main())
+20
View File
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE policyconfig PUBLIC
"-//freedesktop//DTD PolicyKit Policy Configuration 1.0//EN"
"http://www.freedesktop.org/standards/PolicyKit/1/policyconfig.dtd">
<policyconfig>
<vendor>LibreNet Scanner</vendor>
<action id="org.librenet.scanner.privileged">
<description>Exécuter un scan réseau privilégié avec LibreNet Scanner</description>
<description xml:lang="fr">Exécuter un scan réseau privilégié avec LibreNet Scanner</description>
<message>Authentication is required to enable LibreNet Scanner administrator mode</message>
<message xml:lang="fr">Authentification requise pour activer le mode administrateur de LibreNet Scanner</message>
<defaults>
<allow_any>no</allow_any>
<allow_inactive>no</allow_inactive>
<allow_active>auth_admin_keep</allow_active>
</defaults>
<annotate key="org.freedesktop.policykit.exec.path">/usr/libexec/librenet-scanner-helper</annotate>
<annotate key="org.freedesktop.policykit.exec.allow_gui">false</annotate>
</action>
</policyconfig>
+2 -2
View File
@@ -4,8 +4,8 @@ build-backend = "setuptools.build_meta"
[project]
name = "librenet-scanner"
version = "0.1.0"
description = "Scanner réseau graphique libre pour Linux, basé sur Nmap et arp-scan"
version = "1.0.0"
description = "Scanner réseau graphique libre pour Linux avec moteur adaptatif arp-scan, Nmap et Naabu"
readme = "README.md"
requires-python = ">=3.11"
license = {text = "GPL-3.0-or-later"}
+1 -1
View File
@@ -1,3 +1,3 @@
"""LibreNet Scanner - scanner réseau graphique libre pour Linux."""
__version__ = "0.1.0"
__version__ = "1.0.0"
+23
View File
@@ -0,0 +1,23 @@
from __future__ import annotations
import re
import socket
MAC_RE = re.compile(r"^[0-9A-Fa-f]{2}(?::[0-9A-Fa-f]{2}){5}$")
def normalize_mac(mac: str) -> str:
value = mac.strip().replace("-", ":")
if not MAC_RE.match(value):
raise ValueError(f"Adresse MAC invalide : {mac}")
return value.upper()
def send_magic_packet(mac: str, broadcast: str = "255.255.255.255", port: int = 9) -> None:
normalized = normalize_mac(mac)
raw_mac = bytes.fromhex(normalized.replace(":", ""))
packet = b"\xff" * 6 + raw_mac * 16
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
sock.sendto(packet, (broadcast, port))
+160
View File
@@ -0,0 +1,160 @@
from __future__ import annotations
from collections import Counter
from copy import deepcopy
from .identity import mac_identity_kind, normalize_mac
from .intelligence import enrich_host
from .models import Host
def _port_keys(host: Host) -> set[tuple[int, str]]:
return {(p.port, p.protocol) for p in host.ports if p.state == "open"}
def _short_port(port: tuple[int, str]) -> str:
number, protocol = port
return f"{number}/{protocol}"
def _host_differences(current: Host, previous: Host) -> list[str]:
details: list[str] = []
if current.mac and previous.mac and current.mac.upper() != previous.mac.upper():
details.append("MAC changée")
if current.hostname and previous.hostname and current.hostname != previous.hostname:
details.append(f"Nom : {previous.hostname}{current.hostname}")
if current.os_name and previous.os_name and current.os_name != previous.os_name:
details.append("OS modifié")
cur_ports = _port_keys(current)
prev_ports = _port_keys(previous)
added = sorted(cur_ports - prev_ports)
removed = sorted(prev_ports - cur_ports)
if added:
details.append("Ports + " + ", ".join(_short_port(p) for p in added))
if removed:
details.append("Ports - " + ", ".join(_short_port(p) for p in removed))
return details
def _unique_mac_map(hosts: list[Host]) -> dict[str, Host]:
normalized = [normalize_mac(host.mac) for host in hosts if host.mac]
counts = Counter(mac for mac in normalized if mac)
result: dict[str, Host] = {}
for host in hosts:
mac = normalize_mac(host.mac)
if not mac or counts[mac] != 1:
continue
# Une MAC virtuelle (VRRP/CARP/HSRP) désigne un endpoint logique et peut
# changer de nœud physique. On ne l'utilise pas pour conclure à un move IP.
if mac_identity_kind(mac) == "virtual":
continue
result[mac] = host
return result
def _can_infer_ip_move(current: Host, previous: Host) -> bool:
"""Décide si une MAC identique suffit à conclure à un changement d'IP.
Une MAC globale unique est un signal fort entre deux scans consécutifs. Une
LAA peut en revanche être stable ou générée ; elle exige donc un hostname ou
une signature de services concordante. Les MAC virtuelles ne sont jamais
utilisées pour suivre un nœud physique.
"""
mac = normalize_mac(current.mac)
if not mac or mac != normalize_mac(previous.mac):
return False
kind = mac_identity_kind(mac)
if kind == "global":
if current.hostname and previous.hostname and current.hostname != previous.hostname:
overlap = _port_keys(current) & _port_keys(previous)
if not overlap:
return False
return True
if kind == "laa":
# Entre deux scans consécutifs, une LAA strictement identique et unique est
# un indice suffisant pour signaler un déplacement d'IP. Cela ne lui donne
# PAS pour autant le niveau de confiance nécessaire à l'héritage long terme
# d'un fingerprint (géré séparément par identity.py).
if current.hostname and previous.hostname and current.hostname != previous.hostname:
overlap = _port_keys(current) & _port_keys(previous)
if not overlap:
return False
return True
return False
def compare_hosts(current_hosts: list[Host], previous_hosts: list[Host] | None) -> list[Host]:
"""Marque les changements et renvoie aussi les hôtes disparus.
Une IP n'est pas une identité. Si la même IP présente une autre MAC entre deux
scans, LibreNet signale la MAC/identité comme incertaine et ne transfère pas
l'historique. Les changements d'IP ne sont inférés que lorsqu'une MAC est unique
dans les deux scans et que sa nature fournit un niveau de preuve suffisant.
"""
for host in current_hosts:
enrich_host(host)
host.change_status = ""
host.change_detail = ""
host.previous_ip = ""
if previous_hosts is None:
return current_hosts
prev_by_ip = {h.ip: h for h in previous_hosts}
prev_by_mac = _unique_mac_map(previous_hosts)
cur_unique_macs = _unique_mac_map(current_hosts)
matched_prev_ips: set[str] = set()
for host in current_hosts:
previous = prev_by_ip.get(host.ip)
if previous is not None:
current_mac = normalize_mac(host.mac)
previous_mac = normalize_mac(previous.mac)
if current_mac and previous_mac and current_mac != previous_mac:
# Une IP identique avec une autre MAC peut être un bail DHCP réattribué,
# mais aussi un bond/bridge qui bascule, une NIC remplacée ou une MAC
# privée qui tourne. On signale le changement sans prétendre connaître
# l'identité physique. L'historique riche n'est pas transféré.
matched_prev_ips.add(previous.ip)
if "laa" in {mac_identity_kind(current_mac), mac_identity_kind(previous_mac)}:
host.change_status = "Identité incertaine"
else:
host.change_status = "MAC modifiée"
host.change_detail = (
f"Même IP, MAC différente : {previous_mac}{current_mac}; "
"identification historique non transférée"
)
continue
matched_prev_ips.add(previous.ip)
details = _host_differences(host, previous)
if details:
host.change_status = "Modifié"
host.change_detail = "; ".join(details)
else:
host.change_status = "Inchangé"
continue
mac = normalize_mac(host.mac)
moved_from = prev_by_mac.get(mac) if mac and mac in cur_unique_macs else None
if moved_from is not None and _can_infer_ip_move(host, moved_from):
matched_prev_ips.add(moved_from.ip)
host.change_status = "IP modifiée"
host.previous_ip = moved_from.ip
host.change_detail = f"{moved_from.ip}{host.ip}"
else:
host.change_status = "Nouveau"
host.change_detail = "Absent du scan précédent"
result = list(current_hosts)
for previous in previous_hosts:
if previous.ip in matched_prev_ips:
continue
ghost = deepcopy(previous)
enrich_host(ghost)
ghost.status = "down"
ghost.change_status = "Disparu"
ghost.change_detail = "Présent au scan précédent, absent de ce scan"
ghost.latency_ms = None
result.append(ghost)
return result
+68
View File
@@ -0,0 +1,68 @@
from __future__ import annotations
import os
import shutil
import subprocess
from dataclasses import dataclass
@dataclass(slots=True, frozen=True)
class ArpScanDiagnostic:
path: str | None
cap_net_raw: bool | None
detail: str
def find_arp_scan() -> str | None:
found = shutil.which("arp-scan")
if found:
return found
for candidate in ("/usr/sbin/arp-scan", "/usr/bin/arp-scan"):
if os.path.isfile(candidate) and os.access(candidate, os.X_OK):
return candidate
return None
def parse_getcap_output(text: str) -> bool:
lowered = text.casefold()
return "cap_net_raw" in lowered
def arp_scan_succeeded(returncode: int) -> bool:
"""arp-scan considère uniquement le code retour 0 comme un succès."""
return returncode == 0
def arp_scan_diagnostic() -> ArpScanDiagnostic:
path = find_arp_scan()
if not path:
return ArpScanDiagnostic(None, False, "arp-scan est introuvable")
if os.geteuid() == 0:
return ArpScanDiagnostic(path, True, "application exécutée avec les privilèges root")
getcap = shutil.which("getcap")
if not getcap:
return ArpScanDiagnostic(
path,
None,
"getcap est absent : la capability CAP_NET_RAW ne peut pas être vérifiée automatiquement",
)
try:
proc = subprocess.run(
[getcap, path],
capture_output=True,
text=True,
timeout=3,
check=False,
)
except (OSError, subprocess.SubprocessError) as exc:
return ArpScanDiagnostic(path, None, f"vérification getcap impossible : {exc}")
has_cap = parse_getcap_output(proc.stdout)
if has_cap:
detail = "CAP_NET_RAW est présente"
else:
detail = "CAP_NET_RAW n'est pas détectée"
return ArpScanDiagnostic(path, has_cap, detail)
+15 -1
View File
@@ -10,12 +10,20 @@ from .models import Host
def export_csv(path: str | Path, hosts: list[Host]) -> None:
with open(path, "w", encoding="utf-8", newline="") as handle:
writer = csv.writer(handle)
writer.writerow(["status", "hostname", "ip", "mac", "vendor", "ports", "os", "latency_ms", "last_seen"])
writer.writerow([
"status", "change", "change_detail", "device_type", "is_local", "hostname", "ip", "previous_ip",
"mac", "vendor", "ports", "os", "latency_ms", "last_seen",
])
for host in hosts:
writer.writerow([
host.status,
host.change_status,
host.change_detail,
host.device_type,
"yes" if host.is_local else "no",
host.hostname,
host.ip,
host.previous_ip,
host.mac,
host.vendor,
host.ports_summary,
@@ -30,8 +38,13 @@ def export_json(path: str | Path, hosts: list[Host]) -> None:
for host in hosts:
payload.append({
"status": host.status,
"change": host.change_status,
"change_detail": host.change_detail,
"device_type": host.device_type,
"is_local": host.is_local,
"hostname": host.hostname,
"ip": host.ip,
"previous_ip": host.previous_ip,
"mac": host.mac,
"vendor": host.vendor,
"os": host.os_name,
@@ -41,6 +54,7 @@ def export_json(path: str | Path, hosts: list[Host]) -> None:
{
"port": p.port,
"protocol": p.protocol,
"state": p.state,
"service": p.service,
"product": p.product,
"version": p.version,
+142
View File
@@ -0,0 +1,142 @@
from __future__ import annotations
import ipaddress
import json
import os
from dataclasses import dataclass
from .intelligence import canonical_service_name, enrich_host
from .models import Host, PortInfo
from .naabu_runtime import BUNDLED_NAABU_PATH, NAABU_VERSION, naabu_version, trusted_root_binary
SYSTEM_NAABU_CANDIDATES = (BUNDLED_NAABU_PATH,)
@dataclass(slots=True, frozen=True)
class NaabuDiagnostic:
user_path: str | None
admin_path: str | None
user_detail: str
admin_detail: str
def find_naabu() -> str | None:
"""Retourne exclusivement le moteur Naabu provisionné par LibreNet.
Le paquet peut provisionner un Naabu 2.6.1 dans l'espace privé de
LibreNet comme accélérateur optionnel pour les grands ensembles d'hôtes. Ne jamais choisir silencieusement un ``naabu`` du PATH évite qu'une
version différente modifie les options ou le comportement du profil Standard.
Si le moteur intégré est absent ou corrompu, le scanner reste pleinement
utilisable avec Nmap au lieu d'utiliser un binaire inconnu.
"""
candidate = BUNDLED_NAABU_PATH
if os.path.isfile(candidate) and os.access(candidate, os.X_OK):
if naabu_version(candidate) == NAABU_VERSION:
return candidate
return None
def _trusted_system_naabu(path: str) -> bool:
"""Même règle de confiance que le helper root, sans exécuter de privilèges."""
return trusted_root_binary(path)
def find_admin_naabu() -> str | None:
"""Retourne le moteur LibreNet exact et sûr acceptable par le helper Polkit."""
candidate = BUNDLED_NAABU_PATH
if _trusted_system_naabu(candidate) and naabu_version(candidate) == NAABU_VERSION:
return candidate
return None
def naabu_diagnostic() -> NaabuDiagnostic:
user_path = find_naabu()
admin_path = find_admin_naabu()
if user_path:
version = naabu_version(user_path)
suffix = f" — v{version}" if version else " — version non confirmée"
source = "moteur LibreNet intégré" if user_path == BUNDLED_NAABU_PATH else "moteur externe"
user_detail = f"OK — {source}{suffix}"
else:
user_detail = "INDISPONIBLE — Nmap sera utilisé (fonctionnement normal)"
if admin_path:
version = naabu_version(admin_path)
suffix = f", v{version}" if version else ", version non confirmée"
source = "moteur LibreNet intégré" if admin_path == BUNDLED_NAABU_PATH else "moteur externe"
admin_detail = f"OK — {source}, root, non modifiable par groupe/autres{suffix}"
elif user_path:
admin_detail = (
"INDISPONIBLE — un Naabu utilisateur existe, mais le mode Admin exige "
"le moteur LibreNet ou un Naabu système appartenant à root"
)
else:
admin_detail = "INDISPONIBLE — aucun Naabu système de confiance"
return NaabuDiagnostic(user_path, admin_path, user_detail, admin_detail)
def _naabu_payload(line: str) -> dict | None:
try:
payload = json.loads(line)
except (TypeError, json.JSONDecodeError):
return None
return payload if isinstance(payload, dict) else None
def _payload_ipv4(payload: dict) -> str | None:
value = str(payload.get("ip") or payload.get("host") or "").strip()
try:
address = ipaddress.ip_address(value)
except ValueError:
return None
if address.version != 4:
return None
return str(address)
def parse_naabu_host_json_line(line: str) -> Host | None:
"""Parse une ligne JSONL issue de ``naabu -sn -json``.
Depuis Naabu 2.4, un scan de découverte JSON émet aussi les résultats sans port.
LibreNet n'a besoin ici que de l'IPv4 : l'équipement est marqué actif et sera
enrichi par ARP/neighbor/ports lors des phases suivantes.
"""
payload = _naabu_payload(line)
if payload is None:
return None
ip = _payload_ipv4(payload)
if not ip:
return None
return Host(ip=ip, status="up")
def parse_naabu_json_line(line: str) -> Host | None:
"""Transforme une ligne JSONL de scan de ports Naabu en observation LibreNet."""
payload = _naabu_payload(line)
if payload is None:
return None
ip = _payload_ipv4(payload)
if not ip:
return None
try:
port = int(payload.get("port"))
except (ValueError, TypeError):
return None
if not 1 <= port <= 65535:
return None
protocol = str(payload.get("protocol") or "tcp").strip().lower()
if protocol not in {"tcp", "udp"}:
protocol = "tcp"
service = str(payload.get("service") or "").strip()
host = Host(
ip=ip,
status="up",
ports=[
PortInfo(
port=port,
protocol=protocol,
state="open",
service=canonical_service_name(port, protocol, service),
)
],
)
return enrich_host(host)
+405
View File
@@ -0,0 +1,405 @@
from __future__ import annotations
import ipaddress
import json
import re
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Iterable, Mapping, Sequence
from .models import Host
AUTO_APPLY_THRESHOLD = 85
STALE_MOVE_DAYS = 180
@dataclass(frozen=True, slots=True)
class IdentityMatch:
"""Résultat explicable d'une tentative de corrélation historique.
Le but n'est pas de prétendre connaître l'identité physique absolue d'un
équipement, ce qui est impossible depuis un simple scan réseau, mais de
décider si une ancienne identification peut être réutilisée sans risque
déraisonnable de la coller au mauvais hôte.
"""
score: int
reason: str
identity_kind: str
safe_to_apply: bool
def normalize_mac(mac: str) -> str:
compact = re.sub(r"[^0-9A-Fa-f]", "", mac or "")
if len(compact) != 12:
return ""
try:
bytes.fromhex(compact)
except ValueError:
return ""
return ":".join(compact[i : i + 2] for i in range(0, 12, 2)).upper()
def _mac_bytes(mac: str) -> bytes:
normalized = normalize_mac(mac)
return bytes.fromhex(normalized.replace(":", "")) if normalized else b""
def is_multicast_mac(mac: str) -> bool:
raw = _mac_bytes(mac)
return bool(raw and (raw[0] & 0x01))
def is_locally_administered_mac(mac: str) -> bool:
raw = _mac_bytes(mac)
return bool(raw and (raw[0] & 0x02))
def is_known_virtual_mac(mac: str) -> bool:
"""Détecte quelques familles de MAC de redondance L2 bien connues.
- VRRPv3 IPv4 : 00:00:5E:00:01:xx
- VRRPv3 IPv6 : 00:00:5E:00:02:xx
- CARP (OPNsense/pfSense) : 00:00:5E:00:01:xx
- Cisco HSRPv1 : 00:00:0C:07:AC:xx
- Cisco HSRPv2 : 00:00:0C:9F:F0:00 .. 00:00:0C:9F:FF:FF
Une MAC virtuelle identifie un service/redondance, pas nécessairement une
machine physique. On la traite donc plus prudemment qu'une MAC globale.
"""
raw = _mac_bytes(mac)
if len(raw) != 6:
return False
if raw[:5] in (b"\x00\x00\x5e\x00\x01", b"\x00\x00\x5e\x00\x02"):
return True
if raw[:5] == b"\x00\x00\x0c\x07\xac":
return True
if raw[:4] == b"\x00\x00\x0c\x9f" and (raw[4] & 0xF0) == 0xF0:
return True
return False
def mac_identity_kind(mac: str) -> str:
normalized = normalize_mac(mac)
if not normalized:
return "none"
if is_multicast_mac(normalized):
return "multicast"
if is_known_virtual_mac(normalized):
return "virtual"
if is_locally_administered_mac(normalized):
return "laa"
return "global"
def shared_macs(hosts: Iterable[Host]) -> set[str]:
by_mac: dict[str, set[str]] = {}
for host in hosts:
mac = normalize_mac(host.mac)
if not mac or host.status == "down":
continue
by_mac.setdefault(mac, set()).add(host.ip)
return {mac for mac, ips in by_mac.items() if len(ips) > 1}
def identity_key(host: Host, *, shared_mac: bool = False) -> str:
"""Clé de persistance prudente.
Une MAC partagée par plusieurs IP pendant le même scan peut être un bridge,
un proxy ARP, une VIP ou un clone. Dans ce cas on ne fusionne pas toutes ces
IP dans une seule identité persistante : la clé inclut aussi l'IP.
"""
if host.is_local:
return "local:self"
mac = normalize_mac(host.mac)
if mac:
if shared_mac:
return f"macip:{mac}@{host.ip}"
return f"mac:{mac}"
hostname = meaningful_hostname(host.hostname, host.ip)
if hostname:
return "host:" + hostname.casefold()
return "ip:" + host.ip
def meaningful_hostname(hostname: str, ip: str = "") -> str:
value = (hostname or "").strip().rstrip(".")
if not value:
return ""
if value.casefold() in {"localhost", "localhost.localdomain", "unknown"}:
return ""
try:
ipaddress.ip_address(value)
return ""
except ValueError:
pass
if ip and value == ip:
return ""
return value
def os_family(value: str) -> str:
folded = (value or "").casefold()
# Android is Linux-based but must remain a distinct family for display and
# remembered-identification consistency.
if "android" in folded:
return "android"
# Apple platforms. Avoid a generic "ios" substring because Cisco IOS / IOS XE
# are unrelated operating systems.
if any(token in folded for token in (
"macos", "mac os x", "darwin", "iphone os", "apple ios",
"ipados", "apple tv", "tvos",
)):
return "apple"
families = (
("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"),
)
for token, family in families:
if token in folded:
return family
return ""
def open_port_keys(host: Host) -> set[tuple[int, str]]:
return {(p.port, p.protocol.lower()) for p in host.ports if p.state == "open"}
def parse_port_keys(payload: str | Sequence[object] | None) -> set[tuple[int, str]]:
if not payload:
return set()
data: object = payload
if isinstance(payload, str):
try:
data = json.loads(payload)
except (json.JSONDecodeError, TypeError):
return set()
result: set[tuple[int, str]] = set()
if not isinstance(data, Sequence):
return result
for item in data:
if isinstance(item, Mapping):
try:
result.add((int(item.get("port", 0)), str(item.get("protocol", "tcp")).lower()))
except (TypeError, ValueError):
continue
elif isinstance(item, (list, tuple)) and item:
try:
result.add((int(item[0]), str(item[1] if len(item) > 1 else "tcp").lower()))
except (TypeError, ValueError):
continue
return {p for p in result if p[0] > 0}
def port_fingerprint_json(host: Host) -> str:
payload = [
{"port": port, "protocol": proto}
for port, proto in sorted(open_port_keys(host), key=lambda value: (value[1], value[0]))
]
return json.dumps(payload, separators=(",", ":"))
def _port_match_points(current: set[tuple[int, str]], previous: set[tuple[int, str]]) -> tuple[int, str]:
if not current or not previous:
return 0, ""
overlap = current & previous
if not overlap:
# Deux signatures fournies et entièrement disjointes sont un signal
# négatif utile, notamment pour détecter un clone/réemploi de MAC.
if len(current) >= 2 and len(previous) >= 2:
return -25, "services incompatibles"
return 0, ""
coverage = len(overlap) / max(1, min(len(current), len(previous)))
if len(overlap) >= 2 and coverage >= 0.75:
return 25, "services concordants"
if len(overlap) >= 2:
return 15, "plusieurs services concordants"
return 7, "un service concordant"
def _record_identity_base(record: Mapping[str, object]) -> str:
value = str(record.get("identity") or "")
return value.split("::", 1)[-1]
def _record_age_days(record: Mapping[str, object]) -> float | None:
"""Age de la *meilleure identification*, pas de la dernière observation réseau.
``updated_at`` peut être rafraîchi par un scan Standard (nouvelle IP, hostname,
ports, etc.). Il ne doit donc jamais rajeunir artificiellement un fingerprint
OS ancien. Pour une ligne contenant un OS, ``os_seen_at`` est la référence.
Pour une identification uniquement typologique, on utilise ``type_seen_at``.
``updated_at`` n'est qu'un fallback de migration pour les anciennes bases.
"""
if str(record.get("os_name") or "").strip():
value = str(record.get("os_seen_at") or "").strip()
elif str(record.get("device_type") or "").strip():
value = str(record.get("type_seen_at") or "").strip()
else:
value = ""
if not value:
value = str(record.get("updated_at") or "").strip()
if not value:
return None
try:
then = datetime.fromisoformat(value)
now = datetime.now(timezone.utc).astimezone()
if then.tzinfo is None:
then = then.replace(tzinfo=now.tzinfo)
return max(0.0, (now - then.astimezone(now.tzinfo)).total_seconds() / 86400.0)
except ValueError:
return None
def score_identity_match(host: Host, record: Mapping[str, object], *, shared_mac: bool = False) -> IdentityMatch:
"""Score une corrélation sans jamais considérer l'IP seule comme identité.
Le moteur privilégie volontairement les faux négatifs aux faux positifs. Une
adresse IP, un hostname ou une signature de ports peuvent être réutilisés par
une autre machine ; ils servent donc uniquement de preuves complémentaires.
"""
if host.is_local:
is_local_record = _record_identity_base(record) == "local:self"
return IdentityMatch(100 if is_local_record else 0, "poste local", "local", is_local_record)
current_mac = normalize_mac(host.mac)
previous_mac = normalize_mac(str(record.get("mac") or ""))
current_hostname = meaningful_hostname(host.hostname, host.ip).casefold()
previous_hostname = meaningful_hostname(str(record.get("hostname") or ""), str(record.get("ip") or "")).casefold()
same_ip = bool(host.ip and host.ip == str(record.get("ip") or ""))
same_hostname = bool(current_hostname and previous_hostname and current_hostname == previous_hostname)
hostname_conflict = bool(current_hostname and previous_hostname and current_hostname != previous_hostname)
record_was_shared = _record_identity_base(record).startswith("macip:")
effective_shared = shared_mac or record_was_shared
# Une MAC actuelle connue qui contredit la MAC mémorisée bloque formellement
# l'héritage. C'est le cas classique d'une IP DHCP réattribuée.
if current_mac and previous_mac and current_mac != previous_mac:
return IdentityMatch(0, "MAC différente : IP potentiellement réattribuée", "conflict", False)
current_family = os_family(host.os_name)
previous_family = os_family(str(record.get("os_name") or ""))
if current_family and previous_family and current_family != previous_family:
return IdentityMatch(0, "OS actuel incompatible avec l'identification mémorisée", "conflict", False)
current_ports = open_port_keys(host)
previous_ports = parse_port_keys(str(record.get("ports_json") or ""))
port_points, port_reason = _port_match_points(current_ports, previous_ports)
reasons: list[str] = []
kind = mac_identity_kind(current_mac)
score = 0
if current_mac and previous_mac == current_mac:
if effective_shared:
# Une MAC déjà vue sur plusieurs IP (proxy ARP, VIP, clone, certains
# bridges) reste scindée par IP même si, lors du scan courant, une seule
# de ces IP répond encore. Cela évite qu'un ancien endpoint "macip" soit
# soudain assimilé à tous les autres.
if not same_ip:
return IdentityMatch(0, "MAC historiquement partagée : IP différente", "shared", False)
score = 65
reasons.append("MAC partagée + même IP")
kind_for_result = "shared"
elif kind == "global":
score = 95
reasons.append("MAC globale identique")
kind_for_result = "global"
elif kind == "laa":
# Une LAA peut être stable, par SSID, par connexion ou aléatoire. Elle
# apporte un indice utile mais n'est jamais considérée suffisante seule.
score = 60
reasons.append("MAC locale (LAA) identique")
kind_for_result = "laa"
elif kind == "virtual":
# Une MAC VRRP/CARP/HSRP suit un service logique et peut changer de nœud.
if not same_ip:
return IdentityMatch(0, "MAC virtuelle sans continuité d'IP", "virtual", False)
score = 60
reasons.append("MAC virtuelle + même VIP")
kind_for_result = "virtual"
else:
return IdentityMatch(0, "MAC non exploitable comme identité", kind, False)
if same_ip and kind_for_result != "global":
score += 12
reasons.append("même IP")
if same_hostname:
score += 15 if kind_for_result != "global" else 3
reasons.append("même nom")
elif hostname_conflict:
# Un renommage est possible, donc ce n'est pas un rejet absolu. Mais un
# nom différent est un signal important lorsqu'une MAC a pu être clonée.
score -= 12
reasons.append("nom différent")
if port_points:
score += port_points
if port_reason:
reasons.append(port_reason)
if current_family and previous_family and current_family == previous_family:
score += 8
reasons.append("même famille OS")
# Une MAC globale est généralement un très bon identifiant L2, mais une VM
# clonée ou une MAC spoofée peut réapparaître longtemps après sur une autre
# IP. Au-delà de 180 jours, un déplacement d'IP exige donc un indice actuel
# supplémentaire (ports/hostname/OS) au lieu de faire confiance à la MAC seule.
age_days = _record_age_days(record)
if not same_ip and age_days is not None and age_days > STALE_MOVE_DAYS:
score -= 25
reasons.append(f"historique ancien ({int(age_days)} j)")
score = max(0, min(100, score))
safe = score >= AUTO_APPLY_THRESHOLD
return IdentityMatch(score, ", ".join(reasons), kind_for_result, safe)
# Sans MAC actuelle, l'identité physique/logique ne peut pas être vérifiée.
# Sur un réseau routé, DNS, IP et ports sont utiles pour afficher un *indice*,
# mais pas pour réinjecter automatiquement un ancien OS/type : DHCP, NAT,
# load-balancing et DNS obsolète rendraient ce comportement trop risqué.
if previous_mac:
return IdentityMatch(0, "MAC actuelle absente : identité non vérifiable", "no-current-mac", False)
if same_hostname:
score += 45
reasons.append("même nom")
if same_ip:
score += 10
reasons.append("même IP")
if port_points:
score += port_points
if port_reason:
reasons.append(port_reason)
if current_family and previous_family and current_family == previous_family:
score += 10
reasons.append("même famille OS")
score = max(0, min(100, score))
return IdentityMatch(score, ", ".join(reasons) or "IP seule insuffisante", "weak", False)
def candidate_query_values(host: Host) -> tuple[str, str, str]:
"""Valeurs utiles pour chercher des candidats sans décider de l'identité."""
return normalize_mac(host.mac), host.ip, meaningful_hostname(host.hostname, host.ip)
+125
View File
@@ -0,0 +1,125 @@
from __future__ import annotations
import re
from .models import Host
CANONICAL_TCP_SERVICES: dict[int, str] = {
20: "FTP-data",
21: "FTP",
22: "SSH",
23: "Telnet",
25: "SMTP",
53: "DNS",
80: "HTTP",
110: "POP3",
135: "MS-RPC",
139: "NetBIOS",
143: "IMAP",
389: "LDAP",
443: "HTTPS",
445: "SMB",
465: "SMTPS",
515: "LPD",
587: "SMTP submission",
631: "IPP",
636: "LDAPS",
993: "IMAPS",
995: "POP3S",
1433: "MS SQL",
1521: "Oracle",
2049: "NFS",
3306: "MySQL/MariaDB",
3389: "RDP",
5000: "Synology DSM",
5001: "Synology DSM HTTPS",
5432: "PostgreSQL",
5900: "VNC",
5985: "WinRM HTTP",
5986: "WinRM HTTPS",
8006: "Proxmox VE",
8007: "Proxmox Backup Server",
8080: "HTTP alternatif",
8443: "HTTPS alternatif",
9100: "JetDirect",
}
def canonical_service_name(port: int, protocol: str, nmap_name: str = "") -> str:
if protocol.lower() == "tcp" and port in CANONICAL_TCP_SERVICES:
return CANONICAL_TCP_SERVICES[port]
return nmap_name.strip()
def _ports(host: Host) -> set[int]:
return {p.port for p in host.ports if p.state == "open" and p.protocol == "tcp"}
def classify_host(host: Host) -> str:
"""Classe un équipement avec des heuristiques explicables, sans prétendre à une détection certaine."""
name = host.hostname.casefold()
vendor = host.vendor.casefold()
os_name = host.os_name.casefold()
ports = _ports(host)
if host.is_local:
return "Ce poste"
if 8007 in ports or re.search(r"(^|[.-])pbs\d*([.-]|$)", name):
return "Proxmox Backup Server"
if 8006 in ports or "proxmox" in name or re.search(r"(^|[.-])pve\d*([.-]|$)", name):
return "Hyperviseur Proxmox"
if any(token in name for token in ("opnsense", "pfsense")) or re.search(r"(^|[.-])opns\d*([.-]|$)", name):
return "Pare-feu / routeur"
if "synology" in vendor or "synology" in name or name.startswith("syno") or ports.intersection({5000, 5001}):
return "NAS Synology"
if ports.intersection({9100, 515, 631}):
return "Imprimante"
if re.search(r"(^|[.-])sw\d", name) or "switch" in name:
return "Switch"
if re.search(r"(^|[.-])ap\d", name) or any(token in name for token in ("access-point", "accesspoint")):
return "Point d'accès Wi-Fi"
if 3389 in ports or 5985 in ports or 5986 in ports:
return "Poste / serveur Windows"
if "windows" in os_name:
return "Poste / serveur Windows"
if 445 in ports and 22 not in ports:
return "Poste / serveur Windows"
if "linux" in os_name:
return "Serveur Linux" if 22 in ports else "Hôte Linux"
if 22 in ports and ports.intersection({80, 443, 8080, 8443}):
return "Serveur / appliance"
if 22 in ports:
return "Serveur SSH"
if 53 in ports and ports.intersection({80, 443, 8443}):
return "Équipement réseau"
if ports and ports.issubset({80, 443, 8080, 8443}):
return "Appliance Web"
return "Hôte"
def enrich_host(host: Host) -> Host:
for port in host.ports:
port.service = canonical_service_name(port.port, port.protocol, port.service)
host.device_type = classify_host(host)
return host
DEVICE_ICON_NAMES: dict[str, str] = {
"Ce poste": "computer",
"Hyperviseur Proxmox": "computer-server",
"Proxmox Backup Server": "computer-server",
"Pare-feu / routeur": "network-connect",
"NAS Synology": "drive-harddisk",
"Imprimante": "printer",
"Switch": "network-wired",
"Point d'accès Wi-Fi": "network-wireless",
"Poste / serveur Windows": "computer",
"Serveur Linux": "computer-server",
"Hôte Linux": "computer",
"Serveur / appliance": "computer-server",
"Serveur SSH": "computer-server",
"Équipement réseau": "network-wired",
"Appliance Web": "applications-internet",
"Hôte": "computer",
}
+35 -1
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
import sys
from pathlib import Path
from PySide6.QtGui import QIcon
from PySide6.QtWidgets import QApplication
@@ -8,14 +9,47 @@ from PySide6.QtWidgets import QApplication
from .ui import MainWindow
APP_DESKTOP_ID = "librenet-scanner"
APP_ICON_NAME = "librenet-scanner"
def _load_app_icon() -> QIcon:
"""Charge l'identité visuelle LibreNet, sans icône réseau générique."""
candidates = (
Path("/usr/share/icons/hicolor/scalable/apps/librenet-scanner.svg"),
Path(__file__).resolve().parents[2] / "assets" / "librenet-scanner.svg",
)
for path in candidates:
if path.is_file():
icon = QIcon(str(path))
if not icon.isNull():
return icon
# Dernier recours : demander explicitement notre propre nom d'icône au thème.
# On ne revient volontairement jamais à "network-wired".
return QIcon.fromTheme(APP_ICON_NAME)
def main() -> int:
app = QApplication(sys.argv)
app.setApplicationName("LibreNet Scanner")
app.setApplicationDisplayName("LibreNet Scanner")
app.setOrganizationName("LibreNet")
icon = QIcon.fromTheme("network-wired")
# Sous Plasma/Wayland, permet à KWin d'associer la fenêtre au .desktop
# et donc à la bonne icône dans la décoration et le gestionnaire de tâches.
if hasattr(app, "setDesktopFileName"):
app.setDesktopFileName(APP_DESKTOP_ID)
icon = _load_app_icon()
if not icon.isNull():
app.setWindowIcon(icon)
window = MainWindow()
if not icon.isNull():
# Explicite également l'icône sur la fenêtre principale pour les
# décorateurs X11/Wayland qui n'héritent pas toujours de QApplication.
window.setWindowIcon(icon)
window.show()
return app.exec()
+155 -8
View File
@@ -15,8 +15,18 @@ class PortInfo:
@property
def label(self) -> str:
service = self.service or "?"
return f"{self.port}/{self.protocol} {service}" if service else f"{self.port}/{self.protocol}"
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)
@@ -27,9 +37,29 @@ class Host:
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:
@@ -40,6 +70,8 @@ class Host:
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:
@@ -47,16 +79,131 @@ class Host:
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:
if not self.ports:
return ""
return ", ".join(
f"{p.port}/{p.protocol}" + (f" ({p.service})" if p.service else "")
for p in self.ports
if p.state == "open"
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()
+236
View File
@@ -0,0 +1,236 @@
from __future__ import annotations
import hashlib
import io
import os
import platform
import shutil
import stat
import subprocess
import sys
import tempfile
import urllib.request
import zipfile
from pathlib import Path
NAABU_VERSION = "2.6.1"
NAABU_RELEASE_TAG = f"v{NAABU_VERSION}"
BUNDLED_NAABU_PATH = "/usr/lib/librenet-scanner/bin/naabu"
NAABU_AMD64_URL = (
"https://github.com/projectdiscovery/naabu/releases/download/"
f"{NAABU_RELEASE_TAG}/naabu_{NAABU_VERSION}_linux_amd64.zip"
)
# Empreinte publiée par ProjectDiscovery sur la release GitHub v2.6.1.
NAABU_AMD64_SHA256 = "018c4c9884dea971eda860435ede3021d1150732f34cfd245498c6726d8cab90"
class NaabuProvisionError(RuntimeError):
pass
def _machine_is_amd64() -> bool:
return platform.machine().lower() in {"x86_64", "amd64"}
def _download(url: str, *, timeout: float = 60.0) -> bytes:
request = urllib.request.Request(
url,
headers={"User-Agent": "LibreNet-Scanner-Naabu-Provisioner/1.0.0"},
)
try:
with urllib.request.urlopen(request, timeout=timeout) as response:
chunks: list[bytes] = []
total = 0
limit = 100 * 1024 * 1024
while True:
chunk = response.read(1024 * 1024)
if not chunk:
break
total += len(chunk)
if total > limit:
raise NaabuProvisionError("Archive Naabu anormalement volumineuse (> 100 Mio)")
chunks.append(chunk)
return b"".join(chunks)
except Exception as exc: # urllib regroupe plusieurs classes d'erreurs réseau
raise NaabuProvisionError(f"Téléchargement de Naabu impossible : {exc}") from exc
def _verify_archive(data: bytes, expected_sha256: str) -> None:
digest = hashlib.sha256(data).hexdigest()
if digest.lower() != expected_sha256.lower():
raise NaabuProvisionError(
"Empreinte SHA-256 Naabu invalide : "
f"attendue {expected_sha256}, reçue {digest}"
)
def _extract_binary(data: bytes) -> bytes:
try:
with zipfile.ZipFile(io.BytesIO(data)) as archive:
matches = [name for name in archive.namelist() if name.rstrip("/").split("/")[-1] == "naabu"]
if len(matches) != 1:
raise NaabuProvisionError(
f"Archive Naabu inattendue : {len(matches)} exécutable(s) 'naabu' trouvé(s)"
)
return archive.read(matches[0])
except zipfile.BadZipFile as exc:
raise NaabuProvisionError("Archive Naabu ZIP invalide") from exc
def naabu_version(path: str) -> str | None:
try:
result = subprocess.run(
[path, "-version", "-disable-update-check", "-config", "/dev/null", "-auth=false"],
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
encoding="utf-8",
errors="replace",
timeout=8,
check=False,
env={**os.environ, "HOME": "/nonexistent", "NO_COLOR": "1"},
)
except (OSError, subprocess.SubprocessError):
return None
text = (result.stdout or "").strip()
if result.returncode != 0 or NAABU_VERSION not in text:
return None
return NAABU_VERSION
def trusted_root_binary(path: str) -> bool:
try:
info = Path(path).stat()
except OSError:
return False
return (
stat.S_ISREG(info.st_mode)
and info.st_uid == 0
and not (info.st_mode & (stat.S_IWGRP | stat.S_IWOTH))
and os.access(path, os.X_OK)
)
def _copy_existing_system_naabu(destination: str) -> str | None:
"""Réutilise un Naabu système root déjà présent avant tout téléchargement."""
for candidate in ("/usr/local/bin/naabu", "/usr/bin/naabu"):
if candidate == destination:
continue
if not trusted_root_binary(candidate) or naabu_version(candidate) != NAABU_VERSION:
continue
destination_path = Path(destination)
destination_path.parent.mkdir(parents=True, exist_ok=True)
fd, tmp_name = tempfile.mkstemp(prefix=".naabu-", dir=str(destination_path.parent))
try:
with open(candidate, "rb") as source, os.fdopen(fd, "wb") as target:
shutil.copyfileobj(source, target)
target.flush()
os.fsync(target.fileno())
os.chmod(tmp_name, 0o755)
os.chown(tmp_name, 0, 0)
os.replace(tmp_name, destination)
tmp_name = ""
return destination
finally:
if tmp_name:
try:
os.unlink(tmp_name)
except OSError:
pass
return None
def install_naabu(
*,
destination: str = BUNDLED_NAABU_PATH,
url: str = NAABU_AMD64_URL,
expected_sha256: str = NAABU_AMD64_SHA256,
download_func=_download,
require_root: bool = True,
) -> str:
"""Installe atomiquement le binaire Naabu vérifié utilisé par LibreNet.
La fonction est volontairement paramétrable pour permettre des tests hors ligne.
En production, seuls l'URL et le SHA-256 figés ci-dessus sont utilisés.
"""
if require_root and os.geteuid() != 0:
raise NaabuProvisionError("L'installation du moteur Naabu doit être exécutée en root")
if not _machine_is_amd64():
raise NaabuProvisionError(
f"Architecture non prise en charge par ce paquet : {platform.machine()} (amd64 requis)"
)
destination_path = Path(destination)
if destination_path.is_file() and os.access(destination, os.X_OK):
if naabu_version(destination) == NAABU_VERSION:
if require_root:
os.chown(destination, 0, 0)
os.chmod(destination, 0o755)
return destination
if require_root:
reused = _copy_existing_system_naabu(destination)
if reused:
return reused
data = download_func(url)
_verify_archive(data, expected_sha256)
binary = _extract_binary(data)
if not binary.startswith(b"\x7fELF"):
raise NaabuProvisionError("Le fichier Naabu extrait n'est pas un exécutable ELF")
destination_path.parent.mkdir(parents=True, exist_ok=True)
fd, tmp_name = tempfile.mkstemp(prefix=".naabu-", dir=str(destination_path.parent))
try:
with os.fdopen(fd, "wb") as handle:
handle.write(binary)
handle.flush()
os.fsync(handle.fileno())
os.chmod(tmp_name, 0o755)
if require_root:
os.chown(tmp_name, 0, 0)
os.replace(tmp_name, destination)
tmp_name = ""
if naabu_version(destination) != NAABU_VERSION:
try:
destination_path.unlink()
except OSError:
pass
raise NaabuProvisionError(
f"Le moteur Naabu installé ne s'identifie pas comme version {NAABU_VERSION}"
)
return destination
finally:
if tmp_name:
try:
os.unlink(tmp_name)
except OSError:
pass
def ensure_naabu() -> int:
try:
path = install_naabu()
except NaabuProvisionError as exc:
print(f"LibreNet Scanner : ERREUR moteur Naabu : {exc}", file=sys.stderr)
print(
"L'accélérateur Naabu optionnel n'a pas pu être installé. "
"LibreNet Scanner reste utilisable avec son moteur Nmap adaptatif.",
file=sys.stderr,
)
return 1
print(f"LibreNet Scanner : moteur Naabu {NAABU_VERSION} prêt dans {path}")
return 0
def main(argv: list[str] | None = None) -> int:
args = list(sys.argv[1:] if argv is None else argv)
if args not in ([], ["--ensure"]):
print("Usage : librenet-scanner-install-naabu [--ensure]", file=sys.stderr)
return 64
return ensure_naabu()
if __name__ == "__main__":
raise SystemExit(main())
+219 -4
View File
@@ -2,14 +2,20 @@ from __future__ import annotations
import ipaddress
import json
import re
import subprocess
from dataclasses import dataclass
from pathlib import Path
VIRTUAL_PREFIXES = (
"lo", "docker", "br-", "veth", "virbr", "podman", "cni", "flannel", "tun", "tap",
)
_MAC_RE = re.compile(r"^(?:[0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$")
_FULL_RANGE_RE = re.compile(r"^\s*(\d{1,3}(?:\.\d{1,3}){3})\s*-\s*(\d{1,3}(?:\.\d{1,3}){3})\s*$")
_NMAP_RANGE_RE = re.compile(r"^(\d{1,3}\.\d{1,3}\.\d{1,3})\.(\d{1,3})-(\d{1,3})$")
@dataclass(slots=True, frozen=True)
class NetworkInterface:
@@ -18,6 +24,7 @@ class NetworkInterface:
prefixlen: int
network: str
is_virtual: bool = False
mac: str = ""
@property
def label(self) -> str:
@@ -25,14 +32,173 @@ class NetworkInterface:
return f"{self.name}{self.address}/{self.prefixlen}{self.network}{suffix}"
@dataclass(slots=True, frozen=True)
class NeighborEntry:
ip: str
mac: str
state: str = ""
def normalize_interface_mac(value: str) -> str:
"""Normalise une adresse MAC d'interface et rejette les valeurs non exploitables."""
value = (value or "").strip().upper()
if not _MAC_RE.match(value):
return ""
if value == "00:00:00:00:00:00":
return ""
return value
def interface_mac_address(interface_name: str) -> str:
"""Retourne la MAC locale d'une interface sans passer par ARP/Nmap.
Sous Linux, sysfs est la source la plus directe et évite le cas classique où
la machine locale n'apparaît pas dans ``ip neigh``. ``ip -j link`` sert de
repli pour les environnements où sysfs n'est pas lisible.
"""
try:
value = Path("/sys/class/net").joinpath(interface_name, "address").read_text(encoding="utf-8")
mac = normalize_interface_mac(value)
if mac:
return mac
except (OSError, UnicodeError):
pass
try:
proc = subprocess.run(
["ip", "-j", "link", "show", "dev", interface_name],
check=False,
capture_output=True,
text=True,
timeout=5,
)
if proc.returncode == 0:
payload = json.loads(proc.stdout or "[]")
if payload:
return normalize_interface_mac(str(payload[0].get("address", "")))
except (OSError, subprocess.SubprocessError, json.JSONDecodeError, IndexError, TypeError):
pass
return ""
def target_contains_ip(target: str, address: str) -> bool:
"""Indique si une cible validée contient une adresse IPv4 donnée."""
try:
ip = ipaddress.ip_address(address)
if ip.version != 4:
return False
endpoints = _range_endpoints(target)
if endpoints:
return int(endpoints[0]) <= int(ip) <= int(endpoints[1])
if "/" in target:
return ip in ipaddress.ip_network(target, strict=False)
return ip == ipaddress.ip_address(target)
except ValueError:
return False
def _range_endpoints(value: str) -> tuple[ipaddress.IPv4Address, ipaddress.IPv4Address] | None:
full = _FULL_RANGE_RE.match(value)
if full:
start = ipaddress.ip_address(full.group(1))
end = ipaddress.ip_address(full.group(2))
if start.version != 4 or end.version != 4:
raise ValueError("IPv4 uniquement")
return start, end
compact = _NMAP_RANGE_RE.match(value.strip())
if compact:
start = ipaddress.ip_address(f"{compact.group(1)}.{compact.group(2)}")
end = ipaddress.ip_address(f"{compact.group(1)}.{compact.group(3)}")
return start, end
return None
def validate_target(value: str) -> str:
"""Valide une cible IPv4 et accepte aussi la plage A.B.C.1 - A.B.C.254.
Les plages sont volontairement limitées à un même /24 en V0.3 afin de produire
une syntaxe Nmap sûre et lisible (A.B.C.1-254).
"""
value = value.strip()
try:
endpoints = _range_endpoints(value)
if endpoints:
start, end = endpoints
if int(start) > int(end):
raise ValueError("Le début de la plage doit précéder la fin")
if start.packed[:3] != end.packed[:3]:
raise ValueError("Les plages V0.3 doivent rester dans le même /24")
count = int(end) - int(start) + 1
if count > 4096:
raise ValueError("4096 adresses maximum")
prefix = ".".join(str(start).split(".")[:3])
return f"{prefix}.{int(str(start).split('.')[-1])}-{int(str(end).split('.')[-1])}"
if "/" in value:
return str(ipaddress.ip_network(value, strict=False))
return str(ipaddress.ip_address(value))
network = ipaddress.ip_network(value, strict=False)
if network.version != 4:
raise ValueError("IPv4 uniquement")
return str(network)
address = ipaddress.ip_address(value)
if address.version != 4:
raise ValueError("IPv4 uniquement")
return str(address)
except ValueError as exc:
raise ValueError(f"Cible IPv4 invalide : {value}") from exc
raise ValueError(f"Cible IPv4 invalide : {value} ({exc})") from exc
def scan_identity_scope(target: str, interface: NetworkInterface | None = None) -> str:
"""Retourne un domaine de corrélation stable pour l'historique d'identité.
Une même MAC peut exister dans deux VLANs/réseaux distincts (clone de VM,
équipement virtuel, lab). On évite donc une corrélation globale par MAC. Pour
un réseau directement connecté, le préfixe de l'interface sert de domaine ;
sinon le CIDR demandé — ou le /24 contenant une plage — est utilisé.
"""
value = validate_target(target)
if interface is not None and target_is_on_interface(value, interface):
return f"ipv4:{ipaddress.ip_network(interface.network, strict=False)}"
endpoints = _range_endpoints(value)
if endpoints:
network = ipaddress.ip_network(f"{endpoints[0]}/24", strict=False)
return f"ipv4:{network}"
if "/" in value:
return f"ipv4:{ipaddress.ip_network(value, strict=False)}"
address = ipaddress.ip_address(value)
return f"ipv4:{address}/32"
def target_address_count(target: str) -> int:
endpoints = _range_endpoints(target)
if endpoints:
return int(endpoints[1]) - int(endpoints[0]) + 1
if "/" in target:
return int(ipaddress.ip_network(target, strict=False).num_addresses)
return 1
def target_ipv4_hosts(target: str) -> list[str]:
"""Déplie une cible LibreNet en adresses IPv4 hôtes pour les moteurs sans syntaxe Nmap.
LibreNet limite déjà les cibles à 4096 adresses. Pour un CIDR classique, les
adresses réseau/broadcast sont ignorées comme hôtes ; /31 et /32 conservent le
comportement de :meth:`ipaddress.IPv4Network.hosts`.
"""
value = validate_target(target)
endpoints = _range_endpoints(value)
if endpoints:
start, end = endpoints
return [str(ipaddress.ip_address(raw)) for raw in range(int(start), int(end) + 1)]
if "/" in value:
network = ipaddress.ip_network(value, strict=False)
return [str(address) for address in network.hosts()]
return [str(ipaddress.ip_address(value))]
def display_target(target: str) -> str:
endpoints = _range_endpoints(target)
if endpoints:
return f"{endpoints[0]} - {endpoints[1]}"
return target
def list_ipv4_interfaces() -> list[NetworkInterface]:
@@ -49,11 +215,13 @@ def list_ipv4_interfaces() -> list[NetworkInterface]:
return []
result: list[NetworkInterface] = []
mac_cache: dict[str, str] = {}
for item in payload:
name = item.get("ifname", "")
if not name or name == "lo":
continue
virtual = name.startswith(VIRTUAL_PREFIXES)
mac_cache.setdefault(name, interface_mac_address(name))
for addr in item.get("addr_info", []):
if addr.get("family") != "inet" or addr.get("scope") != "global":
continue
@@ -62,16 +230,63 @@ def list_ipv4_interfaces() -> list[NetworkInterface]:
if not local:
continue
network = str(ipaddress.ip_network(f"{local}/{prefixlen}", strict=False))
result.append(NetworkInterface(name, local, prefixlen, network, virtual))
result.append(NetworkInterface(name, local, prefixlen, network, virtual, mac_cache[name]))
result.sort(key=lambda i: (i.is_virtual, i.name, i.address))
return result
def parse_neighbor_json(text: str) -> list[NeighborEntry]:
try:
payload = json.loads(text or "[]")
except json.JSONDecodeError:
return []
result: list[NeighborEntry] = []
for item in payload:
dst = str(item.get("dst", "")).strip()
mac = str(item.get("lladdr", "")).strip().upper()
state_value = item.get("state", "")
if isinstance(state_value, list):
state = ",".join(str(v) for v in state_value)
else:
state = str(state_value)
if not dst or not mac or not _MAC_RE.match(mac):
continue
if "FAILED" in state.upper() or "INCOMPLETE" in state.upper():
continue
try:
if ipaddress.ip_address(dst).version != 4:
continue
except ValueError:
continue
result.append(NeighborEntry(dst, mac, state))
return result
def list_ipv4_neighbors(interface_name: str) -> list[NeighborEntry]:
try:
proc = subprocess.run(
["ip", "-j", "neigh", "show", "dev", interface_name],
capture_output=True,
text=True,
timeout=5,
check=False,
)
except (OSError, subprocess.SubprocessError):
return []
if proc.returncode != 0:
return []
return parse_neighbor_json(proc.stdout)
def target_is_on_interface(target: str, interface: NetworkInterface | None) -> bool:
if interface is None:
return False
try:
iface_net = ipaddress.ip_network(interface.network, strict=False)
endpoints = _range_endpoints(target)
if endpoints:
return endpoints[0] in iface_net and endpoints[1] in iface_net
target_net = ipaddress.ip_network(target, strict=False) if "/" in target else ipaddress.ip_network(f"{target}/32")
return target_net.subnet_of(iface_net)
except ValueError:
+126
View File
@@ -0,0 +1,126 @@
from __future__ import annotations
import json
import urllib.error
import urllib.parse
import urllib.request
from dataclasses import dataclass
from datetime import datetime, timezone
PROVIDER_MACLOOKUP = "maclookup.app"
PROVIDER_MACVENDORS = "macvendors.com"
PROVIDERS = (PROVIDER_MACLOOKUP, PROVIDER_MACVENDORS)
class OnlineVendorError(RuntimeError):
pass
@dataclass(slots=True)
class OnlineVendorResult:
mac: str
provider: str
vendor: str = ""
found: bool = False
block_type: str = ""
is_randomized: bool = False
is_private: bool = False
checked_at: str = ""
from_cache: bool = False
def __post_init__(self) -> None:
if not self.checked_at:
self.checked_at = datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds")
def normalize_mac(mac: str) -> str:
hexchars = "".join(ch for ch in mac.upper() if ch in "0123456789ABCDEF")
if len(hexchars) != 12:
return ""
return ":".join(hexchars[i : i + 2] for i in range(0, 12, 2))
def is_locally_administered(mac: str) -> bool:
normalized = normalize_mac(mac)
if not normalized:
return False
return bool(int(normalized[:2], 16) & 0x02)
def _request(url: str, *, timeout: float = 6.0) -> bytes:
req = urllib.request.Request(
url,
headers={
"User-Agent": "LibreNet-Scanner/1.0.0",
"Accept": "application/json,text/plain;q=0.9,*/*;q=0.1",
},
method="GET",
)
try:
with urllib.request.urlopen(req, timeout=timeout) as response:
return response.read()
except urllib.error.HTTPError as exc:
if exc.code == 404:
return b""
if exc.code == 429:
raise OnlineVendorError("Limite de requêtes atteinte chez le fournisseur en ligne.") from exc
raise OnlineVendorError(f"Service en ligne indisponible (HTTP {exc.code}).") from exc
except urllib.error.URLError as exc:
reason = getattr(exc, "reason", exc)
raise OnlineVendorError(f"Impossible de joindre le service en ligne : {reason}") from exc
except OSError as exc:
raise OnlineVendorError(f"Erreur réseau : {exc}") from exc
def lookup_online_vendor(mac: str, provider: str = PROVIDER_MACLOOKUP, *, timeout: float = 6.0) -> OnlineVendorResult:
normalized = normalize_mac(mac)
if not normalized:
raise OnlineVendorError("Adresse MAC invalide.")
if provider not in PROVIDERS:
raise OnlineVendorError(f"Fournisseur inconnu : {provider}")
encoded = urllib.parse.quote(normalized, safe="")
if provider == PROVIDER_MACLOOKUP:
body = _request(f"https://api.maclookup.app/v2/macs/{encoded}", timeout=timeout)
if not body:
return OnlineVendorResult(mac=normalized, provider=provider)
try:
payload = json.loads(body.decode("utf-8", errors="replace"))
except (json.JSONDecodeError, UnicodeDecodeError) as exc:
raise OnlineVendorError("Réponse invalide de MACLookup.app.") from exc
if not payload.get("success", True):
raise OnlineVendorError(str(payload.get("error") or "Erreur MACLookup.app"))
found = bool(payload.get("found"))
vendor = str(payload.get("company") or "").strip() if found else ""
return OnlineVendorResult(
mac=normalized,
provider=provider,
vendor=vendor,
found=bool(found and vendor),
block_type=str(payload.get("blockType") or ""),
is_randomized=bool(payload.get("isRand")),
is_private=bool(payload.get("isPrivate")),
)
body = _request(f"https://api.macvendors.com/{encoded}", timeout=timeout)
vendor = body.decode("utf-8", errors="replace").strip() if body else ""
return OnlineVendorResult(
mac=normalized,
provider=provider,
vendor=vendor,
found=bool(vendor),
is_randomized=is_locally_administered(normalized),
)
def provider_label(provider: str) -> str:
if provider == PROVIDER_MACVENDORS:
return "MACVendors.com"
return "MACLookup.app"
def provider_min_interval(provider: str) -> float:
# MACVendors limite l'offre gratuite à 1 requête/s ; MACLookup autorise davantage,
# mais une petite temporisation évite de marteler inutilement le service.
return 1.05 if provider == PROVIDER_MACVENDORS else 0.12
+26 -4
View File
@@ -4,6 +4,7 @@ import re
import xml.etree.ElementTree as ET
from datetime import datetime, timezone
from .intelligence import canonical_service_name, enrich_host
from .models import Host, PortInfo
@@ -21,7 +22,9 @@ def parse_arp_scan(text: str) -> list[Host]:
if not match:
continue
vendor = (match.group("vendor") or "").strip()
if vendor == "(Unknown)":
if vendor.casefold().startswith("(unknown"):
# arp-scan peut renvoyer notamment "(Unknown: locally administered)"
# et des suffixes DUP. Ce n'est pas un constructeur exploitable.
vendor = ""
hosts.append(
Host(
@@ -80,11 +83,23 @@ def parse_nmap_xml(text: str) -> list[Host]:
hostname = candidate.get("name", "")
os_name = ""
os_accuracy: int | None = None
os_node = node.find("os")
if os_node is not None:
match = os_node.find("osmatch")
if match is not None:
# Avec --osscan-guess, Nmap peut renvoyer plusieurs osmatch. Ne
# supposons pas que l'ordre XML restera toujours le meilleur : on
# retient explicitement le match ayant la précision la plus élevée.
matches: list[tuple[int, ET.Element]] = []
for candidate in os_node.findall("osmatch"):
try:
accuracy = int(candidate.get("accuracy", "0"))
except (TypeError, ValueError):
accuracy = 0
matches.append((accuracy, candidate))
if matches:
accuracy, match = max(matches, key=lambda item: item[0])
os_name = match.get("name", "")
os_accuracy = accuracy if match.get("accuracy") is not None else None
ports: list[PortInfo] = []
ports_node = node.find("ports")
@@ -100,13 +115,18 @@ def parse_nmap_xml(text: str) -> list[Host]:
port=int(pnode.get("portid", "0")),
protocol=pnode.get("protocol", "tcp"),
state=state,
service=service_node.get("name", "") if service_node is not None else "",
service=canonical_service_name(
int(pnode.get("portid", "0")),
pnode.get("protocol", "tcp"),
service_node.get("name", "") if service_node is not None else "",
),
product=service_node.get("product", "") if service_node is not None else "",
version=service_node.get("version", "") if service_node is not None else "",
)
)
hosts.append(
enrich_host(
Host(
ip=ip,
hostname=hostname,
@@ -114,9 +134,11 @@ def parse_nmap_xml(text: str) -> list[Host]:
vendor=vendor,
status=status,
os_name=os_name,
os_accuracy=os_accuracy,
latency_ms=_latency(node),
ports=ports,
last_seen=now,
)
)
)
return hosts
+260
View File
@@ -0,0 +1,260 @@
from __future__ import annotations
import ipaddress
import os
import re
import signal
import stat
import subprocess
import sys
import threading
import time
from pathlib import Path
COMMON_PORTS = (
"21,22,23,25,53,80,110,135,139,143,389,443,445,465,515,587,631,636,993,995,"
"1433,1521,2049,3306,3389,5000,5001,5432,5900,5985,5986,8006,8007,8080,8443,9100"
)
_INTERFACE_RE = re.compile(r"^[A-Za-z0-9_.:@-]{1,32}$")
_RANGE_RE = re.compile(r"^(\d{1,3}\.\d{1,3}\.\d{1,3})\.(\d{1,3})-(\d{1,3})$")
def _trusted_binary(*candidates: str) -> str:
for candidate in candidates:
path = Path(candidate)
if path.is_file() and os.access(path, os.X_OK):
info = path.stat()
if info.st_uid == 0 and not (info.st_mode & (stat.S_IWGRP | stat.S_IWOTH)):
return str(path)
raise RuntimeError(f"Binaire requis introuvable : {candidates[0]}")
def validate_interface(value: str) -> str:
if not _INTERFACE_RE.fullmatch(value):
raise ValueError("Nom d'interface invalide")
if not Path("/sys/class/net", value).exists():
raise ValueError("Interface réseau inexistante")
return value
def validate_target(value: str, *, network_allowed: bool = True) -> str:
value = value.strip()
if not value or value.startswith("-"):
raise ValueError("Cible invalide")
range_match = _RANGE_RE.fullmatch(value)
if range_match:
if not network_allowed:
raise ValueError("Une adresse hôte est requise")
start = int(range_match.group(2))
end = int(range_match.group(3))
if not (0 <= start <= end <= 255):
raise ValueError("Plage IPv4 invalide")
# valide aussi les trois premiers octets
ipaddress.ip_address(f"{range_match.group(1)}.{start}")
return value
try:
if "/" in value:
if not network_allowed:
raise ValueError("Une adresse hôte est requise")
network = ipaddress.ip_network(value, strict=False)
if network.version != 4:
raise ValueError("IPv4 uniquement")
if network.num_addresses > 4096:
raise ValueError("Réseau trop grand : 4096 adresses maximum")
return str(network)
address = ipaddress.ip_address(value)
if address.version != 4:
raise ValueError("IPv4 uniquement")
return str(address)
except ValueError as exc:
raise ValueError(str(exc)) from exc
def validate_host_list(values: list[str]) -> list[str]:
if not values or len(values) > 4096:
raise ValueError("Liste d'hôtes invalide")
return [validate_target(value, network_allowed=False) for value in values]
def command_for(operation: str, args: list[str]) -> list[str]:
nmap = lambda: _trusted_binary("/usr/bin/nmap", "/usr/local/bin/nmap")
arp_scan = lambda: _trusted_binary("/usr/sbin/arp-scan", "/usr/bin/arp-scan")
naabu = lambda: _trusted_binary("/usr/lib/librenet-scanner/bin/naabu")
if operation == "authorize":
if args:
raise ValueError("Aucun argument attendu")
return []
if operation == "arp-scan":
if len(args) != 2:
raise ValueError("Usage : arp-scan <interface> <cible>")
iface = validate_interface(args[0])
target = validate_target(args[1])
if "-" in target and "/" not in target:
raise ValueError("arp-scan privilégié n'accepte pas les plages compactes ; utilise la découverte Nmap")
return [arp_scan(), "--interface", iface, target]
if operation == "nmap-discover":
if len(args) != 1:
raise ValueError("Usage : nmap-discover <cible>")
target = validate_target(args[0])
return [nmap(), "-sn", "-n", "-T4", "--max-retries", "1", "-oX", "-", target]
if operation == "nmap-standard":
hosts = validate_host_list(args)
return [
nmap(), "-Pn", "-n", "-sS", "--open", "-T4",
"--max-retries", "1", "--host-timeout", "12s", "-p", COMMON_PORTS,
"-oX", "-", *hosts,
]
if operation == "naabu-discover":
hosts = validate_host_list(args)
return [
naabu(), "-host", ",".join(hosts),
"-sn", "-pe", "-ps", "22,80,443,445,3389", "-pa", "80,443",
"-json", "-silent", "-no-color", "-disable-update-check",
"-no-stdin", "-config", "/dev/null", "-auth=false", "-ip-version", "4",
"-rate", "1200", "-retries", "1", "-timeout", "900", "-warm-up-time", "0",
]
if operation == "naabu-standard":
hosts = validate_host_list(args)
return [
naabu(), "-host", ",".join(hosts), "-p", COMMON_PORTS, "-Pn",
"-scan-type", "s", "-stream", "-json", "-silent", "-no-color",
"-disable-update-check", "-no-stdin", "-config", "/dev/null", "-auth=false", "-ip-version", "4",
"-c", "100", "-rate", "2500", "-timeout", "800ms", "-warm-up-time", "0",
]
if operation == "nmap-deep":
if len(args) != 1:
raise ValueError("Usage : nmap-deep <cible>")
target = validate_target(args[0])
return [
nmap(), "-sS", "-sV", "-O", "--osscan-guess", "--version-light",
"--open", "-T4", "--top-ports", "100", "-oX", "-", target,
]
if operation == "nmap-deep-hosts":
# Voie conservée depuis 0.4.8 : uniquement des IP déjà confirmées par la phase de
# découverte. -Pn interdit à Nmap de refaire une host-discovery susceptible
# d'écarter un pare-feu qui filtre certaines sondes.
hosts = validate_host_list(args)
return [
nmap(), "-Pn", "-n", "-sS", "-sV", "-O", "--osscan-guess",
"--version-light", "--open", "-T4", "--top-ports", "1000",
"-oX", "-", *hosts,
]
if operation == "nmap-host":
if len(args) != 1:
raise ValueError("Usage : nmap-host <IP>")
host = validate_target(args[0], network_allowed=False)
return [
nmap(), "-Pn", "-n", "-sS", "-sV", "-O", "--osscan-guess", "--version-light",
"--open", "-T4", "--top-ports", "1000", "-oX", "-", host,
]
raise ValueError("Opération privilégiée non autorisée")
def _terminate_child_group(proc: subprocess.Popen[bytes]) -> None:
"""Termine un moteur de scan lancé en root, puis force après un court délai."""
if proc.poll() is not None:
return
try:
os.killpg(proc.pid, signal.SIGTERM)
except (ProcessLookupError, OSError):
try:
proc.terminate()
except (ProcessLookupError, OSError):
return
try:
proc.wait(timeout=1.5)
return
except subprocess.TimeoutExpired:
pass
try:
os.killpg(proc.pid, signal.SIGKILL)
except (ProcessLookupError, OSError):
try:
proc.kill()
except (ProcessLookupError, OSError):
pass
try:
proc.wait(timeout=1.0)
except subprocess.TimeoutExpired:
pass
def run_supervised(command: list[str], input_stream=None) -> int:
"""Exécute la commande root et écoute le canal de contrôle LibreNet sur stdin.
L'UI écrit exactement ``STOP\n`` lorsqu'un scan doit être interrompu. Le helper,
qui possède les privilèges du moteur enfant, est le seul endroit fiable pour tuer
un Nmap/Naabu root. Un EOF (fermeture/crash de l'UI) annule aussi le scan afin de
ne jamais laisser un processus réseau privilégié orphelin.
"""
stream = sys.stdin if input_stream is None else input_stream
stop_event = threading.Event()
proc = subprocess.Popen(command, start_new_session=True)
def request_stop(_signum=None, _frame=None) -> None:
stop_event.set()
def watch_control() -> None:
try:
while True:
line = stream.readline()
if line == "":
stop_event.set()
return
if line.strip() == "STOP":
stop_event.set()
return
except (OSError, ValueError):
stop_event.set()
previous_term = signal.getsignal(signal.SIGTERM)
previous_int = signal.getsignal(signal.SIGINT)
signal.signal(signal.SIGTERM, request_stop)
signal.signal(signal.SIGINT, request_stop)
threading.Thread(target=watch_control, daemon=True).start()
try:
while proc.poll() is None:
if stop_event.is_set():
_terminate_child_group(proc)
return 130
time.sleep(0.05)
return int(proc.returncode or 0)
finally:
signal.signal(signal.SIGTERM, previous_term)
signal.signal(signal.SIGINT, previous_int)
def main(argv: list[str] | None = None) -> int:
argv = list(sys.argv[1:] if argv is None else argv)
if os.geteuid() != 0:
print("Ce helper doit être lancé via pkexec.", file=sys.stderr)
return 77
if not argv:
print("Opération manquante", file=sys.stderr)
return 64
operation, *args = argv
try:
command = command_for(operation, args)
if operation == "authorize":
print("AUTHORIZED")
return 0
return run_supervised(command)
except (OSError, ValueError, RuntimeError) as exc:
print(str(exc), file=sys.stderr)
return 64
if __name__ == "__main__":
raise SystemExit(main())
+59
View File
@@ -0,0 +1,59 @@
from __future__ import annotations
import os
import shutil
from dataclasses import dataclass
HELPER_PATH = "/usr/libexec/librenet-scanner-helper"
POLICY_PATH = "/usr/share/polkit-1/actions/org.librenet.scanner.policy"
@dataclass(slots=True, frozen=True)
class PrivilegeDiagnostic:
pkexec_path: str | None
helper_path: str | None
policy_path: str | None
ready: bool
detail: str
def find_pkexec() -> str | None:
return shutil.which("pkexec") or ("/usr/bin/pkexec" if os.path.isfile("/usr/bin/pkexec") else None)
def find_helper() -> str | None:
override = os.environ.get("LIBRENET_PRIVILEGED_HELPER", "").strip()
if override and os.path.isfile(override) and os.access(override, os.X_OK):
return override
if os.path.isfile(HELPER_PATH) and os.access(HELPER_PATH, os.X_OK):
return HELPER_PATH
return None
def privileged_command(operation: str, *args: str) -> list[str]:
pkexec = find_pkexec()
helper = find_helper()
if not pkexec:
raise RuntimeError("pkexec est introuvable. Installe le paquet Debian 'pkexec'.")
if not helper:
raise RuntimeError(
"Le helper privilégié LibreNet est introuvable. Réinstalle le paquet librenet-scanner 1.0.0."
)
return [pkexec, helper, operation, *args]
def privilege_diagnostic() -> PrivilegeDiagnostic:
pkexec = find_pkexec()
helper = find_helper()
policy = POLICY_PATH if os.path.isfile(POLICY_PATH) else None
missing: list[str] = []
if not pkexec:
missing.append("pkexec")
if not helper:
missing.append("helper LibreNet")
if not policy:
missing.append("politique Polkit")
if missing:
return PrivilegeDiagnostic(pkexec, helper, policy, False, "Manquant : " + ", ".join(missing))
return PrivilegeDiagnostic(pkexec, helper, policy, True, "Mode administrateur prêt via Polkit/pkexec")
+14
View File
@@ -0,0 +1,14 @@
from __future__ import annotations
from collections.abc import Iterable
from .models import Host
def union_host_ips(*groups: Iterable[Host]) -> set[str]:
"""Retourne l'union des IP découvertes par plusieurs méthodes.
Les méthodes privilégiées sont complémentaires : elles ne doivent jamais
remplacer les résultats d'une découverte utilisateur déjà réussie.
"""
return {host.ip for group in groups for host in group if host.ip}
File diff suppressed because it is too large Load Diff
+656 -10
View File
@@ -3,10 +3,21 @@ from __future__ import annotations
import json
import os
import sqlite3
from contextlib import contextmanager
from datetime import datetime, timezone
from pathlib import Path
from .models import Host
from .identity import (
candidate_query_values,
identity_key,
mac_identity_kind,
normalize_mac,
port_fingerprint_json,
score_identity_match,
shared_macs,
)
from .intelligence import enrich_host
from .models import Host, PortInfo
def data_dir() -> Path:
@@ -25,10 +36,20 @@ class HistoryStore:
self.db_path.parent.mkdir(parents=True, exist_ok=True)
self._init_db()
def _connect(self) -> sqlite3.Connection:
@contextmanager
def _connect(self):
"""Connexion SQLite transactionnelle toujours refermée proprement."""
conn = sqlite3.connect(self.db_path)
conn.row_factory = sqlite3.Row
return conn
conn.execute("PRAGMA foreign_keys=ON")
try:
yield conn
conn.commit()
except Exception:
conn.rollback()
raise
finally:
conn.close()
def _init_db(self) -> None:
with self._connect() as conn:
@@ -52,22 +73,107 @@ class HistoryStore:
ports_json TEXT NOT NULL,
FOREIGN KEY(scan_id) REFERENCES scans(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_scans_target_profile
ON scans(target, profile, id DESC);
CREATE INDEX IF NOT EXISTS idx_scan_hosts_scan_id
ON scan_hosts(scan_id);
CREATE TABLE IF NOT EXISTS host_metadata (
identity TEXT PRIMARY KEY,
mac TEXT,
ip TEXT,
hostname TEXT,
favorite INTEGER NOT NULL DEFAULT 0,
group_name TEXT NOT NULL DEFAULT '',
note TEXT NOT NULL DEFAULT '',
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_host_metadata_mac ON host_metadata(mac);
CREATE INDEX IF NOT EXISTS idx_host_metadata_ip ON host_metadata(ip);
CREATE TABLE IF NOT EXISTS online_vendor_cache (
mac TEXT NOT NULL,
provider TEXT NOT NULL,
vendor TEXT NOT NULL DEFAULT '',
found INTEGER NOT NULL DEFAULT 0,
block_type TEXT NOT NULL DEFAULT '',
is_randomized INTEGER NOT NULL DEFAULT 0,
is_private INTEGER NOT NULL DEFAULT 0,
checked_at TEXT NOT NULL,
PRIMARY KEY(mac, provider)
);
CREATE INDEX IF NOT EXISTS idx_online_vendor_cache_checked
ON online_vendor_cache(checked_at);
CREATE TABLE IF NOT EXISTS host_identification (
identity TEXT PRIMARY KEY,
mac TEXT NOT NULL DEFAULT '',
ip TEXT NOT NULL DEFAULT '',
hostname TEXT NOT NULL DEFAULT '',
os_name TEXT NOT NULL DEFAULT '',
os_score INTEGER NOT NULL DEFAULT 0,
os_source TEXT NOT NULL DEFAULT '',
os_seen_at TEXT NOT NULL DEFAULT '',
device_type TEXT NOT NULL DEFAULT '',
type_score INTEGER NOT NULL DEFAULT 0,
type_source TEXT NOT NULL DEFAULT '',
type_seen_at TEXT NOT NULL DEFAULT '',
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_host_identification_mac ON host_identification(mac);
CREATE INDEX IF NOT EXISTS idx_host_identification_ip ON host_identification(ip);
CREATE TABLE IF NOT EXISTS endpoint_identification (
identity TEXT PRIMARY KEY,
scope TEXT NOT NULL DEFAULT '',
mac TEXT NOT NULL DEFAULT '',
mac_kind TEXT NOT NULL DEFAULT '',
ip TEXT NOT NULL DEFAULT '',
hostname TEXT NOT NULL DEFAULT '',
ports_json TEXT NOT NULL DEFAULT '[]',
os_name TEXT NOT NULL DEFAULT '',
os_accuracy INTEGER,
os_score INTEGER NOT NULL DEFAULT 0,
os_source TEXT NOT NULL DEFAULT '',
os_seen_at TEXT NOT NULL DEFAULT '',
device_type TEXT NOT NULL DEFAULT '',
type_score INTEGER NOT NULL DEFAULT 0,
type_source TEXT NOT NULL DEFAULT '',
type_seen_at TEXT NOT NULL DEFAULT '',
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_endpoint_identification_mac ON endpoint_identification(mac);
CREATE INDEX IF NOT EXISTS idx_endpoint_identification_scope ON endpoint_identification(scope);
CREATE INDEX IF NOT EXISTS idx_endpoint_identification_ip ON endpoint_identification(ip);
CREATE INDEX IF NOT EXISTS idx_endpoint_identification_hostname ON endpoint_identification(hostname);
"""
)
scan_host_columns = {row[1] for row in conn.execute("PRAGMA table_info(scan_hosts)")}
if "os_accuracy" not in scan_host_columns:
conn.execute("ALTER TABLE scan_hosts ADD COLUMN os_accuracy INTEGER")
endpoint_columns = {row[1] for row in conn.execute("PRAGMA table_info(endpoint_identification)")}
if "scope" not in endpoint_columns:
conn.execute("ALTER TABLE endpoint_identification ADD COLUMN scope TEXT NOT NULL DEFAULT ''")
conn.execute("CREATE INDEX IF NOT EXISTS idx_endpoint_identification_scope ON endpoint_identification(scope)")
scan_columns = {row[1] for row in conn.execute("PRAGMA table_info(scans)")}
if "scan_schema" not in scan_columns:
conn.execute("ALTER TABLE scans ADD COLUMN scan_schema TEXT")
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_scans_target_profile_schema "
"ON scans(target, profile, scan_schema, id DESC)"
)
def save_scan(self, target: str, profile: str, hosts: list[Host]) -> int:
def save_scan(self, target: str, profile: str, hosts: list[Host], scan_schema: str = "") -> int:
now = datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds")
active_hosts = [h for h in hosts if h.status != "down"]
with self._connect() as conn:
cur = conn.execute(
"INSERT INTO scans(created_at, target, profile, host_count) VALUES (?, ?, ?, ?)",
(now, target, profile, len(hosts)),
"INSERT INTO scans(created_at, target, profile, host_count, scan_schema) VALUES (?, ?, ?, ?, ?)",
(now, target, profile, len(active_hosts), scan_schema),
)
scan_id = int(cur.lastrowid)
for host in hosts:
for host in active_hosts:
ports = [
{
"port": p.port,
"protocol": p.protocol,
"state": p.state,
"service": p.service,
"product": p.product,
"version": p.version,
@@ -76,10 +182,13 @@ class HistoryStore:
]
conn.execute(
"""
INSERT INTO scan_hosts(scan_id, ip, hostname, mac, vendor, os_name, ports_json)
VALUES (?, ?, ?, ?, ?, ?, ?)
INSERT INTO scan_hosts(scan_id, ip, hostname, mac, vendor, os_name, os_accuracy, ports_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
(scan_id, host.ip, host.hostname, host.mac, host.vendor, host.os_name, json.dumps(ports, ensure_ascii=False)),
(
scan_id, host.ip, host.hostname, host.mac, host.vendor, host.os_name,
host.os_accuracy, json.dumps(ports, ensure_ascii=False),
),
)
return scan_id
@@ -91,3 +200,540 @@ class HistoryStore:
(limit,),
)
)
def clear_scan_history(self) -> int:
"""Supprime uniquement l'historique des scans.
Les métadonnées utilisateur (favoris/groupes/notes), le cache OUI en ligne
et les identifications mémorisées restent intacts. ``scan_hosts`` est
supprimé automatiquement grâce à la clé étrangère ON DELETE CASCADE.
"""
with self._connect() as conn:
count = int(conn.execute("SELECT COUNT(*) FROM scans").fetchone()[0])
conn.execute("DELETE FROM scans")
return count
def latest_scan(self, target: str, profile: str, scan_schema: str = "") -> sqlite3.Row | None:
with self._connect() as conn:
return conn.execute(
"""
SELECT id, created_at, target, profile, host_count, scan_schema
FROM scans
WHERE target = ? AND profile = ? AND scan_schema = ?
ORDER BY id DESC
LIMIT 1
""",
(target, profile, scan_schema),
).fetchone()
def load_scan_hosts(self, scan_id: int) -> list[Host]:
with self._connect() as conn:
scan = conn.execute("SELECT created_at FROM scans WHERE id = ?", (scan_id,)).fetchone()
if scan is None:
return []
rows = list(
conn.execute(
"""
SELECT ip, hostname, mac, vendor, os_name, os_accuracy, ports_json
FROM scan_hosts
WHERE scan_id = ?
ORDER BY ip
""",
(scan_id,),
)
)
result: list[Host] = []
for row in rows:
ports_payload = json.loads(row["ports_json"] or "[]")
ports = [
PortInfo(
port=int(p.get("port", 0)),
protocol=str(p.get("protocol", "tcp")),
state=str(p.get("state", "open")),
service=str(p.get("service", "")),
product=str(p.get("product", "")),
version=str(p.get("version", "")),
)
for p in ports_payload
]
result.append(
enrich_host(
Host(
ip=row["ip"],
hostname=row["hostname"] or "",
mac=(row["mac"] or "").upper(),
vendor=row["vendor"] or "",
os_name=row["os_name"] or "",
os_accuracy=row["os_accuracy"],
ports=ports,
status="up",
last_seen=scan["created_at"],
)
)
)
return result
@staticmethod
def _host_identity(host: Host, *, shared_mac: bool = False, scope: str = "") -> str:
base = identity_key(host, shared_mac=shared_mac)
if base == "local:self" or not scope:
return base
return f"{scope}::{base}"
def host_metadata(self, host: Host, *, shared_mac: bool = False) -> dict[str, object]:
"""Retourne favoris/groupe/note sans transférer une fiche à un autre hôte.
Dès qu'une MAC actuelle est connue, une ligne portant une autre MAC sur la
même IP n'est jamais utilisée. Le fallback IP n'est accepté que pour une
ancienne fiche réellement *legacy* sans MAC. Une MAC partagée (proxy ARP,
VIP, clone...) est scindée par IP afin d'éviter de partager les notes entre
plusieurs endpoints.
"""
identity = self._host_identity(host, shared_mac=shared_mac)
mac = normalize_mac(host.mac)
with self._connect() as conn:
row = conn.execute(
"SELECT * FROM host_metadata WHERE identity = ? LIMIT 1", (identity,)
).fetchone()
if row is None and mac and not shared_mac:
row = conn.execute(
"SELECT * FROM host_metadata WHERE mac = ? ORDER BY updated_at DESC LIMIT 1",
(mac,),
).fetchone()
if row is None and mac:
# Migration sûre d'une fiche créée avant que la MAC ne soit connue.
row = conn.execute(
"""SELECT * FROM host_metadata
WHERE ip = ? AND COALESCE(mac, '') = ''
ORDER BY updated_at DESC LIMIT 1""",
(host.ip,),
).fetchone()
if row is None and not mac:
row = conn.execute(
"SELECT * FROM host_metadata WHERE ip = ? ORDER BY updated_at DESC LIMIT 1",
(host.ip,),
).fetchone()
if row is None:
return {"favorite": False, "group_name": "", "note": ""}
return {
"favorite": bool(row["favorite"]),
"group_name": row["group_name"] or "",
"note": row["note"] or "",
}
def save_host_metadata(
self, host: Host, *, favorite: bool | None = None, group_name: str | None = None,
note: str | None = None, shared_mac: bool = False
) -> None:
current = self.host_metadata(host, shared_mac=shared_mac)
if favorite is None:
favorite = bool(current["favorite"])
if group_name is None:
group_name = str(current["group_name"])
if note is None:
note = str(current["note"])
identity = self._host_identity(host, shared_mac=shared_mac)
mac = normalize_mac(host.mac)
now = datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds")
with self._connect() as conn:
# Ne supprimer que l'ancienne fiche IP sans MAC. Une fiche avec une
# MAC différente peut appartenir au précédent détenteur du bail DHCP.
if mac:
conn.execute(
"DELETE FROM host_metadata WHERE ip = ? AND COALESCE(mac, '') = '' AND identity <> ?",
(host.ip, identity),
)
conn.execute(
"""
INSERT INTO host_metadata(identity, mac, ip, hostname, favorite, group_name, note, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(identity) DO UPDATE SET
mac=excluded.mac, ip=excluded.ip, hostname=excluded.hostname,
favorite=excluded.favorite, group_name=excluded.group_name,
note=excluded.note, updated_at=excluded.updated_at
""",
(identity, mac, host.ip, host.hostname, int(bool(favorite)), group_name.strip(), note.strip(), now),
)
@staticmethod
def _profile_priority(profile: str) -> int:
return {"Rapide": 0, "Standard": 10, "Approfondi": 25, "Détaillé": 30}.get(profile, 0)
@classmethod
def _os_quality(cls, os_name: str, profile: str, accuracy: int | None = None) -> int:
value = (os_name or "").strip()
if not value:
return 0
# Quand Nmap fournit son accuracy, ne la "gonfle" jamais avec le nom ou le
# profil : 82 % doit rester 82 %. Cela évite de transformer une hypothèse
# Nmap en quasi-certitude simplement parce qu'elle contient "OpenWrt 24".
if accuracy is not None:
return max(0, min(100, int(accuracy)))
folded = value.casefold()
score = 35 + cls._profile_priority(profile)
if any(token in folded for token in (
"openwrt", "opnsense", "pfsense", "debian", "ubuntu", "fedora",
"centos", "red hat", "windows", "freebsd", "routeros", "proxmox",
"synology", "vmware", "esxi", "fortios", "ios xe", "junos",
)):
score += 20
elif "linux" in folded or "bsd" in folded:
score += 10
if any(ch.isdigit() for ch in value):
score += 5
return min(score, 95)
@classmethod
def _type_quality(cls, device_type: str, profile: str) -> int:
value = (device_type or "").strip()
if not value or value == "Hôte":
return 0
base = {
"Hôte Linux": 35,
"Serveur SSH": 38,
"Appliance Web": 40,
"Équipement réseau": 45,
"Serveur / appliance": 48,
"Serveur Linux": 60,
"Poste / serveur Windows": 65,
"Imprimante": 80,
"Switch": 82,
"Point d'accès Wi-Fi": 84,
"NAS Synology": 92,
"Pare-feu / routeur": 92,
"Proxmox Backup Server": 95,
"Hyperviseur Proxmox": 95,
"Ce poste": 100,
}.get(value, 50)
return min(base + cls._profile_priority(profile) // 5, 100)
def _identification_candidates(self, host: Host, *, scope: str = "") -> list[sqlite3.Row]:
mac, ip, hostname = candidate_query_values(host)
clauses: list[str] = []
params: list[str] = []
if host.is_local:
clauses.append("identity = ?")
params.append("local:self")
if mac:
clauses.append("mac = ?")
params.append(mac)
if ip:
clauses.append("ip = ?")
params.append(ip)
if hostname:
clauses.append("LOWER(hostname) = LOWER(?)")
params.append(hostname)
if not clauses:
return []
selector = "(" + " OR ".join(clauses) + ")"
if host.is_local:
query = "SELECT * FROM endpoint_identification WHERE " + selector + " ORDER BY updated_at DESC"
else:
query = "SELECT * FROM endpoint_identification WHERE scope = ? AND " + selector + " ORDER BY updated_at DESC"
params = [scope] + params
with self._connect() as conn:
return list(conn.execute(query, params).fetchall())
def host_identification(self, host: Host, *, shared_mac: bool = False, scope: str = "") -> dict[str, object]:
"""Retourne une identification uniquement si la corrélation est assez forte.
L'IP n'est jamais considérée comme une identité. Une IP réattribuée à une
autre MAC est explicitement rejetée ; une LAA, une MAC virtuelle ou une MAC
partagée exigent des preuves supplémentaires (IP/hostname/services).
"""
best_row: sqlite3.Row | None = None
best_match = None
for row in self._identification_candidates(host, scope=scope):
match = score_identity_match(
host, {key: row[key] for key in row.keys()}, shared_mac=shared_mac
)
if best_match is None or match.score > best_match.score:
best_row = row
best_match = match
if best_row is None or best_match is None or not best_match.safe_to_apply:
return {}
result = {key: best_row[key] for key in best_row.keys()}
result["match_score"] = best_match.score
result["match_reason"] = best_match.reason
result["identity_kind"] = best_match.identity_kind
return result
def apply_host_identification(self, host: Host, *, shared_mac: bool = False, scope: str = "") -> Host:
# Toujours repartir d'un état neutre : si le contexte change (ex. la MAC
# devient partagée dans ce scan), une ancienne mémoire ne doit pas rester.
host.remembered_os_name = ""
host.remembered_os_accuracy = None
host.remembered_device_type = ""
host.remembered_os_source = ""
host.remembered_type_source = ""
host.remembered_os_seen_at = ""
host.remembered_type_seen_at = ""
host.remembered_match_score = 0
host.remembered_match_reason = ""
host.remembered_identity_kind = ""
remembered = self.host_identification(host, shared_mac=shared_mac, scope=scope)
if not remembered:
return host
host.remembered_os_name = str(remembered.get("os_name") or "")
host.remembered_os_accuracy = remembered.get("os_accuracy")
host.remembered_device_type = str(remembered.get("device_type") or "")
host.remembered_os_source = str(remembered.get("os_source") or "")
host.remembered_type_source = str(remembered.get("type_source") or "")
host.remembered_os_seen_at = str(remembered.get("os_seen_at") or "")
host.remembered_type_seen_at = str(remembered.get("type_seen_at") or "")
host.remembered_match_score = int(remembered.get("match_score") or 0)
host.remembered_match_reason = str(remembered.get("match_reason") or "")
host.remembered_identity_kind = str(remembered.get("identity_kind") or "")
return host
def apply_identifications(self, hosts: list[Host], *, scope: str = "") -> list[Host]:
shared = shared_macs(hosts)
for host in hosts:
self.apply_host_identification(host, shared_mac=normalize_mac(host.mac) in shared, scope=scope)
return hosts
def _identification_row_by_identity(self, identity: str) -> sqlite3.Row | None:
with self._connect() as conn:
return conn.execute(
"SELECT * FROM endpoint_identification WHERE identity = ? LIMIT 1", (identity,)
).fetchone()
def remember_host_identification(
self, host: Host, profile: str, *, observed_at: str = "", shared_mac: bool = False, scope: str = ""
) -> None:
"""Mémorise le meilleur fingerprint connu pour une identité prudente.
Les scans légers n'écrasent pas une identification riche. À qualité égale,
un nouveau scan Approfondi/Détaillé peut en revanche remplacer une ancienne
version/OS : c'est indispensable après une réinstallation ou une mise à jour.
"""
enrich_host(host)
now = observed_at or datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds")
identity = self._host_identity(host, shared_mac=shared_mac, scope=scope)
current = self._identification_row_by_identity(identity)
authoritative_refresh = profile in {"Approfondi", "Détaillé"}
# Une même MAC peut être clonée/spoofée ou réutilisée. Avant d'écraser une
# identité ``mac:...`` existante après un changement d'IP, on vérifie que
# la corrélation historique est suffisamment forte. Sinon on scinde la
# nouvelle observation en ``macip:...@IP`` afin de préserver les deux
# endpoints au lieu de corrompre silencieusement l'ancien fingerprint.
if current is not None and not host.is_local and normalize_mac(host.mac):
same_ip = host.ip == str(current["ip"] or "")
same_mac = normalize_mac(host.mac) == normalize_mac(str(current["mac"] or ""))
if not (authoritative_refresh and same_ip and same_mac):
match = score_identity_match(
host, {key: current[key] for key in current.keys()}, shared_mac=shared_mac
)
if not match.safe_to_apply:
base = f"macip:{normalize_mac(host.mac)}@{host.ip}"
identity = base if not scope else f"{scope}::{base}"
current = self._identification_row_by_identity(identity)
current_os = str(current["os_name"] or "") if current is not None else ""
current_type = str(current["device_type"] or "") if current is not None else ""
current_os_score = int(current["os_score"] or 0) if current is not None else 0
current_type_score = int(current["type_score"] or 0) if current is not None else 0
candidate_os_score = self._os_quality(host.os_name, profile, host.os_accuracy)
candidate_type_score = self._type_quality(host.device_type, profile)
best_os = current_os
best_os_accuracy = current["os_accuracy"] if current is not None else None
best_os_score = current_os_score
best_os_source = str(current["os_source"] or "") if current is not None else ""
best_os_seen = str(current["os_seen_at"] or "") if current is not None else ""
# Seuls les profils qui réalisent réellement un fingerprint OS peuvent
# créer/rafraîchir ``os_seen_at``. Un Standard peut actualiser l'observation
# réseau de l'endpoint, mais ne rajeunit jamais l'OS mémorisé.
if authoritative_refresh and host.os_name and (
not current_os
or candidate_os_score > current_os_score
or candidate_os_score >= current_os_score - 5
):
best_os = host.os_name
best_os_accuracy = host.os_accuracy
best_os_score = candidate_os_score
best_os_source = profile
best_os_seen = now
best_type = current_type
best_type_score = current_type_score
best_type_source = str(current["type_source"] or "") if current is not None else ""
best_type_seen = str(current["type_seen_at"] or "") if current is not None else ""
if host.device_type and (
not current_type
or candidate_type_score > current_type_score
or (authoritative_refresh and candidate_type_score >= current_type_score - 5)
):
best_type = host.device_type
best_type_score = candidate_type_score
best_type_source = profile
best_type_seen = now
if not best_os and not best_type:
return
mac = normalize_mac(host.mac)
with self._connect() as conn:
conn.execute(
"""
INSERT INTO endpoint_identification(
identity, scope, mac, mac_kind, ip, hostname, ports_json,
os_name, os_accuracy, os_score, os_source, os_seen_at,
device_type, type_score, type_source, type_seen_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(identity) DO UPDATE SET
scope=excluded.scope, mac=excluded.mac, mac_kind=excluded.mac_kind, ip=excluded.ip,
hostname=excluded.hostname, ports_json=excluded.ports_json,
os_name=excluded.os_name, os_accuracy=excluded.os_accuracy,
os_score=excluded.os_score, os_source=excluded.os_source,
os_seen_at=excluded.os_seen_at, device_type=excluded.device_type,
type_score=excluded.type_score, type_source=excluded.type_source,
type_seen_at=excluded.type_seen_at, updated_at=excluded.updated_at
""",
(
identity, "" if host.is_local else scope, mac, mac_identity_kind(mac), host.ip, host.hostname,
port_fingerprint_json(host), best_os, best_os_accuracy, best_os_score,
best_os_source, best_os_seen, best_type, best_type_score,
best_type_source, best_type_seen, now,
),
)
def remember_identifications(self, hosts: list[Host], profile: str, *, observed_at: str = "", scope: str = "") -> None:
shared = shared_macs(hosts)
for host in hosts:
if host.status != "down":
self.remember_host_identification(
host, profile, observed_at=observed_at,
shared_mac=normalize_mac(host.mac) in shared, scope=scope,
)
def forget_identification_for_host(
self, host: Host, *, shared_mac: bool = False, scope: str = ""
) -> int:
"""Oublie uniquement le fingerprint associé à l'endpoint sélectionné."""
remembered = self.host_identification(host, shared_mac=shared_mac, scope=scope)
identities: list[str] = []
if remembered.get("identity"):
identities.append(str(remembered["identity"]))
exact = self._host_identity(host, shared_mac=shared_mac, scope=scope)
if exact not in identities:
identities.append(exact)
# Un endpoint peut avoir été scindé en macip lors d'une ambiguïté antérieure.
mac = normalize_mac(host.mac)
if mac and host.ip:
base = f"macip:{mac}@{host.ip}"
macip = base if not scope else f"{scope}::{base}"
if macip not in identities:
identities.append(macip)
if not identities:
return 0
placeholders = ",".join("?" for _ in identities)
with self._connect() as conn:
count = int(conn.execute(
f"SELECT COUNT(*) FROM endpoint_identification WHERE identity IN ({placeholders})", identities
).fetchone()[0])
conn.execute(
f"DELETE FROM endpoint_identification WHERE identity IN ({placeholders})", identities
)
return count
def forget_identifications_for_scope(self, scope: str) -> int:
"""Oublie les fingerprints du réseau courant, sans toucher au poste local."""
if not scope:
return 0
with self._connect() as conn:
count = int(conn.execute(
"SELECT COUNT(*) FROM endpoint_identification WHERE scope = ?", (scope,)
).fetchone()[0])
conn.execute("DELETE FROM endpoint_identification WHERE scope = ?", (scope,))
return count
def forget_all_identifications(self) -> int:
"""Oublie tous les fingerprints, sans supprimer scans, favoris, groupes ou notes."""
with self._connect() as conn:
count = int(conn.execute("SELECT COUNT(*) FROM endpoint_identification").fetchone()[0])
conn.execute("DELETE FROM endpoint_identification")
# Ancienne table pré-0.4.9 : la vider aussi évite qu'une migration future
# ne ressuscite une identification que l'utilisateur pensait oubliée.
conn.execute("DELETE FROM host_identification")
return count
def known_groups(self) -> list[str]:
with self._connect() as conn:
rows = conn.execute(
"SELECT DISTINCT group_name FROM host_metadata WHERE group_name <> '' ORDER BY group_name COLLATE NOCASE"
).fetchall()
return [str(row[0]) for row in rows]
def online_vendor_cache(self, mac: str, provider: str, *, max_age_days: int = 30) -> dict[str, object] | None:
from .online_vendor import normalize_mac
normalized = normalize_mac(mac)
if not normalized:
return None
with self._connect() as conn:
row = conn.execute(
"SELECT * FROM online_vendor_cache WHERE mac = ? AND provider = ?",
(normalized, provider),
).fetchone()
if row is None:
return None
try:
checked = datetime.fromisoformat(row["checked_at"])
now = datetime.now(timezone.utc).astimezone()
if checked.tzinfo is None:
checked = checked.replace(tzinfo=now.tzinfo)
if (now - checked.astimezone(now.tzinfo)).total_seconds() > max_age_days * 86400:
return None
except (TypeError, ValueError):
return None
return {
"mac": row["mac"],
"provider": row["provider"],
"vendor": row["vendor"] or "",
"found": bool(row["found"]),
"block_type": row["block_type"] or "",
"is_randomized": bool(row["is_randomized"]),
"is_private": bool(row["is_private"]),
"checked_at": row["checked_at"],
"from_cache": True,
}
def save_online_vendor_cache(
self,
mac: str,
provider: str,
*,
vendor: str = "",
found: bool = False,
block_type: str = "",
is_randomized: bool = False,
is_private: bool = False,
checked_at: str = "",
) -> None:
from .online_vendor import normalize_mac
normalized = normalize_mac(mac)
if not normalized:
return
timestamp = checked_at or datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds")
with self._connect() as conn:
conn.execute(
"""
INSERT INTO online_vendor_cache(
mac, provider, vendor, found, block_type, is_randomized, is_private, checked_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(mac, provider) DO UPDATE SET
vendor=excluded.vendor, found=excluded.found, block_type=excluded.block_type,
is_randomized=excluded.is_randomized, is_private=excluded.is_private,
checked_at=excluded.checked_at
""",
(
normalized, provider, vendor.strip(), int(bool(found)), block_type.strip(),
int(bool(is_randomized)), int(bool(is_private)), timestamp,
),
)
+2206 -189
View File
File diff suppressed because it is too large Load Diff
+83
View File
@@ -0,0 +1,83 @@
from __future__ import annotations
from pathlib import Path
from PySide6.QtGui import QIcon
from .models import Host
from .visual_identity import (
DEVICE_THEME_CANDIDATES,
EQUIPMENT_ICON_FILES,
OS_ICON_FILES,
device_icon_key_for_host,
os_icon_key_for_host,
)
def custom_icon_dir() -> Path:
installed = Path("/usr/share/librenet-scanner/icons")
if installed.is_dir():
return installed
return Path(__file__).resolve().parents[2] / "assets" / "icons"
def themed_icon(name: str, *fallbacks: str) -> QIcon:
"""Return the first available icon from the current desktop theme.
Accepting an arbitrary number of fallbacks keeps menu/action construction
robust across KDE/Breeze variants without making callers care how many
alternate icon names are provided.
"""
for candidate in (name, *fallbacks):
if not candidate:
continue
icon = QIcon.fromTheme(candidate)
if not icon.isNull():
return icon
return QIcon()
def _first_theme_icon(names: tuple[str, ...]) -> QIcon:
for name in names:
icon = QIcon.fromTheme(name)
if not icon.isNull():
return icon
return QIcon()
def _asset_icon(filename: str) -> QIcon:
candidate = custom_icon_dir() / filename
if candidate.is_file():
icon = QIcon(str(candidate))
if not icon.isNull():
return icon
return QIcon()
def device_icon(host: Host) -> QIcon:
"""Generic equipment pictogram, guaranteed distinct from the OS icon."""
key = device_icon_key_for_host(host)
icon = _asset_icon(EQUIPMENT_ICON_FILES.get(key, EQUIPMENT_ICON_FILES["unknown"]))
if not icon.isNull():
return icon
icon = _first_theme_icon(DEVICE_THEME_CANDIDATES.get(key, DEVICE_THEME_CANDIDATES["unknown"]))
if not icon.isNull():
return icon
return QIcon.fromTheme("computer")
def os_icon(host: Host) -> QIcon:
"""Broad OS-family marker: Tux/Linux, FreeBSD horned mark, Windows panes, other."""
key = os_icon_key_for_host(host)
icon = _asset_icon(OS_ICON_FILES.get(key, OS_ICON_FILES["other"]))
if not icon.isNull():
return icon
fallback = {
"linux": ("tux", "computer-server"),
"bsd": ("computer-server",),
"windows": ("computer",),
"apple": ("computer",),
"android": ("phone", "smartphone", "computer"),
"other": ("utilities-terminal", "computer"),
}.get(key, ("computer",))
return _first_theme_icon(fallback)
+27
View File
@@ -0,0 +1,27 @@
from __future__ import annotations
def balanced_column_widths(viewport_width: int, *, show_evolution: bool = True) -> tuple[int, int, int, int]:
"""Return balanced widths for the main device tree.
The goal is to keep host names readable without starving the device type/version
column. IP/port and evolution remain compact because their contents are bounded.
"""
width = max(720, int(viewport_width))
ip = min(180, max(142, round(width * 0.14)))
evolution = min(124, max(100, round(width * 0.10))) if show_evolution else 0
type_width = min(430, max(285, round(width * 0.33)))
equipment = max(300, width - ip - evolution - type_width - 8)
return equipment, ip, type_width, evolution
def compact_warning(message: str) -> str:
text = " ".join((message or "").split())
lowered = text.casefold()
if "arp-scan" in lowered and ("privil" in lowered or "cap_net_raw" in lowered):
return "ARP limité : certaines adresses MAC peuvent être incomplètes."
if "aucune mac" in lowered:
return "Aucune adresse MAC récupérée sur ce réseau local."
if len(text) > 110:
return text[:107].rstrip() + ""
return text
+73
View File
@@ -0,0 +1,73 @@
from __future__ import annotations
import re
from functools import lru_cache
from pathlib import Path
VENDOR_DATABASES = (
Path("/usr/share/arp-scan/ieee-oui.txt"),
Path("/usr/share/ieee-data/oui.txt"),
Path("/var/lib/ieee-data/oui.txt"),
Path("/usr/share/arp-scan/mac-vendor.txt"),
)
_PREFIX_LINE = re.compile(
r"^\s*(?P<prefix>(?:[0-9A-Fa-f]{2}[:-]){2,5}[0-9A-Fa-f]{2}|[0-9A-Fa-f]{6,12})"
r"\s+(?:\((?:hex|base 16)\)\s*)?(?P<vendor>.+?)\s*$",
re.IGNORECASE,
)
def _hex_only(value: str) -> str:
return "".join(ch for ch in value.upper() if ch in "0123456789ABCDEF")
def parse_vendor_text(text: str) -> dict[str, str]:
"""Parse les formats courants des bases OUI d'arp-scan/ieee-data.
Les préfixes variables (6 à 12 chiffres hexadécimaux) sont conservés afin
que mac-vendor.txt puisse surcharger une entrée OUI plus générique.
"""
result: dict[str, str] = {}
for raw in text.splitlines():
line = raw.strip()
if not line or line.startswith("#"):
continue
match = _PREFIX_LINE.match(line)
if not match:
continue
prefix = _hex_only(match.group("prefix"))
vendor = match.group("vendor").strip()
if 6 <= len(prefix) <= 12 and vendor:
result[prefix] = vendor
return result
def load_vendor_table(paths: tuple[Path, ...] = VENDOR_DATABASES) -> dict[str, str]:
table: dict[str, str] = {}
for path in paths:
try:
text = path.read_text(encoding="utf-8", errors="replace")
except OSError:
continue
table.update(parse_vendor_text(text))
return table
@lru_cache(maxsize=1)
def _cached_vendor_table() -> dict[str, str]:
return load_vendor_table()
def lookup_mac_vendor(mac: str) -> str:
normalized = _hex_only(mac)
if len(normalized) != 12:
return ""
table = _cached_vendor_table()
# Les fichiers mac-vendor peuvent contenir des préfixes plus précis qu'un OUI.
for length in range(12, 5, -1):
vendor = table.get(normalized[:length])
if vendor:
return vendor
return ""
+126
View File
@@ -0,0 +1,126 @@
from __future__ import annotations
from .models import Host
# V0.4.13 : la première colonne décrit le TYPE D'ÉQUIPEMENT avec des
# pictogrammes génériques explicites et indépendants de l'OS / du produit.
EQUIPMENT_ICON_FILES: dict[str, str] = {
"workstation": "equipment-workstation.svg",
"server": "equipment-server.svg",
"hypervisor": "equipment-hypervisor.svg",
"firewall": "equipment-firewall.svg",
"router": "equipment-router.svg",
"nas": "equipment-nas.svg",
"switch": "equipment-switch.svg",
"access-point": "equipment-access-point.svg",
"printer": "equipment-printer.svg",
"network-device": "equipment-network-device.svg",
"unknown": "equipment-unknown.svg",
}
# Fallbacks KDE/Breeze uniquement si un asset LibreNet ne peut pas être chargé.
DEVICE_THEME_CANDIDATES: dict[str, tuple[str, ...]] = {
"workstation": ("computer", "computer-laptop"),
"server": ("network-server", "computer-server", "computer"),
"hypervisor": ("network-server", "computer-server", "computer"),
"firewall": ("security-high", "network-wired"),
"router": ("network-wired", "network-server"),
"nas": ("drive-harddisk", "network-server"),
"switch": ("network-wired", "network-server"),
"access-point": ("network-wireless", "network-server"),
"printer": ("printer", "computer"),
"network-device": ("network-wired", "network-server"),
"unknown": ("computer", "network-server"),
}
# Les OS ont leur propre petit marqueur visuel, séparé de l'équipement.
OS_ICON_FILES: dict[str, str] = {
"linux": "os-linux.svg",
"bsd": "os-bsd.svg",
"windows": "os-windows.svg",
"apple": "os-apple.svg",
"android": "os-android.svg",
"other": "os-other.svg",
}
def device_icon_key_for_host(host: Host) -> str:
"""Return the generic equipment category to display in column 1.
Product/OS clues may refine an obviously generic historical type (for example
OpenWrt -> network device), but never turn the equipment icon into an OS logo.
"""
dtype = (host.effective_device_type or "").casefold()
os_name = (host.effective_os_name or "").casefold()
hostname = (host.hostname or "").casefold()
if host.is_local:
return "workstation"
# Strong product/role clues first. They fix generic historical labels such as
# "Serveur / appliance" without displaying a product logo.
if any(token in dtype for token in ("pare-feu", "firewall")):
return "firewall"
if any(token in os_name for token in ("opnsense", "pfsense")) or any(token in hostname for token in ("opnsense", "pfsense")) or hostname.startswith("opns"):
return "firewall"
if "openwrt" in os_name or "openwrt" in hostname:
return "network-device"
if "hyperviseur" in dtype or "proxmox" in dtype:
return "hypervisor"
if "backup server" in dtype:
return "server"
if "nas" in dtype or "synology" in dtype:
return "nas"
if "imprimante" in dtype or "printer" in dtype:
return "printer"
if "point d'accès" in dtype or "access point" in dtype or "wi-fi" in dtype or "wifi" in dtype:
return "access-point"
if "switch" in dtype or "commutateur" in dtype:
return "switch"
if "routeur" in dtype or "router" in dtype:
return "router"
if "équipement réseau" in dtype or "network device" in dtype or "appliance web" in dtype:
return "network-device"
# Explicit host roles.
if "poste" in dtype or "workstation" in dtype:
return "workstation"
if "serveur" in dtype or "server" in dtype or "appliance" in dtype:
return "server"
# A plain host is a workstation-like endpoint unless we have evidence of a
# server role. This is deliberately generic.
if "hôte" in dtype or "host" in dtype or "windows" in dtype:
return "workstation"
return "unknown"
def os_icon_key_for_host(host: Host) -> str:
"""Return a broad OS family for the secondary OS marker."""
os_name = (host.effective_os_name or "").casefold()
hostname = (host.hostname or "").casefold()
dtype = (host.effective_device_type or "").casefold()
if any(token in os_name for token in ("freebsd", "openbsd", "netbsd", "opnsense", "pfsense")):
return "bsd"
if any(token in hostname for token in ("opnsense", "pfsense")) or hostname.startswith("opns"):
return "bsd"
# Android is Linux-based but deserves its own visual family. Check it before Linux.
if "android" in os_name or "android" in dtype:
return "android"
# Apple platforms: Nmap may report macOS, Mac OS X, Darwin, iOS or iPadOS.
if any(token in os_name for token in (
"macos", "mac os x", "darwin", "iphone os", "apple ios", "ipados", "apple tv", "tvos",
)) or any(token in dtype for token in ("macos", "mac os", "iphone", "ipad", "ipados")):
return "apple"
if "windows" in os_name or "windows" in dtype:
return "windows"
if any(token in os_name for token in (
"linux", "debian", "ubuntu", "openwrt", "proxmox", "fedora",
"centos", "red hat", "rocky", "almalinux", "arch linux", "opensuse",
)):
return "linux"
if any(token in dtype for token in ("proxmox", "linux")) or "openwrt" in hostname:
return "linux"
return "other"
+1 -1
View File
@@ -35,7 +35,7 @@ class ParserTests(unittest.TestCase):
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")
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")
+40
View File
@@ -0,0 +1,40 @@
import re
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
class StableRelease100Tests(unittest.TestCase):
def test_runtime_and_packaging_version_are_100(self):
init_py = (ROOT / "src/librenet_scanner/__init__.py").read_text(encoding="utf-8")
control = (ROOT / "packaging/debian/control").read_text(encoding="utf-8")
build = (ROOT / "packaging/build-deb.sh").read_text(encoding="utf-8")
pyproject = (ROOT / "pyproject.toml").read_text(encoding="utf-8")
self.assertIn('__version__ = "1.0.0"', init_py)
self.assertIn("Version: 1.0.0", control)
self.assertIn("VERSION=1.0.0", build)
self.assertIn('version = "1.0.0"', pyproject)
def test_readme_is_stable_release_only_and_branded(self):
readme = (ROOT / "README.md").read_text(encoding="utf-8")
self.assertIn("LibreNet Scanner 1.0.0", readme)
self.assertIn('assets/librenet-scanner.svg', readme)
self.assertIn('assets/badges/version.svg', readme)
self.assertIsNone(re.search(r"\b0\.\d+\.\d+\b", readme))
def test_local_badges_exist(self):
for name in ("version", "status", "platform", "ui", "license", "tests"):
path = ROOT / "assets/badges" / f"{name}.svg"
self.assertTrue(path.is_file(), str(path))
self.assertIn("<svg", path.read_text(encoding="utf-8"))
def test_debian_readme_assets_are_packaged(self):
build = (ROOT / "packaging/build-deb.sh").read_text(encoding="utf-8")
self.assertIn('usr/share/doc/librenet-scanner/assets/badges', build)
self.assertIn('assets/badges/"*.svg', build)
self.assertIn('assets/icons/"*.svg', build)
if __name__ == "__main__":
unittest.main()
+127
View File
@@ -0,0 +1,127 @@
import tempfile
import unittest
from pathlib import Path
from librenet_scanner.actions import normalize_mac
from librenet_scanner.comparison import compare_hosts
from librenet_scanner.intelligence import canonical_service_name, enrich_host
from librenet_scanner.models import Host, PortInfo
from librenet_scanner.storage import HistoryStore
class IntelligenceTests(unittest.TestCase):
def test_canonical_proxmox_service(self):
self.assertEqual(canonical_service_name(8006, "tcp", "wpl-analytics"), "Proxmox VE")
def test_proxmox_classification(self):
host = Host("192.168.5.1", hostname="pve01.local", ports=[PortInfo(8006, service="wpl-analytics")])
enrich_host(host)
self.assertEqual(host.device_type, "Hyperviseur Proxmox")
self.assertEqual(host.ports[0].service, "Proxmox VE")
def test_synology_classification(self):
host = Host("192.168.5.11", hostname="syno.maison.bro", ports=[PortInfo(5001)])
enrich_host(host)
self.assertEqual(host.device_type, "NAS Synology")
def test_pbs_classification(self):
host = Host("192.168.5.12", hostname="pbs01.local.raspot.in", ports=[PortInfo(8007, service="ajp13")])
enrich_host(host)
self.assertEqual(host.device_type, "Proxmox Backup Server")
self.assertEqual(host.ports[0].service, "Proxmox Backup Server")
def test_wol_mac_normalization(self):
self.assertEqual(normalize_mac("aa-bb-cc-dd-ee-ff"), "AA:BB:CC:DD:EE:FF")
with self.assertRaises(ValueError):
normalize_mac("not-a-mac")
class ComparisonTests(unittest.TestCase):
def test_new_modified_disappeared(self):
previous = [
Host("10.0.0.1", hostname="srv", ports=[PortInfo(22, service="SSH")]),
Host("10.0.0.2", hostname="old"),
]
current = [
Host("10.0.0.1", hostname="srv", ports=[PortInfo(22, service="SSH"), PortInfo(80, service="HTTP")]),
Host("10.0.0.3", hostname="new"),
]
result = compare_hosts(current, previous)
by_ip = {h.ip: h for h in result}
self.assertEqual(by_ip["10.0.0.1"].change_status, "Modifié")
self.assertIn("80/tcp", by_ip["10.0.0.1"].change_detail)
self.assertEqual(by_ip["10.0.0.3"].change_status, "Nouveau")
self.assertEqual(by_ip["10.0.0.2"].change_status, "Disparu")
self.assertEqual(by_ip["10.0.0.2"].status, "down")
def test_ip_move_by_mac(self):
previous = [Host("10.0.0.10", mac="AA:BB:CC:DD:EE:FF")]
current = [Host("10.0.0.20", mac="AA:BB:CC:DD:EE:FF")]
result = compare_hosts(current, previous)
self.assertEqual(len(result), 1)
self.assertEqual(result[0].change_status, "IP modifiée")
self.assertEqual(result[0].previous_ip, "10.0.0.10")
def test_first_scan_is_reference(self):
current = [Host("10.0.0.1")]
result = compare_hosts(current, None)
self.assertEqual(result[0].change_status, "")
class StorageTests(unittest.TestCase):
def test_history_roundtrip(self):
with tempfile.TemporaryDirectory() as tmp:
store = HistoryStore(Path(tmp) / "history.sqlite3")
host = Host(
"192.168.1.2",
hostname="pve01.local",
mac="AA:BB:CC:DD:EE:01",
ports=[PortInfo(22, service="SSH"), PortInfo(8006, service="Proxmox VE")],
)
scan_id = store.save_scan("192.168.1.0/24", "Standard", [host], "standard-test-v2")
latest = store.latest_scan("192.168.1.0/24", "Standard", "standard-test-v2")
self.assertIsNotNone(latest)
self.assertEqual(int(latest["id"]), scan_id)
loaded = store.load_scan_hosts(scan_id)
self.assertEqual(len(loaded), 1)
self.assertEqual(loaded[0].hostname, "pve01.local")
self.assertEqual(loaded[0].device_type, "Hyperviseur Proxmox")
def test_v01_database_is_migrated_without_becoming_v02_baseline(self):
import sqlite3
from contextlib import closing
with tempfile.TemporaryDirectory() as tmp:
db = Path(tmp) / "history.sqlite3"
with closing(sqlite3.connect(db)) as conn:
conn.executescript(
"""
CREATE TABLE scans (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at TEXT NOT NULL,
target TEXT NOT NULL,
profile TEXT NOT NULL,
host_count INTEGER NOT NULL
);
CREATE TABLE scan_hosts (
scan_id INTEGER NOT NULL,
ip TEXT NOT NULL,
hostname TEXT,
mac TEXT,
vendor TEXT,
os_name TEXT,
ports_json TEXT NOT NULL
);
INSERT INTO scans(created_at, target, profile, host_count)
VALUES ('2026-08-21T12:00:00+02:00', '192.168.5.0/24', 'Standard', 1);
INSERT INTO scan_hosts(scan_id, ip, hostname, mac, vendor, os_name, ports_json)
VALUES (1, '192.168.5.1', 'pve01.local', '', '', '', '[]');
"""
)
store = HistoryStore(db)
self.assertIsNone(store.latest_scan("192.168.5.0/24", "Standard", "standard-v2:test"))
self.assertEqual(len(store.recent_scans()), 1)
if __name__ == "__main__":
unittest.main()
+65
View File
@@ -0,0 +1,65 @@
import tempfile
import unittest
from pathlib import Path
from librenet_scanner.diagnostics import arp_scan_succeeded, parse_getcap_output
from librenet_scanner.network import parse_neighbor_json
from librenet_scanner.vendors import load_vendor_table, lookup_mac_vendor, parse_vendor_text
class NeighborTests(unittest.TestCase):
def test_neighbor_json_filters_unusable_entries(self):
payload = r'''[
{"dst":"192.168.10.2","lladdr":"aa:bb:cc:dd:ee:ff","state":["REACHABLE"]},
{"dst":"192.168.10.3","lladdr":"11:22:33:44:55:66","state":["STALE"]},
{"dst":"192.168.10.4","state":["INCOMPLETE"]},
{"dst":"192.168.10.5","lladdr":"bad-mac","state":["REACHABLE"]}
]'''
entries = parse_neighbor_json(payload)
self.assertEqual(len(entries), 2)
self.assertEqual(entries[0].ip, "192.168.10.2")
self.assertEqual(entries[0].mac, "AA:BB:CC:DD:EE:FF")
self.assertEqual(entries[1].state, "STALE")
def test_invalid_neighbor_json_is_empty(self):
self.assertEqual(parse_neighbor_json("not-json"), [])
class VendorTests(unittest.TestCase):
def test_parse_common_oui_formats(self):
text = '''
# comment
00-11-22 (hex) Example Networks
001122 (base 16) Example Networks Base
AA:BB:CC Another Vendor
AABBCCDDEE Precise Vendor
'''
table = parse_vendor_text(text)
self.assertEqual(table["001122"], "Example Networks Base")
self.assertEqual(table["AABBCC"], "Another Vendor")
self.assertEqual(table["AABBCCDDEE"], "Precise Vendor")
def test_load_vendor_table(self):
with tempfile.TemporaryDirectory() as tmp:
p1 = Path(tmp) / "oui.txt"
p2 = Path(tmp) / "mac-vendor.txt"
p1.write_text("AABBCC Vendor Generic\n")
p2.write_text("AABBCCDDEE Vendor Precise\n")
table = load_vendor_table((p1, p2))
self.assertEqual(table["AABBCC"], "Vendor Generic")
self.assertEqual(table["AABBCCDDEE"], "Vendor Precise")
class DiagnosticTests(unittest.TestCase):
def test_getcap_parser(self):
self.assertTrue(parse_getcap_output("/usr/sbin/arp-scan cap_net_raw=p\n"))
self.assertFalse(parse_getcap_output(""))
def test_arp_scan_exit_code(self):
self.assertTrue(arp_scan_succeeded(0))
self.assertFalse(arp_scan_succeeded(1))
self.assertFalse(arp_scan_succeeded(2))
if __name__ == "__main__":
unittest.main()
+42
View File
@@ -0,0 +1,42 @@
import unittest
from unittest.mock import patch
from librenet_scanner.privileged_helper import command_for, validate_target
class PrivilegedHelperTests(unittest.TestCase):
def test_authorize_exposes_no_command(self):
self.assertEqual(command_for("authorize", []), [])
with self.assertRaises(ValueError):
command_for("authorize", ["unexpected"])
def test_deep_scan_is_fixed_and_validated(self):
with patch("librenet_scanner.privileged_helper._trusted_binary", return_value="/usr/bin/nmap"):
cmd = command_for("nmap-deep", ["192.168.10.0/24"])
self.assertEqual(cmd[0], "/usr/bin/nmap")
self.assertIn("-sS", cmd)
self.assertIn("-O", cmd)
self.assertEqual(cmd[-1], "192.168.10.0/24")
def test_standard_scan_rejects_option_injection(self):
with self.assertRaises(ValueError):
command_for("nmap-standard", ["--script", "192.168.1.1"])
def test_unknown_operation_is_rejected(self):
with self.assertRaises(ValueError):
command_for("shell", ["/bin/sh"])
def test_target_limit(self):
self.assertEqual(validate_target("192.168.1.42/24"), "192.168.1.0/24")
with self.assertRaises(ValueError):
validate_target("10.0.0.0/8")
def test_arp_scan_uses_validated_interface_and_fixed_binary(self):
with patch("librenet_scanner.privileged_helper.validate_interface", return_value="enp42s0"), \
patch("librenet_scanner.privileged_helper._trusted_binary", return_value="/usr/sbin/arp-scan"):
cmd = command_for("arp-scan", ["enp42s0", "192.168.10.0/24"])
self.assertEqual(cmd, ["/usr/sbin/arp-scan", "--interface", "enp42s0", "192.168.10.0/24"])
if __name__ == "__main__":
unittest.main()
+62
View File
@@ -0,0 +1,62 @@
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from librenet_scanner.models import Host
from librenet_scanner.network import NetworkInterface, display_target, target_address_count, target_is_on_interface, validate_target
from librenet_scanner.privileged_helper import command_for
from librenet_scanner.storage import HistoryStore
class TargetRangeTests(unittest.TestCase):
def test_human_range_is_normalized_for_nmap(self):
target = validate_target("192.168.10.1 - 192.168.10.254")
self.assertEqual(target, "192.168.10.1-254")
self.assertEqual(display_target(target), "192.168.10.1 - 192.168.10.254")
self.assertEqual(target_address_count(target), 254)
def test_range_must_stay_in_same_24(self):
with self.assertRaises(ValueError):
validate_target("192.168.10.250 - 192.168.11.2")
def test_range_is_local_to_interface(self):
iface = NetworkInterface("enp42s0", "192.168.10.1", 24, "192.168.10.0/24")
self.assertTrue(target_is_on_interface("192.168.10.1-254", iface))
self.assertFalse(target_is_on_interface("192.168.11.1-254", iface))
class MetadataTests(unittest.TestCase):
def test_favorite_group_note_roundtrip(self):
with tempfile.TemporaryDirectory() as tmp:
store = HistoryStore(Path(tmp) / "db.sqlite3")
host = Host("192.168.10.20", hostname="pve01", mac="AA:BB:CC:DD:EE:FF")
store.save_host_metadata(host, favorite=True, group_name="Infrastructure", note="Cluster principal")
meta = store.host_metadata(host)
self.assertTrue(meta["favorite"])
self.assertEqual(meta["group_name"], "Infrastructure")
self.assertEqual(meta["note"], "Cluster principal")
self.assertEqual(store.known_groups(), ["Infrastructure"])
def test_metadata_follows_mac_after_ip_change(self):
with tempfile.TemporaryDirectory() as tmp:
store = HistoryStore(Path(tmp) / "db.sqlite3")
old = Host("192.168.10.20", mac="AA:BB:CC:DD:EE:FF")
store.save_host_metadata(old, favorite=True)
moved = Host("192.168.10.99", mac="AA:BB:CC:DD:EE:FF")
self.assertTrue(store.host_metadata(moved)["favorite"])
class V03PrivilegedHelperTests(unittest.TestCase):
def test_privileged_discovery_is_fixed(self):
with patch("librenet_scanner.privileged_helper._trusted_binary", return_value="/usr/bin/nmap"):
cmd = command_for("nmap-discover", ["192.168.10.1-254"])
self.assertEqual(cmd, ["/usr/bin/nmap", "-sn", "-n", "-T4", "--max-retries", "1", "-oX", "-", "192.168.10.1-254"])
def test_privileged_discovery_rejects_option_injection(self):
with self.assertRaises(ValueError):
command_for("nmap-discover", ["--script=evil"])
if __name__ == "__main__":
unittest.main()
+26
View File
@@ -0,0 +1,26 @@
import unittest
from librenet_scanner.ui_layout import balanced_column_widths, compact_warning
class UI041LayoutTests(unittest.TestCase):
def test_balanced_columns_fill_viewport(self):
widths = balanced_column_widths(1200, show_evolution=True)
self.assertEqual(len(widths), 4)
self.assertGreaterEqual(widths[0], 300)
self.assertGreater(widths[2], widths[1])
self.assertLessEqual(sum(widths), 1200)
self.assertGreaterEqual(sum(widths), 1180)
def test_evolution_can_be_hidden(self):
widths = balanced_column_widths(1000, show_evolution=False)
self.assertEqual(widths[3], 0)
self.assertGreater(widths[0], 300)
def test_arp_warning_is_shortened(self):
message = "arp-scan n'a pas les privilèges nécessaires pour le scan ARP. Les MAC seront récupérées via ip neigh."
self.assertEqual(compact_warning(message), "ARP limité : certaines adresses MAC peuvent être incomplètes.")
if __name__ == "__main__":
unittest.main()
+45
View File
@@ -0,0 +1,45 @@
import unittest
from unittest.mock import patch
from librenet_scanner.models import Host
from librenet_scanner.parsers import parse_nmap_xml
from librenet_scanner.privileged_helper import command_for
class OsFingerprint0410Tests(unittest.TestCase):
def test_deep_hosts_forces_guess_and_does_not_limit_os_scan(self):
with patch("librenet_scanner.privileged_helper._trusted_binary", return_value="/usr/bin/nmap"):
cmd = command_for("nmap-deep-hosts", ["192.168.10.254"])
self.assertIn("-O", cmd)
self.assertIn("--osscan-guess", cmd)
self.assertNotIn("--osscan-limit", cmd)
self.assertIn("--top-ports", cmd)
self.assertIn("1000", cmd)
self.assertNotIn("-p-", cmd)
def test_detailed_host_forces_guess_without_full_port_scan(self):
with patch("librenet_scanner.privileged_helper._trusted_binary", return_value="/usr/bin/nmap"):
cmd = command_for("nmap-host", ["192.168.10.254"])
self.assertIn("--osscan-guess", cmd)
self.assertNotIn("--osscan-limit", cmd)
self.assertNotIn("-p-", cmd)
def test_parser_keeps_best_os_guess(self):
xml = """<nmaprun><host><status state='up'/><address addr='192.168.10.254' addrtype='ipv4'/>
<os>
<osmatch name='FreeBSD 12.X' accuracy='87'/>
<osmatch name='FreeBSD 11.2-RELEASE' accuracy='93'/>
<osmatch name='Linux 5.X' accuracy='78'/>
</os></host></nmaprun>"""
host = parse_nmap_xml(xml)[0]
self.assertEqual(host.os_name, "FreeBSD 11.2-RELEASE")
self.assertEqual(host.os_accuracy, 93)
self.assertTrue(host.os_is_estimated)
def test_100_percent_os_match_is_not_marked_estimated(self):
host = Host(ip="192.168.1.1", os_name="Linux 6.x", os_accuracy=100)
self.assertFalse(host.os_is_estimated)
if __name__ == "__main__":
unittest.main()
+32
View File
@@ -0,0 +1,32 @@
import unittest
from librenet_scanner.models import Host
from librenet_scanner.visual_identity import device_icon_key_for_host, os_icon_key_for_host
class VisualIdentitySplitRegressionTests(unittest.TestCase):
"""0.4.12 deliberately replaces the product-specific 0.4.11 icon model."""
def test_openwrt_is_linux_os_but_network_equipment_type_is_independent(self):
host = Host("192.0.2.1", os_name="OpenWrt 23.05", device_type="Équipement réseau")
self.assertEqual(os_icon_key_for_host(host), "linux")
self.assertEqual(device_icon_key_for_host(host), "network-device")
def test_opnsense_is_bsd_os_and_firewall_equipment(self):
host = Host("192.0.2.3", hostname="opns01.local", os_name="FreeBSD 13", device_type="Pare-feu / routeur")
self.assertEqual(os_icon_key_for_host(host), "bsd")
self.assertEqual(device_icon_key_for_host(host), "firewall")
def test_proxmox_is_linux_os_and_hypervisor_equipment(self):
host = Host("192.0.2.4", os_name="Linux 6.x", device_type="Hyperviseur Proxmox")
self.assertEqual(os_icon_key_for_host(host), "linux")
self.assertEqual(device_icon_key_for_host(host), "hypervisor")
def test_windows_workstation_is_windows_os_and_workstation_equipment(self):
host = Host("192.0.2.2", os_name="Microsoft Windows 11", device_type="Poste / serveur Windows")
self.assertEqual(os_icon_key_for_host(host), "windows")
self.assertEqual(device_icon_key_for_host(host), "workstation")
if __name__ == "__main__":
unittest.main()
+69
View File
@@ -0,0 +1,69 @@
import unittest
from pathlib import Path
from librenet_scanner.models import Host
from librenet_scanner.visual_identity import (
DEVICE_THEME_CANDIDATES,
OS_ICON_FILES,
device_icon_key_for_host,
os_icon_key_for_host,
)
class GenericEquipmentIconTests(unittest.TestCase):
def test_server_uses_server_class_independent_from_linux(self):
host = Host("192.0.2.10", os_name="Debian GNU/Linux 13", device_type="Serveur Linux")
self.assertEqual(device_icon_key_for_host(host), "server")
self.assertEqual(os_icon_key_for_host(host), "linux")
def test_nas_is_nas_even_if_linux_underneath(self):
host = Host("192.0.2.20", os_name="Linux 6.x", device_type="NAS Synology")
self.assertEqual(device_icon_key_for_host(host), "nas")
self.assertEqual(os_icon_key_for_host(host), "linux")
def test_printer_is_printer(self):
self.assertEqual(device_icon_key_for_host(Host("192.0.2.30", device_type="Imprimante")), "printer")
def test_access_point_is_access_point(self):
self.assertEqual(device_icon_key_for_host(Host("192.0.2.40", device_type="Point d'accès Wi-Fi")), "access-point")
def test_switch_is_switch(self):
self.assertEqual(device_icon_key_for_host(Host("192.0.2.50", device_type="Switch")), "switch")
def test_local_host_is_workstation(self):
host = Host("192.0.2.60", os_name="Linux", device_type="Ce poste", is_local=True)
self.assertEqual(device_icon_key_for_host(host), "workstation")
class GenericOsIconTests(unittest.TestCase):
def test_linux_distributions_share_tux_family(self):
for name in ("Debian GNU/Linux 13", "Ubuntu 26.04", "OpenWrt 24.10", "Linux 6.12", "Proxmox Linux 6.x"):
with self.subTest(name=name):
self.assertEqual(os_icon_key_for_host(Host("192.0.2.1", os_name=name)), "linux")
def test_bsd_family_shares_daemon_icon(self):
for name in ("FreeBSD 14", "OpenBSD 7.6", "NetBSD 10", "OPNsense 25"):
with self.subTest(name=name):
self.assertEqual(os_icon_key_for_host(Host("192.0.2.1", os_name=name)), "bsd")
def test_unknown_os_uses_other(self):
self.assertEqual(os_icon_key_for_host(Host("192.0.2.1")), "other")
def test_only_generic_os_assets_are_declared(self):
self.assertTrue({"linux", "bsd", "windows", "other"}.issubset(set(OS_ICON_FILES)))
def test_theme_device_classes_have_fallbacks(self):
for key, names in DEVICE_THEME_CANDIDATES.items():
with self.subTest(key=key):
self.assertTrue(names)
self.assertTrue(all(names))
def test_os_assets_exist(self):
root = Path(__file__).resolve().parents[1] / "assets" / "icons"
for filename in OS_ICON_FILES.values():
with self.subTest(filename=filename):
self.assertTrue((root / filename).is_file())
if __name__ == "__main__":
unittest.main()
+62
View File
@@ -0,0 +1,62 @@
import unittest
from pathlib import Path
from librenet_scanner.models import Host
from librenet_scanner.visual_identity import (
EQUIPMENT_ICON_FILES,
OS_ICON_FILES,
device_icon_key_for_host,
os_icon_key_for_host,
)
class EquipmentIdentityTests(unittest.TestCase):
def test_local_host_is_workstation(self):
self.assertEqual(device_icon_key_for_host(Host("192.0.2.1", is_local=True, device_type="Ce poste")), "workstation")
def test_linux_server_is_server_not_workstation(self):
host = Host("192.0.2.2", device_type="Serveur Linux", os_name="Debian GNU/Linux 13")
self.assertEqual(device_icon_key_for_host(host), "server")
self.assertEqual(os_icon_key_for_host(host), "linux")
def test_openwrt_is_generic_network_device(self):
host = Host("192.0.2.3", device_type="Serveur Linux", os_name="OpenWrt 24.10")
self.assertEqual(device_icon_key_for_host(host), "network-device")
self.assertEqual(os_icon_key_for_host(host), "linux")
def test_opnsense_is_firewall_and_bsd(self):
host = Host("192.0.2.4", device_type="Serveur / appliance", os_name="OPNsense 25.1 (FreeBSD 14)")
self.assertEqual(device_icon_key_for_host(host), "firewall")
self.assertEqual(os_icon_key_for_host(host), "bsd")
def test_nas_switch_ap_printer_have_distinct_classes(self):
cases = {
"NAS Synology": "nas",
"Switch": "switch",
"Point d'accès Wi-Fi": "access-point",
"Imprimante": "printer",
}
for dtype, expected in cases.items():
with self.subTest(dtype=dtype):
self.assertEqual(device_icon_key_for_host(Host("192.0.2.10", device_type=dtype)), expected)
class IconAssetTests(unittest.TestCase):
def test_all_equipment_assets_exist(self):
root = Path(__file__).resolve().parents[1] / "assets" / "icons"
for filename in EQUIPMENT_ICON_FILES.values():
with self.subTest(filename=filename):
self.assertTrue((root / filename).is_file(), filename)
def test_all_os_assets_exist(self):
root = Path(__file__).resolve().parents[1] / "assets" / "icons"
for filename in OS_ICON_FILES.values():
with self.subTest(filename=filename):
self.assertTrue((root / filename).is_file(), filename)
def test_equipment_assets_are_not_os_assets(self):
self.assertTrue(set(EQUIPMENT_ICON_FILES.values()).isdisjoint(OS_ICON_FILES.values()))
if __name__ == "__main__":
unittest.main()
+45
View File
@@ -0,0 +1,45 @@
from pathlib import Path
import unittest
from librenet_scanner.models import Host
from librenet_scanner.visual_identity import device_icon_key_for_host, os_icon_key_for_host
class V0414IconTests(unittest.TestCase):
def test_equipment_categories_are_distinct(self):
cases = [
(Host(ip='192.0.2.1', device_type='Poste'), 'workstation'),
(Host(ip='192.0.2.2', device_type='Serveur Linux'), 'server'),
(Host(ip='192.0.2.3', device_type='NAS Synology'), 'nas'),
(Host(ip='192.0.2.4', device_type='Switch'), 'switch'),
(Host(ip='192.0.2.5', device_type="Point d'accès Wi-Fi"), 'access-point'),
(Host(ip='192.0.2.6', device_type='Imprimante'), 'printer'),
(Host(ip='192.0.2.7', device_type='Pare-feu / routeur'), 'firewall'),
]
for host, expected in cases:
with self.subTest(expected=expected):
self.assertEqual(device_icon_key_for_host(host), expected)
def test_os_families(self):
self.assertEqual(os_icon_key_for_host(Host(ip='1.1.1.1', os_name='Linux 6.1')), 'linux')
self.assertEqual(os_icon_key_for_host(Host(ip='1.1.1.2', os_name='FreeBSD 14.1')), 'bsd')
self.assertEqual(os_icon_key_for_host(Host(ip='1.1.1.3', os_name='Windows 11')), 'windows')
def test_fontawesome_assets_replaced_homemade_icons(self):
root = Path(__file__).resolve().parents[1]
icons = root / 'assets' / 'icons'
expected = {
'os-linux.svg', 'os-bsd.svg', 'os-windows.svg',
'equipment-workstation.svg', 'equipment-server.svg',
'equipment-firewall.svg', 'equipment-switch.svg',
'equipment-access-point.svg', 'equipment-printer.svg',
}
self.assertTrue(expected.issubset({p.name for p in icons.glob('*.svg')}))
# Homemade 0.4.13 BSD path included a tail/staff; FA FreeBSD glyph is larger and contains many curves.
bsd=(icons/'os-bsd.svg').read_text()
self.assertIn('Font Awesome', (root/'THIRD_PARTY_ASSETS.md').read_text())
self.assertGreater(len(bsd), 500)
if __name__ == '__main__':
unittest.main()
+50
View File
@@ -0,0 +1,50 @@
from pathlib import Path
import unittest
from librenet_scanner.identity import os_family
from librenet_scanner.models import Host
from librenet_scanner.visual_identity import OS_ICON_FILES, os_icon_key_for_host
class V0415OsIconsTests(unittest.TestCase):
def test_windows_still_supported(self):
self.assertEqual(os_icon_key_for_host(Host(ip="192.0.2.10", os_name="Microsoft Windows 11")), "windows")
def test_apple_platforms(self):
for value in (
"Apple macOS 14.5",
"Apple Mac OS X 10.15.7",
"Darwin 23.5.0",
"iPhone OS 17.6",
"iPadOS 18.0",
"Apple iOS 17.6",
"Apple TV tvOS 17",
):
with self.subTest(value=value):
self.assertEqual(os_icon_key_for_host(Host(ip="192.0.2.11", os_name=value)), "apple")
def test_android_wins_over_linux(self):
self.assertEqual(
os_icon_key_for_host(Host(ip="192.0.2.12", os_name="Linux 5.10 (Android 14)")),
"android",
)
def test_identity_families_match_visual_families(self):
self.assertEqual(os_family("Android 14 (Linux 5.10)"), "android")
self.assertEqual(os_family("Apple macOS 15"), "apple")
self.assertEqual(os_family("Windows 11"), "windows")
self.assertNotEqual(os_icon_key_for_host(Host(ip="192.0.2.13", os_name="Cisco IOS 15.2")), "apple")
self.assertEqual(os_family("Cisco IOS XE 17.9"), "iosxe")
def test_assets_are_packaged_sources(self):
root = Path(__file__).resolve().parents[1]
for key in ("apple", "android", "windows", "linux", "bsd"):
filename = OS_ICON_FILES[key]
icon = root / "assets" / "icons" / filename
self.assertTrue(icon.is_file(), f"missing {filename}")
self.assertGreater(icon.stat().st_size, 200)
self.assertIn("Font Awesome", (root / "THIRD_PARTY_ASSETS.md").read_text())
if __name__ == "__main__":
unittest.main()
+164
View File
@@ -0,0 +1,164 @@
import tempfile
import unittest
from datetime import datetime, timedelta, timezone
from pathlib import Path
from librenet_scanner.models import Host, PortInfo
from librenet_scanner.storage import HistoryStore
def ports(*values: int) -> list[PortInfo]:
return [PortInfo(port=value, service="test") for value in values]
class IdentificationFreshness0416Tests(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.store = HistoryStore(Path(self.tmp.name) / "history.sqlite3")
self.scope = "ipv4:192.168.10.0/24"
def tearDown(self):
self.tmp.cleanup()
def _row(self, identity_suffix: str = "mac:00:11:22:33:44:55"):
with self.store._connect() as conn:
return conn.execute(
"SELECT * FROM endpoint_identification WHERE identity LIKE ? ORDER BY identity LIMIT 1",
(f"%{identity_suffix}",),
).fetchone()
def test_standard_does_not_refresh_os_seen_at(self):
deep_time = "2025-01-02T03:04:05+00:00"
standard_time = "2026-08-22T12:00:00+00:00"
deep = Host(
ip="192.168.10.20", mac="00:11:22:33:44:55",
os_name="Debian 13", os_accuracy=97, ports=ports(22, 443),
)
self.store.remember_identifications([deep], "Approfondi", observed_at=deep_time, scope=self.scope)
standard = Host(
ip="192.168.10.20", mac=deep.mac, os_name="Linux 6.x", ports=ports(22, 443),
)
self.store.remember_identifications([standard], "Standard", observed_at=standard_time, scope=self.scope)
row = self._row()
self.assertIsNotNone(row)
self.assertEqual(row["os_name"], "Debian 13")
self.assertEqual(row["os_seen_at"], deep_time)
self.assertEqual(row["os_source"], "Approfondi")
self.assertEqual(row["updated_at"], standard_time)
def test_old_os_stays_old_even_when_endpoint_updated_at_is_recent(self):
old = Host(
ip="192.168.10.20", mac="00:11:22:33:44:55",
os_name="Debian 13", os_accuracy=99, ports=ports(22, 443),
)
self.store.remember_identifications([old], "Approfondi", scope=self.scope)
stale = (datetime.now(timezone.utc) - timedelta(days=365)).astimezone().isoformat(timespec="seconds")
fresh = datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds")
with self.store._connect() as conn:
conn.execute(
"UPDATE endpoint_identification SET os_seen_at = ?, updated_at = ?",
(stale, fresh),
)
moved = Host(ip="192.168.10.99", mac=old.mac)
self.store.apply_identifications([moved], scope=self.scope)
self.assertEqual(moved.remembered_os_name, "")
def test_unsafe_mac_move_does_not_overwrite_original_endpoint_before_matching(self):
old = Host(
ip="192.168.10.20", mac="00:11:22:33:44:55",
hostname="old.local", os_name="Debian 13", os_accuracy=99,
ports=ports(22, 443),
)
self.store.remember_identifications([old], "Approfondi", scope=self.scope)
moved = Host(ip="192.168.10.99", mac=old.mac, hostname="other.local")
# Même si une future régression écrit avant d'appliquer, l'ancien endpoint
# doit rester intact : l'observation ambiguë est scindée en macip.
self.store.remember_identifications([moved], "Standard", scope=self.scope)
with self.store._connect() as conn:
base = conn.execute(
"SELECT * FROM endpoint_identification WHERE identity = ?",
(f"{self.scope}::mac:{old.mac}",),
).fetchone()
split = conn.execute(
"SELECT * FROM endpoint_identification WHERE identity = ?",
(f"{self.scope}::macip:{old.mac}@192.168.10.99",),
).fetchone()
self.assertIsNotNone(base)
self.assertEqual(base["ip"], "192.168.10.20")
self.assertEqual(base["os_name"], "Debian 13")
self.assertIsNotNone(split)
class DataDeletion0416Tests(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.store = HistoryStore(Path(self.tmp.name) / "history.sqlite3")
self.scope = "ipv4:192.168.10.0/24"
self.host = Host(
ip="192.168.10.20", mac="00:11:22:33:44:55", hostname="srv.local",
os_name="Debian 13", os_accuracy=99, ports=ports(22),
)
def tearDown(self):
self.tmp.cleanup()
def test_clear_scan_history_does_not_delete_identification_or_metadata(self):
self.store.remember_identifications([self.host], "Approfondi", scope=self.scope)
self.store.save_host_metadata(self.host, favorite=True, note="Important")
self.store.save_scan("192.168.10.0/24", "Approfondi", [self.host], "deep-test")
self.assertEqual(self.store.clear_scan_history(), 1)
self.assertEqual(self.store.recent_scans(), [])
current = Host(ip=self.host.ip, mac=self.host.mac, ports=ports(22))
self.store.apply_identifications([current], scope=self.scope)
self.assertEqual(current.remembered_os_name, "Debian 13")
self.assertTrue(self.store.host_metadata(self.host)["favorite"])
def test_forget_selected_identification_keeps_metadata(self):
self.store.remember_identifications([self.host], "Approfondi", scope=self.scope)
self.store.save_host_metadata(self.host, favorite=True, group_name="Infra", note="Note")
deleted = self.store.forget_identification_for_host(self.host, scope=self.scope)
self.assertGreaterEqual(deleted, 1)
current = Host(ip=self.host.ip, mac=self.host.mac, ports=ports(22))
self.store.apply_identifications([current], scope=self.scope)
self.assertEqual(current.remembered_os_name, "")
metadata = self.store.host_metadata(self.host)
self.assertTrue(metadata["favorite"])
self.assertEqual(metadata["group_name"], "Infra")
def test_forget_scope_does_not_touch_other_network(self):
other_scope = "ipv4:192.168.20.0/24"
other = Host(ip="192.168.20.20", mac="00:11:22:AA:BB:CC", os_name="OpenWrt 24.10", ports=ports(22, 80))
self.store.remember_identifications([self.host], "Approfondi", scope=self.scope)
self.store.remember_identifications([other], "Approfondi", scope=other_scope)
self.assertEqual(self.store.forget_identifications_for_scope(self.scope), 1)
cur1 = Host(ip=self.host.ip, mac=self.host.mac, ports=ports(22))
cur2 = Host(ip=other.ip, mac=other.mac, ports=ports(22, 80))
self.store.apply_identifications([cur1], scope=self.scope)
self.store.apply_identifications([cur2], scope=other_scope)
self.assertEqual(cur1.remembered_os_name, "")
self.assertEqual(cur2.remembered_os_name, "OpenWrt 24.10")
def test_forget_all_identifications_leaves_scan_history(self):
self.store.remember_identifications([self.host], "Approfondi", scope=self.scope)
self.store.save_scan("192.168.10.0/24", "Approfondi", [self.host], "deep-test")
self.assertGreaterEqual(self.store.forget_all_identifications(), 1)
self.assertEqual(len(self.store.recent_scans()), 1)
current = Host(ip=self.host.ip, mac=self.host.mac, ports=ports(22))
self.store.apply_identifications([current], scope=self.scope)
self.assertEqual(current.remembered_os_name, "")
class SourceOrder0416Tests(unittest.TestCase):
def test_scan_finish_applies_memory_before_updating_endpoint_observation(self):
source = Path(__file__).parents[1] / "src" / "librenet_scanner" / "ui.py"
text = source.read_text(encoding="utf-8")
start = text.index("def _scan_finished")
end = text.index("# ---------- Constructeurs en ligne", start)
block = text[start:end]
self.assertLess(block.index("apply_identifications"), block.index("remember_identifications"))
self.assertIn("Effacer laffichage", text)
self.assertIn("Oublier les identifications", text)
if __name__ == "__main__":
unittest.main()
+27
View File
@@ -0,0 +1,27 @@
import ast
import unittest
from pathlib import Path
ROOT = Path(__file__).parents[1]
class StartupRegression0417Tests(unittest.TestCase):
def test_themed_icon_accepts_variadic_fallbacks(self):
source = (ROOT / "src/librenet_scanner/ui_icons.py").read_text(encoding="utf-8")
tree = ast.parse(source)
fn = next(node for node in tree.body if isinstance(node, ast.FunctionDef) and node.name == "themed_icon")
self.assertIsNotNone(fn.args.vararg, "themed_icon doit accepter plusieurs fallbacks")
self.assertEqual(fn.args.vararg.arg, "fallbacks")
def test_forget_identifications_menu_can_supply_three_theme_candidates(self):
source = (ROOT / "src/librenet_scanner/ui.py").read_text(encoding="utf-8")
self.assertIn('themed_icon("edit-delete", "edit-clear-history", "user-trash")', source)
# The regression in 0.4.16 was exactly a 3-positional-argument call.
# Variadic themed_icon must therefore remain compatible with it.
icon_source = (ROOT / "src/librenet_scanner/ui_icons.py").read_text(encoding="utf-8")
self.assertIn('def themed_icon(name: str, *fallbacks: str)', icon_source)
if __name__ == "__main__":
unittest.main()
+65
View File
@@ -0,0 +1,65 @@
import unittest
from pathlib import Path
from unittest.mock import patch
from librenet_scanner.fastscan import parse_naabu_json_line
from librenet_scanner.privileged_helper import command_for
ROOT = Path(__file__).resolve().parents[1]
class NaabuParser0418Tests(unittest.TestCase):
def test_jsonl_port_becomes_librenet_host(self):
host = parse_naabu_json_line('{"ip":"192.168.10.20","port":8006}')
self.assertIsNotNone(host)
self.assertEqual(host.ip, "192.168.10.20")
self.assertEqual(host.ports[0].port, 8006)
self.assertEqual(host.ports[0].service, "Proxmox VE")
self.assertEqual(host.device_type, "Hyperviseur Proxmox")
def test_invalid_json_is_ignored(self):
self.assertIsNone(parse_naabu_json_line("[INF] starting scan"))
self.assertIsNone(parse_naabu_json_line('{"ip":"not-an-ip","port":443}'))
self.assertIsNone(parse_naabu_json_line('{"ip":"192.168.1.2","port":70000}'))
class NaabuPrivilege0418Tests(unittest.TestCase):
def test_privileged_naabu_uses_fixed_syn_profile(self):
with patch("librenet_scanner.privileged_helper._trusted_binary", return_value="/usr/local/bin/naabu"):
cmd = command_for("naabu-standard", ["192.168.10.10", "192.168.10.11"])
self.assertEqual(cmd[0], "/usr/local/bin/naabu")
self.assertIn("-scan-type", cmd)
self.assertEqual(cmd[cmd.index("-scan-type") + 1], "s")
self.assertIn("-json", cmd)
self.assertIn("-disable-update-check", cmd)
self.assertEqual(cmd[cmd.index("-config") + 1], "/dev/null")
self.assertIn("192.168.10.10,192.168.10.11", cmd)
def test_privileged_naabu_rejects_network_in_host_list(self):
with patch("librenet_scanner.privileged_helper._trusted_binary", return_value="/usr/local/bin/naabu"):
with self.assertRaises(ValueError):
command_for("naabu-standard", ["192.168.10.0/24"])
class Pipeline0418Tests(unittest.TestCase):
def test_standard_and_deep_signatures_changed(self):
text = (ROOT / "src/librenet_scanner/scanner.py").read_text(encoding="utf-8")
self.assertIn('standard-v9:adaptive-nmap-small-naabu-large:', text)
self.assertIn('deep-v10:adaptive-standard-baseline-nmap-enrichment:', text)
def test_standard_uses_fast_engine_with_nmap_fallback(self):
text = (ROOT / "src/librenet_scanner/scanner.py").read_text(encoding="utf-8")
self.assertIn("if len(ips) < NAABU_ACTIVE_HOST_THRESHOLD", text)
self.assertIn("port_hosts = self._naabu_ports", text)
self.assertIn('args = privileged_command("nmap-standard", *ips)', text)
self.assertIn('"nmap", "-Pn", "-n", "-sT"', text)
def test_deep_keeps_nmap_service_and_os_enrichment(self):
text = (ROOT / "src/librenet_scanner/scanner.py").read_text(encoding="utf-8")
self.assertIn('privileged_command("nmap-deep-hosts", *ips)', text)
self.assertIn('"-sV"', text)
self.assertIn('"--top-ports", "1000"', text)
if __name__ == "__main__":
unittest.main()
+44
View File
@@ -0,0 +1,44 @@
import io
import sys
import time
import unittest
from pathlib import Path
from librenet_scanner.privileged_helper import run_supervised
ROOT = Path(__file__).resolve().parents[1]
class Cancellation0419Tests(unittest.TestCase):
def test_privileged_helper_stop_protocol_reaps_root_child(self):
start = time.monotonic()
code = run_supervised(
[sys.executable, "-c", "import time; time.sleep(30)"],
input_stream=io.StringIO("STOP\n"),
)
self.assertEqual(code, 130)
self.assertLess(time.monotonic() - start, 3.0)
def test_scan_processes_have_a_dedicated_process_group_and_stop_channel(self):
source = (ROOT / "src/librenet_scanner/scanner.py").read_text(encoding="utf-8")
self.assertIn("start_new_session=True", source)
self.assertIn('proc.stdin.write("STOP\\n")', source)
self.assertIn("self._kill_process_group(proc, signal.SIGTERM)", source)
def test_stop_button_reports_real_shutdown(self):
source = (ROOT / "src/librenet_scanner/ui.py").read_text(encoding="utf-8")
self.assertIn('self.activity_label.setText("Arrêt du scan en cours…")', source)
self.assertIn("self.stop_btn.setEnabled(False)", source)
class EngineStatus0419Tests(unittest.TestCase):
def test_discovery_and_port_engines_are_named_separately(self):
source = (ROOT / "src/librenet_scanner/scanner.py").read_text(encoding="utf-8")
self.assertIn("découverte rapide Nmap/ARP", source)
self.assertIn("ports Naabu SYN (Admin)", source)
self.assertIn("ports Nmap SYN (Admin)", source)
self.assertIn("REPLI Naabu", source)
if __name__ == "__main__":
unittest.main()
+23
View File
@@ -0,0 +1,23 @@
import unittest
from pathlib import Path
class UI042ScanMenuRegressionTests(unittest.TestCase):
def test_scan_menu_button_has_no_attached_qmenu_indicator(self):
ui = (Path(__file__).resolve().parents[1] / "src/librenet_scanner/ui.py").read_text()
start = ui.index("self.scan_menu_btn = QToolButton()")
end = ui.index("self.stop_btn = QPushButton", start)
block = ui[start:end]
self.assertIn("setArrowType(Qt.DownArrow)", block)
self.assertIn("clicked.connect(self._show_scan_menu)", block)
self.assertNotIn("setMenu(", block)
self.assertNotIn("InstantPopup", block)
def test_scan_profile_menu_is_stored_separately(self):
ui = (Path(__file__).resolve().parents[1] / "src/librenet_scanner/ui.py").read_text()
self.assertIn("self.scan_menu = QMenu(self)", ui)
self.assertIn("self.scan_menu.popup(pos)", ui)
if __name__ == "__main__":
unittest.main()
+61
View File
@@ -0,0 +1,61 @@
import unittest
from pathlib import Path
from unittest.mock import patch
from librenet_scanner.network import target_ipv4_hosts
from librenet_scanner.privileged_helper import command_for
ROOT = Path(__file__).resolve().parents[1]
class StandardPipeline0420Tests(unittest.TestCase):
def test_standard_uses_adaptive_baseline(self):
source = (ROOT / "src/librenet_scanner/scanner.py").read_text(encoding="utf-8")
start = source.index("def _standard_scan")
end = source.index("def _nmap_optional", start)
block = source[start:end]
self.assertIn("self._standard_baseline", block)
self.assertNotIn("target_ipv4_hosts", block)
def test_run_standard_branch_only_enters_standard_pipeline(self):
source = (ROOT / "src/librenet_scanner/scanner.py").read_text(encoding="utf-8")
branch = source[source.index('elif profile == "Standard"'):source.index('elif profile == "Approfondi"')]
self.assertIn("self._standard_scan()", branch)
self.assertNotIn("_discover_hosts", branch)
def test_user_naabu_is_connect_stream_and_skips_host_prefilter(self):
source = (ROOT / "src/librenet_scanner/scanner.py").read_text(encoding="utf-8")
start = source.index("def _naabu_ports")
end = source.index("def _nmap_standard_ports", start)
block = source[start:end]
self.assertIn('"-scan-type", "c"', block)
self.assertIn('"-Pn"', block)
self.assertIn('"-stream"', block)
self.assertNotIn('"-verify"', block)
self.assertNotIn('"-retries"', block)
def test_admin_naabu_is_syn_stream_and_bounded(self):
with patch("librenet_scanner.privileged_helper._trusted_binary", return_value="/usr/local/bin/naabu"):
cmd = command_for("naabu-standard", ["192.168.10.10", "192.168.10.11"])
self.assertEqual(cmd[cmd.index("-scan-type") + 1], "s")
self.assertIn("-Pn", cmd)
self.assertIn("-stream", cmd)
self.assertNotIn("-verify", cmd)
self.assertNotIn("-retries", cmd)
self.assertEqual(cmd[cmd.index("-timeout") + 1], "800ms")
def test_target_range_is_expanded(self):
self.assertEqual(
target_ipv4_hosts("192.168.10.10-12"),
["192.168.10.10", "192.168.10.11", "192.168.10.12"],
)
def test_target_cidr_is_expanded_to_hosts(self):
self.assertEqual(
target_ipv4_hosts("192.168.10.0/30"),
["192.168.10.1", "192.168.10.2"],
)
if __name__ == "__main__":
unittest.main()
+131
View File
@@ -0,0 +1,131 @@
import importlib
import sys
import types
import unittest
from pathlib import Path
from unittest.mock import Mock, patch
from librenet_scanner.fastscan import parse_naabu_host_json_line
from librenet_scanner.models import Host
from librenet_scanner.privileged_helper import command_for
ROOT = Path(__file__).resolve().parents[1]
if "PySide6.QtCore" not in sys.modules:
qtcore = types.ModuleType("PySide6.QtCore")
pyside = types.ModuleType("PySide6")
class DummySignal:
def __init__(self, *args, **kwargs):
self.calls = []
def emit(self, *args):
self.calls.append(args)
class DummyQThread:
def __init__(self, parent=None):
self._interrupted = False
def requestInterruption(self):
self._interrupted = True
def isInterruptionRequested(self):
return self._interrupted
qtcore.Signal = DummySignal
qtcore.QThread = DummyQThread
pyside.QtCore = qtcore
sys.modules["PySide6"] = pyside
sys.modules["PySide6.QtCore"] = qtcore
scanner = importlib.import_module("librenet_scanner.scanner")
class NaabuDiscovery0421Tests(unittest.TestCase):
def test_host_discovery_json_without_port_becomes_live_host(self):
host = parse_naabu_host_json_line('{"ip":"192.168.50.20"}')
self.assertIsNotNone(host)
self.assertEqual(host.ip, "192.168.50.20")
self.assertEqual(host.status, "up")
self.assertEqual(host.ports, [])
def test_legacy_admin_host_discovery_helper_remains_hardened(self):
with patch("librenet_scanner.privileged_helper._trusted_binary", return_value="/usr/local/bin/naabu"):
cmd = command_for("naabu-discover", ["192.168.50.10", "192.168.50.11"])
self.assertIn("-sn", cmd)
self.assertEqual(cmd[cmd.index("-config") + 1], "/dev/null")
self.assertIn("-auth=false", cmd)
class StandardBehavior0421Tests(unittest.TestCase):
def _worker(self, *, privileged=False):
req = scanner.ScanRequest(
target="192.168.50.0/30", profile="Standard", interface=None, privileged=privileged
)
return scanner.ScanWorker(req)
def test_standard_scan_uses_adaptive_baseline(self):
worker = self._worker()
worker._standard_baseline = Mock(return_value=({"192.168.50.1"}, "nmap"))
worker._set_progress = Mock()
worker._standard_scan()
worker._standard_baseline.assert_called_once()
self.assertIn("Nmap TCP (adaptatif)", worker._set_progress.call_args.args[1])
def test_small_active_set_uses_nmap_and_never_starts_naabu(self):
worker = self._worker()
worker._standard_discovery = Mock(return_value={"192.168.50.1"})
worker._naabu_ports = Mock(side_effect=AssertionError("Naabu ne doit pas démarrer pour un petit LAN"))
worker._nmap_standard_ports = Mock(return_value=[])
worker._neighbor_hosts = Mock(return_value=[])
worker._set_progress = Mock()
result, engine = worker._standard_baseline(start_percent=4, end_percent=96)
self.assertEqual(result, {"192.168.50.1"})
self.assertEqual(engine, "nmap")
worker._naabu_ports.assert_not_called()
self.assertFalse(worker._nmap_standard_ports.call_args.kwargs["fallback"])
def test_naabu_failure_falls_back_only_on_large_discovered_set(self):
worker = self._worker()
live = {f"192.168.50.{i}" for i in range(1, scanner.NAABU_ACTIVE_HOST_THRESHOLD + 1)}
worker._standard_discovery = Mock(return_value=live)
worker._naabu_ports = Mock(return_value=None)
worker._nmap_standard_ports = Mock(return_value=[])
worker._neighbor_hosts = Mock(return_value=[])
worker._set_progress = Mock()
result, engine = worker._standard_baseline(start_percent=4, end_percent=96)
self.assertEqual(engine, "nmap-fallback")
self.assertEqual(result, live)
fallback_ips = worker._nmap_standard_ports.call_args.args[0]
self.assertEqual(fallback_ips, sorted(live, key=scanner.ipaddress.ip_address))
self.assertTrue(worker._nmap_standard_ports.call_args.kwargs["fallback"])
def test_deep_reuses_adaptive_baseline_before_enrichment(self):
req = scanner.ScanRequest(
target="192.168.50.0/30", profile="Approfondi", interface=None, privileged=False
)
worker = scanner.ScanWorker(req)
worker._set_progress = Mock()
worker._standard_baseline = Mock(return_value=({"192.168.50.1"}, "nmap"))
worker._nmap = Mock(return_value=[])
worker._neighbor_hosts = Mock(return_value=[])
worker.run()
worker._standard_baseline.assert_called_once()
worker._nmap.assert_called_once()
args = worker._nmap.call_args.args[0]
self.assertIn("-sV", args)
self.assertIn("1000", args)
class Diagnostics0421Tests(unittest.TestCase):
def test_ui_distinguishes_user_and_admin_naabu(self):
source = (ROOT / "src/librenet_scanner/ui.py").read_text(encoding="utf-8")
self.assertIn("Naabu utilisateur", source)
self.assertIn("Naabu Admin SYN", source)
self.assertIn("naabu_diagnostic()", source)
def test_profile_signatures_identify_current_pipeline(self):
source = (ROOT / "src/librenet_scanner/scanner.py").read_text(encoding="utf-8")
self.assertIn("standard-v9:adaptive-nmap-small-naabu-large", source)
self.assertIn("deep-v10:adaptive-standard-baseline-nmap-enrichment", source)
if __name__ == "__main__":
unittest.main()
+262
View File
@@ -0,0 +1,262 @@
import hashlib
import importlib
import io
import os
import stat
import subprocess
import sys
import tempfile
import threading
import time
import types
import unittest
import zipfile
from pathlib import Path
from unittest.mock import Mock, patch
from librenet_scanner import naabu_runtime
from librenet_scanner.fastscan import BUNDLED_NAABU_PATH, SYSTEM_NAABU_CANDIDATES
from librenet_scanner.privileged_helper import command_for
ROOT = Path(__file__).resolve().parents[1]
# Surface Qt minimale pour pouvoir exécuter réellement ScanWorker dans le builder.
if "PySide6.QtCore" not in sys.modules:
qtcore = types.ModuleType("PySide6.QtCore")
pyside = types.ModuleType("PySide6")
class DummySignal:
def __init__(self, *args, **kwargs):
self.calls = []
def emit(self, *args):
self.calls.append(args)
class DummyQThread:
def __init__(self, parent=None):
self._interrupted = False
def requestInterruption(self):
self._interrupted = True
def isInterruptionRequested(self):
return self._interrupted
qtcore.Signal = DummySignal
qtcore.QThread = DummyQThread
pyside.QtCore = qtcore
sys.modules["PySide6"] = pyside
sys.modules["PySide6.QtCore"] = qtcore
scanner = importlib.import_module("librenet_scanner.scanner")
class NaabuProvisioning0422Tests(unittest.TestCase):
def _archive(self, payload: bytes) -> bytes:
stream = io.BytesIO()
with zipfile.ZipFile(stream, "w", zipfile.ZIP_DEFLATED) as zf:
zf.writestr("naabu", payload)
return stream.getvalue()
def test_runtime_constants_pin_official_261_amd64_release(self):
self.assertEqual(naabu_runtime.NAABU_VERSION, "2.6.1")
self.assertTrue(naabu_runtime.NAABU_AMD64_URL.endswith("/v2.6.1/naabu_2.6.1_linux_amd64.zip"))
self.assertEqual(
naabu_runtime.NAABU_AMD64_SHA256,
"018c4c9884dea971eda860435ede3021d1150732f34cfd245498c6726d8cab90",
)
self.assertEqual(BUNDLED_NAABU_PATH, "/usr/lib/librenet-scanner/bin/naabu")
self.assertEqual(SYSTEM_NAABU_CANDIDATES[0], BUNDLED_NAABU_PATH)
def test_offline_simulated_install_verifies_archive_and_installs_atomically(self):
fake_binary = b"\x7fELF" + b"LibreNet fake Naabu test payload\n"
archive = self._archive(fake_binary)
sha = hashlib.sha256(archive).hexdigest()
with tempfile.TemporaryDirectory() as td:
destination = str(Path(td) / "bin" / "naabu")
with patch.object(naabu_runtime, "_machine_is_amd64", return_value=True), \
patch.object(naabu_runtime, "naabu_version", return_value="2.6.1"):
installed = naabu_runtime.install_naabu(
destination=destination,
url="https://example.invalid/naabu.zip",
expected_sha256=sha,
download_func=lambda _url: archive,
require_root=False,
)
self.assertEqual(installed, destination)
self.assertEqual(Path(destination).read_bytes(), fake_binary)
self.assertTrue(os.stat(destination).st_mode & stat.S_IXUSR)
def test_checksum_mismatch_refuses_install(self):
archive = self._archive(b"\x7fELFbad")
with tempfile.TemporaryDirectory() as td:
destination = str(Path(td) / "naabu")
with patch.object(naabu_runtime, "_machine_is_amd64", return_value=True):
with self.assertRaises(naabu_runtime.NaabuProvisionError):
naabu_runtime.install_naabu(
destination=destination,
expected_sha256="0" * 64,
download_func=lambda _url: archive,
require_root=False,
)
self.assertFalse(Path(destination).exists())
def test_version_probe_isolated_from_user_config_cloud_and_updates(self):
completed = subprocess.CompletedProcess(args=[], returncode=0, stdout="Current Version: v2.6.1\n")
with patch("librenet_scanner.naabu_runtime.subprocess.run", return_value=completed) as run:
self.assertEqual(naabu_runtime.naabu_version("/tmp/naabu"), "2.6.1")
args = run.call_args.args[0]
self.assertIn("-disable-update-check", args)
self.assertEqual(args[args.index("-config") + 1], "/dev/null")
self.assertIn("-auth=false", args)
def test_runtime_ignores_external_path_and_requires_private_exact_engine(self):
fastscan = importlib.import_module("librenet_scanner.fastscan")
with tempfile.TemporaryDirectory() as td:
external = Path(td) / "naabu"
external.write_text("#!/bin/sh\necho Current Version: v2.6.1\n")
external.chmod(0o755)
with patch.object(fastscan, "BUNDLED_NAABU_PATH", str(Path(td) / "missing-private-naabu")), \
patch.dict(os.environ, {"PATH": td}, clear=False):
self.assertIsNone(fastscan.find_naabu())
def test_runtime_refuses_private_engine_with_wrong_version(self):
fastscan = importlib.import_module("librenet_scanner.fastscan")
with tempfile.TemporaryDirectory() as td:
private = Path(td) / "naabu"
private.write_text("fake")
private.chmod(0o755)
with patch.object(fastscan, "BUNDLED_NAABU_PATH", str(private)), \
patch.object(fastscan, "naabu_version", return_value="2.6.0"):
self.assertIsNone(fastscan.find_naabu())
class NaabuPipeline0422Tests(unittest.TestCase):
def test_standard_uses_discovery_then_fast_ports_in_user_or_admin_mode(self):
for privileged in (False, True):
request = scanner.ScanRequest(
target="192.168.10.1", profile="Standard", interface=None, privileged=privileged
)
worker = scanner.ScanWorker(request)
worker._standard_baseline = Mock(return_value=({"192.168.10.1"}, "nmap"))
worker._set_progress = Mock()
worker._standard_scan()
worker._standard_baseline.assert_called_once()
def test_user_naabu_port_command_is_connect_pn_and_not_nmap(self):
request = scanner.ScanRequest(
target="192.168.10.1", profile="Standard", interface=None, privileged=False
)
worker = scanner.ScanWorker(request)
worker._run_naabu_json = Mock(return_value=[])
with patch("librenet_scanner.scanner.find_naabu", return_value=BUNDLED_NAABU_PATH):
result = worker._naabu_ports(["192.168.10.1"], start_percent=20, end_percent=80)
self.assertEqual(result, [])
cmd = worker._run_naabu_json.call_args.args[0]
self.assertEqual(cmd[0], BUNDLED_NAABU_PATH)
self.assertEqual(cmd[cmd.index("-scan-type") + 1], "c")
self.assertIn("-Pn", cmd)
self.assertNotIn("nmap", " ".join(cmd).lower())
def test_nonprivileged_deep_command_can_be_stopped_while_silent(self):
request = scanner.ScanRequest(
target="192.168.10.1", profile="Approfondi", interface=None, privileged=False
)
worker = scanner.ScanWorker(request)
def request_stop():
time.sleep(0.25)
worker.stop()
threading.Thread(target=request_stop, daemon=True).start()
started = time.monotonic()
code, _out, _err = worker._run_command(
[sys.executable, "-c", "import time; time.sleep(30)"],
"Nmap approfondi silencieux", start_percent=1, end_percent=2,
)
elapsed = time.monotonic() - started
self.assertNotEqual(code, 0)
self.assertLess(elapsed, 4.0, f"annulation Nmap trop lente: {elapsed:.2f}s")
def test_admin_helper_prefers_librenet_runtime_and_keeps_port_prefilter_disabled(self):
with patch("librenet_scanner.privileged_helper._trusted_binary", side_effect=lambda *candidates: candidates[0]) as trusted:
cmd = command_for("naabu-standard", ["192.168.10.10"])
trusted.assert_called_once_with(BUNDLED_NAABU_PATH)
self.assertEqual(cmd[0], BUNDLED_NAABU_PATH)
self.assertIn("-Pn", cmd)
self.assertNotIn("-wn", cmd)
self.assertEqual(cmd[cmd.index("-scan-type") + 1], "s")
self.assertIn("-stream", cmd)
self.assertNotIn("-verify", cmd)
self.assertNotIn("-retries", cmd)
self.assertEqual(cmd[cmd.index("-warm-up-time") + 1], "0")
def test_standard_profile_signature_changes_for_provisioned_runtime(self):
self.assertIn("standard-v9:adaptive-nmap-small-naabu-large", scanner.PROFILE_SIGNATURES["Standard"])
self.assertIn("deep-v10:adaptive-standard-baseline", scanner.PROFILE_SIGNATURES["Approfondi"])
def test_silent_naabu_can_be_stopped_without_waiting_for_stdout(self):
request = scanner.ScanRequest(
target="192.168.10.1", profile="Standard", interface=None, privileged=False
)
worker = scanner.ScanWorker(request)
def request_stop():
time.sleep(0.25)
worker.stop()
stopper = threading.Thread(target=request_stop, daemon=True)
stopper.start()
started = time.monotonic()
result = worker._run_naabu_json(
[sys.executable, "-c", "import time; time.sleep(30)"],
"test Naabu silencieux",
lambda _line: None,
start_percent=1,
end_percent=2,
phase="le scan de ports",
)
elapsed = time.monotonic() - started
stopper.join(timeout=1)
self.assertEqual(result, [])
self.assertLess(elapsed, 4.0, f"annulation trop lente: {elapsed:.2f}s")
def test_detailed_worker_stop_terminates_its_process_group(self):
worker = scanner.HostScanWorker("127.0.0.1", privileged=False)
proc = subprocess.Popen(
[sys.executable, "-c", "import time; time.sleep(30)"],
start_new_session=True,
)
worker._proc = proc
started = time.monotonic()
worker.stop()
proc.wait(timeout=4)
self.assertLess(time.monotonic() - started, 4.0)
self.assertTrue(worker.isInterruptionRequested())
class Packaging0422Tests(unittest.TestCase):
def test_debian_metadata_is_amd64_and_installation_has_no_mandatory_network_access(self):
control = (ROOT / "packaging/debian/control").read_text(encoding="utf-8")
postinst = (ROOT / "packaging/debian/postinst").read_text(encoding="utf-8")
build = (ROOT / "packaging/build-deb.sh").read_text(encoding="utf-8")
self.assertIn("Version: 1.0.0", control)
self.assertIn("Architecture: amd64", control)
self.assertIn("set -eu", postinst)
self.assertNotIn("librenet-scanner-install-naabu --ensure", postinst)
self.assertIn("Aucune dépendance réseau", postinst)
self.assertIn("VERSION=1.0.0", build)
self.assertIn("ARCH=amd64", build)
def test_runtime_installer_is_packaged_and_postrm_removes_runtime(self):
build = (ROOT / "packaging/build-deb.sh").read_text(encoding="utf-8")
postrm = (ROOT / "packaging/debian/postrm").read_text(encoding="utf-8")
self.assertIn("librenet-scanner-install-naabu", build)
self.assertIn("/usr/lib/librenet-scanner/bin", build)
self.assertIn("NAABU-LICENSE.txt", build)
self.assertIn("/usr/lib/librenet-scanner/bin/naabu", postrm)
if __name__ == "__main__":
unittest.main()
+190
View File
@@ -0,0 +1,190 @@
import importlib
import sys
import threading
import time
import types
import unittest
from unittest.mock import Mock, patch
if "PySide6.QtCore" not in sys.modules:
qtcore = types.ModuleType("PySide6.QtCore")
pyside = types.ModuleType("PySide6")
class DummySignal:
def __init__(self, *args, **kwargs):
self.calls = []
def emit(self, *args):
self.calls.append(args)
class DummyQThread:
def __init__(self, parent=None):
self._interrupted = False
def requestInterruption(self):
self._interrupted = True
def isInterruptionRequested(self):
return self._interrupted
qtcore.Signal = DummySignal
qtcore.QThread = DummyQThread
pyside.QtCore = qtcore
sys.modules["PySide6"] = pyside
sys.modules["PySide6.QtCore"] = qtcore
scanner = importlib.import_module("librenet_scanner.scanner")
class PerformancePipeline0423Tests(unittest.TestCase):
def worker(self, privileged=False, target="192.168.10.0/24"):
return scanner.ScanWorker(scanner.ScanRequest(target, "Standard", None, privileged))
def test_user_case_four_live_hosts_uses_bounded_nmap_not_naabu(self):
worker = self.worker()
live = {"192.168.10.1", "192.168.10.22", "192.168.10.226", "192.168.10.254"}
worker._standard_discovery = Mock(return_value=live)
worker._naabu_ports = Mock(side_effect=AssertionError("Naabu ne doit pas démarrer pour 4 hôtes"))
worker._nmap_standard_ports = Mock(return_value=[])
worker._neighbor_hosts = Mock(return_value=[])
worker._set_progress = Mock()
result, engine = worker._standard_baseline(start_percent=4, end_percent=96)
self.assertEqual(result, live)
self.assertEqual(engine, "nmap")
worker._naabu_ports.assert_not_called()
scanned = worker._nmap_standard_ports.call_args.args[0]
self.assertEqual(scanned, sorted(live, key=scanner.ipaddress.ip_address))
self.assertFalse(worker._nmap_standard_ports.call_args.kwargs["fallback"])
def test_large_active_set_uses_naabu_only_on_discovered_hosts(self):
worker = self.worker()
live = {f"192.168.10.{i}" for i in range(1, scanner.NAABU_ACTIVE_HOST_THRESHOLD + 1)}
worker._standard_discovery = Mock(return_value=live)
worker._naabu_ports = Mock(return_value=[])
worker._nmap_standard_ports = Mock(side_effect=AssertionError("pas de Nmap ports nominal sur grand ensemble"))
worker._neighbor_hosts = Mock(return_value=[])
result, engine = worker._standard_baseline(start_percent=4, end_percent=96)
self.assertEqual(result, live)
self.assertEqual(engine, "naabu")
scanned = worker._naabu_ports.call_args.args[0]
self.assertEqual(scanned, sorted(live, key=scanner.ipaddress.ip_address))
self.assertEqual(len(scanned), scanner.NAABU_ACTIVE_HOST_THRESHOLD)
def test_naabu_is_split_into_small_bounded_batches(self):
worker = self.worker()
ips = [f"192.168.10.{i}" for i in range(1, 71)]
worker._run_naabu_json = Mock(return_value=[])
with patch("librenet_scanner.scanner.find_naabu", return_value="/usr/lib/librenet-scanner/bin/naabu"):
result = worker._naabu_ports(ips, start_percent=20, end_percent=80)
self.assertEqual(result, [])
self.assertEqual(worker._run_naabu_json.call_count, 3)
for call in worker._run_naabu_json.call_args_list:
cmd = call.args[0]
hosts = cmd[cmd.index("-host") + 1].split(",")
self.assertLessEqual(len(hosts), scanner.NAABU_BATCH_SIZE)
self.assertIn("-stream", cmd)
self.assertNotIn("-verify", cmd)
self.assertNotIn("-retries", cmd)
self.assertEqual(call.kwargs["timeout_seconds"], scanner.NAABU_BATCH_TIMEOUT_SECONDS)
def test_silent_naabu_batch_hits_wall_clock_timeout(self):
worker = self.worker(target="127.0.0.1")
started = time.monotonic()
result = worker._run_naabu_json(
[sys.executable, "-c", "import time; time.sleep(30)"],
"Naabu timeout test", lambda _line: None,
start_percent=1, end_percent=2, phase="le scan de ports", timeout_seconds=0.30,
)
elapsed = time.monotonic() - started
self.assertIsNone(result)
self.assertFalse(worker.isInterruptionRequested())
self.assertLess(elapsed, 4.0)
def test_generic_command_timeout_is_bounded(self):
worker = self.worker(target="127.0.0.1")
started = time.monotonic()
code, _out, err = worker._run_command(
[sys.executable, "-c", "import time; time.sleep(30)"],
"timeout test", timeout_seconds=0.30,
)
elapsed = time.monotonic() - started
self.assertEqual(code, 124)
self.assertIn("Délai maximal dépassé", err)
self.assertLess(elapsed, 4.0)
def test_arp_scan_itself_is_bounded(self):
worker = self.worker()
iface = types.SimpleNamespace(
name="enp42s0", address="192.168.10.1", prefixlen=24,
network="192.168.10.0/24", mac="",
)
worker.request.interface = iface
worker._run_command = Mock(return_value=(0, "", ""))
with patch("librenet_scanner.scanner.find_arp_scan", return_value="/usr/bin/arp-scan"):
worker._emit_arp(start_percent=5, end_percent=15)
self.assertEqual(worker._run_command.call_args.kwargs["timeout_seconds"], 8.0)
def test_standard_discovery_is_single_fast_nmap_pass_after_arp(self):
worker = self.worker()
worker._emit_local_host = Mock(return_value=[])
worker._emit_arp = Mock(return_value=[])
worker._neighbor_hosts = Mock(return_value=[])
worker._set_progress = Mock()
worker._nmap = Mock(return_value=[])
result = worker._standard_discovery(
start_percent=4, end_percent=35,
target_ips=[f"192.168.10.{i}" for i in range(1, 255)],
)
self.assertEqual(result, set())
worker._nmap.assert_called_once()
cmd = worker._nmap.call_args.args[0]
self.assertEqual(cmd[:6], ["nmap", "-sn", "-n", "-T4", "--max-retries", "1"])
self.assertIsNotNone(worker._nmap.call_args.kwargs["timeout_seconds"])
def test_naabu_failure_does_not_rescan_dead_addresses_with_nmap(self):
worker = self.worker()
live = {f"192.168.10.{i}" for i in range(1, scanner.NAABU_ACTIVE_HOST_THRESHOLD + 1)}
worker._standard_discovery = Mock(return_value=live)
worker._naabu_ports = Mock(return_value=None)
worker._nmap_standard_ports = Mock(return_value=[])
worker._neighbor_hosts = Mock(return_value=[])
worker._set_progress = Mock()
_known, engine = worker._standard_baseline(start_percent=4, end_percent=96)
self.assertEqual(engine, "nmap-fallback")
self.assertEqual(worker._nmap_standard_ports.call_args.args[0], sorted(live, key=scanner.ipaddress.ip_address))
self.assertTrue(worker._nmap_standard_ports.call_args.kwargs["fallback"])
def test_small_nmap_profile_is_bounded_and_only_receives_live_hosts(self):
worker = self.worker()
worker._nmap = Mock(return_value=[])
live = ["192.168.10.22", "192.168.10.226"]
result = worker._nmap_standard_ports(live, start_percent=20, end_percent=80, fallback=False)
self.assertEqual(result, [])
cmd = worker._nmap.call_args.args[0]
self.assertEqual(cmd[-2:], live)
self.assertIn("-n", cmd)
self.assertEqual(cmd[cmd.index("--max-retries") + 1], "1")
self.assertEqual(cmd[cmd.index("--host-timeout") + 1], "12s")
self.assertIsNotNone(worker._nmap.call_args.kwargs["timeout_seconds"])
def test_privileged_small_nmap_profile_disables_dns_and_bounds_hosts(self):
with patch("librenet_scanner.privileged_helper._trusted_binary", return_value="/usr/bin/nmap"):
from librenet_scanner.privileged_helper import command_for
cmd = command_for("nmap-standard", ["192.168.10.22", "192.168.10.226"])
self.assertIn("-n", cmd)
self.assertEqual(cmd[cmd.index("--max-retries") + 1], "1")
self.assertEqual(cmd[cmd.index("--host-timeout") + 1], "12s")
self.assertEqual(cmd[-2:], ["192.168.10.22", "192.168.10.226"])
def test_admin_naabu_command_is_fast_profile(self):
with patch("librenet_scanner.privileged_helper._trusted_binary", return_value="/usr/lib/librenet-scanner/bin/naabu"):
from librenet_scanner.privileged_helper import command_for
cmd = command_for("naabu-standard", ["192.168.10.22"])
self.assertIn("-stream", cmd)
self.assertEqual(cmd[cmd.index("-c") + 1], "100")
self.assertEqual(cmd[cmd.index("-rate") + 1], "2500")
self.assertEqual(cmd[cmd.index("-timeout") + 1], "800ms")
self.assertNotIn("-verify", cmd)
self.assertNotIn("-retries", cmd)
if __name__ == "__main__":
unittest.main()
+29
View File
@@ -0,0 +1,29 @@
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
class Branding043RegressionTests(unittest.TestCase):
def test_application_no_longer_uses_generic_network_wired_icon(self):
main = (ROOT / "src/librenet_scanner/main.py").read_text()
self.assertNotIn('QIcon.fromTheme("network-wired")', main)
self.assertIn('librenet-scanner.svg', main)
self.assertIn('QIcon.fromTheme(APP_ICON_NAME)', main)
def test_plasma_desktop_file_identity_is_declared(self):
main = (ROOT / "src/librenet_scanner/main.py").read_text()
desktop = (ROOT / "assets/librenet-scanner.desktop").read_text()
self.assertIn('setDesktopFileName(APP_DESKTOP_ID)', main)
self.assertIn('Icon=librenet-scanner', desktop)
self.assertIn('StartupWMClass=librenet-scanner', desktop)
def test_main_window_receives_same_application_icon(self):
main = (ROOT / "src/librenet_scanner/main.py").read_text()
self.assertIn('app.setWindowIcon(icon)', main)
self.assertIn('window.setWindowIcon(icon)', main)
if __name__ == "__main__":
unittest.main()
+24
View File
@@ -0,0 +1,24 @@
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
class Discovery044RegressionTests(unittest.TestCase):
def test_standard_discovers_before_port_scan(self):
scanner = (ROOT / 'src/librenet_scanner/scanner.py').read_text()
block = scanner[scanner.index('def _standard_baseline'):scanner.index('def _standard_scan')]
discovery = block.index('self._standard_discovery(')
ports = block.index('self._naabu_ports(')
self.assertLess(discovery, ports)
self.assertIn('ips = sorted(known_ips', block)
def test_standard_discovery_disables_dns_and_bounds_retries(self):
scanner = (ROOT / 'src/librenet_scanner/scanner.py').read_text()
block = scanner[scanner.index('def _standard_discovery'):scanner.index('def _standard_baseline')]
self.assertIn('"-sn", "-n", "-T4", "--max-retries", "1"', block)
self.assertIn('timeout_seconds=discovery_timeout', block)
if __name__ == '__main__':
unittest.main()
+39
View File
@@ -0,0 +1,39 @@
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
class UI045PolishTests(unittest.TestCase):
def test_type_column_stretches_to_fill_unused_space(self):
ui = (ROOT / "src/librenet_scanner/ui.py").read_text()
self.assertIn("header.setSectionResizeMode(2, QHeaderView.Stretch)", ui)
self.assertIn("self.tree.header().setSectionResizeMode(2, QHeaderView.Stretch)", ui)
def test_scan_progress_does_not_write_phase_to_statusbar(self):
ui = (ROOT / "src/librenet_scanner/ui.py").read_text()
start = ui.index("def _progress(self, message: str)")
end = ui.index("def _progress_state", start)
block = ui[start:end]
self.assertIn("self.activity_label.setText(message)", block)
self.assertNotIn("statusBar().showMessage", block)
finished = ui[ui.index("def _scan_finished"):ui.index("# ---------- Arbre", ui.index("def _scan_finished"))]
self.assertIn("self.statusBar().clearMessage()", finished)
self.assertIn('self.progress.setFormat("100%")', finished)
def test_worker_exposes_stage_aware_progress(self):
scanner = (ROOT / "src/librenet_scanner/scanner.py").read_text()
self.assertIn("progress_state = Signal(int, str)", scanner)
self.assertIn("self.progress_state.emit(-1, label)", scanner)
self.assertIn('self._set_progress(100, "Finalisation du scan…")', scanner)
self.assertTrue("start_percent=62, end_percent=96" in scanner or "start_percent=64, end_percent=96" in scanner)
def test_progress_bar_is_visible_and_readable(self):
ui = (ROOT / "src/librenet_scanner/ui.py").read_text()
self.assertIn("self.progress.setMinimumWidth(240)", ui)
self.assertIn("min-height: 16px", ui)
self.assertIn('self.progress.setFormat("%p%")', ui)
if __name__ == "__main__":
unittest.main()
+62
View File
@@ -0,0 +1,62 @@
import unittest
from pathlib import Path
from unittest.mock import patch
from librenet_scanner.intelligence import enrich_host
from librenet_scanner.models import Host
from librenet_scanner.network import (
NetworkInterface,
interface_mac_address,
normalize_interface_mac,
target_contains_ip,
)
ROOT = Path(__file__).resolve().parents[1]
class LocalHost046Tests(unittest.TestCase):
def test_mac_normalization(self):
self.assertEqual(normalize_interface_mac("aa:bb:cc:dd:ee:ff\n"), "AA:BB:CC:DD:EE:FF")
self.assertEqual(normalize_interface_mac("00:00:00:00:00:00"), "")
self.assertEqual(normalize_interface_mac("invalid"), "")
def test_target_contains_local_ip_for_cidr_and_range(self):
self.assertTrue(target_contains_ip("192.168.10.0/24", "192.168.10.1"))
self.assertTrue(target_contains_ip("192.168.10.1-254", "192.168.10.1"))
self.assertFalse(target_contains_ip("192.168.10.100-254", "192.168.10.1"))
self.assertFalse(target_contains_ip("192.168.5.0/24", "192.168.10.1"))
def test_sysfs_mac_is_preferred(self):
with patch("pathlib.Path.read_text", return_value="a6:2b:b0:a5:49:a7\n"):
self.assertEqual(interface_mac_address("enp42s0"), "A6:2B:B0:A5:49:A7")
def test_local_host_classification_has_priority(self):
host = Host("192.168.10.1", is_local=True)
enrich_host(host)
self.assertEqual(host.device_type, "Ce poste")
def test_local_flag_survives_merge(self):
current = Host("192.168.10.1", is_local=True, mac="AA:BB:CC:DD:EE:FF")
current.merge(Host("192.168.10.1", device_type="Serveur Linux"))
enrich_host(current)
self.assertTrue(current.is_local)
self.assertEqual(current.device_type, "Ce poste")
def test_interface_keeps_mac_without_breaking_old_positional_signature(self):
old_style = NetworkInterface("enp42s0", "192.168.10.1", 24, "192.168.10.0/24", False)
self.assertFalse(old_style.is_virtual)
self.assertEqual(old_style.mac, "")
new_style = NetworkInterface("enp42s0", "192.168.10.1", 24, "192.168.10.0/24", False, "AA:BB:CC:DD:EE:FF")
self.assertEqual(new_style.mac, "AA:BB:CC:DD:EE:FF")
def test_scanner_injects_local_host_additively(self):
scanner = (ROOT / "src/librenet_scanner/scanner.py").read_text()
block = scanner[scanner.index("def _discover_hosts"):scanner.index("def run(self)")]
self.assertIn("local_hosts = self._emit_local_host()", block)
self.assertIn("known_ips.update(union_host_ips(local_hosts))", block)
self.assertIn("interface_mac_address(iface.name)", scanner)
if __name__ == "__main__":
unittest.main()
+78
View File
@@ -0,0 +1,78 @@
import json
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from librenet_scanner.online_vendor import (
PROVIDER_MACLOOKUP,
PROVIDER_MACVENDORS,
is_locally_administered,
lookup_online_vendor,
normalize_mac,
)
from librenet_scanner.storage import HistoryStore
from librenet_scanner.parsers import parse_arp_scan
ROOT = Path(__file__).resolve().parents[1]
class OnlineVendor047Tests(unittest.TestCase):
def test_arp_scan_locally_administered_is_not_mistaken_for_vendor(self):
hosts = parse_arp_scan("192.168.10.250\ta6:2b:b0:a5:49:a7\t(Unknown: locally administered) (DUP: 2)")
self.assertEqual(len(hosts), 1)
self.assertEqual(hosts[0].vendor, "")
def test_mac_normalization_and_laa_detection(self):
self.assertEqual(normalize_mac("a6:2b:b0:a5:49:a7"), "A6:2B:B0:A5:49:A7")
self.assertTrue(is_locally_administered("a6:2b:b0:a5:49:a7"))
self.assertFalse(is_locally_administered("00:11:22:33:44:55"))
def test_maclookup_response_supports_modern_assignment_metadata(self):
payload = {
"success": True,
"found": True,
"company": "TP-Link Corporation Limited",
"blockType": "MA-M",
"isRand": False,
"isPrivate": False,
}
with patch("librenet_scanner.online_vendor._request", return_value=json.dumps(payload).encode()):
result = lookup_online_vendor("00:11:22:33:44:55", PROVIDER_MACLOOKUP)
self.assertTrue(result.found)
self.assertEqual(result.vendor, "TP-Link Corporation Limited")
self.assertEqual(result.block_type, "MA-M")
def test_macvendors_plain_text_response(self):
with patch("librenet_scanner.online_vendor._request", return_value=b"TP-Link Technologies Co., Ltd.\n"):
result = lookup_online_vendor("00:11:22:33:44:55", PROVIDER_MACVENDORS)
self.assertEqual(result.vendor, "TP-Link Technologies Co., Ltd.")
self.assertTrue(result.found)
def test_online_cache_roundtrip(self):
with tempfile.TemporaryDirectory() as tmp:
store = HistoryStore(Path(tmp) / "history.sqlite3")
store.save_online_vendor_cache(
"00:11:22:33:44:55",
PROVIDER_MACLOOKUP,
vendor="Example Vendor",
found=True,
block_type="MA-L",
)
cached = store.online_vendor_cache("00-11-22-33-44-55", PROVIDER_MACLOOKUP)
self.assertIsNotNone(cached)
self.assertEqual(cached["vendor"], "Example Vendor")
self.assertTrue(cached["from_cache"])
def test_ui_exposes_opt_in_privacy_setting_and_manual_lookup(self):
ui = (ROOT / "src/librenet_scanner/ui.py").read_text()
self.assertIn('privacy/onlineMacLookupEnabled', ui)
self.assertIn('Interroger automatiquement une base en ligne', ui)
self.assertIn('L\'option est désactivée par défaut', ui)
self.assertIn('Rechercher en ligne', ui)
self.assertIn('_start_automatic_online_vendor_lookup()', ui)
if __name__ == "__main__":
unittest.main()
+43
View File
@@ -0,0 +1,43 @@
import unittest
from pathlib import Path
from unittest.mock import patch
from librenet_scanner.privileged_helper import command_for
ROOT = Path(__file__).resolve().parents[1]
class DeepScan048RegressionTests(unittest.TestCase):
def test_privileged_deep_hosts_force_pn_and_scan_confirmed_ips(self):
with patch("librenet_scanner.privileged_helper._trusted_binary", return_value="/usr/bin/nmap"):
cmd = command_for("nmap-deep-hosts", ["192.168.10.1", "192.168.10.254"])
self.assertIn("-Pn", cmd)
self.assertIn("-sS", cmd)
self.assertIn("-sV", cmd)
self.assertIn("-O", cmd)
self.assertIn("1000", cmd)
self.assertEqual(cmd[-2:], ["192.168.10.1", "192.168.10.254"])
def test_privileged_deep_hosts_rejects_network_targets(self):
with patch("librenet_scanner.privileged_helper._trusted_binary", return_value="/usr/bin/nmap"):
with self.assertRaises(ValueError):
command_for("nmap-deep-hosts", ["192.168.10.0/24"])
def test_deep_profile_reuses_adaptive_standard_baseline(self):
scanner = (ROOT / "src/librenet_scanner/scanner.py").read_text()
start = scanner.index('elif profile == "Approfondi"')
end = scanner.index('else:\n raise RuntimeError', start)
block = scanner[start:end]
self.assertIn("self._standard_baseline(", block)
self.assertIn('privileged_command("nmap-deep-hosts", *ips)', block)
self.assertIn('"nmap", "-Pn", "-n", "-sT"', block)
self.assertIn('"--top-ports", "1000"', block)
self.assertNotIn('privileged_command("nmap-deep", target)', block)
def test_deep_profile_signature_tracks_current_pipeline(self):
scanner = (ROOT / "src/librenet_scanner/scanner.py").read_text()
self.assertIn('deep-v10:adaptive-standard-baseline-nmap-enrichment:', scanner)
if __name__ == "__main__":
unittest.main()
+313
View File
@@ -0,0 +1,313 @@
import sqlite3
import tempfile
import unittest
from pathlib import Path
from datetime import datetime, timedelta, timezone
from librenet_scanner.comparison import compare_hosts
from librenet_scanner.identity import (
is_known_virtual_mac,
mac_identity_kind,
shared_macs,
)
from librenet_scanner.models import Host, PortInfo
from librenet_scanner.parsers import parse_nmap_xml
from librenet_scanner.network import NetworkInterface, scan_identity_scope
from librenet_scanner.storage import HistoryStore
def ports(*values: int) -> list[PortInfo]:
return [PortInfo(port=value, service="test") for value in values]
class Identity049Tests(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.store = HistoryStore(Path(self.tmp.name) / "history.sqlite3")
def tearDown(self):
self.tmp.cleanup()
def test_dhcp_ip_reuse_with_new_mac_does_not_inherit_identification(self):
old = Host(
ip="192.168.10.50", mac="00:11:22:33:44:55", hostname="oldpc.local",
os_name="Windows 11", os_accuracy=100, ports=ports(135, 445),
)
self.store.remember_identifications([old], "Approfondi")
new = Host(
ip="192.168.10.50", mac="00:11:22:AA:BB:CC", hostname="newpc.local",
ports=ports(22, 80),
)
self.store.apply_identifications([new])
self.assertEqual(new.remembered_os_name, "")
self.assertEqual(new.remembered_device_type, "")
def test_global_mac_follows_dhcp_ip_change(self):
old = Host(
ip="192.168.10.50", mac="00:11:22:33:44:55", hostname="server.local",
os_name="Debian 13", os_accuracy=100, ports=ports(22, 80, 443),
)
self.store.remember_identifications([old], "Approfondi")
current = Host(
ip="192.168.10.73", mac="00:11:22:33:44:55", hostname="server.local",
ports=ports(22, 80),
)
self.store.apply_identifications([current])
self.assertEqual(current.remembered_os_name, "Debian 13")
self.assertGreaterEqual(current.remembered_match_score, 95)
def test_stable_laa_can_follow_ip_change_only_with_supporting_fingerprint(self):
old = Host(
ip="192.168.10.250", mac="A6:2B:B0:A5:49:A7",
os_name="OpenWrt 21.02 (Linux 5.4)", os_accuracy=98,
ports=ports(22, 53, 80, 443),
)
self.store.remember_identifications([old], "Approfondi")
current = Host(
ip="192.168.10.249", mac="A6:2B:B0:A5:49:A7",
ports=ports(22, 53, 80, 443),
)
self.store.apply_identifications([current])
self.assertEqual(current.remembered_os_name, "OpenWrt 21.02 (Linux 5.4)")
self.assertEqual(current.remembered_identity_kind, "laa")
self.assertGreaterEqual(current.remembered_match_score, 85)
def test_laa_alone_is_not_enough_after_ip_change(self):
old = Host(
ip="192.168.10.20", mac="A6:00:00:00:00:01",
os_name="Android", ports=[],
)
self.store.remember_identifications([old], "Approfondi")
current = Host(ip="192.168.10.21", mac="A6:00:00:00:00:01")
self.store.apply_identifications([current])
self.assertEqual(current.remembered_os_name, "")
def test_changed_randomized_laa_does_not_link_by_hostname(self):
old = Host(
ip="192.168.10.20", mac="A6:00:00:00:00:01", hostname="phone.local",
os_name="Linux", ports=ports(1234, 5678),
)
self.store.remember_identifications([old], "Approfondi")
current = Host(
ip="192.168.10.21", mac="B2:00:00:00:00:02", hostname="phone.local",
ports=ports(1234, 5678),
)
self.store.apply_identifications([current])
self.assertEqual(current.remembered_os_name, "")
def test_no_mac_never_inherits_from_mac_record_using_ip_only(self):
old = Host(
ip="192.168.20.5", mac="00:AA:BB:CC:DD:EE", hostname="router.local",
os_name="OpenWrt 24.10", ports=ports(22, 80),
)
self.store.remember_identifications([old], "Approfondi")
current = Host(ip="192.168.20.5", ports=ports(22, 80))
self.store.apply_identifications([current])
self.assertEqual(current.remembered_os_name, "")
def test_proxy_arp_or_shared_mac_is_scoped_per_ip(self):
mac = "00:11:22:33:44:55"
a = Host(ip="192.168.10.20", mac=mac, hostname="a.local", os_name="Debian 13", ports=ports(22, 80))
b = Host(ip="192.168.10.21", mac=mac, hostname="b.local", os_name="OpenWrt 24.10", ports=ports(22, 443))
self.assertEqual(shared_macs([a, b]), {mac})
self.store.remember_identifications([a, b], "Approfondi")
cur_a = Host(ip="192.168.10.20", mac=mac, hostname="a.local", ports=ports(22, 80))
cur_b = Host(ip="192.168.10.21", mac=mac, hostname="b.local", ports=ports(22, 443))
self.store.apply_identifications([cur_a, cur_b])
self.assertEqual(cur_a.remembered_os_name, "Debian 13")
self.assertEqual(cur_b.remembered_os_name, "OpenWrt 24.10")
self.assertEqual(cur_a.remembered_identity_kind, "shared")
def test_vrrp_carp_and_hsrp_macs_are_marked_virtual(self):
self.assertTrue(is_known_virtual_mac("00:00:5E:00:01:42"))
self.assertTrue(is_known_virtual_mac("00:00:5E:00:02:42"))
self.assertTrue(is_known_virtual_mac("00:00:0C:07:AC:01"))
self.assertTrue(is_known_virtual_mac("00:00:0C:9F:F1:23"))
self.assertEqual(mac_identity_kind("00:00:5E:00:01:42"), "virtual")
def test_virtual_mac_does_not_follow_to_another_ip(self):
old = Host(
ip="192.168.10.1", mac="00:00:5E:00:01:01", hostname="gateway.local",
os_name="OPNsense 26", ports=ports(53, 443),
)
self.store.remember_identifications([old], "Approfondi")
current = Host(
ip="192.168.10.2", mac="00:00:5E:00:01:01", hostname="gateway.local",
ports=ports(53, 443),
)
self.store.apply_identifications([current])
self.assertEqual(current.remembered_os_name, "")
def test_reinstall_or_upgrade_replaces_equally_strong_old_os(self):
host = Host(
ip="192.168.10.20", mac="00:11:22:33:44:55",
os_name="Debian 12", os_accuracy=100, ports=ports(22, 80),
)
self.store.remember_identifications([host], "Approfondi")
host.os_name = "Debian 13"
host.os_accuracy = 100
self.store.remember_identifications([host], "Approfondi")
current = Host(ip="192.168.10.20", mac=host.mac, ports=ports(22, 80))
self.store.apply_identifications([current])
self.assertEqual(current.remembered_os_name, "Debian 13")
def test_bond_or_bridge_mac_change_is_not_auto_merged_by_hostname(self):
old = Host(
ip="192.168.10.30", mac="00:11:22:33:44:55", hostname="node.local",
os_name="Debian 13", ports=ports(22, 8006),
)
self.store.remember_identifications([old], "Approfondi")
current = Host(
ip="192.168.10.30", mac="00:11:22:33:44:66", hostname="node.local",
ports=ports(22, 8006),
)
self.store.apply_identifications([current])
self.assertEqual(current.remembered_os_name, "")
def test_local_identity_survives_interface_mac_change(self):
old = Host(
ip="192.168.10.1", mac="00:11:22:33:44:55", is_local=True,
os_name="Debian 13", ports=ports(22),
)
self.store.remember_identifications([old], "Approfondi")
current = Host(ip="192.168.10.2", mac="00:11:22:AA:BB:CC", is_local=True)
self.store.apply_identifications([current])
self.assertEqual(current.remembered_os_name, "Debian 13")
self.assertEqual(current.remembered_identity_kind, "local")
def test_metadata_is_not_transferred_when_dhcp_reuses_ip_with_new_mac(self):
old = Host(ip="192.168.10.50", mac="00:11:22:33:44:55", hostname="old.local")
self.store.save_host_metadata(old, favorite=True, note="Ancienne machine")
new = Host(ip="192.168.10.50", mac="00:11:22:AA:BB:CC", hostname="new.local")
metadata = self.store.host_metadata(new)
self.assertFalse(metadata["favorite"])
self.assertEqual(metadata["note"], "")
def test_shared_mac_metadata_can_be_scoped_per_ip(self):
mac = "00:11:22:33:44:55"
a = Host(ip="192.168.10.20", mac=mac)
b = Host(ip="192.168.10.21", mac=mac)
self.store.save_host_metadata(a, note="A", shared_mac=True)
self.store.save_host_metadata(b, note="B", shared_mac=True)
self.assertEqual(self.store.host_metadata(a, shared_mac=True)["note"], "A")
self.assertEqual(self.store.host_metadata(b, shared_mac=True)["note"], "B")
def test_historically_shared_mac_remains_scoped_if_only_one_ip_answers_later(self):
mac = "00:11:22:33:44:55"
a = Host(ip="192.168.10.20", mac=mac, hostname="a.local", os_name="Debian 13", ports=ports(22, 80))
b = Host(ip="192.168.10.21", mac=mac, hostname="b.local", os_name="OpenWrt 24.10", ports=ports(22, 443))
self.store.remember_identifications([a, b], "Approfondi")
current = Host(ip="192.168.10.20", mac=mac, hostname="a.local", ports=ports(22, 80))
self.store.apply_identifications([current])
self.assertEqual(current.remembered_os_name, "Debian 13")
self.assertEqual(current.remembered_identity_kind, "shared")
def test_no_mac_even_hostname_ip_ports_is_never_auto_applied(self):
old = Host(ip="10.20.30.40", hostname="router.example", os_name="OpenWrt 24.10", ports=ports(22, 80, 443))
self.store.remember_identifications([old], "Approfondi")
current = Host(ip="10.20.30.40", hostname="router.example", os_name="Linux", ports=ports(22, 80, 443))
self.store.apply_identifications([current])
self.assertEqual(current.remembered_os_name, "")
def test_same_mac_in_different_network_scopes_does_not_cross_identify(self):
mac = "00:11:22:33:44:55"
lab = Host(ip="192.168.10.20", mac=mac, os_name="Debian 13", ports=ports(22))
prod = Host(ip="192.168.20.20", mac=mac, os_name="OpenWrt 24.10", ports=ports(22, 80))
self.store.remember_identifications([lab], "Approfondi", scope="ipv4:192.168.10.0/24")
self.store.remember_identifications([prod], "Approfondi", scope="ipv4:192.168.20.0/24")
cur_lab = Host(ip="192.168.10.33", mac=mac, ports=ports(22))
self.store.apply_identifications([cur_lab], scope="ipv4:192.168.10.0/24")
self.assertEqual(cur_lab.remembered_os_name, "Debian 13")
cur_prod = Host(ip="192.168.20.33", mac=mac, ports=ports(22, 80))
self.store.apply_identifications([cur_prod], scope="ipv4:192.168.20.0/24")
self.assertEqual(cur_prod.remembered_os_name, "OpenWrt 24.10")
def test_stale_global_mac_move_requires_current_corroboration(self):
mac = "00:11:22:33:44:55"
old = Host(ip="192.168.10.20", mac=mac, os_name="Debian 13", ports=ports(22, 443))
self.store.remember_identifications([old], "Approfondi")
stale = (datetime.now(timezone.utc) - timedelta(days=365)).astimezone().isoformat(timespec="seconds")
with self.store._connect() as conn:
conn.execute("UPDATE endpoint_identification SET os_seen_at = ?, updated_at = ?", (stale, stale))
# MAC seule, mais déplacement après un historique très ancien : prudence.
current = Host(ip="192.168.10.99", mac=mac)
self.store.apply_identifications([current])
self.assertEqual(current.remembered_os_name, "")
# La même MAC + une signature de services concordante suffit à revalider.
corroborated = Host(ip="192.168.10.99", mac=mac, ports=ports(22, 443))
self.store.apply_identifications([corroborated])
self.assertEqual(corroborated.remembered_os_name, "Debian 13")
class Comparison049Tests(unittest.TestCase):
def test_same_ip_new_mac_is_replaced_not_same_host(self):
previous = [Host(ip="192.168.10.50", mac="00:11:22:33:44:55", ports=ports(445))]
current = [Host(ip="192.168.10.50", mac="00:11:22:AA:BB:CC", ports=ports(22))]
result = compare_hosts(current, previous)
self.assertEqual(result[0].change_status, "MAC modifiée")
self.assertIn("MAC différente", result[0].change_detail)
def test_duplicate_mac_does_not_create_false_ip_move(self):
mac = "00:11:22:33:44:55"
previous = [
Host(ip="192.168.10.20", mac=mac),
Host(ip="192.168.10.21", mac=mac),
]
current = [Host(ip="192.168.10.22", mac=mac)]
result = compare_hosts(current, previous)
self.assertEqual(result[0].change_status, "Nouveau")
def test_laa_same_mac_can_track_ip_between_consecutive_scans(self):
mac = "A6:2B:B0:A5:49:A7"
previous = [Host(ip="192.168.10.20", mac=mac)]
current = [Host(ip="192.168.10.21", mac=mac)]
result = compare_hosts(current, previous)
self.assertEqual(result[0].change_status, "IP modifiée")
def test_rotated_laa_on_same_ip_is_identity_uncertain(self):
previous = [Host(ip="192.168.10.20", mac="A6:00:00:00:00:01")]
current = [Host(ip="192.168.10.20", mac="B2:00:00:00:00:02")]
result = compare_hosts(current, previous)
self.assertEqual(result[0].change_status, "Identité incertaine")
def test_nmap_accuracy_is_not_artificially_inflated(self):
self.assertEqual(HistoryStore._os_quality("OpenWrt 24.10", "Approfondi", 82), 82)
class NetworkScope049Tests(unittest.TestCase):
def test_local_target_uses_interface_network_as_scope(self):
iface = NetworkInterface("eth0", "192.168.10.15", 24, "192.168.10.0/24")
self.assertEqual(
scan_identity_scope("192.168.10.1-254", iface),
"ipv4:192.168.10.0/24",
)
def test_routed_range_gets_its_own_24_scope(self):
iface = NetworkInterface("eth0", "192.168.10.15", 24, "192.168.10.0/24")
self.assertEqual(
scan_identity_scope("192.168.5.1-254", iface),
"ipv4:192.168.5.0/24",
)
class Parser049Tests(unittest.TestCase):
def test_nmap_os_accuracy_is_kept(self):
xml = """<nmaprun><host><status state='up'/><address addr='192.168.1.2' addrtype='ipv4'/>
<os><osmatch name='OpenWrt 24.10' accuracy='97'/></os></host></nmaprun>"""
host = parse_nmap_xml(xml)[0]
self.assertEqual(host.os_accuracy, 97)
if __name__ == "__main__":
unittest.main()