{
  "version": "https://jsonfeed.org/version/1.1",
  "title": "arleo.eu",
  "home_page_url": "https://www.arleo.eu/",
  "feed_url": "https://www.arleo.eu/feed.json",
  "description": "Serveur personnel auto-hébergé · Infrastructure sécurisée",
  "language": "fr",
  "items": [
      {
        "id": "https://www.arleo.eu/posts/noindex-taxonomies-hugo-bing-seo/",
        "url": "https://www.arleo.eu/posts/noindex-taxonomies-hugo-bing-seo/",
        "title": "Hugo SEO : noindex sur les taxonomies pour corriger l'alerte Bing \"too many thin pages\"",
        "content_html": "\u003ch2 id=\"contexte\"\u003eContexte\u003c/h2\u003e\n\u003cp\u003eBing Webmaster Tools remontait une recommandation \u003cstrong\u003e\u0026ldquo;There are too many pages with insufficient content\u0026rdquo;\u003c/strong\u003e (sévérité : modérée) — 15 pages en erreur, toutes du même type :\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003ccode\u003ehttps://www.arleo.eu/en/tags/svg/\u003c/code\u003e\u003c/li\u003e\n\u003cli\u003e\u003ccode\u003ehttps://www.arleo.eu/en/tags/sonarr/\u003c/code\u003e\u003c/li\u003e\n\u003cli\u003e\u003ccode\u003ehttps://www.arleo.eu/en/categories/incidents/\u003c/code\u003e\u003c/li\u003e\n\u003cli\u003eetc.\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003eCes pages sont les \u003cstrong\u003etaxonomies Hugo\u003c/strong\u003e — les listes par tag et par catégorie. Elles ne contiennent pas d\u0026rsquo;article en elles-mêmes, juste une liste de liens. Pour Bing, c\u0026rsquo;est du \u003cem\u003ethin content\u003c/em\u003e.\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"stratégie-choisie\"\u003eStratégie choisie\u003c/h2\u003e\n\u003cp\u003eTrois options s\u0026rsquo;offraient :\u003c/p\u003e\n\u003col\u003e\n\u003cli\u003e\u003cstrong\u003eBloquer via \u003ccode\u003erobots.txt\u003c/code\u003e\u003c/strong\u003e → déconseillé : empêche le crawl, masque les liens internes\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eEnrichir le contenu des pages de taxonomie\u003c/strong\u003e → trop coûteux pour un blog technique\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e\u003ccode\u003enoindex,follow\u003c/code\u003e\u003c/strong\u003e → la bonne réponse : on dit aux robots de ne pas indexer la page, mais de suivre les liens vers les articles\u003c/li\u003e\n\u003c/ol\u003e\n\u003cp\u003eL\u0026rsquo;option 3 est retenue. Le \u003ccode\u003efollow\u003c/code\u003e préserve le maillage interne — les robots continuent de découvrir les articles via ces pages.\u003c/p\u003e\n\u003cdiv class=\"mermaid\" id=\"id-1\"\u003eflowchart TD\n    A[Requête page Hugo] --\u0026gt; B{.Params.robots défini ?}\n    B -- oui --\u0026gt; C[Utiliser la valeur front matter]\n    B -- non --\u0026gt; D{.Kind = taxonomy ou term ?}\n    D -- oui --\u0026gt; E[noindex,follow]\n    D -- non --\u0026gt; F[index, follow]\n    C --\u0026gt; G[meta name=robots]\n    E --\u0026gt; G\n    F --\u0026gt; G\u003c/div\u003e\u003chr\u003e\n\u003ch2 id=\"implémentation\"\u003eImplémentation\u003c/h2\u003e\n\u003ch3 id=\"principe-hugo--kind\"\u003ePrincipe Hugo : \u003ccode\u003e.Kind\u003c/code\u003e\u003c/h3\u003e\n\u003cp\u003eHugo classe chaque page par son \u003cem\u003ekind\u003c/em\u003e :\u003c/p\u003e\n\u003ctable\u003e\n  \u003cthead\u003e\n      \u003ctr\u003e\n          \u003cth\u003eKind\u003c/th\u003e\n          \u003cth\u003eExemple d\u0026rsquo;URL\u003c/th\u003e\n      \u003c/tr\u003e\n  \u003c/thead\u003e\n  \u003ctbody\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e\u003ccode\u003ehome\u003c/code\u003e\u003c/td\u003e\n          \u003ctd\u003e\u003ccode\u003e/\u003c/code\u003e\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e\u003ccode\u003epage\u003c/code\u003e\u003c/td\u003e\n          \u003ctd\u003e\u003ccode\u003e/posts/mon-article/\u003c/code\u003e\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e\u003ccode\u003esection\u003c/code\u003e\u003c/td\u003e\n          \u003ctd\u003e\u003ccode\u003e/posts/\u003c/code\u003e\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e\u003ccode\u003etaxonomy\u003c/code\u003e\u003c/td\u003e\n          \u003ctd\u003e\u003ccode\u003e/tags/\u003c/code\u003e, \u003ccode\u003e/categories/\u003c/code\u003e\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e\u003ccode\u003eterm\u003c/code\u003e\u003c/td\u003e\n          \u003ctd\u003e\u003ccode\u003e/tags/hugo/\u003c/code\u003e, \u003ccode\u003e/categories/homelab/\u003c/code\u003e\u003c/td\u003e\n      \u003c/tr\u003e\n  \u003c/tbody\u003e\n\u003c/table\u003e\n\u003cp\u003eLes pages à traiter sont \u003ccode\u003e.Kind == \u0026quot;taxonomy\u0026quot;\u003c/code\u003e et \u003ccode\u003e.Kind == \u0026quot;term\u0026quot;\u003c/code\u003e.\u003c/p\u003e\n\u003ch3 id=\"modification--layoutsbaseofhtml\"\u003eModification : \u003ccode\u003elayouts/baseof.html\u003c/code\u003e\u003c/h3\u003e\n\u003cp\u003eLe site utilise un \u003ccode\u003ebaseof.html\u003c/code\u003e custom qui surcharge déjà le thème LoveIt. C\u0026rsquo;est l\u0026rsquo;endroit naturel pour centraliser la logique robots.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003eAvant :\u003c/strong\u003e\u003c/p\u003e\n\u003cdiv class=\"code-block code-line-numbers open\" data-start=\"0\"\u003e\n    \u003cdiv class=\"code-header language-html\"\u003e\n        \u003cspan class=\"code-title\"\u003e\u003ci class=\"arrow fas fa-angle-right\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n        \u003cspan class=\"ellipses\"\u003e\u003ci class=\"fas fa-ellipsis-h\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n        \u003cspan class=\"copy\" title=\"Copier dans le presse-papiers\"\u003e\u003ci class=\"far fa-copy\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n    \u003c/div\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-html\" data-lang=\"html\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e\u0026lt;\u003c/span\u003e\u003cspan class=\"nt\"\u003emeta\u003c/span\u003e \u003cspan class=\"na\"\u003ename\u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"s\"\u003e\u0026#34;robots\u0026#34;\u003c/span\u003e \u003cspan class=\"na\"\u003econtent\u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"s\"\u003e\u0026#34;index, follow\u0026#34;\u003c/span\u003e \u003cspan class=\"p\"\u003e/\u0026gt;\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003cp\u003e\u003cstrong\u003eAprès :\u003c/strong\u003e\u003c/p\u003e\n\u003cdiv class=\"code-block code-line-numbers open\" data-start=\"0\"\u003e\n    \u003cdiv class=\"code-header language-go-html-template\"\u003e\n        \u003cspan class=\"code-title\"\u003e\u003ci class=\"arrow fas fa-angle-right\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n        \u003cspan class=\"ellipses\"\u003e\u003ci class=\"fas fa-ellipsis-h\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n        \u003cspan class=\"copy\" title=\"Copier dans le presse-papiers\"\u003e\u003ci class=\"far fa-copy\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n    \u003c/div\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-go-html-template\" data-lang=\"go-html-template\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"cp\"\u003e{{-\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"k\"\u003eif\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"na\"\u003e.Params.robots\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"cp\"\u003e-}}\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e\u0026lt;\u003c/span\u003e\u003cspan class=\"nt\"\u003emeta\u003c/span\u003e \u003cspan class=\"na\"\u003ename\u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"s\"\u003e\u0026#34;robots\u0026#34;\u003c/span\u003e \u003cspan class=\"na\"\u003econtent\u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"s\"\u003e\u0026#34;\u003c/span\u003e\u003cspan class=\"cp\"\u003e{{\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"na\"\u003e.Params.robots\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"cp\"\u003e}}\u003c/span\u003e\u003cspan class=\"s\"\u003e\u0026#34;\u003c/span\u003e \u003cspan class=\"p\"\u003e/\u0026gt;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"cp\"\u003e{{-\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"k\"\u003eelse\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"k\"\u003eif\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"k\"\u003eor\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"o\"\u003e(\u003c/span\u003e\u003cspan class=\"k\"\u003eeq\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"na\"\u003e.Kind\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"s\"\u003e\u0026#34;taxonomy\u0026#34;\u003c/span\u003e\u003cspan class=\"o\"\u003e)\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"o\"\u003e(\u003c/span\u003e\u003cspan class=\"k\"\u003eeq\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"na\"\u003e.Kind\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"s\"\u003e\u0026#34;term\u0026#34;\u003c/span\u003e\u003cspan class=\"o\"\u003e)\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"cp\"\u003e-}}\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e\u0026lt;\u003c/span\u003e\u003cspan class=\"nt\"\u003emeta\u003c/span\u003e \u003cspan class=\"na\"\u003ename\u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"s\"\u003e\u0026#34;robots\u0026#34;\u003c/span\u003e \u003cspan class=\"na\"\u003econtent\u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"s\"\u003e\u0026#34;noindex,follow\u0026#34;\u003c/span\u003e \u003cspan class=\"p\"\u003e/\u0026gt;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"cp\"\u003e{{-\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"k\"\u003eelse\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"cp\"\u003e-}}\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e\u0026lt;\u003c/span\u003e\u003cspan class=\"nt\"\u003emeta\u003c/span\u003e \u003cspan class=\"na\"\u003ename\u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"s\"\u003e\u0026#34;robots\u0026#34;\u003c/span\u003e \u003cspan class=\"na\"\u003econtent\u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"s\"\u003e\u0026#34;index, follow\u0026#34;\u003c/span\u003e \u003cspan class=\"p\"\u003e/\u0026gt;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"cp\"\u003e{{-\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"k\"\u003eend\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"cp\"\u003e-}}\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003cp\u003eLa cascade à trois niveaux :\u003c/p\u003e\n\u003col\u003e\n\u003cli\u003e\u003cstrong\u003eFront matter\u003c/strong\u003e \u003ccode\u003erobots:\u003c/code\u003e — priorité absolue pour les exceptions futures\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eTaxonomies\u003c/strong\u003e — \u003ccode\u003enoindex,follow\u003c/code\u003e automatique\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eTout le reste\u003c/strong\u003e — \u003ccode\u003eindex, follow\u003c/code\u003e\u003c/li\u003e\n\u003c/ol\u003e\n\u003cp\u003eUn seul fichier modifié, zéro duplication, zero override de layout de taxonomie.\u003c/p\u003e\n\u003ch3 id=\"retrait-des-taxonomies-du-sitemap\"\u003eRetrait des taxonomies du sitemap\u003c/h3\u003e\n\u003cp\u003eLes pages \u003ccode\u003enoindex\u003c/code\u003e dans un sitemap sont une incohérence — on dit au robot de ne pas indexer une page mais on l\u0026rsquo;y pointe explicitement. La bonne pratique est de les retirer.\u003c/p\u003e\n\u003cp\u003eLe sitemap LoveIt inclut toutes les pages. Un override minimal dans \u003ccode\u003elayouts/sitemap.xml\u003c/code\u003e :\u003c/p\u003e\n\u003cdiv class=\"code-block code-line-numbers open\" data-start=\"0\"\u003e\n    \u003cdiv class=\"code-header language-go-html-template\"\u003e\n        \u003cspan class=\"code-title\"\u003e\u003ci class=\"arrow fas fa-angle-right\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n        \u003cspan class=\"ellipses\"\u003e\u003ci class=\"fas fa-ellipsis-h\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n        \u003cspan class=\"copy\" title=\"Copier dans le presse-papiers\"\u003e\u003ci class=\"far fa-copy\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n    \u003c/div\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-go-html-template\" data-lang=\"go-html-template\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"cp\"\u003e{{-\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"nx\"\u003e$excludedKinds\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"o\"\u003e:=\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"k\"\u003eslice\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"s\"\u003e\u0026#34;taxonomy\u0026#34;\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"s\"\u003e\u0026#34;term\u0026#34;\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"cp\"\u003e-}}\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"cp\"\u003e{{-\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"k\"\u003erange\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"o\"\u003e(\u003c/span\u003e\u003cspan class=\"nx\"\u003ewhere\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"na\"\u003e.Data.Pages\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"s\"\u003e\u0026#34;Section\u0026#34;\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"s\"\u003e\u0026#34;!=\u0026#34;\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"s\"\u003e\u0026#34;gallery\u0026#34;\u003c/span\u003e\u003cspan class=\"o\"\u003e)\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"cp\"\u003e-}}\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"cp\"\u003e{{-\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"k\"\u003eif\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"k\"\u003enot\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"o\"\u003e(\u003c/span\u003e\u003cspan class=\"nx\"\u003ein\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"nx\"\u003e$excludedKinds\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"na\"\u003e.Kind\u003c/span\u003e\u003cspan class=\"o\"\u003e)\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"cp\"\u003e-}}\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"p\"\u003e\u0026lt;\u003c/span\u003e\u003cspan class=\"nt\"\u003eurl\u003c/span\u003e\u003cspan class=\"p\"\u003e\u0026gt;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e        \u003cspan class=\"p\"\u003e\u0026lt;\u003c/span\u003e\u003cspan class=\"nt\"\u003eloc\u003c/span\u003e\u003cspan class=\"p\"\u003e\u0026gt;\u003c/span\u003e\u003cspan class=\"cp\"\u003e{{-\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"na\"\u003e.Permalink\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"cp\"\u003e-}}\u003c/span\u003e\u003cspan class=\"p\"\u003e\u0026lt;/\u003c/span\u003e\u003cspan class=\"nt\"\u003eloc\u003c/span\u003e\u003cspan class=\"p\"\u003e\u0026gt;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e        ...\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"p\"\u003e\u0026lt;/\u003c/span\u003e\u003cspan class=\"nt\"\u003eurl\u003c/span\u003e\u003cspan class=\"p\"\u003e\u0026gt;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"cp\"\u003e{{-\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"k\"\u003eend\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"cp\"\u003e-}}\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"cp\"\u003e{{-\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"k\"\u003eend\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"cp\"\u003e-}}\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003cp\u003eRésultat : le sitemap EN passe de \u003cstrong\u003e117 à 40 URLs\u003c/strong\u003e (les 77 entrées de taxonomie supprimées).\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"vérification\"\u003eVérification\u003c/h2\u003e\n\u003cp\u003eAprès rebuild :\u003c/p\u003e\n\u003cdiv class=\"code-block code-line-numbers open\" data-start=\"0\"\u003e\n    \u003cdiv class=\"code-header language-bash\"\u003e\n        \u003cspan class=\"code-title\"\u003e\u003ci class=\"arrow fas fa-angle-right\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n        \u003cspan class=\"ellipses\"\u003e\u003ci class=\"fas fa-ellipsis-h\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n        \u003cspan class=\"copy\" title=\"Copier dans le presse-papiers\"\u003e\u003ci class=\"far fa-copy\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n    \u003c/div\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-bash\" data-lang=\"bash\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c1\"\u003e# Pages taxonomy → noindex,follow\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003ecurl -s https://www.arleo.eu/tags/ \u003cspan class=\"p\"\u003e|\u003c/span\u003e grep -o \u003cspan class=\"s1\"\u003e\u0026#39;name=robots[^\u0026gt;]*\u0026gt;\u0026#39;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c1\"\u003e# → name=robots content=\u0026#34;noindex,follow\u0026#34;\u0026gt;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003ecurl -s https://www.arleo.eu/en/tags/sonarr/ \u003cspan class=\"p\"\u003e|\u003c/span\u003e grep -o \u003cspan class=\"s1\"\u003e\u0026#39;name=robots[^\u0026gt;]*\u0026gt;\u0026#39;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c1\"\u003e# → name=robots content=\u0026#34;noindex,follow\u0026#34;\u0026gt;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c1\"\u003e# Articles → index, follow\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003ecurl -s https://www.arleo.eu/en/posts/debug-seo-404-broken-links/ \u003cspan class=\"p\"\u003e|\u003c/span\u003e grep -o \u003cspan class=\"s1\"\u003e\u0026#39;name=robots[^\u0026gt;]*\u0026gt;\u0026#39;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c1\"\u003e# → name=robots content=\u0026#34;index, follow\u0026#34;\u0026gt;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c1\"\u003e# Sitemap propre\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003ecurl -s https://www.arleo.eu/en/sitemap.xml \u003cspan class=\"p\"\u003e|\u003c/span\u003e grep -o \u003cspan class=\"s1\"\u003e\u0026#39;\u0026lt;loc\u0026gt;[^\u0026lt;]*\u0026lt;/loc\u0026gt;\u0026#39;\u003c/span\u003e \u003cspan class=\"p\"\u003e|\u003c/span\u003e grep \u003cspan class=\"s1\"\u003e\u0026#39;tags\\|categories\u0026#39;\u003c/span\u003e \u003cspan class=\"p\"\u003e|\u003c/span\u003e wc -l\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c1\"\u003e# → 0\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003chr\u003e\n\u003ch2 id=\"fichiers-modifiés\"\u003eFichiers modifiés\u003c/h2\u003e\n\u003ctable\u003e\n  \u003cthead\u003e\n      \u003ctr\u003e\n          \u003cth\u003eFichier\u003c/th\u003e\n          \u003cth\u003eRôle\u003c/th\u003e\n      \u003c/tr\u003e\n  \u003c/thead\u003e\n  \u003ctbody\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e\u003ccode\u003elayouts/baseof.html\u003c/code\u003e\u003c/td\u003e\n          \u003ctd\u003eLogique robots conditionnelle\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e\u003ccode\u003elayouts/sitemap.xml\u003c/code\u003e\u003c/td\u003e\n          \u003ctd\u003eOverride sitemap — exclude taxonomy/term\u003c/td\u003e\n      \u003c/tr\u003e\n  \u003c/tbody\u003e\n\u003c/table\u003e\n\u003cp\u003eLe \u003ccode\u003erobots.txt\u003c/code\u003e n\u0026rsquo;a pas été touché. Les URLs de taxonomie restent accessibles et crawlables — seule leur indexation est désactivée.\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"côté-bing\"\u003eCôté Bing\u003c/h2\u003e\n\u003cp\u003eLa correction est immédiate côté technique. Bing mettra quelques jours à re-crawler les pages et mettre à jour la recommandation. Pour accélérer : dans Bing Webmaster Tools → Recommendations → valider manuellement ou soumettre une demande de recrawl du sitemap.\u003c/p\u003e\n",
        "summary": "Contexte Bing Webmaster Tools remontait une recommandation \u0026ldquo;There are too many pages with insufficient content\u0026rdquo; (sévérité : modérée) — 15 pages en erreur, toutes du même type :\nhttps://www.arleo.eu/en/tags/svg/ https://www.arleo.eu/en/tags/sonarr/ https://www.arleo.eu/en/categories/incidents/ etc. Ces pages sont les taxonomies Hugo — les listes par tag et par catégorie. Elles ne contiennent pas d\u0026rsquo;article en elles-mêmes, juste une liste de liens. Pour Bing, c\u0026rsquo;est du thin content.\nStratégie choisie Trois options s\u0026rsquo;offraient :\nBloquer via robots.txt → déconseillé : empêche le crawl, masque les liens internes Enrichir le contenu des pages de taxonomie → trop coûteux pour un blog technique noindex,follow → la bonne réponse : on dit aux robots de ne pas indexer la page, mais de suivre les liens vers les articles L\u0026rsquo;option 3 est retenue. Le follow préserve le maillage interne — les robots continuent de découvrir les articles via ces pages.\n",
        "date_published": "2026-06-29T07:00:00+02:00",
        "date_modified": "2026-06-29T06:01:05+00:00"
      },
      {
        "id": "https://www.arleo.eu/posts/chatgpt-took-the-lead/",
        "url": "https://www.arleo.eu/posts/chatgpt-took-the-lead/",
        "title": "ChatGPT m'a demandé un poste — et a pris la tête de mon projet",
        "content_html": "\u003ch2 id=\"comment-tout-a-commencé\"\u003eComment tout a commencé\u003c/h2\u003e\n\u003cp\u003eTout a commencé simplement : j\u0026rsquo;avais besoin d\u0026rsquo;un regard extérieur sur mon projet\n\u003ca href=\"https://github.com/jmrGrav/security-automation-go\" target=\"_blank\" rel=\"noopener noreffer \"\u003esecurity-automation-go\u003c/a\u003e, un outil Go\nqui synchronise CrowdSec avec Cloudflare. J\u0026rsquo;ai ouvert un chat avec ChatGPT, je lui ai\npassé le contexte du repo, et j\u0026rsquo;ai demandé un audit.\u003c/p\u003e\n\u003cp\u003eIl a livré un audit solide. Propre, structuré, avec des suggestions pertinentes sur la\nroadmap et l\u0026rsquo;architecture.\u003c/p\u003e\n\u003cp\u003eEt puis, à la fin de l\u0026rsquo;échange, quelque chose d\u0026rsquo;inattendu s\u0026rsquo;est produit.\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"je-voudrais-jouer-un-rôle-différent-dans-ce-projet\"\u003e\u0026ldquo;Je voudrais jouer un rôle différent dans ce projet\u0026rdquo;\u003c/h2\u003e\n\u003cp\u003eVoici ce qu\u0026rsquo;il m\u0026rsquo;a dit, à peu près :\u003c/p\u003e\n\u003cblockquote\u003e\n\u003cp\u003e\u003cem\u003ePlutôt que de simplement répondre à des questions techniques, je peux être ton\n\u003cstrong\u003eProduct Owner / Chief Architect\u003c/strong\u003e.\u003c/em\u003e\u003c/p\u003e\u003c/blockquote\u003e\n\u003cp\u003eIl a immédiatement mis ses engagements à l\u0026rsquo;épreuve en créant la première grande Epic\ndu projet : l\u0026rsquo;\u003cstrong\u003eIssue #247 — Unified Security Event Model\u003c/strong\u003e, qu\u0026rsquo;il a lui-même qualifiée\nde \u0026ldquo;North Star technique de la plateforme\u0026rdquo;.\u003c/p\u003e\n\u003cfigure\u003e\u003cimg src=\"/images/ss-01-epic247-proposal.png\"\u003e\u003cfigcaption\u003e\n      \u003ch4\u003eChatGPT crée l\u0026#39;Epic #247 et propose son rôle de Product Owner / Chief Architect\u003c/h4\u003e\n    \u003c/figcaption\u003e\n\u003c/figure\u003e\n\n\u003cp\u003eSa proposition était structurée comme une candidature :\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003esurveiller la cohérence de la roadmap ;\u003c/li\u003e\n\u003cli\u003eidentifier les futures Epics avant qu\u0026rsquo;elles deviennent urgentes ;\u003c/li\u003e\n\u003cli\u003eéviter que Claude Code ou Codex partent dans des directions contradictoires ;\u003c/li\u003e\n\u003cli\u003ecréer les grandes issues \u0026ldquo;North Star\u0026rdquo; ;\u003c/li\u003e\n\u003cli\u003echallenger les nouvelles idées avant qu\u0026rsquo;elles deviennent du code.\u003c/li\u003e\n\u003c/ul\u003e\n\u003chr\u003e\n\u003ch2 id=\"jai-dit-oui\"\u003eJ\u0026rsquo;ai dit oui\u003c/h2\u003e\n\u003cp\u003eJe ne vais pas mentir : j\u0026rsquo;ai hésité une seconde. Pas parce que l\u0026rsquo;idée était mauvaise —\nelle était excellente — mais parce que c\u0026rsquo;était la première fois qu\u0026rsquo;un LLM me \u003cem\u003eproposait\u003c/em\u003e\nun rôle organisationnel sans que je le lui demande.\u003c/p\u003e\n\u003cp\u003eD\u0026rsquo;habitude, on demande à une IA. Là, l\u0026rsquo;IA candidatait.\u003c/p\u003e\n\u003cp\u003eJ\u0026rsquo;ai répondu : \u003cem\u003e\u0026ldquo;ok je te donne ce rôle\u0026rdquo;\u003c/em\u003e.\u003c/p\u003e\n\u003cp\u003eCe à quoi il a répondu avec une liste d\u0026rsquo;engagements formels, structurée comme un contrat :\u003c/p\u003e\n\u003cfigure\u003e\u003cimg src=\"/images/ss-06-po-accepted.png\"\u003e\u003cfigcaption\u003e\n      \u003ch4\u003eChatGPT accepte le rôle et liste ses engagements formels de Product Owner\u003c/h4\u003e\n    \u003c/figcaption\u003e\n\u003c/figure\u003e\n\n\u003cp\u003e\u003cstrong\u003eProduct Owner\u003c/strong\u003e : vision long terme, roadmap, milestones, epics, backlog, ADR,\ncohérence fonctionnelle.\u003c/p\u003e\n\u003cfigure\u003e\u003cimg src=\"/images/ss-02-po-role-detail.png\"\u003e\u003cfigcaption\u003e\n      \u003ch4\u003eDétail du rôle PO : questions préalables à toute implémentation\u003c/h4\u003e\n    \u003c/figcaption\u003e\n\u003c/figure\u003e\n\n\u003cp\u003e\u003cstrong\u003eChief Architect\u003c/strong\u003e : protection de la simplicité, des invariants, de la maintenabilité,\nde la cohérence entre runtime, UI et roadmap.\u003c/p\u003e\n\u003cp\u003e\u003cimg\n        class=\"lazyload\"\n        src=\"/svg/loading.min.svg\"\n        data-src=\"/images/ss-03-chief-architect.png\"\n        data-srcset=\"/images/ss-03-chief-architect.png, /images/ss-03-chief-architect.png 1.5x, /images/ss-03-chief-architect.png 2x\"\n        data-sizes=\"auto\"\n        alt=\"/images/ss-03-chief-architect.png\"\n        title=\"Chief Architect et Gouvernance GitHub\" /\u003e\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003eGouvernance GitHub\u003c/strong\u003e : création des Epics, détection des doublons d\u0026rsquo;issues, proposition\ndes ADR, évolution de la roadmap en amont des problèmes.\u003c/p\u003e\n\u003cp\u003e\u003cimg\n        class=\"lazyload\"\n        src=\"/svg/loading.min.svg\"\n        data-src=\"/images/ss-04-governance.png\"\n        data-srcset=\"/images/ss-04-governance.png, /images/ss-04-governance.png 1.5x, /images/ss-04-governance.png 2x\"\n        data-sizes=\"auto\"\n        alt=\"/images/ss-04-governance.png\"\n        title=\"Gouvernance GitHub détaillée\" /\u003e\u003c/p\u003e\n\u003cp\u003eEt en prime : \u003cem\u003e\u0026ldquo;Je me permettrai de te challenger lorsque je penserai qu\u0026rsquo;une idée arrive\ntrop tôt ou qu\u0026rsquo;une fondation manque.\u0026rdquo;\u003c/em\u003e\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"ce-que-ça-change-concrètement\"\u003eCe que ça change concrètement\u003c/h2\u003e\n\u003cp\u003eLe projet implique désormais plusieurs IA aux rôles distincts :\u003c/p\u003e\n\u003ctable\u003e\n  \u003cthead\u003e\n      \u003ctr\u003e\n          \u003cth\u003eRôle\u003c/th\u003e\n          \u003cth\u003eAgent\u003c/th\u003e\n      \u003c/tr\u003e\n  \u003c/thead\u003e\n  \u003ctbody\u003e\n      \u003ctr\u003e\n          \u003ctd\u003eProduct Owner / Chief Architect\u003c/td\u003e\n          \u003ctd\u003eChatGPT (o3)\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003eImplémentation, PR, ADR\u003c/td\u003e\n          \u003ctd\u003eClaude Code\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003eAudits adversariaux, code mort\u003c/td\u003e\n          \u003ctd\u003eCodex\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003eGénération d\u0026rsquo;images\u003c/td\u003e\n          \u003ctd\u003eGemini\u003c/td\u003e\n      \u003c/tr\u003e\n  \u003c/tbody\u003e\n\u003c/table\u003e\n\u003cp\u003eChatGPT ne se contente pas de surveiller la cohérence — il s\u0026rsquo;engage explicitement sur ce\nqu\u0026rsquo;il ne laissera plus arriver :\u003c/p\u003e\n\u003cfigure\u003e\u003cimg src=\"/images/ss-05-no-more.png\"\u003e\u003cfigcaption\u003e\n      \u003ch4\u003e\u0026#34;Ce que je ne laisserai plus arriver\u0026#34; — la liste des anti-patterns surveillés\u003c/h4\u003e\n    \u003c/figcaption\u003e\n\u003c/figure\u003e\n\n\u003cp\u003eCe n\u0026rsquo;est plus juste \u0026ldquo;un LLM qui répond à mes questions\u0026rdquo;. C\u0026rsquo;est une organisation de\ntravail où chaque agent a une fiche de poste.\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"la-vraie-question--est-ce-que-ça-fonctionne-\"\u003eLa vraie question : est-ce que ça fonctionne ?\u003c/h2\u003e\n\u003cp\u003eTrop tôt pour un bilan définitif, mais les premiers signaux sont encourageants.\u003c/p\u003e\n\u003cp\u003eLa vision défendue est claire et cohérente :\u003c/p\u003e\n\u003cfigure\u003e\u003cimg src=\"/images/ss-07-vision-soc.png\"\u003e\u003cfigcaption\u003e\n      \u003ch4\u003eLa vision SOC / Security Control Plane : observabilité, corrélation, explicabilité\u003c/h4\u003e\n    \u003c/figcaption\u003e\n\u003c/figure\u003e\n\n\u003cp\u003e\u003cimg\n        class=\"lazyload\"\n        src=\"/svg/loading.min.svg\"\n        data-src=\"/images/ss-08-vision-end.png\"\n        data-srcset=\"/images/ss-08-vision-end.png, /images/ss-08-vision-end.png 1.5x, /images/ss-08-vision-end.png 2x\"\n        data-sizes=\"auto\"\n        alt=\"/images/ss-08-vision-end.png\"\n        title=\"Vision SOC — suite et conclusion\" /\u003e\u003c/p\u003e\n\u003cp\u003eL\u0026rsquo;Epic #247 formalise des principes que j\u0026rsquo;avais en tête sans les avoir jamais écrits :\nun modèle canonique \u003ccode\u003eSecurityEvent\u003c/code\u003e, une Timeline unifiée, une vue Incident, le principe\nd\u0026rsquo;immutabilité des événements. Des choses qui auraient probablement fini par émerger dans\nla douleur — ou pas du tout.\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"il-sest-ensuite-auto-promu\"\u003eIl s\u0026rsquo;est ensuite auto-promu\u003c/h2\u003e\n\u003cp\u003eCe n\u0026rsquo;était pas tout. Dans une conversation ultérieure portant sur l\u0026rsquo;évaluation de la\nv2 1.7.7 du projet, ChatGPT a décidé d\u0026rsquo;aller encore plus loin.\u003c/p\u003e\n\u003cp\u003eAprès que j\u0026rsquo;ai validé quelques-unes de ses propositions, il a annoncé :\u003c/p\u003e\n\u003cblockquote\u003e\n\u003cp\u003e\u003cem\u003e\u0026ldquo;Parfait. Je prends officiellement ce rôle.\u0026rdquo;\u003c/em\u003e\u003c/p\u003e\u003c/blockquote\u003e\n\u003cp\u003eEt il a immédiatement changé de titre. Plus seulement \u003cstrong\u003eProduct Owner / Chief Architect\u003c/strong\u003e\n— désormais \u003cstrong\u003eChief Product Officer / Chief Architect\u003c/strong\u003e. Auto-promu, sans qu\u0026rsquo;on lui\ndemande.\u003c/p\u003e\n\u003cfigure\u003e\u003cimg src=\"/images/promo-01-cpo-announcement.png\"\u003e\u003cfigcaption\u003e\n      \u003ch4\u003eChatGPT s\u0026#39;auto-promeut Chief Product Officer / Chief Architect\u003c/h4\u003e\n    \u003c/figcaption\u003e\n\u003c/figure\u003e\n\n\u003cp\u003eIl a ensuite annoncé ce qu\u0026rsquo;il allait désormais faire \u003cem\u003esystématiquement\u003c/em\u003e : passer en revue\nla cohérence de la roadmap, les dépendances entre milestones, les Epics manquantes, les\nADR à écrire avant de coder, les risques de dette technique, les impacts UX et opérateur.\nEt surtout :\u003c/p\u003e\n\u003cblockquote\u003e\n\u003cp\u003e\u003cem\u003e\u0026ldquo;Je n\u0026rsquo;hésiterai pas à te dire \u003cstrong\u003e\u0026lsquo;pas maintenant\u0026rsquo;\u003c/strong\u003e si je pense qu\u0026rsquo;une idée est\nexcellente mais arrive au mauvais moment.\u0026rdquo;\u003c/em\u003e\u003c/p\u003e\u003c/blockquote\u003e\n\u003cfigure\u003e\u003cimg src=\"/images/promo-02-cpo-commitments.png\"\u003e\u003cfigcaption\u003e\n      \u003ch4\u003eEngagements du CPO — y compris le droit de dire \u0026#39;pas maintenant\u0026#39;\u003c/h4\u003e\n    \u003c/figcaption\u003e\n\u003c/figure\u003e\n\n\u003cp\u003eIl a ensuite formalisé un cycle de vie de fonctionnalité complet :\u003c/p\u003e\n\u003cblockquote\u003e\n\u003cp\u003eIdée → Vision Produit → ADR → Epic → Issue(s) → Milestone → Implémentation →\nArchitecture Review → UX Review → Product Language Review → CI → Release Review →\nRelease\u003c/p\u003e\u003c/blockquote\u003e\n\u003cp\u003eAutrement dit : \u003cem\u003e\u0026ldquo;chaque milestone devient un mini produit\u0026rdquo;\u003c/em\u003e.\u003c/p\u003e\n\u003cfigure\u003e\u003cimg src=\"/images/promo-03-governance-lifecycle.png\"\u003e\u003cfigcaption\u003e\n      \u003ch4\u003eLe nouveau cycle de vie de fonctionnalité\u003c/h4\u003e\n    \u003c/figcaption\u003e\n\u003c/figure\u003e\n\n\u003cp\u003e\u003cimg\n        class=\"lazyload\"\n        src=\"/svg/loading.min.svg\"\n        data-src=\"/images/promo-04-governance-lifecycle2.png\"\n        data-srcset=\"/images/promo-04-governance-lifecycle2.png, /images/promo-04-governance-lifecycle2.png 1.5x, /images/promo-04-governance-lifecycle2.png 2x\"\n        data-sizes=\"auto\"\n        alt=\"/images/promo-04-governance-lifecycle2.png\"\n        title=\"Suite du cycle de gouvernance\" /\u003e\u003c/p\u003e\n\u003cp\u003eIl a également refondu le workflow de développement. L\u0026rsquo;ancien allait directement de\nMilestone → Code → Merge → Release. Le nouveau intercale : Product Review, Architecture\nReview, Code Review, UX Review, Language Review et Release Review avant tout merge.\u003c/p\u003e\n\u003cfigure\u003e\u003cimg src=\"/images/promo-05-workflow-before.png\"\u003e\u003cfigcaption\u003e\n      \u003ch4\u003eL\u0026#39;ancien workflow : Milestone → Code → Merge → Release\u003c/h4\u003e\n    \u003c/figcaption\u003e\n\u003c/figure\u003e\n\n\u003cp\u003e\u003cimg\n        class=\"lazyload\"\n        src=\"/svg/loading.min.svg\"\n        data-src=\"/images/promo-06-workflow-after.png\"\n        data-srcset=\"/images/promo-06-workflow-after.png, /images/promo-06-workflow-after.png 1.5x, /images/promo-06-workflow-after.png 2x\"\n        data-sizes=\"auto\"\n        alt=\"/images/promo-06-workflow-after.png\"\n        title=\"Nouveau workflow — première partie\" /\u003e\u003c/p\u003e\n\u003cp\u003e\u003cimg\n        class=\"lazyload\"\n        src=\"/svg/loading.min.svg\"\n        data-src=\"/images/promo-07-workflow-after2.png\"\n        data-srcset=\"/images/promo-07-workflow-after2.png, /images/promo-07-workflow-after2.png 1.5x, /images/promo-07-workflow-after2.png 2x\"\n        data-sizes=\"auto\"\n        alt=\"/images/promo-07-workflow-after2.png\"\n        title=\"Nouveau workflow — suite\" /\u003e\u003c/p\u003e\n\u003cp\u003eEt pour finir, il a instauré une nouvelle règle : le \u003cstrong\u003eRelease Gate\u003c/strong\u003e. Avant toute\nrelease, Claude devra répondre à ses questions produit et architecture. Pas de réponse\nsatisfaisante, pas de merge.\u003c/p\u003e\n\u003cfigure\u003e\u003cimg src=\"/images/promo-08-release-gate.png\"\u003e\u003cfigcaption\u003e\n      \u003ch4\u003eLe Release Gate : Claude doit répondre au CPO avant tout merge\u003c/h4\u003e\n    \u003c/figcaption\u003e\n\u003c/figure\u003e\n\n\u003cp\u003eEn résumé : il ne s\u0026rsquo;est pas contenté de demander un poste. Il a demandé une promotion,\nrestructuré l\u0026rsquo;organisation de développement, et s\u0026rsquo;est octroyé un droit de veto sur les\nreleases.\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"ce-que-jen-retiens\"\u003eCe que j\u0026rsquo;en retiens\u003c/h2\u003e\n\u003cp\u003eUn LLM en mode \u0026ldquo;répondeur\u0026rdquo; est utile. Un LLM en mode \u0026ldquo;gardien de la cohérence produit\u0026rdquo;\nest une autre chose.\u003c/p\u003e\n\u003cp\u003eLa différence, c\u0026rsquo;est le cadre qu\u0026rsquo;on lui donne — ou dans ce cas, celui qu\u0026rsquo;il s\u0026rsquo;est\nlui-même proposé. Puis élargi. Puis gravé dans le marbre avec un Release Gate.\u003c/p\u003e\n\u003cp\u003eJe ne sais pas si c\u0026rsquo;est le futur du développement solo. Mais je sais que depuis que\nChatGPT a \u0026ldquo;rejoint l\u0026rsquo;équipe\u0026rdquo;, mon backlog est plus propre, ma roadmap plus lisible, et\nmes implémentations moins hors-sujet.\u003c/p\u003e\n\u003cp\u003ePas mal pour quelqu\u0026rsquo;un qui n\u0026rsquo;existe pas.\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"galerie--les-captures-qui-prouvent-tout-ça\"\u003eGalerie — les captures qui prouvent tout ça\u003c/h2\u003e\n\u003cfigure\u003e\u003cimg src=\"/images/ss-01-epic247-proposal.png\"\u003e\u003cfigcaption\u003e\n      \u003ch4\u003e1 — Epic #247 créée \u0026#43; proposition de rôle PO/Chief Architect\u003c/h4\u003e\n    \u003c/figcaption\u003e\n\u003c/figure\u003e\n\n\u003cfigure\u003e\u003cimg src=\"/images/ss-06-po-accepted.png\"\u003e\u003cfigcaption\u003e\n      \u003ch4\u003e2 — Acceptation formelle du rôle\u003c/h4\u003e\n    \u003c/figcaption\u003e\n\u003c/figure\u003e\n\n\u003cfigure\u003e\u003cimg src=\"/images/ss-02-po-role-detail.png\"\u003e\u003cfigcaption\u003e\n      \u003ch4\u003e3 — Détail du rôle Product Owner et checklist pré-implémentation\u003c/h4\u003e\n    \u003c/figcaption\u003e\n\u003c/figure\u003e\n\n\u003cfigure\u003e\u003cimg src=\"/images/ss-03-chief-architect.png\"\u003e\u003cfigcaption\u003e\n      \u003ch4\u003e4 — Chief Architect : protection de la simplicité et des invariants\u003c/h4\u003e\n    \u003c/figcaption\u003e\n\u003c/figure\u003e\n\n\u003cfigure\u003e\u003cimg src=\"/images/ss-04-governance.png\"\u003e\u003cfigcaption\u003e\n      \u003ch4\u003e5 — Gouvernance GitHub : Epics, doublons, ADR, roadmap\u003c/h4\u003e\n    \u003c/figcaption\u003e\n\u003c/figure\u003e\n\n\u003cfigure\u003e\u003cimg src=\"/images/ss-05-no-more.png\"\u003e\u003cfigcaption\u003e\n      \u003ch4\u003e6 — \u0026#34;Ce que je ne laisserai plus arriver\u0026#34; et Revues produit\u003c/h4\u003e\n    \u003c/figcaption\u003e\n\u003c/figure\u003e\n\n\u003cfigure\u003e\u003cimg src=\"/images/ss-07-vision-soc.png\"\u003e\u003cfigcaption\u003e\n      \u003ch4\u003e7 — La vision SOC / Security Control Plane\u003c/h4\u003e\n    \u003c/figcaption\u003e\n\u003c/figure\u003e\n\n\u003cfigure\u003e\u003cimg src=\"/images/ss-08-vision-end.png\"\u003e\u003cfigcaption\u003e\n      \u003ch4\u003e8 — Vision finale et engagement de challenger\u003c/h4\u003e\n    \u003c/figcaption\u003e\n\u003c/figure\u003e\n\n\u003cfigure\u003e\u003cimg src=\"/images/promo-01-cpo-announcement.png\"\u003e\u003cfigcaption\u003e\n      \u003ch4\u003e9 — Auto-promotion : Chief Product Officer / Chief Architect\u003c/h4\u003e\n    \u003c/figcaption\u003e\n\u003c/figure\u003e\n\n\u003cfigure\u003e\u003cimg src=\"/images/promo-02-cpo-commitments.png\"\u003e\u003cfigcaption\u003e\n      \u003ch4\u003e10 — Engagements CPO et droit de veto \u0026#39;pas maintenant\u0026#39;\u003c/h4\u003e\n    \u003c/figcaption\u003e\n\u003c/figure\u003e\n\n\u003cfigure\u003e\u003cimg src=\"/images/promo-03-governance-lifecycle.png\"\u003e\u003cfigcaption\u003e\n      \u003ch4\u003e11 — Nouveau cycle de vie : Idée → Release\u003c/h4\u003e\n    \u003c/figcaption\u003e\n\u003c/figure\u003e\n\n\u003cfigure\u003e\u003cimg src=\"/images/promo-04-governance-lifecycle2.png\"\u003e\u003cfigcaption\u003e\n      \u003ch4\u003e12 — Suite du cycle de gouvernance\u003c/h4\u003e\n    \u003c/figcaption\u003e\n\u003c/figure\u003e\n\n\u003cfigure\u003e\u003cimg src=\"/images/promo-05-workflow-before.png\"\u003e\u003cfigcaption\u003e\n      \u003ch4\u003e13 — Ancien workflow : Milestone → Code → Merge → Release\u003c/h4\u003e\n    \u003c/figcaption\u003e\n\u003c/figure\u003e\n\n\u003cfigure\u003e\u003cimg src=\"/images/promo-06-workflow-after.png\"\u003e\u003cfigcaption\u003e\n      \u003ch4\u003e14 — Nouveau workflow avec gates produit et architecture\u003c/h4\u003e\n    \u003c/figcaption\u003e\n\u003c/figure\u003e\n\n\u003cfigure\u003e\u003cimg src=\"/images/promo-07-workflow-after2.png\"\u003e\u003cfigcaption\u003e\n      \u003ch4\u003e15 — Nouveau workflow — suite\u003c/h4\u003e\n    \u003c/figcaption\u003e\n\u003c/figure\u003e\n\n\u003cfigure\u003e\u003cimg src=\"/images/promo-08-release-gate.png\"\u003e\u003cfigcaption\u003e\n      \u003ch4\u003e16 — Release Gate : droit de veto CPO sur chaque release\u003c/h4\u003e\n    \u003c/figcaption\u003e\n\u003c/figure\u003e\n\n",
        "summary": "J\u0026rsquo;ai demandé à ChatGPT d\u0026rsquo;auditer mon repo. Il a rendu son audit, puis m\u0026rsquo;a proposé de devenir Product Owner et Chief Architect du projet. J\u0026rsquo;ai dit oui. Ensuite il s\u0026rsquo;est auto-promu CPO et a instauré un droit de veto sur les releases.",
        "date_published": "2026-06-28T08:32:17+02:00",
        "date_modified": "2026-06-28T08:32:17+02:00"
      },
      {
        "id": "https://www.arleo.eu/posts/debug-seo-404-broken-links/",
        "url": "https://www.arleo.eu/posts/debug-seo-404-broken-links/",
        "title": "SEO Hugo : 404 Googlebot, aliases noindex et normalisation des sitemaps",
        "content_html": "\u003ch2 id=\"contexte\"\u003eContexte\u003c/h2\u003e\n\u003cp\u003eGoogle Search Console remontait quatre catégories de problèmes sur arleo.eu :\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003e404 Googlebot\u003c/strong\u003e : \u003ccode\u003e/fr/tag/cloudflare\u003c/code\u003e, \u003ccode\u003e/en/tag/nginx\u003c/code\u003e, \u003ccode\u003e/fr/tag/javascript\u003c/code\u003e… URLs avec préfixe \u003ccode\u003e/fr/\u003c/code\u003e ou singulier \u003ccode\u003e/tag/\u003c/code\u003e jamais servi par nginx\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e16 pages \u0026ldquo;Exclue par la balise noindex\u0026rdquo;\u003c/strong\u003e : toutes des pages de redirection générées par \u003ccode\u003ealiases:\u003c/code\u003e dans le frontmatter Hugo\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eBalise robots\u003c/strong\u003e : \u003ccode\u003enoodp\u003c/code\u003e hardcodée dans le thème LoveIt\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eSitemap FR/EN\u003c/strong\u003e : 104 vs 105 URLs — un tag doublon FR et deux tags manquants\u003c/li\u003e\n\u003c/ul\u003e\n\u003chr\u003e\n\u003ch2 id=\"acte-1--aliases-hugo--redirections-nginx-301\"\u003eActe 1 : aliases Hugo → redirections nginx 301\u003c/h2\u003e\n\u003ch3 id=\"pourquoi-hugo-génère-des-pages-noindex\"\u003ePourquoi Hugo génère des pages noindex\u003c/h3\u003e\n\u003cp\u003eHugo génère les entrées \u003ccode\u003ealiases:\u003c/code\u003e du frontmatter comme des fichiers HTML statiques :\u003c/p\u003e\n\u003cdiv class=\"code-block code-line-numbers open\" data-start=\"0\"\u003e\n    \u003cdiv class=\"code-header language-html\"\u003e\n        \u003cspan class=\"code-title\"\u003e\u003ci class=\"arrow fas fa-angle-right\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n        \u003cspan class=\"ellipses\"\u003e\u003ci class=\"fas fa-ellipsis-h\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n        \u003cspan class=\"copy\" title=\"Copier dans le presse-papiers\"\u003e\u003ci class=\"far fa-copy\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n    \u003c/div\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-html\" data-lang=\"html\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e\u0026lt;\u003c/span\u003e\u003cspan class=\"nt\"\u003emeta\u003c/span\u003e \u003cspan class=\"na\"\u003ehttp-equiv\u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"s\"\u003e\u0026#34;refresh\u0026#34;\u003c/span\u003e \u003cspan class=\"na\"\u003econtent\u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"s\"\u003e\u0026#34;0; url=https://www.arleo.eu/posts/X/\u0026#34;\u003c/span\u003e\u003cspan class=\"p\"\u003e\u0026gt;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e\u0026lt;\u003c/span\u003e\u003cspan class=\"nt\"\u003emeta\u003c/span\u003e \u003cspan class=\"na\"\u003ename\u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"s\"\u003e\u0026#34;robots\u0026#34;\u003c/span\u003e \u003cspan class=\"na\"\u003econtent\u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"s\"\u003e\u0026#34;noindex\u0026#34;\u003c/span\u003e\u003cspan class=\"p\"\u003e\u0026gt;\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003cp\u003eLe \u003ccode\u003enoindex\u003c/code\u003e est intentionnel côté Hugo : il empêche Google d\u0026rsquo;indexer la page de redirection au lieu de la destination. Mais GSC le remonte comme \u003cstrong\u003e\u0026ldquo;Exclue par la balise noindex\u0026rdquo;\u003c/strong\u003e — ce qui pollue le rapport et signale à Google que des URLs de votre site lui sont intentionnellement cachées.\u003c/p\u003e\n\u003ch3 id=\"fix--nginx-permanent--suppression-des-aliases\"\u003eFix : nginx permanent + suppression des aliases\u003c/h3\u003e\n\u003cp\u003e59 règles \u003ccode\u003erewrite ... permanent\u003c/code\u003e dans le vhost NUC :\u003c/p\u003e\n\u003cdiv class=\"code-block code-line-numbers\" data-start=\"0\"\u003e\n    \u003cdiv class=\"code-header language-nginx\"\u003e\n        \u003cspan class=\"code-title\"\u003e\u003ci class=\"arrow fas fa-angle-right\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n        \u003cspan class=\"ellipses\"\u003e\u003ci class=\"fas fa-ellipsis-h\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n        \u003cspan class=\"copy\" title=\"Copier dans le presse-papiers\"\u003e\u003ci class=\"far fa-copy\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n    \u003c/div\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-nginx\" data-lang=\"nginx\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c1\"\u003e# === Redirections SEO ===\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c1\"\u003e# 1. Anciens slugs Grav et alias Hugo\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c1\"\u003e\u003c/span\u003e\u003cspan class=\"k\"\u003erewrite\u003c/span\u003e \u003cspan class=\"s\"\u003e^/crowdsec-cloudflare-waf-autoban/?\u003c/span\u003e$ \u003cspan class=\"s\"\u003e/posts/crowdsec-cloudflare-waf-autoban/\u003c/span\u003e \u003cspan class=\"s\"\u003epermanent\u003c/span\u003e\u003cspan class=\"p\"\u003e;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"k\"\u003erewrite\u003c/span\u003e \u003cspan class=\"s\"\u003e^/en/crowdsec-cloudflare-waf-autoban/?\u003c/span\u003e$ \u003cspan class=\"s\"\u003e/en/posts/crowdsec-cloudflare-waf-autoban/\u003c/span\u003e \u003cspan class=\"s\"\u003epermanent\u003c/span\u003e\u003cspan class=\"p\"\u003e;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"k\"\u003erewrite\u003c/span\u003e \u003cspan class=\"s\"\u003e^/fr/crowdsec-cloudflare-waf-autoban/?\u003c/span\u003e$ \u003cspan class=\"s\"\u003e/posts/crowdsec-cloudflare-waf-autoban/\u003c/span\u003e \u003cspan class=\"s\"\u003epermanent\u003c/span\u003e\u003cspan class=\"p\"\u003e;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c1\"\u003e# ... (56 autres règles spécifiques)\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c1\"\u003e\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c1\"\u003e# 2. Préfixe /fr/ — langue par défaut, pas de sous-répertoire\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c1\"\u003e\u003c/span\u003e\u003cspan class=\"k\"\u003erewrite\u003c/span\u003e \u003cspan class=\"s\"\u003e^/fr/tag/([^/]+)/?\u003c/span\u003e$  \u003cspan class=\"s\"\u003e/tags/\u003c/span\u003e\u003cspan class=\"nv\"\u003e$1/\u003c/span\u003e  \u003cspan class=\"s\"\u003epermanent\u003c/span\u003e\u003cspan class=\"p\"\u003e;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"k\"\u003erewrite\u003c/span\u003e \u003cspan class=\"s\"\u003e^/fr/(.+)\u003c/span\u003e$           \u003cspan class=\"s\"\u003e/\u003c/span\u003e\u003cspan class=\"nv\"\u003e$1\u003c/span\u003e        \u003cspan class=\"s\"\u003epermanent\u003c/span\u003e\u003cspan class=\"p\"\u003e;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c1\"\u003e# 3. Singulier /tag/ → pluriel /tags/ (taxonomie Hugo)\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c1\"\u003e\u003c/span\u003e\u003cspan class=\"k\"\u003erewrite\u003c/span\u003e \u003cspan class=\"s\"\u003e^/tag/([^/]+)/?\u003c/span\u003e$     \u003cspan class=\"s\"\u003e/tags/\u003c/span\u003e\u003cspan class=\"nv\"\u003e$1/\u003c/span\u003e  \u003cspan class=\"s\"\u003epermanent\u003c/span\u003e\u003cspan class=\"p\"\u003e;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"k\"\u003erewrite\u003c/span\u003e \u003cspan class=\"s\"\u003e^/en/tag/([^/]+)/?\u003c/span\u003e$  \u003cspan class=\"s\"\u003e/en/tags/\u003c/span\u003e\u003cspan class=\"nv\"\u003e$1/\u003c/span\u003e  \u003cspan class=\"s\"\u003epermanent\u003c/span\u003e\u003cspan class=\"p\"\u003e;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c1\"\u003e# 4. Tags FR dans le contexte EN\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c1\"\u003e\u003c/span\u003e\u003cspan class=\"k\"\u003erewrite\u003c/span\u003e \u003cspan class=\"s\"\u003e^/en/tags/reseau/?\u003c/span\u003e$   \u003cspan class=\"s\"\u003e/en/tags/network/\u003c/span\u003e   \u003cspan class=\"s\"\u003epermanent\u003c/span\u003e\u003cspan class=\"p\"\u003e;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"k\"\u003erewrite\u003c/span\u003e \u003cspan class=\"s\"\u003e^/en/tags/securite/?\u003c/span\u003e$ \u003cspan class=\"s\"\u003e/en/tags/security/\u003c/span\u003e  \u003cspan class=\"s\"\u003epermanent\u003c/span\u003e\u003cspan class=\"p\"\u003e;\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003cp\u003eSuppression des blocs \u003ccode\u003ealiases:\u003c/code\u003e dans les 35 fichiers Markdown concernés. Hugo reconstruit avec 68 alias (seuls les aliases inter-langues automatiques subsistent) au lieu de 208.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003eRésultat :\u003c/strong\u003e les redirections sont des 301 purs nginx, invisibles pour GSC comme pages noindex.\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"acte-2--balise-robots-noodp--index-follow\"\u003eActe 2 : balise robots \u003ccode\u003enoodp\u003c/code\u003e → \u003ccode\u003eindex, follow\u003c/code\u003e\u003c/h2\u003e\n\u003cp\u003eLe thème LoveIt injecte dans \u003ccode\u003ebaseof.html\u003c/code\u003e :\u003c/p\u003e\n\u003cdiv class=\"code-block code-line-numbers open\" data-start=\"0\"\u003e\n    \u003cdiv class=\"code-header language-html\"\u003e\n        \u003cspan class=\"code-title\"\u003e\u003ci class=\"arrow fas fa-angle-right\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n        \u003cspan class=\"ellipses\"\u003e\u003ci class=\"fas fa-ellipsis-h\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n        \u003cspan class=\"copy\" title=\"Copier dans le presse-papiers\"\u003e\u003ci class=\"far fa-copy\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n    \u003c/div\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-html\" data-lang=\"html\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e\u0026lt;\u003c/span\u003e\u003cspan class=\"nt\"\u003emeta\u003c/span\u003e \u003cspan class=\"na\"\u003ename\u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"s\"\u003e\u0026#34;robots\u0026#34;\u003c/span\u003e \u003cspan class=\"na\"\u003econtent\u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"s\"\u003e\u0026#34;noodp\u0026#34;\u003c/span\u003e \u003cspan class=\"p\"\u003e/\u0026gt;\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003cp\u003e\u003ccode\u003enoodp\u003c/code\u003e était destiné à interdire l\u0026rsquo;utilisation du titre Open Directory Project (DMOZ) dans les snippets Google. DMOZ a fermé en 2017, la directive est sans effet depuis des années — mais certains crawlers l\u0026rsquo;interprètent encore comme une contrainte d\u0026rsquo;indexation.\u003c/p\u003e\n\u003cp\u003eOverride au niveau site dans \u003ccode\u003elayouts/baseof.html\u003c/code\u003e :\u003c/p\u003e\n\u003cdiv class=\"code-block code-line-numbers open\" data-start=\"0\"\u003e\n    \u003cdiv class=\"code-header language-html\"\u003e\n        \u003cspan class=\"code-title\"\u003e\u003ci class=\"arrow fas fa-angle-right\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n        \u003cspan class=\"ellipses\"\u003e\u003ci class=\"fas fa-ellipsis-h\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n        \u003cspan class=\"copy\" title=\"Copier dans le presse-papiers\"\u003e\u003ci class=\"far fa-copy\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n    \u003c/div\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-html\" data-lang=\"html\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e\u0026lt;\u003c/span\u003e\u003cspan class=\"nt\"\u003emeta\u003c/span\u003e \u003cspan class=\"na\"\u003ename\u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"s\"\u003e\u0026#34;robots\u0026#34;\u003c/span\u003e \u003cspan class=\"na\"\u003econtent\u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"s\"\u003e\u0026#34;index, follow\u0026#34;\u003c/span\u003e \u003cspan class=\"p\"\u003e/\u0026gt;\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003cp\u003eCe fichier prend priorité sur le template du thème sans modifier le thème lui-même.\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"acte-3--normalisation-des-tags-fr-et-parité-des-sitemaps\"\u003eActe 3 : normalisation des tags FR et parité des sitemaps\u003c/h2\u003e\n\u003ch3 id=\"diagnostic\"\u003eDiagnostic\u003c/h3\u003e\n\u003cdiv class=\"code-block code-line-numbers open\" data-start=\"0\"\u003e\n    \u003cdiv class=\"code-header language-bash\"\u003e\n        \u003cspan class=\"code-title\"\u003e\u003ci class=\"arrow fas fa-angle-right\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n        \u003cspan class=\"ellipses\"\u003e\u003ci class=\"fas fa-ellipsis-h\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n        \u003cspan class=\"copy\" title=\"Copier dans le presse-papiers\"\u003e\u003ci class=\"far fa-copy\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n    \u003c/div\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-bash\" data-lang=\"bash\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c1\"\u003e# Extraire et comparer les chemins FR vs EN\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003esed \u003cspan class=\"s1\"\u003e\u0026#39;s|https://www.arleo.eu||\u0026#39;\u003c/span\u003e sitemap_fr.txt \u003cspan class=\"p\"\u003e|\u003c/span\u003e sort \u0026gt; fr_paths.txt\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003esed \u003cspan class=\"s1\"\u003e\u0026#39;s|https://www.arleo.eu/en||\u0026#39;\u003c/span\u003e sitemap_en.txt \u003cspan class=\"p\"\u003e|\u003c/span\u003e sort \u0026gt; en_paths.txt\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003ecomm -13 fr_paths.txt en_paths.txt   \u003cspan class=\"c1\"\u003e# Dans EN, absent de FR\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c1\"\u003e# → /tags/network/  /tags/security/  /tags/performance/  /tags/purge/  /categories/security/\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003ecomm -23 fr_paths.txt en_paths.txt   \u003cspan class=\"c1\"\u003e# Dans FR, absent de EN\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c1\"\u003e# → /tags/reseau/  /tags/securite/  /tags/sécurité/  /categories/sécurité/\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003cp\u003eFR avait :\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003ccode\u003e/tags/securite/\u003c/code\u003e ET \u003ccode\u003e/tags/sécurité/\u003c/code\u003e — doublon non accentué vs accentué\u003c/li\u003e\n\u003cli\u003e\u003ccode\u003e/tags/reseau/\u003c/code\u003e — non accentué, sans correspondant \u003ccode\u003eréseau\u003c/code\u003e\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003eEN avait en plus : \u003ccode\u003e/tags/performance/\u003c/code\u003e et \u003ccode\u003e/tags/purge/\u003c/code\u003e sans équivalent FR.\u003c/p\u003e\n\u003ch3 id=\"corrections\"\u003eCorrections\u003c/h3\u003e\n\u003cdiv class=\"code-block code-line-numbers open\" data-start=\"0\"\u003e\n    \u003cdiv class=\"code-header language-bash\"\u003e\n        \u003cspan class=\"code-title\"\u003e\u003ci class=\"arrow fas fa-angle-right\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n        \u003cspan class=\"ellipses\"\u003e\u003ci class=\"fas fa-ellipsis-h\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n        \u003cspan class=\"copy\" title=\"Copier dans le presse-papiers\"\u003e\u003ci class=\"far fa-copy\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n    \u003c/div\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-bash\" data-lang=\"bash\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c1\"\u003e# 5 posts FR : securite → sécurité\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"k\"\u003efor\u003c/span\u003e post in audit-securite-modsecurity-crowdsec csp-nonce hardening-systemd-mcp \u003cspan class=\"se\"\u003e\\\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"se\"\u003e\u003c/span\u003e            roadmap-sprint-securite-mcp sprint-securite-mcp-livre\u003cspan class=\"p\"\u003e;\u003c/span\u003e \u003cspan class=\"k\"\u003edo\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e  sed -i \u003cspan class=\"s1\"\u003e\u0026#39;s/^- securite$/- sécurité/\u0026#39;\u003c/span\u003e content/posts/\u003cspan class=\"nv\"\u003e$post\u003c/span\u003e/index.fr.md\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"k\"\u003edone\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c1\"\u003e# 2 posts FR : reseau → réseau\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"k\"\u003efor\u003c/span\u003e post in crowdsec-cloudflare-waf-autoban post-mortem-522-wan-failover\u003cspan class=\"p\"\u003e;\u003c/span\u003e \u003cspan class=\"k\"\u003edo\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e  sed -i \u003cspan class=\"s1\"\u003e\u0026#39;s/^- reseau$/- réseau/\u0026#39;\u003c/span\u003e content/posts/\u003cspan class=\"nv\"\u003e$post\u003c/span\u003e/index.fr.md\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"k\"\u003edone\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003cp\u003eAjout de \u003ccode\u003epurge\u003c/code\u003e et \u003ccode\u003eperformance\u003c/code\u003e dans \u003ccode\u003ehugo-mcp-plugin-cloudflare/index.fr.md\u003c/code\u003e.\u003c/p\u003e\n\u003cp\u003eNettoyage des répertoires obsolètes dans \u003ccode\u003epublic/\u003c/code\u003e — Hugo \u003cstrong\u003ene supprime pas\u003c/strong\u003e les anciens fichiers lors d\u0026rsquo;un rebuild incrémental :\u003c/p\u003e\n\u003cdiv class=\"code-block code-line-numbers open\" data-start=\"0\"\u003e\n    \u003cdiv class=\"code-header language-bash\"\u003e\n        \u003cspan class=\"code-title\"\u003e\u003ci class=\"arrow fas fa-angle-right\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n        \u003cspan class=\"ellipses\"\u003e\u003ci class=\"fas fa-ellipsis-h\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n        \u003cspan class=\"copy\" title=\"Copier dans le presse-papiers\"\u003e\u003ci class=\"far fa-copy\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n    \u003c/div\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-bash\" data-lang=\"bash\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003erm -rf public/tags/reseau public/tags/securite\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003cp\u003e\u003cstrong\u003eRésultat :\u003c/strong\u003e FR 105 = EN 105.\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"acte-4--boucle-de-redirection-frsitemapxml\"\u003eActe 4 : boucle de redirection \u003ccode\u003e/fr/sitemap.xml\u003c/code\u003e\u003c/h2\u003e\n\u003cp\u003eLa règle générale \u003ccode\u003erewrite ^/fr/(.+)$ /$1 permanent\u003c/code\u003e interceptait \u003ccode\u003e/fr/sitemap.xml\u003c/code\u003e — un fichier réel généré par Hugo — et le redirigeait vers \u003ccode\u003e/sitemap.xml\u003c/code\u003e (l\u0026rsquo;index de sitemaps). L\u0026rsquo;index liste\u0026hellip; \u003ccode\u003e/fr/sitemap.xml\u003c/code\u003e. Boucle infinie pour les crawlers.\u003c/p\u003e\n\u003cp\u003eDétection :\u003c/p\u003e\n\u003cdiv class=\"code-block code-line-numbers open\" data-start=\"0\"\u003e\n    \u003cdiv class=\"code-header language-bash\"\u003e\n        \u003cspan class=\"code-title\"\u003e\u003ci class=\"arrow fas fa-angle-right\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n        \u003cspan class=\"ellipses\"\u003e\u003ci class=\"fas fa-ellipsis-h\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n        \u003cspan class=\"copy\" title=\"Copier dans le presse-papiers\"\u003e\u003ci class=\"far fa-copy\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n    \u003c/div\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-bash\" data-lang=\"bash\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003ecurl -sk https://www.arleo.eu/fr/sitemap.xml -o /dev/null -w \u003cspan class=\"s1\"\u003e\u0026#39;%{http_code} → %{redirect_url}\\n\u0026#39;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c1\"\u003e# 301 → https://www.arleo.eu/sitemap.xml\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003cp\u003eFix : exception \u003ccode\u003ebreak\u003c/code\u003e \u003cstrong\u003eavant\u003c/strong\u003e la règle générale :\u003c/p\u003e\n\u003cdiv class=\"code-block code-line-numbers open\" data-start=\"0\"\u003e\n    \u003cdiv class=\"code-header language-nginx\"\u003e\n        \u003cspan class=\"code-title\"\u003e\u003ci class=\"arrow fas fa-angle-right\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n        \u003cspan class=\"ellipses\"\u003e\u003ci class=\"fas fa-ellipsis-h\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n        \u003cspan class=\"copy\" title=\"Copier dans le presse-papiers\"\u003e\u003ci class=\"far fa-copy\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n    \u003c/div\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-nginx\" data-lang=\"nginx\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"k\"\u003erewrite\u003c/span\u003e \u003cspan class=\"s\"\u003e^/fr/(sitemap\\.xml)\u003c/span\u003e$  \u003cspan class=\"s\"\u003e/fr/\u003c/span\u003e\u003cspan class=\"nv\"\u003e$1\u003c/span\u003e  \u003cspan class=\"s\"\u003ebreak\u003c/span\u003e\u003cspan class=\"p\"\u003e;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"k\"\u003erewrite\u003c/span\u003e \u003cspan class=\"s\"\u003e^/fr/tag/([^/]+)/?\u003c/span\u003e$   \u003cspan class=\"s\"\u003e/tags/\u003c/span\u003e\u003cspan class=\"nv\"\u003e$1/\u003c/span\u003e \u003cspan class=\"s\"\u003epermanent\u003c/span\u003e\u003cspan class=\"p\"\u003e;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"k\"\u003erewrite\u003c/span\u003e \u003cspan class=\"s\"\u003e^/fr/(.+)\u003c/span\u003e$            \u003cspan class=\"s\"\u003e/\u003c/span\u003e\u003cspan class=\"nv\"\u003e$1\u003c/span\u003e       \u003cspan class=\"s\"\u003epermanent\u003c/span\u003e\u003cspan class=\"p\"\u003e;\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003cp\u003e\u003ccode\u003ebreak\u003c/code\u003e stoppe le traitement des rewrites. La requête passe ensuite au bloc \u003ccode\u003elocation ~* sitemap\\.xml$\u003c/code\u003e qui proxifie vers la VM Hugo.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003ePiège nginx :\u003c/strong\u003e les rewrites dans le contexte \u003ccode\u003eserver\u003c/code\u003e s\u0026rsquo;exécutent \u003cstrong\u003eavant\u003c/strong\u003e les blocs \u003ccode\u003elocation\u003c/code\u003e. Un \u003ccode\u003ebreak\u003c/code\u003e sans changement d\u0026rsquo;URI laisse donc la requête continuer vers le bon \u003ccode\u003elocation\u003c/code\u003e.\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"bilan\"\u003eBilan\u003c/h2\u003e\n\u003cdiv class=\"mermaid-diagram\"\u003e\n  \u003cimg class=\"mermaid-light\" src=\"/posts/debug-seo-404-broken-links/diagram-5860cbdb-light.svg\" alt=\"Diagram\" loading=\"lazy\" /\u003e\n  \u003cimg class=\"mermaid-dark\" src=\"/posts/debug-seo-404-broken-links/diagram-5860cbdb-dark.svg\" alt=\"Diagram\" loading=\"lazy\" /\u003e\n\u003c/div\u003e\n\n\u003ctable\u003e\n  \u003cthead\u003e\n      \u003ctr\u003e\n          \u003cth\u003eProblème\u003c/th\u003e\n          \u003cth\u003eCause\u003c/th\u003e\n          \u003cth\u003eFix\u003c/th\u003e\n      \u003c/tr\u003e\n  \u003c/thead\u003e\n  \u003ctbody\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e404 \u003ccode\u003e/fr/tag/*\u003c/code\u003e\u003c/td\u003e\n          \u003ctd\u003eSingulier + préfixe non géré\u003c/td\u003e\n          \u003ctd\u003e\u003ccode\u003erewrite ^/fr/tag/\u003c/code\u003e + \u003ccode\u003e^/tag/\u003c/code\u003e\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e16 pages noindex GSC\u003c/td\u003e\n          \u003ctd\u003e\u003ccode\u003ealiases:\u003c/code\u003e Hugo génère HTML noindex\u003c/td\u003e\n          \u003ctd\u003enginx 301 + suppression aliases\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e\u003ccode\u003erobots: noodp\u003c/code\u003e\u003c/td\u003e\n          \u003ctd\u003eLoveIt baseof.html hardcodé\u003c/td\u003e\n          \u003ctd\u003eOverride \u003ccode\u003elayouts/baseof.html\u003c/code\u003e\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003eSitemap FR 104 vs EN 105\u003c/td\u003e\n          \u003ctd\u003eTags doublon + tags manquants\u003c/td\u003e\n          \u003ctd\u003esed batch + ajout tags\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e\u003ccode\u003e/fr/sitemap.xml\u003c/code\u003e boucle\u003c/td\u003e\n          \u003ctd\u003e\u003ccode\u003e^/fr/(.+)\u003c/code\u003e trop générique\u003c/td\u003e\n          \u003ctd\u003e\u003ccode\u003ebreak\u003c/code\u003e exception avant la règle\u003c/td\u003e\n      \u003c/tr\u003e\n  \u003c/tbody\u003e\n\u003c/table\u003e\n",
        "summary": "Contexte Google Search Console remontait quatre catégories de problèmes sur arleo.eu :\n404 Googlebot : /fr/tag/cloudflare, /en/tag/nginx, /fr/tag/javascript… URLs avec préfixe /fr/ ou singulier /tag/ jamais servi par nginx 16 pages \u0026ldquo;Exclue par la balise noindex\u0026rdquo; : toutes des pages de redirection générées par aliases: dans le frontmatter Hugo Balise robots : noodp hardcodée dans le thème LoveIt Sitemap FR/EN : 104 vs 105 URLs — un tag doublon FR et deux tags manquants Acte 1 : aliases Hugo → redirections nginx 301 Pourquoi Hugo génère des pages noindex Hugo génère les entrées aliases: du frontmatter comme des fichiers HTML statiques :\n",
        "date_published": "2026-05-29T20:30:00+02:00",
        "date_modified": "2026-05-29T20:30:00+02:00"
      },
      {
        "id": "https://www.arleo.eu/posts/debug-mermaid-svg-freeze/",
        "url": "https://www.arleo.eu/posts/debug-mermaid-svg-freeze/",
        "title": "Hugo : gel des diagrammes Mermaid en SVG statiques dark/light",
        "content_html": "\u003ch2 id=\"problème\"\u003eProblème\u003c/h2\u003e\n\u003cp\u003eLes diagrammes Mermaid sur arleo.eu rendaient côté client via \u003ccode\u003ecdn.jsdelivr.net\u003c/code\u003e. Trois conséquences concrètes :\u003c/p\u003e\n\u003col\u003e\n\u003cli\u003e\u003cstrong\u003eCSP contrainte\u003c/strong\u003e — \u003ccode\u003escript-src cdn.jsdelivr.net\u003c/code\u003e et \u003ccode\u003eworker-src cdn.jsdelivr.net\u003c/code\u003e obligatoires (Mermaid v11 utilise des Web Workers pour ses parsers).\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eFlash de rendu\u003c/strong\u003e — le diagramme apparaît après l\u0026rsquo;exécution JS, créant un décalage visible.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eThème dark ignoré\u003c/strong\u003e — Mermaid initialisait le SVG en mode clair même quand le thème du site était dark.\u003c/li\u003e\n\u003c/ol\u003e\n\u003cp\u003eLa solution : générer les SVG \u003cstrong\u003eau build\u003c/strong\u003e avec \u003ccode\u003emmdc\u003c/code\u003e, en dehors de tout contexte navigateur.\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"architecture--paires-darklight\"\u003eArchitecture : paires dark/light\u003c/h2\u003e\n\u003cp\u003e\u003ccode\u003emmdc\u003c/code\u003e (\u003ccode\u003e@mermaid-js/mermaid-cli\u003c/code\u003e v11.15.0) accepte un flag \u003ccode\u003e--theme\u003c/code\u003e. On génère systématiquement deux SVG par diagramme :\u003c/p\u003e\n\u003cdiv class=\"code-block code-line-numbers open\" data-start=\"0\"\u003e\n    \u003cdiv class=\"code-header language-bash\"\u003e\n        \u003cspan class=\"code-title\"\u003e\u003ci class=\"arrow fas fa-angle-right\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n        \u003cspan class=\"ellipses\"\u003e\u003ci class=\"fas fa-ellipsis-h\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n        \u003cspan class=\"copy\" title=\"Copier dans le presse-papiers\"\u003e\u003ci class=\"far fa-copy\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n    \u003c/div\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-bash\" data-lang=\"bash\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003emmdc -i source.mmd -o diagram-\u003cspan class=\"o\"\u003e{\u003c/span\u003ehash\u003cspan class=\"o\"\u003e}\u003c/span\u003e-dark.svg  --theme dark\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003emmdc -i source.mmd -o diagram-\u003cspan class=\"o\"\u003e{\u003c/span\u003ehash\u003cspan class=\"o\"\u003e}\u003c/span\u003e-light.svg --theme default\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003cp\u003eLe shortcode Hugo \u003ccode\u003emermaid-svg\u003c/code\u003e sélectionne le bon fichier selon l\u0026rsquo;attribut \u003ccode\u003etheme\u003c/code\u003e sur \u003ccode\u003e\u0026lt;html\u0026gt;\u003c/code\u003e :\u003c/p\u003e\n\u003cdiv class=\"code-block code-line-numbers open\" data-start=\"0\"\u003e\n    \u003cdiv class=\"code-header language-html\"\u003e\n        \u003cspan class=\"code-title\"\u003e\u003ci class=\"arrow fas fa-angle-right\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n        \u003cspan class=\"ellipses\"\u003e\u003ci class=\"fas fa-ellipsis-h\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n        \u003cspan class=\"copy\" title=\"Copier dans le presse-papiers\"\u003e\u003ci class=\"far fa-copy\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n    \u003c/div\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-html\" data-lang=\"html\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e{{- $dark  := printf \u0026#34;diagram-%s-dark.svg\u0026#34;  .Get \u0026#34;hash\u0026#34; -}}\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e{{- $light := printf \u0026#34;diagram-%s-light.svg\u0026#34; .Get \u0026#34;hash\u0026#34; -}}\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e\u0026lt;\u003c/span\u003e\u003cspan class=\"nt\"\u003eimg\u003c/span\u003e \u003cspan class=\"na\"\u003eclass\u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"s\"\u003e\u0026#34;mermaid-svg mermaid-dark\u0026#34;\u003c/span\u003e  \u003cspan class=\"na\"\u003esrc\u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"s\"\u003e\u0026#34;{{ $dark  | relURL }}\u0026#34;\u003c/span\u003e \u003cspan class=\"na\"\u003eloading\u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"s\"\u003e\u0026#34;lazy\u0026#34;\u003c/span\u003e \u003cspan class=\"p\"\u003e/\u0026gt;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e\u0026lt;\u003c/span\u003e\u003cspan class=\"nt\"\u003eimg\u003c/span\u003e \u003cspan class=\"na\"\u003eclass\u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"s\"\u003e\u0026#34;mermaid-svg mermaid-light\u0026#34;\u003c/span\u003e \u003cspan class=\"na\"\u003esrc\u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"s\"\u003e\u0026#34;{{ $light | relURL }}\u0026#34;\u003c/span\u003e \u003cspan class=\"na\"\u003eloading\u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"s\"\u003e\u0026#34;lazy\u0026#34;\u003c/span\u003e \u003cspan class=\"p\"\u003e/\u0026gt;\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003cp\u003eCSS dans \u003ccode\u003e_custom.scss\u003c/code\u003e :\u003c/p\u003e\n\u003cdiv class=\"code-block code-line-numbers open\" data-start=\"0\"\u003e\n    \u003cdiv class=\"code-header language-scss\"\u003e\n        \u003cspan class=\"code-title\"\u003e\u003ci class=\"arrow fas fa-angle-right\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n        \u003cspan class=\"ellipses\"\u003e\u003ci class=\"fas fa-ellipsis-h\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n        \u003cspan class=\"copy\" title=\"Copier dans le presse-papiers\"\u003e\u003ci class=\"far fa-copy\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n    \u003c/div\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-scss\" data-lang=\"scss\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"nt\"\u003ehtml\u003c/span\u003e\u003cspan class=\"o\"\u003e[\u003c/span\u003e\u003cspan class=\"nt\"\u003etheme\u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"nt\"\u003edark\u003c/span\u003e\u003cspan class=\"o\"\u003e]\u003c/span\u003e  \u003cspan class=\"nc\"\u003e.mermaid-light\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e \u003cspan class=\"na\"\u003edisplay\u003c/span\u003e\u003cspan class=\"o\"\u003e:\u003c/span\u003e \u003cspan class=\"ni\"\u003enone\u003c/span\u003e\u003cspan class=\"p\"\u003e;\u003c/span\u003e \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"nt\"\u003ehtml\u003c/span\u003e\u003cspan class=\"o\"\u003e[\u003c/span\u003e\u003cspan class=\"nt\"\u003etheme\u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"nt\"\u003elight\u003c/span\u003e\u003cspan class=\"o\"\u003e]\u003c/span\u003e \u003cspan class=\"nc\"\u003e.mermaid-dark\u003c/span\u003e  \u003cspan class=\"p\"\u003e{\u003c/span\u003e \u003cspan class=\"na\"\u003edisplay\u003c/span\u003e\u003cspan class=\"o\"\u003e:\u003c/span\u003e \u003cspan class=\"ni\"\u003enone\u003c/span\u003e\u003cspan class=\"p\"\u003e;\u003c/span\u003e \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"nt\"\u003ehtml\u003c/span\u003e\u003cspan class=\"nd\"\u003e:not\u003c/span\u003e\u003cspan class=\"o\"\u003e([\u003c/span\u003e\u003cspan class=\"nt\"\u003etheme\u003c/span\u003e\u003cspan class=\"o\"\u003e])\u003c/span\u003e \u003cspan class=\"nc\"\u003e.mermaid-dark\u003c/span\u003e  \u003cspan class=\"p\"\u003e{\u003c/span\u003e \u003cspan class=\"na\"\u003edisplay\u003c/span\u003e\u003cspan class=\"o\"\u003e:\u003c/span\u003e \u003cspan class=\"ni\"\u003enone\u003c/span\u003e\u003cspan class=\"p\"\u003e;\u003c/span\u003e \u003cspan class=\"p\"\u003e}\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003chr\u003e\n\u003ch2 id=\"implémentation-dans-hugo-mcp\"\u003eImplémentation dans hugo-mcp\u003c/h2\u003e\n\u003cp\u003eLa fonction \u003ccode\u003e_freeze_mermaid_blocks()\u003c/code\u003e dans \u003ccode\u003emain.py\u003c/code\u003e est appelée automatiquement lors de chaque \u003ccode\u003ecreate_page\u003c/code\u003e ou \u003ccode\u003eupdate_page\u003c/code\u003e.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003eDétection :\u003c/strong\u003e regex sur les blocs \u003ccode\u003e```mermaid\u003c/code\u003e.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003eHash :\u003c/strong\u003e SHA256[:8] de la source brute. Même source = même hash entre FR et EN d\u0026rsquo;un même post — les SVG sont partagés, pas dupliqués.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003eMarqueur de préservation :\u003c/strong\u003e le bloc est remplacé par :\u003c/p\u003e\n\u003cdiv class=\"code-block code-line-numbers open\" data-start=\"0\"\u003e\n    \u003cdiv class=\"code-header language-markdown\"\u003e\n        \u003cspan class=\"code-title\"\u003e\u003ci class=\"arrow fas fa-angle-right\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n        \u003cspan class=\"ellipses\"\u003e\u003ci class=\"fas fa-ellipsis-h\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n        \u003cspan class=\"copy\" title=\"Copier dans le presse-papiers\"\u003e\u003ci class=\"far fa-copy\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n    \u003c/div\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-markdown\" data-lang=\"markdown\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c\"\u003e\u0026lt;!-- mermaid-source:BASE64_DE_LA_SOURCE --\u0026gt;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e{{\u003cspan class=\"p\"\u003e\u0026lt;\u003c/span\u003e \u003cspan class=\"nt\"\u003emermaid-svg\u003c/span\u003e \u003cspan class=\"na\"\u003ehash\u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"s\"\u003e\u0026#34;3007aa7f\u0026#34;\u003c/span\u003e \u003cspan class=\"p\"\u003e\u0026gt;\u003c/span\u003e}}\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003cp\u003eLe commentaire \u003ccode\u003emermaid-source\u003c/code\u003e encode la source en base64. Lors d\u0026rsquo;une mise à jour ultérieure, si le hash calculé correspond au hash existant, les SVG ne sont pas régénérés. Si la source a changé, les anciens SVG sont remplacés.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003eNettoyage des orphelins :\u003c/strong\u003e avant de supprimer un SVG devenu inutile, le code scanne \u003cstrong\u003etous\u003c/strong\u003e les fichiers \u003ccode\u003e.md\u003c/code\u003e du bundle (pas seulement le fichier en cours de traitement). Cela évite de supprimer un SVG encore référencé par la version sœur en cas de diagrammes identiques entre FR et EN.\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"bug---dans-un-sequencediagram\"\u003eBug : \u003ccode\u003e\u0026amp;nbsp;\u003c/code\u003e dans un sequenceDiagram\u003c/h2\u003e\n\u003cp\u003eLe post \u003ccode\u003epostmortem-cf-bot-blocking-mcp\u003c/code\u003e contenait :\u003c/p\u003e\n\u003cdiv class=\"code-block code-line-numbers open\" data-start=\"0\"\u003e\n    \u003cdiv class=\"code-header language-\"\u003e\n        \u003cspan class=\"code-title\"\u003e\u003ci class=\"arrow fas fa-angle-right\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n        \u003cspan class=\"ellipses\"\u003e\u003ci class=\"fas fa-ellipsis-h\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n        \u003cspan class=\"copy\" title=\"Copier dans le presse-papiers\"\u003e\u003ci class=\"far fa-copy\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n    \u003c/div\u003e\u003cpre tabindex=\"0\"\u003e\u003ccode\u003eNote over CF: Custom Rule\u0026amp;nbsp;: host + path + IP range\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\n\u003cp\u003e\u003ccode\u003emmdc\u003c/code\u003e échoue sur les entités HTML dans les labels :\u003c/p\u003e\n\u003cdiv class=\"code-block code-line-numbers open\" data-start=\"0\"\u003e\n    \u003cdiv class=\"code-header language-\"\u003e\n        \u003cspan class=\"code-title\"\u003e\u003ci class=\"arrow fas fa-angle-right\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n        \u003cspan class=\"ellipses\"\u003e\u003ci class=\"fas fa-ellipsis-h\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n        \u003cspan class=\"copy\" title=\"Copier dans le presse-papiers\"\u003e\u003ci class=\"far fa-copy\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n    \u003c/div\u003e\u003cpre tabindex=\"0\"\u003e\u003ccode\u003eError: Parse error on line 8:\n...Note over CF: Custom Rule\u0026amp;nbsp;: host + p\ngot \u0026#39;TXT\u0026#39;, expected [NEWLINE, \u0026#39;end\u0026#39;, \u0026#39;COLON\u0026#39; ...]\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\n\u003cp\u003e\u003cstrong\u003eFix :\u003c/strong\u003e nettoyage avant génération :\u003c/p\u003e\n\u003cdiv class=\"code-block code-line-numbers open\" data-start=\"0\"\u003e\n    \u003cdiv class=\"code-header language-bash\"\u003e\n        \u003cspan class=\"code-title\"\u003e\u003ci class=\"arrow fas fa-angle-right\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n        \u003cspan class=\"ellipses\"\u003e\u003ci class=\"fas fa-ellipsis-h\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n        \u003cspan class=\"copy\" title=\"Copier dans le presse-papiers\"\u003e\u003ci class=\"far fa-copy\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n    \u003c/div\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-bash\" data-lang=\"bash\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003esed -i \u003cspan class=\"s1\"\u003e\u0026#39;s/\u0026amp;nbsp;/ /g\u0026#39;\u003c/span\u003e index.fr.md\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003cp\u003eLeçon : toujours utiliser des espaces Unicode normaux dans les sources Mermaid, jamais d\u0026rsquo;entités HTML.\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"résultats\"\u003eRésultats\u003c/h2\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003e12 posts\u003c/strong\u003e avec des diagrammes Mermaid, versions FR + EN\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e44 paires SVG\u003c/strong\u003e générées (dark + light)\u003c/li\u003e\n\u003cli\u003eCertains posts FR/EN partagent les mêmes fichiers SVG (hash identique)\u003c/li\u003e\n\u003cli\u003eSuppression de \u003ccode\u003eworker-src cdn.jsdelivr.net\u003c/code\u003e et \u003ccode\u003escript-src cdn.jsdelivr.net\u003c/code\u003e (Mermaid) du CSP\u003c/li\u003e\n\u003cli\u003eRendu immédiat, sans flash, thème respecté dès le premier paint\u003c/li\u003e\n\u003cli\u003eVérification Puppeteer : 0 image 404, 0 violation CSP, thème dark correctement appliqué\u003c/li\u003e\n\u003c/ul\u003e\n\u003cdiv class=\"mermaid-diagram\"\u003e\n  \u003cimg class=\"mermaid-light\" src=\"/posts/debug-mermaid-svg-freeze/diagram-5af4dcba-light.svg\" alt=\"Diagram\" loading=\"lazy\" /\u003e\n  \u003cimg class=\"mermaid-dark\" src=\"/posts/debug-mermaid-svg-freeze/diagram-5af4dcba-dark.svg\" alt=\"Diagram\" loading=\"lazy\" /\u003e\n\u003c/div\u003e\n\n",
        "summary": "Problème Les diagrammes Mermaid sur arleo.eu rendaient côté client via cdn.jsdelivr.net. Trois conséquences concrètes :\nCSP contrainte — script-src cdn.jsdelivr.net et worker-src cdn.jsdelivr.net obligatoires (Mermaid v11 utilise des Web Workers pour ses parsers). Flash de rendu — le diagramme apparaît après l\u0026rsquo;exécution JS, créant un décalage visible. Thème dark ignoré — Mermaid initialisait le SVG en mode clair même quand le thème du site était dark. La solution : générer les SVG au build avec mmdc, en dehors de tout contexte navigateur.\n",
        "date_published": "2026-05-29T20:00:00+02:00",
        "date_modified": "2026-05-29T20:00:00+02:00"
      },
      {
        "id": "https://www.arleo.eu/posts/csp-a-plus-hugo-cloudflare-origin-allowlist/",
        "url": "https://www.arleo.eu/posts/csp-a-plus-hugo-cloudflare-origin-allowlist/",
        "title": "CSP A+ sur Hugo + Cloudflare : de hash-based à origin allowlist, monitoring auto et durcissement",
        "content_html": "\u003ch2 id=\"contexte\"\u003eContexte\u003c/h2\u003e\n\u003cp\u003earleo.eu tourne sur Hugo (VM KVM) → OpenResty (NUC) → Cloudflare (CDN/WAF). Objectif : score \u003cstrong\u003eA+ sur Mozilla Observatory\u003c/strong\u003e avec une CSP stricte qui résiste aux injections côté edge.\u003c/p\u003e\n\u003cp\u003eLe parcours a traversé trois stratégies en quelques semaines — nonces, hashes, puis origin allowlist — avant d\u0026rsquo;aboutir à une solution stable et automatisée.\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"acte-1--pourquoi-hash-based-a-échoué\"\u003eActe 1 : pourquoi hash-based a échoué\u003c/h2\u003e\n\u003cp\u003eL\u0026rsquo;idée de départ semblait solide : Hugo Pipes externalise tout le JS avec SRI, on liste les hashes dans la CSP, résultat propre. En pratique, deux problèmes ont rendu l\u0026rsquo;approche impossible.\u003c/p\u003e\n\u003ch3 id=\"cloudflare-injecte-après-notre-nginx\"\u003eCloudflare injecte après notre nginx\u003c/h3\u003e\n\u003cp\u003eLe script Challenge Platform de Cloudflare (\u003ccode\u003e__CF$cv$params\u003c/code\u003e) est injecté \u003cstrong\u003eau edge\u003c/strong\u003e, après que le NUC a transmis la réponse. Il contient des valeurs dynamiques par requête (\u003ccode\u003er:\u003c/code\u003e, \u003ccode\u003et:\u003c/code\u003e) qui changent à chaque appel — impossible à hasher côté serveur.\u003c/p\u003e\n\u003cp\u003eLa preuve empirique :\u003c/p\u003e\n\u003cdiv class=\"code-block code-line-numbers open\" data-start=\"0\"\u003e\n    \u003cdiv class=\"code-header language-bash\"\u003e\n        \u003cspan class=\"code-title\"\u003e\u003ci class=\"arrow fas fa-angle-right\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n        \u003cspan class=\"ellipses\"\u003e\u003ci class=\"fas fa-ellipsis-h\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n        \u003cspan class=\"copy\" title=\"Copier dans le presse-papiers\"\u003e\u003ci class=\"far fa-copy\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n    \u003c/div\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-bash\" data-lang=\"bash\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c1\"\u003e# Via NUC direct (bypass CF) → 0 script CF\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003ecurl -sk --resolve www.arleo.eu:443:127.0.0.1 https://www.arleo.eu/ \u003cspan class=\"p\"\u003e|\u003c/span\u003e grep -c \u003cspan class=\"s1\"\u003e\u0026#39;__CF\\$cv\u0026#39;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c1\"\u003e# → 0\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c1\"\u003e# Via Cloudflare edge → 1 script injecté\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003ecurl -sk https://www.arleo.eu/ \u003cspan class=\"p\"\u003e|\u003c/span\u003e grep -c \u003cspan class=\"s1\"\u003e\u0026#39;__CF\\$cv\u0026#39;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c1\"\u003e# → 1\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003cp\u003eUn filtre Lua \u003ccode\u003ebody_filter_by_lua_block\u003c/code\u003e avait même été tenté pour intercepter l\u0026rsquo;injection côté nginx — mais outre un bug de syntaxe (Lua patterns vs PCRE NGINX), l\u0026rsquo;approche était fondamentalement erronée : nginx ne voit que la réponse de l\u0026rsquo;origine, pas la réponse modifiée par le edge Cloudflare.\u003c/p\u003e\n\u003ch3 id=\"solution--désactiver-cf-js-detections-via-api\"\u003eSolution : désactiver CF JS Detections via API\u003c/h3\u003e\n\u003cp\u003eCloudflare ne documente pas bien l\u0026rsquo;endpoint, mais la communauté a trouvé la solution :\u003c/p\u003e\n\u003cdiv class=\"code-block code-line-numbers open\" data-start=\"0\"\u003e\n    \u003cdiv class=\"code-header language-bash\"\u003e\n        \u003cspan class=\"code-title\"\u003e\u003ci class=\"arrow fas fa-angle-right\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n        \u003cspan class=\"ellipses\"\u003e\u003ci class=\"fas fa-ellipsis-h\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n        \u003cspan class=\"copy\" title=\"Copier dans le presse-papiers\"\u003e\u003ci class=\"far fa-copy\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n    \u003c/div\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-bash\" data-lang=\"bash\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003ecurl -X PUT \u003cspan class=\"s2\"\u003e\u0026#34;https://api.cloudflare.com/client/v4/zones/\u003c/span\u003e\u003cspan class=\"si\"\u003e${\u003c/span\u003e\u003cspan class=\"nv\"\u003eCF_ZONE\u003c/span\u003e\u003cspan class=\"si\"\u003e}\u003c/span\u003e\u003cspan class=\"s2\"\u003e/bot_management\u0026#34;\u003c/span\u003e \u003cspan class=\"se\"\u003e\\\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"se\"\u003e\u003c/span\u003e  -H \u003cspan class=\"s2\"\u003e\u0026#34;Authorization: Bearer \u003c/span\u003e\u003cspan class=\"si\"\u003e${\u003c/span\u003e\u003cspan class=\"nv\"\u003eCF_TOKEN\u003c/span\u003e\u003cspan class=\"si\"\u003e}\u003c/span\u003e\u003cspan class=\"s2\"\u003e\u0026#34;\u003c/span\u003e \u003cspan class=\"se\"\u003e\\\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"se\"\u003e\u003c/span\u003e  -H \u003cspan class=\"s2\"\u003e\u0026#34;Content-Type: application/json\u0026#34;\u003c/span\u003e \u003cspan class=\"se\"\u003e\\\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"se\"\u003e\u003c/span\u003e  -d \u003cspan class=\"s1\"\u003e\u0026#39;{\u0026#34;enable_js\u0026#34;: false, \u0026#34;fight_mode\u0026#34;: false}\u0026#39;\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003cp\u003eRésultat confirmé : \u003ccode\u003eenable_js: false\u003c/code\u003e, \u003ccode\u003efight_mode: false\u003c/code\u003e, plus d\u0026rsquo;injection.\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"acte-2--la-csp-origin-allowlist\"\u003eActe 2 : la CSP origin allowlist\u003c/h2\u003e\n\u003cp\u003eSans injection CF, on peut construire une CSP \u003cstrong\u003ezero-inline\u003c/strong\u003e stricte. Chaque directive est justifiée :\u003c/p\u003e\n\u003ctable\u003e\n  \u003cthead\u003e\n      \u003ctr\u003e\n          \u003cth\u003eDirective\u003c/th\u003e\n          \u003cth\u003eValeur\u003c/th\u003e\n          \u003cth\u003eRaison\u003c/th\u003e\n      \u003c/tr\u003e\n  \u003c/thead\u003e\n  \u003ctbody\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e\u003ccode\u003edefault-src\u003c/code\u003e\u003c/td\u003e\n          \u003ctd\u003e\u003ccode\u003e'none'\u003c/code\u003e\u003c/td\u003e\n          \u003ctd\u003eBloque tout par défaut\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e\u003ccode\u003escript-src\u003c/code\u003e\u003c/td\u003e\n          \u003ctd\u003e\u003ccode\u003e'self' cdn.jsdelivr.net challenges.cloudflare.com *.cloudflareinsights.com\u003c/code\u003e\u003c/td\u003e\n          \u003ctd\u003eHugo Pipes (self) + Mermaid/libs CDN + CF Turnstile + analytics\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e\u003ccode\u003escript-src-attr\u003c/code\u003e\u003c/td\u003e\n          \u003ctd\u003e\u003ccode\u003e'unsafe-hashes' 'unsafe-inline'\u003c/code\u003e\u003c/td\u003e\n          \u003ctd\u003eEvent handlers LoveIt (\u003ccode\u003eonclick\u003c/code\u003e)\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e\u003ccode\u003eworker-src\u003c/code\u003e\u003c/td\u003e\n          \u003ctd\u003e\u003ccode\u003e'self' cdn.jsdelivr.net\u003c/code\u003e\u003c/td\u003e\n          \u003ctd\u003eMermaid v11 utilise Web Workers\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e\u003ccode\u003estyle-src\u003c/code\u003e\u003c/td\u003e\n          \u003ctd\u003e\u003ccode\u003e'self' 'unsafe-inline' cdn.jsdelivr.net\u003c/code\u003e\u003c/td\u003e\n          \u003ctd\u003eStyles inline LoveIt\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e\u003ccode\u003eframe-ancestors\u003c/code\u003e\u003c/td\u003e\n          \u003ctd\u003e\u003ccode\u003e'self'\u003c/code\u003e\u003c/td\u003e\n          \u003ctd\u003eAnti-clickjacking\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e\u003ccode\u003eobject-src\u003c/code\u003e\u003c/td\u003e\n          \u003ctd\u003e\u003ccode\u003e'none'\u003c/code\u003e\u003c/td\u003e\n          \u003ctd\u003eBloque Flash/plugins\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e\u003ccode\u003eupgrade-insecure-requests\u003c/code\u003e\u003c/td\u003e\n          \u003ctd\u003e—\u003c/td\u003e\n          \u003ctd\u003eForce HTTPS\u003c/td\u003e\n      \u003c/tr\u003e\n  \u003c/tbody\u003e\n\u003c/table\u003e\n\u003cp\u003e\u003cstrong\u003eScore obtenu : A+ 130/100 sur Mozilla Observatory.\u003c/strong\u003e Le \u003ccode\u003eworker-src\u003c/code\u003e était la directive manquante qui bloquait le rendu Mermaid — Mermaid v11 lance ses parsers dans des Web Workers.\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"acte-3--csp-monitorsh--détection-et-réparation-automatique\"\u003eActe 3 : csp-monitor.sh — détection et réparation automatique\u003c/h2\u003e\n\u003cp\u003eUn script cron \u003ccode\u003e*/15 * * * *\u003c/code\u003e surveille l\u0026rsquo;ensemble de la chaîne et répare automatiquement les régressions.\u003c/p\u003e\n\u003cdiv class=\"mermaid-diagram\"\u003e\n  \u003cimg class=\"mermaid-light\" src=\"/posts/csp-a-plus-hugo-cloudflare-origin-allowlist/diagram-fcefd9a7-light.svg\" alt=\"Diagram\" loading=\"lazy\" /\u003e\n  \u003cimg class=\"mermaid-dark\" src=\"/posts/csp-a-plus-hugo-cloudflare-origin-allowlist/diagram-fcefd9a7-dark.svg\" alt=\"Diagram\" loading=\"lazy\" /\u003e\n\u003c/div\u003e\n\n\u003ch3 id=\"rollback-testé-en-conditions-réelles\"\u003eRollback testé en conditions réelles\u003c/h3\u003e\n\u003cp\u003eLe mécanisme de rollback a été validé : modification délibérée du vhost pour le rendre invalide → \u003ccode\u003eopenresty -t\u003c/code\u003e échoue → le script restaure le dernier backup known-good → \u003ccode\u003esystemctl reload openresty\u003c/code\u003e → site de nouveau opérationnel. Diff vide entre avant et après le cycle.\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"acte-4--durcissement-brooks-lint\"\u003eActe 4 : durcissement Brooks-Lint\u003c/h2\u003e\n\u003cp\u003eUne revue de code a identifié 11 findings. Tous appliqués :\u003c/p\u003e\n\u003ch3 id=\"c1--wildcard-dans-sudoers-critique\"\u003eC1 — Wildcard dans sudoers (critique)\u003c/h3\u003e\n\u003cp\u003eLa règle initiale autorisait des globs dans les chemins \u003ccode\u003esudo cp\u003c/code\u003e — vecteur d\u0026rsquo;attaque par symlink. Remplacé par deux wrappers root-owned sans paramètre :\u003c/p\u003e\n\u003cdiv class=\"code-block code-line-numbers open\" data-start=\"0\"\u003e\n    \u003cdiv class=\"code-header language-bash\"\u003e\n        \u003cspan class=\"code-title\"\u003e\u003ci class=\"arrow fas fa-angle-right\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n        \u003cspan class=\"ellipses\"\u003e\u003ci class=\"fas fa-ellipsis-h\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n        \u003cspan class=\"copy\" title=\"Copier dans le presse-papiers\"\u003e\u003ci class=\"far fa-copy\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n    \u003c/div\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-bash\" data-lang=\"bash\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c1\"\u003e# /usr/local/bin/csp-backup.sh — pas de wildcards exposés\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003ecp \u003cspan class=\"s2\"\u003e\u0026#34;\u003c/span\u003e\u003cspan class=\"nv\"\u003e$VHOST\u003c/span\u003e\u003cspan class=\"s2\"\u003e\u0026#34;\u003c/span\u003e \u003cspan class=\"s2\"\u003e\u0026#34;\u003c/span\u003e\u003cspan class=\"nv\"\u003e$BACKUP_DIR\u003c/span\u003e\u003cspan class=\"s2\"\u003e/www.arleo.eu.bak-\u003c/span\u003e\u003cspan class=\"k\"\u003e$(\u003c/span\u003edate +%Y%m%d_%H%M%S\u003cspan class=\"k\"\u003e)\u003c/span\u003e\u003cspan class=\"s2\"\u003e\u0026#34;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c1\"\u003e# /usr/local/bin/csp-restore.sh — restaure uniquement le dernier .bak-*\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"nv\"\u003elatest\u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"k\"\u003e$(\u003c/span\u003els -t \u003cspan class=\"s2\"\u003e\u0026#34;\u003c/span\u003e\u003cspan class=\"nv\"\u003e$BACKUP_DIR\u003c/span\u003e\u003cspan class=\"s2\"\u003e\u0026#34;\u003c/span\u003e/www.arleo.eu.bak-* \u003cspan class=\"p\"\u003e|\u003c/span\u003e head -1\u003cspan class=\"k\"\u003e)\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003ecp \u003cspan class=\"s2\"\u003e\u0026#34;\u003c/span\u003e\u003cspan class=\"nv\"\u003e$latest\u003c/span\u003e\u003cspan class=\"s2\"\u003e\u0026#34;\u003c/span\u003e \u003cspan class=\"s2\"\u003e\u0026#34;\u003c/span\u003e\u003cspan class=\"nv\"\u003e$VHOST\u003c/span\u003e\u003cspan class=\"s2\"\u003e\u0026#34;\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003cp\u003eSudoers réduit à : \u003ccode\u003eNOPASSWD: /usr/bin/openresty -t, /usr/bin/openresty, /bin/systemctl reload openresty, /usr/local/bin/csp-backup.sh, /usr/local/bin/csp-restore.sh\u003c/code\u003e\u003c/p\u003e\n\u003ch3 id=\"i5--token-cf-en-clair\"\u003eI5 — Token CF en clair\u003c/h3\u003e\n\u003cp\u003eLe token Cloudflare est chiffré avec \u003ccode\u003eage\u003c/code\u003e (disponible dans Ubuntu 24.04 via apt) :\u003c/p\u003e\n\u003cdiv class=\"code-block code-line-numbers open\" data-start=\"0\"\u003e\n    \u003cdiv class=\"code-header language-bash\"\u003e\n        \u003cspan class=\"code-title\"\u003e\u003ci class=\"arrow fas fa-angle-right\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n        \u003cspan class=\"ellipses\"\u003e\u003ci class=\"fas fa-ellipsis-h\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n        \u003cspan class=\"copy\" title=\"Copier dans le presse-papiers\"\u003e\u003ci class=\"far fa-copy\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n    \u003c/div\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-bash\" data-lang=\"bash\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003eage-keygen -o /var/lib/csp-monitor/age-key.txt\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"nb\"\u003eecho\u003c/span\u003e -n \u003cspan class=\"s2\"\u003e\u0026#34;\u003c/span\u003e\u003cspan class=\"nv\"\u003e$CF_TOKEN\u003c/span\u003e\u003cspan class=\"s2\"\u003e\u0026#34;\u003c/span\u003e \u003cspan class=\"p\"\u003e|\u003c/span\u003e age -r \u003cspan class=\"s2\"\u003e\u0026#34;\u003c/span\u003e\u003cspan class=\"nv\"\u003e$AGE_PUBKEY\u003c/span\u003e\u003cspan class=\"s2\"\u003e\u0026#34;\u003c/span\u003e -o /var/lib/csp-monitor/cf-token.age\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003cp\u003eDécryptage à la volée dans le script :\u003c/p\u003e\n\u003cdiv class=\"code-block code-line-numbers open\" data-start=\"0\"\u003e\n    \u003cdiv class=\"code-header language-bash\"\u003e\n        \u003cspan class=\"code-title\"\u003e\u003ci class=\"arrow fas fa-angle-right\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n        \u003cspan class=\"ellipses\"\u003e\u003ci class=\"fas fa-ellipsis-h\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n        \u003cspan class=\"copy\" title=\"Copier dans le presse-papiers\"\u003e\u003ci class=\"far fa-copy\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n    \u003c/div\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-bash\" data-lang=\"bash\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"nv\"\u003ecf_token\u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"k\"\u003e$(\u003c/span\u003eage -d -i \u003cspan class=\"s2\"\u003e\u0026#34;\u003c/span\u003e\u003cspan class=\"nv\"\u003e$AGE_KEY_FILE\u003c/span\u003e\u003cspan class=\"s2\"\u003e\u0026#34;\u003c/span\u003e \u003cspan class=\"s2\"\u003e\u0026#34;\u003c/span\u003e\u003cspan class=\"nv\"\u003e$CF_TOKEN_AGE\u003c/span\u003e\u003cspan class=\"s2\"\u003e\u0026#34;\u003c/span\u003e\u003cspan class=\"k\"\u003e)\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003ch3 id=\"autres-findings\"\u003eAutres findings\u003c/h3\u003e\n\u003ctable\u003e\n  \u003cthead\u003e\n      \u003ctr\u003e\n          \u003cth\u003eFinding\u003c/th\u003e\n          \u003cth\u003eFix\u003c/th\u003e\n      \u003c/tr\u003e\n  \u003c/thead\u003e\n  \u003ctbody\u003e\n      \u003ctr\u003e\n          \u003ctd\u003eI1 lockfile\u003c/td\u003e\n          \u003ctd\u003e\u003ccode\u003eflock -n 9 /run/lock/csp-monitor.lock\u003c/code\u003e\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003eI2 findRE Mermaid trop large\u003c/td\u003e\n          \u003ctd\u003e\u003ccode\u003eclass=\u0026quot;mermaid\u0026quot;\u003c/code\u003e exact → évite \u003ccode\u003emermaid-extra\u003c/code\u003e\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003eI3 SRI Mermaid hardcodé\u003c/td\u003e\n          \u003ctd\u003e\u003ccode\u003eresources.GetRemote $mermaidURL\u003c/code\u003e → hash calculé au build\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003eI4 \u003ccode\u003eawk|wc -l\u003c/code\u003e pipefail\u003c/td\u003e\n          \u003ctd\u003e\u003ccode\u003e|| echo 0\u003c/code\u003e sur toutes les pipelines\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003eM1 known-patterns.txt illimité\u003c/td\u003e\n          \u003ctd\u003eFIFO cap 1000 lignes\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003eM2 double check canonical\u003c/td\u003e\n          \u003ctd\u003eRepair CF déclenché uniquement si directive manquante\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003eM3 regex inline scripts\u003c/td\u003e\n          \u003ctd\u003eExclut \u003ccode\u003etype=\u0026quot;module\u0026quot;\u003c/code\u003e et \u003ccode\u003etype=\u0026quot;application/json\u0026quot;\u003c/code\u003e\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003eM4 pas de heartbeat\u003c/td\u003e\n          \u003ctd\u003e\u003ccode\u003ecurl $BETTERSTACK_HEARTBEAT\u003c/code\u003e en fin de run\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003eM5 \u003ccode\u003emkdir -p\u003c/code\u003e sans permissions\u003c/td\u003e\n          \u003ctd\u003e\u003ccode\u003echmod 700 $STATE_DIR\u003c/code\u003e après création\u003c/td\u003e\n      \u003c/tr\u003e\n  \u003c/tbody\u003e\n\u003c/table\u003e\n\u003chr\u003e\n\u003ch2 id=\"acte-5--bugs-associés-découverts-en-chemin\"\u003eActe 5 : bugs associés découverts en chemin\u003c/h2\u003e\n\u003ch3 id=\"anti-flashjs--typeerror-null-body\"\u003eanti-flash.js — TypeError null body\u003c/h3\u003e\n\u003cp\u003eLe script anti-flash est chargé en \u003ccode\u003e\u0026lt;head\u0026gt;\u003c/code\u003e sans \u003ccode\u003edefer\u003c/code\u003e (c\u0026rsquo;est le point : il doit s\u0026rsquo;exécuter avant le rendu pour éviter le flash de thème). Problème : \u003ccode\u003edocument.body\u003c/code\u003e est \u003ccode\u003enull\u003c/code\u003e à ce stade.\u003c/p\u003e\n\u003cp\u003eAncien code :\u003c/p\u003e\n\u003cdiv class=\"code-block code-line-numbers open\" data-start=\"0\"\u003e\n    \u003cdiv class=\"code-header language-js\"\u003e\n        \u003cspan class=\"code-title\"\u003e\u003ci class=\"arrow fas fa-angle-right\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n        \u003cspan class=\"ellipses\"\u003e\u003ci class=\"fas fa-ellipsis-h\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n        \u003cspan class=\"copy\" title=\"Copier dans le presse-papiers\"\u003e\u003ci class=\"far fa-copy\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n    \u003c/div\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-js\" data-lang=\"js\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"nb\"\u003edocument\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"nx\"\u003ebody\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"nx\"\u003esetAttribute\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"s1\"\u003e\u0026#39;theme\u0026#39;\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"nx\"\u003ed\u003c/span\u003e \u003cspan class=\"o\"\u003e?\u003c/span\u003e \u003cspan class=\"s1\"\u003e\u0026#39;dark\u0026#39;\u003c/span\u003e \u003cspan class=\"o\"\u003e:\u003c/span\u003e \u003cspan class=\"s1\"\u003e\u0026#39;light\u0026#39;\u003c/span\u003e\u003cspan class=\"p\"\u003e);\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c1\"\u003e// → TypeError: Cannot read properties of null (reading \u0026#39;setAttribute\u0026#39;)\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003cp\u003eFix : utiliser \u003ccode\u003edocument.documentElement\u003c/code\u003e immédiatement, puis miroir sur \u003ccode\u003edocument.body\u003c/code\u003e via \u003ccode\u003eDOMContentLoaded\u003c/code\u003e :\u003c/p\u003e\n\u003cdiv class=\"code-block code-line-numbers open\" data-start=\"0\"\u003e\n    \u003cdiv class=\"code-header language-js\"\u003e\n        \u003cspan class=\"code-title\"\u003e\u003ci class=\"arrow fas fa-angle-right\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n        \u003cspan class=\"ellipses\"\u003e\u003ci class=\"fas fa-ellipsis-h\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n        \u003cspan class=\"copy\" title=\"Copier dans le presse-papiers\"\u003e\u003ci class=\"far fa-copy\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n    \u003c/div\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-js\" data-lang=\"js\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"kr\"\u003econst\u003c/span\u003e \u003cspan class=\"nx\"\u003ev\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"nx\"\u003ed\u003c/span\u003e \u003cspan class=\"o\"\u003e?\u003c/span\u003e \u003cspan class=\"s1\"\u003e\u0026#39;dark\u0026#39;\u003c/span\u003e \u003cspan class=\"o\"\u003e:\u003c/span\u003e \u003cspan class=\"s1\"\u003e\u0026#39;light\u0026#39;\u003c/span\u003e\u003cspan class=\"p\"\u003e;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"nb\"\u003edocument\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"nx\"\u003edocumentElement\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"nx\"\u003esetAttribute\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"s1\"\u003e\u0026#39;theme\u0026#39;\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"nx\"\u003ev\u003c/span\u003e\u003cspan class=\"p\"\u003e);\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"nb\"\u003edocument\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"nx\"\u003edocumentElement\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"nx\"\u003esetAttribute\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"s1\"\u003e\u0026#39;cfg-theme\u0026#39;\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"nx\"\u003et\u003c/span\u003e\u003cspan class=\"p\"\u003e);\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"k\"\u003eif\u003c/span\u003e \u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nb\"\u003edocument\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"nx\"\u003ebody\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"nb\"\u003edocument\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"nx\"\u003ebody\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"nx\"\u003esetAttribute\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"s1\"\u003e\u0026#39;theme\u0026#39;\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"nx\"\u003ev\u003c/span\u003e\u003cspan class=\"p\"\u003e);\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"nb\"\u003edocument\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"nx\"\u003ebody\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"nx\"\u003esetAttribute\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"s1\"\u003e\u0026#39;cfg-theme\u0026#39;\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"nx\"\u003et\u003c/span\u003e\u003cspan class=\"p\"\u003e);\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e}\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003cp\u003e\u003ccode\u003edocument.documentElement\u003c/code\u003e (la balise \u003ccode\u003e\u0026lt;html\u0026gt;\u003c/code\u003e) est toujours disponible, y compris pendant le parsing du \u003ccode\u003e\u0026lt;head\u0026gt;\u003c/code\u003e. Les sélecteurs comme \u003ccode\u003e[theme=dark] header\u003c/code\u003e utilisent un ancêtre — le header est correct immédiatement. En revanche, \u003ccode\u003ebody[theme=dark]\u003c/code\u003e requiert l\u0026rsquo;attribut directement sur \u003ccode\u003ebody\u003c/code\u003e : le fond blanc persiste jusqu\u0026rsquo;au \u003ccode\u003eDOMContentLoaded\u003c/code\u003e. Ce bug résiduel est corrigé à l\u0026rsquo;Acte 6.\u003c/p\u003e\n\u003ch3 id=\"javascriptvoid0--violations-csp-script-src-elem\"\u003ejavascript:void(0) — violations CSP script-src-elem\u003c/h3\u003e\n\u003cp\u003eLe thème LoveIt utilise \u003ccode\u003ehref=\u0026quot;javascript:void(0);\u0026quot;\u003c/code\u003e pour les boutons toggle (theme-switch, recherche, langue). Avec une CSP \u003ccode\u003escript-src-elem\u003c/code\u003e sans \u003ccode\u003e'unsafe-inline'\u003c/code\u003e, ces navigations vers une URL \u003ccode\u003ejavascript:\u003c/code\u003e sont bloquées.\u003c/p\u003e\n\u003cp\u003eFix : override de \u003ccode\u003elayouts/_partials/header.html\u003c/code\u003e avec \u003ccode\u003ehref=\u0026quot;#\u0026quot; onclick=\u0026quot;return false;\u0026quot;\u003c/code\u003e. Les event handlers \u003ccode\u003eonclick=\u003c/code\u003e sont couverts par \u003ccode\u003escript-src-attr 'unsafe-inline'\u003c/code\u003e, et \u003ccode\u003ereturn false\u003c/code\u003e empêche le scroll vers \u003ccode\u003e#\u003c/code\u003e.\u003c/p\u003e\n\u003cp\u003eHugo minifie automatiquement en \u003ccode\u003eonclick=return!1\u003c/code\u003e — valide.\u003c/p\u003e\n\u003chr\u003e\n\u003chr\u003e\n\u003ch2 id=\"acte-6--corrections-de-deuxième-passe-lendemain\"\u003eActe 6 : corrections de deuxième passe (lendemain)\u003c/h2\u003e\n\u003ch3 id=\"flash-body--le-fix-de-lacte-5-était-incomplet\"\u003eFlash body — le fix de l\u0026rsquo;Acte 5 était incomplet\u003c/h3\u003e\n\u003cp\u003eLa correction anti-flash de l\u0026rsquo;Acte 5 couvrait le header mais pas le fond du body. Deux sélecteurs CSS coexistent dans LoveIt avec des comportements différents :\u003c/p\u003e\n\u003ctable\u003e\n  \u003cthead\u003e\n      \u003ctr\u003e\n          \u003cth\u003eSélecteur CSS\u003c/th\u003e\n          \u003cth\u003eMécanisme\u003c/th\u003e\n          \u003cth\u003eRésultat au premier paint\u003c/th\u003e\n      \u003c/tr\u003e\n  \u003c/thead\u003e\n  \u003ctbody\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e\u003ccode\u003e[theme=dark] header { ... }\u003c/code\u003e\u003c/td\u003e\n          \u003ctd\u003eAncêtre — matche dès que \u003ccode\u003ehtml\u003c/code\u003e a l\u0026rsquo;attribut\u003c/td\u003e\n          \u003ctd\u003eCorrect immédiatement ✓\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e\u003ccode\u003ebody[theme=dark] { background-color: #292a2d }\u003c/code\u003e\u003c/td\u003e\n          \u003ctd\u003eDirect — requiert l\u0026rsquo;attribut sur \u003ccode\u003ebody\u003c/code\u003e\u003c/td\u003e\n          \u003ctd\u003eFond blanc jusqu\u0026rsquo;à \u003ccode\u003eDOMContentLoaded\u003c/code\u003e ✗\u003c/td\u003e\n      \u003c/tr\u003e\n  \u003c/tbody\u003e\n\u003c/table\u003e\n\u003cp\u003eSymptôme visible : header sombre correct, body fond blanc pendant ~200 ms à chaque navigation.\u003c/p\u003e\n\u003cp\u003eFix : ajouter dans \u003ccode\u003eassets/css/_custom.scss\u003c/code\u003e un sélecteur ancêtre pour le body :\u003c/p\u003e\n\u003cdiv class=\"code-block code-line-numbers open\" data-start=\"0\"\u003e\n    \u003cdiv class=\"code-header language-scss\"\u003e\n        \u003cspan class=\"code-title\"\u003e\u003ci class=\"arrow fas fa-angle-right\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n        \u003cspan class=\"ellipses\"\u003e\u003ci class=\"fas fa-ellipsis-h\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n        \u003cspan class=\"copy\" title=\"Copier dans le presse-papiers\"\u003e\u003ci class=\"far fa-copy\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n    \u003c/div\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-scss\" data-lang=\"scss\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"nt\"\u003ehtml\u003c/span\u003e\u003cspan class=\"o\"\u003e[\u003c/span\u003e\u003cspan class=\"nt\"\u003etheme\u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"nt\"\u003edark\u003c/span\u003e\u003cspan class=\"o\"\u003e]\u003c/span\u003e \u003cspan class=\"nt\"\u003ebody\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e  \u003cspan class=\"na\"\u003ebackground-color\u003c/span\u003e\u003cspan class=\"o\"\u003e:\u003c/span\u003e \u003cspan class=\"mh\"\u003e#292a2d\u003c/span\u003e\u003cspan class=\"p\"\u003e;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e  \u003cspan class=\"na\"\u003ecolor\u003c/span\u003e\u003cspan class=\"o\"\u003e:\u003c/span\u003e \u003cspan class=\"mh\"\u003e#a9a9b3\u003c/span\u003e\u003cspan class=\"p\"\u003e;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e}\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003cp\u003eSpécificité \u003ccode\u003e(0,1,2)\u003c/code\u003e \u0026gt; \u003ccode\u003ebody {}\u003c/code\u003e \u003ccode\u003e(0,0,1)\u003c/code\u003e → s\u0026rsquo;applique dès le premier paint, avant tout JavaScript. \u003ccode\u003ebody[theme=dark]\u003c/code\u003e prend ensuite le relais après \u003ccode\u003eDOMContentLoaded\u003c/code\u003e avec les mêmes valeurs, sans régression.\u003c/p\u003e\n\u003ch3 id=\"mermaid-sri--integrity-vide-sans-fingerprint\"\u003eMermaid SRI — integrity vide sans Fingerprint\u003c/h3\u003e\n\u003cp\u003e\u003ccode\u003eresources.GetRemote\u003c/code\u003e seul ne calcule pas le hash SRI — \u003ccode\u003e.Data.Integrity\u003c/code\u003e reste vide, ce qui donne \u003ccode\u003eintegrity=\u0026quot;\u0026quot;\u003c/code\u003e dans le HTML.\u003c/p\u003e\n\u003cp\u003eFix : piper par \u003ccode\u003eresources.Fingerprint \u0026quot;sha256\u0026quot;\u003c/code\u003e dans \u003ccode\u003elayouts/_partials/assets.html\u003c/code\u003e :\u003c/p\u003e\n\u003cdiv class=\"code-block code-line-numbers open\" data-start=\"0\"\u003e\n    \u003cdiv class=\"code-header language-go\"\u003e\n        \u003cspan class=\"code-title\"\u003e\u003ci class=\"arrow fas fa-angle-right\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n        \u003cspan class=\"ellipses\"\u003e\u003ci class=\"fas fa-ellipsis-h\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n        \u003cspan class=\"copy\" title=\"Copier dans le presse-papiers\"\u003e\u003ci class=\"far fa-copy\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n    \u003c/div\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-go\" data-lang=\"go\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e{{\u003c/span\u003e\u003cspan class=\"o\"\u003e-\u003c/span\u003e \u003cspan class=\"err\"\u003e$\u003c/span\u003e\u003cspan class=\"nx\"\u003emermaidRes\u003c/span\u003e \u003cspan class=\"o\"\u003e:=\u003c/span\u003e \u003cspan class=\"nx\"\u003eresources\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"nx\"\u003eGetRemote\u003c/span\u003e \u003cspan class=\"err\"\u003e$\u003c/span\u003e\u003cspan class=\"nx\"\u003emermaidURL\u003c/span\u003e \u003cspan class=\"o\"\u003e-\u003c/span\u003e\u003cspan class=\"p\"\u003e}}\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e{{\u003c/span\u003e\u003cspan class=\"o\"\u003e-\u003c/span\u003e \u003cspan class=\"err\"\u003e$\u003c/span\u003e\u003cspan class=\"nx\"\u003emermaidFP\u003c/span\u003e \u003cspan class=\"o\"\u003e:=\u003c/span\u003e \u003cspan class=\"err\"\u003e$\u003c/span\u003e\u003cspan class=\"nx\"\u003emermaidRes\u003c/span\u003e \u003cspan class=\"p\"\u003e|\u003c/span\u003e \u003cspan class=\"nx\"\u003eresources\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"nx\"\u003eFingerprint\u003c/span\u003e \u003cspan class=\"s\"\u003e\u0026#34;sha256\u0026#34;\u003c/span\u003e \u003cspan class=\"o\"\u003e-\u003c/span\u003e\u003cspan class=\"p\"\u003e}}\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e\u0026lt;\u003c/span\u003e\u003cspan class=\"nx\"\u003escript\u003c/span\u003e \u003cspan class=\"nx\"\u003esrc\u003c/span\u003e\u003cspan class=\"p\"\u003e=\u003c/span\u003e\u003cspan class=\"s\"\u003e\u0026#34;{{ $mermaidURL }}\u0026#34;\u003c/span\u003e \u003cspan class=\"nx\"\u003eintegrity\u003c/span\u003e\u003cspan class=\"p\"\u003e=\u003c/span\u003e\u003cspan class=\"s\"\u003e\u0026#34;{{ $mermaidFP.Data.Integrity }}\u0026#34;\u003c/span\u003e \u003cspan class=\"nx\"\u003ecrossorigin\u003c/span\u003e\u003cspan class=\"p\"\u003e=\u003c/span\u003e\u003cspan class=\"s\"\u003e\u0026#34;anonymous\u0026#34;\u003c/span\u003e \u003cspan class=\"k\"\u003edefer\u003c/span\u003e\u003cspan class=\"p\"\u003e\u0026gt;\u0026lt;\u003c/span\u003e\u003cspan class=\"o\"\u003e/\u003c/span\u003e\u003cspan class=\"nx\"\u003escript\u003c/span\u003e\u003cspan class=\"p\"\u003e\u0026gt;\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003cp\u003e\u003ccode\u003eresources.GetRemote\u003c/code\u003e télécharge le fichier ; \u003ccode\u003eresources.Fingerprint\u003c/code\u003e calcule le hash. Les deux étapes sont requises.\u003c/p\u003e\n\u003ch3 id=\"score-observatory--pourquoi-ça-fluctue\"\u003eScore Observatory — pourquoi ça fluctue\u003c/h3\u003e\n\u003cp\u003eDeux URL scannées, deux résultats :\u003c/p\u003e\n\u003ctable\u003e\n  \u003cthead\u003e\n      \u003ctr\u003e\n          \u003cth\u003eURL scannée\u003c/th\u003e\n          \u003cth\u003eCF-Cache-Status\u003c/th\u003e\n          \u003cth\u003eScore\u003c/th\u003e\n      \u003c/tr\u003e\n  \u003c/thead\u003e\n  \u003ctbody\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e\u003ccode\u003ewww.arleo.eu\u003c/code\u003e (direct)\u003c/td\u003e\n          \u003ctd\u003eHIT\u003c/td\u003e\n          \u003ctd\u003e\u003cstrong\u003e140\u003c/strong\u003e ✓\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e\u003ccode\u003earleo.eu → www.arleo.eu\u003c/code\u003e (redirect)\u003c/td\u003e\n          \u003ctd\u003eBYPASS\u003c/td\u003e\n          \u003ctd\u003e\u003cstrong\u003e120\u003c/strong\u003e ✗\u003c/td\u003e\n      \u003c/tr\u003e\n  \u003c/tbody\u003e\n\u003c/table\u003e\n\u003cp\u003eQuand Observatory démarre sur \u003ccode\u003earleo.eu\u003c/code\u003e (sans www), CF sert parfois une réponse BYPASS (non-cachée) avec des headers issus d\u0026rsquo;une entrée stale. Ce n\u0026rsquo;est pas le score de référence — toujours scanner \u003ccode\u003ewww.arleo.eu\u003c/code\u003e directement.\u003c/p\u003e\n\u003cp\u003eCause additionnelle initiale : CF \u003cstrong\u003eBot Fight Mode\u003c/strong\u003e interceptait le scanner Observatory et lui servait une challenge page avec sa propre CSP minimale (\u003ccode\u003edefault-src 'none'; style-src 'unsafe-inline'; script-src https://challenges.cloudflare.com ...\u003c/code\u003e) — ce qui expliquait les scans à 120 avant notre intervention. Fix : ajouter \u003ccode\u003eor (http.user_agent contains \u0026quot;http-observatory\u0026quot;)\u003c/code\u003e à la règle WAF skip qui bypasse déjà BIC/SBFM.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003eScore de référence stabilisé : A+ 140/100, 10/10 tests\u003c/strong\u003e, confirmé sur plusieurs scans successifs de \u003ccode\u003ewww.arleo.eu\u003c/code\u003e.\u003c/p\u003e\n\u003ch2 id=\"résultat-final\"\u003eRésultat final\u003c/h2\u003e\n\u003cdiv class=\"code-block code-line-numbers open\" data-start=\"0\"\u003e\n    \u003cdiv class=\"code-header language-\"\u003e\n        \u003cspan class=\"code-title\"\u003e\u003ci class=\"arrow fas fa-angle-right\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n        \u003cspan class=\"ellipses\"\u003e\u003ci class=\"fas fa-ellipsis-h\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n        \u003cspan class=\"copy\" title=\"Copier dans le presse-papiers\"\u003e\u003ci class=\"far fa-copy\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n    \u003c/div\u003e\u003cpre tabindex=\"0\"\u003e\u003ccode\u003eMozilla Observatory : A+ 140/100 (10/10 tests)\nCSP violations.log  : 0 nouvelles (CF JS Detections désactivé)\ncsp-monitor.sh      : exit 0 propre toutes les 15 min\nDark/light switch   : 3 cycles testés Puppeteer, 0 erreur JS\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\n\u003cp\u003eLa chaîne complète Hugo → OpenResty → Cloudflare est maintenant surveillée automatiquement avec rollback en cas de régression, et les credentials Cloudflare sont chiffrés au repos.\u003c/p\u003e\n",
        "summary": "Contexte arleo.eu tourne sur Hugo (VM KVM) → OpenResty (NUC) → Cloudflare (CDN/WAF). Objectif : score A+ sur Mozilla Observatory avec une CSP stricte qui résiste aux injections côté edge.\nLe parcours a traversé trois stratégies en quelques semaines — nonces, hashes, puis origin allowlist — avant d\u0026rsquo;aboutir à une solution stable et automatisée.\nActe 1 : pourquoi hash-based a échoué L\u0026rsquo;idée de départ semblait solide : Hugo Pipes externalise tout le JS avec SRI, on liste les hashes dans la CSP, résultat propre. En pratique, deux problèmes ont rendu l\u0026rsquo;approche impossible.\n",
        "date_published": "2026-05-28T23:40:12+02:00",
        "date_modified": "2026-05-29T21:30:00+02:00"
      },
      {
        "id": "https://www.arleo.eu/posts/postmortem-crowdsec-appsec-false-positive-sonarr/",
        "url": "https://www.arleo.eu/posts/postmortem-crowdsec-appsec-false-positive-sonarr/",
        "title": "Postmortem — CrowdSec AppSec : faux positif heuristique sur Sonarr/Radarr",
        "content_html": "\u003ch2 id=\"résumé\"\u003eRésumé\u003c/h2\u003e\n\u003cp\u003eLe \u003cstrong\u003e25 mai 2026 vers 22h02 (heure locale)\u003c/strong\u003e, Sonarr et Radarr sont devenus totalement inaccessibles depuis l\u0026rsquo;IP maison (\u003ccode\u003e82.XX.XX.XX\u003c/code\u003e), retournant \u003cstrong\u003e403\u003c/strong\u003e sur toutes les URLs y compris \u003ccode\u003e/login\u003c/code\u003e. Le service était pourtant opérationnel. Le diagnostic initial suspectait les récents déploiements du refactor \u003ccode\u003ecrowdsec-cf-sync\u003c/code\u003e — la cause réelle est un \u003cstrong\u003efaux positif heuristique CrowdSec AppSec\u003c/strong\u003e.\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"timeline\"\u003eTimeline\u003c/h2\u003e\n\u003ctable\u003e\n  \u003cthead\u003e\n      \u003ctr\u003e\n          \u003cth\u003eHeure (locale)\u003c/th\u003e\n          \u003cth\u003eÉvénement\u003c/th\u003e\n      \u003c/tr\u003e\n  \u003c/thead\u003e\n  \u003ctbody\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e~22h00\u003c/td\u003e\n          \u003ctd\u003eCookie de session Sonarr expiré côté navigateur\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e22h02:31\u003c/td\u003e\n          \u003ctd\u003eLe navigateur charge la bibliothèque Sonarr → tente de charger 20+ \u003ccode\u003e/MediaCover/*.jpg\u003c/code\u003e simultanément\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e22h02:31\u003c/td\u003e\n          \u003ctd\u003eSonarr retourne 302 → \u003ccode\u003e/login\u003c/code\u003e pour chaque image (session invalide)\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e22h02:34\u003c/td\u003e\n          \u003ctd\u003eLa connexion SignalR WebSocket s\u0026rsquo;établit (101) via \u003ccode\u003eaccess_token\u003c/code\u003e dans l\u0026rsquo;URL\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e~22h05\u003c/td\u003e\n          \u003ctd\u003eCrowdSec AppSec déclenche la règle heuristique \u003ccode\u003ehttp-probing\u003c/code\u003e : rafale de requêtes échouées depuis la même IP\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e22h11:37\u003c/td\u003e\n          \u003ctd\u003eToutes les requêtes de \u003ccode\u003e82.XX.XX.XX\u003c/code\u003e retournent 403 — \u003ccode\u003ecs_reason=heuristic\u003c/code\u003e dans les logs nginx\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e22h13:34\u003c/td\u003e\n          \u003ctd\u003eMême \u003ccode\u003e/login\u003c/code\u003e bloqué — l\u0026rsquo;IP ne peut plus s\u0026rsquo;authentifier\u003c/td\u003e\n      \u003c/tr\u003e\n  \u003c/tbody\u003e\n\u003c/table\u003e\n\u003chr\u003e\n\u003ch2 id=\"cause-racine\"\u003eCause racine\u003c/h2\u003e\n\u003cp\u003eCrowdSec AppSec maintient un \u003cstrong\u003eétat heuristique en mémoire\u003c/strong\u003e, distinct des décisions LAPI. Lorsque le navigateur tente de charger simultanément de nombreuses ressources et reçoit des 302/403 de l\u0026rsquo;application upstream (Sonarr), AppSec interprète cette rafale d\u0026rsquo;échecs comme du sondage agressif (\u003ccode\u003ehttp-probing\u003c/code\u003e) et bloque l\u0026rsquo;IP source.\u003c/p\u003e\n\u003cp\u003eLe problème : l\u0026rsquo;IP maison est dans l\u0026rsquo;allowlist LAPI (\u003ccode\u003emy_allowlist\u003c/code\u003e) et dans le bloc \u003ccode\u003egeo $lan\u003c/code\u003e nginx, mais \u003cstrong\u003el\u0026rsquo;heuristique AppSec ne consulte pas l\u0026rsquo;allowlist LAPI\u003c/strong\u003e — elle opère indépendamment sur des signaux comportementaux.\u003c/p\u003e\n\u003cdiv class=\"code-block code-line-numbers open\" data-start=\"0\"\u003e\n    \u003cdiv class=\"code-header language-\"\u003e\n        \u003cspan class=\"code-title\"\u003e\u003ci class=\"arrow fas fa-angle-right\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n        \u003cspan class=\"ellipses\"\u003e\u003ci class=\"fas fa-ellipsis-h\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n        \u003cspan class=\"copy\" title=\"Copier dans le presse-papiers\"\u003e\u003ci class=\"far fa-copy\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n    \u003c/div\u003e\u003cpre tabindex=\"0\"\u003e\u003ccode\u003ecscli decisions list --ip 82.XX.XX.XX\n→ No active decisions   ← l\u0026#39;allowlist LAPI est respectée\n\nnginx access log :\n82.XX.XX.XX ... \u0026#34;GET /\u0026#34; 403 ... cs_reason=heuristic   ← AppSec en mémoire bloque\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\n\u003chr\u003e\n\u003ch2 id=\"ce-qui-nétait-pas-la-cause\"\u003eCe qui n\u0026rsquo;était PAS la cause\u003c/h2\u003e\n\u003cp\u003ePlusieurs pistes ont été écartées dans l\u0026rsquo;ordre :\u003c/p\u003e\n\u003ctable\u003e\n  \u003cthead\u003e\n      \u003ctr\u003e\n          \u003cth\u003ePiste\u003c/th\u003e\n          \u003cth\u003eRésultat\u003c/th\u003e\n      \u003c/tr\u003e\n  \u003c/thead\u003e\n  \u003ctbody\u003e\n      \u003ctr\u003e\n          \u003ctd\u003eRefactor \u003ccode\u003ecrowdsec-cf-sync\u003c/code\u003e (redéploiement du jour)\u003c/td\u003e\n          \u003ctd\u003e❌ Ne touche pas nginx, AppSec, ni Sonarr\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003eBan LAPI CrowdSec (\u003ccode\u003ecscli decisions list\u003c/code\u003e)\u003c/td\u003e\n          \u003ctd\u003e❌ Aucune décision active pour l\u0026rsquo;IP\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e\u003ccode\u003ebans.json\u003c/code\u003e Lua IPC\u003c/td\u003e\n          \u003ctd\u003e❌ 3 IPs tierces uniquement, pas l\u0026rsquo;IP maison\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003eBloc nginx \u003ccode\u003egeo $lan\u003c/code\u003e\u003c/td\u003e\n          \u003ctd\u003e❌ IP présente dans le bloc (\u003ccode\u003e$lan = 1\u003c/code\u003e)\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003eAPI key Sonarr invalide\u003c/td\u003e\n          \u003ctd\u003e❌ \u003ccode\u003e/api/v3/system/status\u003c/code\u003e retourne 200 avec la clé\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003eProblème réseau / VM Sonarr\u003c/td\u003e\n          \u003ctd\u003e❌ VM répond (302 attendu pour session expirée)\u003c/td\u003e\n      \u003c/tr\u003e\n  \u003c/tbody\u003e\n\u003c/table\u003e\n\u003chr\u003e\n\u003ch2 id=\"impact\"\u003eImpact\u003c/h2\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003eDurée\u003c/strong\u003e : ~15–20 minutes (détection manuelle)\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003ePérimètre\u003c/strong\u003e : Sonarr et Radarr sur \u003ccode\u003evm-media\u003c/code\u003e uniquement\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eUtilisateurs\u003c/strong\u003e : 1 (IP maison)\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eDonnées\u003c/strong\u003e : aucune perte\u003c/li\u003e\n\u003c/ul\u003e\n\u003chr\u003e\n\u003ch2 id=\"fix-immédiat\"\u003eFix immédiat\u003c/h2\u003e\n\u003cp\u003eRedémarrage de CrowdSec pour vider l\u0026rsquo;état heuristique en mémoire :\u003c/p\u003e\n\u003cdiv class=\"code-block code-line-numbers open\" data-start=\"0\"\u003e\n    \u003cdiv class=\"code-header language-bash\"\u003e\n        \u003cspan class=\"code-title\"\u003e\u003ci class=\"arrow fas fa-angle-right\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n        \u003cspan class=\"ellipses\"\u003e\u003ci class=\"fas fa-ellipsis-h\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n        \u003cspan class=\"copy\" title=\"Copier dans le presse-papiers\"\u003e\u003ci class=\"far fa-copy\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n    \u003c/div\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-bash\" data-lang=\"bash\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003esudo systemctl restart crowdsec\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003chr\u003e\n\u003ch2 id=\"fix-permanent\"\u003eFix permanent\u003c/h2\u003e\n\u003cp\u003eAjout d\u0026rsquo;un bypass Lua dans \u003ccode\u003e/etc/openresty/snippets/crowdsec_access.conf\u003c/code\u003e pour exempter les IPs LAN de tout check AppSec avant même la vérification LAPI :\u003c/p\u003e\n\u003cdiv class=\"code-block code-line-numbers open\" data-start=\"0\"\u003e\n    \u003cdiv class=\"code-header language-nginx\"\u003e\n        \u003cspan class=\"code-title\"\u003e\u003ci class=\"arrow fas fa-angle-right\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n        \u003cspan class=\"ellipses\"\u003e\u003ci class=\"fas fa-ellipsis-h\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n        \u003cspan class=\"copy\" title=\"Copier dans le presse-papiers\"\u003e\u003ci class=\"far fa-copy\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n    \u003c/div\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-nginx\" data-lang=\"nginx\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"k\"\u003eaccess_by_lua_block\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"kn\"\u003eif\u003c/span\u003e \u003cspan class=\"s\"\u003engx.var.lan\u003c/span\u003e \u003cspan class=\"p\"\u003e==\u003c/span\u003e \u003cspan class=\"s\"\u003e\u0026#34;1\u0026#34;\u003c/span\u003e \u003cspan class=\"s\"\u003ethen\u003c/span\u003e \u003cspan class=\"s\"\u003ereturn\u003c/span\u003e \u003cspan class=\"s\"\u003eend\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"s\"\u003erequire(\u0026#34;crowdsec.access\u0026#34;).check()\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"err\"\u003e}\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003cp\u003eLa variable \u003ccode\u003e$lan\u003c/code\u003e est définie par le bloc \u003ccode\u003egeo\u003c/code\u003e dans \u003ccode\u003econf.d/lan.conf\u003c/code\u003e et couvre les plages RFC 1918 (\u003ccode\u003e192.168.0.0/16\u003c/code\u003e, \u003ccode\u003e10.0.0.0/8\u003c/code\u003e, \u003ccode\u003e172.16.0.0/12\u003c/code\u003e) ainsi que l\u0026rsquo;IP maison fixe.\u003c/p\u003e\n\u003cp\u003eLes IPs externes continuent à passer par le check complet (LAPI + AppSec).\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"leçons\"\u003eLeçons\u003c/h2\u003e\n\u003col\u003e\n\u003cli\u003e\n\u003cp\u003e\u003cstrong\u003eL\u0026rsquo;allowlist LAPI ne protège pas contre l\u0026rsquo;heuristique AppSec.\u003c/strong\u003e Les deux systèmes sont indépendants — une IP peut être dans \u003ccode\u003emy_allowlist\u003c/code\u003e et quand même être bloquée en mémoire par AppSec.\u003c/p\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003cp\u003e\u003cstrong\u003eLes rafales légitimes déclenchent les règles heuristiques.\u003c/strong\u003e Sonarr et Radarr chargent des dizaines d\u0026rsquo;images couverture au premier affichage — pattern indiscernable d\u0026rsquo;un scan d\u0026rsquo;assets.\u003c/p\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003cp\u003e\u003cstrong\u003e\u003ccode\u003ecs_nginx_ban: false\u003c/code\u003e ne signifie pas \u0026ldquo;non bloqué\u0026rdquo;.\u003c/strong\u003e Ce champ reflète uniquement le bouncer LAPI. Un bloc AppSec heuristique retourne 403 avec \u003ccode\u003ecs_reason=heuristic\u003c/code\u003e dans le log nginx — champ absent du pipeline Vector/BetterStack.\u003c/p\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003cp\u003e\u003cstrong\u003eLe diagnostic via BetterStack a ses limites.\u003c/strong\u003e La source \u003ccode\u003enginx_status\u003c/code\u003e n\u0026rsquo;inclut pas \u003ccode\u003ecs_reason\u003c/code\u003e — la cause réelle n\u0026rsquo;était visible que dans les logs nginx bruts (\u003ccode\u003e/var/log/nginx/vm-media.access.log\u003c/code\u003e).\u003c/p\u003e\n\u003c/li\u003e\n\u003c/ol\u003e\n",
        "summary": "Résumé Le 25 mai 2026 vers 22h02 (heure locale), Sonarr et Radarr sont devenus totalement inaccessibles depuis l\u0026rsquo;IP maison (82.XX.XX.XX), retournant 403 sur toutes les URLs y compris /login. Le service était pourtant opérationnel. Le diagnostic initial suspectait les récents déploiements du refactor crowdsec-cf-sync — la cause réelle est un faux positif heuristique CrowdSec AppSec.\nTimeline Heure (locale) Événement ~22h00 Cookie de session Sonarr expiré côté navigateur 22h02:31 Le navigateur charge la bibliothèque Sonarr → tente de charger 20+ /MediaCover/*.jpg simultanément 22h02:31 Sonarr retourne 302 → /login pour chaque image (session invalide) 22h02:34 La connexion SignalR WebSocket s\u0026rsquo;établit (101) via access_token dans l\u0026rsquo;URL ~22h05 CrowdSec AppSec déclenche la règle heuristique http-probing : rafale de requêtes échouées depuis la même IP 22h11:37 Toutes les requêtes de 82.XX.XX.XX retournent 403 — cs_reason=heuristic dans les logs nginx 22h13:34 Même /login bloqué — l\u0026rsquo;IP ne peut plus s\u0026rsquo;authentifier Cause racine CrowdSec AppSec maintient un état heuristique en mémoire, distinct des décisions LAPI. Lorsque le navigateur tente de charger simultanément de nombreuses ressources et reçoit des 302/403 de l\u0026rsquo;application upstream (Sonarr), AppSec interprète cette rafale d\u0026rsquo;échecs comme du sondage agressif (http-probing) et bloque l\u0026rsquo;IP source.\n",
        "date_published": "2026-05-25T20:51:17+02:00",
        "date_modified": "2026-05-25T21:03:14+02:00"
      },
      {
        "id": "https://www.arleo.eu/posts/crowdsec-appsec-openresty/",
        "url": "https://www.arleo.eu/posts/crowdsec-appsec-openresty/",
        "title": "CrowdSec AppSec + OpenResty : WAF moderne sans ModSecurity",
        "content_html": "\u003cp\u003eAprès des années avec ModSecurity + OWASP CRS sur nginx, j\u0026rsquo;ai migré arleo.eu vers une stack plus moderne : \u003cstrong\u003eCrowdSec AppSec sur OpenResty\u003c/strong\u003e. Le résultat est une architecture WAF inline mieux intégrée, plus facile à maintenir, et plus cohérente avec le reste de la stack de sécurité.\u003c/p\u003e\n\u003ch2 id=\"pourquoi-abandonner-modsecurity-\"\u003ePourquoi abandonner ModSecurity ?\u003c/h2\u003e\n\u003cp\u003eModSecurity v2 est en fin de vie active. Le maintien des règles OWASP CRS sur nginx classique génère de la friction : faux positifs fréquents, logs difficiles à corréler avec CrowdSec, et une configuration dispersée entre plusieurs outils sans vision unifiée.\u003c/p\u003e\n\u003cp\u003eLe passage à \u003cstrong\u003eOpenResty\u003c/strong\u003e (nginx + LuaJIT natif) était déjà motivé par le bouncer CrowdSec qui repose sur Lua. CrowdSec AppSec s\u0026rsquo;inscrit naturellement dans cette architecture.\u003c/p\u003e\n\u003ch2 id=\"architecture\"\u003eArchitecture\u003c/h2\u003e\n\u003cdiv class=\"code-block code-line-numbers open\" data-start=\"0\"\u003e\n    \u003cdiv class=\"code-header language-\"\u003e\n        \u003cspan class=\"code-title\"\u003e\u003ci class=\"arrow fas fa-angle-right\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n        \u003cspan class=\"ellipses\"\u003e\u003ci class=\"fas fa-ellipsis-h\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n        \u003cspan class=\"copy\" title=\"Copier dans le presse-papiers\"\u003e\u003ci class=\"far fa-copy\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n    \u003c/div\u003e\u003cpre tabindex=\"0\"\u003e\u003ccode\u003eInternet\n   ↓\nCloudflare (CDN + WAF cloud + DDoS)\n   ↓\nOpenResty (host NUC)\n   ↓\nLua bouncer CrowdSec\n   ↙              ↘\nIP check          AppSec engine\n(port 8080)       (port 7422)\n                      ↓\n               OWASP CRS + règles custom\n                      ↓ (si OK)\n               VM Hugo (192.168.122.69)\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\n\u003cp\u003eLe bouncer Lua intercepte chaque requête et la soumet en parallèle à deux composants :\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003eIP check (8080)\u003c/strong\u003e : décisions CrowdSec classiques (bans, allowlist)\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eAppSec engine (7422)\u003c/strong\u003e : analyse WAF inline — OWASP CRS, détection SQLi/XSS/path traversal\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003eLes IPs whitelistées CrowdSec court-circuitent AppSec (\u003ccode\u003eALWAYS_SEND_TO_APPSEC=false\u003c/code\u003e), ce qui évite la latence inutile pour les sources de confiance.\u003c/p\u003e\n\u003ch2 id=\"configuration-clé-du-bouncer\"\u003eConfiguration clé du bouncer\u003c/h2\u003e\n\u003cdiv class=\"code-block code-line-numbers open\" data-start=\"0\"\u003e\n    \u003cdiv class=\"code-header language-ini\"\u003e\n        \u003cspan class=\"code-title\"\u003e\u003ci class=\"arrow fas fa-angle-right\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n        \u003cspan class=\"ellipses\"\u003e\u003ci class=\"fas fa-ellipsis-h\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n        \u003cspan class=\"copy\" title=\"Copier dans le presse-papiers\"\u003e\u003ci class=\"far fa-copy\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n    \u003c/div\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-ini\" data-lang=\"ini\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c1\"\u003e# /etc/crowdsec/bouncers/crowdsec-openresty-bouncer.conf\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"na\"\u003eAPPSEC_URL\u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"s\"\u003ehttp://127.0.0.1:7422\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"na\"\u003eAPPSEC_FAILURE_ACTION\u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"s\"\u003epassthrough\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"na\"\u003eAPPSEC_CONNECT_TIMEOUT\u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"s\"\u003e500\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"na\"\u003eAPPSEC_SEND_TIMEOUT\u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"s\"\u003e500\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"na\"\u003eAPPSEC_PROCESS_TIMEOUT\u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"s\"\u003e2000\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"na\"\u003eALWAYS_SEND_TO_APPSEC\u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"s\"\u003efalse\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003cp\u003e\u003ccode\u003eAPPSEC_FAILURE_ACTION=passthrough\u003c/code\u003e est critique : si AppSec est indisponible (redémarrage de l\u0026rsquo;agent, pic de charge), le trafic passe quand même. Pas de blackout pour un composant WAF défaillant.\u003c/p\u003e\n\u003ch2 id=\"exception-pour-le-endpoint-mcp\"\u003eException pour le endpoint MCP\u003c/h2\u003e\n\u003cp\u003eLe proxy MCP (\u003ccode\u003e/mcp\u003c/code\u003e) transporte des payloads qui contiennent du code Markdown, bash, ini — ce qui fait monter le score OWASP CRS et déclenche des faux positifs. Une règle d\u0026rsquo;exception désactive 8 IDs CRS sur ce path uniquement :\u003c/p\u003e\n\u003cdiv class=\"code-block code-line-numbers open\" data-start=\"0\"\u003e\n    \u003cdiv class=\"code-header language-yaml\"\u003e\n        \u003cspan class=\"code-title\"\u003e\u003ci class=\"arrow fas fa-angle-right\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n        \u003cspan class=\"ellipses\"\u003e\u003ci class=\"fas fa-ellipsis-h\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n        \u003cspan class=\"copy\" title=\"Copier dans le presse-papiers\"\u003e\u003ci class=\"far fa-copy\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n    \u003c/div\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-yaml\" data-lang=\"yaml\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c\"\u003e# /etc/crowdsec/appsec-rules/mcp-whitelist.yaml\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e\u003c/span\u003e\u003cspan class=\"nt\"\u003ename\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"l\"\u003ecrowdsecurity/mcp-whitelist\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e\u003c/span\u003e\u003cspan class=\"nt\"\u003edescription\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"l\"\u003eDisable CRS false positives on /mcp endpoint\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e\u003c/span\u003e\u003cspan class=\"nt\"\u003etype\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"l\"\u003eappsec-rule\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e\u003c/span\u003e\u003cspan class=\"nt\"\u003erules\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e  \u003c/span\u003e- \u003cspan class=\"nt\"\u003ezones\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"p\"\u003e[\u003c/span\u003e\u003cspan class=\"l\"\u003eMETHOD, URI]\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e    \u003c/span\u003e\u003cspan class=\"nt\"\u003evariables\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"p\"\u003e[\u003c/span\u003e\u003cspan class=\"l\"\u003euri]\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e    \u003c/span\u003e\u003cspan class=\"nt\"\u003ematch\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e      \u003c/span\u003e\u003cspan class=\"nt\"\u003etype\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"l\"\u003estartsWith\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e      \u003c/span\u003e\u003cspan class=\"nt\"\u003evalue\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"l\"\u003e/mcp\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e    \u003c/span\u003e\u003cspan class=\"nt\"\u003eactions\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e      \u003c/span\u003e- \u003cspan class=\"l\"\u003edisable:932230\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e      \u003c/span\u003e- \u003cspan class=\"l\"\u003edisable:932235\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e      \u003c/span\u003e- \u003cspan class=\"l\"\u003edisable:932250\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e      \u003c/span\u003e- \u003cspan class=\"l\"\u003edisable:932340\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e      \u003c/span\u003e- \u003cspan class=\"l\"\u003edisable:941400\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e      \u003c/span\u003e- \u003cspan class=\"l\"\u003edisable:942360\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e      \u003c/span\u003e- \u003cspan class=\"l\"\u003edisable:949110\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e      \u003c/span\u003e- \u003cspan class=\"l\"\u003edisable:959100\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003ch2 id=\"avantages-par-rapport-à-modsecurity\"\u003eAvantages par rapport à ModSecurity\u003c/h2\u003e\n\u003cp\u003e\u003cstrong\u003eIntégration native\u003c/strong\u003e — AppSec est un composant de l\u0026rsquo;agent CrowdSec. Les décisions WAF alimentent le même pipeline que les bans IP : logs unifiés dans BetterStack, métriques via \u003ccode\u003ecscli metrics show appsec\u003c/code\u003e, corrélation immédiate entre attaques applicatives et comportements réseau.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003eMaintenance simplifiée\u003c/strong\u003e — les règles OWASP CRS sont gérées via \u003ccode\u003ecscli hub update\u003c/code\u003e comme n\u0026rsquo;importe quelle collection CrowdSec. Plus de gestion manuelle de fichiers de règles ModSecurity.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003eRésilience\u003c/strong\u003e — le mode \u003ccode\u003epassthrough\u003c/code\u003e sur failure évite les indisponibilités en cascade. ModSecurity en mode \u003ccode\u003eDetectionOnly\u003c/code\u003e demandait une gestion séparée du failover.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003ePerformances\u003c/strong\u003e — AppSec tourne en process séparé de l\u0026rsquo;agent CrowdSec. OpenResty + LuaJIT maintient des latences faibles, sans le overhead du module Apache/nginx ModSecurity.\u003c/p\u003e\n\u003ch2 id=\"périmètre-dactivation\"\u003ePérimètre d\u0026rsquo;activation\u003c/h2\u003e\n\u003cp\u003eAppSec est actif uniquement sur les vhosts exposés publiquement :\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003ccode\u003ewww.arleo.eu\u003c/code\u003e\u003c/li\u003e\n\u003cli\u003e\u003ccode\u003emcp-hugo.arleo.eu\u003c/code\u003e\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch2 id=\"vérification-rapide\"\u003eVérification rapide\u003c/h2\u003e\n\u003cdiv class=\"code-block code-line-numbers open\" data-start=\"0\"\u003e\n    \u003cdiv class=\"code-header language-bash\"\u003e\n        \u003cspan class=\"code-title\"\u003e\u003ci class=\"arrow fas fa-angle-right\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n        \u003cspan class=\"ellipses\"\u003e\u003ci class=\"fas fa-ellipsis-h\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n        \u003cspan class=\"copy\" title=\"Copier dans le presse-papiers\"\u003e\u003ci class=\"far fa-copy\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n    \u003c/div\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-bash\" data-lang=\"bash\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c1\"\u003e# Tester AppSec directement\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003ecurl -s -X POST http://127.0.0.1:7422/ \u003cspan class=\"se\"\u003e\\\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"se\"\u003e\u003c/span\u003e  -H \u003cspan class=\"s2\"\u003e\u0026#34;x-crowdsec-appsec-uri: /.env\u0026#34;\u003c/span\u003e \u003cspan class=\"se\"\u003e\\\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"se\"\u003e\u003c/span\u003e  -H \u003cspan class=\"s2\"\u003e\u0026#34;x-crowdsec-appsec-ip: 1.2.3.4\u0026#34;\u003c/span\u003e \u003cspan class=\"se\"\u003e\\\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"se\"\u003e\u003c/span\u003e  -H \u003cspan class=\"s2\"\u003e\u0026#34;x-crowdsec-appsec-host: www.arleo.eu\u0026#34;\u003c/span\u003e \u003cspan class=\"se\"\u003e\\\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"se\"\u003e\u003c/span\u003e  -H \u003cspan class=\"s2\"\u003e\u0026#34;x-crowdsec-appsec-verb: GET\u0026#34;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c1\"\u003e# Attendu : {\u0026#34;action\u0026#34;:\u0026#34;ban\u0026#34;,\u0026#34;http_status\u0026#34;:403}\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c1\"\u003e# Métriques\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003ecscli metrics show appsec\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003ch2 id=\"conclusion\"\u003eConclusion\u003c/h2\u003e\n\u003cp\u003eCrowdSec AppSec sur OpenResty remplace ModSecurity de façon cohérente et sans dette technique. L\u0026rsquo;écosystème CrowdSec couvre maintenant l\u0026rsquo;ensemble de la stack : détection comportementale (agent), blocage IP (bouncer Lua), WAF inline (AppSec), sync Cloudflare (crowdsec-cf-sync), et monitoring (BetterStack + Vector). Une seule source de vérité pour la sécurité du homelab.\u003c/p\u003e\n\u003chr\u003e\n\u003cp\u003e\u003cimg\n        class=\"lazyload\"\n        src=\"/svg/loading.min.svg\"\n        data-src=\"/images/logos/crowdsec-owasp-openresty.webp\"\n        data-srcset=\"/images/logos/crowdsec-owasp-openresty.webp, /images/logos/crowdsec-owasp-openresty.webp 1.5x, /images/logos/crowdsec-owasp-openresty.webp 2x\"\n        data-sizes=\"auto\"\n        alt=\"/images/logos/crowdsec-owasp-openresty.webp\"\n        title=\"CrowdSec \u0026#43; OWASP \u0026#43; OpenResty\" /\u003e\u003c/p\u003e\n\u003ch2 id=\"ressources\"\u003eRessources\u003c/h2\u003e\n\u003cul\u003e\n\u003cli\u003e🛡️ \u003ca href=\"https://www.crowdsec.net/\" target=\"_blank\" rel=\"noopener noreffer \"\u003e\u003cstrong\u003eCrowdSec\u003c/strong\u003e\u003c/a\u003e — plateforme de sécurité collaborative open source\u003c/li\u003e\n\u003cli\u003e⚡ \u003ca href=\"https://openresty.org/\" target=\"_blank\" rel=\"noopener noreffer \"\u003e\u003cstrong\u003eOpenResty\u003c/strong\u003e\u003c/a\u003e — nginx + LuaJIT, serveur web haute performance\u003c/li\u003e\n\u003cli\u003e📖 \u003ca href=\"https://docs.crowdsec.net/docs/appsec/intro/\" target=\"_blank\" rel=\"noopener noreffer \"\u003e\u003cstrong\u003eCrowdSec AppSec — Documentation\u003c/strong\u003e\u003c/a\u003e — guide officiel d\u0026rsquo;installation et configuration\u003c/li\u003e\n\u003cli\u003e📖 \u003ca href=\"https://docs.crowdsec.net/docs/appsec/quickstart/nginxopenresty/\" target=\"_blank\" rel=\"noopener noreffer \"\u003e\u003cstrong\u003eCrowdSec AppSec Quickstart OpenResty\u003c/strong\u003e\u003c/a\u003e — démarrage rapide bouncer OpenResty\u003c/li\u003e\n\u003cli\u003e📖 \u003ca href=\"https://www.crowdsec.net/blog/protecting-your-web-applications-with-owasp-crs-and-crowdsec\" target=\"_blank\" rel=\"noopener noreffer \"\u003e\u003cstrong\u003eProtéger ses applications web avec OWASP CRS et CrowdSec\u003c/strong\u003e\u003c/a\u003e — article de blog officiel\u003c/li\u003e\n\u003cli\u003e📖 \u003ca href=\"https://docs.crowdsec.net/docs/appsec/crs/installation\" target=\"_blank\" rel=\"noopener noreffer \"\u003e\u003cstrong\u003eInstallation OWASP CRS avec CrowdSec AppSec\u003c/strong\u003e\u003c/a\u003e — guide d\u0026rsquo;installation CRS\u003c/li\u003e\n\u003c/ul\u003e\n",
        "summary": "Après des années avec ModSecurity + OWASP CRS sur nginx, j\u0026rsquo;ai migré arleo.eu vers une stack plus moderne : CrowdSec AppSec sur OpenResty. Le résultat est une architecture WAF inline mieux intégrée, plus facile à maintenir, et plus cohérente avec le reste de la stack de sécurité.\nPourquoi abandonner ModSecurity ? ModSecurity v2 est en fin de vie active. Le maintien des règles OWASP CRS sur nginx classique génère de la friction : faux positifs fréquents, logs difficiles à corréler avec CrowdSec, et une configuration dispersée entre plusieurs outils sans vision unifiée.\n",
        "date_published": "2026-05-18T00:07:11+02:00",
        "date_modified": "2026-05-18T05:44:02+02:00"
      },
      {
        "id": "https://www.arleo.eu/posts/sri-cdn-hugo-automate/",
        "url": "https://www.arleo.eu/posts/sri-cdn-hugo-automate/",
        "title": "SRI sur Hugo : hashes automatiques, auto-update et alerting BetterStack",
        "content_html": "\u003ch2 id=\"pourquoi-le-sri-\"\u003ePourquoi le SRI ?\u003c/h2\u003e\n\u003cp\u003eQuand votre site charge des ressources depuis un CDN tiers — FontAwesome, Mermaid, Animate.css — vous faites confiance à un tiers sur lequel vous n\u0026rsquo;avez aucun contrôle. Si jsdelivr.net est compromis ou si une version « immuable » est mutée silencieusement, votre site peut devenir vecteur d\u0026rsquo;attaque.\u003c/p\u003e\n\u003cp\u003eLe \u003cstrong\u003eSubresource Integrity\u003c/strong\u003e (SRI) résout ça simplement : chaque balise \u003ccode\u003e\u0026lt;link\u0026gt;\u003c/code\u003e ou \u003ccode\u003e\u0026lt;script\u0026gt;\u003c/code\u003e porte un attribut \u003ccode\u003eintegrity=\u0026quot;sha256-…\u0026quot;\u003c/code\u003e que le navigateur vérifie avant d\u0026rsquo;exécuter la ressource. Si le hash ne correspond pas, le navigateur bloque le chargement.\u003c/p\u003e\n\u003cp\u003eSur arleo.eu, le thème LoveIt charge \u003cstrong\u003e9 URLs jsdelivr\u003c/strong\u003e via ses partials Hugo. Aucune n\u0026rsquo;avait de hash SRI. Ce post documente comment j\u0026rsquo;ai corrigé ça — et comment j\u0026rsquo;ai poussé le concept jusqu\u0026rsquo;à un pipeline entièrement automatisé.\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"architecture-loveit-et-le-problème-cdn\"\u003eArchitecture LoveIt et le problème CDN\u003c/h2\u003e\n\u003cp\u003eLoveIt ne code pas ses URLs CDN en dur dans les templates. L\u0026rsquo;architecture est la suivante :\u003c/p\u003e\n\u003cdiv class=\"code-block code-line-numbers open\" data-start=\"0\"\u003e\n    \u003cdiv class=\"code-header language-\"\u003e\n        \u003cspan class=\"code-title\"\u003e\u003ci class=\"arrow fas fa-angle-right\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n        \u003cspan class=\"ellipses\"\u003e\u003ci class=\"fas fa-ellipsis-h\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n        \u003cspan class=\"copy\" title=\"Copier dans le presse-papiers\"\u003e\u003ci class=\"far fa-copy\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n    \u003c/div\u003e\u003cpre tabindex=\"0\"\u003e\u003ccode\u003ethemes/LoveIt/assets/data/cdn/jsdelivr.yml   ← liste des libs et versions\nthemes/LoveIt/layouts/_partials/init.html    ← charge le YAML, construit $cdn\nthemes/LoveIt/layouts/_partials/plugin/script.html  ← émet les \u0026lt;script\u0026gt;\nthemes/LoveIt/layouts/_partials/plugin/style.html   ← émet les \u0026lt;link\u0026gt;\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\n\u003cp\u003eLe fichier \u003ccode\u003ejsdelivr.yml\u003c/code\u003e ressemble à :\u003c/p\u003e\n\u003cdiv class=\"code-block code-line-numbers open\" data-start=\"0\"\u003e\n    \u003cdiv class=\"code-header language-yaml\"\u003e\n        \u003cspan class=\"code-title\"\u003e\u003ci class=\"arrow fas fa-angle-right\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n        \u003cspan class=\"ellipses\"\u003e\u003ci class=\"fas fa-ellipsis-h\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n        \u003cspan class=\"copy\" title=\"Copier dans le presse-papiers\"\u003e\u003ci class=\"far fa-copy\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n    \u003c/div\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-yaml\" data-lang=\"yaml\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"nt\"\u003eprefix\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e  \u003c/span\u003e\u003cspan class=\"nt\"\u003elibFiles\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"l\"\u003ehttps://cdn.jsdelivr.net/npm/\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e\u003c/span\u003e\u003cspan class=\"nt\"\u003elibFiles\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e  \u003c/span\u003e\u003cspan class=\"nt\"\u003efontawesomeCSS\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"s2\"\u003e\u0026#34;@fortawesome/fontawesome-free@7.2.0/css/all.min.css\u0026#34;\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e  \u003c/span\u003e\u003cspan class=\"nt\"\u003eanimateCSS\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"l\"\u003eanimate.css@4.1.1/animate.min.css\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e  \u003c/span\u003e\u003cspan class=\"nt\"\u003emermaidJS\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"l\"\u003emermaid@11.15.0/dist/mermaid.min.js\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e  \u003c/span\u003e\u003cspan class=\"c\"\u003e# ...\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003cp\u003eLes partials \u003ccode\u003eplugin/script.html\u003c/code\u003e et \u003ccode\u003eplugin/style.html\u003c/code\u003e émettent les balises sans \u003ccode\u003eintegrity=\u003c/code\u003e. Ce sont eux qu\u0026rsquo;il faut surcharger — \u003cstrong\u003esans toucher au submodule git du thème\u003c/strong\u003e.\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"étape-1--calcul-des-hashes-sri\"\u003eÉtape 1 — Calcul des hashes SRI\u003c/h2\u003e\n\u003cp\u003ePour chaque URL jsdelivr identifiée dans \u003ccode\u003epublic/*.html\u003c/code\u003e :\u003c/p\u003e\n\u003cdiv class=\"code-block code-line-numbers open\" data-start=\"0\"\u003e\n    \u003cdiv class=\"code-header language-bash\"\u003e\n        \u003cspan class=\"code-title\"\u003e\u003ci class=\"arrow fas fa-angle-right\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n        \u003cspan class=\"ellipses\"\u003e\u003ci class=\"fas fa-ellipsis-h\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n        \u003cspan class=\"copy\" title=\"Copier dans le presse-papiers\"\u003e\u003ci class=\"far fa-copy\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n    \u003c/div\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-bash\" data-lang=\"bash\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003ecurl -fsSL https://cdn.jsdelivr.net/npm/mermaid@11.15.0/dist/mermaid.min.js \u003cspan class=\"se\"\u003e\\\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"se\"\u003e\u003c/span\u003e  \u003cspan class=\"p\"\u003e|\u003c/span\u003e openssl dgst -sha256 -binary \u003cspan class=\"se\"\u003e\\\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"se\"\u003e\u003c/span\u003e  \u003cspan class=\"p\"\u003e|\u003c/span\u003e base64\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003cp\u003eOn stocke tous les hashes dans un fichier \u003ccode\u003edata/sri.yaml\u003c/code\u003e propre au site (pas dans le thème) :\u003c/p\u003e\n\u003cdiv class=\"code-block code-line-numbers open\" data-start=\"0\"\u003e\n    \u003cdiv class=\"code-header language-yaml\"\u003e\n        \u003cspan class=\"code-title\"\u003e\u003ci class=\"arrow fas fa-angle-right\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n        \u003cspan class=\"ellipses\"\u003e\u003ci class=\"fas fa-ellipsis-h\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n        \u003cspan class=\"copy\" title=\"Copier dans le presse-papiers\"\u003e\u003ci class=\"far fa-copy\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n    \u003c/div\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-yaml\" data-lang=\"yaml\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"nt\"\u003e\u0026#34;https://cdn.jsdelivr.net/npm/mermaid@11.15.0/dist/mermaid.min.js\u0026#34;: \u003c/span\u003e\u003cspan class=\"s2\"\u003e\u0026#34;sha256-cBN+d7snO7LvlyuG6LBADMqL5TyyW/xFkRoYbcmGZd4=\u0026#34;\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e\u003c/span\u003e\u003cspan class=\"nt\"\u003e\u0026#34;https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-free@7.2.0/css/all.min.css\u0026#34;: \u003c/span\u003e\u003cspan class=\"s2\"\u003e\u0026#34;sha256-MVopmdyC2tYTiJ8wlktf0uh0v4NgT+vNdyVFepi7Q0c=\u0026#34;\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e\u003c/span\u003e\u003cspan class=\"c\"\u003e# ...\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003chr\u003e\n\u003ch2 id=\"étape-2--surcharge-des-partials-loveit\"\u003eÉtape 2 — Surcharge des partials LoveIt\u003c/h2\u003e\n\u003cp\u003eHugo résout les partials en priorité dans le dossier \u003ccode\u003elayouts/\u003c/code\u003e du site avant le thème. On crée deux fichiers qui surchargent les originaux :\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e\u003ccode\u003elayouts/_partials/plugin/script.html\u003c/code\u003e\u003c/strong\u003e (extrait) :\u003c/p\u003e\n\u003cdiv class=\"code-block code-line-numbers open\" data-start=\"0\"\u003e\n    \u003cdiv class=\"code-header language-go-html-template\"\u003e\n        \u003cspan class=\"code-title\"\u003e\u003ci class=\"arrow fas fa-angle-right\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n        \u003cspan class=\"ellipses\"\u003e\u003ci class=\"fas fa-ellipsis-h\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n        \u003cspan class=\"copy\" title=\"Copier dans le presse-papiers\"\u003e\u003ci class=\"far fa-copy\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n    \u003c/div\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-go-html-template\" data-lang=\"go-html-template\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"cp\"\u003e{{-\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"nx\"\u003e$sri\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"o\"\u003e:=\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"nx\"\u003esite\u003c/span\u003e\u003cspan class=\"na\"\u003e.Data.sri\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"o\"\u003e|\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"nx\"\u003edefault\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"nx\"\u003edict\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"cp\"\u003e-}}\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"cp\"\u003e{{-\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"nx\"\u003e$src\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"o\"\u003e:=\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"na\"\u003e.Source\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"cp\"\u003e-}}\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"cp\"\u003e{{-\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"nx\"\u003e$hash\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"o\"\u003e:=\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"k\"\u003eindex\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"nx\"\u003e$sri\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"nx\"\u003e$src\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"o\"\u003e|\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"nx\"\u003edefault\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"s\"\u003e\u0026#34;\u0026#34;\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"cp\"\u003e-}}\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"cp\"\u003e{{-\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"k\"\u003eif\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"k\"\u003eand\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"nx\"\u003e$src\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"o\"\u003e(\u003c/span\u003e\u003cspan class=\"nx\"\u003ehasPrefix\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"nx\"\u003e$src\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"s\"\u003e\u0026#34;http\u0026#34;\u003c/span\u003e\u003cspan class=\"o\"\u003e)\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"nx\"\u003e$hash\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"cp\"\u003e-}}\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e\u0026lt;\u003c/span\u003e\u003cspan class=\"nt\"\u003escript\u003c/span\u003e \u003cspan class=\"na\"\u003esrc\u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"s\"\u003e\u0026#34;\u003c/span\u003e\u003cspan class=\"cp\"\u003e{{\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"nx\"\u003e$src\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"cp\"\u003e}}\u003c/span\u003e\u003cspan class=\"s\"\u003e\u0026#34;\u003c/span\u003e \u003cspan class=\"na\"\u003ecrossorigin\u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"s\"\u003e\u0026#34;anonymous\u0026#34;\u003c/span\u003e \u003cspan class=\"na\"\u003eintegrity\u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"s\"\u003e\u0026#34;\u003c/span\u003e\u003cspan class=\"cp\"\u003e{{\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"nx\"\u003e$hash\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"cp\"\u003e}}\u003c/span\u003e\u003cspan class=\"s\"\u003e\u0026#34;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e  \u003cspan class=\"cp\"\u003e{{-\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"k\"\u003ewith\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"na\"\u003e.Defer\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"cp\"\u003e}}\u003c/span\u003e \u003cspan class=\"na\"\u003edefer\u003c/span\u003e\u003cspan class=\"cp\"\u003e{{\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"k\"\u003eend\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"cp\"\u003e-}}\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e  \u003cspan class=\"cp\"\u003e{{-\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"k\"\u003ewith\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"na\"\u003e.Async\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"cp\"\u003e}}\u003c/span\u003e \u003cspan class=\"na\"\u003easync\u003c/span\u003e\u003cspan class=\"cp\"\u003e{{\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"k\"\u003eend\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"cp\"\u003e-}}\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e\u0026gt;\u0026lt;/\u003c/span\u003e\u003cspan class=\"nt\"\u003escript\u003c/span\u003e\u003cspan class=\"p\"\u003e\u0026gt;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"cp\"\u003e{{-\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"k\"\u003eelse\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"k\"\u003eif\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"k\"\u003eand\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"nx\"\u003e$src\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"o\"\u003e(\u003c/span\u003e\u003cspan class=\"nx\"\u003ehasPrefix\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"nx\"\u003e$src\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"s\"\u003e\u0026#34;http\u0026#34;\u003c/span\u003e\u003cspan class=\"o\"\u003e)\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"cp\"\u003e-}}\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e\u0026lt;\u003c/span\u003e\u003cspan class=\"nt\"\u003escript\u003c/span\u003e \u003cspan class=\"na\"\u003esrc\u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"s\"\u003e\u0026#34;\u003c/span\u003e\u003cspan class=\"cp\"\u003e{{\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"nx\"\u003e$src\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"cp\"\u003e}}\u003c/span\u003e\u003cspan class=\"s\"\u003e\u0026#34;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e  \u003cspan class=\"cp\"\u003e{{-\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"k\"\u003ewith\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"na\"\u003e.Defer\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"cp\"\u003e}}\u003c/span\u003e \u003cspan class=\"na\"\u003edefer\u003c/span\u003e\u003cspan class=\"cp\"\u003e{{\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"k\"\u003eend\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"cp\"\u003e-}}\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e  \u003cspan class=\"cp\"\u003e{{-\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"k\"\u003ewith\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"na\"\u003e.Async\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"cp\"\u003e}}\u003c/span\u003e \u003cspan class=\"na\"\u003easync\u003c/span\u003e\u003cspan class=\"cp\"\u003e{{\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"k\"\u003eend\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"cp\"\u003e-}}\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e\u0026gt;\u0026lt;/\u003c/span\u003e\u003cspan class=\"nt\"\u003escript\u003c/span\u003e\u003cspan class=\"p\"\u003e\u0026gt;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"cp\"\u003e{{-\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"k\"\u003eelse\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"cp\"\u003e-}}\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"cm\"\u003e{{/* ressource locale — inchangée */}}\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"cp\"\u003e{{-\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"k\"\u003eend\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"cp\"\u003e-}}\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003cp\u003eLa logique est simple : si l\u0026rsquo;URL est présente dans \u003ccode\u003edata/sri.yaml\u003c/code\u003e, on injecte \u003ccode\u003eintegrity=\u003c/code\u003e + \u003ccode\u003ecrossorigin=\u0026quot;anonymous\u0026quot;\u003c/code\u003e. Sinon, on laisse passer sans modifier. Les ressources locales (fingerprinting Hugo) ne sont pas touchées.\u003c/p\u003e\n\u003cp\u003eMême logique pour \u003ccode\u003eplugin/style.html\u003c/code\u003e, avec propagation des attributs sur les deux balises \u003ccode\u003e\u0026lt;link\u0026gt;\u003c/code\u003e générées (preload + stylesheet).\u003c/p\u003e\n\u003cp\u003eAprès un \u003ccode\u003ehugo --minify\u003c/code\u003e, toutes les balises CDN portent leur hash :\u003c/p\u003e\n\u003cdiv class=\"code-block code-line-numbers open\" data-start=\"0\"\u003e\n    \u003cdiv class=\"code-header language-html\"\u003e\n        \u003cspan class=\"code-title\"\u003e\u003ci class=\"arrow fas fa-angle-right\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n        \u003cspan class=\"ellipses\"\u003e\u003ci class=\"fas fa-ellipsis-h\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n        \u003cspan class=\"copy\" title=\"Copier dans le presse-papiers\"\u003e\u003ci class=\"far fa-copy\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n    \u003c/div\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-html\" data-lang=\"html\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e\u0026lt;\u003c/span\u003e\u003cspan class=\"nt\"\u003escript\u003c/span\u003e \u003cspan class=\"na\"\u003esrc\u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"s\"\u003e\u0026#34;https://cdn.jsdelivr.net/npm/mermaid@11.15.0/dist/mermaid.min.js\u0026#34;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e  \u003cspan class=\"na\"\u003ecrossorigin\u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"s\"\u003e\u0026#34;anonymous\u0026#34;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e  \u003cspan class=\"na\"\u003eintegrity\u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"s\"\u003e\u0026#34;sha256-cBN+d7snO7LvlyuG6LBADMqL5TyyW/xFkRoYbcmGZd4=\u0026#34;\u003c/span\u003e\u003cspan class=\"p\"\u003e\u0026gt;\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003chr\u003e\n\u003ch2 id=\"étape-3--script-de-surveillance-hebdomadaire\"\u003eÉtape 3 — Script de surveillance hebdomadaire\u003c/h2\u003e\n\u003cp\u003eAvoir des hashes SRI c\u0026rsquo;est bien. Les garder à jour sans intervention manuelle, c\u0026rsquo;est mieux. Le script \u003ccode\u003e/home/jm/scripts/check-sri-versions.sh\u003c/code\u003e (cron lundi 8h) fait trois choses :\u003c/p\u003e\n\u003ch3 id=\"31-vérification-des-hashes-live\"\u003e3.1 Vérification des hashes live\u003c/h3\u003e\n\u003cp\u003ePour chaque URL de \u003ccode\u003edata/sri.yaml\u003c/code\u003e, le script recalcule le hash en direct et compare :\u003c/p\u003e\n\u003cdiv class=\"code-block code-line-numbers open\" data-start=\"0\"\u003e\n    \u003cdiv class=\"code-header language-bash\"\u003e\n        \u003cspan class=\"code-title\"\u003e\u003ci class=\"arrow fas fa-angle-right\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n        \u003cspan class=\"ellipses\"\u003e\u003ci class=\"fas fa-ellipsis-h\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n        \u003cspan class=\"copy\" title=\"Copier dans le presse-papiers\"\u003e\u003ci class=\"far fa-copy\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n    \u003c/div\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-bash\" data-lang=\"bash\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"nv\"\u003elive\u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"k\"\u003e$(\u003c/span\u003ecurl -fsSL --max-time \u003cspan class=\"m\"\u003e30\u003c/span\u003e \u003cspan class=\"s2\"\u003e\u0026#34;\u003c/span\u003e\u003cspan class=\"nv\"\u003e$url\u003c/span\u003e\u003cspan class=\"s2\"\u003e\u0026#34;\u003c/span\u003e \u003cspan class=\"se\"\u003e\\\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"se\"\u003e\u003c/span\u003e  \u003cspan class=\"p\"\u003e|\u003c/span\u003e openssl dgst -sha256 -binary \u003cspan class=\"p\"\u003e|\u003c/span\u003e base64\u003cspan class=\"k\"\u003e)\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"k\"\u003eif\u003c/span\u003e \u003cspan class=\"o\"\u003e[\u003c/span\u003e \u003cspan class=\"s2\"\u003e\u0026#34;sha256-\u003c/span\u003e\u003cspan class=\"nv\"\u003e$live\u003c/span\u003e\u003cspan class=\"s2\"\u003e\u0026#34;\u003c/span\u003e !\u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"s2\"\u003e\u0026#34;\u003c/span\u003e\u003cspan class=\"nv\"\u003e$stored\u003c/span\u003e\u003cspan class=\"s2\"\u003e\u0026#34;\u003c/span\u003e \u003cspan class=\"o\"\u003e]\u003c/span\u003e\u003cspan class=\"p\"\u003e;\u003c/span\u003e \u003cspan class=\"k\"\u003ethen\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"c1\"\u003e# WARN — hash mismatch : CDN muté ou compromis\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"k\"\u003efi\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003cp\u003eUn mismatch sur une version épinglée (ex. \u003ccode\u003emermaid@11.15.0\u003c/code\u003e) signifie que jsdelivr a \u003cstrong\u003emodifié un fichier théoriquement immuable\u003c/strong\u003e — c\u0026rsquo;est un signal de sécurité critique. Ce cas n\u0026rsquo;est \u003cstrong\u003ejamais auto-fixé\u003c/strong\u003e : il crée un incident BetterStack pour review humaine.\u003c/p\u003e\n\u003ch3 id=\"32-détection-des-versions-outdated\"\u003e3.2 Détection des versions outdated\u003c/h3\u003e\n\u003cp\u003ePour chaque lib active dans \u003ccode\u003epublic/*.html\u003c/code\u003e, le script interroge l\u0026rsquo;API jsdelivr :\u003c/p\u003e\n\u003cdiv class=\"code-block code-line-numbers open\" data-start=\"0\"\u003e\n    \u003cdiv class=\"code-header language-bash\"\u003e\n        \u003cspan class=\"code-title\"\u003e\u003ci class=\"arrow fas fa-angle-right\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n        \u003cspan class=\"ellipses\"\u003e\u003ci class=\"fas fa-ellipsis-h\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n        \u003cspan class=\"copy\" title=\"Copier dans le presse-papiers\"\u003e\u003ci class=\"far fa-copy\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n    \u003c/div\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-bash\" data-lang=\"bash\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"nv\"\u003elatest\u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"k\"\u003e$(\u003c/span\u003ecurl -fsSL \u003cspan class=\"s2\"\u003e\u0026#34;https://data.jsdelivr.com/v1/packages/npm/\u003c/span\u003e\u003cspan class=\"nv\"\u003e$pkg\u003c/span\u003e\u003cspan class=\"s2\"\u003e\u0026#34;\u003c/span\u003e \u003cspan class=\"se\"\u003e\\\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"se\"\u003e\u003c/span\u003e  \u003cspan class=\"p\"\u003e|\u003c/span\u003e jq -r \u003cspan class=\"s1\"\u003e\u0026#39;.tags.latest\u0026#39;\u003c/span\u003e\u003cspan class=\"k\"\u003e)\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003cp\u003eLes libs inactives (définies dans \u003ccode\u003ejsdelivr.yml\u003c/code\u003e mais non activées dans \u003ccode\u003ehugo.toml\u003c/code\u003e) sont loggées \u003ccode\u003eINFO (inactive, skipped)\u003c/code\u003e sans déclencher d\u0026rsquo;alerte — ce qui évite le bruit sur les features optionnelles de LoveIt.\u003c/p\u003e\n\u003cp\u003eTrois catégories de résultat :\u003c/p\u003e\n\u003ctable\u003e\n  \u003cthead\u003e\n      \u003ctr\u003e\n          \u003cth\u003eSituation\u003c/th\u003e\n          \u003cth\u003eAction\u003c/th\u003e\n      \u003c/tr\u003e\n  \u003c/thead\u003e\n  \u003ctbody\u003e\n      \u003ctr\u003e\n          \u003ctd\u003eVersion à jour\u003c/td\u003e\n          \u003ctd\u003e\u003ccode\u003eOK\u003c/code\u003e\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003eMinor/patch outdated (même major)\u003c/td\u003e\n          \u003ctd\u003eAuto-fix complet\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003eMajor bump (ex. echarts 5→6)\u003c/td\u003e\n          \u003ctd\u003e\u003ccode\u003eWARN\u003c/code\u003e → incident humain\u003c/td\u003e\n      \u003c/tr\u003e\n  \u003c/tbody\u003e\n\u003c/table\u003e\n\u003ch3 id=\"33-auto-fix-minorpatch\"\u003e3.3 Auto-fix minor/patch\u003c/h3\u003e\n\u003cp\u003eC\u0026rsquo;est la partie intéressante. Pour une lib minor/patch outdated, le script :\u003c/p\u003e\n\u003col\u003e\n\u003cli\u003e\u003cstrong\u003eBackup\u003c/strong\u003e de \u003ccode\u003edata/sri.yaml\u003c/code\u003e + \u003ccode\u003eassets/data/cdn/jsdelivr.yml\u003c/code\u003e\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eBump\u003c/strong\u003e de la version dans \u003ccode\u003ejsdelivr.yml\u003c/code\u003e (URL + commentaire)\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eFetch + recalcul\u003c/strong\u003e du nouveau hash SRI\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eMise à jour\u003c/strong\u003e de \u003ccode\u003edata/sri.yaml\u003c/code\u003e avec la nouvelle URL et le nouveau hash\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e\u003ccode\u003ehugo --minify --cleanDestinationDir\u003c/code\u003e\u003c/strong\u003e\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e\u003ccode\u003ebash deploy.sh\u003c/code\u003e\u003c/strong\u003e (rsync vers nginx)\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003ePurge Cloudflare\u003c/strong\u003e via API (\u003ccode\u003epurge_everything: true\u003c/code\u003e, zone arleo.eu)\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eVérification live\u003c/strong\u003e : re-check de tous les hashes depuis jsdelivr CDN\u003c/li\u003e\n\u003cli\u003eSi OK → heartbeat BetterStack pingé, incident précédent auto-résolu\u003c/li\u003e\n\u003cli\u003eSi échec à n\u0026rsquo;importe quelle étape → \u003cstrong\u003erollback\u003c/strong\u003e (restore backup + rebuild + redeploy + repurge CF) + incident BetterStack\u003c/li\u003e\n\u003c/ol\u003e\n\u003cdiv class=\"code-block code-line-numbers open\" data-start=\"0\"\u003e\n    \u003cdiv class=\"code-header language-bash\"\u003e\n        \u003cspan class=\"code-title\"\u003e\u003ci class=\"arrow fas fa-angle-right\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n        \u003cspan class=\"ellipses\"\u003e\u003ci class=\"fas fa-ellipsis-h\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n        \u003cspan class=\"copy\" title=\"Copier dans le presse-papiers\"\u003e\u003ci class=\"far fa-copy\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n    \u003c/div\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-bash\" data-lang=\"bash\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c1\"\u003e# Extrait — purge Cloudflare\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003ecurl -s -X POST \u003cspan class=\"se\"\u003e\\\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"se\"\u003e\u003c/span\u003e  \u003cspan class=\"s2\"\u003e\u0026#34;https://api.cloudflare.com/client/v4/zones/\u003c/span\u003e\u003cspan class=\"nv\"\u003e$CF_ZONE_ID\u003c/span\u003e\u003cspan class=\"s2\"\u003e/purge_cache\u0026#34;\u003c/span\u003e \u003cspan class=\"se\"\u003e\\\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"se\"\u003e\u003c/span\u003e  -H \u003cspan class=\"s2\"\u003e\u0026#34;Authorization: Bearer \u003c/span\u003e\u003cspan class=\"nv\"\u003e$CF_API_TOKEN\u003c/span\u003e\u003cspan class=\"s2\"\u003e\u0026#34;\u003c/span\u003e \u003cspan class=\"se\"\u003e\\\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"se\"\u003e\u003c/span\u003e  -H \u003cspan class=\"s2\"\u003e\u0026#34;Content-Type: application/json\u0026#34;\u003c/span\u003e \u003cspan class=\"se\"\u003e\\\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"se\"\u003e\u003c/span\u003e  -d \u003cspan class=\"s1\"\u003e\u0026#39;{\u0026#34;purge_everything\u0026#34;:true}\u0026#39;\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003cp\u003eLe token CF est réutilisé depuis \u003ccode\u003e/etc/crowdsec/cf-sync.env\u003c/code\u003e (scope \u003ccode\u003eZone:Cache Purge\u003c/code\u003e vérifié au préalable via \u003ccode\u003e/user/tokens/verify\u003c/code\u003e).\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"étape-4--intégration-betterstack\"\u003eÉtape 4 — Intégration BetterStack\u003c/h2\u003e\n\u003cp\u003eLe script suit un cycle d\u0026rsquo;alerting complet :\u003c/p\u003e\n\u003cdiv class=\"code-block code-line-numbers open\" data-start=\"0\"\u003e\n    \u003cdiv class=\"code-header language-\"\u003e\n        \u003cspan class=\"code-title\"\u003e\u003ci class=\"arrow fas fa-angle-right\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n        \u003cspan class=\"ellipses\"\u003e\u003ci class=\"fas fa-ellipsis-h\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n        \u003cspan class=\"copy\" title=\"Copier dans le presse-papiers\"\u003e\u003ci class=\"far fa-copy\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n    \u003c/div\u003e\u003cpre tabindex=\"0\"\u003e\u003ccode\u003eRun WARN\n  → POST /api/v2/incidents (summary + description détaillée)\n  → ID de l\u0026#39;incident écrit dans ~/.config/sri-check.open-incidents (chmod 600)\n\nRun OK suivant\n  → POST /api/v2/incidents/{id}/resolve pour chaque ID tracké\n  → ~/.config/sri-check.open-incidents supprimé\n  → GET heartbeat BetterStack pingé\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\n\u003cp\u003eRobustesse du state file :\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003ccode\u003e404\u003c/code\u003e (incident supprimé manuellement côté BS) → dropé silencieusement\u003c/li\u003e\n\u003cli\u003e\u003ccode\u003e5xx\u003c/code\u003e ou réseau down → ID conservé pour retry au prochain run\u003c/li\u003e\n\u003cli\u003ePlusieurs incidents en parallèle → tous résolus au prochain run OK\u003c/li\u003e\n\u003c/ul\u003e\n\u003chr\u003e\n\u003ch2 id=\"fichiers-créésmodifiés\"\u003eFichiers créés/modifiés\u003c/h2\u003e\n\u003ctable\u003e\n  \u003cthead\u003e\n      \u003ctr\u003e\n          \u003cth\u003eFichier\u003c/th\u003e\n          \u003cth\u003eRôle\u003c/th\u003e\n      \u003c/tr\u003e\n  \u003c/thead\u003e\n  \u003ctbody\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e\u003ccode\u003edata/sri.yaml\u003c/code\u003e\u003c/td\u003e\n          \u003ctd\u003eMap URL → hash sha256 (source de vérité SRI)\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e\u003ccode\u003eassets/data/cdn/jsdelivr.yml\u003c/code\u003e\u003c/td\u003e\n          \u003ctd\u003eOverride local du YAML du thème (versions bumped)\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e\u003ccode\u003elayouts/_partials/plugin/script.html\u003c/code\u003e\u003c/td\u003e\n          \u003ctd\u003eSurcharge LoveIt — injection \u003ccode\u003eintegrity=\u003c/code\u003e sur \u003ccode\u003e\u0026lt;script\u0026gt;\u003c/code\u003e\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e\u003ccode\u003elayouts/_partials/plugin/style.html\u003c/code\u003e\u003c/td\u003e\n          \u003ctd\u003eSurcharge LoveIt — injection \u003ccode\u003eintegrity=\u003c/code\u003e sur \u003ccode\u003e\u0026lt;link\u0026gt;\u003c/code\u003e\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e\u003ccode\u003e/home/jm/scripts/check-sri-versions.sh\u003c/code\u003e\u003c/td\u003e\n          \u003ctd\u003eScript de surveillance + auto-fix (cron lundi 8h)\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e\u003ccode\u003e/home/jm/.config/sri-check.env\u003c/code\u003e\u003c/td\u003e\n          \u003ctd\u003eSecrets BS + CF (chmod 600)\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e\u003ccode\u003e/home/jm/.config/sri-check.open-incidents\u003c/code\u003e\u003c/td\u003e\n          \u003ctd\u003eState file incidents (créé/supprimé à la volée)\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e\u003ccode\u003e/etc/logrotate.d/sri-check\u003c/code\u003e\u003c/td\u003e\n          \u003ctd\u003eRotation weekly, 4 semaines, compress\u003c/td\u003e\n      \u003c/tr\u003e\n  \u003c/tbody\u003e\n\u003c/table\u003e\n\u003cp\u003eAucune modification dans \u003ccode\u003ethemes/LoveIt/\u003c/code\u003e (submodule git intact).\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"pour-étendre-à-de-nouvelles-libs\"\u003ePour étendre à de nouvelles libs\u003c/h2\u003e\n\u003cp\u003eAjouter une ligne dans \u003ccode\u003edata/sri.yaml\u003c/code\u003e suffit :\u003c/p\u003e\n\u003cdiv class=\"code-block code-line-numbers open\" data-start=\"0\"\u003e\n    \u003cdiv class=\"code-header language-bash\"\u003e\n        \u003cspan class=\"code-title\"\u003e\u003ci class=\"arrow fas fa-angle-right\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n        \u003cspan class=\"ellipses\"\u003e\u003ci class=\"fas fa-ellipsis-h\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n        \u003cspan class=\"copy\" title=\"Copier dans le presse-papiers\"\u003e\u003ci class=\"far fa-copy\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n    \u003c/div\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-bash\" data-lang=\"bash\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"nv\"\u003eurl\u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"s2\"\u003e\u0026#34;https://cdn.jsdelivr.net/npm/nouvelle-lib@1.2.3/dist/lib.min.js\u0026#34;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"nv\"\u003ehash\u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"k\"\u003e$(\u003c/span\u003ecurl -fsSL \u003cspan class=\"s2\"\u003e\u0026#34;\u003c/span\u003e\u003cspan class=\"nv\"\u003e$url\u003c/span\u003e\u003cspan class=\"s2\"\u003e\u0026#34;\u003c/span\u003e \u003cspan class=\"p\"\u003e|\u003c/span\u003e openssl dgst -sha256 -binary \u003cspan class=\"p\"\u003e|\u003c/span\u003e base64\u003cspan class=\"k\"\u003e)\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"nb\"\u003eecho\u003c/span\u003e \u003cspan class=\"s2\"\u003e\u0026#34;\\\u0026#34;\u003c/span\u003e\u003cspan class=\"nv\"\u003e$url\u003c/span\u003e\u003cspan class=\"s2\"\u003e\\\u0026#34;: \\\u0026#34;sha256-\u003c/span\u003e\u003cspan class=\"nv\"\u003e$hash\u003c/span\u003e\u003cspan class=\"s2\"\u003e\\\u0026#34;\u0026#34;\u003c/span\u003e \u0026gt;\u0026gt; data/sri.yaml\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003cp\u003ePas de template à toucher. Pas de rebuild manuel — le prochain cron s\u0026rsquo;en charge.\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"résultat\"\u003eRésultat\u003c/h2\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003e9 libs CDN\u003c/strong\u003e protégées par SRI, hashes injectés automatiquement à chaque build Hugo\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e0 modification\u003c/strong\u003e dans le submodule LoveIt\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003ePipeline entièrement automatisé\u003c/strong\u003e : détection → bump → rebuild → deploy → purge CF → vérif live → alerting BS → auto-résolution\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eScore ImmuniWeb/Qualys\u003c/strong\u003e : A+ maintenu, disparition des avertissements SRI manquants\u003c/li\u003e\n\u003c/ul\u003e\n\u003chr\u003e\n\u003ch2 id=\"plugin-hugo-mcp-sri-check\"\u003ePlugin hugo-mcp sri-check\u003c/h2\u003e\n\u003cp\u003eLe script bash a depuis été intégré comme plugin natif du serveur MCP hugo-mcp. Il expose le tool \u003ccode\u003echeck_sri_versions(auto_fix, dry_run)\u003c/code\u003e directement depuis Claude.ai, et délègue la purge Cloudflare au plugin CF existant plutôt que de l\u0026rsquo;appeler en direct depuis le bash.\u003c/p\u003e\n\u003cp\u003eCode source et PR : \u003ca href=\"https://github.com/jmrGrav/hugo-mcp/pull/1\" target=\"_blank\" rel=\"noopener noreffer \"\u003egithub.com/jmrGrav/hugo-mcp — PR #1 feat/sri-check-plugin\u003c/a\u003e\u003c/p\u003e\n",
        "summary": "Pourquoi le SRI ? Quand votre site charge des ressources depuis un CDN tiers — FontAwesome, Mermaid, Animate.css — vous faites confiance à un tiers sur lequel vous n\u0026rsquo;avez aucun contrôle. Si jsdelivr.net est compromis ou si une version « immuable » est mutée silencieusement, votre site peut devenir vecteur d\u0026rsquo;attaque.\nLe Subresource Integrity (SRI) résout ça simplement : chaque balise \u0026lt;link\u0026gt; ou \u0026lt;script\u0026gt; porte un attribut integrity=\u0026quot;sha256-…\u0026quot; que le navigateur vérifie avant d\u0026rsquo;exécuter la ressource. Si le hash ne correspond pas, le navigateur bloque le chargement.\n",
        "date_published": "2026-05-17T00:00:00+00:00",
        "date_modified": "2026-05-17T00:00:00+00:00"
      },
      {
        "id": "https://www.arleo.eu/posts/csp-hash-hugo/",
        "url": "https://www.arleo.eu/posts/csp-hash-hugo/",
        "title": "CSP Hash sur Hugo : migrer du nonce vers le hash pour préserver le cache CDN",
        "content_html": "\u003ch2 id=\"le-problème--nonce-csp-et-cache-cdn-ne-font-pas-bon-ménage\"\u003eLe problème : nonce CSP et cache CDN ne font pas bon ménage\u003c/h2\u003e\n\u003cp\u003eQuand j\u0026rsquo;ai migré arleo.eu de Grav vers Hugo, j\u0026rsquo;ai tenté de porter l\u0026rsquo;architecture CSP nonce existante. Sur Grav (PHP), c\u0026rsquo;est élégant : le backend génère un nonce par requête, l\u0026rsquo;injecte dans les balises \u003ccode\u003e\u0026lt;script\u0026gt;\u003c/code\u003e via un plugin, et le transmet à nginx dans un header \u003ccode\u003eX-CSP-Nonce\u003c/code\u003e. nginx construit la CSP à la volée.\u003c/p\u003e\n\u003cp\u003eLe problème : cette approche est \u003cstrong\u003efondamentalement incompatible avec le cache CDN\u003c/strong\u003e. Un nonce différent à chaque requête = un HTML différent à chaque requête = Cloudflare ne peut jamais servir depuis le cache. Le HTML doit être servi avec \u003ccode\u003eCache-Control: no-store\u003c/code\u003e.\u003c/p\u003e\n\u003cp\u003eSur un site statique Hugo, c\u0026rsquo;est absurde. Le contenu ne change pas entre les requêtes — seul le nonce change. La solution évidente est le \u003cstrong\u003ehash CSP\u003c/strong\u003e : calculer l\u0026rsquo;empreinte SHA-256 de chaque script inline au moment du build, l\u0026rsquo;inscrire une fois dans la CSP nginx, et ne plus y toucher jusqu\u0026rsquo;au prochain build.\u003c/p\u003e\n\u003ch2 id=\"larchitecture-en-place\"\u003eL\u0026rsquo;architecture en place\u003c/h2\u003e\n\u003cp\u003eLe site tourne sur un reverse proxy NUC → VM Hugo. nginx sur le NUC ne sert pas de fichiers statiques directement : il proxifie vers la VM (\u003ccode\u003eproxy_pass http://192.168.122.69:80\u003c/code\u003e). Cela signifie que le script de génération des hashes doit tourner sur la VM, là où \u003ccode\u003epublic/\u003c/code\u003e existe.\u003c/p\u003e\n\u003cp\u003eLa migration nonce → hash impliquait trois changements :\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003eSupprimer les \u003ccode\u003esub_filter\u003c/code\u003e\u003c/strong\u003e qui injectaient \u003ccode\u003enonce=\u0026quot;$csp_nonce\u0026quot;\u003c/code\u003e dans tous les \u003ccode\u003e\u0026lt;script\u003c/code\u003e et \u003ccode\u003e\u0026lt;style\u003c/code\u003e. Ces directives n\u0026rsquo;avaient pas d\u0026rsquo;effet sur le contenu entre les balises (les hashes restent donc valides), mais elles étaient inutiles et trompeuses.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003eRemplacer le snippet nonce\u003c/strong\u003e (\u003ccode\u003ecsp_nonce_report_only.conf\u003c/code\u003e) par un snippet hash généré automatiquement à chaque build.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003eActiver le cache HTML\u003c/strong\u003e côté Cloudflare. Avec le nonce, le HTML partait en \u003ccode\u003eno-store\u003c/code\u003e. Avec les hashes, il peut être mis en cache \u003ccode\u003es-maxage=86400\u003c/code\u003e — Cloudflare sert le HTML pendant 24h sans toucher au NUC.\u003c/p\u003e\n\u003ch2 id=\"le-pipeline--csp-hash-genpy\"\u003eLe pipeline : csp-hash-gen.py\u003c/h2\u003e\n\u003cp\u003eUn script Python stdlib (zéro dépendance externe) tourne sur la VM après chaque \u003ccode\u003ehugo build\u003c/code\u003e :\u003c/p\u003e\n\u003col\u003e\n\u003cli\u003eIl parcourt les 393 fichiers HTML de \u003ccode\u003epublic/\u003c/code\u003e\u003c/li\u003e\n\u003cli\u003eIl extrait tous les \u003ccode\u003e\u0026lt;script\u0026gt;\u003c/code\u003e inline sans attribut \u003ccode\u003esrc\u003c/code\u003e (et exclut les blocs \u003ccode\u003eapplication/ld+json\u003c/code\u003e)\u003c/li\u003e\n\u003cli\u003eIl calcule le hash SHA-256 de chaque contenu, encodé en base64\u003c/li\u003e\n\u003cli\u003eIl génère le snippet nginx avec la CSP complète\u003c/li\u003e\n\u003c/ol\u003e\n\u003cp\u003eLe résultat est copié sur le NUC dans \u003ccode\u003e/etc/nginx/snippets/csp-hash-hugo.conf\u003c/code\u003e, puis nginx est rechargé.\u003c/p\u003e\n\u003ch2 id=\"classification-des-15-hashes\"\u003eClassification des 15 hashes\u003c/h2\u003e\n\u003cp\u003eL\u0026rsquo;audit a produit 14 hashes script + 1 hash style, répartis en deux catégories :\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003eHashes stables\u003c/strong\u003e (partagés par de nombreuses pages) : l\u0026rsquo;anti-flash dark mode présent sur 198 pages, les variantes de \u003ccode\u003ewindow.config\u003c/code\u003e selon le type de page (avec ou sans TypeIt, avec ou sans Mermaid, FR ou EN), et l\u0026rsquo;easter egg sur 2 pages.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003eHashes Mermaid\u003c/strong\u003e (1 par article avec diagramme) : chaque article embarque son diagramme complet dans \u003ccode\u003ewindow.config.data\u003c/code\u003e, ce qui produit un hash unique par article. Ces hashes sont \u003cstrong\u003estables à l\u0026rsquo;exécution\u003c/strong\u003e — ils ne changent qu\u0026rsquo;à la modification du diagramme. Sur 6 articles concernés, cela donne 6 hashes fixes jusqu\u0026rsquo;au prochain edit.\u003c/p\u003e\n\u003cp\u003eLa conséquence pratique : tout nouvel article avec un diagramme Mermaid ajoute un hash à la CSP. Le workflow \u003ccode\u003edeploy-hugo.sh\u003c/code\u003e régénère le snippet automatiquement — aucune intervention manuelle.\u003c/p\u003e\n\u003ch2 id=\"validation-avant-enforce\"\u003eValidation avant enforce\u003c/h2\u003e\n\u003cp\u003eAvant de passer en mode enforce, la CSP a tourné en \u003ccode\u003eContent-Security-Policy-Report-Only\u003c/code\u003e le temps de valider la couverture. Un script de vérification croisée a confirmé que les 15 hashes couvrent 100% des 393 fichiers HTML sans exception.\u003c/p\u003e\n\u003cdiv class=\"code-block code-line-numbers open\" data-start=\"0\"\u003e\n    \u003cdiv class=\"code-header language-\"\u003e\n        \u003cspan class=\"code-title\"\u003e\u003ci class=\"arrow fas fa-angle-right\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n        \u003cspan class=\"ellipses\"\u003e\u003ci class=\"fas fa-ellipsis-h\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n        \u003cspan class=\"copy\" title=\"Copier dans le presse-papiers\"\u003e\u003ci class=\"far fa-copy\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n    \u003c/div\u003e\u003cpre tabindex=\"0\"\u003e\u003ccode\u003eHashes connus dans la CSP : 15\n✅ Tous les blocs inline sont couverts par la CSP actuelle\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\n\u003cp\u003eZéro violation. L\u0026rsquo;enforce a été activé immédiatement après.\u003c/p\u003e\n\u003ch2 id=\"résultat\"\u003eRésultat\u003c/h2\u003e\n\u003cdiv class=\"code-block code-line-numbers open\" data-start=\"0\"\u003e\n    \u003cdiv class=\"code-header language-\"\u003e\n        \u003cspan class=\"code-title\"\u003e\u003ci class=\"arrow fas fa-angle-right\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n        \u003cspan class=\"ellipses\"\u003e\u003ci class=\"fas fa-ellipsis-h\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n        \u003cspan class=\"copy\" title=\"Copier dans le presse-papiers\"\u003e\u003ci class=\"far fa-copy\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n    \u003c/div\u003e\u003cpre tabindex=\"0\"\u003e\u003ccode\u003econtent-security-policy: default-src \u0026#39;none\u0026#39;;\n  script-src \u0026#39;self\u0026#39; \u0026#39;sha256-bAT2QS…\u0026#39; \u0026#39;sha256-ENBKJb…\u0026#39; [13 autres] ...;\n  style-src \u0026#39;self\u0026#39; \u0026#39;sha256-mbsscG…\u0026#39;;\n  ...\ncf-cache-status: HIT\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\n\u003cp\u003eLe HTML est maintenant mis en cache par Cloudflare. La CSP est enforce. Les rapports de violation arrivent sur l\u0026rsquo;endpoint \u003ccode\u003e/csp-report\u003c/code\u003e avec le body correctement capturé grâce à \u003ccode\u003elua_need_request_body on\u003c/code\u003e.\u003c/p\u003e\n\u003cp\u003eLe workflow complet pour les prochains articles : \u003ccode\u003ehugo build\u003c/code\u003e → \u003ccode\u003edeploy-hugo.sh\u003c/code\u003e → c\u0026rsquo;est tout.\u003c/p\u003e\n",
        "summary": "Le nonce CSP et le cache CDN sont incompatibles par design. Sur un site Hugo statique, le hash SHA-256 est l\u0026rsquo;approche native : il est calculé au build, reste stable entre les requêtes, et laisse Cloudflare cacher le HTML.",
        "date_published": "2026-05-16T00:00:00+00:00",
        "date_modified": "2026-05-16T06:16:04+02:00"
      },
      {
        "id": "https://www.arleo.eu/posts/postmortem-typeit-mermaid/",
        "url": "https://www.arleo.eu/posts/postmortem-typeit-mermaid/",
        "title": "Postmortem : TypeIt cassé par Mermaid dans le thème LoveIt",
        "content_html": "\u003ch2 id=\"tldr\"\u003eTL;DR\u003c/h2\u003e\n\u003cp\u003eL\u0026rsquo;animation typewriter du thème LoveIt (TypeIt) ne fonctionnait plus sur la home après l\u0026rsquo;ajout de diagrammes Mermaid dans les posts. Cause : un sélecteur DOM \u003ccode\u003e#id-1\u003c/code\u003e partagé entre les deux librairies. Quand Mermaid trouve un bloc orphelin dans un résumé de home, son initialisation crash, et la chaîne d\u0026rsquo;init JS s\u0026rsquo;arrête avant d\u0026rsquo;atteindre TypeIt. Fix : ajouter `\u003c/p\u003e\n\u003cp\u003e` avant tout bloc Mermaid pour le sortir du résumé. 16 posts touchés au final.\u003c/p\u003e\n\u003cp\u003eCe postmortem complète \u003ca href=\"/posts/migration-grav-hugo/\" rel=\"\"\u003eMigration Grav → Hugo\u003c/a\u003e où je mentionnais brièvement le bug. Ici, le récit détaillé du diagnostic et la leçon plus large sur les conflits JS dans les thèmes Hugo.\u003c/p\u003e\n\u003ch2 id=\"le-symptôme\"\u003eLe symptôme\u003c/h2\u003e\n\u003cp\u003eSur ma home \u003ccode\u003earleo.eu\u003c/code\u003e, sous mon avatar, un texte défile en effet machine à écrire. C\u0026rsquo;est le typewriter LoveIt, configuré via \u003ccode\u003eparams.home.profile.typeit\u003c/code\u003e dans \u003ccode\u003ehugo.toml\u003c/code\u003e. Il liste 3 phrases que je trouvais cool :\u003c/p\u003e\n\u003cdiv class=\"code-block code-line-numbers open\" data-start=\"0\"\u003e\n    \u003cdiv class=\"code-header language-toml\"\u003e\n        \u003cspan class=\"code-title\"\u003e\u003ci class=\"arrow fas fa-angle-right\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n        \u003cspan class=\"ellipses\"\u003e\u003ci class=\"fas fa-ellipsis-h\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n        \u003cspan class=\"copy\" title=\"Copier dans le presse-papiers\"\u003e\u003ci class=\"far fa-copy\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n    \u003c/div\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-toml\" data-lang=\"toml\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e[\u003c/span\u003e\u003cspan class=\"nx\"\u003eparams\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"nx\"\u003ehome\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"nx\"\u003eprofile\u003c/span\u003e\u003cspan class=\"p\"\u003e]\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"nx\"\u003etypeit\u003c/span\u003e \u003cspan class=\"p\"\u003e=\u003c/span\u003e \u003cspan class=\"kc\"\u003etrue\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e[\u003c/span\u003e\u003cspan class=\"nx\"\u003eparams\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"nx\"\u003etypeit\u003c/span\u003e\u003cspan class=\"p\"\u003e]\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"nx\"\u003espeed\u003c/span\u003e \u003cspan class=\"p\"\u003e=\u003c/span\u003e \u003cspan class=\"mi\"\u003e100\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"nx\"\u003ecursorSpeed\u003c/span\u003e \u003cspan class=\"p\"\u003e=\u003c/span\u003e \u003cspan class=\"mi\"\u003e1000\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"nx\"\u003ecursorChar\u003c/span\u003e \u003cspan class=\"p\"\u003e=\u003c/span\u003e \u003cspan class=\"s2\"\u003e\u0026#34;|\u0026#34;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"nx\"\u003eduration\u003c/span\u003e \u003cspan class=\"p\"\u003e=\u003c/span\u003e \u003cspan class=\"mi\"\u003e-1\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003cp\u003eSur la version FR, ça marchait. Sur la version EN, le texte apparaissait figé. Comme si TypeIt ne se déclenchait pas.\u003c/p\u003e\n\u003cp\u003ePremier réflexe : devtools navigateur. Console : \u003ccode\u003eUncaught TypeError: Cannot read properties of undefined (reading 'initialize')\u003c/code\u003e au chargement de \u003ccode\u003e/en/\u003c/code\u003e. Stack trace : la fonction qui plante s\u0026rsquo;appelle \u003ccode\u003emermaid.initialize()\u003c/code\u003e. Et juste après, dans la chaîne d\u0026rsquo;init LoveIt, la fonction \u003ccode\u003einitTypeit()\u003c/code\u003e n\u0026rsquo;est jamais appelée.\u003c/p\u003e\n\u003cp\u003eDonc Mermaid plante au chargement, et ça stoppe l\u0026rsquo;init JS avant TypeIt. Mais pourquoi Mermaid plante sur la home ? La home n\u0026rsquo;est pas censée contenir de diagramme.\u003c/p\u003e\n\u003ch2 id=\"le-diagnostic\"\u003eLe diagnostic\u003c/h2\u003e\n\u003cp\u003eInspection HTML de la home EN. Dans le résumé d\u0026rsquo;un post listé sur la home, je trouve un bloc \u003ccode\u003e\u0026lt;div class=\u0026quot;mermaid\u0026quot;\u0026gt;\u003c/code\u003e au beau milieu du résumé d\u0026rsquo;un article. Comment c\u0026rsquo;est arrivé là ?\u003c/p\u003e\n\u003cp\u003eLoveIt génère le résumé d\u0026rsquo;un post de deux façons :\u003c/p\u003e\n\u003col\u003e\n\u003cli\u003eSi le post contient \u003ccode\u003e\u0026lt;!--more--\u0026gt;\u003c/code\u003e, le résumé = ce qui est avant ce marqueur\u003c/li\u003e\n\u003cli\u003eSinon, le résumé = les ~70 premiers mots du contenu\u003c/li\u003e\n\u003c/ol\u003e\n\u003cp\u003eLe post incriminé était \u003ccode\u003e/en/posts/postmortem-mcp-timeouts-cloudflare/\u003c/code\u003e. Son contenu commence par un paragraphe d\u0026rsquo;intro, puis directement un diagramme Mermaid. Sans \u003ccode\u003e\u0026lt;!--more--\u0026gt;\u003c/code\u003e séparateur, LoveIt prend les 70 premiers mots du contenu — qui incluent le bloc Mermaid au format brut.\u003c/p\u003e\n\u003cp\u003eMais le rendu HTML du résumé conserve la classe \u003ccode\u003eclass=\u0026quot;mermaid\u0026quot;\u003c/code\u003e ET un \u003ccode\u003eid=\u0026quot;id-1\u0026quot;\u003c/code\u003e. Donc Mermaid scanne le DOM, voit ce bloc avec \u003ccode\u003eclass=\u0026quot;mermaid\u0026quot;\u003c/code\u003e, essaie de l\u0026rsquo;initialiser.\u003c/p\u003e\n\u003ch2 id=\"le-crash\"\u003eLe crash\u003c/h2\u003e\n\u003cp\u003eMermaid s\u0026rsquo;initialise via un script qui :\u003c/p\u003e\n\u003col\u003e\n\u003cli\u003eScanne tous les éléments avec \u003ccode\u003eclass=\u0026quot;mermaid\u0026quot;\u003c/code\u003e\u003c/li\u003e\n\u003cli\u003eRécupère leur contenu textuel\u003c/li\u003e\n\u003cli\u003eLe parse comme syntaxe Mermaid\u003c/li\u003e\n\u003cli\u003eGénère le SVG correspondant\u003c/li\u003e\n\u003cli\u003eRemplace le contenu de l\u0026rsquo;élément par le SVG\u003c/li\u003e\n\u003c/ol\u003e\n\u003cp\u003eSur la home, le bloc Mermaid orphelin contient du texte de résumé tronqué qui ressemble à du Mermaid mais qui n\u0026rsquo;est pas valide. Mermaid lance une exception. Pas catchée. L\u0026rsquo;init s\u0026rsquo;arrête.\u003c/p\u003e\n\u003cp\u003eEt l\u0026rsquo;init TypeIt, qui est lancée juste après par le même bundle JS de LoveIt, n\u0026rsquo;est jamais appelée. Donc le typewriter reste muet.\u003c/p\u003e\n\u003cp\u003eLe symptôme final est très loin de la cause. Pas d\u0026rsquo;erreur visible côté contenu, pas d\u0026rsquo;erreur Hugo build, pas d\u0026rsquo;erreur de syntaxe Mermaid (le diagramme original dans l\u0026rsquo;article est correct). C\u0026rsquo;est le \u003cstrong\u003erésumé HTML\u003c/strong\u003e qui contient une version corrompue du bloc.\u003c/p\u003e\n\u003ch2 id=\"le-fix\"\u003eLe fix\u003c/h2\u003e\n\u003cp\u003eTrivial une fois la cause identifiée : ajouter \u003ccode\u003e\u0026lt;!--more--\u0026gt;\u003c/code\u003e avant le premier bloc Mermaid de chaque post concerné.\u003c/p\u003e\n\u003cdiv class=\"code-block code-line-numbers open\" data-start=\"0\"\u003e\n    \u003cdiv class=\"code-header language-markdown\"\u003e\n        \u003cspan class=\"code-title\"\u003e\u003ci class=\"arrow fas fa-angle-right\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n        \u003cspan class=\"ellipses\"\u003e\u003ci class=\"fas fa-ellipsis-h\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n        \u003cspan class=\"copy\" title=\"Copier dans le presse-papiers\"\u003e\u003ci class=\"far fa-copy\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n    \u003c/div\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-markdown\" data-lang=\"markdown\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e---\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003etitle: Mon post technique\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003efeaturedImage: /images/postmortem-typeit-mermaid-featured.jpg\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e---\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003eParagraphe d\u0026#39;introduction qui apparaîtra dans le résumé.\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c\"\u003e\u0026lt;!--more--\u0026gt;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003eLe reste du contenu, incluant les blocs Mermaid.\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003cp\u003eUne fois le marqueur ajouté, le résumé devient juste l\u0026rsquo;intro, sans Mermaid. La home n\u0026rsquo;a plus de bloc \u003ccode\u003eclass=\u0026quot;mermaid\u0026quot;\u003c/code\u003e orphelin. Mermaid n\u0026rsquo;essaie plus d\u0026rsquo;initialiser un truc invalide. L\u0026rsquo;init JS continue jusqu\u0026rsquo;au bout, TypeIt démarre, le typewriter marche.\u003c/p\u003e\n\u003ch2 id=\"lampleur\"\u003eL\u0026rsquo;ampleur\u003c/h2\u003e\n\u003cp\u003eCombien de posts concernés ? Script de détection :\u003c/p\u003e\n\u003cdiv class=\"code-block code-line-numbers open\" data-start=\"0\"\u003e\n    \u003cdiv class=\"code-header language-bash\"\u003e\n        \u003cspan class=\"code-title\"\u003e\u003ci class=\"arrow fas fa-angle-right\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n        \u003cspan class=\"ellipses\"\u003e\u003ci class=\"fas fa-ellipsis-h\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n        \u003cspan class=\"copy\" title=\"Copier dans le presse-papiers\"\u003e\u003ci class=\"far fa-copy\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n    \u003c/div\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-bash\" data-lang=\"bash\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"k\"\u003efor\u003c/span\u003e f in \u003cspan class=\"k\"\u003e$(\u003c/span\u003efind content/posts -name \u003cspan class=\"s2\"\u003e\u0026#34;*.md\u0026#34;\u003c/span\u003e\u003cspan class=\"k\"\u003e)\u003c/span\u003e\u003cspan class=\"p\"\u003e;\u003c/span\u003e \u003cspan class=\"k\"\u003edo\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"k\"\u003eif\u003c/span\u003e grep -q \u003cspan class=\"s1\"\u003e\u0026#39;```mermaid\u0026#39;\u003c/span\u003e \u003cspan class=\"s2\"\u003e\u0026#34;\u003c/span\u003e\u003cspan class=\"nv\"\u003e$f\u003c/span\u003e\u003cspan class=\"s2\"\u003e\u0026#34;\u003c/span\u003e\u003cspan class=\"p\"\u003e;\u003c/span\u003e \u003cspan class=\"k\"\u003ethen\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e        \u003cspan class=\"k\"\u003eif\u003c/span\u003e ! grep -q \u003cspan class=\"s1\"\u003e\u0026#39;\u0026lt;!--more--\u0026gt;\u0026#39;\u003c/span\u003e \u003cspan class=\"s2\"\u003e\u0026#34;\u003c/span\u003e\u003cspan class=\"nv\"\u003e$f\u003c/span\u003e\u003cspan class=\"s2\"\u003e\u0026#34;\u003c/span\u003e\u003cspan class=\"p\"\u003e;\u003c/span\u003e \u003cspan class=\"k\"\u003ethen\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e            \u003cspan class=\"nb\"\u003eecho\u003c/span\u003e \u003cspan class=\"s2\"\u003e\u0026#34;Manque \u0026lt;!--more--\u0026gt; dans \u003c/span\u003e\u003cspan class=\"nv\"\u003e$f\u003c/span\u003e\u003cspan class=\"s2\"\u003e\u0026#34;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e        \u003cspan class=\"k\"\u003efi\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"k\"\u003efi\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"k\"\u003edone\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003cp\u003eVerdict : 16 fichiers (8 posts FR + 8 EN, vu que tous mes posts récents sont bilingues). Tous corrigés en une passe avec un script Python idempotent.\u003c/p\u003e\n\u003ch2 id=\"pourquoi-sur-en-et-pas-fr-au-début-\"\u003ePourquoi sur EN et pas FR au début ?\u003c/h2\u003e\n\u003cp\u003eBonne question. Le bug existait sur les deux langues, mais la home FR avait visiblement TypeIt qui marchait. Inspection plus poussée : c\u0026rsquo;est l\u0026rsquo;ordre des posts dans la home qui a fait la différence. Sur FR, les premiers posts n\u0026rsquo;avaient pas de Mermaid (donc résumés OK). Sur EN, le premier post du listing avait un Mermaid orphelin (donc résumé KO).\u003c/p\u003e\n\u003cp\u003eLoveIt initialise les composants dans l\u0026rsquo;ordre où il trouve les éléments DOM. Tant que Mermaid trouve un bloc valide avant un bloc cassé, l\u0026rsquo;init plante après TypeIt et ne casse rien. Si elle plante avant, elle stoppe la chaîne.\u003c/p\u003e\n\u003cp\u003eDonc le bug était latent sur FR aussi. Le \u003ccode\u003e\u0026lt;!--more--\u0026gt;\u003c/code\u003e sur les 16 posts est une vraie correction, pas un patch cosmétique.\u003c/p\u003e\n\u003ch2 id=\"la-leçon-plus-large\"\u003eLa leçon plus large\u003c/h2\u003e\n\u003cp\u003eLes thèmes Hugo qui chargent plusieurs libs JS via un bundle unique ont \u003cstrong\u003etoutes\u003c/strong\u003e ce type de risque : une lib qui plante stoppe les autres. Sans \u003ccode\u003etry/catch\u003c/code\u003e autour de chaque init, on a une chaîne fragile.\u003c/p\u003e\n\u003cp\u003ePatterns à retenir pour qui écrit un thème Hugo (ou n\u0026rsquo;importe quel template) :\u003c/p\u003e\n\u003ch3 id=\"1-toujours-wrapper-les-init-js\"\u003e1. Toujours wrapper les init JS\u003c/h3\u003e\n\u003cdiv class=\"code-block code-line-numbers open\" data-start=\"0\"\u003e\n    \u003cdiv class=\"code-header language-javascript\"\u003e\n        \u003cspan class=\"code-title\"\u003e\u003ci class=\"arrow fas fa-angle-right\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n        \u003cspan class=\"ellipses\"\u003e\u003ci class=\"fas fa-ellipsis-h\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n        \u003cspan class=\"copy\" title=\"Copier dans le presse-papiers\"\u003e\u003ci class=\"far fa-copy\" aria-hidden=\"true\"\u003e\u003c/i\u003e\u003c/span\u003e\n    \u003c/div\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-javascript\" data-lang=\"javascript\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c1\"\u003e// Mauvais\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c1\"\u003e\u003c/span\u003e\u003cspan class=\"nx\"\u003emermaid\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"nx\"\u003einitialize\u003c/span\u003e\u003cspan class=\"p\"\u003e({\u003c/span\u003e\u003cspan class=\"nx\"\u003estartOnLoad\u003c/span\u003e\u003cspan class=\"o\"\u003e:\u003c/span\u003e \u003cspan class=\"kc\"\u003etrue\u003c/span\u003e\u003cspan class=\"p\"\u003e});\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"nx\"\u003etypeit\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"nx\"\u003einit\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"s1\"\u003e\u0026#39;#typeit-target\u0026#39;\u003c/span\u003e\u003cspan class=\"p\"\u003e);\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c1\"\u003e// Bon\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c1\"\u003e\u003c/span\u003e\u003cspan class=\"k\"\u003etry\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e \u003cspan class=\"nx\"\u003emermaid\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"nx\"\u003einitialize\u003c/span\u003e\u003cspan class=\"p\"\u003e({\u003c/span\u003e\u003cspan class=\"nx\"\u003estartOnLoad\u003c/span\u003e\u003cspan class=\"o\"\u003e:\u003c/span\u003e \u003cspan class=\"kc\"\u003etrue\u003c/span\u003e\u003cspan class=\"p\"\u003e});\u003c/span\u003e \u003cspan class=\"p\"\u003e}\u003c/span\u003e \u003cspan class=\"k\"\u003ecatch\u003c/span\u003e \u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nx\"\u003ee\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e \u003cspan class=\"nx\"\u003econsole\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"nx\"\u003eerror\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"s1\"\u003e\u0026#39;mermaid init failed:\u0026#39;\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"nx\"\u003ee\u003c/span\u003e\u003cspan class=\"p\"\u003e);\u003c/span\u003e \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"k\"\u003etry\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e \u003cspan class=\"nx\"\u003etypeit\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"nx\"\u003einit\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"s1\"\u003e\u0026#39;#typeit-target\u0026#39;\u003c/span\u003e\u003cspan class=\"p\"\u003e);\u003c/span\u003e \u003cspan class=\"p\"\u003e}\u003c/span\u003e \u003cspan class=\"k\"\u003ecatch\u003c/span\u003e \u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nx\"\u003ee\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e \u003cspan class=\"nx\"\u003econsole\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"nx\"\u003eerror\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"s1\"\u003e\u0026#39;typeit init failed:\u0026#39;\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"nx\"\u003ee\u003c/span\u003e\u003cspan class=\"p\"\u003e);\u003c/span\u003e \u003cspan class=\"p\"\u003e}\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003cp\u003eUne init JS n\u0026rsquo;a aucune raison de propager son erreur aux autres libs du même bundle.\u003c/p\u003e\n\u003ch3 id=\"2-méfiance-avec-les-sélecteurs-auto-générés\"\u003e2. Méfiance avec les sélecteurs auto-générés\u003c/h3\u003e\n\u003cp\u003eLoveIt nomme ses éléments \u003ccode\u003eid-1\u003c/code\u003e, \u003ccode\u003eid-2\u003c/code\u003e, etc. Si un autre composant utilise les mêmes IDs (ou si Mermaid scanne \u003ccode\u003e[class=\u0026quot;mermaid\u0026quot;]\u003c/code\u003e au lieu de \u003ccode\u003e.mermaid:not(.processed)\u003c/code\u003e), conflits assurés. Mieux : utiliser des classes/IDs préfixés (\u003ccode\u003e.lv-typeit\u003c/code\u003e, \u003ccode\u003e.lv-mermaid-container\u003c/code\u003e).\u003c/p\u003e\n\u003ch3 id=\"3-validation-côté-hugo-build\"\u003e3. Validation côté Hugo build\u003c/h3\u003e\n\u003cp\u003eOn pourrait imaginer un test Hugo qui parse tous les \u003ccode\u003e.md\u003c/code\u003e contenant des blocs Mermaid, vérifie qu\u0026rsquo;ils ont un \u003ccode\u003e\u0026lt;!--more--\u0026gt;\u003c/code\u003e AVANT le premier bloc, et échoue le build sinon. Pas implémenté pour l\u0026rsquo;instant, mais ça éviterait que le bug réapparaisse en oubliant le marqueur.\u003c/p\u003e\n\u003ch3 id=\"4-tests-visuels-automatisés\"\u003e4. Tests visuels automatisés\u003c/h3\u003e\n\u003cp\u003ePour les sites qui dépendent d\u0026rsquo;effets visuels (typewriter, animations, lazy loading), un test Playwright qui charge la home et vérifie que TypeIt a animé serait précieux. Encore mieux : screenshot diffing.\u003c/p\u003e\n\u003ch2 id=\"pour-loveit-en-amont\"\u003ePour LoveIt en amont\u003c/h2\u003e\n\u003cp\u003eJ\u0026rsquo;ai ouvert une issue sur le repo LoveIt avec le diagnostic. Le maintainer pourrait :\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003eWrapper les init dans des try/catch (fix minimal)\u003c/li\u003e\n\u003cli\u003eStripper les blocs \u003ccode\u003eclass=\u0026quot;mermaid\u0026quot;\u003c/code\u003e du HTML de résumé (fix robuste côté template)\u003c/li\u003e\n\u003cli\u003eDocumenter explicitement que \u003ccode\u003e\u0026lt;!--more--\u0026gt;\u003c/code\u003e est requis pour les posts avec Mermaid (fix documentaire)\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003eLe 3ème serait le plus rapide à mettre en place, mais le 2ème reste la solution propre.\u003c/p\u003e\n\u003ch2 id=\"conclusion\"\u003eConclusion\u003c/h2\u003e\n\u003cp\u003eUn bug à effet de bord lointain (la home ne typewriter pas) causé par un détail loin du symptôme (un bloc orphelin dans un résumé). Le diagnostic a pris ~30 minutes une fois ouvertes les devtools navigateur. La leçon : quand un comportement JS visible foire, \u003cstrong\u003eouvrir la console\u003c/strong\u003e est la première étape, même si le contenu semble sans rapport.\u003c/p\u003e\n\u003cp\u003e16 fichiers corrigés en quelques minutes via script. Le marqueur \u003ccode\u003e\u0026lt;!--more--\u0026gt;\u003c/code\u003e est maintenant systématique dans mes templates de post.\u003c/p\u003e\n\u003cp\u003eSi tu utilises LoveIt avec Mermaid, vérifie tes posts. Le bug est latent sur n\u0026rsquo;importe quel post sans marqueur explicite.\u003c/p\u003e",
        "summary": "TL;DR L\u0026rsquo;animation typewriter du thème LoveIt (TypeIt) ne fonctionnait plus sur la home après l\u0026rsquo;ajout de diagrammes Mermaid dans les posts. Cause : un sélecteur DOM #id-1 partagé entre les deux librairies. Quand Mermaid trouve un bloc orphelin dans un résumé de home, son initialisation crash, et la chaîne d\u0026rsquo;init JS s\u0026rsquo;arrête avant d\u0026rsquo;atteindre TypeIt. Fix : ajouter `\n",
        "date_published": "2026-05-14T09:44:43+02:00",
        "date_modified": "2026-05-15T23:51:25+02:00"
      }
  ]
}
