Audit

Broulala — sécurité et qualité

Revue complète du back, du front et de la surface d'intégration, au 21/09/2026, sur la révision 0a9e256. Mis à jour le même jour : les trois lots du plan d'action ont été livrés et déployés, et deux constats de plus sont apparus en les livrant — ils sont dans la liste, à leur place. Chaque constat ci-dessous a été vérifié dans le code ou mesuré — rien n'est déduit d'une lecture de la documentation, et ce qui n'a pas pu être établi est dit dans la section Questions plutôt que supposé.

15
corrigés
3
documentés
1
assumé
0
ouvert
Les trois lots du plan sont livrés le 21/09/2026, et les dix-sept constats sont clos. Quatorze corrigés, deux écrits au contrat parce qu'ils étaient vrais et tacites plutôt que faux, et un assumé — la clé d'API gardée dans le navigateur, dont l'alternative est pire.

Rien n'a été effacé de ce rapport. Chaque constat garde son texte d'origine sous sa note de correction : ce qui a été trouvé explique pourquoi le code est ce qu'il est, et un rapport dont on retire les constats réparés finit par ne plus rien dire.

Quatre constats ne viennent pas de l'audit mais de sa réparation. A16 et A17 sont apparus en corrigeant les premiers et sont corrigés ; A18 et A19 sont apparus le lendemain, en exploitant la plateforme, et restent ouverts — ils ont une section à eux, la 5 bis.

Et deux corrections ont fait tomber la production : le durcissement des conteneurs (A15), deux fois, à une heure d'intervalle et pour deux capacités différentes. C'est consigné là où c'est arrivé plutôt que dans une note de bas de page.

Les deux que l'exploitation a produits sont traités aussi, le même jour : l'un par une commande éprouvée contre une image périmée avant d'être annoncée, l'autre par une ligne de procédure, parce qu'il n'y avait rien à corriger — seulement quelque chose à savoir. Aucun contrôle du dépôt ne pouvait les voir, et c'est ce qui les rend intéressants : ils ne viennent ni d'une relecture ni d'une suite de tests, mais d'une journée passée à faire tourner la chose.

Le jugement d'ensemble d'abord, parce qu'il change la façon de lire la suite. Cette plateforme est notablement plus sûre que la moyenne de ce qu'on trouve à ce stade de maturité. Les fondations qui coûtent cher à rattraper — étanchéité des deux authentifications, absence de contenu dans les journaux, suppression de la surface SSRF par construction, CSP sans unsafe-inline, zéro any — sont en place et testées. Il n'y a aucune faille critique.

Ce qui restait se répartissait en deux familles : d'un côté une porte non protégée — la connexion administrateur —, de l'autre des contrôles à moitié construits : un journal d'accès que personne ne pouvait lire, des refus qui protègent de l'argent et qu'aucun test n'affirmait, des limites de ressources absentes sur une machine déjà tombée deux fois.

Les deux familles sont traitées. Ce qui reste n'est pas une liste de défauts mais une liste de faits écrits : le quota est un disjoncteur et non une barrière, la limitation de débit compte dans un seul processus, et une clé d'API vit dans le navigateur de la console parce que l'alternative est pire. Trois choses vraies qui l'étaient déjà, et qui ne l'étaient nulle part.

Ce que contient ce rapport

  1. Méthode et périmètre
  2. Ce qui est bien, et qu'il faut préserver
  3. Constats — criticité élevée corrigé
  4. Constats — criticité moyenne
  5. Constats — criticité faible
  6. Ce que la réparation a fait apparaître
  7. Plan d'action priorisé
  8. Questions ouvertes

1. Méthode et périmètre

Ce qui a été examiné, ligne à ligne ou par sondage ciblé :

ZoneVolumeCe qui a été cherché
src/auth/540 l.étanchéité des deux portes, comparaison en temps constant, oracles de temps, force des condensats
src/routes/3 399 l.autorisation serveur, validation d'entrée, limitation de débit, fuites dans les erreurs
src/db/2 238 l.injection SQL, courses de concurrence, contraintes d'unicité non rattrapées
src/adapters/2 259 l.traversée de chemin, SSRF, citation de corps de réponse, plafonds d'écriture
src/services/, src/jobs/3 500 l.baux, idempotence, quota, dépenses machine
src/config/, src/cli/1 702 l.validation des secrets, opérations privilégiées
web/src/7 432 l.XSS, stockage de jetons, autorisation côté client, robustesse de rendu
Conteneurs—privilèges, ports publiés, limites de ressources, secrets
Dépendances—npm audit, production et développement
Production—en-têtes réellement servis, état de la base, journaux

Ce qui n'a pas été fait, et qu'il faut savoir absent : aucun test d'intrusion actif, aucune tentative d'exploitation contre la production, aucune revue de l'infrastructure hôte au-delà du compose (pare-feu, durcissement du noyau, configuration du reverse-proxy en amont). Ces angles peuvent changer la sévérité du constat n° 1 et sont repris en Questions.

2. Ce qui est bien, et qu'il faut préserver

Ces quatorze points ne sont pas des compliments : ce sont des propriétés à ne pas perdre en corrigeant le reste. Plusieurs corrections proposées plus bas pourraient les entamer si elles étaient écrites sans y penser, et c'est dit à chaque fois.

Architecture et frontières

Secrets et cryptographie

Surface d'attaque

Journaux et dépendances

Et une propriété de qualité qui mérite d'être chiffrée : sur 24 168 lignes de TypeScript, zéro any réel (les six occurrences sont le mot anglais dans des commentaires), zéro assertion non nulle, zéro @ts-ignore, zéro TODO. Les dix eslint-disable sont huit no-console dans les CLI et deux désactivations typées d'une ligne. C'est rare, et c'est ce qui rend l'audit rapide.

3. Constats — criticité élevée

Un seul, et il est corrigé depuis le 21/09/2026. Il reste ici avec ce qu'il a coûté à trouver et ce que le corriger a appris — un rapport dont on retire les constats réparés finit par ne plus rien expliquer.

corrigé A1 — La connexion administrateur n'était pas limitée en débit

Corrigé et déployé le 21/09/2026. Trois compteurs sur POST /admin/v1/session — par adresse, par source et globalement —, tous comptés avant la vérification du mot de passe. Vérifié en production : cinq 401 puis 429 avec son Retry-After.

