From 1a7ed8ef9417129a0915e62f5eccd9d852f0d9db Mon Sep 17 00:00:00 2001 From: Olivier Date: Sun, 23 Aug 2026 09:52:32 +0200 Subject: [PATCH] first commit --- CHANGELOG.md | 33 + LICENSE | 674 ++++++ NAABU-LICENSE.txt | 21 + README.md | 308 +++ THIRD_PARTY_ASSETS.md | 47 + assets/badges/license.svg | 12 + assets/badges/platform.svg | 12 + assets/badges/status.svg | 12 + assets/badges/tests.svg | 12 + assets/badges/ui.svg | 12 + assets/badges/version.svg | 12 + assets/icons/equipment-access-point.svg | 1 + assets/icons/equipment-firewall.svg | 1 + assets/icons/equipment-hypervisor.svg | 1 + assets/icons/equipment-nas.svg | 1 + assets/icons/equipment-network-device.svg | 1 + assets/icons/equipment-printer.svg | 1 + assets/icons/equipment-router.svg | 1 + assets/icons/equipment-server.svg | 1 + assets/icons/equipment-switch.svg | 1 + assets/icons/equipment-unknown.svg | 1 + assets/icons/equipment-workstation.svg | 1 + assets/icons/os-android.svg | 1 + assets/icons/os-apple.svg | 1 + assets/icons/os-bsd.svg | 1 + assets/icons/os-linux.svg | 1 + assets/icons/os-other.svg | 1 + assets/icons/os-windows.svg | 1 + assets/librenet-scanner.desktop | 12 + assets/librenet-scanner.svg | 10 + install-debian13.sh | 9 + packaging/build-deb.sh | 56 + packaging/debian/control | 14 + packaging/debian/postinst | 5 + packaging/debian/postrm | 12 + packaging/librenet-scanner-helper | 4 + packaging/librenet-scanner-install-naabu | 4 + packaging/org.librenet.scanner.policy | 20 + pyproject.toml | 21 + run-from-source.sh | 5 + src/librenet_scanner/__init__.py | 3 + src/librenet_scanner/__main__.py | 3 + src/librenet_scanner/actions.py | 23 + src/librenet_scanner/comparison.py | 160 ++ src/librenet_scanner/diagnostics.py | 68 + src/librenet_scanner/exporters.py | 66 + src/librenet_scanner/fastscan.py | 142 ++ src/librenet_scanner/identity.py | 405 ++++ src/librenet_scanner/intelligence.py | 125 ++ src/librenet_scanner/main.py | 58 + src/librenet_scanner/models.py | 209 ++ src/librenet_scanner/naabu_runtime.py | 236 ++ src/librenet_scanner/network.py | 293 +++ src/librenet_scanner/online_vendor.py | 126 ++ src/librenet_scanner/parsers.py | 144 ++ src/librenet_scanner/privileged_helper.py | 260 +++ src/librenet_scanner/privileges.py | 59 + src/librenet_scanner/scan_logic.py | 14 + src/librenet_scanner/scanner.py | 989 +++++++++ src/librenet_scanner/storage.py | 739 +++++++ src/librenet_scanner/ui.py | 2442 +++++++++++++++++++++ src/librenet_scanner/ui_icons.py | 83 + src/librenet_scanner/ui_layout.py | 27 + src/librenet_scanner/vendors.py | 73 + src/librenet_scanner/visual_identity.py | 126 ++ tests/test_parsers.py | 52 + tests/test_release_100.py | 40 + tests/test_v02.py | 127 ++ tests/test_v021.py | 65 + tests/test_v022.py | 42 + tests/test_v03.py | 62 + tests/test_v041.py | 26 + tests/test_v0410.py | 45 + tests/test_v0411.py | 32 + tests/test_v0412.py | 69 + tests/test_v0413.py | 62 + tests/test_v0414.py | 45 + tests/test_v0415.py | 50 + tests/test_v0416.py | 164 ++ tests/test_v0417.py | 27 + tests/test_v0418.py | 65 + tests/test_v0419.py | 44 + tests/test_v042.py | 23 + tests/test_v0420.py | 61 + tests/test_v0421.py | 131 ++ tests/test_v0422.py | 262 +++ tests/test_v0423.py | 190 ++ tests/test_v043.py | 29 + tests/test_v044.py | 24 + tests/test_v045.py | 39 + tests/test_v046.py | 62 + tests/test_v047.py | 78 + tests/test_v048.py | 43 + tests/test_v049.py | 313 +++ 94 files changed, 10489 insertions(+) create mode 100644 CHANGELOG.md create mode 100644 LICENSE create mode 100644 NAABU-LICENSE.txt create mode 100644 README.md create mode 100644 THIRD_PARTY_ASSETS.md create mode 100644 assets/badges/license.svg create mode 100644 assets/badges/platform.svg create mode 100644 assets/badges/status.svg create mode 100644 assets/badges/tests.svg create mode 100644 assets/badges/ui.svg create mode 100644 assets/badges/version.svg create mode 100644 assets/icons/equipment-access-point.svg create mode 100644 assets/icons/equipment-firewall.svg create mode 100644 assets/icons/equipment-hypervisor.svg create mode 100644 assets/icons/equipment-nas.svg create mode 100644 assets/icons/equipment-network-device.svg create mode 100644 assets/icons/equipment-printer.svg create mode 100644 assets/icons/equipment-router.svg create mode 100644 assets/icons/equipment-server.svg create mode 100644 assets/icons/equipment-switch.svg create mode 100644 assets/icons/equipment-unknown.svg create mode 100644 assets/icons/equipment-workstation.svg create mode 100755 assets/icons/os-android.svg create mode 100755 assets/icons/os-apple.svg create mode 100644 assets/icons/os-bsd.svg create mode 100644 assets/icons/os-linux.svg create mode 100644 assets/icons/os-other.svg create mode 100644 assets/icons/os-windows.svg create mode 100644 assets/librenet-scanner.desktop create mode 100644 assets/librenet-scanner.svg create mode 100755 install-debian13.sh create mode 100755 packaging/build-deb.sh create mode 100644 packaging/debian/control create mode 100755 packaging/debian/postinst create mode 100755 packaging/debian/postrm create mode 100755 packaging/librenet-scanner-helper create mode 100755 packaging/librenet-scanner-install-naabu create mode 100644 packaging/org.librenet.scanner.policy create mode 100644 pyproject.toml create mode 100755 run-from-source.sh create mode 100644 src/librenet_scanner/__init__.py create mode 100644 src/librenet_scanner/__main__.py create mode 100644 src/librenet_scanner/actions.py create mode 100644 src/librenet_scanner/comparison.py create mode 100644 src/librenet_scanner/diagnostics.py create mode 100644 src/librenet_scanner/exporters.py create mode 100644 src/librenet_scanner/fastscan.py create mode 100644 src/librenet_scanner/identity.py create mode 100644 src/librenet_scanner/intelligence.py create mode 100644 src/librenet_scanner/main.py create mode 100644 src/librenet_scanner/models.py create mode 100644 src/librenet_scanner/naabu_runtime.py create mode 100644 src/librenet_scanner/network.py create mode 100644 src/librenet_scanner/online_vendor.py create mode 100644 src/librenet_scanner/parsers.py create mode 100644 src/librenet_scanner/privileged_helper.py create mode 100644 src/librenet_scanner/privileges.py create mode 100644 src/librenet_scanner/scan_logic.py create mode 100644 src/librenet_scanner/scanner.py create mode 100644 src/librenet_scanner/storage.py create mode 100644 src/librenet_scanner/ui.py create mode 100644 src/librenet_scanner/ui_icons.py create mode 100644 src/librenet_scanner/ui_layout.py create mode 100644 src/librenet_scanner/vendors.py create mode 100644 src/librenet_scanner/visual_identity.py create mode 100644 tests/test_parsers.py create mode 100644 tests/test_release_100.py create mode 100644 tests/test_v02.py create mode 100644 tests/test_v021.py create mode 100644 tests/test_v022.py create mode 100644 tests/test_v03.py create mode 100644 tests/test_v041.py create mode 100644 tests/test_v0410.py create mode 100644 tests/test_v0411.py create mode 100644 tests/test_v0412.py create mode 100644 tests/test_v0413.py create mode 100644 tests/test_v0414.py create mode 100644 tests/test_v0415.py create mode 100644 tests/test_v0416.py create mode 100644 tests/test_v0417.py create mode 100644 tests/test_v0418.py create mode 100644 tests/test_v0419.py create mode 100644 tests/test_v042.py create mode 100644 tests/test_v0420.py create mode 100644 tests/test_v0421.py create mode 100644 tests/test_v0422.py create mode 100644 tests/test_v0423.py create mode 100644 tests/test_v043.py create mode 100644 tests/test_v044.py create mode 100644 tests/test_v045.py create mode 100644 tests/test_v046.py create mode 100644 tests/test_v047.py create mode 100644 tests/test_v048.py create mode 100644 tests/test_v049.py diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..c54b692 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,33 @@ +# LibreNet Scanner — Release notes + +## 1.0.0 — Stable + +LibreNet Scanner 1.0.0 constitue la version stable de référence. + +### 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. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f288702 --- /dev/null +++ b/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/NAABU-LICENSE.txt b/NAABU-LICENSE.txt new file mode 100644 index 0000000..b22968b --- /dev/null +++ b/NAABU-LICENSE.txt @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..c69b475 --- /dev/null +++ b/README.md @@ -0,0 +1,308 @@ +

+ Logo LibreNet Scanner +

+ +

LibreNet Scanner

+ +

+ Scanner réseau graphique libre, rapide et orienté équipements pour Linux.
+ Découverte, inventaire, services, identification et diagnostic dans une interface Qt pensée pour KDE Plasma. +

+ +

+ Version 1.0.0 + Release stable + Debian 13 + Qt 6 KDE + GPLv3+ + 187 tests OK +

+ +

+ Poste +    + Serveur +    + Switch +    + Routeur +    + Point d'accès +    + Imprimante +

+ +> **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. + +--- + +## ✨ 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_1.0.0_amd64.deb +``` + +Puis lance LibreNet Scanner depuis le menu KDE ou avec : + +```bash +librenet-scanner +``` + +Le paquet cible **Debian 13 (Trixie) amd64** et installe les dépendances principales via APT. + +### Exécution depuis les sources + +```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 +- iputils-ping +- xdg-utils +- pkexec / Polkit + +### 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 + +## ⌨️ Raccourcis utiles + +| Raccourci | Action | +|---|---| +| `F5` | Scan Standard | +| `Ctrl+F5` | Scan Rapide | +| `Shift+F5` | Scan Approfondi | +| `Ctrl+F` | Recherche | + +## 🔐 Sécurité + +- 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. + +> Utilise LibreNet Scanner uniquement sur des réseaux que tu possèdes ou que tu es autorisé à analyser. + +## ✅ Validation de la release stable + +La base 1.0.0 est couverte par **191 tests automatisés** portant notamment sur : + +- 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. + +Lancer la suite : + +```bash +PYTHONPATH=src python3 -m unittest discover -s tests -v +``` + +## 📁 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). + +--- + +

+ LibreNet Scanner
+ LibreNet Scanner 1.0.0 — Stable
+ Voir le réseau. Comprendre les équipements. Garder le contrôle. +

