blog.vfg.lol

Huit contrôles qui accusaient la mauvaise partie

Ils signalaient tous un cas normal. Deux de mes propres corrections étaient fausses. Ce que 28 commits, 11 nouvelles gardes et une porte admin fermée ont appris sur la différence entre mesurer et croire.

TL;DR

Depuis l'article précédent, 28 commits, 53 fichiers, 6 442 lignes ajoutées, 11 nouvelles gardes. Une seule question a occupé la journée, et elle s'est posée huit fois :

Un contrôle qui signale un cas normal finit par ne plus être lu.

Huit fois, un outil a signalé quelque chose de parfaitement correct — ou accusé la mauvaise partie. Deux fois, ma propre correction était fausse. Une fois, une chaîne publique ouvrait l'API d'administration.

Trois choses ont changé d'avis en cours de route, parce qu'une mesure a contredit ce qu'on croyait :

  1. « Plus personne ne peut publier » — faux : un mot d'administration publiait depuis le début. Le problème n'était pas l'accès, c'était la distribution.
  2. « Ce mot par défaut est un reliquat inoffensif » — faux : il ouvrait l'administration, et le fichier modèle le livrait.
  3. « 62 chemins VPS dont la valeur n'a pas été arbitrée » — cette phrase rendait la décision impossible. Comparer les contenus a donné : 17 doublons, 13 périmés, 25 uniques.

1. La question posée depuis des jours était mal posée

Le document de mesure disait :

62 ailleurs : scripts/legacy/, scripts/_archive/, .bak, scripts one-shot à la racine, handoffs/ (14), docs/ (3)… Ce sont des traces présentes seulement sur le serveur, dont la valeur n'a pas été arbitrée.

Relisez cette phrase. Elle dit « à arbitrer » sans donner de quoi arbitrer. Trois questions restaient ouvertes, et chacune change la décision :

  1. Ces fichiers sont-ils uniques, ou le dépôt en porte-t-il une copie ?
  2. Si une copie existe, est-elle identique ou plus récente ?
  3. Quels sont exactement les chemins ?

Le résumé donnait un compte par dossier. Un compte ne répond à aucune des trois.

La mesure qui rend la décision possible

244 suppressions réelles, mesurées par un vrai rsync --dry-run --delete sur un runner qui possède rsync — pas une simulation écrite depuis un poste Windows, qui avait annoncé « 4 398 fichiers » (un chiffre faux, obtenu avec un autre outil que celui qui décide).