Le troisième compteur ne figurait pas dans ce rapport, et c'est lui qui tient. La recommandation ci-dessous disait « par IP et par adresse » ; en l'écrivant, il est apparu que request.ip est lu depuis X-Forwarded-For — donc choisi par l'appelant (constat A16). Un plafond par source seul se contourne en changeant d'en-tête à chaque requête. Le plafond global est la seule clé que personne ne choisit, et donc le seul qui borne réellement ce qu'Argon2id coûte à la machine.

Et les parcours ont trouvé un défaut de conception dans le premier jet : une connexion réussie consommait les compteurs de source et global, si bien que trente-sept connexions légitimes depuis une machine épuisaient la porte. Une réussite rembourse désormais une place — une seule, jamais le plafond.

POST /admin/v1/session accepte un nombre illimité de tentatives d'adresse et de mot de passe. La limitation de débit existe et fonctionne bien, mais elle est appliquée uniquement sur /v1/* et indexée par clé d'API — c'est-à-dire sur la seule porte qui exige déjà un secret pour être atteinte. La porte qui n'en exige pas est la seule des deux à ne rien avoir devant elle.

Deux dommages distincts, et le second est le moins évident :

  1. Force brute sur les mots de passe. Ce que la porte ouvre n'est pas mince : le contenu des organisations dont le compte est membre (§7), l'émission et la révocation de clés d'API, la suspension d'une organisation cliente, et — depuis l'écran d'exploitation — la location d'une carte graphique facturée à la minute.
  2. Épuisement de ressources, non authentifié. Chaque tentative déclenche une vérification Argon2id qui alloue 19 Mio et coûte 26 ms de processeur (mesuré). Un attaquant n'a besoin d'aucun identifiant valide pour en déclencher autant qu'il veut, sur une VM de 10 Go qui héberge aussi Notula — et qui est déjà tombée deux fois le 20/09 sous pression mémoire. La défense contre le vol de mot de passe est ici, telle quelle, un levier de déni de service.

Ce qui atténue, et qu'il ne faut pas confondre avec une protection : les comptes sont peu nombreux et leurs adresses ne sont pas publiques, il n'y a pas de création de compte en libre-service, et un reverse-proxy se trouve en amont. Mais aucune de ces trois choses n'est un contrôle que le dépôt possède, et la troisième est une hypothèse que je n'ai pas pu vérifier (voir Q1).

Ce que je recommande, et dans cet ordre. Une fenêtre glissante par adresse IP et par adresse courriel — les deux, parce que l'une seule se contourne par distribution et l'autre seule par rotation d'adresse. Le service src/services/rateLimit.ts existe déjà et prend une clé arbitraire : c'est une quinzaine de lignes de route, pas une construction. Un délai croissant après trois échecs sur la même adresse coûte encore moins et suffit à rendre la force brute inutile.

Attention à ne pas casser deux choses en le faisant. Le refus doit rester 401 INVALID_CREDENTIALS — un 429 distinct sur une adresse existante recréerait l'oracle d'énumération du constat A2. Et la limitation doit compter avant l'appel à Argon2, sinon elle ne protège pas du second dommage.

Vérifié : src/routes/admin/session.ts:74 — aucune limitation ; src/routes/v1/index.ts:106 — la limitation n'existe que côté intégration. Mesuré : 26,1 ms par vérification Argon2id, paramètres de production.

4. Constats — criticité moyenne

Sept en tout sur la journée, dont cinq corrigés et un à moitié. Restent ouverts : A16, et la moitié d'A5 qui attend une route de lecture.

corrigé A2 — La connexion révélait par le temps si une adresse existe

Corrigé et déployé le 21/09/2026. Un condensat leurre, tiré du CSPRNG au premier usage et jamais écrit nulle part, est vérifié quand l'adresse est inconnue : les deux chemins paient exactement le même Argon2id. Il ferme au passage une troisième classe que ce constat n'avait pas nommée — le compte invité qui n'a jamais posé de mot de passe, dont le condensat nul court-circuitait lui aussi.

La boîte de contrat qui le garde mesure un temps plutôt que d'espionner un appel, parce que ce qui est affirmé est un coût et non une invocation. Le seuil est loin des deux valeurs qu'il sépare, donc elle ne peut ni passer au vert un matin lent ni au rouge un matin rapide.

Dans src/routes/admin/session.ts, le court-circuit de l'opérateur || fait que passwordMatches n'est pas appelé du tout quand l'adresse est inconnue. Et même appelé, il rend false immédiatement si le condensat stocké est nul, sans exécuter Argon2.

Conséquence : une adresse inconnue répond en ≈ 1 ms, une adresse connue avec un mauvais mot de passe en ≈ 27 ms. L'écart est d'un ordre de grandeur et parfaitement mesurable à distance. Un attaquant énumère ainsi les comptes de la console, ce qui rend le constat A1 sensiblement plus rentable.

L'ironie mérite d'être relevée, parce qu'elle indique où est l'angle mort. Les trois lignes qui suivent immédiatement ont été écrites pour éviter exactement ce défaut : « Vérifié après le mot de passe, pas avant : refuser plus tôt répondrait plus vite pour un compte suspendu que pour un mot de passe faux, et une différence de temps est un oracle comme un autre. » Le raisonnement est juste ; il n'a pas été appliqué un cran plus haut, là où le compte n'existe pas du tout.

Correction : vérifier un condensat factice lorsque l'adresse est inconnue, pour que les deux chemins paient le même Argon2. Une constante de condensat leurre engendrée au démarrage suffit ; c'est trois lignes.

Vérifié : src/routes/admin/session.ts:78 et src/auth/passwords.ts:42. Mesuré : 26,1 ms contre un chemin sans appel.

corrigé A3 — Aucune limite de mémoire sur les conteneurs

Corrigé et déployé le 21/09/2026. Un plafond par service, dimensionné sur des relevés de production — api 113 Mio, worker 238 Mio, superviseur 108 Mio, base 36 Mio, tunnel 8 Mio — avec deux à quatre fois cette mesure de marge, parce qu'un plafond ajusté au repos tue au premier pic.