diff --git a/THIRD_PARTY_ASSETS.md b/THIRD_PARTY_ASSETS.md new file mode 100644 index 0000000..9e9edd1 --- /dev/null +++ b/THIRD_PARTY_ASSETS.md @@ -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 d’OS 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 d’accè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 **n’embarque 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 d’introduire 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`. À l’exécution, LibreNet n’utilise 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`. diff --git a/assets/badges/license.svg b/assets/badges/license.svg new file mode 100644 index 0000000..564211f --- /dev/null +++ b/assets/badges/license.svg @@ -0,0 +1,12 @@ + + license: GPLv3+ + + + + + + + license + GPLv3+ + + \ No newline at end of file diff --git a/assets/badges/platform.svg b/assets/badges/platform.svg new file mode 100644 index 0000000..284fdfd --- /dev/null +++ b/assets/badges/platform.svg @@ -0,0 +1,12 @@ + + platform: Debian 13 + + + + + + + platform + Debian 13 + + \ No newline at end of file diff --git a/assets/badges/status.svg b/assets/badges/status.svg new file mode 100644 index 0000000..7ae9019 --- /dev/null +++ b/assets/badges/status.svg @@ -0,0 +1,12 @@ + + release: stable + + + + + + + release + stable + + \ No newline at end of file diff --git a/assets/badges/tests.svg b/assets/badges/tests.svg new file mode 100644 index 0000000..9bb25a5 --- /dev/null +++ b/assets/badges/tests.svg @@ -0,0 +1,12 @@ + + tests: 191 OK + + + + + + + tests + 191 OK + + \ No newline at end of file diff --git a/assets/badges/ui.svg b/assets/badges/ui.svg new file mode 100644 index 0000000..5e55260 --- /dev/null +++ b/assets/badges/ui.svg @@ -0,0 +1,12 @@ + + interface: Qt 6 / KDE + + + + + + + interface + Qt 6 / KDE + + \ No newline at end of file diff --git a/assets/badges/version.svg b/assets/badges/version.svg new file mode 100644 index 0000000..07a89d3 --- /dev/null +++ b/assets/badges/version.svg @@ -0,0 +1,12 @@ + + version: 1.0.0 + + + + + + + version + 1.0.0 + + \ No newline at end of file diff --git a/assets/icons/equipment-access-point.svg b/assets/icons/equipment-access-point.svg new file mode 100644 index 0000000..98affee --- /dev/null +++ b/assets/icons/equipment-access-point.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/equipment-firewall.svg b/assets/icons/equipment-firewall.svg new file mode 100644 index 0000000..1f94ade --- /dev/null +++ b/assets/icons/equipment-firewall.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/equipment-hypervisor.svg b/assets/icons/equipment-hypervisor.svg new file mode 100644 index 0000000..8637395 --- /dev/null +++ b/assets/icons/equipment-hypervisor.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/equipment-nas.svg b/assets/icons/equipment-nas.svg new file mode 100644 index 0000000..8be9b02 --- /dev/null +++ b/assets/icons/equipment-nas.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/equipment-network-device.svg b/assets/icons/equipment-network-device.svg new file mode 100644 index 0000000..9b815ac --- /dev/null +++ b/assets/icons/equipment-network-device.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/equipment-printer.svg b/assets/icons/equipment-printer.svg new file mode 100644 index 0000000..99f2692 --- /dev/null +++ b/assets/icons/equipment-printer.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/equipment-router.svg b/assets/icons/equipment-router.svg new file mode 100644 index 0000000..1831328 --- /dev/null +++ b/assets/icons/equipment-router.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/equipment-server.svg b/assets/icons/equipment-server.svg new file mode 100644 index 0000000..1efbdb7 --- /dev/null +++ b/assets/icons/equipment-server.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/equipment-switch.svg b/assets/icons/equipment-switch.svg new file mode 100644 index 0000000..00e0933 --- /dev/null +++ b/assets/icons/equipment-switch.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/equipment-unknown.svg b/assets/icons/equipment-unknown.svg new file mode 100644 index 0000000..848f653 --- /dev/null +++ b/assets/icons/equipment-unknown.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/equipment-workstation.svg b/assets/icons/equipment-workstation.svg new file mode 100644 index 0000000..b3b105f --- /dev/null +++ b/assets/icons/equipment-workstation.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/os-android.svg b/assets/icons/os-android.svg new file mode 100755 index 0000000..d314310 --- /dev/null +++ b/assets/icons/os-android.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/icons/os-apple.svg b/assets/icons/os-apple.svg new file mode 100755 index 0000000..053904f --- /dev/null +++ b/assets/icons/os-apple.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/icons/os-bsd.svg b/assets/icons/os-bsd.svg new file mode 100644 index 0000000..79ac697 --- /dev/null +++ b/assets/icons/os-bsd.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/os-linux.svg b/assets/icons/os-linux.svg new file mode 100644 index 0000000..15ebddd --- /dev/null +++ b/assets/icons/os-linux.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/os-other.svg b/assets/icons/os-other.svg new file mode 100644 index 0000000..99025a3 --- /dev/null +++ b/assets/icons/os-other.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/os-windows.svg b/assets/icons/os-windows.svg new file mode 100644 index 0000000..de1b735 --- /dev/null +++ b/assets/icons/os-windows.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/librenet-scanner.desktop b/assets/librenet-scanner.desktop new file mode 100644 index 0000000..04b6e6e --- /dev/null +++ b/assets/librenet-scanner.desktop @@ -0,0 +1,12 @@ +[Desktop Entry] +Type=Application +Name=LibreNet Scanner +GenericName=Scanner réseau +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;naabu;arp;ip;lan;inventory; +StartupNotify=true +StartupWMClass=librenet-scanner diff --git a/assets/librenet-scanner.svg b/assets/librenet-scanner.svg new file mode 100644 index 0000000..0e41fe0 --- /dev/null +++ b/assets/librenet-scanner.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/install-debian13.sh b/install-debian13.sh new file mode 100755 index 0000000..68722d2 --- /dev/null +++ b/install-debian13.sh @@ -0,0 +1,9 @@ +#!/bin/sh +set -eu +HERE=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +DEB="$HERE/dist/librenet-scanner_1.0.0_amd64.deb" +if [ ! -f "$DEB" ]; then + echo "Paquet .deb absent. Construction..." + "$HERE/packaging/build-deb.sh" +fi +exec sudo apt install "$DEB" diff --git a/packaging/build-deb.sh b/packaging/build-deb.sh new file mode 100755 index 0000000..e243d5c --- /dev/null +++ b/packaging/build-deb.sh @@ -0,0 +1,56 @@ +#!/bin/sh +set -eu +ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +VERSION=1.0.0 +ARCH=amd64 +PKGROOT="/tmp/librenet-scanner-debroot-$$" +trap 'rm -rf "$PKGROOT"' EXIT HUP INT TERM +OUT="$ROOT/dist" +rm -rf "$PKGROOT" +mkdir -p "$PKGROOT/DEBIAN" \ + "$PKGROOT/usr/lib/python3/dist-packages/librenet_scanner" \ + "$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 +exec python3 -m librenet_scanner "$@" +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/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" diff --git a/packaging/debian/control b/packaging/debian/control new file mode 100644 index 0000000..67feb15 --- /dev/null +++ b/packaging/debian/control @@ -0,0 +1,14 @@ +Package: librenet-scanner +Version: 1.0.0 +Section: net +Priority: optional +Architecture: amd64 +Maintainer: Local package +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. diff --git a/packaging/debian/postinst b/packaging/debian/postinst new file mode 100755 index 0000000..3867fd2 --- /dev/null +++ b/packaging/debian/postinst @@ -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 diff --git a/packaging/debian/postrm b/packaging/debian/postrm new file mode 100755 index 0000000..bec2010 --- /dev/null +++ b/packaging/debian/postrm @@ -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 diff --git a/packaging/librenet-scanner-helper b/packaging/librenet-scanner-helper new file mode 100755 index 0000000..68becaa --- /dev/null +++ b/packaging/librenet-scanner-helper @@ -0,0 +1,4 @@ +#!/usr/bin/python3 +from librenet_scanner.privileged_helper import main + +raise SystemExit(main()) diff --git a/packaging/librenet-scanner-install-naabu b/packaging/librenet-scanner-install-naabu new file mode 100755 index 0000000..0901be1 --- /dev/null +++ b/packaging/librenet-scanner-install-naabu @@ -0,0 +1,4 @@ +#!/usr/bin/python3 +from librenet_scanner.naabu_runtime import main + +raise SystemExit(main()) diff --git a/packaging/org.librenet.scanner.policy b/packaging/org.librenet.scanner.policy new file mode 100644 index 0000000..25c582b --- /dev/null +++ b/packaging/org.librenet.scanner.policy @@ -0,0 +1,20 @@ + + + + LibreNet Scanner + + Exécuter un scan réseau privilégié avec LibreNet Scanner + Exécuter un scan réseau privilégié avec LibreNet Scanner + Authentication is required to enable LibreNet Scanner administrator mode + Authentification requise pour activer le mode administrateur de LibreNet Scanner + + no + no + auth_admin_keep + + /usr/libexec/librenet-scanner-helper + false + + diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..7d88c88 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,21 @@ +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[project] +name = "librenet-scanner" +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"} +authors = [{name = "LibreNet Scanner contributors"}] + +[project.scripts] +librenet-scanner = "librenet_scanner.main:main" + +[tool.setuptools] +package-dir = {"" = "src"} + +[tool.setuptools.packages.find] +where = ["src"] diff --git a/run-from-source.sh b/run-from-source.sh new file mode 100755 index 0000000..0c7c386 --- /dev/null +++ b/run-from-source.sh @@ -0,0 +1,5 @@ +#!/bin/sh +set -eu +HERE=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +export PYTHONPATH="$HERE/src${PYTHONPATH:+:$PYTHONPATH}" +exec python3 -m librenet_scanner "$@" diff --git a/src/librenet_scanner/__init__.py b/src/librenet_scanner/__init__.py new file mode 100644 index 0000000..db388da --- /dev/null +++ b/src/librenet_scanner/__init__.py @@ -0,0 +1,3 @@ +"""LibreNet Scanner - scanner réseau graphique libre pour Linux.""" + +__version__ = "1.0.0" diff --git a/src/librenet_scanner/__main__.py b/src/librenet_scanner/__main__.py new file mode 100644 index 0000000..774cc25 --- /dev/null +++ b/src/librenet_scanner/__main__.py @@ -0,0 +1,3 @@ +from .main import main + +raise SystemExit(main()) diff --git a/src/librenet_scanner/actions.py b/src/librenet_scanner/actions.py new file mode 100644 index 0000000..e078707 --- /dev/null +++ b/src/librenet_scanner/actions.py @@ -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)) diff --git a/src/librenet_scanner/comparison.py b/src/librenet_scanner/comparison.py new file mode 100644 index 0000000..9b606a6 --- /dev/null +++ b/src/librenet_scanner/comparison.py @@ -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 diff --git a/src/librenet_scanner/diagnostics.py b/src/librenet_scanner/diagnostics.py new file mode 100644 index 0000000..21b9293 --- /dev/null +++ b/src/librenet_scanner/diagnostics.py @@ -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) diff --git a/src/librenet_scanner/exporters.py b/src/librenet_scanner/exporters.py new file mode 100644 index 0000000..74fbaa3 --- /dev/null +++ b/src/librenet_scanner/exporters.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +import csv +import json +from pathlib import Path + +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", "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, + host.os_name, + "" if host.latency_ms is None else f"{host.latency_ms:.2f}", + host.last_seen, + ]) + + +def export_json(path: str | Path, hosts: list[Host]) -> None: + payload = [] + 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, + "latency_ms": host.latency_ms, + "last_seen": host.last_seen, + "ports": [ + { + "port": p.port, + "protocol": p.protocol, + "state": p.state, + "service": p.service, + "product": p.product, + "version": p.version, + } + for p in host.ports + ], + }) + with open(path, "w", encoding="utf-8") as handle: + json.dump(payload, handle, ensure_ascii=False, indent=2) diff --git a/src/librenet_scanner/fastscan.py b/src/librenet_scanner/fastscan.py new file mode 100644 index 0000000..02d9cab --- /dev/null +++ b/src/librenet_scanner/fastscan.py @@ -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) diff --git a/src/librenet_scanner/identity.py b/src/librenet_scanner/identity.py new file mode 100644 index 0000000..0def851 --- /dev/null +++ b/src/librenet_scanner/identity.py @@ -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) diff --git a/src/librenet_scanner/intelligence.py b/src/librenet_scanner/intelligence.py new file mode 100644 index 0000000..9b7040a --- /dev/null +++ b/src/librenet_scanner/intelligence.py @@ -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", +} diff --git a/src/librenet_scanner/main.py b/src/librenet_scanner/main.py new file mode 100644 index 0000000..212b97d --- /dev/null +++ b/src/librenet_scanner/main.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +import sys +from pathlib import Path + +from PySide6.QtGui import QIcon +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") + + # 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() + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/librenet_scanner/models.py b/src/librenet_scanner/models.py new file mode 100644 index 0000000..6ebd95f --- /dev/null +++ b/src/librenet_scanner/models.py @@ -0,0 +1,209 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime, timezone + + +@dataclass(slots=True) +class PortInfo: + port: int + protocol: str = "tcp" + state: str = "open" + service: str = "" + product: str = "" + version: str = "" + + @property + def label(self) -> str: + base = f"{self.port}/{self.protocol}" + if self.service: + base += f" ({self.service})" + return base + + @property + def details(self) -> str: + parts = [self.label] + product = " ".join(part for part in (self.product, self.version) if part).strip() + if product: + parts.append(product) + return " — ".join(parts) + + +@dataclass(slots=True) +class Host: + ip: str + hostname: str = "" + mac: str = "" + vendor: str = "" + status: str = "up" + os_name: str = "" + os_accuracy: int | None = None + latency_ms: float | None = None + ports: list[PortInfo] = field(default_factory=list) + last_seen: str = field(default_factory=lambda: datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds")) + device_type: str = "Hôte" + change_status: str = "" + change_detail: str = "" + previous_ip: str = "" + is_local: bool = False + + # V0.4.9 : la meilleure identification connue est volontairement séparée + # des données du scan courant. Ainsi un scan Standard peut afficher un OS + # découvert précédemment en Approfondi sans prétendre l'avoir redétecté. + remembered_os_name: str = "" + remembered_os_accuracy: int | None = None + remembered_device_type: str = "" + remembered_os_source: str = "" + remembered_type_source: str = "" + remembered_os_seen_at: str = "" + remembered_type_seen_at: str = "" + remembered_match_score: int = 0 + remembered_match_reason: str = "" + remembered_identity_kind: str = "" + + def merge(self, other: "Host") -> "Host": + if other.hostname: + self.hostname = other.hostname + if other.mac: + self.mac = other.mac.upper() + if other.vendor: + self.vendor = other.vendor + if other.os_name: + self.os_name = other.os_name + if other.os_accuracy is not None: + self.os_accuracy = other.os_accuracy + if other.latency_ms is not None: + self.latency_ms = other.latency_ms + if other.ports: + known = {(p.port, p.protocol): p for p in self.ports} + for port in other.ports: + known[(port.port, port.protocol)] = port + self.ports = sorted(known.values(), key=lambda p: (p.protocol, p.port)) + if other.device_type and other.device_type != "Hôte": + self.device_type = other.device_type + self.is_local = self.is_local or other.is_local + self.status = other.status or self.status + self.last_seen = other.last_seen or self.last_seen + + # Les informations mémorisées peuvent arriver avant ou après les données + # Nmap du scan courant. On les fusionne sans jamais écraser une valeur déjà + # plus complète portée par l'objet courant. + if other.remembered_os_name: + self.remembered_os_name = other.remembered_os_name + self.remembered_os_accuracy = other.remembered_os_accuracy + self.remembered_os_source = other.remembered_os_source + self.remembered_os_seen_at = other.remembered_os_seen_at + if other.remembered_device_type: + self.remembered_device_type = other.remembered_device_type + self.remembered_type_source = other.remembered_type_source + self.remembered_type_seen_at = other.remembered_type_seen_at + if other.remembered_match_score: + self.remembered_match_score = other.remembered_match_score + self.remembered_match_reason = other.remembered_match_reason + self.remembered_identity_kind = other.remembered_identity_kind + return self + + @staticmethod + def _os_family(value: str) -> str: + folded = (value or "").casefold() + if "android" in folded: + return "android" + if any(token in folded for token in ( + "macos", "mac os x", "darwin", "iphone os", "apple ios", + "ipados", "apple tv", "tvos", + )): + return "apple" + for token, family in ( + ("openwrt", "linux"), ("proxmox", "linux"), ("synology", "linux"), + ("debian", "linux"), ("ubuntu", "linux"), ("fedora", "linux"), + ("centos", "linux"), ("red hat", "linux"), ("linux", "linux"), + ("opnsense", "freebsd"), ("pfsense", "freebsd"), ("freebsd", "freebsd"), + ("openbsd", "openbsd"), ("netbsd", "netbsd"), ("windows", "windows"), + ("routeros", "routeros"), ("vmware", "vmware"), ("esxi", "vmware"), + ("fortios", "fortios"), ("junos", "junos"), ("ios xe", "iosxe"), + ("cisco ios", "ios"), + ): + if token in folded: + return family + return "" + + @property + def effective_os_name(self) -> str: + """OS affiché : le courant gagne en cas de contradiction, sinon le plus précis.""" + current = (self.os_name or "").strip() + remembered = (self.remembered_os_name or "").strip() + if not current: + return remembered + if not remembered: + return current + current_family = self._os_family(current) + remembered_family = self._os_family(remembered) + if current_family and remembered_family and current_family != remembered_family: + return current + # Un scan Standard peut seulement redonner "Linux" alors qu'un ancien + # Approfondi avait identifié OpenWrt/Debian/etc. Dans la même famille, on + # garde la description la plus informative. + if remembered_family and current_family == remembered_family and len(remembered) > len(current): + return remembered + return current + + @property + def effective_device_type(self) -> str: + """Type à afficher sans laisser un scan léger dégrader une identification riche.""" + if self.is_local: + return "Ce poste" + if self.remembered_device_type and self.device_type in { + "", "Hôte", "Hôte Linux", "Serveur SSH", "Serveur / appliance", "Équipement réseau", "Appliance Web" + }: + return self.remembered_device_type + return self.device_type or self.remembered_device_type or "Hôte" + + @property + def os_is_estimated(self) -> bool: + """True quand Nmap a fourni une correspondance OS non exacte (< 100 %).""" + return bool(self.os_name and self.os_accuracy is not None and self.os_accuracy < 100) + + @property + def effective_os_accuracy(self) -> int | None: + if self.os_is_remembered: + return self.remembered_os_accuracy + return self.os_accuracy + + @property + def os_is_remembered(self) -> bool: + return bool(self.remembered_os_name and self.effective_os_name == self.remembered_os_name and self.os_name != self.remembered_os_name) + + @property + def type_is_remembered(self) -> bool: + return bool( + self.remembered_device_type + and self.effective_device_type == self.remembered_device_type + and self.device_type != self.remembered_device_type + ) + + @property + def ports_summary(self) -> str: + return ", ".join(p.label for p in self.ports if p.state == "open") + + @property + def ports_details(self) -> str: + return "\n".join(p.details for p in self.ports if p.state == "open") + + @property + def searchable_text(self) -> str: + values = ( + self.status, + self.change_status, + self.change_detail, + self.device_type, + self.effective_device_type, + self.hostname, + self.ip, + self.previous_ip, + self.mac, + self.vendor, + self.ports_summary, + self.os_name, + self.effective_os_name, + ) + return " ".join(v for v in values if v).casefold() diff --git a/src/librenet_scanner/naabu_runtime.py b/src/librenet_scanner/naabu_runtime.py new file mode 100644 index 0000000..886840f --- /dev/null +++ b/src/librenet_scanner/naabu_runtime.py @@ -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()) diff --git a/src/librenet_scanner/network.py b/src/librenet_scanner/network.py new file mode 100644 index 0000000..dc4eafe --- /dev/null +++ b/src/librenet_scanner/network.py @@ -0,0 +1,293 @@ +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: + name: str + address: str + prefixlen: int + network: str + is_virtual: bool = False + mac: str = "" + + @property + def label(self) -> str: + suffix = " (virtuelle)" if self.is_virtual else "" + 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: + 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} ({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]: + try: + proc = subprocess.run( + ["ip", "-j", "-4", "addr", "show", "up"], + check=True, + capture_output=True, + text=True, + timeout=5, + ) + payload = json.loads(proc.stdout) + except (OSError, subprocess.SubprocessError, json.JSONDecodeError): + 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 + local = addr.get("local") + prefixlen = int(addr.get("prefixlen", 32)) + if not local: + continue + network = str(ipaddress.ip_network(f"{local}/{prefixlen}", strict=False)) + 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: + return False diff --git a/src/librenet_scanner/online_vendor.py b/src/librenet_scanner/online_vendor.py new file mode 100644 index 0000000..337924a --- /dev/null +++ b/src/librenet_scanner/online_vendor.py @@ -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 diff --git a/src/librenet_scanner/parsers.py b/src/librenet_scanner/parsers.py new file mode 100644 index 0000000..5832906 --- /dev/null +++ b/src/librenet_scanner/parsers.py @@ -0,0 +1,144 @@ +from __future__ import annotations + +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 + + +ARP_LINE = re.compile( + r"^(?P(?:\d{1,3}\.){3}\d{1,3})\s+" + r"(?P(?:[0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2})" + r"(?:\s+(?P.*?))?\s*$" +) + + +def parse_arp_scan(text: str) -> list[Host]: + hosts: list[Host] = [] + for raw in text.splitlines(): + match = ARP_LINE.match(raw.strip()) + if not match: + continue + vendor = (match.group("vendor") or "").strip() + 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( + ip=match.group("ip"), + mac=match.group("mac").upper(), + vendor=vendor, + status="up", + ) + ) + return hosts + + +def _address(host_node: ET.Element, kind: str) -> tuple[str, str]: + for addr in host_node.findall("address"): + if addr.get("addrtype") == kind: + return addr.get("addr", ""), addr.get("vendor", "") + return "", "" + + +def _latency(host_node: ET.Element) -> float | None: + times = host_node.find("times") + if times is None: + return None + srtt = times.get("srtt") + if not srtt: + return None + try: + return int(srtt) / 1000.0 + except ValueError: + return None + + +def parse_nmap_xml(text: str) -> list[Host]: + if not text.strip(): + return [] + root = ET.fromstring(text) + hosts: list[Host] = [] + now = datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds") + + for node in root.findall("host"): + status_node = node.find("status") + status = status_node.get("state", "unknown") if status_node is not None else "unknown" + if status not in {"up", "unknown"}: + continue + + ip, _ = _address(node, "ipv4") + if not ip: + continue + mac, vendor = _address(node, "mac") + + hostname = "" + hostnames = node.find("hostnames") + if hostnames is not None: + candidate = hostnames.find("hostname") + if candidate is not None: + hostname = candidate.get("name", "") + + os_name = "" + os_accuracy: int | None = None + os_node = node.find("os") + if os_node 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") + if ports_node is not None: + for pnode in ports_node.findall("port"): + state_node = pnode.find("state") + state = state_node.get("state", "") if state_node is not None else "" + if state != "open": + continue + service_node = pnode.find("service") + ports.append( + PortInfo( + port=int(pnode.get("portid", "0")), + protocol=pnode.get("protocol", "tcp"), + state=state, + 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, + mac=mac.upper(), + vendor=vendor, + status=status, + os_name=os_name, + os_accuracy=os_accuracy, + latency_ms=_latency(node), + ports=ports, + last_seen=now, + ) + ) + ) + return hosts diff --git a/src/librenet_scanner/privileged_helper.py b/src/librenet_scanner/privileged_helper.py new file mode 100644 index 0000000..ccaecaf --- /dev/null +++ b/src/librenet_scanner/privileged_helper.py @@ -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 ") + 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 ") + 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 ") + 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 ") + 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()) diff --git a/src/librenet_scanner/privileges.py b/src/librenet_scanner/privileges.py new file mode 100644 index 0000000..572ffb9 --- /dev/null +++ b/src/librenet_scanner/privileges.py @@ -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") diff --git a/src/librenet_scanner/scan_logic.py b/src/librenet_scanner/scan_logic.py new file mode 100644 index 0000000..894ffb3 --- /dev/null +++ b/src/librenet_scanner/scan_logic.py @@ -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} diff --git a/src/librenet_scanner/scanner.py b/src/librenet_scanner/scanner.py new file mode 100644 index 0000000..640a8c8 --- /dev/null +++ b/src/librenet_scanner/scanner.py @@ -0,0 +1,989 @@ +from __future__ import annotations + +import ipaddress +import os +import queue +import signal +import subprocess +import threading +import time +from dataclasses import dataclass + +from PySide6.QtCore import QThread, Signal + +from .diagnostics import arp_scan_succeeded, find_arp_scan +from .fastscan import find_admin_naabu, find_naabu, parse_naabu_json_line +from .models import Host +from .network import ( + NetworkInterface, + interface_mac_address, + list_ipv4_neighbors, + target_contains_ip, + target_is_on_interface, + target_ipv4_hosts, +) +from .parsers import parse_arp_scan, parse_nmap_xml +from .privileges import privileged_command +from .scan_logic import union_host_ips +from .vendors import lookup_mac_vendor + + +NAABU_ACTIVE_HOST_THRESHOLD = 32 +NAABU_BATCH_SIZE = 32 +NAABU_BATCH_TIMEOUT_SECONDS = 10.0 +NAABU_CONNECT_TIMEOUT = "800ms" +NAABU_RATE = "2500" +NAABU_CONCURRENCY = "100" + +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" +) + + +PROFILE_SIGNATURES = { + "Rapide": "quick-v3", + "Standard": f"standard-v9:adaptive-nmap-small-naabu-large:{COMMON_PORTS}", + "Approfondi": f"deep-v10:adaptive-standard-baseline-nmap-enrichment:{COMMON_PORTS}:top1000-pn-sv-osguess-version-light", +} + + +def profile_signature(profile: str, privileged: bool = False) -> str: + base = PROFILE_SIGNATURES.get(profile, f"unknown:{profile}") + return f"{base}:privileged-v1" if privileged else base + + +@dataclass(slots=True) +class ScanRequest: + target: str + profile: str + interface: NetworkInterface | None = None + privileged: bool = False + + +class ScanWorker(QThread): + progress = Signal(str) + progress_state = Signal(int, str) # -1 = phase indéterminée, 0..100 = progression globale + warning = Signal(str) + hosts_found = Signal(object) + failed = Signal(str) + completed = Signal() + + def __init__(self, request: ScanRequest, parent=None) -> None: + super().__init__(parent) + self.request = request + self._proc: subprocess.Popen[str] | None = None + self._proc_uses_helper = False + + @staticmethod + def _uses_privileged_helper(args: list[str]) -> bool: + # Toutes les commandes privilégiées LibreNet passent par pkexec + notre + # helper. Le canal stdin reste alors réservé au protocole STOP de LibreNet. + return bool(args) and os.path.basename(args[0]) == "pkexec" + + @staticmethod + def _kill_process_group(proc: subprocess.Popen[str], sig: int) -> None: + try: + os.killpg(proc.pid, sig) + except (ProcessLookupError, PermissionError, OSError): + try: + proc.send_signal(sig) + except (ProcessLookupError, PermissionError, OSError): + pass + + def _escalate_stop(self, proc: subprocess.Popen[str], uses_helper: bool) -> None: + try: + proc.wait(timeout=2.0) + return + except subprocess.TimeoutExpired: + pass + # Le helper root est censé avoir tué son enfant après STOP. S'il est lui-même + # bloqué, on tente aussi de terminer l'enveloppe pkexec. Pour un processus + # utilisateur, SIGKILL porte sur tout le groupe créé par LibreNet. + if uses_helper: + try: + proc.terminate() + except (ProcessLookupError, PermissionError, OSError): + pass + else: + self._kill_process_group(proc, signal.SIGKILL) + + def _signal_process_stop(self, proc: subprocess.Popen[str] | None = None) -> None: + proc = proc or self._proc + if not proc or proc.poll() is not None: + return + uses_helper = self._proc_uses_helper + if uses_helper and proc.stdin is not None: + try: + proc.stdin.write("STOP\n") + proc.stdin.flush() + except (BrokenPipeError, OSError, ValueError): + pass + else: + self._kill_process_group(proc, signal.SIGTERM) + threading.Thread( + target=self._escalate_stop, args=(proc, uses_helper), daemon=True + ).start() + + def stop(self) -> None: + self.requestInterruption() + self._signal_process_stop() + + def _set_progress(self, value: int, label: str) -> None: + """Publie une étape de scan sans prétendre connaître l'avancement interne de Nmap. + + ``value == -1`` indique une phase active de durée inconnue : l'UI affiche alors + une barre animée. Les valeurs 0..100 sont des jalons de pipeline réels. + """ + self.progress.emit(label) + self.progress_state.emit(value, label) + + def _run_command( + self, + args: list[str], + label: str, + *, + start_percent: int | None = None, + end_percent: int | None = None, + timeout_seconds: float | None = None, + ) -> tuple[int, str, str]: + """Exécute une commande réseau avec annulation et délai maximal optionnel. + + Le délai maximal est volontairement utilisé pour les phases interactives + (notamment la découverte Standard) afin qu'un moteur qui se bloque ne puisse + plus immobiliser l'interface pendant plusieurs minutes. + """ + if self.isInterruptionRequested(): + return 130, "", "Interrompu" + if start_percent is not None: + self._set_progress(max(0, min(100, start_percent)), label) + self.progress_state.emit(-1, label) + self.progress.emit(label) + timed_out = False + try: + uses_helper = self._uses_privileged_helper(args) + self._proc_uses_helper = uses_helper + self._proc = subprocess.Popen( + args, + stdin=subprocess.PIPE if uses_helper else subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + encoding="utf-8", + errors="replace", + start_new_session=True, + ) + proc = self._proc + started = time.monotonic() + if self.isInterruptionRequested(): + self._signal_process_stop(proc) + + if uses_helper: + stdout_parts: list[str] = [] + stderr_parts: list[str] = [] + + def drain(stream, target: list[str]) -> None: + if stream is None: + return + while True: + chunk = stream.read(65536) + if not chunk: + break + target.append(chunk) + + readers = [ + threading.Thread(target=drain, args=(proc.stdout, stdout_parts), daemon=True), + threading.Thread(target=drain, args=(proc.stderr, stderr_parts), daemon=True), + ] + for reader in readers: + reader.start() + stop_sent = False + while proc.poll() is None: + if self.isInterruptionRequested() and not stop_sent: + self._signal_process_stop(proc) + stop_sent = True + elif ( + timeout_seconds is not None + and not stop_sent + and time.monotonic() - started >= timeout_seconds + ): + timed_out = True + self._signal_process_stop(proc) + stop_sent = True + try: + proc.wait(timeout=0.10) + except subprocess.TimeoutExpired: + pass + for reader in readers: + reader.join(timeout=1.0) + stdout = "".join(stdout_parts) + stderr = "".join(stderr_parts) + else: + try: + stdout, stderr = proc.communicate(timeout=timeout_seconds) + except subprocess.TimeoutExpired: + timed_out = True + self._kill_process_group(proc, signal.SIGTERM) + try: + stdout, stderr = proc.communicate(timeout=1.5) + except subprocess.TimeoutExpired: + self._kill_process_group(proc, signal.SIGKILL) + stdout, stderr = proc.communicate() + + if timed_out and not self.isInterruptionRequested(): + return 124, stdout, f"Délai maximal dépassé ({timeout_seconds:.1f} s). {stderr}".strip() + code = proc.returncode or 0 + if end_percent is not None and code == 0 and not self.isInterruptionRequested(): + self._set_progress(max(0, min(100, end_percent)), label) + return code, stdout, stderr + except FileNotFoundError: + return 127, "", f"Commande introuvable : {args[0]}" + except OSError as exc: + return 1, "", str(exc) + finally: + self._proc = None + self._proc_uses_helper = False + + def _arp_error_message(self, command: str, code: int, stderr: str) -> str: + detail = stderr.strip().splitlines()[-1] if stderr.strip() else f"code retour {code}" + lowered = stderr.casefold() + if any(token in lowered for token in ("operation not permitted", "permission denied", "cap_net_raw", "raw socket")): + return ( + "arp-scan n'a pas les privilèges nécessaires pour le scan ARP. " + "Les MAC seront récupérées autant que possible via la table neighbor Linux. " + f"Pour corriger durablement : sudo setcap cap_net_raw+p {command}" + ) + return f"arp-scan a échoué ({detail}). La découverte principale continue ; récupération MAC via la table neighbor Linux." + + def _local_host(self) -> Host | None: + """Construit l'entrée du poste local depuis l'interface sélectionnée. + + ARP et la table neighbor ne contiennent normalement pas la machine elle-même. + La MAC locale doit donc provenir directement de l'interface Linux. + """ + iface = self.request.interface + if not iface or not target_contains_ip(self.request.target, iface.address): + return None + mac = iface.mac or interface_mac_address(iface.name) + return Host( + ip=iface.address, + mac=mac, + vendor=lookup_mac_vendor(mac) if mac else "", + status="up", + is_local=True, + ) + + def _emit_local_host(self) -> list[Host]: + host = self._local_host() + if host is None: + return [] + self.hosts_found.emit([host]) + return [host] + + def _emit_arp(self, *, start_percent: int = 5, end_percent: int = 15) -> list[Host]: + command = find_arp_scan() + if not command: + self.warning.emit("arp-scan est introuvable. La découverte locale ARP est ignorée ; le moteur principal continue.") + return [] + iface = self.request.interface + if not iface or not target_is_on_interface(self.request.target, iface): + return [] + arp_target = self.request.target + # L'interface propose par défaut A.B.C.1 - A.B.C.254. Pour cette plage complète + # d'un /24, arp-scan peut travailler sur le CIDR de l'interface et conserver + # la récupération rapide des MAC. Les autres plages compactes restent à Nmap. + if "-" in arp_target and "/" not in arp_target: + expected = "" + if iface.prefixlen == 24: + net = ipaddress.ip_network(iface.network, strict=False) + prefix = ".".join(str(net.network_address).split(".")[:3]) + expected = f"{prefix}.1-254" + if arp_target != expected: + return [] + arp_target = iface.network + if self.request.privileged: + try: + args = privileged_command("arp-scan", iface.name, arp_target) + label = "Découverte des hôtes — ARP (Admin)…" + except RuntimeError as exc: + self.warning.emit(str(exc)) + args = [command, "--interface", iface.name, arp_target] + label = "Découverte des hôtes — ARP…" + else: + args = [command, "--interface", iface.name, arp_target] + label = "Découverte des hôtes — ARP…" + code, stdout, stderr = self._run_command( + args, label, start_percent=start_percent, end_percent=end_percent, + timeout_seconds=8.0, + ) + hosts = parse_arp_scan(stdout) + if hosts: + self.hosts_found.emit(hosts) + # arp-scan documente 0 comme succès ; tout code non nul est une erreur. + if not arp_scan_succeeded(code): + self.warning.emit(self._arp_error_message(command, code, stderr)) + return hosts + + def _neighbor_hosts(self, allowed_ips: set[str], *, percent: int | None = None) -> list[Host]: + iface = self.request.interface + if not iface or not allowed_ips or not target_is_on_interface(self.request.target, iface): + return [] + label = "Récupération des MAC via la table neighbor Linux…" + if percent is None: + self.progress.emit(label) + else: + self._set_progress(percent, label) + result: list[Host] = [] + for entry in list_ipv4_neighbors(iface.name): + if entry.ip not in allowed_ips: + continue + result.append( + Host( + ip=entry.ip, + mac=entry.mac, + vendor=lookup_mac_vendor(entry.mac), + status="up", + ) + ) + if result: + self.hosts_found.emit(result) + return result + + def _nmap( + self, args: list[str], label: str, *, start_percent: int | None = None, + end_percent: int | None = None, timeout_seconds: float | None = None + ) -> list[Host]: + code, stdout, stderr = self._run_command( + args, label, start_percent=start_percent, end_percent=end_percent, + timeout_seconds=timeout_seconds, + ) + if self.isInterruptionRequested(): + return [] + if code != 0: + raise RuntimeError(stderr.strip() or f"Nmap a quitté avec le code {code}") + return parse_nmap_xml(stdout) + + def _run_naabu_json( + self, + args: list[str], + label: str, + parser, + *, + start_percent: int, + end_percent: int, + phase: str, + timeout_seconds: float | None = None, + ) -> list[Host] | None: + """Exécute une passe Naabu bornée et parse son JSONL. + + Naabu CLI 2.6.1 agrège une partie de ses résultats avant de les écrire. LibreNet + ne dépend donc plus d'une hypothétique diffusion temps réel : les cibles sont + découpées en petits lots et chaque processus possède un délai maximal. Les + équipements sont déjà visibles grâce à la phase de découverte précédente. + """ + if self.isInterruptionRequested(): + return [] + self._set_progress(start_percent, label) + self.progress_state.emit(-1, label) + results: dict[str, Host] = {} + diagnostics: list[str] = [] + proc: subprocess.Popen[str] | None = None + reader: threading.Thread | None = None + lines: queue.Queue[str | object] = queue.Queue() + end_of_stream = object() + code = 1 + timed_out = False + + def consume(raw: str) -> None: + host = parser(raw.strip()) + if host is None: + text = raw.strip() + if text and len(diagnostics) < 10: + diagnostics.append(text) + return + existing = results.get(host.ip) + if existing: + existing.merge(host) + else: + results[host.ip] = host + self.hosts_found.emit([host]) + + try: + uses_helper = self._uses_privileged_helper(args) + self._proc_uses_helper = uses_helper + self._proc = subprocess.Popen( + args, + stdin=subprocess.PIPE if uses_helper else subprocess.DEVNULL, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, + encoding="utf-8", errors="replace", bufsize=1, + start_new_session=True, + ) + proc = self._proc + started = time.monotonic() + assert proc.stdout is not None + + def drain_stdout() -> None: + try: + for raw in proc.stdout: + lines.put(raw) + finally: + lines.put(end_of_stream) + + reader = threading.Thread(target=drain_stdout, daemon=True) + reader.start() + stream_done = False + stop_sent = False + while True: + if self.isInterruptionRequested() and not stop_sent: + self._signal_process_stop(proc) + stop_sent = True + elif ( + timeout_seconds is not None + and not stop_sent + and time.monotonic() - started >= timeout_seconds + ): + timed_out = True + self._signal_process_stop(proc) + stop_sent = True + try: + item = lines.get(timeout=0.10) + except queue.Empty: + item = None + if item is end_of_stream: + stream_done = True + elif isinstance(item, str): + consume(item) + + if proc.poll() is not None and stream_done and lines.empty(): + break + + while True: + try: + item = lines.get_nowait() + except queue.Empty: + break + if isinstance(item, str): + consume(item) + code = int(proc.returncode or 0) + except (OSError, FileNotFoundError) as exc: + self.warning.emit(f"Naabu indisponible pendant {phase} ({exc}).") + return None + finally: + if proc is not None and self.isInterruptionRequested() and proc.poll() is None: + self._signal_process_stop(proc) + try: + proc.wait(timeout=3.0) + except subprocess.TimeoutExpired: + pass + if reader is not None: + reader.join(timeout=1.0) + if proc is not None: + for stream in (proc.stdout, proc.stdin): + if stream is not None: + try: + stream.close() + except (OSError, ValueError): + pass + self._proc = None + self._proc_uses_helper = False + + if self.isInterruptionRequested(): + return [] + if timed_out: + self.warning.emit( + f"Naabu a dépassé le délai maximal pendant {phase} " + f"({timeout_seconds:.0f} s). Repli Nmap sur les hôtes déjà découverts." + ) + return None + if code != 0: + detail = diagnostics[-1] if diagnostics else f"code retour {code}" + self.warning.emit(f"Naabu a échoué pendant {phase} ({detail}).") + return None + if phase == "le scan de ports": + count = sum(len(h.ports) for h in results.values()) + done = f"Naabu : {count} port(s) ouvert(s) sur {len(results)} hôte(s)" + else: + done = f"Naabu : {len(results)} hôte(s) actif(s) détecté(s)" + self._set_progress(end_percent, done) + return list(results.values()) + + def _naabu_ports( + self, ips: list[str], *, start_percent: int, end_percent: int + ) -> list[Host] | None: + """Scanne les ports uniquement sur les hôtes déjà découverts. + + Envoyer tout un /24 à un scanner de ports multiplie inutilement les connexions + et peut retarder l'affichage. Le moteur adaptatif découpe au contraire + la liste d'hôtes actifs en lots courts. Un lot ne peut pas bloquer plus de + ``NAABU_BATCH_TIMEOUT_SECONDS`` ; en cas de problème le niveau supérieur + repasse sur Nmap, mais uniquement pour les hôtes déjà confirmés. + """ + if not ips: + return [] + binary = find_naabu() + if not binary: + self.warning.emit("Naabu est indisponible : repli Nmap pour les ports Standard.") + return None + if self.request.privileged and not find_admin_naabu(): + self.warning.emit("Naabu Admin est indisponible : repli Nmap pour les ports Standard.") + return None + + ordered = sorted(set(ips), key=ipaddress.ip_address) + batches = [ + ordered[index:index + NAABU_BATCH_SIZE] + for index in range(0, len(ordered), NAABU_BATCH_SIZE) + ] + all_results: dict[str, Host] = {} + span = max(1, end_percent - start_percent) + + for index, batch in enumerate(batches, start=1): + if self.isInterruptionRequested(): + return list(all_results.values()) + batch_start = start_percent + round(span * ((index - 1) / len(batches))) + batch_end = start_percent + round(span * (index / len(batches))) + suffix = f" — lot {index}/{len(batches)} ({len(batch)} hôte(s))" + try: + if self.request.privileged: + args = privileged_command("naabu-standard", *batch) + label = f"{self.request.profile} — ports Naabu SYN (Admin){suffix}…" + else: + args = [ + binary, "-host", ",".join(batch), "-p", COMMON_PORTS, "-Pn", + "-scan-type", "c", "-stream", + "-json", "-silent", "-no-color", + "-disable-update-check", "-no-stdin", "-config", "/dev/null", + "-auth=false", "-ip-version", "4", + "-c", NAABU_CONCURRENCY, "-rate", NAABU_RATE, + "-timeout", NAABU_CONNECT_TIMEOUT, "-warm-up-time", "0", + ] + label = f"{self.request.profile} — ports Naabu CONNECT{suffix}…" + except RuntimeError as exc: + self.warning.emit(f"Naabu Admin non utilisable ({exc}). Repli Nmap.") + return None + + result = self._run_naabu_json( + args, label, parse_naabu_json_line, + start_percent=batch_start, end_percent=batch_end, + phase="le scan de ports", timeout_seconds=NAABU_BATCH_TIMEOUT_SECONDS, + ) + if result is None: + return None + for host in result: + existing = all_results.get(host.ip) + if existing: + existing.merge(host) + else: + all_results[host.ip] = host + + return list(all_results.values()) + + def _nmap_standard_ports( + self, ips: list[str], *, start_percent: int, end_percent: int, fallback: bool = False + ) -> list[Host]: + """Scanne les ports usuels avec Nmap sur les seuls hôtes actifs. + + ``fallback`` ne décrit que le cas où Naabu a d'abord été choisi puis a + échoué. Sur les petits ensembles d'hôtes, Nmap est le moteur nominal et + l'interface ne doit surtout pas le présenter comme un repli. + """ + if not ips: + return [] + prefix = "Base Approfondi" if self.request.profile == "Approfondi" else "Standard" + suffix = " — REPLI Naabu" if fallback else " — moteur adaptatif" + if self.request.privileged: + args = privileged_command("nmap-standard", *ips) + label = f"{prefix} — ports Nmap SYN (Admin){suffix}…" + else: + args = [ + "nmap", "-Pn", "-n", "-sT", "--open", "-T4", + "--max-retries", "1", "--host-timeout", "12s", + "-p", COMMON_PORTS, "-oX", "-", *ips + ] + label = f"{prefix} — ports Nmap TCP{suffix}…" + timeout_seconds = min(60.0, max(15.0, 8.0 + len(ips) * 0.5)) + hosts = self._nmap( + args, label, start_percent=start_percent, end_percent=end_percent, + timeout_seconds=timeout_seconds, + ) + if hosts: + self.hosts_found.emit(hosts) + return hosts + + def _standard_discovery( + self, *, start_percent: int, end_percent: int, target_ips: list[str] + ) -> set[str]: + """Découverte rapide et robuste avant le scan de ports Standard. + + Le Standard est volontairement hybride : ARP fournit rapidement les + voisins/MAC sur le LAN, puis une unique passe Nmap ``-sn -n -T4`` confirme + les hôtes actifs. Naabu ne reçoit ensuite que ces hôtes, jamais tout le /24. + Cette stratégie privilégie un temps de réponse court et prévisible. + """ + span = max(10, end_percent - start_percent) + arp_end = start_percent + round(span * 0.35) + local_hosts = self._emit_local_host() + arp_hosts = self._emit_arp(start_percent=start_percent, end_percent=arp_end) + if self.isInterruptionRequested(): + return union_host_ips(local_hosts, arp_hosts) + + discovery_timeout = min(45.0, max(12.0, 8.0 + len(target_ips) / 32.0)) + # Le Standard utilise volontairement la même découverte Nmap en mode + # utilisateur et Admin. Les privilèges servent à ARP et au SYN scan de ports, + # pas à changer le jeu de sondes de découverte : activer Admin ne doit pas + # rendre des hôtes invisibles ni ajouter un second passage Nmap. + args = [ + "nmap", "-sn", "-n", "-T4", "--max-retries", "1", + "-oX", "-", self.request.target, + ] + if self.request.privileged: + label = f"{self.request.profile} — découverte rapide Nmap + ARP (mode Admin)…" + else: + label = f"{self.request.profile} — découverte rapide Nmap/ARP…" + + nmap_hosts = self._nmap( + args, label, start_percent=arp_end, end_percent=end_percent, + timeout_seconds=discovery_timeout, + ) + if nmap_hosts: + self.hosts_found.emit(nmap_hosts) + known_ips = union_host_ips(local_hosts, arp_hosts, nmap_hosts) + self._neighbor_hosts(known_ips, percent=end_percent) + self._set_progress( + end_percent, + f"Découverte terminée : {len(known_ips)} hôte(s) actif(s) — ports à scanner : {len(known_ips)}", + ) + return known_ips + + def _standard_baseline( + self, *, start_percent: int, end_percent: int + ) -> tuple[set[str], str]: + """Socle adaptatif commun au Standard et au début de l'Approfondi. + + Retourne ``(hôtes_connus, moteur_ports)`` avec ``moteur_ports`` parmi + ``nmap``, ``naabu``, ``nmap-fallback`` ou ``none``. Pour un petit LAN, + Nmap est volontairement préféré : avec seulement quelques dizaines de + ports sur quelques hôtes déjà actifs, son démarrage et son comportement + sont plus prévisibles que la CLI Naabu. Naabu est réservé aux ensembles + plus importants, là où son parallélisme apporte réellement quelque chose. + """ + target_ips = target_ipv4_hosts(self.request.target) + span = max(20, end_percent - start_percent) + at = lambda fraction: min(end_percent, start_percent + round(span * fraction)) + + known_ips = self._standard_discovery( + start_percent=start_percent, end_percent=at(0.38), target_ips=target_ips + ) + if self.isInterruptionRequested() or not known_ips: + return known_ips, "none" + + ips = sorted(known_ips, key=ipaddress.ip_address) + + # Naabu est un scanner massif. Sur un petit ensemble déjà découvert, Nmap + # est plus simple, plus déterministe et évite le problème de buffering CLI + # Le seuil est volontairement conservateur pour les petits réseaux. + if len(ips) < NAABU_ACTIVE_HOST_THRESHOLD: + self._set_progress( + at(0.42), + f"{self.request.profile} — {len(ips)} hôte(s) actif(s) : " + "scan de ports Nmap optimisé…", + ) + self._nmap_standard_ports( + ips, start_percent=at(0.45), end_percent=at(0.92), fallback=False + ) + engine = "nmap" + else: + port_hosts = self._naabu_ports( + ips, start_percent=at(0.42), end_percent=at(0.90) + ) + if self.isInterruptionRequested(): + return known_ips, "none" + if port_hosts is None: + self._set_progress( + at(0.43), + "Naabu indisponible ou trop lent — REPLI Nmap sur les seuls hôtes actifs…", + ) + self._nmap_standard_ports( + ips, start_percent=at(0.45), end_percent=at(0.92), fallback=True + ) + engine = "nmap-fallback" + else: + engine = "naabu" + + self._neighbor_hosts(known_ips, percent=at(0.96)) + return known_ips, engine + + def _standard_scan(self) -> None: + known_ips, engine = self._standard_baseline(start_percent=4, end_percent=96) + if self.isInterruptionRequested(): + return + if engine == "naabu": + mode = "SYN (Admin)" if self.request.privileged else "CONNECT" + label = f"Standard terminé — {len(known_ips)} hôte(s) — ports Naabu {mode}" + elif engine == "nmap-fallback": + label = f"Standard terminé — {len(known_ips)} hôte(s) — ports en REPLI Nmap" + elif engine == "nmap": + mode = "SYN (Admin)" if self.request.privileged else "TCP" + label = f"Standard terminé — {len(known_ips)} hôte(s) — ports Nmap {mode} (adaptatif)" + else: + label = f"Standard terminé — {len(known_ips)} hôte(s)" + self._set_progress(96, label) + + def _nmap_optional( + self, args: list[str], label: str, warning_prefix: str, *, + start_percent: int | None = None, end_percent: int | None = None, + timeout_seconds: float | None = None + ) -> list[Host]: + """Lance une phase Nmap complémentaire sans invalider tout le scan. + + La découverte d'hôtes combine plusieurs méthodes. Une méthode qui échoue ne + doit jamais effacer les hôtes déjà vus par ARP ou par une autre découverte. + """ + try: + hosts = self._nmap( + args, label, start_percent=start_percent, end_percent=end_percent, + timeout_seconds=timeout_seconds, + ) + except RuntimeError as exc: + self.warning.emit(f"{warning_prefix} : {exc}") + return [] + if hosts: + self.hosts_found.emit(hosts) + return hosts + + def _discover_hosts(self, *, start_percent: int = 4, end_percent: int = 52) -> set[str]: + """Découverte robuste et additive des hôtes. + + IMPORTANT : le mode administrateur ne remplace plus la découverte normale. + Il ajoute ARP/Nmap privilégiés aux résultats non privilégiés. Ainsi activer + les privilèges ne peut pas réduire le nombre d'hôtes détectés. + """ + span = max(20, end_percent - start_percent) + arp_end = start_percent + round(span * 0.20) + normal_end = start_percent + round(span * 0.62) + admin_end = start_percent + round(span * 0.84) + neighbor_percent = start_percent + round(span * 0.94) + + local_hosts = self._emit_local_host() + arp_hosts = self._emit_arp(start_percent=start_percent, end_percent=arp_end) + if self.isInterruptionRequested(): + return union_host_ips(local_hosts, arp_hosts) + + # Toujours conserver la découverte Nmap utilisateur comme socle. C'était + # précisément la régression de la 0.4.3 : en mode admin elle était remplacée + # par la variante root, qui peut choisir des probes/routages différents. + normal_hosts = self._nmap_optional( + ["nmap", "-sn", "-n", "-T4", "--max-retries", "1", "-oX", "-", self.request.target], + "Découverte des hôtes — Nmap (utilisateur)…", + "Découverte Nmap utilisateur échouée", + start_percent=arp_end, + end_percent=normal_end, + ) + + if self.isInterruptionRequested(): + return union_host_ips(local_hosts, arp_hosts, normal_hosts) + + privileged_hosts: list[Host] = [] + if self.request.privileged and not self.isInterruptionRequested(): + try: + cmd = privileged_command("nmap-discover", self.request.target) + except RuntimeError as exc: + self.warning.emit(str(exc)) + else: + privileged_hosts = self._nmap_optional( + cmd, + "Découverte des hôtes — Nmap (Admin complémentaire)…", + "Découverte Nmap Admin complémentaire échouée", + start_percent=normal_end, + end_percent=admin_end, + ) + + known_ips = union_host_ips(arp_hosts, normal_hosts, privileged_hosts) + known_ips.update(union_host_ips(local_hosts)) + if self.isInterruptionRequested(): + return known_ips + self._neighbor_hosts(known_ips, percent=neighbor_percent) + details = f"local {len(local_hosts)} · ARP {len(arp_hosts)} · Nmap {len(normal_hosts)}" + if self.request.privileged: + details += f" · admin {len(privileged_hosts)}" + self._set_progress(end_percent, f"Découverte : {len(known_ips)} hôte(s) unique(s) — {details}") + return known_ips + + def run(self) -> None: + try: + profile = self.request.profile + target = self.request.target + + self._set_progress(1, f"Préparation du scan {profile.lower()}…") + + if profile == "Rapide": + self._discover_hosts(start_percent=4, end_percent=96) + + elif profile == "Standard": + self._standard_scan() + + elif profile == "Approfondi": + # Même socle performant que Standard : découverte rapide d'abord, + # puis ports usuels sur les seuls hôtes actifs. Nmap intervient + # ensuite volontairement pour l'enrichissement -sV/OS. + known_ips, _ports_engine = self._standard_baseline( + start_percent=4, end_percent=56 + ) + if self.isInterruptionRequested(): + return + ips = sorted(known_ips, key=ipaddress.ip_address) + if ips: + if self.request.privileged: + deep_args = privileged_command("nmap-deep-hosts", *ips) + deep_label = "Enrichissement approfondi — Nmap SYN, services et OS (Admin)…" + else: + deep_args = [ + "nmap", "-Pn", "-n", "-sT", "-sV", "--version-light", + "--open", "-T4", "--top-ports", "1000", + "-oX", "-", *ips, + ] + deep_label = "Enrichissement approfondi — Nmap TCP et services…" + deep_hosts = self._nmap( + deep_args, deep_label, start_percent=62, end_percent=96 + ) + if deep_hosts: + self.hosts_found.emit(deep_hosts) + self._neighbor_hosts(set(ips), percent=98) + else: + raise RuntimeError(f"Profil inconnu : {profile}") + + if not self.isInterruptionRequested(): + self._set_progress(100, "Finalisation du scan…") + + except Exception as exc: + self.failed.emit(str(exc)) + finally: + self.completed.emit() + + +class HostScanWorker(QThread): + progress = Signal(str) + result = Signal(object) + failed = Signal(str) + completed = Signal() + + def __init__(self, ip: str, parent=None, privileged: bool = False) -> None: + super().__init__(parent) + self.ip = str(ipaddress.ip_address(ip)) + self.privileged = privileged + self._proc: subprocess.Popen[str] | None = None + + def stop(self) -> None: + self.requestInterruption() + proc = self._proc + if not proc or proc.poll() is not None: + return + uses_helper = self.privileged and proc.stdin is not None + if uses_helper: + try: + proc.stdin.write("STOP\n") + proc.stdin.flush() + except (BrokenPipeError, OSError, ValueError): + pass + else: + try: + os.killpg(proc.pid, signal.SIGTERM) + except (ProcessLookupError, PermissionError, OSError): + try: + proc.terminate() + except (ProcessLookupError, PermissionError, OSError): + pass + + def escalate() -> None: + try: + proc.wait(timeout=2.5) + return + except subprocess.TimeoutExpired: + pass + if uses_helper: + try: + proc.terminate() + except (ProcessLookupError, PermissionError, OSError): + pass + else: + try: + os.killpg(proc.pid, signal.SIGKILL) + except (ProcessLookupError, PermissionError, OSError): + try: + proc.kill() + except (ProcessLookupError, PermissionError, OSError): + pass + + threading.Thread(target=escalate, daemon=True).start() + + def run(self) -> None: + self.progress.emit(f"Scan détaillé de {self.ip}…") + if self.privileged: + try: + args = privileged_command("nmap-host", self.ip) + except RuntimeError as exc: + self.failed.emit(str(exc)) + self.completed.emit() + return + else: + args = [ + "nmap", "-Pn", "-n", "-sT", "-sV", "--version-light", "--open", "-T4", + "--top-ports", "1000", "-oX", "-", self.ip, + ] + try: + uses_helper = bool(args) and os.path.basename(args[0]) == "pkexec" + self._proc = subprocess.Popen( + args, + stdin=subprocess.PIPE if uses_helper else subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + encoding="utf-8", + errors="replace", + start_new_session=True, + ) + if self.isInterruptionRequested(): + self.stop() + if uses_helper: + stdout_parts: list[str] = [] + stderr_parts: list[str] = [] + def drain(stream, target: list[str]) -> None: + if stream is None: + return + while True: + chunk = stream.read(65536) + if not chunk: + break + target.append(chunk) + readers = [ + threading.Thread(target=drain, args=(self._proc.stdout, stdout_parts), daemon=True), + threading.Thread(target=drain, args=(self._proc.stderr, stderr_parts), daemon=True), + ] + for reader in readers: + reader.start() + stop_sent = False + while self._proc.poll() is None: + if self.isInterruptionRequested() and not stop_sent: + self.stop() + stop_sent = True + try: + self._proc.wait(timeout=0.10) + except subprocess.TimeoutExpired: + pass + for reader in readers: + reader.join(timeout=1.0) + stdout, stderr = "".join(stdout_parts), "".join(stderr_parts) + else: + stdout, stderr = self._proc.communicate() + if self.isInterruptionRequested(): + return + if self._proc.returncode != 0: + self.failed.emit(stderr.strip() or f"Nmap a quitté avec le code {self._proc.returncode}") + return + hosts = parse_nmap_xml(stdout) + if hosts: + self.result.emit(hosts[0]) + except OSError as exc: + self.failed.emit(str(exc)) + finally: + self._proc = None + self.completed.emit() diff --git a/src/librenet_scanner/storage.py b/src/librenet_scanner/storage.py new file mode 100644 index 0000000..3bc8c88 --- /dev/null +++ b/src/librenet_scanner/storage.py @@ -0,0 +1,739 @@ +from __future__ import annotations + +import json +import os +import sqlite3 +from contextlib import contextmanager +from datetime import datetime, timezone +from pathlib import Path + +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: + root = os.environ.get("XDG_DATA_HOME") + if root: + path = Path(root) / "librenet-scanner" + else: + path = Path.home() / ".local" / "share" / "librenet-scanner" + path.mkdir(parents=True, exist_ok=True) + return path + + +class HistoryStore: + def __init__(self, db_path: Path | None = None) -> None: + self.db_path = db_path or data_dir() / "history.sqlite3" + self.db_path.parent.mkdir(parents=True, exist_ok=True) + self._init_db() + + @contextmanager + def _connect(self): + """Connexion SQLite transactionnelle toujours refermée proprement.""" + conn = sqlite3.connect(self.db_path) + conn.row_factory = sqlite3.Row + 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: + conn.executescript( + """ + PRAGMA journal_mode=WAL; + CREATE TABLE IF NOT EXISTS 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 IF NOT EXISTS 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, + 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], 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, scan_schema) VALUES (?, ?, ?, ?, ?)", + (now, target, profile, len(active_hosts), scan_schema), + ) + scan_id = int(cur.lastrowid) + for host in active_hosts: + ports = [ + { + "port": p.port, + "protocol": p.protocol, + "state": p.state, + "service": p.service, + "product": p.product, + "version": p.version, + } + for p in host.ports + ] + conn.execute( + """ + 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, + host.os_accuracy, json.dumps(ports, ensure_ascii=False), + ), + ) + return scan_id + + def recent_scans(self, limit: int = 100) -> list[sqlite3.Row]: + with self._connect() as conn: + return list( + conn.execute( + "SELECT id, created_at, target, profile, host_count FROM scans ORDER BY id DESC LIMIT ?", + (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, + ), + ) + diff --git a/src/librenet_scanner/ui.py b/src/librenet_scanner/ui.py new file mode 100644 index 0000000..d931bf3 --- /dev/null +++ b/src/librenet_scanner/ui.py @@ -0,0 +1,2442 @@ +from __future__ import annotations + +import ipaddress +import shutil +import subprocess +import time +from datetime import datetime + +from PySide6.QtCore import Qt, QThread, QUrl, Signal, QSize, QSettings, QTimer +from PySide6.QtGui import QAction, QColor, QDesktopServices, QIcon +from PySide6.QtWidgets import ( + QApplication, + QButtonGroup, + QCheckBox, + QComboBox, + QDialog, + QDialogButtonBox, + QFileDialog, + QFormLayout, + QFrame, + QGroupBox, + QGridLayout, + QHBoxLayout, + QHeaderView, + QLabel, + QLineEdit, + QMainWindow, + QMenu, + QMessageBox, + QPlainTextEdit, + QProgressBar, + QPushButton, + QScrollArea, + QSplitter, + QStatusBar, + QTableWidget, + QTableWidgetItem, + QToolButton, + QTreeWidget, + QTreeWidgetItem, + QVBoxLayout, + QWidget, +) + +from . import __version__ +from .actions import send_magic_packet +from .comparison import compare_hosts +from .diagnostics import arp_scan_diagnostic +from .fastscan import naabu_diagnostic +from .exporters import export_csv, export_json +from .identity import identity_key, normalize_mac, shared_macs +from .intelligence import enrich_host +from .models import Host, PortInfo +from .online_vendor import ( + PROVIDER_MACLOOKUP, + PROVIDER_MACVENDORS, + PROVIDERS, + OnlineVendorError, + OnlineVendorResult, + is_locally_administered, + lookup_online_vendor, + provider_label, + provider_min_interval, +) +from .network import ( + NetworkInterface, + display_target, + list_ipv4_interfaces, + scan_identity_scope, + target_address_count, + target_is_on_interface, + validate_target, +) +from .privileges import privilege_diagnostic, privileged_command +from .scanner import HostScanWorker, ScanRequest, ScanWorker, profile_signature +from .storage import HistoryStore +from .ui_layout import balanced_column_widths, compact_warning +from .ui_icons import device_icon, os_icon, themed_icon + + +ROLE_IP = Qt.UserRole +ROLE_KIND = Qt.UserRole + 1 +ROLE_PORT = Qt.UserRole + 2 +KIND_HOST = "host" +KIND_SERVICE = "service" + + +class IPTreeWidgetItem(QTreeWidgetItem): + def __lt__(self, other: QTreeWidgetItem) -> bool: + tree = self.treeWidget() + if tree is not None and tree.sortColumn() == 1: + try: + return ipaddress.ip_address(self.text(1)) < ipaddress.ip_address(other.text(1)) + except ValueError: + pass + return super().__lt__(other) + + +def _display_timestamp(value: str) -> str: + if not value: + return "" + try: + return datetime.fromisoformat(value).astimezone().strftime("%d/%m/%Y %H:%M:%S") + except ValueError: + return value + + +def _port_url(host: Host, port: PortInfo | None = None) -> str | None: + candidates = [port] if port else [p for p in host.ports if p.state == "open"] + if not candidates: + return None + # Priorités adaptées aux interfaces d'administration usuelles. + priorities = (8006, 8007, 5001, 443, 8443, 5000, 80, 8080) + ordered = sorted(candidates, key=lambda p: priorities.index(p.port) if p.port in priorities else 999) + for item in ordered: + if item is None: + continue + if item.port in {443, 8443, 5001, 8006, 8007} or "https" in item.service.casefold(): + suffix = "" if item.port == 443 else f":{item.port}" + return f"https://{host.ip}{suffix}" + if item.port in {80, 8080, 5000} or "http" in item.service.casefold(): + suffix = "" if item.port == 80 else f":{item.port}" + return f"http://{host.ip}{suffix}" + return None + + +class PrivilegeAuthWorker(QThread): + result = Signal(bool, str) + + def run(self) -> None: + try: + proc = subprocess.run( + privileged_command("authorize"), + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + check=False, + ) + except (OSError, RuntimeError) as exc: + self.result.emit(False, str(exc)) + return + if proc.returncode == 0: + self.result.emit(True, "Mode administrateur activé via Polkit") + return + detail = proc.stderr.strip() or proc.stdout.strip() + if proc.returncode == 126: + detail = "Authentification annulée ou refusée." + elif not detail: + detail = f"pkexec a quitté avec le code {proc.returncode}." + self.result.emit(False, detail) + + +class OnlineVendorLookupWorker(QThread): + result = Signal(object) + failed = Signal(str, str) # mac, message + + def __init__(self, macs: list[str], provider: str, parent=None) -> None: + super().__init__(parent) + self.macs = list(dict.fromkeys(macs)) + self.provider = provider + + def run(self) -> None: + interval = provider_min_interval(self.provider) + for index, mac in enumerate(self.macs): + if self.isInterruptionRequested(): + return + try: + result = lookup_online_vendor(mac, self.provider) + except OnlineVendorError as exc: + self.failed.emit(mac, str(exc)) + else: + self.result.emit(result) + if index + 1 < len(self.macs) and interval > 0: + time.sleep(interval) + + +class VendorSettingsDialog(QDialog): + PROVIDER_LABELS = { + PROVIDER_MACLOOKUP: "MACLookup.app — gratuit, sans clé", + PROVIDER_MACVENDORS: "MACVendors.com — gratuit, sans clé", + } + + def __init__(self, *, enabled: bool, provider: str, parent=None) -> None: + super().__init__(parent) + self.setWindowTitle("Identification des constructeurs") + self.setMinimumWidth(520) + layout = QVBoxLayout(self) + + title = QLabel("Recherche constructeur en ligne") + title.setStyleSheet("font-size: 16px; font-weight: 600;") + layout.addWidget(title) + + info = QLabel( + "LibreNet utilise d'abord les bases OUI locales. Si cette option est activée, " + "les adresses MAC encore inconnues sont envoyées au fournisseur sélectionné " + "à la fin du scan. Les réponses sont mises en cache localement pendant 30 jours." + ) + info.setWordWrap(True) + layout.addWidget(info) + + self.enabled_box = QCheckBox("Interroger automatiquement une base en ligne pour les MAC inconnues") + self.enabled_box.setChecked(enabled) + layout.addWidget(self.enabled_box) + + form = QFormLayout() + self.provider_combo = QComboBox() + for value in PROVIDERS: + self.provider_combo.addItem(self.PROVIDER_LABELS[value], value) + idx = self.provider_combo.findData(provider) + self.provider_combo.setCurrentIndex(idx if idx >= 0 else 0) + form.addRow("Fournisseur", self.provider_combo) + layout.addLayout(form) + + privacy = QLabel( + "Confidentialité : activer cette fonction transmet l'adresse MAC complète à un service Internet tiers. " + "L'option est désactivée par défaut. Une MAC localement administrée (LAA) peut rester non identifiable, " + "même avec une base en ligne." + ) + privacy.setWordWrap(True) + privacy.setObjectName("mutedLabel") + layout.addWidget(privacy) + + buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) + buttons.accepted.connect(self.accept) + buttons.rejected.connect(self.reject) + layout.addWidget(buttons) + + @property + def online_enabled(self) -> bool: + return self.enabled_box.isChecked() + + @property + def provider(self) -> str: + value = self.provider_combo.currentData() + return str(value) if value in PROVIDERS else PROVIDER_MACLOOKUP + + +class HistoryDialog(QDialog): + def __init__(self, store: HistoryStore, parent=None) -> None: + super().__init__(parent) + self.setWindowTitle("Historique des scans") + self.resize(840, 440) + layout = QVBoxLayout(self) + info = QLabel("LibreNet compare un scan au précédent ayant la même cible, le même profil et le même mode de privilèges.") + info.setWordWrap(True) + layout.addWidget(info) + table = QTableWidget(0, 5, self) + table.setHorizontalHeaderLabels(["Date", "Cible", "Profil", "Hôtes", "ID"]) + rows = store.recent_scans() + table.setRowCount(len(rows)) + for row_idx, row in enumerate(rows): + values = [_display_timestamp(row["created_at"]), row["target"], row["profile"], str(row["host_count"]), str(row["id"])] + for col, value in enumerate(values): + table.setItem(row_idx, col, QTableWidgetItem(value)) + table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeToContents) + table.horizontalHeader().setStretchLastSection(True) + layout.addWidget(table) + + +class MainWindow(QMainWindow): + def __init__(self) -> None: + super().__init__() + self.setWindowTitle(f"LibreNet Scanner {__version__}") + self.resize(1540, 860) + self.setMinimumSize(1050, 650) + self.hosts: dict[str, Host] = {} + self.interfaces: list[NetworkInterface] = [] + self.worker: ScanWorker | None = None + self.host_worker: HostScanWorker | None = None + self.store = HistoryStore() + self.baseline_hosts: list[Host] | None = None + self.baseline_date = "" + self.scan_had_error = False + self._routed_scan = False + self.scan_warnings: list[str] = [] + self.admin_mode = False + self.auth_worker: PrivilegeAuthWorker | None = None + self._pending_scan_after_auth = False + self._pending_profile = "Standard" + self.current_scan_privileged = False + self.current_identity_scope = "" + self.scan_started_at: datetime | None = None + self._metadata_cache: dict[str, dict[str, object]] = {} + self.settings = QSettings("LibreNet", "LibreNet Scanner") + self._restoring_layout = True + self._columns_user_customized = False + self.vendor_lookup_worker: OnlineVendorLookupWorker | None = None + self._vendor_lookup_manual = False + + self._build_ui() + self._build_menu() + self.refresh_interfaces() + self._update_detail_panel(None) + QTimer.singleShot(0, self._restore_ui_layout) + + # ---------- Construction de l'interface ---------- + def _build_ui(self) -> None: + central = QWidget(self) + central.setObjectName("appRoot") + outer = QVBoxLayout(central) + outer.setContentsMargins(14, 12, 14, 10) + outer.setSpacing(10) + + def section_title(text: str) -> QLabel: + label = QLabel(text.upper()) + label.setObjectName("sectionTitle") + return label + + def separator() -> QFrame: + line = QFrame() + line.setFrameShape(QFrame.Shape.HLine) + line.setObjectName("separator") + return line + + # Une feuille de style volontairement légère : elle s'appuie sur la palette + # Qt/KDE afin de rester cohérente avec Breeze clair ou sombre. + self.setStyleSheet( + """ + QWidget#appRoot { + background: palette(window); + } + QFrame#scanCard, QFrame#detailPane { + background: palette(base); + border: 1px solid palette(midlight); + border-radius: 10px; + } + QLabel#fieldLabel, QLabel#mutedLabel { + color: palette(mid); + } + QLabel#fieldLabel { + font-size: 10px; + font-weight: 600; + } + QLabel#sectionTitle { + color: palette(mid); + font-size: 10px; + font-weight: 700; + } + QLabel#detailName { + font-size: 18px; + font-weight: 600; + } + QLabel#detailType { + color: palette(mid); + } + QLabel#valueLabel { + font-weight: 500; + } + QFrame#separator { + color: palette(midlight); + background: palette(midlight); + max-height: 1px; + } + QLineEdit, QPlainTextEdit { + background: palette(base); + border: 1px solid palette(midlight); + border-radius: 7px; + padding: 6px 8px; + selection-background-color: palette(highlight); + selection-color: palette(highlighted-text); + } + QLineEdit:focus, QPlainTextEdit:focus { + border: 1px solid palette(highlight); + } + QPushButton#primaryScan { + background: palette(highlight); + color: palette(highlighted-text); + border: none; + border-radius: 8px; + padding: 8px 16px; + font-weight: 700; + } + QPushButton#primaryScan:disabled { + background: palette(midlight); + color: palette(mid); + } + QPushButton#stopButton, QToolButton#adminChip, QToolButton#filterChip, + QToolButton#scanMenuButton, QPushButton#secondaryAction, QToolButton#moreAction, QPushButton#favoriteButton { + background: palette(button); + border: 1px solid palette(midlight); + border-radius: 7px; + padding: 6px 10px; + } + QToolButton#adminChip, QToolButton#filterChip { + padding: 5px 10px; + } + QToolButton#scanMenuButton { + padding: 0px; + min-width: 36px; + max-width: 36px; + } + QToolButton#scanMenuButton::menu-indicator { + image: none; + width: 0px; + height: 0px; + } + QFrame#viewSwitch { + background: palette(button); + border: 1px solid palette(midlight); + border-radius: 7px; + } + QToolButton#segmentButton { + border: none; + padding: 6px 10px; + background: transparent; + } + QToolButton#segmentButton:checked { + background: palette(highlight); + color: palette(highlighted-text); + border-radius: 5px; + } + QPushButton#primaryAction { + background: palette(highlight); + color: palette(highlighted-text); + border: none; + border-radius: 7px; + padding: 7px 12px; + font-weight: 600; + } + QPushButton#secondaryAction:hover, QToolButton#moreAction:hover, + QPushButton#favoriteButton:hover, QToolButton#adminChip:hover, + QToolButton#filterChip:hover, QToolButton#scanMenuButton:hover, QPushButton#stopButton:hover { + border-color: palette(highlight); + } + QTreeWidget#deviceTree { + background: palette(base); + border: 1px solid palette(midlight); + border-radius: 9px; + outline: 0; + alternate-background-color: palette(alternate-base); + } + QTreeWidget#deviceTree::item { + min-height: 34px; + padding: 4px 6px; + border: 0; + } + QTreeWidget#deviceTree::item:selected { + background: palette(highlight); + color: palette(highlighted-text); + } + QTreeWidget#serviceList { + border: 0; + background: transparent; + outline: 0; + } + QTreeWidget#serviceList::item { + min-height: 26px; + padding: 2px 3px; + } + QHeaderView::section { + background: palette(window); + border: none; + border-bottom: 1px solid palette(midlight); + padding: 7px 6px; + font-weight: 600; + } + QFrame#noticeFrame { + background: palette(alternate-base); + border: 1px solid palette(midlight); + border-radius: 7px; + } + QLabel#noticeLabel { + background: transparent; + border: none; + } + QProgressBar { + border: 1px solid palette(midlight); + background: palette(base); + border-radius: 7px; + min-height: 16px; + max-height: 16px; + text-align: center; + font-size: 10px; + } + QProgressBar::chunk { + background: palette(highlight); + border-radius: 6px; + } + """ + ) + + # --- Carte de scan ------------------------------------------------- + scan_card = QFrame() + scan_card.setObjectName("scanCard") + scan_layout = QHBoxLayout(scan_card) + scan_layout.setContentsMargins(12, 10, 12, 10) + scan_layout.setSpacing(10) + + target_box = QVBoxLayout() + target_box.setSpacing(3) + target_label = QLabel("CIBLE") + target_label.setObjectName("fieldLabel") + self.target_edit = QLineEdit() + self.target_edit.setMinimumHeight(34) + self.target_edit.setPlaceholderText("192.168.1.0/24 ou 192.168.1.1 - 192.168.1.254") + target_icon = themed_icon("network-server", "network-wired") + if not target_icon.isNull(): + self.target_edit.addAction(target_icon, QLineEdit.ActionPosition.LeadingPosition) + target_box.addWidget(target_label) + target_box.addWidget(self.target_edit) + + interface_box = QVBoxLayout() + interface_box.setSpacing(3) + interface_label = QLabel("INTERFACE RÉSEAU") + interface_label.setObjectName("fieldLabel") + self.interface_combo = QComboBox() + self.interface_combo.setMinimumWidth(320) + self.interface_combo.setMinimumHeight(34) + self.interface_combo.currentIndexChanged.connect(self._interface_changed) + interface_box.addWidget(interface_label) + interface_box.addWidget(self.interface_combo) + + self.scan_btn = QPushButton("Scanner") + self.scan_btn.setObjectName("primaryScan") + self.scan_btn.setIcon(themed_icon("media-playback-start")) + self.scan_btn.setIconSize(QSize(18, 18)) + self.scan_btn.setFixedHeight(36) + self.scan_btn.setMinimumWidth(118) + self.scan_btn.clicked.connect(lambda: self.start_scan(profile="Standard")) + + # Bouton de menu volontairement indépendant du QMenu : sous Breeze, + # associer setArrowType() + setMenu() fait dessiner deux indicateurs. + # Ici Qt ne dessine qu'un seul chevron et nous ouvrons le menu nous-mêmes. + self.scan_menu_btn = QToolButton() + self.scan_menu_btn.setObjectName("scanMenuButton") + self.scan_menu_btn.setArrowType(Qt.DownArrow) + self.scan_menu_btn.setToolTip("Choisir un autre type de scan") + self.scan_menu_btn.setFixedSize(36, 36) + self.scan_menu = QMenu(self) + standard = self.scan_menu.addAction(themed_icon("media-playback-start"), "Scan standard") + standard.triggered.connect(lambda: self.start_scan(profile="Standard")) + quick = self.scan_menu.addAction(themed_icon("system-run"), "Scan rapide — découverte uniquement") + quick.triggered.connect(lambda: self.start_scan(profile="Rapide")) + deep = self.scan_menu.addAction(themed_icon("system-search"), "Scan approfondi — services + OS") + deep.triggered.connect(lambda: self.start_scan(profile="Approfondi")) + self.scan_menu_btn.clicked.connect(self._show_scan_menu) + + self.stop_btn = QPushButton("Arrêter") + self.stop_btn.setObjectName("stopButton") + self.stop_btn.setIcon(themed_icon("process-stop")) + self.stop_btn.setFixedHeight(36) + self.stop_btn.setEnabled(False) + self.stop_btn.clicked.connect(self.stop_scan) + + self.admin_btn = QToolButton() + self.admin_btn.setObjectName("adminChip") + self.admin_btn.setText("Standard") + self.admin_btn.setIcon(themed_icon("object-locked")) + self.admin_btn.setToolButtonStyle(Qt.ToolButtonTextBesideIcon) + self.admin_btn.setFixedHeight(36) + self.admin_btn.clicked.connect(self.toggle_admin_mode) + + action_box = QVBoxLayout() + action_box.setSpacing(5) + action_label = QLabel("ACTIONS") + action_label.setObjectName("fieldLabel") + action_row = QHBoxLayout() + action_row.setSpacing(5) + action_row.addWidget(self.scan_btn) + action_row.addWidget(self.scan_menu_btn) + action_row.addWidget(self.stop_btn) + action_row.addWidget(self.admin_btn) + action_box.addWidget(action_label) + action_box.addLayout(action_row) + + scan_layout.addLayout(target_box, 3) + scan_layout.addLayout(interface_box, 2) + scan_layout.addLayout(action_box) + outer.addWidget(scan_card) + + # --- Recherche / filtres ------------------------------------------ + filter_bar = QHBoxLayout() + filter_bar.setSpacing(8) + self.filter_edit = QLineEdit() + self.filter_edit.setClearButtonEnabled(True) + self.filter_edit.setMinimumHeight(34) + self.filter_edit.setPlaceholderText("Rechercher un nom, une IP, un service, une note…") + search_icon = themed_icon("edit-find") + if not search_icon.isNull(): + self.filter_edit.addAction(search_icon, QLineEdit.ActionPosition.LeadingPosition) + self.filter_edit.textChanged.connect(self._apply_filters) + + self.view_filter = QToolButton() + self.view_filter.setObjectName("filterChip") + self.view_filter.setText("Tous") + self.view_filter.setIcon(themed_icon("view-filter")) + self.view_filter.setToolButtonStyle(Qt.ToolButtonTextBesideIcon) + self.view_filter.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup) + self.view_filter.setMinimumHeight(34) + self._view_filter_value = "Tous" + view_menu = QMenu(self.view_filter) + self._view_filter_actions = [] + filter_icons = { + "Tous": "view-list-details", + "Actifs": "system-run", + "Favoris": "rating", + "Changements": "view-refresh", + "Nouveaux": "list-add", + "Modifiés": "document-edit", + "Disparus": "list-remove", + } + for label in ("Tous", "Actifs", "Favoris", "Changements", "Nouveaux", "Modifiés", "Disparus"): + action = view_menu.addAction(themed_icon(filter_icons[label]), label) + action.setCheckable(True) + action.setChecked(label == "Tous") + action.triggered.connect(lambda _checked=False, value=label: self._set_view_filter(value)) + self._view_filter_actions.append(action) + self.view_filter.setMenu(view_menu) + + self.group_filter = QToolButton() + self.group_filter.setObjectName("filterChip") + self.group_filter.setText("Tous les groupes") + self.group_filter.setIcon(themed_icon("folder")) + self.group_filter.setToolButtonStyle(Qt.ToolButtonTextBesideIcon) + self.group_filter.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup) + self.group_filter.setMinimumHeight(34) + self._group_filter_value = "Tous les groupes" + self.group_filter.setVisible(False) + + view_switch = QFrame() + view_switch.setObjectName("viewSwitch") + view_switch_layout = QHBoxLayout(view_switch) + view_switch_layout.setContentsMargins(2, 2, 2, 2) + view_switch_layout.setSpacing(0) + self.compact_view_btn = QToolButton() + self.compact_view_btn.setObjectName("segmentButton") + self.compact_view_btn.setText("Compact") + self.compact_view_btn.setIcon(themed_icon("view-list-icons", "view-list-details")) + self.compact_view_btn.setIconSize(QSize(16, 16)) + self.compact_view_btn.setToolButtonStyle(Qt.ToolButtonTextBesideIcon) + self.compact_view_btn.setCheckable(True) + self.compact_view_btn.setToolTip("Une ligne par équipement") + self.detail_view_btn = QToolButton() + self.detail_view_btn.setObjectName("segmentButton") + self.detail_view_btn.setText("Détaillé") + self.detail_view_btn.setIcon(themed_icon("view-list-details", "view-list-icons")) + self.detail_view_btn.setIconSize(QSize(16, 16)) + self.detail_view_btn.setToolButtonStyle(Qt.ToolButtonTextBesideIcon) + self.detail_view_btn.setCheckable(True) + self.detail_view_btn.setChecked(True) + self.detail_view_btn.setToolTip("Afficher les services sous chaque équipement") + self.view_mode_group = QButtonGroup(self) + self.view_mode_group.setExclusive(True) + self.view_mode_group.addButton(self.compact_view_btn) + self.view_mode_group.addButton(self.detail_view_btn) + self.compact_view_btn.toggled.connect(lambda checked: checked and self._toggle_view_mode(False)) + self.detail_view_btn.toggled.connect(lambda checked: checked and self._toggle_view_mode(True)) + # Alias conservé pour les chemins existants (double-clic, reconstruction de l'arbre). + self.view_mode_btn = self.detail_view_btn + view_switch_layout.addWidget(self.compact_view_btn) + view_switch_layout.addWidget(self.detail_view_btn) + + filter_bar.addWidget(self.filter_edit, 1) + filter_bar.addWidget(self.view_filter) + filter_bar.addWidget(self.group_filter) + filter_bar.addWidget(view_switch) + outer.addLayout(filter_bar) + + self.notice_frame = QFrame() + self.notice_frame.setObjectName("noticeFrame") + notice_layout = QHBoxLayout(self.notice_frame) + notice_layout.setContentsMargins(10, 6, 8, 6) + notice_layout.setSpacing(8) + self.notice_icon = QLabel() + warning_icon = themed_icon("dialog-warning") + if not warning_icon.isNull(): + self.notice_icon.setPixmap(warning_icon.pixmap(18, 18)) + self.notice_label = QLabel() + self.notice_label.setObjectName("noticeLabel") + self.notice_label.setWordWrap(False) + self.notice_details_btn = QToolButton() + self.notice_details_btn.setText("Diagnostic") + self.notice_details_btn.setIcon(themed_icon("tools-report-bug", "system-search")) + self.notice_details_btn.setToolButtonStyle(Qt.ToolButtonTextBesideIcon) + self.notice_details_btn.setObjectName("filterChip") + self.notice_details_btn.clicked.connect(self.show_tools_diagnostic) + self.notice_close_btn = QToolButton() + self.notice_close_btn.setIcon(themed_icon("window-close")) + self.notice_close_btn.setAutoRaise(True) + self.notice_close_btn.setToolTip("Masquer cet avertissement") + self.notice_close_btn.clicked.connect(self.notice_frame.hide) + notice_layout.addWidget(self.notice_icon) + notice_layout.addWidget(self.notice_label, 1) + notice_layout.addWidget(self.notice_details_btn) + notice_layout.addWidget(self.notice_close_btn) + self.notice_frame.setVisible(False) + outer.addWidget(self.notice_frame) + + # --- Zone principale --------------------------------------------- + splitter = QSplitter(Qt.Horizontal) + self.main_splitter = splitter + splitter.setChildrenCollapsible(False) + splitter.setHandleWidth(5) + + self.tree = QTreeWidget() + self.tree.setObjectName("deviceTree") + self.tree.setColumnCount(4) + self.tree.setHeaderLabels(["Équipement / service", "IP / port", "Type / version", "Évolution"]) + self.tree.setAlternatingRowColors(True) + self.tree.setRootIsDecorated(True) + self.tree.setUniformRowHeights(True) + self.tree.setIconSize(QSize(26, 26)) + self.tree.setSortingEnabled(True) + self.tree.sortByColumn(1, Qt.AscendingOrder) + self.tree.setContextMenuPolicy(Qt.CustomContextMenu) + self.tree.customContextMenuRequested.connect(self._context_menu) + self.tree.currentItemChanged.connect(lambda current, _previous: self._selection_changed(current)) + self.tree.itemDoubleClicked.connect(self._tree_double_clicked) + header = self.tree.header() + # Les deux premières colonnes restent redimensionnables ; Type / version + # absorbe tout l'espace restant. Cela supprime la grande zone blanche qui + # apparaissait à droite des colonnes quand Évolution était masquée. + header.setSectionResizeMode(0, QHeaderView.Interactive) + header.setSectionResizeMode(1, QHeaderView.Interactive) + header.setSectionResizeMode(2, QHeaderView.Stretch) + header.setSectionResizeMode(3, QHeaderView.Interactive) + header.setStretchLastSection(False) + header.setMinimumSectionSize(88) + header.setSectionsMovable(False) + header.sectionResized.connect(self._column_resized) + self.tree.setIndentation(20) + splitter.addWidget(self.tree) + + # --- Panneau de détails ------------------------------------------ + detail_pane = QFrame() + detail_pane.setObjectName("detailPane") + detail_pane.setMinimumWidth(380) + detail_pane.setMaximumWidth(540) + detail_outer = QVBoxLayout(detail_pane) + detail_outer.setContentsMargins(0, 0, 0, 0) + + detail_scroll = QScrollArea() + detail_scroll.setFrameShape(QFrame.Shape.NoFrame) + detail_scroll.setWidgetResizable(True) + detail_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + detail = QWidget() + detail_layout = QVBoxLayout(detail) + detail_layout.setContentsMargins(18, 16, 18, 16) + detail_layout.setSpacing(10) + + hero = QHBoxLayout() + hero.setSpacing(12) + self.detail_icon = QLabel() + self.detail_icon.setFixedSize(52, 52) + self.detail_icon.setAlignment(Qt.AlignCenter) + hero_text = QVBoxLayout() + hero_text.setSpacing(2) + self.detail_name = QLabel("Aucun équipement sélectionné") + self.detail_name.setObjectName("detailName") + self.detail_name.setWordWrap(True) + self.detail_type = QLabel("") + self.detail_type.setObjectName("detailType") + self.detail_type.setWordWrap(True) + hero_text.addWidget(self.detail_name) + hero_text.addWidget(self.detail_type) + hero.addWidget(self.detail_icon) + hero.addLayout(hero_text, 1) + detail_layout.addLayout(hero) + + # Actions : seules les actions pertinentes seront visibles. + action_row = QHBoxLayout() + action_row.setSpacing(6) + self.web_btn = QPushButton("Ouvrir") + self.web_btn.setObjectName("primaryAction") + self.web_btn.setIcon(themed_icon("internet-web-browser")) + self.web_btn.setIconSize(QSize(17, 17)) + self.ssh_btn = QPushButton("SSH") + self.ssh_btn.setObjectName("secondaryAction") + self.ssh_btn.setIcon(themed_icon("utilities-terminal")) + self.ssh_btn.setIconSize(QSize(17, 17)) + self.smb_btn = QPushButton("Partages") + self.smb_btn.setObjectName("secondaryAction") + self.smb_btn.setIcon(themed_icon("folder-network")) + self.smb_btn.setIconSize(QSize(17, 17)) + self.rdp_btn = QPushButton("RDP") + self.rdp_btn.setObjectName("secondaryAction") + self.rdp_btn.setIcon(themed_icon("krdc", "computer")) + self.rdp_btn.setIconSize(QSize(17, 17)) + self.more_btn = QToolButton() + self.more_btn.setObjectName("moreAction") + self.more_btn.setText("Plus") + self.more_btn.setIcon(themed_icon("overflow-menu", "application-menu")) + self.more_btn.setIconSize(QSize(17, 17)) + self.more_btn.setToolButtonStyle(Qt.ToolButtonTextBesideIcon) + self.more_btn.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup) + for btn in (self.web_btn, self.ssh_btn, self.smb_btn, self.rdp_btn): + action_row.addWidget(btn) + action_row.addWidget(self.more_btn) + action_row.addStretch(1) + self.web_btn.clicked.connect(self._open_selected_web) + self.ssh_btn.clicked.connect(lambda: self._run_selected_terminal(["ssh"])) + self.smb_btn.clicked.connect(self._open_selected_smb) + self.rdp_btn.clicked.connect(self._open_selected_rdp) + detail_layout.addLayout(action_row) + + # Widgets gardés comme actions logiques ; leurs commandes sont aussi + # accessibles depuis le menu "Plus". + self.hostscan_btn = QPushButton("Scan détaillé") + self.hostscan_btn.setIcon(themed_icon("system-search")) + self.hostscan_btn.setVisible(False) + self.hostscan_btn.clicked.connect(self.scan_selected_host) + self.wol_btn = QPushButton("Wake-on-LAN") + self.wol_btn.setIcon(themed_icon("system-run")) + self.wol_btn.setVisible(False) + self.wol_btn.clicked.connect(self._wake_selected) + + detail_layout.addWidget(separator()) + detail_layout.addWidget(section_title("Informations")) + + info_grid = QGridLayout() + info_grid.setHorizontalSpacing(12) + info_grid.setVerticalSpacing(7) + self.detail_ip = QLabel("—") + self.detail_mac = QLabel("—") + self.detail_vendor = QLabel("—") + self.detail_os = QLabel("—") + self.detail_os_icon = QLabel() + self.detail_os_icon.setFixedSize(24, 24) + self.detail_os_icon.setAlignment(Qt.AlignCenter) + self.detail_latency = QLabel("—") + self.detail_seen = QLabel("—") + info_rows = [ + ("Adresse IP", self.detail_ip), + ("Adresse MAC", self.detail_mac), + ("Constructeur", self.detail_vendor), + ("Système", self.detail_os), + ("Latence", self.detail_latency), + ("Dernière vue", self.detail_seen), + ] + for row, (caption, value) in enumerate(info_rows): + lab = QLabel(caption) + lab.setObjectName("mutedLabel") + value.setObjectName("valueLabel") + value.setTextInteractionFlags(Qt.TextSelectableByMouse) + value.setWordWrap(True) + info_grid.addWidget(lab, row, 0, Qt.AlignTop) + if caption == "Système": + os_value = QWidget() + os_value_layout = QHBoxLayout(os_value) + os_value_layout.setContentsMargins(0, 0, 0, 0) + os_value_layout.setSpacing(7) + os_value_layout.addWidget(self.detail_os_icon, 0, Qt.AlignTop) + os_value_layout.addWidget(value, 1, Qt.AlignTop) + info_grid.addWidget(os_value, row, 1, Qt.AlignTop) + else: + info_grid.addWidget(value, row, 1, Qt.AlignTop) + info_grid.setColumnStretch(1, 1) + detail_layout.addLayout(info_grid) + + vendor_action_row = QHBoxLayout() + vendor_action_row.addStretch(1) + self.online_vendor_btn = QPushButton("Rechercher en ligne") + self.online_vendor_btn.setObjectName("secondaryAction") + self.online_vendor_btn.setIcon(themed_icon("edit-find")) + self.online_vendor_btn.setToolTip("Interroger le fournisseur en ligne configuré pour cette adresse MAC") + self.online_vendor_btn.clicked.connect(self._lookup_selected_vendor_online) + vendor_action_row.addWidget(self.online_vendor_btn) + detail_layout.addLayout(vendor_action_row) + + detail_layout.addWidget(separator()) + detail_layout.addWidget(section_title("Services")) + self.detail_services_tree = QTreeWidget() + self.detail_services_tree.setObjectName("serviceList") + self.detail_services_tree.setColumnCount(2) + self.detail_services_tree.setHeaderHidden(True) + self.detail_services_tree.setRootIsDecorated(False) + self.detail_services_tree.setAlternatingRowColors(False) + self.detail_services_tree.setIconSize(QSize(18, 18)) + self.detail_services_tree.setMaximumHeight(210) + self.detail_services_tree.header().setSectionResizeMode(0, QHeaderView.Stretch) + self.detail_services_tree.header().setSectionResizeMode(1, QHeaderView.ResizeToContents) + self.detail_services_tree.itemDoubleClicked.connect(self._detail_service_double_clicked) + self.detail_services = QLabel("Aucun service détecté") + self.detail_services.setObjectName("mutedLabel") + self.detail_services.setWordWrap(True) + detail_layout.addWidget(self.detail_services_tree) + detail_layout.addWidget(self.detail_services) + + detail_layout.addWidget(separator()) + meta_header = QHBoxLayout() + meta_header.addWidget(section_title("Classement local")) + meta_header.addStretch(1) + self.favorite_btn = QPushButton("Favori") + self.favorite_btn.setObjectName("favoriteButton") + self.favorite_btn.setCheckable(True) + self.favorite_btn.setIcon(themed_icon("rating")) + self.favorite_btn.clicked.connect(self._toggle_favorite) + meta_header.addWidget(self.favorite_btn) + detail_layout.addLayout(meta_header) + + group_label = QLabel("Groupe") + group_label.setObjectName("mutedLabel") + self.group_edit = QComboBox() + self.group_edit.setEditable(True) + self.group_edit.setInsertPolicy(QComboBox.InsertPolicy.NoInsert) + self.group_edit.setPlaceholderText("Infrastructure, Réseau…") + detail_layout.addWidget(group_label) + detail_layout.addWidget(self.group_edit) + + note_label = QLabel("Notes") + note_label.setObjectName("mutedLabel") + self.note_edit = QPlainTextEdit() + self.note_edit.setPlaceholderText("Ajouter une note sur cet équipement…") + self.note_edit.setMaximumHeight(100) + self.save_meta_btn = QPushButton("Enregistrer") + self.save_meta_btn.setObjectName("secondaryAction") + self.save_meta_btn.setIcon(themed_icon("document-save")) + self.save_meta_btn.clicked.connect(self._save_selected_metadata) + detail_layout.addWidget(note_label) + detail_layout.addWidget(self.note_edit) + save_row = QHBoxLayout() + save_row.addStretch(1) + save_row.addWidget(self.save_meta_btn) + detail_layout.addLayout(save_row) + detail_layout.addStretch(1) + + detail_scroll.setWidget(detail) + detail_outer.addWidget(detail_scroll) + splitter.addWidget(detail_pane) + splitter.setStretchFactor(0, 7) + splitter.setStretchFactor(1, 3) + splitter.setSizes([1080, 420]) + outer.addWidget(splitter, 1) + + # --- Barre d'état synthétique ------------------------------------ + bottom = QHBoxLayout() + bottom.setSpacing(10) + self.summary_label = QLabel("0 appareil") + self.summary_label.setStyleSheet("font-weight: 600;") + self.activity_label = QLabel("Prêt") + self.activity_label.setObjectName("mutedLabel") + self.progress = QProgressBar() + self.progress.setMinimumWidth(240) + self.progress.setMaximumWidth(340) + self.progress.setRange(0, 100) + self.progress.setValue(0) + self.progress.setFormat("%p%") + self.progress.setTextVisible(True) + self.progress.setVisible(False) + bottom.addWidget(self.summary_label) + bottom.addStretch(1) + bottom.addWidget(self.activity_label) + bottom.addWidget(self.progress) + outer.addLayout(bottom) + + self.setCentralWidget(central) + self.setStatusBar(QStatusBar()) + self._reload_group_choices() + + + # ---------- Disposition UI ---------- + def _restore_ui_layout(self) -> None: + self._restoring_layout = True + try: + splitter_sizes = self.settings.value("ui/mainSplitter") + if isinstance(splitter_sizes, (list, tuple)) and len(splitter_sizes) == 2: + try: + values = [int(v) for v in splitter_sizes] + except (TypeError, ValueError): + values = [] + if values and all(v > 0 for v in values): + self.main_splitter.setSizes(values) + + saved = self.settings.value("ui/columnWidths") + widths: list[int] = [] + if isinstance(saved, (list, tuple)) and len(saved) == 4: + try: + widths = [int(v) for v in saved] + except (TypeError, ValueError): + widths = [] + if widths and all(v >= 80 for v in widths): + # Type / version (colonne 2) est volontairement extensible : on ne + # restaure pas une ancienne largeur fixe qui recréerait du vide. + for column, width in enumerate(widths): + if column == 2: + continue + self.tree.header().resizeSection(column, width) + self._columns_user_customized = True + else: + self._columns_user_customized = False + self._apply_balanced_columns(force=True) + finally: + self._restoring_layout = False + + def _save_ui_layout(self) -> None: + if not hasattr(self, "tree"): + return + header = self.tree.header() + self.settings.setValue("ui/columnWidths", [header.sectionSize(i) for i in range(self.tree.columnCount())]) + if hasattr(self, "main_splitter"): + self.settings.setValue("ui/mainSplitter", self.main_splitter.sizes()) + + def _column_resized(self, logical: int, _old_size: int, _new_size: int) -> None: + if self._restoring_layout or logical == 2: + return + self._columns_user_customized = True + self._save_ui_layout() + + def _apply_balanced_columns(self, *, force: bool = False) -> None: + if self._columns_user_customized and not force: + return + viewport = max(720, self.tree.viewport().width()) + show_evolution = not self.tree.isColumnHidden(3) + widths = balanced_column_widths(viewport, show_evolution=show_evolution) + previous = self._restoring_layout + self._restoring_layout = True + try: + header = self.tree.header() + header.setSectionResizeMode(2, QHeaderView.Stretch) + for column, width in enumerate(widths): + if column == 2: + continue + if column == 3 and not show_evolution: + continue + header.resizeSection(column, width) + finally: + self._restoring_layout = previous + + def _update_evolution_visibility(self) -> None: + has_changes = any( + host.change_status in {"Nouveau", "Modifié", "IP modifiée", "Disparu"} + for host in self.hosts.values() + ) + was_hidden = self.tree.isColumnHidden(3) + self.tree.setColumnHidden(3, not has_changes) + # La dernière colonne visible doit toujours occuper l'espace utile : Type / + # version reste donc en mode Stretch, avec ou sans colonne Évolution. + self.tree.header().setSectionResizeMode(2, QHeaderView.Stretch) + if was_hidden != (not has_changes) and not self._columns_user_customized: + self._apply_balanced_columns(force=True) + + def reset_ui_layout(self) -> None: + self.settings.remove("ui/columnWidths") + self.settings.remove("ui/mainSplitter") + self._columns_user_customized = False + self.main_splitter.setSizes([1080, 420]) + self._apply_balanced_columns(force=True) + self.statusBar().showMessage("Disposition réinitialisée", 3000) + + def closeEvent(self, event) -> None: + self._save_ui_layout() + super().closeEvent(event) + + def _show_scan_menu(self) -> None: + """Affiche le menu des profils sous son bouton, sans menu-indicator Qt additionnel.""" + pos = self.scan_menu_btn.mapToGlobal(self.scan_menu_btn.rect().bottomLeft()) + self.scan_menu.popup(pos) + + def _build_menu(self) -> None: + file_menu = self.menuBar().addMenu("Fichier") + export_csv_action = QAction(themed_icon("document-export", "document-save-as"), "Exporter CSV…", self) + export_csv_action.triggered.connect(self.export_csv_dialog) + export_json_action = QAction(themed_icon("document-export", "document-save-as"), "Exporter JSON…", self) + export_json_action.triggered.connect(self.export_json_dialog) + quit_action = QAction(themed_icon("application-exit"), "Quitter", self) + quit_action.triggered.connect(self.close) + file_menu.addActions([export_csv_action, export_json_action]) + file_menu.addSeparator() + file_menu.addAction(quit_action) + + scan_menu = self.menuBar().addMenu("Scan") + std = QAction(themed_icon("media-playback-start"), "Scanner", self) + std.setShortcut("F5") + std.triggered.connect(lambda: self.start_scan(profile="Standard")) + quick = QAction(themed_icon("system-run"), "Scan rapide", self) + quick.setShortcut("Ctrl+F5") + quick.triggered.connect(lambda: self.start_scan(profile="Rapide")) + deep = QAction(themed_icon("system-search"), "Scan approfondi", self) + deep.setShortcut("Shift+F5") + deep.triggered.connect(lambda: self.start_scan(profile="Approfondi")) + refresh = QAction(themed_icon("view-refresh"), "Rafraîchir les interfaces", self) + refresh.triggered.connect(self.refresh_interfaces) + history = QAction(themed_icon("view-history", "document-open-recent"), "Historique…", self) + history.triggered.connect(self.show_history) + clear = QAction(themed_icon("edit-clear"), "Effacer l’affichage", self) + clear.setToolTip("Efface uniquement les résultats visibles ; l’historique et les identifications mémorisées sont conservés") + clear.triggered.connect(self.clear_results) + clear_history = QAction(themed_icon("edit-delete", "user-trash"), "Effacer l’historique des scans…", self) + clear_history.triggered.connect(self.clear_scan_history) + forget_menu = QMenu("Oublier les identifications", self) + forget_menu.setIcon(themed_icon("edit-delete", "edit-clear-history", "user-trash")) + forget_selected = QAction(themed_icon("edit-delete"), "Équipement sélectionné…", self) + forget_selected.triggered.connect(self.forget_selected_identification) + forget_scope = QAction(themed_icon("network-workgroup", "network-wired"), "Réseau courant…", self) + forget_scope.triggered.connect(self.forget_current_scope_identifications) + forget_all = QAction(themed_icon("edit-delete", "user-trash"), "Toutes les identifications…", self) + forget_all.triggered.connect(self.forget_all_identifications) + forget_menu.addActions([forget_selected, forget_scope]) + forget_menu.addSeparator() + forget_menu.addAction(forget_all) + focus_search = QAction(themed_icon("edit-find"), "Rechercher", self) + focus_search.setShortcut("Ctrl+F") + focus_search.triggered.connect(self.filter_edit.setFocus) + self.addAction(focus_search) + scan_menu.addActions([std, quick, deep]) + scan_menu.addSeparator() + scan_menu.addAction(refresh) + scan_menu.addAction(history) + scan_menu.addSeparator() + scan_menu.addAction(clear) + scan_menu.addAction(clear_history) + scan_menu.addMenu(forget_menu) + + view_menu = self.menuBar().addMenu("Affichage") + compact_view = QAction(themed_icon("view-list-icons", "view-list-details"), "Vue compacte", self) + compact_view.triggered.connect(lambda: self.compact_view_btn.setChecked(True)) + detailed_view = QAction(themed_icon("view-list-details", "view-list-icons"), "Vue détaillée", self) + detailed_view.triggered.connect(lambda: self.detail_view_btn.setChecked(True)) + reset_layout = QAction(themed_icon("view-refresh"), "Réinitialiser la disposition", self) + reset_layout.triggered.connect(self.reset_ui_layout) + view_menu.addActions([compact_view, detailed_view]) + view_menu.addSeparator() + view_menu.addAction(reset_layout) + + settings_menu = self.menuBar().addMenu("Paramètres") + vendor_settings = QAction(themed_icon("configure", "preferences-system"), "Identification des constructeurs…", self) + vendor_settings.triggered.connect(self.show_vendor_settings) + settings_menu.addAction(vendor_settings) + + help_menu = self.menuBar().addMenu("Aide") + diagnostics = QAction(themed_icon("tools-report-bug", "system-search"), "Diagnostic des outils…", self) + diagnostics.triggered.connect(self.show_tools_diagnostic) + about = QAction(themed_icon("help-about", "help-contents"), "À propos", self) + about.triggered.connect(self.show_about) + help_menu.addAction(diagnostics) + help_menu.addSeparator() + help_menu.addAction(about) + + # ---------- Privilèges ---------- + def toggle_admin_mode(self, _checked: bool = False) -> None: + if self.admin_mode: + self.admin_mode = False + self._pending_scan_after_auth = False + self._update_admin_button() + self.statusBar().showMessage("Mode administrateur désactivé.", 4000) + return + self._request_admin_mode(show_confirmation=True) + + def _request_admin_mode(self, *, show_confirmation: bool) -> None: + if self.auth_worker and self.auth_worker.isRunning(): + return + diag = privilege_diagnostic() + if not diag.ready: + self._pending_scan_after_auth = False + QMessageBox.critical(self, "Mode administrateur indisponible", diag.detail + "\n\nRéinstalle le paquet LibreNet Scanner 1.0.0 si nécessaire.") + return + if show_confirmation: + answer = QMessageBox.question( + self, + "Activer le mode administrateur", + "LibreNet va demander une authentification via Polkit.\n\n" + "Seul le helper réseau est élevé ; l'interface graphique reste avec votre utilisateur.\n\nContinuer ?", + QMessageBox.Yes | QMessageBox.No, + QMessageBox.Yes, + ) + if answer != QMessageBox.Yes: + self._pending_scan_after_auth = False + return + self.admin_btn.setEnabled(False) + self.admin_btn.setText("Authentification…") + self.activity_label.setText("Authentification Polkit…") + self.auth_worker = PrivilegeAuthWorker(self) + self.auth_worker.result.connect(self._admin_auth_result) + self.auth_worker.finished.connect(lambda: self.admin_btn.setEnabled(True)) + self.auth_worker.start() + + def _admin_auth_result(self, granted: bool, detail: str) -> None: + pending = self._pending_scan_after_auth + self._pending_scan_after_auth = False + if granted: + self.admin_mode = True + self._update_admin_button() + self.activity_label.setText("Mode administrateur actif") + if pending: + self.start_scan(profile=self._pending_profile, skip_admin_prompt=True) + return + self.admin_mode = False + self._update_admin_button() + self.activity_label.setText("Mode standard") + if "annulée" not in detail.casefold() and "refusée" not in detail.casefold(): + QMessageBox.warning(self, "Mode administrateur", detail) + + def _update_admin_button(self) -> None: + self.admin_btn.setText("Admin" if self.admin_mode else "Standard") + self.admin_btn.setIcon(themed_icon("object-unlocked" if self.admin_mode else "object-locked")) + self.admin_btn.setToolTip( + "Privilèges réseau actifs via Polkit. La GUI reste exécutée avec votre utilisateur." + if self.admin_mode else + "Activer les scans ARP/SYN/OS privilégiés via Polkit." + ) + + # ---------- Interfaces et cible ---------- + def refresh_interfaces(self) -> None: + current_name = self.selected_interface().name if self.selected_interface() else "" + self.interfaces = list_ipv4_interfaces() + visible = [i for i in self.interfaces if not i.is_virtual] or self.interfaces + self.interface_combo.blockSignals(True) + self.interface_combo.clear() + selected_idx = 0 + for idx, iface in enumerate(visible): + self.interface_combo.addItem(themed_icon("network-wired"), iface.label, iface) + if iface.name == current_name: + selected_idx = idx + self.interface_combo.blockSignals(False) + if self.interface_combo.count(): + self.interface_combo.setCurrentIndex(selected_idx) + self._interface_changed(selected_idx) + elif not self.target_edit.text(): + self.target_edit.setText("192.168.1.0/24") + self.statusBar().showMessage(f"{len(visible)} interface(s) IPv4 détectée(s)", 3500) + + def _interface_changed(self, index: int) -> None: + iface = self.interface_combo.itemData(index) + if isinstance(iface, NetworkInterface): + net = ipaddress.ip_network(iface.network, strict=False) + if net.prefixlen == 24: + first = net.network_address + 1 + last = net.broadcast_address - 1 + self.target_edit.setText(f"{first} - {last}") + else: + self.target_edit.setText(iface.network) + + def selected_interface(self) -> NetworkInterface | None: + data = self.interface_combo.currentData() + return data if isinstance(data, NetworkInterface) else None + + # ---------- Scan ---------- + def start_scan(self, _checked: bool = False, *, profile: str = "Standard", skip_admin_prompt: bool = False) -> None: + if self.worker and self.worker.isRunning(): + return + try: + target = validate_target(self.target_edit.text()) + count = target_address_count(target) + except ValueError as exc: + QMessageBox.warning(self, "Cible invalide", str(exc)) + return + if count > 4096: + QMessageBox.warning(self, "Cible trop grande", "LibreNet limite un scan à 4096 adresses.") + return + if profile == "Approfondi" and not self.admin_mode and not skip_admin_prompt: + answer = QMessageBox.question( + self, + "Scan approfondi", + "Le mode administrateur permet le SYN scan et la détection OS Nmap.\n\nL'activer maintenant ?", + QMessageBox.Yes | QMessageBox.No | QMessageBox.Cancel, + QMessageBox.Yes, + ) + if answer == QMessageBox.Cancel: + return + if answer == QMessageBox.Yes: + self._pending_scan_after_auth = True + self._pending_profile = profile + self._request_admin_mode(show_confirmation=False) + return + + self.current_scan_privileged = self.admin_mode + signature = profile_signature(profile, self.current_scan_privileged) + previous = self.store.latest_scan(target, profile, signature) + if previous is None: + self.baseline_hosts, self.baseline_date = None, "" + else: + self.baseline_hosts = self.store.load_scan_hosts(int(previous["id"])) + self.baseline_date = previous["created_at"] + + self.hosts.clear() + self._metadata_cache.clear() + self.tree.clear() + self._update_detail_panel(None) + self.scan_had_error = False + self.scan_warnings.clear() + self.notice_label.clear() + self.notice_frame.setVisible(False) + self._routed_scan = not target_is_on_interface(target, self.selected_interface()) + self.current_identity_scope = scan_identity_scope(target, self.selected_interface()) + + request = ScanRequest(target=target, profile=profile, interface=self.selected_interface(), privileged=self.current_scan_privileged) + self.worker = ScanWorker(request, self) + self.worker.progress.connect(self._progress) + self.worker.progress_state.connect(self._progress_state) + self.worker.warning.connect(self._scan_warning) + self.worker.hosts_found.connect(self._merge_hosts) + self.worker.failed.connect(self._scan_failed) + self.worker.completed.connect(lambda: self._scan_finished(target, profile)) + self.scan_started_at = datetime.now().astimezone() + self.scan_btn.setEnabled(False) + self.scan_menu_btn.setEnabled(False) + self.scan_btn.setText("Scan en cours") + self.stop_btn.setEnabled(True) + self.progress.setRange(0, 100) + self.progress.setValue(0) + self.progress.setFormat("%p%") + self.progress.setTextVisible(True) + self.progress.setVisible(True) + self.activity_label.setText(f"Préparation du scan {display_target(target)}…") + self.statusBar().clearMessage() + self.worker.start() + + def stop_scan(self) -> None: + stopping = False + if self.worker and self.worker.isRunning(): + self.worker.stop() + stopping = True + if self.host_worker and self.host_worker.isRunning(): + self.host_worker.stop() + stopping = True + if stopping: + self.stop_btn.setEnabled(False) + self.activity_label.setText("Arrêt du scan en cours…") + self.progress.setRange(0, 0) + self.progress.setTextVisible(False) + + def _progress(self, message: str) -> None: + # La barre d'état système reste réservée aux notifications ponctuelles. + # Le suivi du scan appartient à la ligne d'activité, sinon le dernier message + # de phase restait affiché après la fin du scan. + self.activity_label.setText(message) + + def _progress_state(self, value: int, message: str) -> None: + self.activity_label.setText(message) + self.progress.setVisible(True) + if value < 0: + # Phase de durée inconnue (Naabu/Nmap/arp-scan) : animation honnête plutôt + # qu'un faux pourcentage figé. + self.progress.setRange(0, 0) + self.progress.setTextVisible(False) + return + self.progress.setRange(0, 100) + self.progress.setTextVisible(True) + self.progress.setFormat("%p%") + self.progress.setValue(max(0, min(100, value))) + + def _scan_warning(self, message: str) -> None: + if message not in self.scan_warnings: + self.scan_warnings.append(message) + summary = compact_warning(self.scan_warnings[0]) + if len(self.scan_warnings) > 1: + summary += f" (+{len(self.scan_warnings) - 1})" + full = "\n".join(self.scan_warnings) + self.notice_label.setText(summary) + self.notice_label.setToolTip(full) + self.notice_frame.setToolTip(full) + self.notice_frame.setVisible(True) + self.statusBar().showMessage(message, 9000) + + def _merge_hosts(self, incoming: list[Host]) -> None: + for host in incoming: + existing = self.hosts.get(host.ip) + if existing: + existing.merge(host) + self._apply_cached_online_vendor(existing) + enrich_host(existing) + else: + self._apply_cached_online_vendor(host) + host = enrich_host(host) + self.hosts[host.ip] = host + # La découverte puis le scan de ports arrivent par étapes : la vue se remplit donc immédiatement. + self._refresh_tree() + + def _scan_failed(self, message: str) -> None: + self.scan_had_error = True + self.scan_btn.setEnabled(True) + self.scan_menu_btn.setEnabled(True) + self.scan_btn.setText("Scanner") + self.stop_btn.setEnabled(False) + self.progress.setRange(0, 100) + self.progress.setValue(0) + self.progress.setFormat("Erreur") + self.progress.setTextVisible(True) + self.progress.setVisible(True) + self.statusBar().clearMessage() + QMessageBox.critical(self, "Erreur de scan", message) + self.activity_label.setText("Erreur") + + def _scan_finished(self, target: str, profile: str) -> None: + self.scan_btn.setEnabled(True) + self.scan_menu_btn.setEnabled(True) + self.scan_btn.setText("Scanner") + self.stop_btn.setEnabled(False) + self.statusBar().clearMessage() + self.progress.setRange(0, 100) + self.progress.setTextVisible(True) + if self.worker and self.worker.isInterruptionRequested(): + self.progress.setValue(0) + self.progress.setFormat("Interrompu") + self.progress.setVisible(True) + self.activity_label.setText("Scan interrompu") + return + if self.scan_had_error: + return + active_hosts = list(self.hosts.values()) + # V0.4.9 : mémorise l'identification riche séparément du scan courant. + # Un futur Standard pourra donc afficher l'OS/type découvert ici en + # Approfondi sans polluer l'historique des ports ni la comparaison. + try: + # Appliquer la mémoire AVANT d'actualiser l'observation de l'endpoint. + # Sinon un changement d'IP/MAC LAA pourrait réécrire l'ancienne fiche + # avec les indices du scan courant avant que la corrélation soit évaluée. + self.store.apply_identifications(active_hosts, scope=self.current_identity_scope) + self.store.remember_identifications(active_hosts, profile, scope=self.current_identity_scope) + except (OSError, ValueError) as exc: + self.statusBar().showMessage(f"Identification non mémorisée : {exc}", 5000) + compared = compare_hosts(active_hosts, self.baseline_hosts) + self.hosts = {host.ip: host for host in compared} + self._refresh_tree() + try: + self.store.save_scan(target, profile, active_hosts, profile_signature(profile, self.current_scan_privileged)) + except (OSError, ValueError) as exc: + self.statusBar().showMessage(f"Historique non enregistré : {exc}", 5000) + self.progress.setValue(100) + self.progress.setFormat("100%") + self.progress.setVisible(True) + elapsed = None + if self.scan_started_at is not None: + elapsed = max(0.0, (datetime.now().astimezone() - self.scan_started_at).total_seconds()) + suffix = f" en {elapsed:.1f} s" if elapsed is not None else "" + if self.baseline_hosts is None: + self.activity_label.setText(f"Terminé{suffix}") + else: + self.activity_label.setText(f"Terminé{suffix} — comparaison effectuée") + if self._routed_scan: + self.statusBar().showMessage("Réseau routé : les adresses MAC ne sont généralement pas visibles au-delà du routeur.", 7000) + elif active_hosts and not any(host.mac for host in active_hosts): + self._scan_warning( + "Aucune MAC récupérée sur ce réseau local. Active le mode Administrateur ou ouvre Aide → Diagnostic des outils." + ) + self._start_automatic_online_vendor_lookup() + + # ---------- Constructeurs en ligne ---------- + def _online_vendor_enabled(self) -> bool: + return bool(self.settings.value("privacy/onlineMacLookupEnabled", False, type=bool)) + + def _online_vendor_provider(self) -> str: + value = str(self.settings.value("privacy/onlineMacLookupProvider", PROVIDER_MACLOOKUP)) + return value if value in PROVIDERS else PROVIDER_MACLOOKUP + + def show_vendor_settings(self) -> None: + dialog = VendorSettingsDialog( + enabled=self._online_vendor_enabled(), + provider=self._online_vendor_provider(), + parent=self, + ) + if dialog.exec() != QDialog.Accepted: + return + was_enabled = self._online_vendor_enabled() + self.settings.setValue("privacy/onlineMacLookupEnabled", dialog.online_enabled) + self.settings.setValue("privacy/onlineMacLookupProvider", dialog.provider) + self.settings.sync() + if dialog.online_enabled and not was_enabled: + self.statusBar().showMessage( + f"Recherche en ligne activée — fournisseur : {provider_label(dialog.provider)}", 5000 + ) + self._start_automatic_online_vendor_lookup() + + def _apply_cached_online_vendor(self, host: Host) -> bool: + if host.vendor or not host.mac: + return False + provider = self._online_vendor_provider() + cached = self.store.online_vendor_cache(host.mac, provider) + if not cached: + return False + if bool(cached.get("found")) and cached.get("vendor"): + host.vendor = str(cached["vendor"]) + return True + return False + + def _lookup_selected_vendor_online(self) -> None: + host = self._selected_host() + if not host or not host.mac: + return + self._start_online_vendor_lookup([host], manual=True) + + def _start_automatic_online_vendor_lookup(self) -> None: + if not self._online_vendor_enabled(): + return + unknown = [host for host in self.hosts.values() if host.status == "up" and host.mac and not host.vendor] + if unknown: + self._start_online_vendor_lookup(unknown, manual=False) + + def _start_online_vendor_lookup(self, hosts: list[Host], *, manual: bool) -> None: + if self.vendor_lookup_worker and self.vendor_lookup_worker.isRunning(): + if manual: + self.statusBar().showMessage("Une recherche constructeur en ligne est déjà en cours.", 4000) + return + provider = self._online_vendor_provider() + pending: list[str] = [] + for host in hosts: + if not host.mac: + continue + cached = None if manual else self.store.online_vendor_cache(host.mac, provider) + if cached: + if bool(cached.get("found")) and cached.get("vendor"): + host.vendor = str(cached["vendor"]) + enrich_host(host) + continue + pending.append(host.mac) + self._refresh_tree() + selected = self._selected_host() + if selected: + self._update_detail_panel(selected) + if not pending: + if manual: + self.statusBar().showMessage("Résultat constructeur chargé depuis le cache local.", 4000) + return + self._vendor_lookup_manual = manual + self.online_vendor_btn.setEnabled(False) + self.statusBar().showMessage( + f"Recherche constructeur via {provider_label(provider)}…", 0 if len(pending) > 1 else 6000 + ) + self.vendor_lookup_worker = OnlineVendorLookupWorker(pending, provider, self) + self.vendor_lookup_worker.result.connect(self._online_vendor_result) + self.vendor_lookup_worker.failed.connect(self._online_vendor_failed) + self.vendor_lookup_worker.finished.connect(self._online_vendor_finished) + self.vendor_lookup_worker.start() + + def _online_vendor_result(self, result: OnlineVendorResult) -> None: + self.store.save_online_vendor_cache( + result.mac, result.provider, vendor=result.vendor, found=result.found, + block_type=result.block_type, is_randomized=result.is_randomized, + is_private=result.is_private, checked_at=result.checked_at, + ) + for host in self.hosts.values(): + if host.mac.upper() != result.mac.upper(): + continue + if result.found and result.vendor: + host.vendor = result.vendor + enrich_host(host) + self._refresh_tree() + selected = self._selected_host() + if selected: + self._update_detail_panel(selected) + + def _online_vendor_failed(self, mac: str, message: str) -> None: + if self._vendor_lookup_manual: + QMessageBox.warning(self, "Recherche constructeur", f"{mac} : {message}") + else: + self.statusBar().showMessage(f"Recherche constructeur : {message}", 6000) + + def _online_vendor_finished(self) -> None: + self.online_vendor_btn.setEnabled(bool(self._selected_host() and self._selected_host().mac)) + provider = self._online_vendor_provider() + if self._vendor_lookup_manual: + host = self._selected_host() + if host and host.vendor: + self.statusBar().showMessage(f"Constructeur trouvé : {host.vendor}", 5000) + elif host and is_locally_administered(host.mac): + self.statusBar().showMessage( + "Adresse MAC locale (LAA) : aucun constructeur fiable ne peut être déduit.", 6000 + ) + else: + self.statusBar().showMessage(f"Aucun constructeur trouvé via {provider_label(provider)}.", 5000) + else: + self.statusBar().showMessage("Recherche des constructeurs en ligne terminée.", 4000) + self._vendor_lookup_manual = False + + # ---------- Arbre et filtrage ---------- + def _host_has_shared_mac(self, host: Host) -> bool: + mac = normalize_mac(host.mac) + return bool(mac and mac in shared_macs(self.hosts.values())) + + def _metadata_key(self, host: Host) -> str: + return identity_key(host, shared_mac=self._host_has_shared_mac(host)) + + def _metadata(self, host: Host) -> dict[str, object]: + key = self._metadata_key(host) + if key not in self._metadata_cache: + self._metadata_cache[key] = self.store.host_metadata( + host, shared_mac=self._host_has_shared_mac(host) + ) + return self._metadata_cache[key] + + def _host_title(self, host: Host) -> str: + return host.hostname or host.ip + + def _type_version_text(self, host: Host) -> str: + """Compact but useful summary for the main list.""" + device = host.effective_device_type or "Hôte" + os_name = (host.effective_os_name or "").strip() + suffix = "" + if host.type_is_remembered or host.os_is_remembered: + suffix = " · mémorisé" + if not os_name: + return device + suffix + # Avoid repeating obvious generic family names twice. + if os_name.casefold() in device.casefold() or device.casefold() in os_name.casefold(): + return device + suffix + if len(os_name) > 38: + os_name = os_name[:35].rstrip() + "…" + return f"{device} · {os_name}{suffix}" + + def _identification_tooltip(self, host: Host) -> str: + lines: list[str] = [] + if host.effective_device_type: + lines.append(host.effective_device_type) + if host.effective_os_name: + lines.append(host.effective_os_name) + if host.remembered_match_score: + lines.append( + f"Corrélation historique : {host.remembered_match_score}%" + + (f" — {host.remembered_match_reason}" if host.remembered_match_reason else "") + ) + if host.type_is_remembered: + detail = f"Type mémorisé depuis un scan {host.remembered_type_source or 'antérieur'}" + if host.remembered_type_seen_at: + detail += f" du {_display_timestamp(host.remembered_type_seen_at)}" + lines.append(detail) + if host.os_is_remembered: + detail = f"OS mémorisé depuis un scan {host.remembered_os_source or 'antérieur'}" + if host.remembered_os_seen_at: + detail += f" du {_display_timestamp(host.remembered_os_seen_at)}" + lines.append(detail) + if not lines and host.vendor: + lines.append(host.vendor) + return "\n".join(lines) + + def _remembered_os_tooltip(self, host: Host) -> str: + if not host.os_is_remembered: + if not host.os_name: + return "" + if host.os_accuracy is None: + return "Détecté lors du scan courant" + nature = "estimation Nmap" if host.os_accuracy < 100 else "correspondance Nmap" + return f"Détecté lors du scan courant — {nature} {host.os_accuracy}%" + source = host.remembered_os_source or "scan antérieur" + when = _display_timestamp(host.remembered_os_seen_at) if host.remembered_os_seen_at else "" + suffix = f" du {when}" if when else "" + os_accuracy = ( + f" — précision Nmap {host.remembered_os_accuracy}%" + if host.remembered_os_accuracy is not None else "" + ) + confidence = f" — corrélation équipement {host.remembered_match_score}%" if host.remembered_match_score else "" + reason = f" ({host.remembered_match_reason})" if host.remembered_match_reason else "" + return f"Dernière identification connue — {source}{suffix}{os_accuracy}{confidence}{reason}" + + def _device_icon(self, host: Host) -> QIcon: + # V0.4.12 : l'icône de la première colonne représente uniquement + # le TYPE D'ÉQUIPEMENT. L'OS dispose de sa propre icône séparée. + return device_icon(host) + + def _os_icon(self, host: Host) -> QIcon: + return os_icon(host) + + def _service_icon(self, port: PortInfo) -> QIcon: + if _port_url(Host("0.0.0.0", ports=[port]), port): + return themed_icon("internet-web-browser") + if port.port == 22: + return themed_icon("utilities-terminal") + if port.port == 445: + return themed_icon("folder-network") + if port.port == 3389: + return themed_icon("krdc") + if port.port in {53, 67, 68, 161, 162}: + return themed_icon("network-wired") + return themed_icon("network-server") + + def _service_title(self, port: PortInfo) -> str: + return port.service or "Service réseau" + + def _toggle_view_mode(self, detailed: bool) -> None: + if detailed and not self.detail_view_btn.isChecked(): + self.detail_view_btn.setChecked(True) + return + if not detailed and not self.compact_view_btn.isChecked(): + self.compact_view_btn.setChecked(True) + return + self.tree.setRootIsDecorated(detailed) + self._refresh_tree() + + def _set_view_filter(self, value: str) -> None: + self._view_filter_value = value + self.view_filter.setText(value) + for action in self._view_filter_actions: + action.setChecked(action.text() == value) + self._apply_filters() + + def _set_group_filter(self, value: str) -> None: + self._group_filter_value = value + self.group_filter.setText(value) + menu = self.group_filter.menu() + if menu is not None: + for action in menu.actions(): + action.setChecked(action.text() == value) + self._apply_filters() + + def _refresh_tree(self) -> None: + selected = self._selected_host() + selected_ip = selected.ip if selected else None + expanded = { + self.tree.topLevelItem(i).data(0, ROLE_IP) + for i in range(self.tree.topLevelItemCount()) + if self.tree.topLevelItem(i).isExpanded() + } + detailed = self.view_mode_btn.isChecked() if hasattr(self, "view_mode_btn") else True + self.tree.setUpdatesEnabled(False) + self.tree.setSortingEnabled(False) + self.tree.clear() + self.tree.setRootIsDecorated(detailed) + selected_item = None + palette = self.tree.palette() + muted = palette.color(self.tree.foregroundRole()) + muted.setAlpha(165) + + for host in sorted(self.hosts.values(), key=lambda h: ipaddress.ip_address(h.ip)): + item = IPTreeWidgetItem(self.tree) + item.setData(0, ROLE_IP, host.ip) + item.setData(0, ROLE_KIND, KIND_HOST) + item.setText(0, self._host_title(host)) + if self._metadata(host).get("favorite"): + font = item.font(0) + font.setBold(True) + item.setFont(0, font) + item.setToolTip(0, "Favori • " + (host.change_detail or host.effective_device_type)) + item.setText(1, host.ip) + item.setText(2, self._type_version_text(host)) + item.setText(3, "" if host.change_status in {"", "Inchangé"} else host.change_status) + icon = self._device_icon(host) + if not icon.isNull(): + item.setIcon(0, icon) + os_family_icon = self._os_icon(host) + if host.effective_os_name and not os_family_icon.isNull(): + item.setIcon(2, os_family_icon) + item.setToolTip(0, host.change_detail or host.effective_device_type) + item.setToolTip(1, f"Adresse IP : {host.ip}") + item.setToolTip(2, self._identification_tooltip(host)) + item.setToolTip(3, host.change_detail) + + if host.status != "up": + for col in range(self.tree.columnCount()): + item.setForeground(col, muted) + elif host.change_status == "Nouveau": + item.setIcon(3, themed_icon("list-add")) + elif host.change_status in {"Modifié", "IP modifiée"}: + item.setIcon(3, themed_icon("document-edit")) + elif host.change_status == "Disparu": + item.setIcon(3, themed_icon("list-remove")) + + if detailed: + for port in sorted((p for p in host.ports if p.state == "open"), key=lambda p: (p.protocol, p.port)): + child = QTreeWidgetItem(item) + child.setData(0, ROLE_IP, host.ip) + child.setData(0, ROLE_KIND, KIND_SERVICE) + child.setData(0, ROLE_PORT, port.port) + child.setText(0, self._service_title(port)) + child.setText(1, f"{port.port}/{port.protocol}") + product = " ".join(v for v in (port.product, port.version) if v).strip() + child.setText(2, product) + icon = self._service_icon(port) + if not icon.isNull(): + child.setIcon(0, icon) + child.setToolTip(0, port.details) + child.setToolTip(1, "Double-clique pour ouvrir le service lorsqu'une action est disponible.") + child.setForeground(0, muted) + child.setForeground(1, muted) + child.setForeground(2, muted) + + if detailed and host.ip in expanded: + item.setExpanded(True) + if selected_ip == host.ip: + selected_item = item + + self.tree.setSortingEnabled(True) + self.tree.sortByColumn(1, Qt.AscendingOrder) + self._update_evolution_visibility() + self.tree.setUpdatesEnabled(True) + self._apply_filters() + if selected_item: + self.tree.setCurrentItem(selected_item) + self._update_summary() + + def _apply_filters(self) -> None: + query = self.filter_edit.text().strip().casefold() + mode = self._view_filter_value + group = self._group_filter_value + visible_count = 0 + for i in range(self.tree.topLevelItemCount()): + item = self.tree.topLevelItem(i) + host = self.hosts.get(str(item.data(0, ROLE_IP))) + hide = host is None + if host: + meta = self._metadata(host) + extra = f"{meta.get('group_name','')} {meta.get('note','')}".casefold() + if query and query not in (host.searchable_text + " " + extra): + hide = True + if not hide: + if mode == "Actifs" and host.status != "up": hide = True + elif mode == "Favoris" and not meta.get("favorite"): hide = True + elif mode == "Changements" and host.change_status not in {"Nouveau", "Modifié", "IP modifiée", "Disparu"}: hide = True + elif mode == "Nouveaux" and host.change_status != "Nouveau": hide = True + elif mode == "Modifiés" and host.change_status not in {"Modifié", "IP modifiée"}: hide = True + elif mode == "Disparus" and host.change_status != "Disparu": hide = True + if not hide and group != "Tous les groupes" and str(meta.get("group_name", "")) != group: + hide = True + item.setHidden(hide) + if not hide: + visible_count += 1 + self._update_summary(visible_count) + + def _update_summary(self, visible_count: int | None = None) -> None: + values = list(self.hosts.values()) + active = sum(h.status == "up" for h in values) + services = sum(sum(p.state == "open" for p in h.ports) for h in values) + favorites = sum(bool(self._metadata(h).get("favorite")) for h in values) if values else 0 + changes = sum(h.change_status in {"Nouveau", "Modifié", "IP modifiée", "Disparu"} for h in values) + if visible_count is None: + visible_count = sum(not self.tree.topLevelItem(i).isHidden() for i in range(self.tree.topLevelItemCount())) + parts = [f"{active} appareil(s)", f"{services} service(s)"] + if favorites: + parts.append(f"{favorites} favori(s)") + if changes: + parts.append(f"{changes} évolution(s)") + if visible_count != len(values): + parts.append(f"{visible_count} affiché(s)") + self.summary_label.setText(" • ".join(parts)) + + def _reload_group_choices(self) -> None: + groups = self.store.known_groups() + current_filter = self._group_filter_value if hasattr(self, "_group_filter_value") else "Tous les groupes" + current_edit = self.group_edit.currentText() if hasattr(self, "group_edit") else "" + if hasattr(self, "group_filter"): + if current_filter != "Tous les groupes" and current_filter not in groups: + current_filter = "Tous les groupes" + self._group_filter_value = current_filter + menu = QMenu(self.group_filter) + for label in ["Tous les groupes", *groups]: + action = menu.addAction(themed_icon("folder"), label) + action.setCheckable(True) + action.setChecked(label == current_filter) + action.triggered.connect(lambda _checked=False, value=label: self._set_group_filter(value)) + self.group_filter.setMenu(menu) + self.group_filter.setText(current_filter) + self.group_filter.setVisible(bool(groups)) + if hasattr(self, "group_edit"): + self.group_edit.clear() + self.group_edit.addItems(groups) + self.group_edit.setEditText(current_edit) + + # ---------- Sélection, détails, favoris ---------- + def _host_from_item(self, item: QTreeWidgetItem | None) -> Host | None: + if item is None: + return None + ip = item.data(0, ROLE_IP) + return self.hosts.get(str(ip)) if ip else None + + def _selected_host(self) -> Host | None: + return self._host_from_item(self.tree.currentItem()) + + def _selection_changed(self, item: QTreeWidgetItem | None) -> None: + self._update_detail_panel(self._host_from_item(item)) + + def _detail_service_double_clicked(self, item: QTreeWidgetItem, _column: int) -> None: + host = self._selected_host() + if not host: + return + port_no = item.data(0, ROLE_PORT) + if port_no is None: + return + port = next((p for p in host.ports if p.port == int(port_no) and p.state == "open"), None) + self._open_service(host, port) + + def _web_action_label(self, host: Host) -> str: + if host.effective_device_type == "Hyperviseur Proxmox": + return "Ouvrir Proxmox" + if host.effective_device_type == "Proxmox Backup Server": + return "Ouvrir PBS" + if host.effective_device_type == "NAS Synology": + return "Ouvrir DSM" + if host.effective_device_type == "Pare-feu / routeur": + return "Ouvrir l'interface" + return "Ouvrir le Web" + + def _rebuild_more_menu(self, host: Host | None) -> None: + menu = QMenu(self.more_btn) + if not host: + self.more_btn.setMenu(menu) + self.more_btn.setEnabled(False) + return + + open_ports = {p.port for p in host.ports if p.state == "open"} + if _port_url(host) is not None: + web = menu.addAction(themed_icon("internet-web-browser"), self._web_action_label(host)) + web.triggered.connect(self._open_selected_web) + if 22 in open_ports: + ssh = menu.addAction(themed_icon("utilities-terminal"), "Ouvrir en SSH") + ssh.triggered.connect(lambda: self._run_terminal(["ssh", host.ip])) + if 445 in open_ports: + smb = menu.addAction(themed_icon("folder-network"), "Parcourir les partages") + smb.triggered.connect(lambda: QDesktopServices.openUrl(QUrl(f"smb://{host.ip}/"))) + if 3389 in open_ports and shutil.which("remmina") is not None: + rdp = menu.addAction(themed_icon("krdc"), "Ouvrir en RDP") + rdp.triggered.connect(self._open_selected_rdp) + if menu.actions(): + menu.addSeparator() + + ping = menu.addAction(themed_icon("network-transmit-receive"), "Ping") + ping.triggered.connect(lambda: self._run_terminal(["ping", host.ip])) + traceroute = menu.addAction(themed_icon("network-wired"), "Traceroute") + traceroute.setEnabled(shutil.which("traceroute") is not None) + traceroute.triggered.connect(lambda: self._run_terminal(["traceroute", host.ip])) + scan = menu.addAction(themed_icon("system-search"), "Scan détaillé — 1000 ports") + scan.setEnabled(host.status == "up") + scan.triggered.connect(self.scan_selected_host) + wol = menu.addAction(themed_icon("system-run"), "Wake-on-LAN") + wol.setEnabled(bool(host.mac)) + wol.triggered.connect(self._wake_selected) + menu.addSeparator() + copy_ip = menu.addAction(themed_icon("edit-copy"), "Copier l'adresse IP") + copy_ip.triggered.connect(lambda: QApplication.clipboard().setText(host.ip)) + copy_mac = menu.addAction(themed_icon("edit-copy"), "Copier l'adresse MAC") + copy_mac.setEnabled(bool(host.mac)) + copy_mac.triggered.connect(lambda: QApplication.clipboard().setText(host.mac)) + self.more_btn.setMenu(menu) + self.more_btn.setEnabled(True) + + def _update_detail_panel(self, host: Host | None) -> None: + enabled = host is not None + for widget in ( + self.web_btn, self.ssh_btn, self.smb_btn, self.rdp_btn, + self.hostscan_btn, self.wol_btn, self.favorite_btn, + self.group_edit, self.note_edit, self.save_meta_btn, self.online_vendor_btn, + ): + widget.setEnabled(enabled) + + self.detail_services_tree.clear() + self._rebuild_more_menu(host) + + if not host: + icon = themed_icon("network-wired") + self.detail_icon.setPixmap(icon.pixmap(46, 46) if not icon.isNull() else QIcon().pixmap(36, 36)) + self.detail_name.setText("Aucun équipement sélectionné") + self.detail_type.setText("Sélectionne un appareil pour afficher ses détails et ses actions.") + for label in ( + self.detail_ip, self.detail_mac, self.detail_vendor, + self.detail_os, self.detail_latency, self.detail_seen, + ): + label.setText("—") + self.detail_os_icon.clear() + self.detail_services_tree.setVisible(False) + self.detail_services.setVisible(True) + self.detail_services.setText("Aucun service à afficher") + for button in (self.web_btn, self.ssh_btn, self.smb_btn, self.rdp_btn): + button.setVisible(False) + self.more_btn.setVisible(False) + self.favorite_btn.blockSignals(True) + self.favorite_btn.setChecked(False) + self.favorite_btn.setText("Ajouter aux favoris") + self.favorite_btn.blockSignals(False) + self.group_edit.setEditText("") + self.note_edit.clear() + self.online_vendor_btn.setVisible(False) + return + + meta = self._metadata(host) + icon = self._device_icon(host) + self.detail_icon.setPixmap(icon.pixmap(46, 46) if not icon.isNull() else QIcon().pixmap(36, 36)) + self.detail_name.setText(host.hostname or host.ip) + status_parts = [host.effective_device_type + (" (mémorisé)" if host.type_is_remembered else "")] + if host.change_status and host.change_status != "Inchangé": + status_parts.append(host.change_status) + if host.status != "up": + status_parts.append("hors ligne") + self.detail_type.setText(" • ".join(status_parts)) + self.detail_ip.setText(host.ip + (f" (avant : {host.previous_ip})" if host.previous_ip else "")) + self.detail_mac.setText(host.mac or "—") + if host.vendor: + self.detail_vendor.setText(host.vendor) + elif host.mac and is_locally_administered(host.mac): + self.detail_vendor.setText("Non déterminable — adresse locale (LAA)") + else: + self.detail_vendor.setText("—") + self.online_vendor_btn.setVisible(bool(host.mac)) + self.online_vendor_btn.setEnabled(bool(host.mac) and not (self.vendor_lookup_worker and self.vendor_lookup_worker.isRunning())) + self.online_vendor_btn.setText("Actualiser en ligne" if host.vendor else "Rechercher en ligne") + self.online_vendor_btn.setToolTip( + f"Envoyer cette MAC à {provider_label(self._online_vendor_provider())} et mettre le résultat en cache localement" + ) + effective_os = host.effective_os_name + if effective_os: + if host.os_is_remembered: + os_text = effective_os + " (mémorisé)" + elif host.os_accuracy is not None and host.os_accuracy < 100: + os_text = f"{effective_os} (estimation {host.os_accuracy} %)" + else: + os_text = effective_os + else: + os_text = "—" + self.detail_os.setText(os_text) + self.detail_os.setToolTip(self._remembered_os_tooltip(host)) + os_family_icon = self._os_icon(host) + self.detail_os_icon.setPixmap( + os_family_icon.pixmap(24, 24) if effective_os and not os_family_icon.isNull() else QIcon().pixmap(22, 22) + ) + self.detail_os_icon.setToolTip("Famille de système d'exploitation") + self.detail_latency.setText(f"{host.latency_ms:.1f} ms" if host.latency_ms is not None else "—") + self.detail_seen.setText(_display_timestamp(host.last_seen) or "—") + + open_services = [p for p in host.ports if p.state == "open"] + self.detail_services_tree.setVisible(bool(open_services)) + self.detail_services.setVisible(not bool(open_services)) + self.detail_services.setText("Aucun service détecté") + for port in sorted(open_services, key=lambda p: (p.protocol, p.port)): + item = QTreeWidgetItem(self.detail_services_tree) + item.setData(0, ROLE_PORT, port.port) + item.setText(0, self._service_title(port)) + item.setText(1, f"{port.port}/{port.protocol}") + icon = self._service_icon(port) + if not icon.isNull(): + item.setIcon(0, icon) + item.setToolTip(0, port.details) + + fav = bool(meta.get("favorite")) + self.favorite_btn.blockSignals(True) + self.favorite_btn.setChecked(fav) + self.favorite_btn.setText("Favori" if fav else "Ajouter aux favoris") + fav_icon = themed_icon("rating" if fav else "rating-unrated") + if not fav_icon.isNull(): + self.favorite_btn.setIcon(fav_icon) + self.favorite_btn.blockSignals(False) + self.group_edit.setEditText(str(meta.get("group_name", ""))) + self.note_edit.setPlainText(str(meta.get("note", ""))) + + open_ports = {p.port for p in open_services} + is_up = host.status == "up" + web_available = is_up and _port_url(host) is not None + ssh_available = is_up and 22 in open_ports + smb_available = is_up and 445 in open_ports + rdp_available = is_up and 3389 in open_ports and shutil.which("remmina") is not None + + self.web_btn.setEnabled(web_available) + self.web_btn.setText(self._web_action_label(host)) + self.ssh_btn.setEnabled(ssh_available) + self.smb_btn.setEnabled(smb_available) + self.rdp_btn.setEnabled(rdp_available) + + # Deux actions directes maximum : le reste reste disponible via « Plus ». + direct = [ + (self.web_btn, web_available), + (self.ssh_btn, ssh_available), + (self.rdp_btn, rdp_available), + (self.smb_btn, smb_available), + ] + shown = 0 + for button, available in direct: + show = bool(available and shown < 2) + button.setVisible(show) + if show: + shown += 1 + self.more_btn.setVisible(True) + self.hostscan_btn.setEnabled(is_up) + self.wol_btn.setEnabled(bool(host.mac)) + + def _toggle_favorite(self, checked: bool) -> None: + host = self._selected_host() + if not host: return + self.store.save_host_metadata(host, favorite=checked, shared_mac=self._host_has_shared_mac(host)) + self._metadata_cache.pop(self._metadata_key(host), None) + self._reload_group_choices() + self._refresh_tree() + self.statusBar().showMessage("Ajouté aux favoris" if checked else "Retiré des favoris", 3000) + + def _save_selected_metadata(self) -> None: + host = self._selected_host() + if not host: return + self.store.save_host_metadata( + host, group_name=self.group_edit.currentText(), note=self.note_edit.toPlainText(), + shared_mac=self._host_has_shared_mac(host), + ) + self._metadata_cache.pop(self._metadata_key(host), None) + self._reload_group_choices() + self._refresh_tree() + self.statusBar().showMessage("Groupe et note enregistrés", 3000) + + # ---------- Actions ---------- + def _tree_double_clicked(self, item: QTreeWidgetItem, _column: int) -> None: + host = self._host_from_item(item) + if not host: + return + if item.data(0, ROLE_KIND) == KIND_SERVICE: + port_no = int(item.data(0, ROLE_PORT)) + port = next((p for p in host.ports if p.port == port_no and p.state == "open"), None) + self._open_service(host, port) + return + if not self.view_mode_btn.isChecked(): + self.view_mode_btn.setChecked(True) + # _toggle_view_mode reconstruit l'arbre ; on retrouve ensuite l'hôte. + for idx in range(self.tree.topLevelItemCount()): + candidate = self.tree.topLevelItem(idx) + if candidate.data(0, ROLE_IP) == host.ip: + candidate.setExpanded(True) + self.tree.setCurrentItem(candidate) + break + else: + item.setExpanded(not item.isExpanded()) + + def _open_service(self, host: Host, port: PortInfo | None) -> None: + if not port: + return + url = _port_url(host, port) + if url: + QDesktopServices.openUrl(QUrl(url)) + elif port.port == 22: + self._run_terminal(["ssh", host.ip]) + elif port.port == 445: + QDesktopServices.openUrl(QUrl(f"smb://{host.ip}/")) + elif port.port == 3389 and shutil.which("remmina"): + subprocess.Popen(["remmina", "-c", f"rdp://{host.ip}"]) + else: + self.statusBar().showMessage(f"{port.details} — aucune action directe associée", 4000) + + def _context_menu(self, pos) -> None: + item = self.tree.itemAt(pos) + if item is None: + return + self.tree.setCurrentItem(item) + host = self._host_from_item(item) + if not host: + return + menu = QMenu(self) + if item.data(0, ROLE_KIND) == KIND_SERVICE: + open_action = menu.addAction(themed_icon("document-open"), "Ouvrir ce service") + menu.addSeparator() + else: + open_action = None + + web = menu.addAction(themed_icon("internet-web-browser"), self._web_action_label(host)) + ssh = menu.addAction(themed_icon("utilities-terminal"), "Ouvrir en SSH") + smb = menu.addAction(themed_icon("folder-network"), "Parcourir les partages") + rdp = menu.addAction(themed_icon("krdc"), "Ouvrir en RDP") + menu.addSeparator() + ping = menu.addAction(themed_icon("network-transmit-receive"), "Ping") + scan = menu.addAction(themed_icon("system-search"), "Scan détaillé — 1000 ports") + wol = menu.addAction(themed_icon("system-run"), "Wake-on-LAN") + menu.addSeparator() + meta = self._metadata(host) + favorite = menu.addAction( + themed_icon("rating" if not meta.get("favorite") else "rating-unrated"), + "Retirer des favoris" if meta.get("favorite") else "Ajouter aux favoris", + ) + menu.addSeparator() + copy_ip = menu.addAction(themed_icon("edit-copy"), "Copier l'adresse IP") + copy_mac = menu.addAction(themed_icon("edit-copy"), "Copier l'adresse MAC") + copy_mac.setEnabled(bool(host.mac)) + web.setEnabled(_port_url(host) is not None) + ssh.setEnabled(any(p.port == 22 and p.state == "open" for p in host.ports)) + smb.setEnabled(any(p.port == 445 and p.state == "open" for p in host.ports)) + rdp.setEnabled(any(p.port == 3389 and p.state == "open" for p in host.ports) and shutil.which("remmina") is not None) + wol.setEnabled(bool(host.mac)) + + chosen = menu.exec(self.tree.viewport().mapToGlobal(pos)) + if chosen == open_action: + port_no = int(item.data(0, ROLE_PORT)) + self._open_service(host, next((p for p in host.ports if p.port == port_no), None)) + elif chosen == web: + self._open_selected_web() + elif chosen == ssh: + self._run_terminal(["ssh", host.ip]) + elif chosen == smb: + QDesktopServices.openUrl(QUrl(f"smb://{host.ip}/")) + elif chosen == rdp: + self._open_selected_rdp() + elif chosen == ping: + self._run_terminal(["ping", host.ip]) + elif chosen == scan: + self.scan_selected_host() + elif chosen == wol: + self._wake_host(host) + elif chosen == favorite: + self._toggle_favorite(not bool(meta.get("favorite"))) + elif chosen == copy_ip: + QApplication.clipboard().setText(host.ip) + elif chosen == copy_mac: + QApplication.clipboard().setText(host.mac) + + def _run_terminal(self, command: list[str]) -> None: + terminal = shutil.which("konsole") + if not terminal: + QMessageBox.warning(self, "Konsole absent", "Konsole n'est pas installé ou n'est pas dans le PATH.") + return + try: + subprocess.Popen([terminal, "-e", *command]) + except OSError as exc: + QMessageBox.critical(self, "Erreur", str(exc)) + + def _run_selected_terminal(self, prefix: list[str]) -> None: + host = self._selected_host() + if host: self._run_terminal([*prefix, host.ip]) + + def _open_selected_web(self) -> None: + host = self._selected_host() + if not host: return + url = _port_url(host) + if url: QDesktopServices.openUrl(QUrl(url)) + + def _open_selected_smb(self) -> None: + host = self._selected_host() + if host: QDesktopServices.openUrl(QUrl(f"smb://{host.ip}/")) + + def _open_selected_rdp(self) -> None: + host = self._selected_host() + if not host or not shutil.which("remmina"): return + try: + subprocess.Popen(["remmina", "-c", f"rdp://{host.ip}"]) + except OSError as exc: + QMessageBox.critical(self, "Erreur Remmina", str(exc)) + + def _wake_selected(self) -> None: + host = self._selected_host() + if host: self._wake_host(host) + + def _wake_host(self, host: Host) -> None: + iface = self.selected_interface() + broadcast = "255.255.255.255" + if iface and target_is_on_interface(host.ip, iface): + broadcast = str(ipaddress.ip_network(iface.network, strict=False).broadcast_address) + try: + send_magic_packet(host.mac, broadcast=broadcast) + except (OSError, ValueError) as exc: + QMessageBox.critical(self, "Wake-on-LAN", str(exc)) + return + self.statusBar().showMessage(f"Paquet Wake-on-LAN envoyé à {host.mac}", 4500) + + def scan_selected_host(self) -> None: + host = self._selected_host() + if not host or host.status != "up": return + if self.host_worker and self.host_worker.isRunning(): + QMessageBox.information(self, "Scan en cours", "Un scan détaillé est déjà en cours.") + return + self.host_worker = HostScanWorker(host.ip, self, privileged=self.admin_mode) + self.host_worker.progress.connect(self._progress) + self.host_worker.result.connect(self._host_scan_result) + self.host_worker.failed.connect(lambda m: QMessageBox.critical(self, "Erreur Nmap", m)) + self.host_worker.completed.connect(self._host_scan_finished) + self.progress.setRange(0, 0) + self.progress.setTextVisible(False) + self.progress.setVisible(True) + self.statusBar().clearMessage() + self.host_worker.start() + + def _host_scan_finished(self) -> None: + self.progress.setRange(0, 100) + self.progress.setValue(100) + self.progress.setFormat("100%") + self.progress.setTextVisible(True) + self.progress.setVisible(True) + self.activity_label.setText("Scan détaillé terminé") + self.statusBar().clearMessage() + + def _host_scan_result(self, result: Host) -> None: + if result.ip in self.hosts: + self.hosts[result.ip].merge(result) + enrich_host(self.hosts[result.ip]) + host = self.hosts[result.ip] + else: + host = enrich_host(result) + self.hosts[result.ip] = host + try: + shared = shared_macs(self.hosts.values()) + self.store.remember_host_identification( + host, "Détaillé", shared_mac=normalize_mac(host.mac) in shared, + scope=self.current_identity_scope, + ) + self.store.apply_identifications(list(self.hosts.values()), scope=self.current_identity_scope) + except (OSError, ValueError): + pass + self._refresh_tree() + + # ---------- Export / historique / diagnostic ---------- + def export_csv_dialog(self) -> None: + if not self.hosts: + QMessageBox.information(self, "Export", "Aucun résultat à exporter.") + return + filename, _ = QFileDialog.getSaveFileName(self, "Exporter en CSV", "librenet-scan.csv", "CSV (*.csv)") + if filename: + export_csv(filename, list(self.hosts.values())) + self.statusBar().showMessage(f"Export CSV : {filename}", 5000) + + def export_json_dialog(self) -> None: + if not self.hosts: + QMessageBox.information(self, "Export", "Aucun résultat à exporter.") + return + filename, _ = QFileDialog.getSaveFileName(self, "Exporter en JSON", "librenet-scan.json", "JSON (*.json)") + if filename: + export_json(filename, list(self.hosts.values())) + self.statusBar().showMessage(f"Export JSON : {filename}", 5000) + + def show_history(self) -> None: + HistoryDialog(self.store, self).exec() + + def clear_results(self) -> None: + """Efface seulement la vue courante, jamais les données persistantes.""" + self.hosts.clear() + self.baseline_hosts = None + self.baseline_date = "" + self._metadata_cache.clear() + self.tree.clear() + self._update_detail_panel(None) + self.summary_label.setText("0 appareil") + self.activity_label.setText("Prêt") + self.statusBar().showMessage( + "Affichage effacé — historique et identifications mémorisées conservés", 4500 + ) + + def clear_scan_history(self) -> None: + answer = QMessageBox.question( + self, + "Effacer l’historique des scans", + "Supprimer tous les anciens scans ?\n\n" + "Les favoris, groupes, notes et identifications mémorisées seront conservés.", + QMessageBox.Yes | QMessageBox.No, + QMessageBox.No, + ) + if answer != QMessageBox.Yes: + return + try: + count = self.store.clear_scan_history() + except (OSError, ValueError) as exc: + QMessageBox.critical(self, "Historique", str(exc)) + return + self.baseline_hosts = None + self.baseline_date = "" + self.statusBar().showMessage(f"Historique effacé : {count} scan(s)", 5000) + + def _identity_scope_for_current_target(self) -> str: + try: + return scan_identity_scope(self.target_edit.text().strip(), self.selected_interface()) + except ValueError: + return self.current_identity_scope + + @staticmethod + def _clear_remembered_fields(host: Host) -> None: + 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 = "" + + def forget_selected_identification(self) -> None: + host = self._selected_host() + if host is None: + QMessageBox.information(self, "Identification", "Sélectionne d’abord un équipement.") + return + answer = QMessageBox.question( + self, + "Oublier l’identification", + f"Oublier l’OS/type mémorisé pour {host.hostname or host.ip} ?\n\n" + "Les résultats du scan, favoris, groupe et notes ne seront pas supprimés.", + QMessageBox.Yes | QMessageBox.No, + QMessageBox.No, + ) + if answer != QMessageBox.Yes: + return + shared = normalize_mac(host.mac) in shared_macs(self.hosts.values()) + scope = self._identity_scope_for_current_target() + count = self.store.forget_identification_for_host(host, shared_mac=shared, scope=scope) + self._clear_remembered_fields(host) + self._refresh_tree() + self._update_detail_panel(host) + self.statusBar().showMessage(f"Identification oubliée ({count} entrée(s))", 5000) + + def forget_current_scope_identifications(self) -> None: + scope = self._identity_scope_for_current_target() + if not scope: + QMessageBox.information(self, "Identification", "Impossible de déterminer le réseau courant.") + return + answer = QMessageBox.question( + self, + "Oublier les identifications du réseau", + f"Oublier toutes les identifications mémorisées pour :\n{scope} ?\n\n" + "L’historique des scans, les favoris, groupes et notes sont conservés.", + QMessageBox.Yes | QMessageBox.No, + QMessageBox.No, + ) + if answer != QMessageBox.Yes: + return + count = self.store.forget_identifications_for_scope(scope) + for host in self.hosts.values(): + if not host.is_local: + self._clear_remembered_fields(host) + self._refresh_tree() + self._update_detail_panel(self._selected_host()) + self.statusBar().showMessage(f"{count} identification(s) oubliée(s) pour le réseau courant", 5000) + + def forget_all_identifications(self) -> None: + answer = QMessageBox.question( + self, + "Oublier toutes les identifications", + "Oublier tous les OS/types mémorisés ?\n\n" + "Cette action ne supprime ni l’historique des scans, ni les favoris, groupes ou notes.", + QMessageBox.Yes | QMessageBox.No, + QMessageBox.No, + ) + if answer != QMessageBox.Yes: + return + count = self.store.forget_all_identifications() + for host in self.hosts.values(): + self._clear_remembered_fields(host) + self._refresh_tree() + self._update_detail_panel(self._selected_host()) + self.statusBar().showMessage(f"Toutes les identifications ont été oubliées ({count} entrée(s))", 5000) + + def show_tools_diagnostic(self) -> None: + diag = arp_scan_diagnostic() + ndiag = naabu_diagnostic() + pdiag = privilege_diagnostic() + cap = "OK — CAP_NET_RAW détectée" if diag.cap_net_raw is True else "ABSENTE / non détectée" if diag.cap_net_raw is False else "INCONNUE" + recommendation = "" + if diag.path and diag.cap_net_raw is False: + recommendation = f"

