Audit
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é.
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
Ce qui a été examiné, ligne à ligne ou par sondage ciblé :
| Zone | Volume | Ce 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.
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.
/admin/v1/* et un jeton de session sur /v1/* sont refusés, et depuis le 20/09 chaque porte refuse dans son propre vocabulaire — une boîte de test lit la charge entière pour qu'un mot de l'autre porte y échoue.storage, transcription, generation, compute, webhook, mailer.assertSuperAdmin répartis sur cinq fichiers de routes ; la console cache des entrées de menu, elle ne décide rien.timingSafeEqual, src/auth/tokens.ts).$executeRawUnsafe, zéro $queryRawUnsafe ; les six requêtes brutes sont toutes des template tags.detail ne porte qu'un code HTTP. Un SSRF y serait donc aveugle et sans valeur.unsafe-inline, vérifiée sur la production — et le front s'y astreint réellement : aucun attribut style, aucun dangerouslySetInnerHTML, aucun eval.Et une propriété de qualité qui mérite d'être chiffrée : sur 24 168 lignes de TypeScript, zéro
anyréel (les six occurrences sont le mot anglais dans des commentaires), zéro assertion non nulle, zéro@ts-ignore, zéroTODO. Les dixeslint-disablesont huitno-consoledans les CLI et deux désactivations typées d'une ligne. C'est rare, et c'est ce qui rend l'audit rapide.
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.
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.
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.
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 :
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.
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.
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.
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.
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.
500src/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.
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.
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.
DailyUsage, plutôt qu'un nombre neuf — et il se lit par
GET /admin/v1/jobs/{job_id}/reads.
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.
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.
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 :
| Refus | Ce qu'il empêche |
|---|---|
MACHINE_BUSY | détruire une carte en train de transcrire — le travail et les minutes payées sont perdus, et le superviseur en relouera une |
MACHINE_ALREADY_UP | créer une seconde carte facturée alors que l'état demandé est déjà vrai |
MACHINE_AMBIGUOUS | choisir au hasard entre deux machines de même nom, donc peut-être tuer celle qui travaille |
HOLD_MS | qu'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.
request.ip était écrit par l'appelantX-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.
<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.
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 :
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.
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.
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.
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).
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.
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.
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.
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.
redact, et sérialise les erreurs sans borneredact 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.
includeSubDomains ni preloadincludeSubDomains 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.
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.
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.
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.
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.
/health existe plutôt
qu'une sonde TCP.
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.
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.
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.
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.
:7, poursuit jusqu'à la seconde, et sort en 1. Un contrôle qui ne
peut pas échouer n'en est pas un.
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.
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.
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.
| # | Action | Ferme | Effort |
|---|---|---|---|
| 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.
| # | Action | Ferme | Effort |
|---|---|---|---|
| 4 | swappiness à 10, persistant au redémarrage. |
A17 | fait |
| 5 | P2002 en refus nommé |
A4 | fait |
| 6 | raiseMachine et stopMachineFakeCompute, la retenue comprise. |
A6 | fait |
| 7 | ContentReadDailyUsage. |
A5 (moitié) | fait |
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.
| # | Action | Ferme | Effort |
|---|---|---|---|
| 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 |
| # | Action | Ferme | Effort |
|---|---|---|---|
| 8 | Rendre le journal d'accès lisible — une route en lecture pour l'admin membre, qui est la personne que le registre protège. | A5 | fait |
| 9 | Corriger le §8 : les deux portes sont exemptes de CSRF par construction, et dire ce qui la rouvrirait. | A7 | fait |
| 10 | Une limite d'erreur React à la racine de la console, affichant le request_id. | A10 | fait |
| 11 | Tester les cinq commandes privilégiées, en commençant par adminPromote. | A8 | fait |
| 12 | Durcir les conteneurs — no-new-privileges, cap_drop: ALL, read_only où c'est possible. | A15 | fait |
| 13 | Ajouter redact au journal sur authorization et err.meta. | A12 | fait |
| 14 | Documenter le quota comme disjoncteur et la limitation en mémoire comme mono-instance — deux hypothèses aujourd'hui vraies et tacites. | A11 A14 | fait |
| 15 | Décider pour HSTS selon ce qui vit sous le domaine. | A13 | fait |
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é.
<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.
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.
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.
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.
compose — pare-feu, noyau, reverse-proxy, TLS en amont — n'a pas été examinée.