Mais la conclusion de ce constat était fausse sur un point, et la mesure l'a dit. Il annonçait qu'un plafond permettrait de relever le moteur dégradé. Levé pour vérifier, celui-ci se stabilise à 3,85 Gio de mémoire anonyme, modèle chargé, sans rien transcrire — et non « un peu plus de deux gigaoctets » comme le disait la documentation depuis la veille. Il est donc redescendu.

Ce que les plafonds achètent tient : un dépassement tue le conteneur fautif et non l'hôte. Ce qu'ils n'achètent pas, c'est de rendre abordable un service qui coûte le tiers de la machine en permanence.

Et le moteur dégradé est revenu le même jour, une fois A17 corrigé. Ce n'est pas le plafond qui l'a permis mais l'espace d'échange : le chargement des modèles pousse contre les 4 Gio, le noyau récupère au lieu de tuer — 8 113 recyclages, zéro oom_kill —, le conteneur se stabilise à 4,2 Gio dont 0,86 en swap, puis se tait. La plateforme l'a vu : mode: degraded, servesTranscription: true. La plateforme a de nouveau deux chemins de transcription, ce qu'elle avait perdu le 20/09.

Un piège trouvé en réglant ceci, et qui vaut pour toute la configuration : compose.override.yaml n'est pas suivi par git et repose son propre mem_limit. Modifier la valeur dans le fichier suivi n'a donc aucun effet sur le déploiement — constaté en la passant à 5 Gio et en lisant 4 Gio dans le cgroup.

Ni compose.yaml ni l'override ne déclarent mem_limit, cpus ou une section deploy.resources. Un seul conteneur peut donc consommer toute la VM.

Ce n'est pas théorique : c'est arrivé deux fois le 20/09/2026. La machine est tombée sous pression mémoire, et la mesure prise en réponse a été de retirer le moteur dégradé du démarrage automatique — ce qui a coûté un vrai filet (il n'y a plus qu'un seul chemin de transcription) pour un problème que trois lignes de limites auraient borné. La VM porte dix gigaoctets et deux applications complètes.

Correction : une limite par service, dimensionnée sur l'observation plutôt que devinée, et un restart: unless-stopped déjà présent qui fera le reste. C'est aussi ce qui permettrait de relever le moteur dégradé sans reprendre le risque, et donc de rendre à la plateforme le chemin lent qu'elle a perdu.

Vérifié : aucune occurrence de limite de ressources dans les deux fichiers compose. Preuve : deux redémarrages de la VM le 20/09/2026.

corrigé A4 — Une violation de contrainte d'unicité devenait un 500

Corrigé le 21/09/2026. src/db/violations.ts traduit un P2002, dans le seul arbre qui importe Prisma — un service qui aurait dû reconnaître une classe d'erreur de Prisma aurait percé la frontière du §5.

Nommé sur l'index, pas sur « une violation quelconque » : cette table n'a qu'une contrainte unique aujourd'hui, et un appelant qui avalerait la deuxième avalerait quelque chose que personne n'a pensé. Le magasin rend désormais reserved ou taken ; sur taken, le service relit et rejoue — le gagnant a peut-être déjà attaché un job, ou pas, et c'est cette seconde lecture qui fait une course qui se résout plutôt qu'une branche qui devine.

Aucun endroit du code ne rattrape P2002. Toute course sur un index unique remonte donc jusqu'au gestionnaire générique et devient INTERNAL_ERROR.

Le cas le plus gênant est l'idempotence elle-même, et c'est ce qui fait la sévérité. src/services/idempotency.ts lit puis écrit (findFirst suivi de create) : deux requêtes simultanées portant la même Idempotency-Key passent toutes deux la lecture, et la seconde échoue en 500. Or la simultanéité est précisément le cas pour lequel l'idempotence existe — un client qui ré-émet sur délai d'attente réseau. Le contrat promet « la même clé rend le même résultat » ; sous concurrence réelle, elle rend une erreur serveur.

Les mêmes courses existent, moins gravement, sur Organisation ↔ email (EMAIL_ALREADY_EXISTS est vérifié avant, donc rattrapable) et sur l'index (jobId, status) de WebhookDelivery.

Correction : traiter P2002 comme un fait normal plutôt que comme un accident. Sur l'idempotence, la violation est la réponse — elle signifie « quelqu'un d'autre a réservé cette clé », donc on relit et on rejoue. Un utilitaire partagé dans src/db/ qui traduit P2002 en un refus nommé vaut mieux qu'un try par site d'appel.

Vérifié : aucune occurrence de P2002 ni de PrismaClientKnownRequestError dans src/ ; contrainte @@unique([organisationId, key]) présente en base.

corrigé A5 — Le journal des lectures ne se lisait nulle part et ne s'élaguait jamais

Le §7 des instructions du dépôt promet : « Une lecture de contenu est journalisée — qui, quel appel, quand. C'est ce qui reste quand la confiance ne suffit plus. » La table ContentRead existe et est bien écrite à chaque lecture (src/routes/admin/jobs.ts:279). Deux choses manquent autour.

Personne ne peut la lire. Aucune route d'administration, aucun écran de console ne la consulte. « Ce qui reste » n'est donc atteignable qu'en ouvrant un psql sur la base de production. Le jour où un client demande « qui a lu la transcription de mon appel », la réponse existe et n'est pas servable — et c'est exactement la promesse que le §7 dit pouvoir faire « sans réserve » à un tiers.

Et elle grossit sans fin. Toutes les autres tables ont une règle de rétention — résultats à 30 jours, DailyUsage à 13 mois, MachineWindow à 7 jours, médias à 48 h après un échec. ContentRead n'apparaît nulle part dans src/services/maintenance.ts. Elle accumule indéfiniment des couples utilisateur nommé ↔ appel client ↔ horodatage, c'est-à-dire de la donnée personnelle sur les administrateurs, sans durée. Une plateforme qui borne tout le reste à la journée ou au mois garde ceci pour toujours.

Les deux moitiés sont corrigées le 21/09/2026. Le registre est élagué à treize mois — la durée de DailyUsage, plutôt qu'un nombre neuf — et il se lit par GET /admin/v1/jobs/{job_id}/reads.

Réservé à l'admin membre de l'organisation, et refusé au super-administrateur non membre exactement comme la transcription l'est. Refuser les deux est ce qui rend la première promesse crédible : un registre d'accès lisible par quelqu'un que le contenu ne regarde pas serait une seconde porte sur la même information.