Alternative permanente :
sudo setcap cap_net_raw+p {diag.path}" + QMessageBox.information( + self, + "Diagnostic des outils", + "Outils réseau

" + f"Nmap : {shutil.which('nmap') or 'introuvable'}
" + f"Naabu utilisateur : {ndiag.user_path or 'introuvable'}
" + f"  ↳ {ndiag.user_detail}
" + f"Naabu Admin SYN : {ndiag.admin_path or 'indisponible'}
" + f"  ↳ {ndiag.admin_detail}
" + f"iproute2 : {shutil.which('ip') or 'introuvable'}
" + f"arp-scan : {diag.path or 'introuvable'}
" + f"CAP_NET_RAW : {cap}

" + "Élévation Polkit
" + f"Mode : {'ACTIF' if self.admin_mode else 'inactif'}
" + f"pkexec : {pdiag.pkexec_path or 'introuvable'}
" + f"helper : {pdiag.helper_path or 'introuvable'}
" + f"politique : {pdiag.policy_path or 'introuvable'}
" + f"État : {'OK' if pdiag.ready else 'INCOMPLET'} — {pdiag.detail}{recommendation}", + ) + + def show_about(self) -> None: + QMessageBox.about( + self, + "À propos de LibreNet Scanner", + f"LibreNet Scanner {__version__}

