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 ADDED
@@ -0,0 +1,6 @@
1
+ """pmescan — scanner de sécurité pour PME francophones.
2
+
3
+ Ce paquet analyse un site web et produit un rapport clair en français.
4
+ """
5
+
6
+ __version__ = "0.1.0"
@@ -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