Et lire le registre ne l'alimente pas — un registre qui enregistrerait sa propre consultation grossirait d'être lu, ce qui est la seule façon de le rendre inutile.

Vérifié : ContentRead écrite en un point, lue en aucun ; absente de maintenance.ts.

corrigé A6 — Les deux services qui dépensent de l'argent n'avaient aucun test

Corrigé le 21/09/2026 : neuf boîtes dans tests/unit/machines.test.ts, toutes contre l'adaptateur fictif, qui ne louent rien — c'est précisément ce que le port Compute existe pour permettre. Les quatre refus du tableau ci-dessous sont affirmés un par un, la retenue comprise et dans les deux sens.

Et le premier jet de ces tests portait le défaut du §13 : le jeu d'essai affirmait sa forme avec as unknown as, s'est trompé sur quatre noms de champs, et a compilé. L'échec est arrivé à l'exécution. Le cast est retiré — un cast sur un jeu d'essai est une doublure qui ne s'accorde avec rien.

src/services/raiseMachine.ts et src/services/stopMachine.ts portent quatre refus qui protègent chacun quelque chose de concret :

RefusCe qu'il empêche
MACHINE_BUSYdétruire une carte en train de transcrire — le travail et les minutes payées sont perdus, et le superviseur en relouera une
MACHINE_ALREADY_UPcréer une seconde carte facturée alors que l'état demandé est déjà vrai
MACHINE_AMBIGUOUSchoisir au hasard entre deux machines de même nom, donc peut-être tuer celle qui travaille
HOLD_MSqu'une carte levée à la main soit moissonnée pendant le chargement du modèle — le défaut que B12 a payé

Aucun test du dépôt ne nomme l'un de ces quatre. Les boîtes de contrat qui couvrent ces routes n'affirment que l'autorisation — « seul un super admin peut demander », « seul un super admin peut arrêter ». Le comportement, lui, n'est affirmé nulle part : ni la retenue, ni le refus sur travail en cours, ni l'idempotence par l'état.

Pourquoi ça pèse plus qu'un trou de couverture ordinaire : ces deux fonctions ont un effet irréversible et facturé, elles sont récentes, et le port Compute existe précisément pour qu'elles soient testables sans appeler un fournisseur. Le coût du test est donc faible et le coût de l'absence est en euros.

Vérifié : aucune occurrence de HOLD_MS, MACHINE_BUSY ni MACHINE_ALREADY_UP dans tests/.

corrigé A16 — request.ip était écrit par l'appelant

Corrigé le 21/09/2026, et le constat a été démontré avant de l'être. Une requête portant X-Forwarded-For: 1.2.3.4, envoyée depuis l'internet, était journalisée par la production comme remoteAddress: 1.2.3.4. Ce n'était donc pas une hypothèse.

Le nombre de sauts a été mesuré, pas deviné — c'était la question ouverte Q1. À l'origine, l'en-tête vaut <ce que l'appelant a écrit>,<la vraie adresse, ajoutée par Cloudflare>, et le pair de socket est le conteneur du tunnel. Un seul saut de confiance tombe donc sur la seule entrée de la liste qu'un appelant ne choisit pas. Vérifié après déploiement : la même requête forgée est désormais journalisée à la vraie adresse.

Le jour où un intermédiaire est ajouté devant, ce nombre est faux d'exactement un, et rien ne le dira — le symptôme serait une adresse qui appartient au nouvel intermédiaire. C'est écrit dans le code à côté du réglage.

src/app.ts posait trustProxy: env.NODE_ENV === 'production', c'est-à-dire true — « fais confiance à tous les intermédiaires ». Fastify résout alors request.ip depuis X-Forwarded-For en prenant l'entrée la plus à gauche, qui est celle que le client a écrite. L'adresse que la plateforme croit être la source est donc choisie par la source.

Deux conséquences, et la première n'est pas celle qu'on attend :

  1. Tout compteur indexé sur l'IP est contournable en changeant l'en-tête à chaque requête — et son espace de clés devient illimité par la même occasion, ce qui en fait un levier de consommation mémoire plutôt qu'une protection. C'est la raison pour laquelle le compteur de connexions de A1 a un troisième plafond, global : c'est le seul dont la clé n'est pas choisie par celui qu'il compte.
  2. Toute adresse journalisée est déclarative. Le remoteAddress des lignes de journal ne dit pas d'où venait un appel, il dit ce que l'appel prétendait. Personne n'en dépend aujourd'hui, et c'est exactement pour ça qu'il faut l'écrire avant que quelqu'un s'y fie.

Ce qui atténue : l'API n'écoute que sur la boucle locale, derrière un reverse-proxy, donc l'en-tête arrive par un chemin contrôlé — mais rien n'empêche un client d'en envoyer un que le proxy complétera au lieu de le remplacer.

Correction : remplacer true par le nombre de sauts réels, ou par le réseau du proxy. La difficulté est qu'il faut connaître ce nombre, et se tromper coûte cher dans les deux sens — trop bas, l'adresse devient celle du proxy pour tout le monde ; trop haut, on revient ici. À faire en regardant la chaîne réelle, ce que cet audit n'a pas fait (voir Q1). Le plafond global de A1 rend ce travail non urgent, ce qui est précisément pourquoi il a été écrit comme ça.

Vérifié : src/app.ts:97. Découvert en écrivant la correction de A1, pas pendant l'audit.

corrigé A17 — La machine n'avait aucun espace d'échange

Corrigé le 21/09/2026, par l'exploitant, et vérifié : Swap: 4095 0 4095, vm.swappiness = 10, /swapfile en -rw------- root, et la ligne de fstab en place — donc il survivra au prochain redémarrage, ce qui est la moitié de l'affaire que l'on oublie.

Ce que ça change concrètement : ce qui vit hors conteneur — une construction d'image, au premier chef — a désormais quelque part où déborder. Les plafonds du lot 1 bornaient les conteneurs et ne pouvaient rien pour elle ; c'est pourtant elle qui a fait tomber la machine le 20/09.

Ce que ça ne change pas : quatre gigaoctets ne sont pas devenus gratuits. Un espace d'échange transforme un arrêt brutal en ralentissement — un bien meilleur échec, pas de la capacité en plus.

free -m sur l'hôte, le 21/09/2026 : Swap: 0 0 0. La VM porte dix gigaoctets, deux applications complètes, et rien derrière elle.