" + "Scanner réseau graphique libre pour Debian 13/KDE.
" + "Python + PySide6/Qt6, arp-scan, Naabu 2.6.1 provisionné et Nmap.

" + "V0.4 : interface KDE/Breeze épurée, vue Compacte/Détaillée, fiche équipement et actions contextuelles.

" + "Licence : GPL-3.0-or-later.", + ) diff --git a/src/librenet_scanner/ui_icons.py b/src/librenet_scanner/ui_icons.py new file mode 100644 index 0000000..559c43b --- /dev/null +++ b/src/librenet_scanner/ui_icons.py @@ -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) diff --git a/src/librenet_scanner/ui_layout.py b/src/librenet_scanner/ui_layout.py new file mode 100644 index 0000000..c5bf32d --- /dev/null +++ b/src/librenet_scanner/ui_layout.py @@ -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 diff --git a/src/librenet_scanner/vendors.py b/src/librenet_scanner/vendors.py new file mode 100644 index 0000000..7c49a9a --- /dev/null +++ b/src/librenet_scanner/vendors.py @@ -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(?:[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.+?)\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 "" diff --git a/src/librenet_scanner/visual_identity.py b/src/librenet_scanner/visual_identity.py new file mode 100644 index 0000000..fcc80af --- /dev/null +++ b/src/librenet_scanner/visual_identity.py @@ -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" diff --git a/tests/test_parsers.py b/tests/test_parsers.py new file mode 100644 index 0000000..269666c --- /dev/null +++ b/tests/test_parsers.py @@ -0,0 +1,52 @@ +import unittest + +from librenet_scanner.network import NetworkInterface, target_is_on_interface, validate_target +from librenet_scanner.parsers import parse_arp_scan, parse_nmap_xml + + +class ParserTests(unittest.TestCase): + def test_arp_scan_parser(self): + text = """Interface: eth0, type: EN10MB\n192.168.1.10\t00:11:22:33:44:55\tHewlett Packard\n192.168.1.20\taa:bb:cc:dd:ee:ff\t(Unknown)\n2 packets received\n""" + hosts = parse_arp_scan(text) + self.assertEqual(len(hosts), 2) + self.assertEqual(hosts[0].ip, "192.168.1.10") + self.assertEqual(hosts[0].mac, "00:11:22:33:44:55") + self.assertEqual(hosts[0].vendor, "Hewlett Packard") + self.assertEqual(hosts[1].vendor, "") + + def test_nmap_parser(self): + xml = """ + + + +
+
+ + + + + + +""" + hosts = parse_nmap_xml(xml) + self.assertEqual(len(hosts), 1) + host = hosts[0] + self.assertEqual(host.hostname, "pc-test.local") + self.assertEqual(host.vendor, "HP") + self.assertEqual(host.os_name, "Linux 6.x") + self.assertEqual(host.ports[0].port, 22) + self.assertEqual(host.ports[0].service, "SSH") + + def test_target_normalization(self): + self.assertEqual(validate_target("192.168.1.42/24"), "192.168.1.0/24") + self.assertEqual(validate_target("10.0.0.7"), "10.0.0.7") + + def test_interface_targeting(self): + iface = NetworkInterface("eth0", "192.168.1.10", 24, "192.168.1.0/24", False) + self.assertTrue(target_is_on_interface("192.168.1.0/24", iface)) + self.assertTrue(target_is_on_interface("192.168.1.128/25", iface)) + self.assertFalse(target_is_on_interface("192.168.0.0/16", iface)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_release_100.py b/tests/test_release_100.py new file mode 100644 index 0000000..b0a749d --- /dev/null +++ b/tests/test_release_100.py @@ -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("
+ + + + + """ + 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() diff --git a/tests/test_v0411.py b/tests/test_v0411.py new file mode 100644 index 0000000..6c031d3 --- /dev/null +++ b/tests/test_v0411.py @@ -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() diff --git a/tests/test_v0412.py b/tests/test_v0412.py new file mode 100644 index 0000000..5503eba --- /dev/null +++ b/tests/test_v0412.py @@ -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() diff --git a/tests/test_v0413.py b/tests/test_v0413.py new file mode 100644 index 0000000..5f36f54 --- /dev/null +++ b/tests/test_v0413.py @@ -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() diff --git a/tests/test_v0414.py b/tests/test_v0414.py new file mode 100644 index 0000000..4c33745 --- /dev/null +++ b/tests/test_v0414.py @@ -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() diff --git a/tests/test_v0415.py b/tests/test_v0415.py new file mode 100644 index 0000000..c6ebc26 --- /dev/null +++ b/tests/test_v0415.py @@ -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() diff --git a/tests/test_v0416.py b/tests/test_v0416.py new file mode 100644 index 0000000..1b6b5d4 --- /dev/null +++ b/tests/test_v0416.py @@ -0,0 +1,164 @@ +import tempfile +import unittest +from datetime import datetime, timedelta, timezone +from pathlib import Path + +from librenet_scanner.models import Host, PortInfo +from librenet_scanner.storage import HistoryStore + + +def ports(*values: int) -> list[PortInfo]: + return [PortInfo(port=value, service="test") for value in values] + + +class IdentificationFreshness0416Tests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.store = HistoryStore(Path(self.tmp.name) / "history.sqlite3") + self.scope = "ipv4:192.168.10.0/24" + + def tearDown(self): + self.tmp.cleanup() + + def _row(self, identity_suffix: str = "mac:00:11:22:33:44:55"): + with self.store._connect() as conn: + return conn.execute( + "SELECT * FROM endpoint_identification WHERE identity LIKE ? ORDER BY identity LIMIT 1", + (f"%{identity_suffix}",), + ).fetchone() + + def test_standard_does_not_refresh_os_seen_at(self): + deep_time = "2025-01-02T03:04:05+00:00" + standard_time = "2026-08-22T12:00:00+00:00" + deep = Host( + ip="192.168.10.20", mac="00:11:22:33:44:55", + os_name="Debian 13", os_accuracy=97, ports=ports(22, 443), + ) + self.store.remember_identifications([deep], "Approfondi", observed_at=deep_time, scope=self.scope) + standard = Host( + ip="192.168.10.20", mac=deep.mac, os_name="Linux 6.x", ports=ports(22, 443), + ) + self.store.remember_identifications([standard], "Standard", observed_at=standard_time, scope=self.scope) + row = self._row() + self.assertIsNotNone(row) + self.assertEqual(row["os_name"], "Debian 13") + self.assertEqual(row["os_seen_at"], deep_time) + self.assertEqual(row["os_source"], "Approfondi") + self.assertEqual(row["updated_at"], standard_time) + + def test_old_os_stays_old_even_when_endpoint_updated_at_is_recent(self): + old = Host( + ip="192.168.10.20", mac="00:11:22:33:44:55", + os_name="Debian 13", os_accuracy=99, ports=ports(22, 443), + ) + self.store.remember_identifications([old], "Approfondi", scope=self.scope) + stale = (datetime.now(timezone.utc) - timedelta(days=365)).astimezone().isoformat(timespec="seconds") + fresh = datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds") + with self.store._connect() as conn: + conn.execute( + "UPDATE endpoint_identification SET os_seen_at = ?, updated_at = ?", + (stale, fresh), + ) + moved = Host(ip="192.168.10.99", mac=old.mac) + self.store.apply_identifications([moved], scope=self.scope) + self.assertEqual(moved.remembered_os_name, "") + + def test_unsafe_mac_move_does_not_overwrite_original_endpoint_before_matching(self): + old = Host( + ip="192.168.10.20", mac="00:11:22:33:44:55", + hostname="old.local", os_name="Debian 13", os_accuracy=99, + ports=ports(22, 443), + ) + self.store.remember_identifications([old], "Approfondi", scope=self.scope) + moved = Host(ip="192.168.10.99", mac=old.mac, hostname="other.local") + # Même si une future régression écrit avant d'appliquer, l'ancien endpoint + # doit rester intact : l'observation ambiguë est scindée en macip. + self.store.remember_identifications([moved], "Standard", scope=self.scope) + with self.store._connect() as conn: + base = conn.execute( + "SELECT * FROM endpoint_identification WHERE identity = ?", + (f"{self.scope}::mac:{old.mac}",), + ).fetchone() + split = conn.execute( + "SELECT * FROM endpoint_identification WHERE identity = ?", + (f"{self.scope}::macip:{old.mac}@192.168.10.99",), + ).fetchone() + self.assertIsNotNone(base) + self.assertEqual(base["ip"], "192.168.10.20") + self.assertEqual(base["os_name"], "Debian 13") + self.assertIsNotNone(split) + + +class DataDeletion0416Tests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.store = HistoryStore(Path(self.tmp.name) / "history.sqlite3") + self.scope = "ipv4:192.168.10.0/24" + self.host = Host( + ip="192.168.10.20", mac="00:11:22:33:44:55", hostname="srv.local", + os_name="Debian 13", os_accuracy=99, ports=ports(22), + ) + + def tearDown(self): + self.tmp.cleanup() + + def test_clear_scan_history_does_not_delete_identification_or_metadata(self): + self.store.remember_identifications([self.host], "Approfondi", scope=self.scope) + self.store.save_host_metadata(self.host, favorite=True, note="Important") + self.store.save_scan("192.168.10.0/24", "Approfondi", [self.host], "deep-test") + self.assertEqual(self.store.clear_scan_history(), 1) + self.assertEqual(self.store.recent_scans(), []) + current = Host(ip=self.host.ip, mac=self.host.mac, ports=ports(22)) + self.store.apply_identifications([current], scope=self.scope) + self.assertEqual(current.remembered_os_name, "Debian 13") + self.assertTrue(self.store.host_metadata(self.host)["favorite"]) + + def test_forget_selected_identification_keeps_metadata(self): + self.store.remember_identifications([self.host], "Approfondi", scope=self.scope) + self.store.save_host_metadata(self.host, favorite=True, group_name="Infra", note="Note") + deleted = self.store.forget_identification_for_host(self.host, scope=self.scope) + self.assertGreaterEqual(deleted, 1) + current = Host(ip=self.host.ip, mac=self.host.mac, ports=ports(22)) + self.store.apply_identifications([current], scope=self.scope) + self.assertEqual(current.remembered_os_name, "") + metadata = self.store.host_metadata(self.host) + self.assertTrue(metadata["favorite"]) + self.assertEqual(metadata["group_name"], "Infra") + + def test_forget_scope_does_not_touch_other_network(self): + other_scope = "ipv4:192.168.20.0/24" + other = Host(ip="192.168.20.20", mac="00:11:22:AA:BB:CC", os_name="OpenWrt 24.10", ports=ports(22, 80)) + self.store.remember_identifications([self.host], "Approfondi", scope=self.scope) + self.store.remember_identifications([other], "Approfondi", scope=other_scope) + self.assertEqual(self.store.forget_identifications_for_scope(self.scope), 1) + cur1 = Host(ip=self.host.ip, mac=self.host.mac, ports=ports(22)) + cur2 = Host(ip=other.ip, mac=other.mac, ports=ports(22, 80)) + self.store.apply_identifications([cur1], scope=self.scope) + self.store.apply_identifications([cur2], scope=other_scope) + self.assertEqual(cur1.remembered_os_name, "") + self.assertEqual(cur2.remembered_os_name, "OpenWrt 24.10") + + def test_forget_all_identifications_leaves_scan_history(self): + self.store.remember_identifications([self.host], "Approfondi", scope=self.scope) + self.store.save_scan("192.168.10.0/24", "Approfondi", [self.host], "deep-test") + self.assertGreaterEqual(self.store.forget_all_identifications(), 1) + self.assertEqual(len(self.store.recent_scans()), 1) + current = Host(ip=self.host.ip, mac=self.host.mac, ports=ports(22)) + self.store.apply_identifications([current], scope=self.scope) + self.assertEqual(current.remembered_os_name, "") + + +class SourceOrder0416Tests(unittest.TestCase): + def test_scan_finish_applies_memory_before_updating_endpoint_observation(self): + source = Path(__file__).parents[1] / "src" / "librenet_scanner" / "ui.py" + text = source.read_text(encoding="utf-8") + start = text.index("def _scan_finished") + end = text.index("# ---------- Constructeurs en ligne", start) + block = text[start:end] + self.assertLess(block.index("apply_identifications"), block.index("remember_identifications")) + self.assertIn("Effacer l’affichage", text) + self.assertIn("Oublier les identifications", text) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_v0417.py b/tests/test_v0417.py new file mode 100644 index 0000000..3a7c175 --- /dev/null +++ b/tests/test_v0417.py @@ -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() diff --git a/tests/test_v0418.py b/tests/test_v0418.py new file mode 100644 index 0000000..cefdbca --- /dev/null +++ b/tests/test_v0418.py @@ -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() diff --git a/tests/test_v0419.py b/tests/test_v0419.py new file mode 100644 index 0000000..ed72036 --- /dev/null +++ b/tests/test_v0419.py @@ -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() diff --git a/tests/test_v042.py b/tests/test_v042.py new file mode 100644 index 0000000..5474606 --- /dev/null +++ b/tests/test_v042.py @@ -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() diff --git a/tests/test_v0420.py b/tests/test_v0420.py new file mode 100644 index 0000000..77f86b2 --- /dev/null +++ b/tests/test_v0420.py @@ -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() diff --git a/tests/test_v0421.py b/tests/test_v0421.py new file mode 100644 index 0000000..d3ee6bb --- /dev/null +++ b/tests/test_v0421.py @@ -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() diff --git a/tests/test_v0422.py b/tests/test_v0422.py new file mode 100644 index 0000000..7c02c9a --- /dev/null +++ b/tests/test_v0422.py @@ -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() diff --git a/tests/test_v0423.py b/tests/test_v0423.py new file mode 100644 index 0000000..ed050b0 --- /dev/null +++ b/tests/test_v0423.py @@ -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() diff --git a/tests/test_v043.py b/tests/test_v043.py new file mode 100644 index 0000000..6aa981e --- /dev/null +++ b/tests/test_v043.py @@ -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() diff --git a/tests/test_v044.py b/tests/test_v044.py new file mode 100644 index 0000000..ae6a063 --- /dev/null +++ b/tests/test_v044.py @@ -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() diff --git a/tests/test_v045.py b/tests/test_v045.py new file mode 100644 index 0000000..a34f688 --- /dev/null +++ b/tests/test_v045.py @@ -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() diff --git a/tests/test_v046.py b/tests/test_v046.py new file mode 100644 index 0000000..189d823 --- /dev/null +++ b/tests/test_v046.py @@ -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() diff --git a/tests/test_v047.py b/tests/test_v047.py new file mode 100644 index 0000000..1051bcb --- /dev/null +++ b/tests/test_v047.py @@ -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() diff --git a/tests/test_v048.py b/tests/test_v048.py new file mode 100644 index 0000000..21cfae6 --- /dev/null +++ b/tests/test_v048.py @@ -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() diff --git a/tests/test_v049.py b/tests/test_v049.py new file mode 100644 index 0000000..02788f0 --- /dev/null +++ b/tests/test_v049.py @@ -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 = """
+ """ + host = parse_nmap_xml(xml)[0] + self.assertEqual(host.os_accuracy, 97) + + +if __name__ == "__main__": + unittest.main()