7b9eee4805c12ae8a3139b06e34722d4202b2148
69
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
7b9eee4805 |
feat(db): mesurer la derive du schema, au lieu de la supposer
DB-001. create_all() cree les tables manquantes et ne fait jamais d ALTER. Une colonne ajoutee a un modele il y a six mois est donc absente de toute base qui possedait deja la table, et rien ne le dit : l application demarre, et la premiere requete qui touche cette colonne echoue a l execution. C est la raison d etre de migrations/add_tryout_coaches.py, ecrit a la main pour rattraper un cas. Personne ne sait combien il y en a d autres. app/supporting_scripts/schema_report.py compare le catalogue d une base vivante aux modeles : tables, colonnes, types, nullabilite, contraintes d unicite, cles etrangeres, index. En **lecture seule** — il ouvre une connexion, lit, imprime, sort. Aucun DDL, aucun DML. Les constats sont classes par ce qu ils coutent, pas par ce qu ils sont : - BLOCKING : les modeles l attendent, la base ne l a pas. C est la derive ; - RISK : la base l a, aucun modele ne le decrit. Inoffensif tant que rien ne bouge — et **un alembic --autogenerate proposera de le supprimer**, avec ses donnees. C est la classe qu on lit en entier ; - DIFFERENCE : types, nullabilite, contraintes. Chacune demande un humain. Les types sont compares apres compilation vers le meme dialecte : opposer String(200) a VARCHAR(200) en chaines aurait signale chaque colonne comme differente, et un rapport qui crie partout ne se lit plus. --check-seed-accounts repond a la question de SEC-003 a laquelle le depot ne peut pas repondre : le compte admin/password seme par clear_db.py existe-t-il encore, et son mot de passe est-il toujours celui-la. 13 tests le pilotent contre des bases SQLite fabriquees pour diverger d une facon connue. Le cas qui compte le plus est la base propre : un rapport qui crie sur une base saine ne sera pas lu, et un rapport qui dit « aucun ecart » sur une base derivee est pire que pas de rapport — c est un feu vert pour laisser autogenerate ecrire la difference en DROP. docs/database-schema.md donne la suite, etape par etape, avec le piege de DB-002 en toutes lettres : la migration initiale doit decrire la base telle qu elle est, pas telle que les modeles la decrivent. Generer depuis les modeles puis estampiller revient a declarer que la derive n existe pas. Alembic n est pas ajoute aux dependances : rien ne l utilise encore, et une dependance que rien n utilise est exactement ce que l audit reprochait ailleurs. Le document dit a quelle etape l ajouter. 530 tests. |
||
|
|
3882b6035f |
fix(ops): defauts surs a la copie, CDN epingles, actions epinglees
Quatre taches de la matrice du rapport, toutes sans dependance, qu aucune liste de « ce qui reste » ne reprenait. OPS-003 — app/.env.exemple disait « copiez ce fichier et remplissez les valeurs pour la production », puis posait FLASK_DEBUG=true, SESSION_COOKIE_SECURE=false et FORCE_HTTPS=false. Le debogueur Werkzeug execute du code soumis par le navigateur : cette ligne transformait un copier-coller en shell distant. Chaque valeur est desormais sure a la copie, et le fichier refuse de demarrer tant que les deux secrets obligatoires ne sont pas remplis plutot que de demarrer grand ouvert. Renomme en .env.example : l orthographe francaise ne correspondait pas a l exception !.env.example du .gitignore, donc le fichier n etait suivi que par accident de l ordre des regles. Les deux points de la decision ouverte du §8 tombent d un seul git mv. OPS-002 — trusted_proxy='*' et HOST ne sont plus soudes dans wsgi.py. Les defauts sont **inchanges**, deliberement : choisir sans connaitre la topologie coupe la prod si nginx est ailleurs, ou casse la limitation de debit pour tout le monde si on cesse de croire X-Forwarded-For alors que c etait la seule source d adresses. Ce sont maintenant des variables, les valeurs sures sont dans .env.example pour un nouveau deploiement, et docs/deployment.md donne les quatre topologies avec la valeur de chacune. wsgi.py avertit au demarrage tant que les deux defauts sont en place. Le commentaire de HOST annoncait « bind to localhost by default » a cote d un defaut a 0.0.0.0 : il decrivait l intention pendant que le code faisait l inverse. Il dit maintenant ce qu il fait. QUA-004 — Font Awesome et FullCalendar etaient charges sans empreinte, depuis des hotes que la CSP autorise nommement. Qui controle ces CDN controlait ce qui s execute sur chaque page. Empreintes posees, avec ce que SRI promet et ce qu il ne promet pas ecrit a cote : ca fige le fichier, ca ne prouve pas qu il etait honnete au moment du calcul. **Le CSS de FullCalendar n existait pas.** La v6 embarque ses styles dans le JS et ce fichier n est pas publie : le <link> repondait 404 a chaque ouverture du calendrier depuis la montee de version. Une feuille de style en echec est silencieuse dans le navigateur, c est ce qui l a fait durer. CI-003 — actions epinglees sur un commit, version en commentaire, dans les deux forges. Un tag est un pointeur mobile : deplacer v4 fait executer du code arbitraire dans le job qui detient la cle SSH de production. Ce job recoit aussi enfin un bloc permissions. 517 tests. |
||
|
|
39808dd04e |
ops: un deploiement qui refuse de partir casse, et qui se verifie
OPS-011, en partie. Ce que le workflow garantit maintenant : - rien ne part d un arbre casse. La suite, ruff check et ruff format tournent sur le runner de deploiement avant tout envoi. Une CI verte sur GitHub ne prouve rien ici : le deploiement se declenche a la main, sur ce que la branche contient a cet instant ; - seuls les fichiers nommes partent. La charge est une liste blanche — app/, wsgi.py, requirements.txt — et non l arbre de travail moins neuf exclusions. C est par cette porte que clear_db.py, la suite de tests et les definitions de CI se sont retrouves sur le noeud de production ; - le deploiement est verifie. /health est interroge pendant deux minutes apres l envoi et le job echoue s il ne repond jamais « healthy ». Avant, un arbre a moitie televerse etait un deploiement vert. Ce qui n est pas garanti, et c est ecrit dans le fichier : la bascule n est pas atomique. Le miroir se fait sur place, donc pendant le transfert la production execute un melange de deux versions. En cherchant a fermer ce point, un defaut a part entiere est apparu. Les contrats etaient ranges a os.getcwd()/documents et leur chemin absolu ecrit en base. La racine de stockage suivait donc le repertoire depuis lequel le processus avait ete lance : redemarrer le serveur ailleurs envoie les nouveaux contrats dans un nouvel arbre et rend les anciens illisibles — la base continuant d affirmer qu ils sont la, la panne se manifeste par un 500 au telechargement, pas par quelque chose d actionnable. app/storage.py fixe la racine et DOCUMENTS_ROOT la deplace. Les nouvelles lignes gardent un chemin relatif, les anciennes gardent leur chemin absolu et continuent de resoudre : aucune migration de donnees n est necessaire, donc ce changement n attend pas Alembic. C etait aussi le troisieme pre-requis de la bascule par repertoires de version. Les deux autres sont hors d atteinte d ici — la commande de demarrage Pterodactyl doit pointer sur current/, et les repertoires partages doivent etre installes sur le noeud. Les deux sont decrits dans docs/deployment.md, avec la procedure de retour arriere qui manquait. 511 tests. |
||
|
|
c5e5cfa014 |
refactor(validation): un schema aux frontieres tryout et evaluation
ARCH-005, seconde moitie. Meme forme que pour les matchs : des champs lus a la main sur request.form, deux verifies et le reste cru sur parole. Cote tryout : - game pilote la liste des postes et les champs de gamertag montres au joueur qui s inscrit. Il etait accepte tel quel : une faute de frappe produisait une selection pour laquelle personne ne pouvait etre evalue ; - max_players etait int(x) if x else None — un 500 sur « twelve », et un -3 accepte sans broncher ; - coach_ids etait charge par User.id.in_(...) sans filtre de role. Une soumission fabriquee a la main pouvait donc nommer un joueur coach d une selection, ce qui est une attribution de droits : gerer la selection et evaluer ses joueurs. Ce n est pas un formulaire que l interface propose, et ca marchait. Cote evaluation, validate_score transformait tout ce qui sortait de 1..10 — 11, 0, « bien » — en None. Le critere disparaissait de la moyenne et la page annoncait l evaluation enregistree. Rien ne distinguait « non evalue » de « evalue, refuse, et oublie ». Le calcul de la moyenne remonte sur le modele, en Evaluation.overall_from et apply_scores. Il vivait dans la route, additionnant neuf variables locales, et ne pouvait pas etre exerce sans requete HTTP, session authentifiee et base — c est TEST-002, et c est pourquoi le calcul des scores n avait aucun test. Il en a maintenant six, sans rien monter. Une precision qui compte : aucun critere rempli donne None, pas 0. La grille commence a 1, donc un zero serait une note qu aucun joueur ne peut recevoir, et qui le classerait sous tout le monde dans la liste. Les neuf criteres sont ecrits en toutes lettres dans le schema plutot que generes depuis le modele — un schema se lit — et un test verifie que les deux listes coincident. C est la garde qui empeche la derive, pas l astuce. Douze chaines traduites, dont trois que pybabel avait devinees en fuzzy : une entree fuzzy est ignoree a l execution, le piege est consigne dans docs/translations.md. 30 tests neufs. 477 au total. |
||
|
|
0308eb9eef |
refactor(validation): un schema a la frontiere des matchs
ARCH-005, premiere moitie. matches.py et team_matches.py lisaient une quarantaine de champs sur request.form a la main et les croyaient tous. Ce que ca produisait n etait pas bruyant : - edit_match attrapait une heure invalide et faisait start_time = None, puis annoncait que le match etait mis a jour. Le match perdait son heure et le calendrier l affichait a minuit ; - match_type etait accepte tel quel. Une valeur inconnue creait un match auquel aucun joueur n etait rattache, sans un mot ; - une fin avant le debut etait enregistree telle quelle ; - title est NOT NULL dans le modele et n etait pas verifie dans la route, donc un titre vide etait un 500 ; - 'a,b' dans la selection de joueurs arrivait sur int() sans garde. app/forms.py rassemble les deux fonctions de frontiere, qui vivaient dans users/_shared.py parce que c est la qu elles avaient d abord servi. Elles y restent re-exportees, donc aucun des trente appels n a bouge. Le mixin des schemas lit desormais un champ vide comme un champ absent. C est ce qui rendait ces formulaires invalidables : un formulaire HTML envoie tout ce qu il affiche, donc une date optionnelle non remplie arrive comme '' et non comme rien. Seuls les champs declares optionnels sont concernes ; un champ requis laisse vide doit toujours echouer. Deux duplications absorbees au passage, toutes deux nommees par l audit : la boucle de creation des participants, ecrite deux fois et deja divergee — la copie de edit_match gardait ses identifiants en chaines et appelait int() une ligne plus loin — et le contexte de re-affichage du formulaire, dont les versions courtes faisaient mourir un refus dans tojson sur un Undefined : un message de validation devenait un 500. Limite connue et consignee : le formulaire revient rempli avec les valeurs enregistrees, pas avec la saisie refusee. Reafficher la soumission demande de toucher aux gabarits, c est un autre changement. 19 tests neufs sur ces routes, qui n en avaient aucun. 447 au total. |
||
|
|
d8541678a6 |
fix(auth): retirer un CAPTCHA qui ne protegeait rien, filtrer autrement
SEC-AUTH-008. L addition a deux operandes entre 1 et 10 avait dix-neuf reponses possibles et se resolvait en lisant la question comme une chaine. Elle n arretait aucune inscription automatisee. Elle ajoutait en revanche une etape a chaque personne, lecteur d ecran compris, contre une apparence de protection — ce qui est pire que rien, puisque ca se compte comme une protection. L autre branche proposee par l audit etait un vrai service de CAPTCHA : un tiers, une cle d API, une requete a chaque affichage, et un script etranger remis dans script-src, defaisant le travail qui a ferme SEC-WEB-001. Disproportionne pour le site d un club. A la place, deux verifications invisibles pour un visiteur : un champ piege, cache par la feuille de style et hors du parcours clavier, qu un robot remplisseur complete et qu une personne ne voit jamais ; et un delai minimal entre la remise du formulaire et son retour, l horodatage etant dans la session signee et non dans un champ. Le plafond est dit dans le code plutot que sous-entendu : ceci arrete le pourriel de masse, pas quelqu un qui lit la page. Ce qui filtrerait vraiment les inscriptions serait l activation des comptes par le staff — is_active_account vaut True par defaut. C est une decision de produit. L angle « session forgeable » du constat tombe : avec SECRET_KEY compromise (SEC-001), on forge une session connectee sur n importe quel compte et on n a aucune raison de s inscrire. Au passage, ARCH-005 en partie : le bloc « regenerer, purger les mots de passe, re-rendre » etait recopie quatre fois. Un seul helper, et la purge des mots de passe ne peut plus etre oubliee dans la cinquieme copie. Les refus sont journalises avec leur motif — c est le seul endroit ou un abus du formulaire devient visible — mais restent indistinguables pour l expediteur : nommer la regle indique comment la contourner. 429 tests. |
||
|
|
d0a9e75fe6 |
perf: servir les statiques par nginx, et dire ce que le bot n a pas livre
PERF-006. Le bloc location /static/ etait commente : 59 Ko de CSS et de JS
passaient par Waitress a chaque page. L activer tel quel aurait ete une
regression : ces URL ne changent jamais, donc un cache de 30 jours sert une
feuille de style vieille d un mois apres chaque deploiement, sans moyen de
l invalider. url_for('static') estampille maintenant chaque URL du mtime du
fichier ; c est ce qui rend le immutable vrai et pas seulement rapide.
Deux pieges nginx consignes dans le fichier : un add_header dans un location
annule tous les add_header herites du server (nosniff disparaissait du
JavaScript), et un statique manquant doit renvoyer 404 plutot que retomber
sur Flask, sinon un deploiement casse se cache derriere une page qui marche.
PERF-005. Les objets utilisateur Discord sont mis en cache. A etre precis
sur le gain : un envoi coute deux appels reseau, resoudre puis envoyer, et
seul le premier est economise — un premier match a vingt joueurs fait
toujours vingt resolutions. Ce qui est gagne l est entre notifications, la
ou le bot ecrit aux memes personnes soir apres soir.
Chaque message dit desormais ce qu il est devenu, avec le destinataire et
la raison. Les trois echecs ne se ressemblent pas et ne se lisent plus
pareil : une boite fermee est definitive et ne se retente pas, une erreur
HTTP est passagere, un identifiant sans proprietaire est un compte a
corriger. Le lot quotidien annonce son propre deficit.
Piege trouve en ecrivant les tests : configure_logging met propagate=False
sur le logger 'app', et le handler de caplog est sur la racine. Les
assertions sur les journaux passaient seules et echouaient dans la suite
complete, ou une application avait deja ete construite — elles lisaient un
journal vide, pas un bot silencieux.
417 tests.
|
||
|
|
47ff544848 |
chore(lint): trier les imports, sauf la facade des modeles
Active la regle isort (I) de ruff. 45 fichiers reordonnes, aucun changement de comportement : la suite passe avant comme apres. app/models/__init__.py en est exclu. Ses imports sont ranges en onze couches commentees qui decrivent le graphe de dependances ; trier par ordre alphabetique laisse chaque titre au-dessus d un import qu il ne decrit pas, et ce fichier n a qu un role, etre lu. Commit isole, comme le formatage : un diff de brassage ne doit pas servir de couverture a un changement de comportement. |
||
|
|
ab0b975211 |
fix(arch): l inscription est une operation, donc une transaction
ARCH-006, avec une requalification du constat.
**Le chiffre de l audit surestimait le probleme.** « 58 commit() en routes
pour un seul rollback() » lisait un ratio comme un defaut. Mesure plutot
que suppose :
- Flask-SQLAlchemy demonte la session a la fin de chaque requete, ce qui
annule tout ce qui n a pas ete commite ;
- l unique rollback est dans le gestionnaire 500, c est-a-dire au bon
endroit ;
- depuis la vague D, aucun module de routes ne contient `except
Exception` : les 34 releves sont dans discord_bot.py, les scripts et le
service de notification, ou avaler l erreur est le comportement voulu
et documente.
Ce que le decompte ne pouvait pas voir, c est le vrai defaut : une fonction
qui commite DEUX fois, ou un echec apres le premier commit laisse une
demi-operation persistee. Il y en avait deux dans tout le depot -- une
analyse AST le confirme. edit_user etait la grave, corrigee avec ARCH-008.
register est la seconde : le compte etait commite, puis les gamertags dans
une seconde transaction. Un echec entre les deux laissait un compte dont
les jeux declares etaient absents, l inscription etant annoncee reussie.
Le premier commit devient un flush -- l identifiant est necessaire pour les
lignes suivantes, pas la durabilite.
Les deux commit() de login ne sont pas concernes : ils sont dans des
branches mutuellement exclusives, succes et echec.
tests/test_transactions.py enonce la garantie plutot que de la supposer :
un echec en cours de requete ne laisse aucune ligne, et l inscription est
tout ou rien. Le second echoue sur le code d avant.
Co-Authored-By: Claude Opus 5 <[email protected]>
|
||
|
|
e5c29d8113 |
fix(ops): rendre visibles les pannes silencieuses du bot Discord
OPS-005, OPS-006, OPS-007, OPS-009 et OPS-012. Cinq constats, un motif commun : le bot pouvait cesser de fonctionner correctement sans que rien, nulle part, ne le dise. OPS-006 -- etat en attente ecrit en place _save_pending ouvrait le fichier de destination en ecriture puis serialisait dedans : toute interruption laissait un JSON tronque. Et _load_pending interceptait l erreur de lecture, la journalisait, puis repartait avec un dictionnaire vide -- toutes les correspondances message Discord <-> demande disparaissaient, les reactions en cours cessaient d avoir un effet, et l interface n en montrait rien. Ecriture par fichier temporaire voisin puis os.replace : la destination contient l ancien contenu ou le nouveau, jamais la moitie d un des deux. A la lecture, un fichier illisible est deplace en .corrupt-<horodatage> plutot qu ecrase, et le message dit ce qui est perdu. Ecart assume avec la recommandation d audit (« echouer bruyamment ») : le bot demarre quand meme. Refuser de demarrer supprimerait toutes les notifications au lieu de celles deja en vol. OPS-007 -- fuite lente Les entrees n etaient retirees qu apres reaction. Elles portent desormais `created_at` et sont purgees au chargement au-dela de 30 jours. Une entree sans horodatage est conservee : elle precede ce champ, la supprimer serait deviner son age. OPS-005 -- planificateur `coalesce=True`, `misfire_grace_time=3600`, `max_instances=1`. Sans delai de grace, un redemarrage a 18 h 05 perdait les rappels du jour sans trace ; sans coalescence, un planificateur en retard envoie un rappel par occurrence manquee, donc des messages en double. OPS-009 -- controle d identite asymetrique handle_one_on_one_approve et _reject comparent depuis toujours le compte qui reagit au coach destinataire. Les deux gestionnaires de presence ne le faisaient pas. Meme forme de message, meme risque, un seul verifiait : c est l asymetrie qui etait le bug. Au passage : confirmer sa presence a un tryout ecrivait un attribut qui n a pas de colonne (DB-008, bloque sur Alembic). Le joueur lisait « confirme » et rien n etait enregistre. Toujours vrai, mais desormais journalise en warning avec l identifiant concerne. OPS-012 -- etat du bot dans /health Le bot tourne dans un fil demon du processus web. Quand ce fil meurt, le site continue de servir des pages et plus aucune notification ne part. /health expose maintenant configured / running / connected / pending. Signale, pas fatal : un club sans rappels Discord est degrade, pas hors service, et un 503 le sortirait du repartiteur de charge pour ca. discord_pending.json passe hors suivi git. La regle d ignore etait en place mais inerte. Consequence non relevee par l audit : le deploiement etant un miroir de fichiers, chaque livraison ecrasait l etat vivant du serveur par celui du depot. 13 tests, sans aucun appel a Discord. Co-Authored-By: Claude Opus 5 <[email protected]> |
||
|
|
2e10bbbd62 |
perf: view_tryout, une requete par lot au lieu d une par ligne
PERF-001, la page la plus consultee de l application. Quatre boucles
posaient une requete par ligne :
User.query.get() par inscription
Evaluation.query par joueur inscrit, pour savoir si ce coach
l avait deja evalue
TeamMember.query par equipe
User.query.get() par membre d equipe
Plus, sur chaque match de type player_vs_player, deux interrogations
supplementaires de la relation dynamique `participants` pour trier par
camp -- alors que la liste complete venait d etre chargee douze lignes plus
haut.
Toutes remplacees par un chargement groupe. Les evaluations de ce coach
sont deduites de la liste `evaluations` deja en memoire, pas redemandees.
Mesure, sur un tryout de 10 inscrits, 2 equipes et 1 match :
34 requetes avant, 12 apres. Le test fixe un budget de 25, volontairement
large -- il ne peut que baisser, et il echoue sur le code d avant.
_users_by_id() est le helper partage par les trois chargements ; une ligne
absente est simplement absente du dictionnaire, ce que faisait deja un
get() renvoyant None.
Un second test verifie que les dix joueurs apparaissent toujours sur la
page : une requete groupee qui perd des lignes est le risque reel ici, pas
l erreur bruyante.
Co-Authored-By: Claude Opus 5 <[email protected]>
|
||
|
|
9d0456c2fd |
perf: borner le calendrier et remplacer trois boucles par des requetes
PERF-002, PERF-003, PERF-004. Aucun changement de comportement : chaque reecriture est accompagnee de tests qui enoncent la reponse attendue, pas la methode. PERF-002 -- /matches/api/events Le flux parcourait `tryout.matches` pour chaque tryout visible -- pour un president, tout l historique du club -- puis posait une requete MatchParticipant PAR match pour savoir si la personne qui regarde y figure. Le cout du calendrier croissait avec l historique, a chaque navigation. FullCalendar envoie deja `start` et `end` sur une source d evenements de type URL. Personne ne les lisait. La requete est desormais bornee, et les participants de tous les matchs de la fenetre sont charges en une fois, joueur compris. Des bornes illisibles sont ignorees plutot que refusees : un calendrier qui en montre trop est un probleme de performance, un calendrier qui renvoie 400 est une page blanche. PERF-003 -- get_players_available_at_time Chargeait tous les joueurs actifs, puis une requete PlayerDisponibility par joueur, sur une colonne non indexee. Deux requetes desormais, quelle que soit la taille du club. Mesure dans le test : 7 requetes pour 6 joueurs avant, 2 apres. PERF-004 -- decompte des evaluations en attente Chargeait toutes les inscriptions du club et toutes les evaluations du coach, construisait deux ensembles Python et les soustrayait -- deux lectures de table entiere pour produire un entier. Un COUNT DISTINCT avec NOT EXISTS. Les tests couvrent ce que la reecriture aurait pu changer sans bruit : fin de creneau exclusive, compte desactive exclu, joueur a cheval sur deux creneaux compte une fois, evaluation d un autre coach qui ne libere pas la ligne, double inscription comptee une fois (DB-006 n a pas encore atterri, donc le cas existe). PERF-001 (view_tryout) n est pas fait : c est le plus gros des quatre, il touche la page la plus consultee et merite son propre lot. 23 tests ajoutes, 371 au total. Co-Authored-By: Claude Opus 5 <[email protected]> |
||
|
|
0cd9a186ee |
refactor(services): un seul endroit pour annoncer un match planifie
Seconde moitie d ARCH-003.
Les memes vingt lignes vivaient dans trois routes -- matches.create_match,
matches.edit_match et team_matches.create_match : formater la date et
l heure, puis parcourir deux listes paralleles pour apparier un joueur avec
la ligne de participation qu une reaction Discord doit pouvoir retrouver.
Trois copies, donc trois occasions de diverger. Elles avaient deja diverge :
create_match lisait les heures des variables qu il venait d analyser, et
affichait 'TBD' des qu une des deux manquait ;
edit_match les relisait depuis la ligne enregistree et substituait
l heure de debut a une heure de fin absente.
Un match avec une heure de debut et pas de fin annoncait donc une heure
dans une route et 'TBD' dans l autre, pour la meme donnee.
app/services/scheduling.py retient la regle la plus soigneuse des deux.
zip_participants() isole l appariement par index, qui n est correct que
tant que les deux listes sont construites en phase -- desormais un seul
endroit a relire au lieu de trois, et une liste plus courte donne None,
converti en reference vers le match lui-meme.
matches.py 711 -> 687 lignes, team_matches.py 347 -> 337.
9 tests sur le service seul, sans base ni requete.
Co-Authored-By: Claude Opus 5 <[email protected]>
|
||
|
|
5c87064a17 |
refactor(routes): decouper users.py en paquet, extraire les notifications
ARCH-004 et la moitie NotificationService d ARCH-003. app/routes/users.py faisait 1 699 lignes et couvrait six sujets qui ne partageaient rien d autre qu un prefixe d URL. Il devient un paquet : blueprint.py l objet Blueprint, seul _shared.py helpers de formulaire, gamertags, validation de PDF accounts.py 348 l. liste, creation, edition, suppression, fiche availability.py 233 l. disponibilites joueur et creneaux coach contracts.py 206 l. depot, signature, telechargement notes.py 376 l. notes d equipe et notes nominatives one_on_one.py 259 l. demandes de seance individuelle profile.py 132 l. profil de la personne connectee Aucun fichier ne depasse 400 lignes -- le critere d acceptation de l audit. **Un seul blueprint, pas six.** Les endpoints restent `users.*`. Les renommer aurait touche 137 appels `url_for` dans les gabarits, pour un benefice nul : l objectif est un fichier qu on peut lire, pas une carte d URL a reapprendre. Les 30 endpoints sont identiques avant et apres, verifie sur url_map. send_discord_notification part dans app/services/notifications.py. Elle tirait `requests`, `logging` et le bot Discord dans un module dont le sujet est le traitement HTTP, et se trouvait coincee entre deux definitions de route. Son `except Exception` est conserve et documente : une notification qui n arrive pas ne doit pas annuler la transaction qu elle annoncait. tests/test_route_map.py, nouveau : il parcourt gabarits et code, releve tout endpoint nomme litteralement dans un url_for, et verifie qu il existe dans la carte. C est le mode de defaillance de ce genre de decoupage -- pas une erreur a l import, mais un BuildError chez la premiere personne qui ouvre la page concernee. Un test de garde verifie aussi que le scan trouve quelque chose, sinon le reste serait vide de sens. Piege rencontre, et corrige : test_role_change patchait `app.routes.users.update_user_gamertags`. Apres le decoupage ce nom est un reexport, pas celui qu accounts.py resout -- le patch aurait pu laisser la route appeler la vraie fonction et le test passer sans rien verifier. Ici monkeypatch a echoue bruyamment, mais la cible est desormais explicite et le test enregistre que la doublure a bien ete appelee. 347 tests passent (263 + 84, dont 82 parametres par la carte des routes). Co-Authored-By: Claude Opus 5 <[email protected]> |
||
|
|
8e3865f557 |
chore(lint): elargir les regles ruff et rendre le format bloquant en CI
QUA-002, seconde moitie. Le depot etant formate, l elargissement porte sur
des defauts et non sur du brassage.
Ajoute a la selection : B (bugbear), C4, RET, SIM, UP. Le lot entier n a
produit que 24 signalements sur 76 fichiers -- le code etait plus propre
que l audit ne le craignait. Neuf corriges automatiquement, quinze a la
main.
SIM108 est ignore : forcer un ternaire se lit moins bien que le if/else
qu il remplace, au seul endroit ou il se declenche.
isort (I) n est PAS active. Il reordonnerait les imports de 48 fichiers,
soit une seconde passe de pur brassage juste apres le commit de formatage.
A faire, mais seul.
Deux vrais defauts trouves par les nouvelles regles
- team_matches.edit_match faisait `except ValueError: pass` sur l heure de
debut et l heure de fin, trois lignes sous un champ date qui, lui,
signale et redirige. Une heure mal saisie etait donc acceptee par le
formulaire, jetee, l ancienne valeur conservee -- et la page annoncait
la reussite. Meme traitement que la date desormais.
- backup.py levait BackupError depuis deux blocs `except` sans `from`,
ce qui perdait la cause d origine dans la trace.
Ainsi que : un `return` explicite dans force_https, `%`-formatage remplace
dans log_auth_event (operations de chaine avant journalisation, pas des
gabarits de logger -- la redaction n est pas affectee), une compréhension
inutile, un `set(...)` en compréhension d ensemble, `open(..., 'r')`, une
variable de boucle inutilisee, et `contextlib.suppress` dans conftest.
CI : `ruff format --check` remplace le commentaire qui expliquait pourquoi
il etait absent.
263 tests passent. Les deux nouveaux messages sont traduits ; attention,
pybabel les avait apparies en `fuzzy` avec des entrees « date » existantes,
et une entree fuzzy est ignoree a l execution.
Co-Authored-By: Claude Opus 5 <[email protected]>
|
||
|
|
7cec18c139 |
style: formater le depot avec ruff format
QUA-002, premiere moitie. **Ce commit ne fait que reformater** : aucun changement de comportement, aucune ligne de logique touchee. 72 fichiers, 4 restaient deja conformes. Il est isole exprès, pour que `git log -p` sur les commits voisins reste lisible. `quote-style = "preserve"` etait deja pose dans pyproject.toml, ce qui evite le brassage guillemets simples / doubles : le diff porte sur les retours a la ligne, l indentation des appels longs et les virgules finales, pas sur le style de chaine. Verification : 263 tests passent avant et apres, ruff check propre. L activation en CI arrive dans le commit suivant, separement, pour que ce diff-ci ne contienne rien d autre. Co-Authored-By: Claude Opus 5 <[email protected]> |
||
|
|
2f40290f00 |
fix(deps): nommer le pilote PostgreSQL, sinon rien ne demarre
QUA-001, volet dialecte.
`postgresql://` ne veut pas dire "le pilote installe" : SQLAlchemy y lit
psycopg2 et importe ce module a la creation du moteur. requirements.txt
epingle psycopg 3 (`psycopg[binary]`) et pas psycopg2. Une installation
propre demarree sur cette URL leve donc
ModuleNotFoundError: No module named 'psycopg2'
avant la premiere requete. Verifie dans le .venv du depot, et c est
exactement la forme que Render distribue -- celle que docs/deployment.md et
docs/database-restore.md donnaient en exemple.
normalise_database_url() nomme le pilote quand l URL n en nomme pas.
`postgres://` (alias hérite, abandonne par SQLAlchemy en 1.4) est traite de
meme. Une URL qui nomme deja son pilote est laissee telle quelle, y compris
`postgresql+psycopg2://` : un environnement qui a psycopg2 garde le choix.
La normalisation a lieu apres l application de la configuration passee en
argument, pour couvrir aussi les appels de test. backup.py n avait pas
besoin d etre touche : il retirait deja le suffixe +pilote.
Documentation alignee sur les trois fichiers qui donnaient l exemple, dont
docs/deployment.md qui proposait sqlite:/// pour DATABASE_URL alors que
create_app refuse de demarrer sans PostgreSQL.
Reste de QUA-001, dit franchement
- les trois paquets parasites (dotenv, login, discord) ne sont plus dans
requirements.txt : deja retires. psycopg est deja epingle.
- la consolidation vers des groupes de dependances n est PAS faite. Le
deploiement est un miroir de fichiers lftp sans etape de construction ;
les groupes PEP 735 demandent pip >= 25.1 sur une machine dont on ne
peut pas verifier la version d ici. A revoir avec OPS-011.
14 tests, dont trois qui prouvent que l echec est reel et non theorique.
Co-Authored-By: Claude Opus 5 <[email protected]>
|
||
|
|
51877b46a0 |
refactor(arch): un point d entree par usage, et retrait du code mort
ARCH-007.
Trois points d entree, trois configurations differentes
python app/app.py 0.0.0.0:10000, debogueur desactive par defaut
python run.py 127.0.0.2:5000, debogueur ACTIVE par defaut
python wsgi.py Waitress, production
Le bloc __main__ de app/app.py disparait : ce module expose la fabrique.
run.py reste le point d entree de developpement, wsgi.py celui de
production, et c est tout.
run.py passait FLASK_DEBUG a 'true' par defaut. Le debogueur Werkzeug
execute du code soumis depuis le navigateur ; un processus lance ainsi et
laisse joignable est un shell distant. Le defaut passe a 'false', avec
l avertissement reecrit pour dire ce que le mode implique reellement.
L hote devient 127.0.0.1 -- 127.0.0.2 est une boucle locale valide mais
inhabituelle -- et hote comme port sont surchargeables par DEV_HOST et
DEV_PORT.
Code mort retire
- discord_bot.py, notify_player_about_one_on_one : jamais appelee, seule
la variante _direct l est.
- evaluations.py, branche else de list_evaluations : elle listait les
evaluations recues, une vue de joueur, alors que les joueurs sont
rediriges au debut de la fonction et que can_evaluate() est vrai pour
les quatre roles restants. Inatteignable.
Les autres elements du constat sont deja resorbes : get_auth_logger est
appelee depuis log_auth_event (OBS-001), et ALLOWED_CONTRACT_EXTENSIONS /
ALLOWED_SIGNED_EXTENSIONS sont lues par pdf_upload_error (SEC-021).
wsgi.py n est pas touche : trusted_proxy et HOST attendent la reponse du
developpeur sur la topologie reelle (nginx sur la meme machine ou non).
Co-Authored-By: Claude Opus 5 <[email protected]>
|
||
|
|
20158a9e7a |
fix(security): verifier les fichiers televerses et retirer un intent Discord
SEC-021 -- rien ne validait le contrat signe upload_signed_contract se contentait d un nom de fichier non vide. ALLOWED_SIGNED_EXTENSIONS etait declaree juste a cote et jamais lue. Le fichier atterrissait sur le disque sous un nom que download_signed_contract sert ensuite : ce qu un joueur televerse est ce qu un gerant ouvre. Le nom seul ne suffisait pas non plus cote upload_contract, qui verifiait `.pdf` en fin de chaine -- payload.pdf ne dit rien des octets. pdf_upload_error() couvre les deux routes : extension dans la liste, puis signature %PDF- en tete de flux. Le flux est rembobine, l appelant enregistre toujours le fichier entier. OPS-014 -- intent Discord privilegie inutile Le bot demandait GUILD_MEMBERS et ne s en servait pas : rien n enumere ni ne recherche de membre de serveur, les personnes sont jointes par le discord_user_id enregistre sur leur compte. Retire. message_content reste : on_raw_reaction_add lit le texte de la reponse d un coach pour consigner un motif de refus. CI-003 et CI-005 sont deja appliques (permissions: contents: read, checkout@v4, exclusions de deploiement). L epinglage par SHA des actions n est pas fait : ce sont des actions GitHub de premiere partie, et l epingler sans Dependabot echange une exposition contre une autre. 11 tests, dont deux verifient que signer un contrat marche toujours et qu un autre joueur ne peut pas le faire. Co-Authored-By: Claude Opus 5 <[email protected]> |
||
|
|
835a394d3f |
fix(auth): enumeration, verrou, deconnexion en GET, redirection ouverte
Quatre constats de la liste des gains rapides, tous sur auth.py. SEC-017 -- enumeration de comptes Le formulaire repondait "il vous reste 3 tentative(s)" a un compte connu et "verifiez le nom d utilisateur et le mot de passe" a un inconnu. Le decompte lui-meme etait la fuite : la meme information, etalee sur cinq requetes. S y ajoutait un ecart de temps de reponse, check_password n etant appele que si la ligne existait -- scrypt est cher, l ecart est mesurable. Un seul message pour tous les echecs, et la verification s execute desormais sur les deux branches : contre un hachage aleatoire tire une fois par processus quand l identifiant n existe pas. SEC-018 -- verrou de compte declenchable par un tiers Cinq mauvaises reponses mettaient un compte connu hors service pendant quinze minutes, indefiniment renouvelables. Sur un compte president, c est toute l administration, et aucun ecran ne permettait de defaire. Le compteur et la fenetre restent -- ce sont la trace qu un administrateur lit quand un compte est pilonne, et la fenetre double jusqu a un plafond. Ce qui change : de bons identifiants passent, fenetre ouverte ou non, et remettent le compteur a zero. Le proprietaire du compte ne peut plus etre bloque par un tiers. Ce que cela coute, dit franchement : un verrou dur n arretait de toute facon pas un attaquant ayant trouve le mot de passe -- il lui suffisait d attendre. Le debit de tentatives reste borne par la limite de 10/minute par IP. Une limite par couple (compte, IP) demanderait un stockage dedie ; elle attend Alembic. L evenement account.locked devient account.throttled : "locked" affirmait plus que ce qui se passe. SEC-019 -- deconnexion en GET /auth/logout n avait pas de methods, donc GET, donc hors protection CSRF : n importe quelle page pouvait deconnecter un visiteur avec une balise img. La route passe en POST et l entree de navigation devient un formulaire avec jeton. Le style suit -- les regles .nav-links visaient les liens seuls. SEC-020 -- validation de redirection is_safe_url interrogeait urlparse().netloc. urlparse lit /\evil.com comme un chemin, sans netloc ; plusieurs navigateurs normalisent l antislash en barre oblique avant de resoudre, ce qui en fait //evil.com. La fonction refuse maintenant antislash et caracteres de controle, exige un chemin enracine, et compare l origine explicitement. Le xfail(strict) qui documentait SEC-017 est leve. 40 tests dans test_auth_session.py, dont la table des cibles refusees. Co-Authored-By: Claude Opus 5 <[email protected]> |
||
|
|
d85da32ef5 |
fix(arch): changement de role sans jeter la session, et en une transaction
ARCH-008. `role` est le discriminateur polymorphe, et SQLAlchemy fixe la classe d une instance au chargement. edit_user ecrivait donc la colonne par un UPDATE de niveau instruction puis relisait la ligne -- c est correct. La suite ne l etait pas. db.session.commit() db.session.remove() # discard stale session entirely user = User.query.get(user_id_local) Deux defauts dans ces trois lignes. 1. remove() jette la session entiere. Tout ce que la requete tenait encore se retrouvait detache, current_user compris ; le moindre acces a un attribut ensuite levait DetachedInstanceError. La route ne survivait qu en ayant recopie le nom et l id de l acteur dans des variables locales avant -- un contournement, pas la correction. Un expunge de la seule instance perimee suffit. 2. Le commit intermediaire coupait l edition en deux. Le role etait acquis avant que le reste du formulaire soit applique : une erreur ensuite laissait un compte promu et le reste perdu, sans qu aucune interface ne le signale. Sans ce commit, l UPDATE reste dans la transaction, la relecture le voit, et l ensemble part en un seul commit. Les evenements d audit passent apres le commit. account.role_changed etait journalise avant l UPDATE : le journal affirmait un changement que la transaction pouvait encore annuler. tests/test_role_change.py, 6 tests. Un seul echoue sur le code d avant -- celui de l atomicite ; les cinq autres fixent le comportement qui marchait deja, pour que la suite de la vague D ne le casse pas. Co-Authored-By: Claude Opus 5 <[email protected]> |
||
|
|
92d72e4d48 |
refactor(authz): un seul point de verite pour les autorisations d equipe
ARCH-002. La question "a quelles equipes ce coach est-il rattache ?" etait
posee a huit endroits, de cinq facons differentes, et trois d entre elles
donnaient une mauvaise reponse en production.
Le motif fautif, present tel quel dans six routes :
OrgTeam.query.filter_by(coach_id=user.id).first()
Il repond au plus une equipe, et seulement par la colonne heritee. Deux
pannes en decoulaient, silencieuses -- les pages s affichaient, vides :
- un coach rattache uniquement par la relation many-to-many n avait
aucune equipe, donc aucun joueur, aucun contrat, aucune note d equipe,
aucun match a venir sur son tableau de bord ;
- un coach de deux equipes n en voyait qu une. Le formulaire de contrat
lui proposait la moitie de son effectif, alors que la route POST
acceptait l autre moitie.
app/permissions.py devient le module ou la question se pose une fois :
coach_org_teams, manager_org_teams, attached_org_teams, visible_org_teams,
can_manage_org_team, org_team_player_ids, coach_player_ids,
can_manage_player_contract, coach_tryouts, coach_manages_tryout. Toutes
lisent les deux rattachements et toutes les equipes.
Le meme ecart existait dans le modele : Coach.get_visible_tryouts ne lisait
que la relation m2m -- calendrier vide pour un coach rattache par la
colonne -- et can_manage_this_tryout ignorait la colonne pour l equipe
cible. Les deux delegent desormais.
Corrections de portee, au passage
- one_on_one lisait org_team.coach_id : un joueur dont l equipe declare
ses coachs par la relation etait informe qu il n avait pas de coach, et
le formulaire de demande restait ferme. Passe par get_coaches(), qui
retombe deja sur la colonne heritee.
- notes_dashboard conditionnait les notes personnelles du coach a
l existence d une equipe : un coach sans equipe ne voyait pas ses
propres notes.
- can_manage_team_match reformulait can_manage_this_org_team ; la
reformulation avait derive. Elle appelle maintenant la regle.
Limite assumee : le panneau de notes d equipe reste ecrit pour une seule
equipe et affiche donc la premiere. La resolution est corrigee, la mise en
page multi-equipes ne l est pas -- c est un choix produit, pas un bug.
14 tests ajoutes. Cinq echouent sur le code d avant, verifie en remettant
les routes et le modele a leur etat precedent.
ARCH-001 fera disparaitre la colonne heritee ; cela demande une migration
de donnees, donc Alembic. D ici la, ce module est ce qui rend la
duplication inoffensive.
Co-Authored-By: Claude Opus 5 <[email protected]>
|
||
|
|
37f70c89e3 |
feat(i18n): traduire les messages flash et de validation
198 appels flash dans les sept modules de routes, plus les 23 messages de validation de app/validators.py. Le catalogue compte desormais 631 chaines, aucune non traduite. validators.py utilise lazy_gettext : les champs de schema sont construits a l'import, donc avant qu'une requete existe. Un gettext ordinaire s'y resoudrait une seule fois, dans la langue active au demarrage. Un bug introduit par la conversion, puis corrige Le convertisseur automatique ne voyait que le premier litteral d'un appel flash, ce qui a casse deux chaines concatenees sur plusieurs lignes dans users.py -- le resultat n'etait meme pas du Python valide. Ma premiere verification ne l'a pas vu : elle enchainait py_compile sur head, or head reussit toujours, donc le "OK" s'affichait quoi qu'il arrive. Les deux appels sont reecrits et la verification refaite correctement. Un bug plus interessant, revele par le test de fumee La langue choisie ne survivait pas a la connexion. login() et logout() appellent tous deux session.clear() -- l'un contre la fixation de session, l'autre pour terminer la session -- et le choix de langue partait avec le reste. Concretement : quelqu'un qui lisait la page de connexion en anglais se retrouvait en francais des qu'il se connectait. La langue est une preference d'affichage, pas un etat appartenant au compte. Les deux endroits la reportent maintenant explicitement, a cote du jeton CSRF. Quatre tests couvrent le cas, dont un qui verifie que corriger une cle preservee n'a pas fait tomber l'autre. Detail de nommage : le convertisseur avait genere %(value)s pour une expression conditionnelle, ce qui n'aide pas un traducteur. Renomme en %(player)s. Les 14 traductions ecrites avec une apostrophe droite sont normalisees en apostrophe typographique. Sans consequence en HTML, ou ' s'affiche correctement -- mais les blocs <script> ne decodent pas les entites, et autant que le catalogue soit homogene. 200 tests. Co-Authored-By: Claude Opus 5 <[email protected]> |
||
|
|
80bc1413f1 |
feat(i18n): traduire l'ensemble des gabarits
Le site s'affiche desormais integralement en francais. 483 chaines, aucune non traduite, dans les deux catalogues. Couvert : navigation et mise en page partagee, connexion, les cinq pages d'erreur, et les 29 gabarits de pages. Methode Marquage semi-automatique, conservateur par construction : seuls des motifs sans ambiguite sont balises -- contenu de balises de texte, attributs placeholder/title/aria-label, texte suivant une icone, blocs title et page_title -- et tout contenu comportant du Jinja ou du balisage imbrique est laisse de cote. Deux passes, la seconde pour td, li, h6, strong, em et caption. 97 entrees etaient marquees fuzzy par pybabel update, c'est-a-dire devinees par similarite. Une entree fuzzy est **ignoree a l'execution** : elles ont donc ete traitees comme non traduites, et le drapeau retire une fois la traduction ecrite. Deux pieges rencontres, tous deux documentes dans docs/translations.md Une entite HTML n'est pas du texte. ×, utilise comme libelle de bouton de fermeture, a ete balise par la passe automatique. Jinja l'echappait alors en &times; et le bouton aurait affiche le texte litteral × au lieu de la croix. Corrige dans cinq gabarits. Huit chaines subsistent dans des blocs <script>. Elles fonctionnent mais restent fragiles : Jinja echappe & < > " ' dans un bloc script, et ces entites n'y sont pas decodees -- une traduction contenant une apostrophe droite arriverait dans la chaine JavaScript sous la forme '. Le francais retenu utilise des apostrophes typographiques, non echappees, donc l'existant est sur. Toute nouvelle chaine a cet endroit devra passer par un attribut data- ou un bloc <script type="application/json">. Vocabulaire retenu -- a valider avec le club tryout -> selection · manager -> gerant · coach -> coach (conserve, terme d'usage en e-sport) · scout -> recruteur · email -> courriel · scrim -> scrim. Les noms de jeux et les postes (Support, Duelist, AWPer) restent en anglais : ce sont les termes employes par les joueurs. Ces choix vivent dans un seul catalogue, donc chacun se change en un endroit. Reste a faire : les messages flash hors routes/auth.py, et les messages de validation de app/validators.py, qui necessitent lazy_gettext puisque les champs de schema sont construits a l'import. Verifie : 13 pages parcourues dans les deux langues, aucune ne laisse de marqueur Jinja non evalue ni d'entite doublement echappee. 193 tests. Co-Authored-By: Claude Opus 5 <[email protected]> |
||
|
|
fcb58e8a17 |
feat(csp): retirer unsafe-inline de script-src
SEC-WEB-001 / OPS-010, ferme. C'est cette directive qui laissait s'executer
le XSS stocke de SEC-XSS-001 au lieu de le bloquer.
Les cinq derniers gabarits sont migres : match_form 13, calendar 11,
teams 11, evaluate_player 9, view_tryout 8. Total sur le chantier : 82
gestionnaires en ligne retires dans 17 gabarits. Il n'en reste aucun.
Deux motifs generiques de plus dans main.js
data-mirror affichage direct de la valeur d'un curseur.
evaluate_player repetait le meme
oninput="this.nextElementSibling.textContent = ..."
sur ses neuf curseurs de note.
data-submit-on-change remplace onchange="this.form.submit()"
Markup genere dans des chaines JavaScript
match_form construisait sept gestionnaires par concatenation, en y
injectant l'identifiant du joueur. Le markup portait deja data-player-id :
returnToPool et assignToTeam lisent desormais leurs arguments depuis
l'element clique. Cela supprime a la fois l'attribut en ligne et la
concatenation qui l'alimentait. Meme motif que dans coach_availability.
Bascule
CSP_ALLOW_INLINE_SCRIPT passe a false. script-src vaut maintenant
'self' 'nonce-<aleatoire par requete>' https://cdn.jsdelivr.net.
La variable d'environnement reste, comme issue de secours si un
deploiement rencontrait un gestionnaire oublie -- mais la laisser active
revient a renoncer a la protection.
Le cliquet devient une garde
Le budget par gabarit est vide et les tests deviennent absolus : aucun
gestionnaire en ligne, et tout bloc <script> inline doit porter son
nonce. Sans nonce, un bloc n'est simplement pas execute, et rien dans les
journaux ne le signale -- d'ou le test.
Verifications
22 pages parcourues avec les trois roles : toutes rendent en 200, aucune
ne contient de gestionnaire en ligne, et chaque bloc inline porte bien le
nonce de sa propre reponse. Syntaxe JavaScript de chaque gabarit verifiee
par node --check.
193 tests. Le dernier xfail de SEC-WEB-001 reussissait, le marqueur est
retire. Il n'en reste qu'un : SEC-AUTH-006, enumeration de comptes.
style-src conserve 'unsafe-inline' : les attributs style="" sont partout et
ne sont pas un vecteur XSS a eux seuls. Migration distincte, non prioritaire.
Co-Authored-By: Claude Opus 5 <[email protected]>
|
||
|
|
09453199b8 |
refactor(csp): migrer dix gabarits vers les comportements declaratifs
OPS-010, suite. 76 gestionnaires en ligne -> 52, dans 5 gabarits au lieu de
15. Le cliquet de tests/test_csp.py est abaisse en consequence.
Six motifs recurrents, generalises dans main.js plutot que traites un a un
data-action clic, resolu par un ecouteur delegue
data-change changement -- attribut distinct du clic, sans quoi un
<select> declencherait son gestionnaire des le clic
qui l'ouvre
data-confirm confirmation avant un envoi destructeur, en
remplacement de onsubmit="return confirm(...)". Le
texte reste dans le markup, donc traduisible.
data-navigate navigation sur selection, {value} etant encode
remove-element suppression d'un ancetre designe par data-remove
history-back retour arriere
registerActions()
Les fonctions propres a une page vivent dans son bloc de script et ne
peuvent donc pas figurer dans la table globale. Chaque page declare les
siennes, l'ecouteur delegue reste unique.
Cas particulier, coach_availability
Le gestionnaire y etait construit dans une chaine JavaScript, au moment
de generer la grille de creneaux. Le markup portait deja data-day et
data-time : toggleSlot lit desormais ses arguments depuis l'element, ce
qui supprime a la fois l'attribut en ligne et la concatenation.
Gabarits migres : my_teams, register, users, view_user, one_on_one,
coach_availability, profile, team_matches, notes, contracts.
Restent, par ordre decroissant : match_form 13, calendar 11, teams 11,
evaluate_player 9, view_tryout 8.
Syntaxe JavaScript de chaque bloc modifie verifiee par node --check.
A noter : la traduction de ces dix gabarits reste a faire. Seules les deux
chaines devenues visibles dans le markup au cours de cette migration -- les
messages de confirmation de suppression -- sont balisees et traduites.
192 tests.
Co-Authored-By: Claude Opus 5 <[email protected]>
|
||
|
|
15bfebf4fc |
feat(csp): infrastructure de sortie de unsafe-inline, et couche partagee migree
SEC-WEB-001 / OPS-010. script-src porte toujours 'unsafe-inline' : c'est
pour cela que le XSS stocke de SEC-XSS-001 s'executait au lieu d'etre
bloque. Le retirer n'est pas un changement d'une ligne.
Ce qui bloque reellement
Un nonce autorise des elements <script> ; il ne peut rien pour un
attribut onclick="...". Mesure faite : 76 gestionnaires en ligne repartis
dans 15 gabarits. Tant qu'il en reste un, la politique ne peut pas etre
durcie.
Piege supplementaire, documente dans build_csp() : en CSP niveau 3, un
navigateur ignore 'unsafe-inline' des qu'un nonce est present. Emettre
les deux ne serait donc pas une transition douce -- ce serait couper
d'un coup tous les scripts en ligne et tous les onclick, et uniquement
sur les navigateurs recents. La bascule doit etre atomique, d'ou un
drapeau unique : CSP_ALLOW_INLINE_SCRIPT.
Infrastructure posee
build_csp() assemble l'en-tete selon le drapeau. Un nonce est genere par
requete et n'est emis que lorsque l'inline est interdit. Les 15 blocs
<script> portent deja nonce="{{ csp_nonce }}", inerte aujourd'hui : la
bascule finale sera un changement de configuration, pas de gabarits.
Couche partagee migree en premier
base.html et macros.html sont rendus sur absolument toutes les pages. Six
gestionnaires retires, remplaces par des attributs data-action et un
ecouteur delegue unique dans main.js. La delegation plutot qu'un
ecouteur par widget : le contenu injecte dynamiquement herite du
comportement sans re-attachement.
Un cliquet plutot qu'une promesse
tests/test_csp.py fixe un budget par gabarit qui ne peut que baisser.
Ajouter un gestionnaire en ligne fait echouer la suite ; en retirer sans
mettre le budget a jour aussi, ce qui force a enregistrer la progression
dans le diff. A zero, il ne reste qu'a basculer le drapeau.
Le cliquet a d'ailleurs corrige mon propre relevé : mon grep initial
comptait 83 gestionnaires, la mesure exacte en donne 76 -- le motif ne
verifiait pas l'espace avant l'attribut.
style-src conserve 'unsafe-inline' : les attributs style="" sont partout et
ne constituent pas un vecteur XSS a eux seuls. Migration distincte.
192 tests.
Co-Authored-By: Claude Opus 5 <[email protected]>
|
||
|
|
d15a3de2a1 |
fix(data): reparer les trois suppressions cassees
DATA-004, DATA-005, DATA-006. L'audit les classait "forte probabilite" faute de pouvoir les executer. Les tests les confirment : ce sont des bugs averes, declenchables par tout manager ou administrateur depuis l'interface. Erreurs reellement obtenues avant correction : NOT NULL constraint failed: match_participants.match_id NOT NULL constraint failed: team_matches.org_team_id Supprimer un match (DATA-004) Match.participants n'avait pas de cascade. SQLAlchemy tentait donc de detacher les participants en mettant match_id a NULL, ce que la colonne refuse. Tout match ayant eu des participants etait indestructible. TeamMatch.participants declarait deja delete-orphan ; Match non. Supprimer un tryout (DATA-006) Les PersonalNote pointant vers ses matchs, equipes ou vers lui-meme n'etaient pas traitees. Supprimer une equipe (DATA-005) TeamNote.org_team_id et TeamMatch.org_team_id sont NOT NULL et n'etaient pas traites du tout. De plus la fonction validait trois fois : un echec au troisieme temps laissait les tryouts detaches et les joueurs retires sans que l'equipe soit supprimee -- un etat incoherent que rien ne rattrapait. Une seule transaction desormais. Regle appliquee, uniforme Ce qui n'a de sens que dans le parent est supprime avec lui : participants, membres, notes d'equipe, matchs de saison. Ce qui lui survit est seulement detache : les notes personnelles sont les observations d'un coach sur un joueur, pas des donnees de tryout. Les supprimer avec le tryout detruirait du contenu sans rapport. Idem pour les contrats et les demandes de rencontre individuelle. Fidelite des tests conftest.py active PRAGMA foreign_keys=ON. SQLite ignore les cles etrangeres par defaut ; PostgreSQL les applique toujours. Sans ce reglage, la suite pouvait valider une suppression qui echoue en production -- precisement la classe de bug corrigee ici. Les 146 tests passent avec les contraintes actives. 9 tests, dont trois qui verifient que les entites survivantes survivent vraiment : une note garde son contenu et perd son contexte, un tryout survit a l'equipe qu'il visait. Co-Authored-By: Claude Opus 5 <[email protected]> |
||
|
|
a92600c305 |
feat(i18n): francais comme langue principale, anglais accessible
Le site s'affiche desormais en francais par defaut, avec un selecteur de langue permettant de basculer vers l'anglais. Choix de conception : les chaines sources restent en anglais Elles servent d'identifiants gettext, et le francais est fourni par catalogue avec BABEL_DEFAULT_LOCALE = 'fr'. Le code reste ainsi dans une seule langue -- la meme que ses commentaires et docstrings -- tandis que ce qu'un membre voit est du francais. Consequence qui rend la migration praticable : une chaine non encore traduite retombe en anglais, pas sur un identifiant brut. Les gabarits peuvent donc etre migres un par un sans jamais laisser le site a moitie casse. Selection de la langue (app/i18n.py) 1. choix explicite via le selecteur, garde en session 2. sinon en-tete Accept-Language du navigateur, restreint a fr et en 3. sinon francais Un choix explicite prime toujours, y compris sur un navigateur anglophone. Selecteur Extrait en partiel et inclus dans les deux branches de la mise en page : barre laterale une fois connecte, ET page d'authentification. Quelqu'un qui ne lit pas la langue courante doit pouvoir en changer AVANT de se connecter -- le laisser derriere l'authentification aurait ete un defaut d'accessibilite. Chaque langue est ecrite dans sa propre langue. La route /lang/<locale> valide le Referer avant de rediriger : sans ce controle, elle constituait une redirection ouverte. Migre dans cette passe navigation complete, page de connexion, les cinq pages d'erreur, et l'integralite des messages flash de routes/auth.py. 64 chaines, dont aucune non traduite. Verification 25 tests, dont deux garde-fous d'integrite : un catalogue .mo manquant ou une entree non traduite font echouer la suite. Sans cela, une compilation oubliee servirait de l'anglais partout, en silence et sans rien dans les journaux. Un test existant a du etre corrige, et c'est instructif test_login_failure_message_does_not_reveal_account_existence cherchait la sous-chaine anglaise 'attempt(s) remaining'. La page etant desormais en francais, elle etait absente des deux cotes, l'assertion passait, et le mode strict a signale le faux succes. Le test comparait donc l'anglais, pas le comportement. Il compare desormais les messages flash rendus, quelle que soit la langue. La faille SEC-AUTH-006 reste ouverte, et le test la documente toujours. Les catalogues .po ET .mo sont versionnes : le deploiement est un simple miroir de fichiers, sans etape de compilation. messages.pot, regenerable, ne l'est pas. docs/translations.md documente le processus, les deux pieges (concatenation de phrases, traduction a l'import), et l'etat de la migration. A noter pour la suite : les chaines dans les blocs <script> ne peuvent pas etre balisees telles quelles, il faudra les passer par des attributs data- -- ce qui rejoint le chantier de sortie de unsafe-inline (OPS-010). Co-Authored-By: Claude Opus 5 <[email protected]> |
||
|
|
afab7070fb |
fix(authz): une seule regle pour l'acces coach vers joueur
SEC-AUTHZ-004 et SEC-AUTHZ-005. La meme question -- ce coach peut-il agir
sur ce joueur ? -- recevait cinq reponses differentes selon la route :
teams.py:add_player_note verifiait l'appartenance via TeamPlayer
users.py, 4 routes de notes ne verifiaient rien au-dela d'isinstance
contract.py:can_view interrogeait la colonne heritee coach_id, et
traitait un team_id nul comme un joker
Consequences levees
- tout coach pouvait ecrire une note nominative sur tout joueur du club.
Ces notes sont visibles par le joueur concerne.
- tout coach figurant dans OrgTeam.coach_id pouvait lire n'importe quel
contrat sans equipe rattachee. Or upload_contract laisse team_id nul des
que le joueur n'appartient a aucune equipe : la condition
`not self.team_id or ...` ouvrait donc largement.
- symetriquement, un coach rattache uniquement par la relation
many-to-many ne voyait aucun contrat.
app/permissions.py
Premier pas concret vers ARCH-002, sans refonte : un module unique, pas
une couche de services. coach_org_team_ids() lit la relation m2m ET la
colonne heritee, donc le deuxieme coach d'une equipe cesse d'etre
invisible. coach_can_access_player() accorde l'acces si le joueur est sur
une equipe du coach, ou inscrit a un tryout qu'il gere, ou participant a
un match de ce tryout.
14 tests, dont deux verifient que les chemins legitimes fonctionnent
toujours : un coach note bien son propre joueur, et voit bien son contrat.
Note : la regle metier retenue -- equipe OU tryout -- est une lecture du
comportement existant, pas une decision produit. Si le club attend autre
chose, c'est desormais un seul endroit a changer.
Co-Authored-By: Claude Opus 5 <[email protected]>
|
||
|
|
7bf428f9a0 |
fix(ops): sauvegarder reellement la base PostgreSQL
DATA-002 / OPS-001. backup.py ciblait SQLite : import sqlite3, DATABASE_PATH par defaut instance/team_tryouts.db, et l'API de sauvegarde sqlite3. La production tourne sur PostgreSQL, donc le fichier n'existait pas. Le script affichait "[WARNING] Database not found... Skipping database backup" -- puis, main() ne suivant que le resultat de la verification, **sortait avec le code 0**. Toute tache planifiee surveillant le code de sortie voyait vert alors qu'aucune sauvegarde n'avait jamais ete produite. Il n'existait donc aucune sauvegarde applicative de la base. Reecriture pg_dump en --format=custom : compresse, et pg_restore permet une restauration selective, ce qu'un dump SQL a plat ne permet pas. parse_database_url accepte les suffixes de dialecte SQLAlchemy (postgresql+psycopg://) que pg_dump ne comprend pas, et refuse explicitement une URL SQLite -- le cas exact qui passait en silence. Le mot de passe ne figure jamais dans la ligne de commande : il serait visible de tout processus capable de lister argv. Il passe par PGPASSWORD. Il est egalement absent des messages affiches, qui atterrissent dans les journaux du planificateur. verify_backup lit l'archive avec pg_restore --list et exige au moins une table : une archive illisible ne se restaure pas, et une archive sans table signifie que le dump a vise la mauvaise cible. Les deux sont des echecs silencieux qu'il vaut mieux attraper maintenant que pendant un incident. Le code de sortie vaut 0 uniquement si le dump a ete produit ET verifie. L'archive des documents est conservee : les contrats signes n'existent que sur disque, la base ne stocke que des chemins. Restaurer l'une sans l'autre laisse des lignes pointant vers des fichiers absents. docs/database-restore.md Procedure de restauration testable sur une base jetable, requetes de controle, demarrage de l'application sur la copie restauree, plan de reprise par scenario. ENABLE_DISCORD_BOT=false y est signale comme non optionnel : sans lui, l'exercice demarre un vrai bot et envoie de vraies notifications a de vraies personnes, a partir de donnees restaurees. Les points ouverts sont listes tels quels : aucune copie hors site, pas de chiffrement au repos, aucune planification, et l'exercice de restauration n'a jamais ete effectue. 17 tests sur ce qui est verifiable sans serveur PostgreSQL : analyse de l'URL, construction de la commande, non-fuite du mot de passe, et surtout codes de sortie -- le silence ne vaut plus succes. Co-Authored-By: Claude Opus 5 <[email protected]> |
||
|
|
ca45be80db |
fix(security): proteger le dernier administrateur, et fermer le CORS permissif
SEC-AUTHZ-007 - auto-verrouillage de l'administration
Le changement de role n'excluait ni l'utilisateur courant, ni le dernier
compte admin actif. Une seule manipulation suffisait a transformer le seul
president en joueur, et plus aucune interface ne permettait de revenir en
arriere : il fallait une intervention directe en base.
Deux gardes distinctes, parce que ce sont deux erreurs differentes :
- changer son propre role est refuse, meme s'il reste d'autres admins.
Un president qui veut se retrograder doit le faire faire par un autre.
- retrograder le dernier admin actif est refuse.
Le decompte exclut les comptes desactives : trois admins dont deux
desactives, cela fait un seul administrateur reel.
SEC-WEB-003 - CORS ouvert par defaut
Sans CORS_ALLOWED_ORIGINS, la branche else appelait
CORS(app, supports_credentials=True) sans argument origins. flask-cors
retient alors '*' et, les identifiants etant autorises, renvoie en echo
l'Origin de l'appelant avec Access-Control-Allow-Credentials: true --
l'inverse exact de ce qu'annonçait le commentaire.
L'exploitation etait bloquee par SESSION_COOKIE_SAMESITE = 'Lax', qui
empeche le navigateur de joindre le cookie de session a une requete
fetch inter-site. Toute la protection tenait donc a ce seul reglage.
Cette application rend du HTML en meme origine : elle n'a besoin
d'aucune politique CORS. La branche par defaut est supprimee, la
configuration explicite reste possible.
5 tests ajoutes, dont deux verifient que les chemins legitimes continuent
de fonctionner : un autre administrateur reste retrogradable, et une
origine explicitement configuree est toujours honoree.
Co-Authored-By: Claude Opus 5 <[email protected]>
|
||
|
|
ab44258d72 |
feat(obs): journaliser les evenements d'authentification
OBS-001. logging_config.py configurait un fichier auth.log avec rotation,
un logger nomme 'team_tryouts.auth' et un filtre de redaction. Mais
get_auth_logger n'etait importe nulle part : le fichier etait cree et
restait vide. Aucune connexion, aucun echec, aucun verrouillage, aucun
changement de role, aucune suppression de compte ne laissait de trace.
En cas de suspicion de compromission, il n'y avait rien a consulter.
Ajout de log_auth_event(event, **fields), qui emet des paires cle=valeur
ordonnees -- greppable sans dependance de journalisation JSON.
Evenements couverts
authentification login.success, login.failure,
login.failure.unknown_user, login.rejected.locked,
login.rejected.deactivated, account.locked, logout,
account.registered
administration account.created_by_admin, account.updated,
account.role_changed (avec ancien et nouveau role),
account.deleted, account.password_reset_by_admin
libre-service account.password_changed
Le champ ip vient de request.remote_addr, donc de X-Forwarded-For. Tant que
Waitress tourne avec trusted_proxy='*' (SEC-WEB-002), cette valeur est
choisie par l'appelant : c'est une indication, pas une preuve. Le point est
documente dans la docstring.
Un test a fait remonter ARCH-003, jusqu'ici classe comme fragilite latente
Journaliser en fin de edit_user levait DetachedInstanceError : le
changement de role appelle db.session.remove() en plein cycle de requete,
ce qui detache current_user de la session. Le code s'en tirait parce qu'il
redirigeait immediatement sans plus y toucher. L'identite de l'acteur est
desormais capturee en debut de traitement. Le constat est donc confirme
comme reel, et non plus seulement probable -- sa correction de fond reste
au programme.
15 tests, dont 4 sur le filtre de redaction lui-meme : c'est un controle de
securite, il doit etre verifie.
Co-Authored-By: Claude Opus 5 <[email protected]>
|
||
|
|
983b7a1f49 |
fix(auth): ajouter le parametre state au flux OAuth2 Discord
SEC-AUTH-004. L'URL d'autorisation ne portait que client_id, redirect_uri,
response_type et scope. Sans state, le callback acceptait n'importe quel
code d'autorisation qu'on lui presentait.
Scenario ferme : un attaquant obtient un code pour SON compte Discord, puis
fait charger l'URL de callback par le navigateur de la victime. Le
formulaire d'inscription de la victime se retrouve pre-rempli avec
l'identite Discord de l'attaquant. C'est le login CSRF decrit par la
RFC 6749 §10.12.
Mise en oeuvre
secrets.token_urlsafe(32) genere le jeton, stocke en session avant la
redirection. Le callback le compare en temps constant avec
secrets.compare_digest, et le consomme systematiquement -- valide ou non --
pour qu'il ne puisse pas etre rejoue. Le controle intervient avant
l'echange du code : un callback rejete ne declenche aucun appel reseau.
Deux corrections accessoires sur le meme chemin
- DISCORD_REDIRECT_URI est desormais verifie au meme titre que
DISCORD_CLIENT_ID. Non defini, il faisait lever requests.utils.quote(None)
au lieu de signaler un probleme de configuration.
- la construction de la chaine de requete passe a urlencode() plutot qu'a
une concatenation manuelle.
9 tests : presence du state, stockage en session, unicite entre deux
demandes, rejet sans state, avec un state forge, sans demande prealable,
et non-rejouabilite.
Reste ouvert : l'identite Discord obtenue reste ensuite reinjectee par un
champ cache du formulaire (SEC-AUTH-005). Le state protege la liaison, pas
encore la valeur elle-meme.
Co-Authored-By: Claude Opus 5 <[email protected]>
|
||
|
|
d05e9cde32 |
fix(security): supprimer le XSS stocke du calendrier
SEC-XSS-001. Chaine complete : un nom d'utilisateur libre arrivait dans le
DOM d'un coach ou d'un administrateur, en meme origine, avec sa session.
La CSP autorisant 'unsafe-inline', rien ne l'arretait.
Cote serveur - la cause
/matches/api/events construisait de la presentation dans un champ JSON :
match_desc = participants_str + f"<br>{match.description}"
Le navigateur deposait cette valeur telle quelle dans innerHTML. Les noms
de joueurs y transitaient sans echappement -- et il ne pouvait pas y en
avoir : c'est du JSON, pas du HTML.
Les deux valeurs etaient deja des cles distinctes du payload. La
concatenation faisait donc aussi afficher les participants deux fois dans
le modal : une fois dans "Teams", une fois en tete de "Description".
Corriger la faille corrige l'affichage.
Cote navigateur - le sink
showEventModal assemblait une chaine HTML puis l'affectait a innerHTML.
Remplace par une construction de noeuds : makeEl / detailItem /
multilineNode / teamNode passent tout texte par textContent. Les retours
a la ligne d'une description restent rendus, via des <br> crees en dur.
Les deux listes deroulantes concatenaient egalement titres de tryout et
noms d'equipe dans innerHTML. Remplacees par new Option(), dont le
premier argument est pose en texte.
Verification
5 tests sur le contrat de l'API, dont un avec un nom d'utilisateur
hostile ecrit directement en base -- ce que la validation refuse
desormais, mais que des lignes anterieures peuvent contenir.
Le JavaScript inline extrait passe `node --check`.
Reste ouvert : la CSP autorise toujours 'unsafe-inline' (SEC-WEB-001), donc
la defense en profondeur manque encore. Suivi en OPS-010.
Co-Authored-By: Claude Opus 5 <[email protected]>
|
||
|
|
32f193c008 |
style: f-strings sans placeholder signalees par ruff (F541)
Aucun changement de comportement. Les chaines concernees ne contenaient aucune substitution. A noter pour plus tard : run_https.py conserve une banniere en caracteres semi-graphiques, du meme type que celle qui faisait planter security_scan.py sur une console Windows en cp1252. Le script n'etant lance qu'en developpement et de facon explicite, le point est signale sans etre corrige ici. Co-Authored-By: Claude Opus 5 <[email protected]> |
||
|
|
5ecea55f55 |
fix(ci): rendre la chaine d'integration reellement verifiante
Les quatre jobs existaient ; aucun ne verifiait ce qu'il annoncait.
security-audit
`pip-audit --require-hashes --no-deps || pip-audit`. L'etape
d'installation ne posait que pip-audit, et aucune des deux formes ne
nommait le fichier d'exigences : le repli auditait l'environnement du
runner, qui ne contenait que pip-audit lui-meme. Le job passait au vert
sans avoir examine une seule dependance de l'application. Remplace par
`pip-audit -r requirements.txt`.
security-scan
Appelait `python security_scan.py`, alors que le fichier se trouve dans
app/supporting_scripts/. En echec a chaque execution depuis le
deplacement du fichier. Trois autres defauts sont apparus en le faisant
tourner :
- la CI passe --skip-http, un argument que l'argparse du script
n'acceptait pas : sortie en erreur 2 meme avec le bon chemin.
- check_dependencies lisait data['dependencies'] comme la liste des
vulnerabilites. Ce tableau liste en realite TOUTES les dependances,
chacune portant un champ vulns vide si le paquet est sain. Les ~45
paquets installes etaient donc signales vulnerables a chaque
execution. Le filtrage se fait desormais sur vulns non vide.
- check_flask_config interceptait son exception et renvoyait quand
meme all_ok : ne pas reussir a charger l'application comptait comme
un controle reussi. La section la plus importante du rapport n'avait
jamais tourne. Elle renvoie desormais False, et l'import fonctionne
grace a l'ajout de la racine du projet dans sys.path.
- la banniere en caracteres semi-graphiques faisait planter le script
sur une console Windows en cp1252, la plateforme meme du projet.
Passee en ASCII.
lint
Ruff n'avait aucun fichier de configuration : le job tournait sur le jeu
de regles par defaut. La configuration vit maintenant dans pyproject.toml.
`ruff format --check` est retire pour l'instant : la base n'ayant jamais
ete formatee, il echouerait sur 62 fichiers sur 64 pour des raisons
etrangeres a la correction. Reformatage puis application : QUA-002.
test
Un `echo` protege par continue-on-error : le job annoncait un succes
sans rien executer. Il lance desormais pytest avec couverture, et bloque.
permissions: contents: read au niveau du workflow, aucune etape n'ecrivant
dans le depot.
Deploiement Gitea
actions/checkout@v7 n'existe pas (derniere majeure : v5) : le workflow
echouait des sa premiere etape. Ramene a v4.
Le miroir lftp poussait l'integralite de l'arbre de travail, dont
clear_db.py -- un script qui vide toutes les tables et recree
admin/password -- vers le noeud de production. Liste d'exclusions ajoutee.
--delete reste volontairement absent : les contrats televerses, les
journaux et le .env du serveur vivent sous la racine de deploiement et
sont absents du depot ; les supprimer detruirait des donnees.
Le workflow de deploiement n'a pas pu etre execute depuis ici : la
syntaxe lftp reste a valider lors du prochain deploiement manuel.
Co-Authored-By: Claude Opus 5 <[email protected]>
|
||
|
|
a793b7ed0d |
fix(authz): verifier le rattachement equipe/tryout, et nettoyer le code mort
SEC-AUTHZ-002. add_to_team recevait tryout_id et team_id independamment
dans l'URL, controlait l'autorisation sur le tryout, puis operait sur
l'equipe sans jamais etablir de lien entre les deux. Un gestionnaire du
tryout A pouvait donc modifier une equipe du tryout B.
Le lint pointait exactement dessus : `team` etait charge ligne 463 puis
jamais utilise. La correction automatique proposee etait de supprimer la
variable, ce qui aurait fait taire l'avertissement en cimentant la faille.
Elle est desormais utilisee pour ce a quoi elle servait.
Trois defauts sur la meme route, corriges ensemble :
- team.tryout_id != tryout_id repond maintenant 404
- seuls les joueurs inscrits au tryout peuvent rejoindre ses equipes
- int(player_id) sur une entree de formulaire brute levait ValueError,
donc une erreur 500, sur toute valeur non numerique
Nettoyage automatique par ruff : 34 imports et variables morts retires
sur l'ensemble du paquet. La suite de tests a servi de filet, elle passe
a l'identique avant et apres. Aucun changement de comportement.
A noter, OneOnOneRequestSchema figurait aussi parmi les imports morts :
c'est un quatrieme schema jamais appele, la route one_on_one validant ses
dates a la main. Unifier la validation reste a faire (ARCH-005).
Co-Authored-By: Claude Opus 5 <[email protected]>
|
||
|
|
b277c453f9 |
fix(security): appliquer les schemas de validation sur la gestion des comptes
SEC-AUTHZ-001. CreateUserSchema, EditUserSchema et EditProfileSchema
etaient importes dans users.py et jamais appeles : chaque nom
n'apparaissait qu'une fois dans le fichier, sur sa ligne d'import. Les
trois routes lisaient request.form directement.
Consequences levees :
- aucune politique de mot de passe sur create_user, edit_user et
edit_profile. Un mot de passe d'un caractere etait accepte pour un
compte administrateur.
- aucune validation de format sur username, email, phone,
discord_user_id.
- edit_user ne verifiait pas l'unicite du courriel avant affectation :
la contrainte unique remontait en IntegrityError, donc en erreur 500.
Un controle explicite excluant l'utilisateur courant est ajoute.
C'est aussi le point d'injection de la chaine de XSS stocke SEC-XSS-001 :
edit_profile acceptait n'importe quel nom d'utilisateur, charge HTML
comprise, qui ressortait ensuite en JSON via /matches/api/events et
etait injectee par innerHTML dans le calendrier.
Deux details de formulaire imposaient un adaptateur, _form_payload :
- request.form.to_dict() ne conserve que la premiere valeur d'une cle
repetee, donc games doit etre relu avec getlist().
- une case a cocher non cochee est absente de la soumission, ce qui
n'est pas la meme chose qu'un load_default. Sans injection explicite,
decocher is_active_account aurait cesse de desactiver le compte.
- un mot de passe vide signifie "conserver l'actuel" et non "definir le
mot de passe vide" : le champ est retire avant validation.
Le controle manuel du role devient redondant, le schema le contraint deja
par OneOf(USER_TYPES).
Verifie par quatre tests qui echouaient avant ce changement.
Co-Authored-By: Claude Opus 5 <[email protected]>
|
||
|
|
1b990a84d9 |
test: socle de tests executables et fabrique d'application parametrable
Il n'existait aucun test, et le code n'offrait aucune prise pour en ecrire :
create_app() exigeait SECRET_KEY et DATABASE_URL dans l'environnement,
creait les tables et demarrait un bot Discord. C'etait la cause, pas le
symptome.
create_app(config=None)
Les valeurs par defaut viennent toujours de l'environnement, les
surcharges de l'appelant sont appliquees ensuite, et la validation
vient en dernier pour qu'un test puisse fournir les siennes. Deux
effets de bord passent sous drapeau, actifs par defaut pour que la
production et le developpement se comportent a l'identique :
AUTO_CREATE_TABLES controle db.create_all()
ENABLE_DISCORD_BOT controle start_bot()
FORCE_HTTPS passe egalement en configuration : lu via os.getenv a
chaque requete, il renvoyait un 301 sur tout appel de test.
Suite de tests : 47 tests, 3 xfail, 32 % de couverture.
tests/conftest.py fabriques par role, connexion par le vrai
formulaire, base SQLite temporaire
test_auth_session.py expiration de session, desactivation de
compte, deconnexion
test_security_headers.py en-tetes, non-divulgation sur /health,
echappement de nl2br
test_authorization.py acces anonyme, vertical, horizontal,
validation des entrees, CSRF
Les tests marques xfail(strict=True) decrivent des constats non encore
corriges. Ils echouent par construction ; le mode strict transforme une
reussite inattendue en echec, ce qui signale qu'il faut retirer le
marqueur. Trois subsistent : enumeration de comptes (SEC-AUTH-006), CSP
unsafe-inline (SEC-WEB-001), auto-retrogradation du dernier administrateur
(SEC-AUTHZ-007).
pyproject.toml
Configuration pytest et ruff. Ruff n'avait aucune configuration : la CI
l'executait avec le jeu de regles par defaut. Les 33 F401 de
app/models/__init__.py sont ignores par fichier, c'est une facade de
re-export intentionnelle.
requirements-dev.txt separe l'outillage de test des dependances de
production.
Co-Authored-By: Claude Opus 5 <[email protected]>
|
||
|
|
de9448a9aa |
fix(security,obs): fuite d'erreur sur /health, filtre nl2br, redaction des logs
/health divulguait le message brut du pilote
L'endpoint n'est pas authentifie et renvoyait f'error: {str(e)}'. Les
exceptions psycopg contiennent regulierement l'hote, le port, le nom de
la base et l'utilisateur. Le detail part desormais dans les journaux,
la reponse ne porte plus qu'un statut.
Filtre nl2br non echappant
Markup('<br>'.join(...)) marquait le texte comme sur sans l'echapper.
Le filtre n'etant utilise dans aucun gabarit, la faille etait latente :
elle se serait ouverte au premier usage. Corrige en Markup('<br>').join(),
qui echappe chaque segment. Verifie : nl2br('<script>alert(1)</script>')
rend desormais <script>alert(1)</script>.
Filtre de redaction des secrets sans effet
SensitiveDataFilter n'inspectait que record.msg. Or le code journalise
en style parametre ('...: %s', valeur) : record.msg ne contient que la
chaine de format, et la donnee sensible vit dans record.args, ignore.
La redaction ne s'appliquait donc pratiquement jamais. Le record est
desormais rendu avant filtrage, puis args vide.
Sortie console conditionnee a FLASK_DEBUG
En production, l'application n'ecrivait rien sur stdout, precisement ou
regarde la console Pterodactyl. Le handler devient inconditionnel, seul
son niveau varie.
Journaux du bot Discord perdus
discord_bot.py utilise getLogger(__name__), soit 'app.discord_bot'.
Aucun handler n'etait attache a la hierarchie 'app' : les INFO etaient
jetes et les WARNING+ tombaient sur le handler de dernier recours, sans
format. Les handlers sont desormais rattaches au logger de paquet.
X-XSS-Protection retire (app.py et nginx.conf)
En-tete deprecie, l'auditeur vise a ete supprime des navigateurs
courants et ses dernieres implementations introduisaient elles-memes
des vulnerabilites.
Co-Authored-By: Claude Opus 5 <[email protected]>
|
||
|
|
2c37c05c8f |
fix(auth): rendre effectives l'expiration de session et la desactivation de compte
Deux protections etaient configurees sans avoir d'effet. Expiration de session app.py:73 definit PERMANENT_SESSION_LIFETIME = 3600, mais Flask n'applique cette duree qu'aux sessions marquees permanentes. Aucune occurrence de session.permanent n'existait dans app/. Le cookie emis etait donc un cookie de session navigateur, sans expiration, et le serveur ne verifiait aucune anciennete. Ajout de session.permanent juste avant login_user, apres la rotation anti-fixation. Desactivation de compte is_active_account n'etait consulte qu'au moment du login (auth.py:141). User n'ayant pas surcharge is_active, UserMixin renvoyait True en permanence. Desactiver un compte empechait donc la reconnexion mais laissait vivre la session en cours. La propriete is_active seule ne suffit pas : Flask-Login ne la consulte qu'a l'appel de login_user, jamais lors de la restauration d'une session depuis le cookie. Le verrou effectif est donc dans load_user, qui renvoie desormais None pour un compte desactive. La propriete est ajoutee malgre tout pour que login_user soit coherent avec le chargeur. load_user passe au passage de Query.get() (API heritee, avertie en SQLAlchemy 2.0) a db.session.get(), et tolere un identifiant non entier sans lever. Co-Authored-By: Claude Opus 5 <[email protected]> |
||
|
|
bb0bc1c192 | ajout de plateforme de base pour les url TRN | ||
|
|
f89e4deb30 | régler problème avec mise a jour des status de message du discord bot | ||
|
|
fcf10bcdff |
bug fix:
Manager ne pouvait pas voir les tryouts. probleme avec discord bot |
||
|
|
68e3da6601 | fix probleme avec dispos | ||
|
|
d25b35c928 | régler erreur 500 sur changement de role par admin | ||
|
|
aeba4d78cf | régler problème de changement de rôle | ||
|
|
7bb80229db | added a clear_db to start fresh with only an admin | ||
|
|
0dd4ecdb4c | Erreur de frappe dans un des dossiers |