C'est la cause profonde de A3, et elle survit à sa correction. Sans espace d'échange, un pic de mémoire ne ralentit pas la machine : le noyau tue un processus, et rien ne garantit qu'il choisisse le fautif. Les plafonds posés le 21/09 bornent ce qu'un conteneur peut prendre — ils ne donnent aucune marge à ce qui vit hors conteneur, et c'est un docker build, qui n'est borné par aucun d'eux, qui a fait tomber la machine le 20/09.

Et c'est ce qui rend le moteur dégradé inabordable aujourd'hui. Avec 3,85 Gio résidents et aucune marge, le relever revient à parier qu'aucune construction d'image n'aura lieu pendant qu'il tourne.

Correction — quatre commandes, à passer sur l'hôte. Elles demandent sudo, donc elles reviennent à l'exploitant :

sudo fallocate -l 4G /swapfile && sudo chmod 600 /swapfile
sudo mkswap /swapfile && sudo swapon /swapfile
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab
echo 'vm.swappiness=10' | sudo tee /etc/sysctl.d/99-swappiness.conf && sudo sysctl --system

La troisième ligne est celle qu'on oublie : sans elle, l'espace d'échange disparaît au prochain redémarrage et le problème revient sans prévenir. La quatrième dit au noyau de ne s'en servir qu'en dernier ressort — un swappiness par défaut ferait paginer Postgres alors qu'il y a de la mémoire libre, ce qui échange une panne rare contre une lenteur permanente.

Ce que ça ne fait pas : rendre quatre gigaoctets gratuits. Un espace d'échange transforme un arrêt brutal en ralentissement, ce qui est un bien meilleur échec — pas en capacité supplémentaire. Vérifier après coup avec free -m : la ligne Swap doit annoncer 4096.

Vérifié : free -m sur l'hôte, 21/09/2026. Hors du périmètre annoncé — l'infrastructure hôte n'a pas été auditée — et retenu quand même, parce que A3 y mène directement.

5. Constats — criticité faible

corrigé A7 — Les instructions affirment une protection CSRF qui n'existe pas

Corrigé le 21/09/2026. Le §8 écrit désormais que les deux portes sont exemptes par construction, et nomme ce qui rouvrirait la question : mettre le jeton de session dans un cookie.

Le §8 de CLAUDE.md écrivait « CSRF sur toutes les mutations d'administration » dans une liste introduite par « Chaque point a un test ». Il n'y a ni implémentation ni test, et le commentaire de src/routes/admin/index.ts:118 le dit à demi-mot : la protection « arrive avec le front qui en a besoin ».

Et elle n'en a pas besoin — c'est le point important. La console s'authentifie par Authorization: Bearer depuis sessionStorage, sans aucun cookie et sans credentials: 'include' : un navigateur n'envoie rien de lui-même, donc CSRF est structurellement impossible, exactement comme sur l'API d'intégration que le §8 déclare exempte « par construction ».

Le défaut n'est donc pas une faille, c'est une fausse assurance dans un document de sécurité — le genre de phrase sur laquelle on s'appuie pour ne pas regarder. Correction : écrire que les deux portes sont exemptes par construction, et dire ce qui la rouvrirait (passer le jeton en cookie).

corrigé A8 — Les commandes privilégiées en ligne de commande n'ont aucun test

Corrigé le 21/09/2026 : sept boîtes qui exécutent les commandes au lieu de les importer — ce qui couvre l'analyse des arguments et les codes de sortie, et correspond à ce qu'un exploitant tape réellement.

Une de mes affirmations était fausse et le code avait raison : une boîte posait que user:create ne pose aucun mot de passe. Elle en engendre un et l'affiche une fois — parce qu'elle exige un shell sur le serveur, qui est la barre plus haute que la décision B22 nomme comme ce qui rend l'arbitrage acceptable. La boîte épingle désormais cette distinction au lieu de la contredire.

adminPromote (qui accorde ou retire le drapeau de super-administrateur), orgCreate, userCreate, catalogueSeed et templateTry ne sont couverts par aucun test. Ce sont les seules voies par lesquelles un droit global s'octroie, et elles s'exécutent sur la production.

Le risque n'est pas qu'elles soient contournables — elles exigent un accès au serveur — mais qu'une régression silencieuse y passe : une inversion de drapeau, une promotion qui ne révoque pas. Correction : un test par commande sur la base de test, du même type que ceux qui couvrent déjà pod.ts.

assumé A9 — Une clé d'API de production est conservée dans le navigateur

Assumé, et conservé tel quel. C'est le seul des dix-sept constats qui n'appelle pas de correction : l'alternative — recréer une clé à chaque essai — est pire, et la CSP stricte est la vraie atténuation. À relire le jour où la CSP s'assouplirait, parce que c'est la prémisse et non la conclusion qui pourrait changer.

L'écran d'essai d'appel garde la clé saisie dans sessionStorage (broulala.test-call-key). Le choix est délibéré, documenté et raisonné — il évite qu'on « crée une nouvelle clé à chaque essai », ce qui serait pire.

Il reste qu'un secret d'intégration vit alors dans le stockage du navigateur, à côté du jeton de session : une XSS future exfiltrerait les deux. La CSP stricte rend cette XSS difficile, ce qui est la vraie mitigation. À conserver tel quel, mais à réexaminer le jour où la CSP s'assouplirait — c'est le genre d'arbitrage dont la prémisse peut changer sans que personne ne relise la conclusion.

corrigé A10 — Aucune limite d'erreur React : une faute de rendu vide la console

Corrigé le 21/09/2026. Une limite d'erreur à la racine, hors de App pour qu'elle tienne encore quand c'est App qui a échoué. Elle affiche ce qui s'est passé et un bouton de rechargement, et n'envoie rien nulle part : une erreur de rendu porte les propriétés sur lesquelles elle a échoué, ce qui sur cette console peut être une transcription.

Aucun ErrorBoundary dans web/src/. Une exception dans un rendu démonte l'arbre entier et laisse une page blanche, sans message ni moyen de revenir. Pour une console d'exploitation, « écran blanc » est le pire diagnostic possible : il ne dit pas si la plateforme est tombée ou si c'est l'affichage.

Correction : une limite d'erreur à la racine qui affiche l'identifiant de requête et un bouton de rechargement. Une vingtaine de lignes.

