first commit

This commit is contained in:
2026-08-23 09:52:32 +02:00
commit 1a7ed8ef94
94 changed files with 10489 additions and 0 deletions
+33
View File
@@ -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.
+674
View File
@@ -0,0 +1,674 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
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.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
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 <https://www.gnu.org/licenses/>.
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:
<program> Copyright (C) <year> <name of author>
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
<https://www.gnu.org/licenses/>.
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
<https://www.gnu.org/licenses/why-not-lgpl.html>.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2021 ProjectDiscovery, Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+308
View File
@@ -0,0 +1,308 @@
<p align="center">
<img src="assets/librenet-scanner.svg" width="132" height="132" alt="Logo LibreNet Scanner">
</p>
<h1 align="center">LibreNet Scanner</h1>
<p align="center">
<strong>Scanner réseau graphique libre, rapide et orienté équipements pour Linux.</strong><br>
Découverte, inventaire, services, identification et diagnostic dans une interface Qt pensée pour KDE Plasma.
</p>
<p align="center">
<img src="assets/badges/version.svg" alt="Version 1.0.0">
<img src="assets/badges/status.svg" alt="Release stable">
<img src="assets/badges/platform.svg" alt="Debian 13">
<img src="assets/badges/ui.svg" alt="Qt 6 KDE">
<img src="assets/badges/license.svg" alt="GPLv3+">
<img src="assets/badges/tests.svg" alt="187 tests OK">
</p>
<p align="center">
<img src="assets/icons/equipment-workstation.svg" width="34" alt="Poste">
&nbsp;&nbsp;
<img src="assets/icons/equipment-server.svg" width="34" alt="Serveur">
&nbsp;&nbsp;
<img src="assets/icons/equipment-switch.svg" width="34" alt="Switch">
&nbsp;&nbsp;
<img src="assets/icons/equipment-router.svg" width="34" alt="Routeur">
&nbsp;&nbsp;
<img src="assets/icons/equipment-access-point.svg" width="34" alt="Point d'accès">
&nbsp;&nbsp;
<img src="assets/icons/equipment-printer.svg" width="34" alt="Imprimante">
</p>
> **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).
---
<p align="center">
<img src="assets/librenet-scanner.svg" width="54" alt="LibreNet Scanner"><br>
<strong>LibreNet Scanner 1.0.0 — Stable</strong><br>
<sub>Voir le réseau. Comprendre les équipements. Garder le contrôle.</sub>
</p>
+47
View File
@@ -0,0 +1,47 @@
# Third-party assets
LibreNet Scanner est distribué sous GPLv3, mais certains pictogrammes graphiques embarqués ont leur propre licence.
## Font Awesome Free — pictogrammes UI
Les pictogrammes dOS et d’équipement dans `assets/icons/` sont des rendus SVG dérivés de glyphes **Font Awesome Free**.
Utilisations principales :
- Linux / Tux (`linux`)
- FreeBSD (`freebsd`)
- Windows (`windows`)
- Apple (`apple`)
- Android (`android`)
- poste (`desktop`)
- serveur (`server`)
- hyperviseur (`layer-group`)
- pare-feu (`shield-alt`)
- routeur (`ethernet`)
- NAS (`hdd`)
- switch (`network-wired`)
- point daccès (`wifi`)
- imprimante (`print`)
- équipement réseau (`sitemap`)
Font Awesome Free (assets utilisés jusqu’à la série 6.7.2) : Copyright Fonticons, Inc. / Font Awesome contributors.
Les icônes Font Awesome Free sont distribuées sous **CC BY 4.0**. Les fontes sont distribuées sous **SIL OFL 1.1** et le code Font Awesome sous licence MIT. Voir : https://fontawesome.com/license/free
Les marques et logos représentés (notamment FreeBSD, Linux et Windows) restent la propriété de leurs détenteurs respectifs et sont utilisés uniquement comme identifiants visuels de plateformes.
## BSD Daemon / Beastie
LibreNet Scanner **nembarque pas Beastie**, le BSD Daemon historique. Son image est protégée par des droits spécifiques. LibreNet Scanner utilise à la place le pictogramme FreeBSD de Font Awesome Free afin d’éviter dintroduire une licence/autorisation supplémentaire dans un dépôt public.
## Naabu — moteur réseau ProjectDiscovery
LibreNet Scanner peut utiliser **Naabu 2.6.1**, projet ProjectDiscovery distribué sous licence **MIT**, comme accélérateur de scan de ports lorsque le Standard a découvert un grand nombre d'hôtes. Il n'est pas requis pour le fonctionnement normal : sur les petits ensembles, Nmap est volontairement utilisé. Le binaire Naabu n'est pas versionné dans l'archive source LibreNet et l'installation du paquet n'effectue aucun téléchargement réseau. L'installateur optionnel place une version 2.6.1 vérifiée dans `/usr/lib/librenet-scanner/bin/naabu`. À lexécution, LibreNet nutilise pas directement un Naabu externe du `PATH`.
Le téléchargement officiel est vérifié avant extraction avec le SHA-256 :
```text
018c4c9884dea971eda860435ede3021d1150732f34cfd245498c6726d8cab90
```
Projet officiel : https://github.com/projectdiscovery/naabu
Licence Naabu : MIT. Le texte de licence est fourni dans `NAABU-LICENSE.txt`.
+12
View File
@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" width="140" height="28" viewBox="0 0 140 28" role="img" aria-label="license: GPLv3+">
<title>license: GPLv3+</title>
<defs><clipPath id="r"><rect width="140" height="28" rx="14"/></clipPath></defs>
<g clip-path="url(#r)">
<rect width="74" height="28" fill="#24292f"/>
<rect x="74" width="66" height="28" fill="#8250df"/>
</g>
<g fill="#fff" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="12" font-weight="600" text-anchor="middle">
<text x="37.0" y="18">license</text>
<text x="107.0" y="18">GPLv3+</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 601 B

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

