Revue statique de l'ensemble du code Python, de la configuration CI/nginx, du .gitignore et des dependances. 44 constats documentes avec references fichier:ligne, impact et correctif propose. - audit/01-securite.md 19 constats (4 critiques) - audit/02-maintenabilite.md 15 constats - audit/03-standards-stack.md 10 ecarts aux conventions Flask/SQLAlchemy - audit/plan-remediation.md ordre de traitement en 6 lots Points critiques : secrets de production reels committes dans app/.env.exemple, seed automatique en production avec mot de passe password, CORS ouvert a toutes les origines avec credentials par defaut, token du bot Discord imprime sur stdout au demarrage. Aucune modification du code applicatif. Co-Authored-By: Claude Opus 5 <[email protected]>
24 KiB
3 — Respect des standards de la stack
Écarts aux conventions établies de Flask, SQLAlchemy et du déploiement d'applications web Python.
| ID | Constat | Sévérité |
|---|---|---|
| STD-01 | Pas de migrations : db.create_all() au démarrage |
🟠 Élevé |
| STD-02 | create_app() déclenche des effets de bord au démarrage |
🟡 Moyen |
| STD-03 | Le bot Discord est démarré depuis la fabrique d'application | 🟡 Moyen |
| STD-04 | Trois approches de validation coexistent ; Flask-WTF inutilisé | 🟡 Moyen |
| STD-05 | Aucun objet de configuration ni séparation d'environnements | 🟡 Moyen |
| STD-06 | Ports et adresses d'écoute incohérents entre cinq fichiers | 🟡 Moyen |
| STD-07 | Journalisation vers des fichiers locaux plutôt que stdout | 🟡 Moyen |
| STD-08 | Logique métier dans les vues, sans couche de service | 🔵 Faible |
| STD-09 | Gestion d'erreurs API par préfixe d'URL codé en dur | 🔵 Faible |
| STD-10 | Pas de conteneurisation ni de procédure de déploiement reproductible | 🔵 Faible |
STD-01 · 🟠
Pas de migrations : db.create_all() au démarrage
app/app.py:348-351
with app.app_context():
import app.models as models
from app.models import User
db.create_all()
Ni Flask-Migrate ni alembic ne figurent dans requirements.txt, et il n'existe pas de dossier migrations/.
db.create_all() ne crée que les tables absentes. Il ne modifie jamais une table existante : ajouter une colonne, changer un type, ajouter une contrainte d'unicité ou une clé étrangère ON DELETE CASCADE n'a aucun effet sur une base déjà initialisée.
Impact. C'est le constat qui bloque le plus de corrections proposées dans ce rapport :
- SEC-07 demande une contrainte d'unicité sur
User.discord_user_id— impossible sans migration ; - MNT-11 demande des
ondelete='CASCADE'sur les clés étrangères d'OrgTeam— impossible sans migration ; - MNT-13 demande de passer les colonnes en
DateTime(timezone=True)— impossible sans migration.
Au-delà, le mode de fonctionnement actuel implique que toute évolution du schéma se fait à la main en production, par des ALTER TABLE non versionnés, non revus et non rejouables. Les environnements divergent silencieusement : une base créée aujourd'hui par create_all() n'a pas la même structure qu'une base créée il y a six mois puis modifiée manuellement. Il n'existe aucun moyen de savoir laquelle est correcte.
Correction. Introduire Flask-Migrate. La séquence sur une base existante :
pip install Flask-Migrate
flask db init
flask db stamp head # déclare la base actuelle comme point de départ
puis, pour chaque évolution :
flask db migrate -m "unicité sur discord_user_id"
flask db upgrade
Dans app.py, remplacer le bloc create_all() par l'initialisation de l'extension :
from flask_migrate import Migrate
migrate = Migrate() # dans extensions.py
migrate.init_app(app, db)
et retirer db.create_all() de la fabrique — les migrations s'appliquent lors du déploiement, pas au démarrage du processus.
Relire systématiquement les migrations générées : l'autogénération d'Alembic gère mal les modèles à héritage polymorphe de table unique comme User, et produit régulièrement des suppressions de colonnes non voulues.
STD-02 · 🟡
create_app() déclenche des effets de bord au démarrage
app/app.py:33-365. La fabrique, en plus de configurer l'application, exécute :
| Ligne | Effet de bord |
|---|---|
:351 |
db.create_all() — écriture dans le schéma |
:354-356 |
Insertion des données de démonstration si la table est vide |
:359-361 |
Démarrage d'un thread portant un client Discord |
logging_config.py:66-67 |
os.makedirs('logs') dans le répertoire de travail courant |
Le motif de fabrique d'application (application factory) existe précisément pour permettre de construire plusieurs instances configurées différemment — c'est ce qui rend une application Flask testable. Ici, chaque appel à create_app() écrit dans la base et ouvre une connexion Discord.
Impact.
- Les tests d'intégration sont impraticables. Le motif standard est une fixture
app = create_app(TestConfig)par session de test ; ici, chaque construction tenterait de se connecter à Discord et de peupler la base. C'est un facteur direct de MNT-03 (absence de tests) : l'architecture rend le premier test coûteux à écrire. - Le comportement dépend du répertoire de travail (
os.getcwd()enlogging_config.py:66etusers.py:541), donc du mode de lancement — le dossierlogs/n'apparaît pas au même endroit selon qu'on lancepython run.pyoupython wsgi.pydepuis un autre répertoire. - Le démarrage n'est pas idempotent (cf. STD-03).
Correction. Faire accepter une configuration à la fabrique et sortir les effets de bord :
def create_app(config_object=None):
app = Flask(__name__)
app.config.from_object(config_object or ProductionConfig)
...
return app
create_all/ seed → commandes CLI (flask db upgrade,flask seed-demo), exécutées au déploiement ;- bot Discord → processus séparé (cf. STD-03) ;
- chemin des logs → configurable, dérivé de
app.root_pathplutôt que deos.getcwd().
STD-03 · 🟡
Le bot Discord est démarré depuis la fabrique d'application
app/app.py:358-363
try:
from app.discord_bot import start_bot
start_bot(flask_app=app)
except Exception as e:
app.logger.warning('Could not start Discord bot: %s', e)
discord_bot.py lance un thread portant une boucle asyncio, un client Discord et un AsyncIOScheduler avec une tâche cron quotidienne à 18h00 (:78-84).
Impact. Le couplage entre le cycle de vie du serveur web et celui du bot pose trois problèmes concrets :
- Multiplication des instances. Un seul processus est lancé aujourd'hui (
wsgi.pyavec Waitress multi-threads), donc un seul bot. Mais toute mise à l'échelle horizontale — plusieurs workers, deux instances derrière un équilibreur, un déploiement bleu-vert où l'ancienne et la nouvelle version tournent en parallèle — crée autant de connexions Discord et autant de planificateurs. Les rappels quotidiens partiraient alors en double ou en triple. Discord limite en outre le nombre de sessions simultanées par token. - Défaillance silencieuse. Le
except Exceptionavale toute erreur en un simplewarning. Si le token est invalide ou révoqué — ce qui va arriver lors de la correction de SEC-01 — l'application démarre normalement et les notifications cessent, sans alerte. - Le web ne peut pas redémarrer sans couper les notifications, et inversement.
Correction. Séparer les processus, ce qui est le modèle standard pour une tâche de fond persistante :
web: python wsgi.py
bot: python -m app.discord_bot
La communication passe alors par la base de données partagée — plutôt que par la Queue en mémoire actuelle (discord_bot.py:53), qui impose justement le partage du processus.
Si la séparation est trop coûteuse à court terme, deux garde-fous minimaux :
- conditionner le démarrage à une variable (
ENABLE_DISCORD_BOT), pour pouvoir le désactiver en développement et en CI ; - poser un verrou consultatif PostgreSQL (
pg_advisory_lock) pour garantir qu'une seule instance planifie les rappels.
Journaliser l'échec en error, pas en warning.
STD-04 · 🟡
Trois approches de validation coexistent ; Flask-WTF inutilisé
Trois mécanismes se répartissent la validation des entrées, sans règle apparente :
| Approche | Où | Exemple |
|---|---|---|
| Schémas Marshmallow | auth.py uniquement, plus upload_contract |
auth.py:206-218 |
| Contrôles manuels | partout ailleurs | evaluations.py:20-30 (validate_score), tryouts.py:296 (if new_status in [...]) |
| Aucune validation | users.py (create/edit user, edit profile) |
SEC-06 |
Flask-WTF==1.3.0 est installé et CSRFProtect en est utilisé — mais aucun FlaskForm n'est défini dans le projet. La bibliothèque n'est présente que pour la protection CSRF, alors que sa fonction principale est la définition et le rendu de formulaires validés. WTForms==3.2.2 est également installé et totalement inutilisé.
Impact. L'absence de convention est la cause structurelle de SEC-06 : quand trois approches sont acceptables, aucune n'est obligatoire, et une route peut n'en appliquer aucune sans que cela détonne à la lecture. Les règles sont par ailleurs dupliquées — la politique de mot de passe existe dans validators.py:22-24, mais rien n'oblige les routes à passer par là.
Les schémas Marshmallow sont bien écrits (StripMixin avec unknown = EXCLUDE pour ignorer proprement le csrf_token, validateurs réutilisables, messages explicites). Le problème n'est pas leur qualité mais leur application partielle.
Correction. Choisir une approche unique et l'appliquer partout. Deux options défendables :
- Marshmallow partout (recommandé ici, car les schémas existent déjà et couvrent presque tous les formulaires) : compléter les schémas manquants —
TeamCreateSchema,MatchCreateSchema,EvaluationSchema,TryoutSchema— et faire passer chaquerequest.formpar unload(). Un décorateur factorise le traitement des erreurs :@validate_form(EditProfileSchema, on_error='pages/edit_profile.html') def edit_profile(validated): ... - Flask-WTF partout : plus idiomatique pour une application rendue côté serveur, puisque les formulaires se rendent et se repeuplent seuls dans Jinja2 en cas d'erreur, et que la protection CSRF est intégrée par champ. Coût de conversion plus élevé, car les 40 templates écrivent leurs
<input>à la main.
Dans les deux cas, retirer la bibliothèque non retenue des dépendances (MNT-05).
STD-05 · 🟡
Aucun objet de configuration ni séparation d'environnements
app/app.py:54-95 affecte 12 clés de configuration une par une, en mêlant constantes et lectures d'environnement :
app.config['SECRET_KEY'] = os.getenv('SECRET_KEY')
app.config['SQLALCHEMY_DATABASE_URI'] = os.getenv('DATABASE_URL')
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024
app.config['SESSION_COOKIE_SECURE'] = os.getenv('SESSION_COOKIE_SECURE', 'true').lower() == 'true'
...
D'autres réglages sont lus directement via os.getenv() au fil du code, hors de app.config : FORCE_HTTPS (app.py:183), LOG_LEVEL et FLASK_DEBUG (logging_config.py:73,133), DISCORD_WEBHOOK_URL (users.py:636, au moment de l'import), DISCORD_BOT_TOKEN (discord_bot.py:24). load_dotenv() est appelé dans deux modules distincts (app.py:16, discord_bot.py:23).
Il n'existe aucune notion d'environnement : les mêmes valeurs par défaut s'appliquent en développement, en CI et en production. C'est la cause directe de SEC-02 (seed en production) et de SEC-03 (CORS permissif par défaut).
Impact. Impossible de répondre par la lecture à des questions comme « quelle configuration s'applique en production ? » ou « qu'est-ce qui change entre local et prod ? » — il faut parcourir cinq fichiers et reconstituer les valeurs par défaut de chaque os.getenv. Les valeurs par défaut sont d'ailleurs incohérentes entre elles : FLASK_DEBUG vaut 'false' dans app.py:371 et 'true' dans run.py:20.
Correction. Motif standard Flask — des classes de configuration, sélectionnées par une variable d'environnement :
# app/config.py
class BaseConfig:
SQLALCHEMY_TRACK_MODIFICATIONS = False
MAX_CONTENT_LENGTH = 16 * 1024 * 1024
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SAMESITE = 'Lax'
PERMANENT_SESSION_LIFETIME = 3600
WTF_CSRF_ENABLED = True
class ProductionConfig(BaseConfig):
SESSION_COOKIE_SECURE = True
FORCE_HTTPS = True
SEED_DEMO_DATA = False
def __init__(self):
self.SECRET_KEY = _require('SECRET_KEY')
self.SQLALCHEMY_DATABASE_URI = _require('DATABASE_URL')
self.CORS_ALLOWED_ORIGINS = _require('CORS_ALLOWED_ORIGINS')
class DevelopmentConfig(BaseConfig):
SESSION_COOKIE_SECURE = False
FORCE_HTTPS = False
SEED_DEMO_DATA = True
_require() lève au démarrage si la variable manque — l'application refuse de démarrer mal configurée, ce que app.py:56-62 fait déjà pour deux clés et qu'il suffit de généraliser. C'est ce mécanisme qui aurait empêché SEC-03.
Un seul load_dotenv(), au point d'entrée. Toute lecture de configuration passe ensuite par current_app.config, ce qui la rend surchargeable en test.
STD-06 · 🟡
Ports et adresses d'écoute incohérents entre cinq fichiers
| Fichier | Ligne | Écoute / cible |
|---|---|---|
run.py |
:26 |
app.run(host='127.0.0.2', port=5000) |
app/app.py |
:377 |
app.run(host='0.0.0.0', port=10000) |
wsgi.py |
:24-26 |
PORT défaut 10000, HOST défaut 0.0.0.0 |
app/.env.exemple |
:25-26 |
HOST=127.0.0.1, PORT=5000 |
app/nginx.conf |
:122 |
proxy_pass http://0.0.0.0:5000 |
Quatre combinaisons différentes pour trois points d'entrée. Points notables :
127.0.0.2dansrun.pyest presque certainement une faute de frappe pour127.0.0.1. L'adresse est techniquement valide (tout le bloc127.0.0.0/8est en boucle locale sous Linux), mais elle n'est pas joignable sous Windows sans configuration supplémentaire — or le projet cible Windows (Waitress,nginx.confavec des cheminsC:/).proxy_pass http://0.0.0.0:5000:0.0.0.0désigne « toutes les interfaces » comme adresse d'écoute, mais n'a pas de sens comme adresse de destination. La cible correcte est127.0.0.1:5000.wsgi.pyécoute sur 10000 par défaut, nginx envoie vers 5000. SansPORT=5000dans l'environnement, le proxy ne trouve pas l'application.- Le commentaire de
wsgi.py:26dit « Bind to localhost by default (Nginx reverse proxy) » alors que le défaut est0.0.0.0— l'intention est documentée, l'implémentation fait l'inverse (cf. SEC-05, dont c'est un facteur aggravant).
Impact. La chaîne nginx → Waitress ne fonctionne pas avec les valeurs par défaut : elle exige des variables d'environnement non documentées dans le README. Un déploiement suivant la documentation aboutit à un 502.
Correction.
- Un seul point d'entrée de production :
wsgi.py, avecHOSTpar défaut à127.0.0.1etPORTpar défaut à5000pour s'aligner sur nginx et sur.env.exemple. - Supprimer le bloc
if __name__ == '__main__'deapp/app.py:368-377, qui fait doublon avecrun.pyet diverge de lui. - Corriger
run.py:26en127.0.0.1et lui faire lireHOST/PORT. nginx.conf:122→proxy_pass http://127.0.0.1:5000;.- Documenter le tableau des ports dans le README.
STD-07 · 🟡
Journalisation vers des fichiers locaux plutôt que stdout
app/logging_config.py:66-128 configure trois RotatingFileHandler (10 Mo, 5 à 10 archives) écrivant dans os.getcwd()/logs/. Le handler console n'est ajouté que si FLASK_DEBUG=true (:133-138) — en production, l'application n'écrit donc rien sur stdout.
Impact. Cela contredit le principe des logs comme flux d'événements (facteur XI des douze facteurs), qui veut que le processus écrive sans tampon sur stdout et laisse l'environnement d'exécution router, agréger et archiver.
Conséquences concrètes :
docker logs,journalctl, et les collecteurs des plateformes d'hébergement (Render, entre autres, ne capture que stdout/stderr) ne voient rien. Sur un hébergement conteneurisé, les logs sont écrits dans un système de fichiers éphémère et perdus à chaque redéploiement.- Le chemin dépend du répertoire de travail (
os.getcwd()), donc de la façon dont le processus a été lancé — les logs peuvent atterrir à des endroits différents selon le mode de démarrage (cf. STD-02). - Le format est textuel (
logging_config.py:81-84), non structuré : pas d'agrégation ni de requêtage par champ.
Le reste du module est de bonne facture : la séparation erreurs / auth / application est pertinente, et le SensitiveDataFilter (:18-50) est une précaution que l'on voit rarement.
Correction. Conserver la structure, changer la destination :
handler = logging.StreamHandler(sys.stdout)
handler.setFormatter(formatter)
handler.addFilter(sensitive_filter)
app.logger.addHandler(handler)
Garder les handlers fichier optionnels, activés par LOG_TO_FILES=true pour le déploiement Windows sur serveur dédié où ils gardent leur intérêt.
Passer au format JSON (python-json-logger) si une agrégation est envisagée ; les canaux distincts deviennent alors un champ logger plutôt que trois fichiers.
STD-08 · 🔵
Logique métier dans les vues, sans couche de service
L'application suit le découpage blueprints / modèles / templates, mais toute la logique métier vit dans les fonctions de vue. Exemples :
matches.create_match(matches.py:215-337, 122 lignes) : validation, création du match, création des participants selon trois types de match, puis envoi des notifications Discord — le tout dans une seule fonction, avec desdb.session.flush()intercalés dans les boucles ;users.delete_user(users.py:142-185) : orchestration de la suppression de 15 modèles liés ;main.dashboard(main.py:26-139) : cinq variantes de calcul de statistiques selon le rôle, avec des requêtes agrégées.
Conséquence : cette logique n'est atteignable que par une requête HTTP. Tester la règle « supprimer un joueur d'un tryout le retire aussi de ses équipes et de ses matchs » impose de monter un client de test, une session authentifiée et un jeton CSRF — pour vérifier une règle qui n'a rien de HTTP.
Le couplage aux notifications est le plus visible : matches.py:325-331 et team_matches.py:185-192 appellent send_schedule_notification directement dans la vue, en boucle sur les participants. Créer un match par un autre chemin (import, script, tâche planifiée) n'enverrait aucune notification.
Impact. Faible aujourd'hui — le code reste lisible et l'application fonctionne. C'est un constat de trajectoire : c'est ce qui rend l'écriture des tests de MNT-03 plus coûteuse qu'elle ne devrait l'être, et ce qui a permis les duplications de MNT-12.
Correction. Extraire progressivement, en commençant par ce qui est dupliqué ou testable :
# app/services/matches.py
def create_match(tryout, form_data, author) -> Match:
"""Crée un match, ses participants et déclenche les notifications."""
La vue se réduit alors à : valider l'entrée → appeler le service → rendre le résultat. Inutile de tout convertir d'un coup ; appliquer la règle aux nouvelles fonctionnalités et aux zones déjà remaniées suffit à inverser la tendance.
STD-09 · 🔵
Gestion d'erreurs API par préfixe d'URL codé en dur
app/app.py:219-343. Les sept gestionnaires d'erreur décident du format de réponse en testant le chemin :
if request.path.startswith('/users/disponibilities') or \
request.path.startswith('/users/coach-availability') or \
request.path.startswith('/users/api/'):
return jsonify({'error': 'Bad request', 'message': str(error)}), 400
return render_template('errors/400.html', error=error), 400
Le bloc est répété sept fois, avec des listes de préfixes qui divergent entre les gestionnaires : le 400 teste trois préfixes, les 401/403/404/429/500 n'en testent que deux (coach-availability a été oublié).
Ces préfixes ne couvrent d'ailleurs pas toutes les routes JSON de l'application. matches.py expose /matches/api/events, /matches/api/manageable-tryouts, /matches/api/available_players/… ; team_matches.py expose /team-matches/api/manageable-teams ; teams.py:392 et team_matches.py:288 renvoient du JSON sur /teams/<id>/toggle_status/<id> et /team-matches/<id>/toggle-presence/<id>. Aucune de ces routes n'est reconnue : une erreur 403 sur /matches/api/events renvoie une page HTML à un appel fetch(), qui échouera au response.json() avec un message incompréhensible côté client.
Impact. Comportement d'erreur incohérent selon la route, et couplage fort entre la structure d'URL et la gestion d'erreurs : renommer un blueprint casse silencieusement le format de réponse. Chaque nouvelle route JSON doit penser à s'ajouter à sept listes.
Correction. Utiliser la négociation de contenu, qui est le mécanisme prévu par HTTP :
def wants_json():
return (request.accept_mimetypes.best_match(['application/json', 'text/html'])
== 'application/json' or request.path.startswith('/api/'))
Plus propre encore : regrouper les routes JSON sous un blueprint api_bp avec url_prefix='/api' et lui attacher ses propres gestionnaires via @api_bp.errorhandler — Flask applique alors automatiquement le bon format selon le blueprint qui a traité la requête, sans test de chemin.
Factoriser en tout état de cause les sept blocs identiques en une fonction unique.
STD-10 · 🔵
Pas de conteneurisation ni de procédure de déploiement reproductible
Le dépôt ne contient ni Dockerfile, ni docker-compose.yml, ni Procfile, ni manifeste de plateforme (render.yaml, fly.toml…). Le déploiement repose sur :
app/nginx.conf, avec des chemins Windows absolus codés en dur (C:/nginx/certs/fullchain.pem,:86-87) ;app/supporting_scrits/run_https.py, qui génère des certificats auto-signés viasubprocess;docs/deployment.md, une procédure manuelle ;backup.py, dont le docstring évoque le Planificateur de tâches Windows — et qui ne fonctionne pas (MNT-07).
.gitignore:23 ignorant docs/, la documentation de déploiement est dans une situation ambiguë : présente dans l'index, mais toute mise à jour ultérieure risque de passer inaperçue (MNT-01).
Impact. L'environnement de production n'est pas reproductible : il est le produit d'une suite d'actions manuelles sur une machine Windows particulière. Reconstruire l'installation après une panne matérielle demande de retrouver la bonne version de Python, d'installer nginx manuellement, de placer les certificats aux chemins attendus, de configurer les variables d'environnement et de créer les tâches planifiées — sans que rien ne vérifie que le résultat correspond à l'existant.
Pour une association étudiante, où les personnes qui déploient changent chaque année, c'est un risque de continuité réel : la connaissance du déploiement n'est pas dans le dépôt.
Correction. Un Dockerfile couvre l'essentiel du besoin et supprime la dépendance à Windows :
FROM python:3.12-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends libpq5 \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
RUN useradd --create-home appuser && chown -R appuser /app
USER appuser
EXPOSE 5000
CMD ["python", "wsgi.py"]
avec un docker-compose.yml réunissant l'application, PostgreSQL et le bot Discord en service distinct (STD-03).
Prérequis : corriger l'encodage de requirements.txt (MNT-02), sans quoi l'étape pip install peut échouer selon l'image de base ; et remplacer psycopg2 par psycopg2-binary (MNT-05) pour éviter d'embarquer une chaîne de compilation.
Si le déploiement doit rester sur Windows, alors documenter la procédure de façon exécutable — un script PowerShell d'installation versionné plutôt qu'une suite d'étapes en prose.