documenté A11 — Le quota est un disjoncteur, pas une barrière

Écrit au contrat le 21/09/2026 (§13.1), avec le calcul du dépassement possible et la raison de ne pas le corriger : une barrière stricte demanderait une réservation, donc une transaction sur le chemin chaud de chaque création — payée par tous pour une précision qui n'intéresse que le dernier appel avant le plafond.

Le plafond est lu à l'admission et la consommation écrite à la complétion. Plusieurs jobs soumis dans la même fenêtre passent donc tous le contrôle avant qu'aucun ne compte : le dépassement possible vaut nombre de jobs simultanés × coût unitaire. La limitation par clé le borne, et c'est la nature d'un disjoncteur. À documenter plutôt qu'à corriger — une barrière stricte demanderait une réservation, donc une transaction sur le chemin chaud.

corrigé A12 — Le journal n'a pas de redact, et sérialise les erreurs sans borne

Corrigé le 21/09/2026 : une liste redact sur l'en-tête authorization et sur err.meta, qui nomme les colonnes d'une contrainte échouée et peut citer la valeur qui l'a fait échouer. Une ceinture sous une règle qui tenait déjà.

log.error({ err: error }, 'unhandled error') sérialise l'erreur entière. Le test du corpus marqué couvre le chemin des moteurs fictifs ; une erreur inattendue d'une autre origine — une contrainte Prisma, un analyseur JSON — pourrait porter une valeur d'entrée dans son message. Le risque est résiduel et la discipline actuelle est bonne ; une liste redact explicite sur authorization et err.meta coûterait deux lignes et fermerait la classe.

corrigé A13 — HSTS sans includeSubDomains ni preload

Tranché le 21/09/2026 : includeSubDomains ajouté, preload délibérément non. Tout ce qui vit sous ce domaine est cette plateforme et n'est servi qu'en HTTPS ; en revanche l'apex ne répond pas du tout, et un engagement gravé dans les navigateurs pour des mois ne se prend pas sur un nom qui ne sert rien.

L'en-tête servi était max-age=31536000 seul. Correct et conditionné au HTTPS, donc jamais trompeur en clair — mais un sous-domaine reste attaquable en rétrogradation. À décider selon ce qui vit sous broulala.fr.

documenté A14 — La limitation de débit est en mémoire de processus

Écrit au contrat le 21/09/2026 (§13.1) : une seconde instance autoriserait le double, un redéploiement oublie la fenêtre en cours, et c'est acceptable pour une garde contre les rafales — c'est exactement pourquoi le quota, lui, n'est pas implémenté ainsi.

Les fenêtres vivent dans une Map : elles repartent à zéro à chaque déploiement, et une seconde instance d'API doublerait mécaniquement les plafonds. Sans conséquence aujourd'hui — il y a une instance, et le dépôt refuse Redis à raison — mais c'est une hypothèse à écrire avant qu'elle ne se périme.

corrigé A15 — Durcissement de conteneur incomplet

Corrigé le 21/09/2026 — et la correction a fait tomber la production, ce qui vaut d'être écrit ici plutôt qu'ailleurs.

cap_drop: [ALL] posé sur tous les services a mis la base en boucle de redémarrage sur chmod: /var/lib/postgresql/data: Operation not permitted, et l'API avec elle puisqu'elle en dépend ; puis le tunnel, pour la même raison. L'entrypoint officiel de Postgres démarre en root, ajuste les droits du volume et redescend en utilisateur postgres — une séquence qui demande cinq capacités.

Réparé en nommant les cinq plutôt qu'en annulant le durcissement : CHOWN, DAC_OVERRIDE, FOWNER, SETGID, SETUID sur la base et le tunnel ; tout le reste demeure retiré, CAP_NET_RAW compris. Et les quatre autres services ont été vérifiés au lieu d'être découverts un par un — ils tournent déjà sous un utilisateur non privilégié et n'ajustent rien au démarrage.

Et le tunnel est tombé une seconde fois, une heure plus tard, pour une capacité que la première réparation n'avait pas rendue : CAP_SYS_CHROOT. Le serveur SSH démarrait, annonçait Server listening on 0.0.0.0 port 4444, acceptait les connexions — et tuait chaque session à la séparation de privilèges. Vu d'une machine louée, cela ressemble à un Connection refused, donc au réseau. Signalé par le client, pas par un contrôle.

Le conteneur était « up » tout du long, et c'est le piège. Après la panne de la base, le contrôle passé sur celui-ci a été « redémarre-t-il en boucle ? » — et la réponse était non. Un service qui écoute n'est pas un service qui sert, ce que ce dépôt sait très bien ailleurs : c'est exactement la raison pour laquelle /health existe plutôt qu'une sonde TCP.

Et npm run test:tunnel était vert, correctement : il construit ses propres conteneurs avec les capacités par défaut de Docker, donc il éprouve l'image et la configuration de sshd et ne dit rien de la façon dont le conteneur déployé est lancé. C'est écrit en tête de ce script désormais.

La leçon est celle du rapport lui-même, retournée contre son auteur : « aucun effet fonctionnel attendu » était une supposition, et elle figurait dans le plan d'action ci-dessous. Une capacité retirée ne se lit pas dans un Dockerfile qu'on n'a pas ouvert — ni dans un conteneur dont on a seulement vérifié qu'il tournait.

L'image tourne bien en USER node et les ports sont sur la boucle locale, ce qui est l'essentiel. Manquent security_opt: [no-new-privileges:true], cap_drop: [ALL] et read_only: true — trois lignes par service, sans effet fonctionnel attendu hors du volume d'envois qui doit rester inscriptible.

5 bis. Ce que la réparation a fait apparaître

Deux constats de plus, traités le jour même, et ils ne viennent pas de la revue mais de la journée qui l'a suivie. Ils sont ici plutôt que dilués dans les précédents, parce qu'ils partagent une propriété que les quinze autres n'ont pas : aucun contrôle du dépôt ne pouvait les voir, et c'est ce qui les rend intéressants.

corrigé A18 — Un correctif, deux images, une seule reconstruite

Corrigé le 21/09/2026 : npm run test:images vérifie les deux d'un seul geste. Il lit le tag de l'image louée dans le .env plutôt que de le deviner — un tag qui a dérivé est précisément ce qu'il est là pour attraper — et il rend compte à la fin plutôt que de s'arrêter au premier échec, parce que savoir que les deux sont périmées est un fait différent de savoir qu'une l'est.