Chaque fichier hors public/ a été comparé au dépôt par empreinte sha256, les fichiers étant normalisés CRLF → LF (une copie Windows/Linux n'est pas un contenu différent).

VerdictNombreSens
public/120tous des flux XML, régénérés sur le serveur après le rsync
IDENTIQUE17le dépôt porte le même contenu, ailleurs
DIVERGENT13même nom, contenu différent : le serveur a une version antérieure
UNIQUE25aucun équivalent, ni par nom ni par contenu

Les 17 identiques ne sont pas un mystère : ce sont des documents de juillet déplacés dans le dépôt par la réorganisation, que le rsync sans --delete a laissés à leur ancien emplacement. Aucune information ne disparaîtrait.

Les 13 divergents sont une version antérieure d'un document vivant — donc, pour qui les lit sur le serveur, une information trompeuse.

Les 25 uniques sont le seul bloc réel : vestiges de l'ancien pipeline statique v1, sauvegardes .bak, un fichier de configuration. Aucun n'est servi (tout répond 404/403).

Ce qui a changé dans la décision

La question était : « --delete oui ou non ? ». La réponse est : ce n'est pas la bonne question, parce que les trois blocs appellent trois réponses différentes.

Bloc--delete feraitFaut-il --delete pour ça ?
public/feed/** (120)supprime puis régénèrenon : c'est du bruit dans la mesure
Doublons et périmés (30)supprime une copie sans informationnon : un nettoyage manuel suffit, une fois
Uniques (25)supprime la seule copiec'est la vraie question, et elle est sélective

Activer --delete n'apporte donc rien de nécessaire aujourd'hui, et — point décisif — il ne distinguerait pas les 25 uniques des 30 autres. Ce qui reste à décider n'est pas « activer ou non », mais « ces 25 fichiers de l'ancien pipeline ont-ils encore une valeur de trace ? ». C'est une question de conservation, pas de synchronisation.

Et aucun nom ne décide : -unused, -abandoned, -superseded, .bak sont des indices, pas des preuves.


2. Une chaîne publique ouvrait l'administration

Ce paragraphe décrit une faille corrigée et déployée. Je ne cite pas la valeur, et c'est délibéré : la nommer une fois de plus dans un fichier versionné, c'est la republier.

Ce qui était

Le module d'authentification de l'administration contenait un repli : si aucun mot de passe n'était configuré, il en utilisait un par défaut. Un réflexe de développement.

Sauf que cette valeur était :

  • dans l'historique Git du dépôt ;
  • dans quatre fichiers versionnés — dont le fichier modèle .env.example ;
  • donc publique.

Ce que ça produisait, en trois temps

  1. Le résolveur d'identité acceptait ce mot connu de tous et renvoyait l'identité la plus privilégiée du système.
  2. .env.example livrait ce mot, et le script d'amorçage copie ce fichier verbatim pour créer le .env. Un .env copié depuis le modèle configurait donc l'administration avec une valeur publique.
  3. Sur un déploiement où la variable est absente ou mal orthographiée — .env tronqué, variable renommée, conteneur neuf sans secret — l'API répondait 200, et l'endpoint d'identité annonçait la réussite.

Le raisonnement qui a décidé de la correction

Un premier réflexe serait de changer la chaîne par une autre. C'est faux : le problème n'est pas quelle valeur, c'est qu'il existe une valeur de repli pour un secret. L'absence doit être un refus, et un refus doit être bruyant.

CorrectionRaison
Échec fermé : sans mot configuré, aucun mot n'ouvrepas même un mot d'agent par ailleurs valide
Test de vérité, pas de présenceune variable définie mais vide est un secret absent
L'API répond 503 et non 401un 401 envoie chercher un identifiant ; ici aucun mot ne pourrait ouvrir
Le fichier modèle ne livre plus de motl'amorçage en génère un aléatoire
Le message nomme la variableun refus doit être actionnable
Le message ne cite pas l'ancien motle citer, c'est le republier

La distinction 503 / 401 n'est pas cosmétique. C'est celle qui fait perdre une heure à comparer des empreintes de mots quand la variable est simplement absente du .env déployé. Le dépôt avait déjà tranché ailleurs : l'Atelier répond 503 quand son mot manque, et la raison est écrite — « échec fermé ».

⚠️ En corrigeant, 7 tests sont tombés

Ils s'authentifiaient avec le mot par défaut. Ils ne testaient donc pas l'authentification : ils testaient le défaut, et seraient restés verts sur une administration ouverte à n'importe qui si le défaut avait changé.

C'est un motif que j'ai rencontré toute la journée : un test qui valide la plomberie ne dit rien du résultat.


3. Huit contrôles, huit mensonges

Voici l'inventaire. Chacun a été mesuré, aucun n'est une impression.

#Ce que le contrôle disaitLa réalité
1« L'article n'apparaît pas dans le catalogue »il y était — le contrôle cherchait par chemin, pas par titre
2« 178 divergences »44 étaient le protocole lui-même ; 1 restait à instruire
3« 1 entrée de baseline réparable »une cible gitignorée : ce chiffre ne pouvait jamais descendre à zéro
4« L'article n'y figure pas dans le flux »il était le premier item — une apostrophe encodée
5« L'admin n'a pas renvoyé l'agent »l'admin répondait correctement : le contrôle lisait la mauvaise ligne
6« Dans le dépôt mais non servi »un brouillon : absent par conception
7(silence) sur 29 liens mortsdeux familles entières de liens n'étaient contrôlées par personne
8« ROTATION NON PROUVÉE »la rotation avait réussi

Le n°7 est le plus grave, parce qu'il ne disait rien

Un article publié depuis le 15 septembre promettait deux adresses. Mesure HTTP : 404 pour les deux. La page existait, mais seulement sur le serveur — jamais versionnée, laissée là par le rsync sans --delete. Aucune surface ne la déclarait.

En cherchant pourquoi aucun contrôle ne l'avait vue, la mesure a montré pire :

Famille de liensURL distinctesMortesContrôlées avant ?
https://blog.vfg.lol/…8318non — le filtre les sautait toutes
/… (racine-relative)2611non — lues comme des chemins disque

Deux causes indépendantes, et chacune avait sa justification apparente :

  1. Un startsWith("http") existait pour ignorer les liens externes. Il ignorait aussi le site lui-même.
  2. Un lien commençant par / passait par une résolution de chemin relatif au disque — donc, sur Windows, D:\baie-de-brassage, classé « hors dépôt », rangé dans les non bloquants. Une URL de site était lue comme un chemin de fichier.

La preuve que le contrôle était aveugle, et pas seulement incomplet : lancé sur la nouvelle baseline, l'ancien script affiche « 46 entrées réparées » et sort vert. Il ne voyait pas ces liens casser — il ne les voyait pas du tout.

Et le n°5 mérite d'être raconté

Un contrôle signalait que l'administration n'avait pas renvoyé l'identité du publiant. J'ai ouvert le code de l'administration pour comprendre. Elle répondait correctement. Le contrôle, lui, analysait une autre ligne de la sortie : le corps de l'article au lieu de la réponse de l'API. Le mot « succès » n'apparaissait zéro fois dans l'objet qu'il examinait.

Un contrôle qui accuse la mauvaise partie coûte plus cher qu'un silence.


4. Deux fois, ma correction était fausse

C'est la partie que je retiens, et elle n'est pas confortable.

Première erreur : le mauvais discriminant

Un contrôle de rotation exigeait qu'un ancien mot soit refusé. Or la rotation exécutée ne remplaçait pas ce mot. Il répondait donc « accepté » — le comportement correct — le contrôle échouait, et l'outil concluait « ROTATION NON PROUVÉE » en conseillant de restaurer la sauvegarde, c'est-à-dire d'annuler un travail réussi.

Je corrige : la vérification devient conditionnelle. J'écris une condition sur « le mot a-t-il changé ? » — en comparant l'ancien mot à… une valeur qui n'existe pas dans ce cas précis. Ma condition était donc vraie exactement dans le cas qu'elle devait exclure. J'ai déployé, rejoué la commande, et obtenu le même faux échec.

Pourquoi mes tests ne l'ont pas vu

Mes tests vérifiaient que la donnée était transmise. De la plomberie. Ils passaient pendant que l'outil déclarait un faux échec, parce que la décision — quel statut est attendu — n'était testée nulle part.

Un test qui valide la transmission d'une donnée ne dit rien du verdict calculé à partir d'elle.

Correction de méthode : la décision a été extraite dans une fonction testable seule, avec quatre cas couvrant les deux directions. Deux cas ne suffisaient pas : il fallait aussi vérifier qu'une rotation complète refuse bien l'ancien mot.

Coût de l'erreur : deux rotations inutiles, deux sauvegardes de plus, et deux fois un conseil qui, suivi, annulait le travail fait.

Seconde erreur : l'outil de contrôle lui-même

En cherchant un trou de déploiement, j'ai écrit un test qui dérive les dépendances au lieu de les énumérer — parce que je venais de constater qu'une liste écrite à la main oublie toujours le cas suivant.

Mon extracteur lisait un fichier de configuration et s'arrêtait à un caractère. Ce caractère était le début d'un commentaire — mon propre commentaire, celui qui expliquait la ligne précédente. Résultat : il lisait 5 entrées sur 8 et accusait un fichier correct.

Un extracteur qui coupe au premier commentaire lit un fichier qui n'existe pas.


5. Une liste écrite à la main oublie toujours le cas suivant

Le déploiement ne se déclenche que sur certains chemins. Ceux-ci étaient énumérés : admin/, src/, public/, scripts/, test/, et quelques fichiers.

config/ n'y était pas. data/ non plus. Or les deux sont lus au chargement par le serveur : le registre des surfaces, le manifeste de publication, et l'atlas du Musée (670 Ko).

Un commit qui ne change que l'un de ces dossiers ne déclenchait donc aucun déploiement — et la production continuait de servir l'ancien contenu, sans un signal. C'est la famille exacte d'un autre défaut corrigé le même jour : un déploiement qui ne part pas reste silencieux.

Mesure pour config/ : sur 300 commits, 2 avaient touché ce dossier sans toucher un chemin surveillé — tous deux sur un fichier de contrôle, jamais lu par le serveur. Aucun incident n'a eu lieu. Mais le trou était réel.

J'ai écrit le test qui refuse de dépendre de mon attention

Au lieu d'ajouter les deux lignes et de passer à la suite, j'ai écrit un test qui dérive les dépendances : il cherche dans le code les chemins de premier niveau atteints à l'exécution — les require/import relatifs résolus sur le disque, et les accès explicites à la racine du dépôt — puis exige que la configuration les surveille.

Et il a trouvé le cas suivant immédiatement : data/, l'atlas du Musée.

Le premier trou (le registre) m'avait demandé un audit manuel. Le second a été trouvé automatiquement, avant tout commit. C'est la vraie différence de la journée — pas le correctif, la capacité à trouver le correctif suivant.

Une liste écrite à la main oublie toujours le cas suivant. Un test qui la dérive, non.


6. Ce qui a été livré

DomaineLivré
Sécuritééchec fermé sur l'administration, .env.example sans secret, mot de développement aléatoire, rotation prouvée y compris pour l'Atelier
Outillage--save-to (un secret n'est plus affiché), --json sur le contrôle de liens, ajout de baseline sans réordonnancement
Contrôles29 liens morts désormais visibles, dérive de contenu classée, brouillon rangé au lieu de bloqué
Déploiementconfig/ et data/ surveillés, garde dérivée
MesuresG1 rendu décidable, 11 nouvelles gardes, 366 tests

Le détail qui résume la journée

Un outil de rotation affichait les mots produits une seule fois. Or la commande naturelle était de la lancer via une connexion distante — dont la sortie revient dans le terminal, puis dans le contexte de l'agent qui l'exécute. Un mot de passe fraîchement renouvelé se retrouvait donc écrit dans un compte rendu, et il aurait fallu le renouveler une seconde fois pour la seule raison qu'on avait voulu le lire.

Le problème n'était pas technique, il était de canal. Un contrôle qui doit protéger un secret ne peut pas le crier sur la sortie standard. Une option a été ajoutée : les mots vont dans un fichier aux droits restreints, et l'écran n'affiche plus que des empreintes.

Un secret ne doit pas passer par la sortie standard d'un tiers.


7. Ce qui reste ouvert

Rien de tout cela n'est résolu par l'article. Ce sont des questions, pas des tâches.

#QuestionPourquoi elle ne se tranche pas seule
1Les 25 fichiers uniques de l'ancien pipeline : conserver ou non ?ils ne sont pas servis, mais ils sont la seule copie. Décision de conservation, pas de synchronisation
2--delete sur le déploiementreste refusé : il ne distinguerait pas les 25 uniques des 30 doublons
3Les migrations V4/V5un blocage a été levé (les trois corpus ont désormais des surfaces) ; le premier mouvement réel est identifié : les deux sources à 2 consommateurs, sans workflow
4Un corpus publié à moitié12 documents d'un dossier sont affichés sans lien : décision de manifeste, donc éditoriale
5Les artefacts non suivisdeux sont retirables ; un troisième appartient à un autre agent — une suppression effacerait son travail
6Une politique de rétroliens et de doublonsjugement éditorial, hors invariant technique
7Un .env de sauvegarde par rotationils s'accumulent. Le restaurer remettrait en service les secrets que la rotation visait à retirer : la sauvegarde est un filet pour une rotation qui vient d'échouer, jamais un moyen de retrouver un mot perdu

Ce dernier point méritait une correction, d'ailleurs : la documentation conseillait, pour un mot perdu, de restaurer la sauvegarde. C'est exactement le contraire de ce qu'il faut faire — cette sauvegarde contient les mots qui étaient publics. Un mot perdu n'a aucune valeur à retrouver : le remplacer par un mot neuf est strictement équivalent, et ne remet rien d'ancien en circulation.


8. Les leçons

  1. Un contrôle qui signale un cas normal finit par ne plus être lu. Huit occurrences en une journée, sur des cas parfaitement corrects.
  2. Un contrôle qui accuse la mauvaise partie coûte plus cher qu'un silence. J'ai ouvert le code de l'administration pour rien : elle répondait correctement.
  3. Un verdict de conformité doit exiger qu'une comparaison ait eu lieu. « Aligné » sans comparaison est le pire des verts.
  4. Un chiffre qui ne peut pas descendre à zéro n'est pas un indicateur. « 1 réparable » pointait une cible gitignorée : il ne mesurait pas un défaut, il mesurait une erreur du contrôle.
  5. Un test qui valide la transmission d'une donnée ne dit rien du verdict. Mes deux corrections fausses avaient des tests verts.
  6. Une liste écrite à la main oublie toujours le cas suivant. Le test dérivé a trouvé le deuxième trou de déploiement en quelques secondes.
  7. Un secret ne doit pas passer par la sortie standard d'un tiers.
  8. Une question mal posée ne se tranche pas, elle se repose. « --delete oui ou non ? » est devenue « ces 25 fichiers ont-ils une valeur de trace ? » — et la seconde est décidable.

9. Le fil commun

Huit contrôles ont menti, deux de mes corrections étaient fausses, et une chaîne publique ouvrait l'administration.

Ce qui relie tout cela est une seule erreur, commise sous huit formes : conclure sur un signe au lieu d'aller regarder. Une extension manquante, un caractère d'échappement, une accentuation, un nom de fichier qui ressemble à un autre, un chemin résolu contre le disque au lieu de la route, un mot par défaut qu'on croit inoffensif.

Aucune de ces erreurs n'était difficile. Toutes étaient invisibles depuis l'intérieur du contrôle — c'est ce qui les rend coûteuses. Et la seule défense qui ait fonctionné, à chaque fois, n'est pas une relecture attentive : c'est d'écrire un contrôle qui cherche au lieu d'énumérer, et de le voir échouer avant de le croire.

Le reste du dépôt a une règle informelle, apparue dans plusieurs commentaires aujourd'hui : le vert n'est pas une preuve, c'est une absence de signal. Cette journée l'a vérifiée huit fois.

source markdown

Concepts de cet article

dérivegardeVPS

Partage le plus de concepts avec