After

Width:  |  Height:  |  Size: 613 B

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

After

Width:  |  Height:  |  Size: 601 B

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

After

Width:  |  Height:  |  Size: 594 B

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

After

Width:  |  Height:  |  Size: 619 B

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

After

Width:  |  Height:  |  Size: 598 B

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

After

Width:  |  Height:  |  Size: 620 B

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

After

Width:  |  Height:  |  Size: 406 B

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

After

Width:  |  Height:  |  Size: 677 B

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

After

Width:  |  Height:  |  Size: 620 B

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

After

Width:  |  Height:  |  Size: 780 B

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

After

Width:  |  Height:  |  Size: 621 B

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

After

Width:  |  Height:  |  Size: 461 B

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

After

Width:  |  Height:  |  Size: 1.1 KiB

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

After

Width:  |  Height:  |  Size: 747 B

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

After

Width:  |  Height:  |  Size: 672 B

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

After

Width:  |  Height:  |  Size: 445 B

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

After

Width:  |  Height:  |  Size: 592 B

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

After

Width:  |  Height:  |  Size: 726 B

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

After

Width:  |  Height:  |  Size: 600 B

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

After

Width:  |  Height:  |  Size: 3.3 KiB

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

After

Width:  |  Height:  |  Size: 448 B

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

After

Width:  |  Height:  |  Size: 426 B

+12
View File
@@ -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
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128">
<rect x="8" y="8" width="112" height="112" rx="24" fill="#1f6feb"/>
<circle cx="64" cy="64" r="9" fill="white"/>
<circle cx="64" cy="64" r="25" fill="none" stroke="white" stroke-width="6" opacity="0.9"/>
<path d="M64 25a39 39 0 0 1 39 39" fill="none" stroke="white" stroke-width="6" stroke-linecap="round" opacity="0.75"/>
<path d="M64 103a39 39 0 0 1-39-39" fill="none" stroke="white" stroke-width="6" stroke-linecap="round" opacity="0.75"/>
<circle cx="101" cy="42" r="7" fill="#8be9fd"/>
<circle cx="27" cy="87" r="7" fill="#8be9fd"/>
</svg>

After

Width:  |  Height:  |  Size: 702 B

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