Éprouvé contre une image d'avant le correctif, et pas seulement contre une saine : il refuse :7, poursuit jusqu'à la seconde, et sort en 1. Un contrôle qui ne peut pas échouer n'en est pas un.

Ce qui manquait n'était pas un contrôle — test:pod-image était vert et avait été lancé sur la bonne image. C'était de savoir qu'il y avait deux choses à contrôler, et c'est maintenant une commande, écrite là où on lit comment déployer.

Le moteur dégradé et la machine louée partagent pod/sitecustomize.py — le fichier qui rend l'image utilisable du tout, et qui porte depuis le 20/09 le correctif du vocabulaire. Ce sont deux images distinctes. Corriger la source et reconstruire l'une ne reconstruit pas l'autre.

Mesuré le 21/09/2026, et le client l'a payé. L'image du pod a été republiée en :8 le 20 ; celle du moteur dégradé est restée sur une construction de quatre jours. Deux cours portant un transcription.vocabulary sont partis sur le chemin dégradé et ont échoué en PROCESSING_FAILED — pour exactement le défaut qu'on croyait corrigé la veille.

Le contrôle qui aurait tranché en une seconde, et qui n'a été écrit qu'après :

docker run --rm --entrypoint sh broulala-whisper:latest \
  -c "grep -c initial_prompt /opt/broulala/sitecustomize.py"
# 0 avant, 13 après

Ce qui reste ouvert n'est pas l'incident mais la classe. L'image est reconstruite et vérifiée ; rien n'empêche la prochaine modification du correctif partagé de repartir sur la même erreur. npm run test:pod-image prend une image en argument et sait donc vérifier les deux — ce qui manque est que quelqu'un sache qu'il faut le faire deux fois. C'est écrit dans le compose à côté du service, et ce n'est pas un contrôle.

Vérifié : 0 occurrence du correctif dans l'image en service, 13 après reconstruction. Coût : deux appels clients en échec.

documenté A19 — Recréer le tunnel coupe le worker et le superviseur

Écrit dans la procédure de déploiement le 21/09/2026, avec la commande groupée et ce que coûte de l'oublier. Ce n'est pas un correctif de code et ça n'en appelle pas : le partage d'espace réseau est voulu — c'est par les redirections du tunnel que passe le trafic vers les moteurs loués.

Et un depends_on n'y suffirait pas : il ordonne un démarrage, il ne force pas une recréation. Ce qui reste est donc une connaissance d'exploitation, et la seule chose à faire d'une connaissance est de l'écrire là où elle est lue.

worker et supervisor déclarent network_mode: 'service:tunnel' : ils partagent l'espace réseau du conteneur du tunnel, ce qui est exactement ce qu'on veut — le trafic vers les moteurs loués passe par ses redirections locales.

Mais recréer le tunnel seul leur laisse une référence morte. Les deux continuent de tourner, se déclarent en bonne santé, et échouent sur chaque sortie réseau. Mesuré le 21/09/2026 : supervisor: pass failed — fetch failed toutes les vingt secondes, à partir de vingt secondes après un docker compose up -d tunnel. Pendant ce temps plus aucune machine n'est levée ni couchée, et rien ne le dit.

Ce qui le rend vicieux : la commande qui casse est celle qu'on tape pour réparer le tunnel, et le symptôme apparaît ailleurs, sur deux services qu'on n'a pas touchés. Un docker compose up -d complet les recrée et ne pose pas le problème ; c'est le déploiement ciblé qui l'introduit.

Correction : recréer les trois ensemble — docker compose up -d --force-recreate tunnel worker supervisor — et l'écrire là où on lit comment déployer. Un depends_on n'y suffirait pas : il ordonne un démarrage, il ne force pas une recréation.

Vérifié : bascule à 09:56:21 pour un up -d tunnel passé à 09:56:01 ; rétabli par une recréation des deux dépendants.

6. Plan d'action priorisé

L'ordre n'est pas celui de la criticité seule : il tient compte de ce que chaque correction coûte et de ce qu'elle débloque. Trois d'entre elles se font en une demi-journée et ferment l'essentiel du risque ; le reste peut suivre au rythme du dépôt.

Lot 1 — livré le 21/09/2026

Les trois actions sont faites, déployées et vérifiées. Deux d'entre elles ont appris quelque chose en chemin : l'action 1 a fait apparaître A16 et a dû gagner un troisième compteur, et l'action 3 a démenti sa propre justification — un plafond ne rend pas le moteur dégradé abordable, il borne seulement le dégât. C'est la raison pour laquelle ce tableau reste affiché.
#ActionFermeEffort
1 Limiter la connexion administrateur, par IP et par adresse, en comptant avant Argon2. Réutiliser src/services/rateLimit.ts, qui prend déjà une clé arbitraire. A1 ~½ journée
2 Payer un Argon2 leurre sur adresse inconnue, pour égaliser les deux chemins. Condensat factice engendré au démarrage. A2 ~1 heure
3 Poser des limites de mémoire par service dans le compose, dimensionnées sur l'observation. Puis relever le moteur dégradé, dont l'extinction était la mesure de contournement. A3 ~½ journée

Pourquoi ces trois ensemble. Les deux premières se corrigent dans le même fichier et se testent dans la même boîte de contrat — les séparer ferait payer deux fois la relecture. La troisième est le seul point de ce rapport qui a déjà coûté un incident et une fonctionnalité : la plateforme a perdu son chemin lent pour éviter de retomber, et une limite mémoire le lui rend.

Lot 2 — livré le 21/09/2026

Les quatre actions sont faites. L'espace d'échange était le seul point de tout le rapport dont la correction n'était pas du code. Et la course d'idempotence, corrigée ici, était la plus gênante du rapport : le seul garde-fou contre un job en double cédait sous la simultanéité pour laquelle il avait été écrit.

Une moitié reste ouverte et passe au lot 3 : le registre des lectures de contenu est désormais élagué, mais toujours illisible par une route.
#ActionFermeEffort
4 Poser un espace d'échange sur l'hôte — fait le 21/09/2026, et vérifié : 4 Gio actifs, swappiness à 10, persistant au redémarrage. A17 fait
5 Traduire P2002 en refus nommé — fait. Sur l'idempotence, la violation est la réponse : on relit et on rejoue. A4 fait
6 Tester les quatre refus de raiseMachine et stopMachine — fait, neuf boîtes contre FakeCompute, la retenue comprise. A6 fait
7 Élaguer ContentRead — fait, à treize mois, la durée de DailyUsage. A5 (moitié) fait

