pmescan 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pmescan/__init__.py +6 -0
- pmescan/autorisation.py +70 -0
- pmescan/checks/__init__.py +1 -0
- pmescan/checks/base.py +21 -0
- pmescan/checks/cms.py +460 -0
- pmescan/checks/dns_mail.py +176 -0
- pmescan/checks/entetes.py +158 -0
- pmescan/checks/exposes.py +118 -0
- pmescan/checks/ports.py +170 -0
- pmescan/checks/tls.py +252 -0
- pmescan/cli.py +243 -0
- pmescan/domaine.py +84 -0
- pmescan/i18n/__init__.py +19 -0
- pmescan/i18n/fr.yaml +607 -0
- pmescan/modele.py +45 -0
- pmescan/notation.py +34 -0
- pmescan/rapport/__init__.py +5 -0
- pmescan/rapport/generateur.py +164 -0
- pmescan/rapport/modele.html +257 -0
- pmescan/scanner.py +78 -0
- pmescan-0.1.0.dist-info/METADATA +268 -0
- pmescan-0.1.0.dist-info/RECORD +25 -0
- pmescan-0.1.0.dist-info/WHEEL +4 -0
- pmescan-0.1.0.dist-info/entry_points.txt +2 -0
- pmescan-0.1.0.dist-info/licenses/LICENSE +21 -0
pmescan/__init__.py
ADDED
pmescan/autorisation.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Garde d'autorisation des modes intrusifs et journal local de consentement.
|
|
2
|
+
|
|
3
|
+
Les modes ``actif`` et ``agressif`` sollicitent activement la cible : ils ne doivent
|
|
4
|
+
s'exécuter que si l'utilisateur confirme être propriétaire de la cible ou disposer d'une
|
|
5
|
+
autorisation écrite (voir ``README-mode-agressif.md``). Chaque scan intrusif autorisé est
|
|
6
|
+
tracé localement dans ``~/.pmescan/consent.log``.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
from datetime import UTC, datetime
|
|
11
|
+
from enum import Enum
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
# Variable d'environnement permettant de déplacer le dossier de pmescan (utile aux tests).
|
|
15
|
+
VARIABLE_DOSSIER = "PMESCAN_DOSSIER"
|
|
16
|
+
|
|
17
|
+
# Nom du fichier de journal de consentement, dans le dossier de pmescan.
|
|
18
|
+
NOM_JOURNAL = "consent.log"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ModeScan(str, Enum): # noqa: UP042 - même convention que Gravite (modele.py)
|
|
22
|
+
"""Modes de scan acceptés par la ligne de commande, du moins au plus intrusif."""
|
|
23
|
+
|
|
24
|
+
PASSIF = "passif"
|
|
25
|
+
ACTIF = "actif"
|
|
26
|
+
AGRESSIF = "agressif"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# Modes qui sollicitent activement la cible et exigent une autorisation explicite.
|
|
30
|
+
MODES_INTRUSIFS: frozenset[ModeScan] = frozenset({ModeScan.ACTIF, ModeScan.AGRESSIF})
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def exige_autorisation(mode: ModeScan) -> bool:
|
|
34
|
+
"""Vrai si le mode demandé exige le flag d'autorisation."""
|
|
35
|
+
return mode in MODES_INTRUSIFS
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def dossier_pmescan() -> Path:
|
|
39
|
+
"""Renvoie le dossier local de pmescan (``PMESCAN_DOSSIER`` ou ``~/.pmescan``)."""
|
|
40
|
+
surcharge = os.environ.get(VARIABLE_DOSSIER)
|
|
41
|
+
if surcharge:
|
|
42
|
+
return Path(surcharge)
|
|
43
|
+
return Path.home() / ".pmescan"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _nettoyer_champ(valeur: str) -> str:
|
|
47
|
+
"""Remplace tout caractère non imprimable par une espace.
|
|
48
|
+
|
|
49
|
+
Garde une ligne par entrée et un champ par colonne : couvre tabulations, retours à
|
|
50
|
+
la ligne (\\r, \\n, \\x0b, \\x0c, \\x85, U+2028, U+2029) et le caractère
|
|
51
|
+
d'échappement des séquences ANSI (\\x1b), qui pourraient falsifier l'affichage du
|
|
52
|
+
journal dans un terminal.
|
|
53
|
+
"""
|
|
54
|
+
return "".join(caractere if caractere.isprintable() else " " for caractere in valeur)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def journaliser_consentement(domaine: str, mode: ModeScan) -> Path:
|
|
58
|
+
"""Ajoute une ligne « horodatage UTC, domaine, mode » au journal de consentement.
|
|
59
|
+
|
|
60
|
+
Le dossier est créé si besoin. Renvoie le chemin du journal. Lève ``OSError`` si
|
|
61
|
+
l'écriture est impossible : l'appelant décide alors de la conduite à tenir.
|
|
62
|
+
"""
|
|
63
|
+
dossier = dossier_pmescan()
|
|
64
|
+
dossier.mkdir(parents=True, exist_ok=True)
|
|
65
|
+
chemin = dossier / NOM_JOURNAL
|
|
66
|
+
horodatage = datetime.now(UTC).isoformat(timespec="seconds")
|
|
67
|
+
ligne = "\t".join([horodatage, _nettoyer_champ(domaine), mode.value])
|
|
68
|
+
with chemin.open("a", encoding="utf-8") as journal:
|
|
69
|
+
journal.write(ligne + "\n")
|
|
70
|
+
return chemin
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Vérifications de sécurité de pmescan (TLS, en-têtes, DNS, CMS, ports, exposés)."""
|
pmescan/checks/base.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Contrat commun à toutes les vérifications de sécurité."""
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
|
|
5
|
+
from pmescan.modele import Cible, Resultat
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Verification(ABC):
|
|
9
|
+
"""Classe mère de toutes les vérifications.
|
|
10
|
+
|
|
11
|
+
Chaque vérification fait une seule chose : analyser la cible et renvoyer une
|
|
12
|
+
liste de ``Resultat``. Elle ne connaît ni la notation ni le rapport.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
nom: str = "verification"
|
|
16
|
+
niveau_minimum: str = "passif" # mode minimal qui active ce check
|
|
17
|
+
|
|
18
|
+
@abstractmethod
|
|
19
|
+
async def executer(self, cible: Cible) -> list[Resultat]:
|
|
20
|
+
"""Analyse la cible et renvoie la liste des problèmes trouvés."""
|
|
21
|
+
...
|
pmescan/checks/cms.py
ADDED
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
"""Détection de CMS (WordPress, Joomla, Drupal, PrestaShop) et de versions obsolètes.
|
|
2
|
+
|
|
3
|
+
Comportement selon le mode :
|
|
4
|
+
|
|
5
|
+
- **passif** (défaut) : on ne lit QUE la page d'accueil (meta generator, en-tête
|
|
6
|
+
X-Generator, marqueurs techniques, cookies). Aucune autre URL de la cible n'est demandée.
|
|
7
|
+
- **actif** / **agressif**, avec autorisation explicite (``cible.autorisation``) : on sonde
|
|
8
|
+
en plus des fichiers de version connus (readme.html, CHANGELOG.txt, manifeste Joomla,
|
|
9
|
+
/admin/) après une sonde « soft 404 », et on mène les vérifications propres à
|
|
10
|
+
WordPress (xmlrpc.php, énumération des auteurs). Sans autorisation, le comportement
|
|
11
|
+
reste celui du mode passif.
|
|
12
|
+
|
|
13
|
+
Le signal principal est la **branche majeure non maintenue** : on compare la branche
|
|
14
|
+
majeure détectée aux branches maintenues (table embarquée, meilleur effort).
|
|
15
|
+
|
|
16
|
+
En complément et de façon optionnelle (``--verifier-cve``), on interroge l'API publique
|
|
17
|
+
OSV pour savoir si la version détectée est touchée par des failles connues. Cette étape
|
|
18
|
+
est en meilleur effort : la couverture d'OSV sur les cœurs de CMS est partielle, et toute
|
|
19
|
+
indisponibilité réseau se dégrade proprement (aucun faux positif, aucune exception).
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import re
|
|
23
|
+
import uuid
|
|
24
|
+
|
|
25
|
+
import httpx
|
|
26
|
+
|
|
27
|
+
from pmescan.checks.base import Verification
|
|
28
|
+
from pmescan.modele import Cible, Gravite, Resultat
|
|
29
|
+
|
|
30
|
+
# Branches majeures encore maintenues (correctifs de sécurité) pour chaque CMS suivi.
|
|
31
|
+
# Table de meilleur effort, à vérifier sur les sites officiels, dernière mise à jour :
|
|
32
|
+
# 2026-10-25. Elle sert uniquement à repérer une branche manifestement abandonnée ; ce
|
|
33
|
+
# n'est pas une source d'autorité sur les correctifs de sécurité. Un simple retard de
|
|
34
|
+
# correctif à l'intérieur d'une branche maintenue n'est volontairement PAS signalé.
|
|
35
|
+
BRANCHES_MAINTENUES: dict[str, set[str]] = {
|
|
36
|
+
# WordPress : 6.x (et 7.x dès sa sortie). Les branches 5.x et antérieures ne sont
|
|
37
|
+
# plus la version recommandée par wordpress.org.
|
|
38
|
+
"WordPress": {"6", "7"},
|
|
39
|
+
# Joomla : 4.x en fin de vie depuis octobre 2025 ; 5.x et 6.x maintenues.
|
|
40
|
+
"Joomla": {"5", "6"},
|
|
41
|
+
# Drupal : 10.x (jusqu'à fin 2026) et 11.x ; 7.x en fin de vie depuis janvier 2025.
|
|
42
|
+
"Drupal": {"10", "11"},
|
|
43
|
+
# PrestaShop : 8.x et 9.x ; 1.7.x et antérieures en fin de vie.
|
|
44
|
+
"PrestaShop": {"8", "9"},
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
# Correspondance CMS -> (écosystème, paquet) pour l'interrogation OSV. La couverture d'OSV
|
|
48
|
+
# sur les cœurs de CMS est partielle : une absence de réponse n'est donc pas une preuve
|
|
49
|
+
# d'absence de vulnérabilité. WordPress n'y figure pas : son cœur n'est pas indexé de
|
|
50
|
+
# façon fiable par OSV, on émet donc « cms_cve_indeterminee » plutôt qu'un faux « sain ».
|
|
51
|
+
_PAQUETS_OSV: dict[str, tuple[str, str]] = {
|
|
52
|
+
"Drupal": ("Packagist", "drupal/core"),
|
|
53
|
+
"PrestaShop": ("Packagist", "prestashop/prestashop"),
|
|
54
|
+
"Joomla": ("Packagist", "joomla/joomla-cms"),
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
# Point d'accès public de l'API OSV (requête par version de paquet).
|
|
58
|
+
_URL_OSV = "https://api.osv.dev/v1/query"
|
|
59
|
+
|
|
60
|
+
# Délai maximal (secondes) de la requête OSV : un tiers lent ne doit pas bloquer le scan.
|
|
61
|
+
_DELAI_OSV = 5.0
|
|
62
|
+
|
|
63
|
+
# Modes autorisant la sonde de chemins et les vérifications propres à WordPress.
|
|
64
|
+
_MODES_SONDE = ("actif", "agressif")
|
|
65
|
+
|
|
66
|
+
# Balise <meta name="generator" content="..."> : source de CMS + version la plus fiable.
|
|
67
|
+
_MOTIF_GENERATOR = re.compile(
|
|
68
|
+
r"""<meta[^>]+name=["']generator["'][^>]+content=["']([^"']+)["']""",
|
|
69
|
+
re.IGNORECASE,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
# Un numéro de version du style « 6.2 » ou « 10.1.4 ».
|
|
73
|
+
_MOTIF_VERSION = re.compile(r"\d+(?:\.\d+)+")
|
|
74
|
+
|
|
75
|
+
# Signature de readme.html WordPress : un <title> qui mentionne WordPress...
|
|
76
|
+
_MOTIF_TITRE_WORDPRESS = re.compile(r"<title>[^<]*wordpress[^<]*</title>", re.IGNORECASE)
|
|
77
|
+
|
|
78
|
+
# ... ET un marqueur propre à ce fichier : le logo <h1 id="logo"> ou un lien vers
|
|
79
|
+
# wordpress.org.
|
|
80
|
+
_MOTIF_MARQUEUR_README_WORDPRESS = re.compile(
|
|
81
|
+
r"""<h1\s+id=["']logo["']|href=["']https?://(?:[a-z]+\.)?wordpress\.org""",
|
|
82
|
+
re.IGNORECASE,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
# Version affichée par readme.html : « <br /> Version X.Y » (et rien d'autre).
|
|
86
|
+
_MOTIF_VERSION_README_WORDPRESS = re.compile(r"<br\s*/?>\s*[Vv]ersion\s+(\d+(?:\.\d+)+)")
|
|
87
|
+
|
|
88
|
+
# CHANGELOG.txt de Drupal : le fichier COMMENCE par « Drupal X.Y » (après espaces).
|
|
89
|
+
_MOTIF_CHANGELOG_DRUPAL = re.compile(r"\s*Drupal\s+(\d+(?:\.\d+)+)")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _extraire_version(texte: str) -> str | None:
|
|
93
|
+
"""Renvoie le premier numéro de version trouvé dans ``texte``, sinon None."""
|
|
94
|
+
correspondance = _MOTIF_VERSION.search(texte)
|
|
95
|
+
return correspondance.group(0) if correspondance else None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _cms_depuis_libelle(libelle: str) -> str | None:
|
|
99
|
+
"""Associe un libellé libre (meta generator, en-tête) à un CMS connu."""
|
|
100
|
+
libelle_bas = libelle.lower()
|
|
101
|
+
if "wordpress" in libelle_bas:
|
|
102
|
+
return "WordPress"
|
|
103
|
+
if "joomla" in libelle_bas:
|
|
104
|
+
return "Joomla"
|
|
105
|
+
if "drupal" in libelle_bas:
|
|
106
|
+
return "Drupal"
|
|
107
|
+
if "prestashop" in libelle_bas:
|
|
108
|
+
return "PrestaShop"
|
|
109
|
+
return None
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _branche_non_maintenue(version: str, maintenues: set[str]) -> str | None:
|
|
113
|
+
"""Renvoie la branche majeure de ``version`` si elle n'est plus maintenue, sinon None.
|
|
114
|
+
|
|
115
|
+
Une branche est jugée non maintenue seulement si elle est absente de ``maintenues``
|
|
116
|
+
ET strictement inférieure à la plus petite branche maintenue : une branche plus récente
|
|
117
|
+
que la table (table en retard) ne produit donc jamais de faux positif. En cas de format
|
|
118
|
+
inattendu, on renvoie None.
|
|
119
|
+
"""
|
|
120
|
+
majeure = version.split(".", 1)[0]
|
|
121
|
+
try:
|
|
122
|
+
numero = int(majeure)
|
|
123
|
+
plus_petite = min(int(branche) for branche in maintenues)
|
|
124
|
+
except ValueError:
|
|
125
|
+
return None
|
|
126
|
+
if majeure in maintenues or numero >= plus_petite:
|
|
127
|
+
return None
|
|
128
|
+
return majeure
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class VerificationCms(Verification):
|
|
132
|
+
"""Détecte le CMS de la cible, repère une branche non maintenue et interroge OSV."""
|
|
133
|
+
|
|
134
|
+
nom = "cms"
|
|
135
|
+
niveau_minimum = "passif"
|
|
136
|
+
|
|
137
|
+
async def executer(self, cible: Cible) -> list[Resultat]:
|
|
138
|
+
resultats: list[Resultat] = []
|
|
139
|
+
base = f"https://{cible.domaine}"
|
|
140
|
+
# Défense en profondeur : les sondes de chemins et les vérifications WordPress
|
|
141
|
+
# exigent un mode intrusif ET l'autorisation explicite (cible.autorisation). L'API
|
|
142
|
+
# Python ne lance jamais de requête intrusive sans autorisation explicite.
|
|
143
|
+
sonde_autorisee = cible.mode in _MODES_SONDE and cible.autorisation
|
|
144
|
+
# verify=False délibéré : la validité du certificat est traitée par le module
|
|
145
|
+
# sslyze. Ce client httpx ne lit que des pages publiques de la CIBLE pour
|
|
146
|
+
# identifier le CMS et ne doit pas échouer sur un certificat invalide. Il n'est
|
|
147
|
+
# jamais utilisé pour parler à un tiers (OSV a son propre client, vérifié).
|
|
148
|
+
async with httpx.AsyncClient(
|
|
149
|
+
follow_redirects=True, timeout=10.0, verify=False
|
|
150
|
+
) as client:
|
|
151
|
+
cms, version = await self._detecter(client, base, sonde_autorisee)
|
|
152
|
+
if cms is None:
|
|
153
|
+
return [
|
|
154
|
+
Resultat(
|
|
155
|
+
identifiant="cms_non_detecte",
|
|
156
|
+
gravite=Gravite.INFO,
|
|
157
|
+
reussi=True,
|
|
158
|
+
details={"domaine": cible.domaine},
|
|
159
|
+
)
|
|
160
|
+
]
|
|
161
|
+
|
|
162
|
+
preuve = f"{cms} {version}" if version else cms
|
|
163
|
+
resultats.append(
|
|
164
|
+
Resultat(
|
|
165
|
+
identifiant="cms_detecte",
|
|
166
|
+
gravite=Gravite.INFO,
|
|
167
|
+
reussi=True,
|
|
168
|
+
preuve=preuve,
|
|
169
|
+
details={"cms": cms, "version": version},
|
|
170
|
+
)
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
if version is not None:
|
|
174
|
+
resultats.extend(await self._verifier_version(cible, cms, version))
|
|
175
|
+
|
|
176
|
+
# xmlrpc.php et ?author=1 sollicitent d'autres URL que la page d'accueil :
|
|
177
|
+
# réservés aux modes actif et agressif.
|
|
178
|
+
if cms == "WordPress" and sonde_autorisee:
|
|
179
|
+
resultats.extend(await self._verifier_wordpress(client, base))
|
|
180
|
+
|
|
181
|
+
return resultats
|
|
182
|
+
|
|
183
|
+
async def _verifier_version(
|
|
184
|
+
self, cible: Cible, cms: str, version: str
|
|
185
|
+
) -> list[Resultat]:
|
|
186
|
+
"""Contrôle la branche de la version détectée et, en opt-in, les failles OSV."""
|
|
187
|
+
resultats: list[Resultat] = []
|
|
188
|
+
|
|
189
|
+
# OPT-IN : l'interrogation d'OSV envoie le nom et la version du CMS à un tiers
|
|
190
|
+
# (api.osv.dev), jamais le domaine analysé. Pour respecter le principe « aucune
|
|
191
|
+
# donnée envoyée à un tiers » de 03-CONCEPTION.md, on ne l'exécute que si la cible
|
|
192
|
+
# a activé explicitement --verifier-cve.
|
|
193
|
+
resultat_osv: Resultat | None = None
|
|
194
|
+
if cible.verifier_cve:
|
|
195
|
+
resultat_osv = await self._interroger_osv(cms, version)
|
|
196
|
+
if resultat_osv is not None:
|
|
197
|
+
resultats.append(resultat_osv)
|
|
198
|
+
|
|
199
|
+
# Pas de double pénalité : si des failles connues sont confirmées
|
|
200
|
+
# (cms_vulnerable, CRITIQUE), elles englobent l'obsolescence de la même version.
|
|
201
|
+
# On n'émet alors pas cms_obsolete en plus, pour ne pas compter deux fois le même
|
|
202
|
+
# problème dans la note.
|
|
203
|
+
if resultat_osv is not None and resultat_osv.identifiant == "cms_vulnerable":
|
|
204
|
+
return resultats
|
|
205
|
+
|
|
206
|
+
maintenues = BRANCHES_MAINTENUES.get(cms)
|
|
207
|
+
if maintenues:
|
|
208
|
+
branche = _branche_non_maintenue(version, maintenues)
|
|
209
|
+
if branche is not None:
|
|
210
|
+
liste_maintenues = ", ".join(sorted(maintenues, key=int))
|
|
211
|
+
resultats.append(
|
|
212
|
+
Resultat(
|
|
213
|
+
identifiant="cms_obsolete",
|
|
214
|
+
gravite=Gravite.ELEVEE,
|
|
215
|
+
reussi=False,
|
|
216
|
+
preuve=(
|
|
217
|
+
f"{cms} {version} : branche {branche}.x non maintenue "
|
|
218
|
+
f"(branches maintenues : {liste_maintenues})"
|
|
219
|
+
),
|
|
220
|
+
details={
|
|
221
|
+
"cms": cms,
|
|
222
|
+
"version": version,
|
|
223
|
+
"branche": branche,
|
|
224
|
+
"branches_maintenues": sorted(maintenues, key=int),
|
|
225
|
+
},
|
|
226
|
+
)
|
|
227
|
+
)
|
|
228
|
+
return resultats
|
|
229
|
+
|
|
230
|
+
async def _obtenir(
|
|
231
|
+
self, client: httpx.AsyncClient, url: str
|
|
232
|
+
) -> httpx.Response | None:
|
|
233
|
+
"""Récupère une URL en meilleur effort ; renvoie None sur toute erreur réseau."""
|
|
234
|
+
try:
|
|
235
|
+
return await client.get(url)
|
|
236
|
+
except httpx.HTTPError:
|
|
237
|
+
return None
|
|
238
|
+
|
|
239
|
+
async def _detecter(
|
|
240
|
+
self, client: httpx.AsyncClient, base: str, sonde_autorisee: bool
|
|
241
|
+
) -> tuple[str | None, str | None]:
|
|
242
|
+
"""Identifie le CMS et sa version via la page d'accueil puis, si permis, des chemins."""
|
|
243
|
+
accueil = await self._obtenir(client, f"{base}/")
|
|
244
|
+
if accueil is not None:
|
|
245
|
+
cms, version = self._detecter_depuis_reponse(accueil)
|
|
246
|
+
if cms is not None:
|
|
247
|
+
return cms, version
|
|
248
|
+
|
|
249
|
+
# En mode passif, on s'arrête à la page d'accueil.
|
|
250
|
+
if not sonde_autorisee:
|
|
251
|
+
return None, None
|
|
252
|
+
return await self._detecter_par_chemins(client, base)
|
|
253
|
+
|
|
254
|
+
def _detecter_depuis_reponse(
|
|
255
|
+
self, reponse: httpx.Response
|
|
256
|
+
) -> tuple[str | None, str | None]:
|
|
257
|
+
"""Déduit (CMS, version) d'une réponse : meta generator, en-têtes, puis corps."""
|
|
258
|
+
# 1) Balise <meta name="generator"> : la plus fiable pour CMS + version.
|
|
259
|
+
correspondance = _MOTIF_GENERATOR.search(reponse.text)
|
|
260
|
+
if correspondance is not None:
|
|
261
|
+
contenu = correspondance.group(1)
|
|
262
|
+
cms = _cms_depuis_libelle(contenu)
|
|
263
|
+
if cms is not None:
|
|
264
|
+
return cms, _extraire_version(contenu)
|
|
265
|
+
|
|
266
|
+
# 2) En-tête X-Generator (utilisé notamment par Drupal).
|
|
267
|
+
entete_generator = reponse.headers.get("x-generator")
|
|
268
|
+
if entete_generator is not None:
|
|
269
|
+
cms = _cms_depuis_libelle(entete_generator)
|
|
270
|
+
if cms is not None:
|
|
271
|
+
return cms, _extraire_version(entete_generator)
|
|
272
|
+
|
|
273
|
+
# 3) Marqueurs techniques dans le corps (sans numéro de version fiable). On évite
|
|
274
|
+
# les mots-clés nus (« joomla », « prestashop ») qui apparaissent dans un simple
|
|
275
|
+
# article de blog et produiraient des faux positifs.
|
|
276
|
+
corps = reponse.text.lower()
|
|
277
|
+
if "/wp-content/" in corps or "/wp-includes/" in corps:
|
|
278
|
+
return "WordPress", None
|
|
279
|
+
if "drupal.settings" in corps or "/sites/default/files" in corps:
|
|
280
|
+
return "Drupal", None
|
|
281
|
+
if "/media/jui/" in corps or "/media/system/js/" in corps or "option=com_" in corps:
|
|
282
|
+
return "Joomla", None
|
|
283
|
+
if "var prestashop =" in corps or "/modules/ps_" in corps:
|
|
284
|
+
return "PrestaShop", None
|
|
285
|
+
|
|
286
|
+
# 4) Cookie de session caractéristique de PrestaShop (nom « PrestaShop-<hash> »).
|
|
287
|
+
for cookie in reponse.headers.get_list("set-cookie"):
|
|
288
|
+
if cookie.lstrip().startswith("PrestaShop-"):
|
|
289
|
+
return "PrestaShop", None
|
|
290
|
+
return None, None
|
|
291
|
+
|
|
292
|
+
def _reponse_ok(self, reponse: httpx.Response | None) -> httpx.Response | None:
|
|
293
|
+
"""Renvoie ``reponse`` si c'est un vrai 200, sinon None."""
|
|
294
|
+
if reponse is None or reponse.status_code != 200:
|
|
295
|
+
return None
|
|
296
|
+
return reponse
|
|
297
|
+
|
|
298
|
+
async def _detecter_par_chemins(
|
|
299
|
+
self, client: httpx.AsyncClient, base: str
|
|
300
|
+
) -> tuple[str | None, str | None]:
|
|
301
|
+
"""Sonde des fichiers connus pour confirmer un CMS et en extraire la version.
|
|
302
|
+
|
|
303
|
+
Avant toute sonde, on demande un chemin aléatoire qui ne peut pas exister. Si le
|
|
304
|
+
site y répond 200 (« soft 404 » : page d'erreur ou d'accueil servie partout, au
|
|
305
|
+
contenu parfois dynamique), aucun 200 n'est digne de confiance : on ne conclut
|
|
306
|
+
rien des chemins. Chaque fichier doit en outre porter une signature qui lui est
|
|
307
|
+
propre.
|
|
308
|
+
"""
|
|
309
|
+
sonde = await self._obtenir(client, f"{base}/pmescan-{uuid.uuid4().hex}")
|
|
310
|
+
if sonde is not None and sonde.status_code == 200:
|
|
311
|
+
return None, None
|
|
312
|
+
|
|
313
|
+
# WordPress : readme.html porte un <title> WordPress ET un marqueur propre au
|
|
314
|
+
# fichier (logo ou lien wordpress.org) ; la version suit « <br /> Version ».
|
|
315
|
+
reponse = self._reponse_ok(await self._obtenir(client, f"{base}/readme.html"))
|
|
316
|
+
if (
|
|
317
|
+
reponse is not None
|
|
318
|
+
and _MOTIF_TITRE_WORDPRESS.search(reponse.text)
|
|
319
|
+
and _MOTIF_MARQUEUR_README_WORDPRESS.search(reponse.text)
|
|
320
|
+
):
|
|
321
|
+
correspondance = _MOTIF_VERSION_README_WORDPRESS.search(reponse.text)
|
|
322
|
+
return "WordPress", (correspondance.group(1) if correspondance else None)
|
|
323
|
+
|
|
324
|
+
# Drupal : CHANGELOG.txt COMMENCE par « Drupal X.Y, ... ».
|
|
325
|
+
for chemin in ("/CHANGELOG.txt", "/core/CHANGELOG.txt"):
|
|
326
|
+
reponse = self._reponse_ok(await self._obtenir(client, f"{base}{chemin}"))
|
|
327
|
+
if reponse is not None:
|
|
328
|
+
correspondance = _MOTIF_CHANGELOG_DRUPAL.match(reponse.text)
|
|
329
|
+
if correspondance is not None:
|
|
330
|
+
return "Drupal", correspondance.group(1)
|
|
331
|
+
|
|
332
|
+
# Joomla : manifeste XML <extension ...> exposant <version>X.Y.Z</version>.
|
|
333
|
+
reponse = self._reponse_ok(
|
|
334
|
+
await self._obtenir(client, f"{base}/administrator/manifests/files/joomla.xml")
|
|
335
|
+
)
|
|
336
|
+
if (
|
|
337
|
+
reponse is not None
|
|
338
|
+
and "<extension" in reponse.text
|
|
339
|
+
and "joomla" in reponse.text.lower()
|
|
340
|
+
):
|
|
341
|
+
correspondance = re.search(r"<version>\s*(\d+(?:\.\d+)+)\s*</version>", reponse.text)
|
|
342
|
+
return "Joomla", (correspondance.group(1) if correspondance else None)
|
|
343
|
+
|
|
344
|
+
# PrestaShop : page d'administration par défaut accessible.
|
|
345
|
+
reponse = self._reponse_ok(await self._obtenir(client, f"{base}/admin/"))
|
|
346
|
+
if reponse is not None and "prestashop" in reponse.text.lower():
|
|
347
|
+
return "PrestaShop", None
|
|
348
|
+
|
|
349
|
+
return None, None
|
|
350
|
+
|
|
351
|
+
async def _interroger_osv(self, cms: str, version: str) -> Resultat | None:
|
|
352
|
+
"""Interroge OSV pour la version détectée (meilleur effort).
|
|
353
|
+
|
|
354
|
+
Renvoie ``cms_vulnerable`` (CRITIQUE) si des failles identifiées sont connues,
|
|
355
|
+
``None`` si OSV ne connaît pas de faille, et ``cms_cve_indeterminee`` (INFO) si le
|
|
356
|
+
CMS n'est pas couvert, si OSV est injoignable ou renvoie une réponse inexploitable.
|
|
357
|
+
Aucune exception ne sort de cette méthode.
|
|
358
|
+
"""
|
|
359
|
+
paquet = _PAQUETS_OSV.get(cms)
|
|
360
|
+
if paquet is None:
|
|
361
|
+
return self._cve_indeterminee(cms, version, "CMS non couvert par OSV")
|
|
362
|
+
ecosysteme, nom_paquet = paquet
|
|
363
|
+
corps = {
|
|
364
|
+
"version": version,
|
|
365
|
+
"package": {"name": nom_paquet, "ecosystem": ecosysteme},
|
|
366
|
+
}
|
|
367
|
+
# Client DÉDIÉ au tiers OSV : certificat vérifié (verify=True), aucune redirection
|
|
368
|
+
# suivie et délai court. Il ne partage rien avec le client de la cible.
|
|
369
|
+
try:
|
|
370
|
+
async with httpx.AsyncClient(
|
|
371
|
+
verify=True, follow_redirects=False, timeout=_DELAI_OSV
|
|
372
|
+
) as client_osv:
|
|
373
|
+
reponse = await client_osv.post(_URL_OSV, json=corps)
|
|
374
|
+
except httpx.HTTPError:
|
|
375
|
+
return self._cve_indeterminee(cms, version, "OSV injoignable")
|
|
376
|
+
|
|
377
|
+
if reponse.status_code != 200:
|
|
378
|
+
return self._cve_indeterminee(cms, version, f"OSV a répondu {reponse.status_code}")
|
|
379
|
+
|
|
380
|
+
try:
|
|
381
|
+
donnees = reponse.json()
|
|
382
|
+
except ValueError:
|
|
383
|
+
return self._cve_indeterminee(cms, version, "réponse OSV illisible")
|
|
384
|
+
|
|
385
|
+
vulnerabilites = donnees.get("vulns") if isinstance(donnees, dict) else None
|
|
386
|
+
if not vulnerabilites:
|
|
387
|
+
# OSV ne connaît aucune faille pour cette version (couverture partielle des
|
|
388
|
+
# cœurs de CMS) : on n'émet rien plutôt qu'un faux « site sain ».
|
|
389
|
+
return None
|
|
390
|
+
if not isinstance(vulnerabilites, list):
|
|
391
|
+
return self._cve_indeterminee(cms, version, "réponse OSV illisible")
|
|
392
|
+
|
|
393
|
+
identifiants = [
|
|
394
|
+
str(vuln.get("id"))
|
|
395
|
+
for vuln in vulnerabilites
|
|
396
|
+
if isinstance(vuln, dict) and vuln.get("id")
|
|
397
|
+
]
|
|
398
|
+
if not identifiants:
|
|
399
|
+
# Des entrées existent mais aucune n'est identifiable : on ne peut pas affirmer
|
|
400
|
+
# une vulnérabilité précise.
|
|
401
|
+
return self._cve_indeterminee(cms, version, "réponse OSV sans identifiant")
|
|
402
|
+
|
|
403
|
+
apercu = ", ".join(identifiants[:5])
|
|
404
|
+
return Resultat(
|
|
405
|
+
identifiant="cms_vulnerable",
|
|
406
|
+
gravite=Gravite.CRITIQUE,
|
|
407
|
+
reussi=False,
|
|
408
|
+
preuve=f"{cms} {version} : {len(identifiants)} faille(s) connue(s) ({apercu})",
|
|
409
|
+
details={"cms": cms, "version": version, "cve": identifiants},
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
def _cve_indeterminee(self, cms: str, version: str, motif: str) -> Resultat:
|
|
413
|
+
"""Fabrique un résultat INFO signalant que l'état des failles n'a pas pu être établi."""
|
|
414
|
+
return Resultat(
|
|
415
|
+
identifiant="cms_cve_indeterminee",
|
|
416
|
+
gravite=Gravite.INFO,
|
|
417
|
+
reussi=False,
|
|
418
|
+
preuve=f"{cms} {version} : {motif}",
|
|
419
|
+
details={"cms": cms, "version": version, "motif": motif},
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
async def _verifier_wordpress(
|
|
423
|
+
self, client: httpx.AsyncClient, base: str
|
|
424
|
+
) -> list[Resultat]:
|
|
425
|
+
"""Vérifications propres à WordPress : xmlrpc.php actif et énumération d'utilisateurs."""
|
|
426
|
+
resultats: list[Resultat] = []
|
|
427
|
+
|
|
428
|
+
# xmlrpc.php : en GET, un serveur actif répond « only POST requests ».
|
|
429
|
+
reponse = await self._obtenir(client, f"{base}/xmlrpc.php")
|
|
430
|
+
if reponse is not None and (
|
|
431
|
+
reponse.status_code in (200, 405) and "xml-rpc" in reponse.text.lower()
|
|
432
|
+
):
|
|
433
|
+
resultats.append(
|
|
434
|
+
Resultat(
|
|
435
|
+
identifiant="wp_xmlrpc_actif",
|
|
436
|
+
gravite=Gravite.FAIBLE,
|
|
437
|
+
reussi=False,
|
|
438
|
+
preuve=f"{base}/xmlrpc.php",
|
|
439
|
+
details={"url": f"{base}/xmlrpc.php"},
|
|
440
|
+
)
|
|
441
|
+
)
|
|
442
|
+
|
|
443
|
+
# Énumération d'utilisateurs : /?author=1 redirige vers /author/<identifiant>/, et
|
|
444
|
+
# cette page finale doit exister (200), sinon la redirection ne prouve rien.
|
|
445
|
+
reponse = await self._obtenir(client, f"{base}/?author=1")
|
|
446
|
+
if (
|
|
447
|
+
reponse is not None
|
|
448
|
+
and reponse.status_code == 200
|
|
449
|
+
and "/author/" in str(reponse.url)
|
|
450
|
+
):
|
|
451
|
+
resultats.append(
|
|
452
|
+
Resultat(
|
|
453
|
+
identifiant="wp_enumeration_utilisateurs",
|
|
454
|
+
gravite=Gravite.MOYENNE,
|
|
455
|
+
reussi=False,
|
|
456
|
+
preuve=str(reponse.url),
|
|
457
|
+
details={"url": str(reponse.url)},
|
|
458
|
+
)
|
|
459
|
+
)
|
|
460
|
+
return resultats
|