Lot 4 — livré le 21/09/2026

Les deux actions sont faites. La première a produit une commande — npm run test:images — éprouvée contre une image périmée avant d'être annoncée ; la seconde une ligne de procédure, parce qu'il n'y avait rien à corriger, seulement quelque chose à savoir.
#ActionFermeEffort
16 Écrire la recréation groupée — tunnel worker supervisor — là où se lit la procédure de déploiement, et pas seulement dans un commentaire. C'est la commande qu'on tape pour réparer le tunnel qui casse les deux autres. A19 ~15 min
17 Faire vérifier les deux images par un seul geste. test:pod-image sait déjà en prendre une en argument ; ce qui manque est qu'une modification du correctif partagé impose les deux reconstructions et les deux contrôles. A18 ~1 heure

Lot 3 — livré le 21/09/2026

Les huit actions sont faites. Deux d'entre elles étaient des décisions plutôt que du travail — HSTS et le nombre de sauts devant l'API — et toutes deux ont été tranchées sur une mesure : ce qui vit sous le domaine, et ce que l'en-tête contient réellement à l'origine. La seconde a fait passer A16 de « théorique » à « démontré ».

Et le durcissement des conteneurs a fait tomber la production avant d'être juste. Voir A15.
#ActionFermeEffort
8Rendre le journal d'accès lisible — une route en lecture pour l'admin membre, qui est la personne que le registre protège.A5fait
9Corriger le §8 : les deux portes sont exemptes de CSRF par construction, et dire ce qui la rouvrirait.A7fait
10Une limite d'erreur React à la racine de la console, affichant le request_id.A10fait
11Tester les cinq commandes privilégiées, en commençant par adminPromote.A8fait
12Durcir les conteneurs — no-new-privileges, cap_drop: ALL, read_only où c'est possible.A15fait
13Ajouter redact au journal sur authorization et err.meta.A12fait
14Documenter le quota comme disjoncteur et la limitation en mémoire comme mono-instance — deux hypothèses aujourd'hui vraies et tacites.A11 A14fait
15Décider pour HSTS selon ce qui vit sous le domaine.A13fait

7. Questions ouvertes

Quatre choses que je n'ai pas pu établir depuis le dépôt et la production, et dont deux changent une sévérité.

répondu Q1 — Combien d'intermédiaires y a-t-il devant l'API ?

Mesuré le 21/09/2026, au lieu d'être demandé. Une requête forgée envoyée depuis l'internet, puis le journal de l'origine relu : l'en-tête y vaut <ce que l'appelant a écrit>,<la vraie adresse, ajoutée par Cloudflare> et le pair de socket est le conteneur du tunnel. Un saut de confiance, et c'est ce qui est posé.

La question a changé de raison le 21/09. Elle portait sur l'existence d'une limitation en amont — sans objet maintenant que la plateforme a la sienne. Elle porte désormais sur A16 : corriger trustProxy demande de savoir combien de sauts séparent l'API du client, et se tromper coûte cher dans les deux sens. Le domaine passe par Cloudflare, puis par un tunnel, puis peut-être par un reverse-proxy local — il faut regarder la chaîne réelle, ce que cet audit n'a pas fait.

Ce n'est plus bloquant : le plafond global du lot 1 tient sans dépendre d'un X-Forwarded-For honnête, et c'est délibérément ainsi qu'il a été écrit.

tranché Q2 — Quelle durée pour le journal des lectures ?

Treize mois, la durée de DailyUsage — plutôt qu'un nombre neuf qui dériverait d'elle. Ce sont les deux seuls registres qui survivent au contenu qu'ils décrivent, et la question que celui-ci sert se pose à rythme annuel.

C'est une décision de politique, pas une question technique : ce registre existe pour qu'on puisse répondre « qui a lu quoi » longtemps après. Trop court, il ne protège plus ; trop long, il devient lui-même une collecte. Douze mois s'aligneraient sur DailyUsage ; treize suivraient l'usage comptable. Il faut trancher pour écrire l'action 6.

Q3 — La base de production est-elle sauvegardée de façon cohérente ?

La VM est sauvegardée quotidiennement par proxmox-backup-server. Une image de VM d'un PostgreSQL en fonctionnement est restaurable dans la plupart des cas, mais ce n'est pas la même garantie qu'un pg_dump : la première restaure un état de disque, la seconde un état transactionnel. Je n'ai pas cherché à le vérifier et ce n'était pas dans le périmètre, mais c'est la seule chose de cette liste dont l'échec serait irrattrapable.

écrit Q4 — Une seconde instance d'API est-elle envisagée ?

Toujours sans réponse, et ce n'est plus bloquant : l'hypothèse mono-instance est désormais écrite au §13.1 du contrat d'intégration au lieu d'être vraie et tue. Le jour où une seconde instance apparaît, c'est cette ligne qu'il faut relire.

Si oui, la limitation de débit en mémoire (A14) cesse d'être une note pour devenir un défaut, et l'action 1 doit être écrite en conséquence dès maintenant plutôt que refaite. Si non, l'hypothèse mono-instance doit être écrite quelque part — c'est ce que l'action 13 propose.

Ce que ce rapport ne couvre pas

Audit mené le 21/09/2026 sur la révision 0a9e256 · 24 168 lignes de TypeScript applicatif, 16 447 lignes de tests · 582 tests unitaires, 240 cases de contrat, 37 parcours navigateur.
Mis à jour le 21/09/2026, les trois lots livrés et déployés : quatorze constats corrigés, trois écrits là où on les lit, un assumé, aucun ouvert — les deux derniers produits par la journée d'exploitation qui a suivi la revue, et traités le même jour. 606 tests unitaires, 246 cases de contrat, 37 parcours navigateur, 15 boîtes de purge, 7 boîtes de commandes. A16 et A17 ont été ajoutés en cours de réparation. 606 tests unitaires, 246 cases de contrat, 37 parcours navigateur, 15 boîtes de purge, 7 boîtes de commandes.