<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Odilon HUGONNOT</title>
    <description>The latest articles on DEV Community by Odilon HUGONNOT (@ohugonnot).</description>
    <link>https://dev.to/ohugonnot</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3833552%2F48d32eab-68ed-4496-8ba6-f01e32806723.png</url>
      <title>DEV Community: Odilon HUGONNOT</title>
      <link>https://dev.to/ohugonnot</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ohugonnot"/>
    <language>en</language>
    <item>
      <title>Offline PWA: the pitfalls of an app that never asks for the network</title>
      <dc:creator>Odilon HUGONNOT</dc:creator>
      <pubDate>Thu, 17 Sep 2026 09:00:02 +0000</pubDate>
      <link>https://dev.to/ohugonnot/offline-pwa-the-pitfalls-of-an-app-that-never-asks-for-the-network-fc7</link>
      <guid>https://dev.to/ohugonnot/offline-pwa-the-pitfalls-of-an-app-that-never-asks-for-the-network-fc7</guid>
      <description>&lt;p&gt;In the &lt;a href="https://www.web-developpeur.com/en/blog/caa-pictogrammes-enfant-autiste" rel="noopener noreferrer"&gt;previous article&lt;/a&gt;, I told the story of why I built Mes mots, a pictogram communication app for a kid who doesn't talk yet. One of the project's constraints is that it has to work in airplane mode, on a tablet that might not see wifi for days. This article is only about that: how you build something truly offline, and above all how it still gets updated without a permanent connection.&lt;/p&gt;

&lt;p&gt;"Offline-first", in most projects that carry the label, means something different from what it means here. Worth clarifying before getting into the code.&lt;/p&gt;

&lt;h2&gt;
  
  
  The misunderstanding around "offline-first"
&lt;/h2&gt;

&lt;p&gt;Most apps labeled offline-first actually have a server behind them. A Trello, a Google Docs, a mail client: they cache, let you write offline, then sync and reconcile conflicts once the network comes back. That's a real engineering problem, conflict resolution, vector clocks, three-way merges, and a good chunk of the writing on the topic is about exactly that.&lt;/p&gt;

&lt;p&gt;Mes mots doesn't have that problem, because it doesn't have a server at all. There's nothing to sync, because there's no one on the other end. The only thing that ever "arrives" from the network, occasionally, is a new version of the app's own code, not data. That's a much rarer category: not offline-first with a network fallback, but offline, period, with an occasional update.&lt;/p&gt;

&lt;p&gt;That removes a whole class of problems (no conflicts, no auth token to refresh, no partial sync state to display). It brings in another one, narrower and stranger: getting a code update to arrive without ever disturbing whatever the user is doing right now, while having literally no way to talk to them other than through the screen they're already using.&lt;/p&gt;

&lt;h2&gt;
  
  
  What needs caching, and the file everyone forgets
&lt;/h2&gt;

&lt;p&gt;The project uses the &lt;a href="https://vite-pwa-org.netlify.app/" rel="noopener noreferrer"&gt;vite-plugin-pwa&lt;/a&gt; plugin, which generates a Workbox-powered service worker from a simple declarative config. The starting point is the list of files to precache:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;workbox&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// phrase MP3s must be cached: without them the app is mute offline&lt;/span&gt;
  &lt;span class="nl"&gt;globPatterns&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;**/*.{js,css,html,png,svg,woff2,mp3}&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="c1"&gt;// a previous version's cache has already served a bundle gone from disk, which&lt;/span&gt;
  &lt;span class="c1"&gt;// silently hid a shipped feature: the family would stay on an old version&lt;/span&gt;
  &lt;span class="c1"&gt;// without knowing it, with no way to notice&lt;/span&gt;
  &lt;span class="nx"&gt;cleanupOutdatedCaches&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The trap lives in a forgotten extension. The &lt;code&gt;{js,css,html,png,svg,woff2}&lt;/code&gt; pattern catches everything a typical frontend build produces. Except this app talks: every tile speaks its word by playing an &lt;code&gt;.mp3&lt;/code&gt; file. Without &lt;code&gt;mp3&lt;/code&gt; in the list, the app installs perfectly fine, opens offline with no error, tiles render, everything looks normal. And nothing comes out of the speaker. No exception in the console, no error screen: just a silence a parent would discover in airplane mode, at the worst possible moment.&lt;/p&gt;

&lt;p&gt;That's the nature of a caching bug in a PWA: it doesn't break anything, it quietly removes a feature. The only safety net is listing, explicitly, everything the app needs to function without a network, not just the code.&lt;/p&gt;

&lt;h2&gt;
  
  
  A root domain, a subfolder, and a blank screen with no error
&lt;/h2&gt;

&lt;p&gt;The app runs in two places: on the family's tablet, served at the root of a domain (&lt;code&gt;anime-sanctuary.net&lt;/code&gt;), and as a public demo on GitHub Pages, under &lt;code&gt;/mes-mots/&lt;/code&gt;. A service worker and its precached assets are scoped to a specific base URL, and a PWA built for the root looks for its files one level too high when served from a subfolder.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="cm"&gt;/**
 * The tablet serves the app at its domain root. The public demo lives under
 * `/mes-mots/` on GitHub Pages, and without this prefix it would look for its files
 * one level too high: blank page, no readable error. `BASE_PUBLIQUE` exists only for it.
 */&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;BASE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;BASE_PUBLIQUE&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One environment variable, read once at build time, decides between the two cases. The lesson here isn't the variable itself, it's the failure mode: a wrong base path doesn't throw a clean exception you can read in devtools, it produces a blank page. The kind of bug you can't debug remotely, on the tablet of someone with no terminal.&lt;/p&gt;

&lt;h2&gt;
  
  
  An update that arrives without being asked for
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;registerType: 'autoUpdate'&lt;/code&gt; in the plugin config means the service worker downloads the new version as soon as it sees network, installs it in the background, and takes over on its own, no "an update is available" banner to click. What that mode doesn't tell you is that the page already open keeps running the old JavaScript until a full reload.&lt;/p&gt;

&lt;p&gt;The browser exposes an event to find out: &lt;code&gt;controllerchange&lt;/code&gt;, fired when a new service worker has just taken control of the page.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="cm"&gt;/**
 * The new service worker takes over on its own, but the displayed page keeps running
 * the old one until a reload: it took two loads to see a fix, and the listener only
 * lived in the parents screen, absent from the child's screen. Here it starts at
 * boot. `controller` doesn't exist on the very first install, which would otherwise
 * look like an update.
 */&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;updateReady&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;serviceWorker&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;controller&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;serviceWorker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;controllerchange&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;updateReady&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The comment tells a real fixed regression: this listener originally lived in the parents screen, never visible from the child's screen, the one that actually runs all day. As a result, an update taken by the service worker was only detected if a parent opened their space, which could take days. The guard on &lt;code&gt;controller&lt;/code&gt; avoids, in turn, confusing the very first install (where no controller exists yet) with an actual update.&lt;/p&gt;

&lt;h2&gt;
  
  
  Never reload a tile mid-speech
&lt;/h2&gt;

&lt;p&gt;Detecting that an update is ready doesn't say when to apply it. Reloading the page immediately would cut a word mid-speech, erase a phrase being composed on the sentence bar, or lose a parent's half-filled form in the settings screen. For a kid who doesn't understand why the screen changed, each of those is a bad surprise.&lt;/p&gt;

&lt;p&gt;The fix is defining an idle state and only applying the update then:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="cm"&gt;/**
 * The reload waits until the tablet isn't serving anyone: otherwise it would cut off
 * a word mid-speech, erase a phrase being composed, or lose a parent's half-filled
 * form. At rest, it goes unnoticed.
 */&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tabletIdle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;computed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="nx"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;child&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
    &lt;span class="nx"&gt;speakingTileId&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
    &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;isReadingPhrase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
    &lt;span class="nx"&gt;phrase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;watch&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nx"&gt;updateReady&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;tabletIdle&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;updateReady&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;tabletIdle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reload&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four conditions have to be true at once: the child screen is shown (not the parents space), no tile is currently speaking, the phrase bar isn't being read back, and the composed phrase is empty. The reload waits patiently for all four to turn green, and only fires then. On an app that runs all day and never gets closed, that moment always ends up arriving, usually within minutes.&lt;/p&gt;

&lt;p&gt;It's a rule that generalizes well beyond this project: a silent background update is harmless for a blog or a dashboard you can refresh without thinking. It turns dangerous the moment the page's state has value, a form in progress, media mid-playback, unsaved input. At that point you need an explicit safe point, not just a timer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Knowing which version is running, without opening devtools
&lt;/h2&gt;

&lt;p&gt;Last detail, tiny in the code, decisive in practice: every build prints its own build date.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="cm"&gt;/** Build date, shown in the parents screen: "which version is running?" should be
 *  answered by looking at the screen, not by digging through a service worker cache. */&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;BUILD_VERSION&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;T&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt; &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A developer diagnoses a caching problem by opening the Application tab in devtools. A parent will never open that tab, and shouldn't have to. Showing the version in plain sight in the parents screen turns a debugging question ("did the service worker actually pick up the latest version?") into one anyone can answer just by looking at the screen.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;The network, in web development, we've learned to be wary of it: timeouts, retries, loading states, a whole vocabulary built around its absence. The silence of an update arriving unseen, much less so. Building an app that never asks for the network doesn't remove complexity, it moves it to a place you rarely think about: the exact moment it becomes safe to swap the ground under the feet of someone who's currently standing on it.&lt;/p&gt;

</description>
      <category>pwa</category>
      <category>serviceworker</category>
      <category>offlinefirst</category>
      <category>vite</category>
    </item>
    <item>
      <title>Web security — free interactive course</title>
      <dc:creator>Odilon HUGONNOT</dc:creator>
      <pubDate>Thu, 17 Sep 2026 08:45:31 +0000</pubDate>
      <link>https://dev.to/ohugonnot/web-security-free-interactive-course-10pd</link>
      <guid>https://dev.to/ohugonnot/web-security-free-interactive-course-10pd</guid>
      <description>&lt;h1&gt;
  
  
  Web security
&lt;/h1&gt;

&lt;p&gt;Current with the OWASP Top 10 2025 (Injection moved to A05, most courses still teach the old ranking), with real sandboxed attack labs, not just theory.&lt;/p&gt;

&lt;p&gt;Free interactive web security course in 14 lessons. OWASP Top 10 2025, SQL injection, XSS, CSRF, CORS, cookies, CSP. PHP, Go and JS examples with safe challenges. No signup.&lt;/p&gt;

&lt;p&gt;Free, interactive, no signup: &lt;a href="https://www.web-developpeur.com/en/learning/securite/" rel="noopener noreferrer"&gt;https://www.web-developpeur.com/en/learning/securite/&lt;/a&gt;&lt;/p&gt;

</description>
      <category>learning</category>
      <category>webdev</category>
      <category>securite</category>
    </item>
    <item>
      <title>Coding with AI — free interactive course</title>
      <dc:creator>Odilon HUGONNOT</dc:creator>
      <pubDate>Thu, 17 Sep 2026 08:44:18 +0000</pubDate>
      <link>https://dev.to/ohugonnot/coding-with-ai-free-interactive-course-1bko</link>
      <guid>https://dev.to/ohugonnot/coding-with-ai-free-interactive-course-1bko</guid>
      <description>&lt;h1&gt;
  
  
  Coding with AI
&lt;/h1&gt;

&lt;p&gt;Not a language course: it's the missing manual for coding alongside AI without losing control of the review, the debugging or the architecture.&lt;/p&gt;

&lt;p&gt;Learn to use AI as a development tool, not a crutch. Effective prompts, code review, debugging, architecture, agents and MCP. Free interactive lessons.&lt;/p&gt;

&lt;p&gt;Free, interactive, no signup: &lt;a href="https://www.web-developpeur.com/en/learning/coder-avec-ia/" rel="noopener noreferrer"&gt;https://www.web-developpeur.com/en/learning/coder-avec-ia/&lt;/a&gt;&lt;/p&gt;

</description>
      <category>learning</category>
      <category>webdev</category>
      <category>coderavecia</category>
    </item>
    <item>
      <title>An AAC app for a kid who doesn't talk yet</title>
      <dc:creator>Odilon HUGONNOT</dc:creator>
      <pubDate>Wed, 16 Sep 2026 09:00:02 +0000</pubDate>
      <link>https://dev.to/ohugonnot/an-aac-app-for-a-kid-who-doesnt-talk-yet-32do</link>
      <guid>https://dev.to/ohugonnot/an-aac-app-for-a-kid-who-doesnt-talk-yet-32do</guid>
      <description>&lt;p&gt;Someone I know has a kid who doesn't talk yet, and can't read yet either. The diagnosis is autism. What works for him, right now, is a tile you touch with a finger, and it says the word for him. I got asked whether I could build that.&lt;/p&gt;

&lt;p&gt;Serious AAC (augmentative and alternative communication) apps already exist. Proloquo2Go, TouchChat, LAMP Words for Life: vocabularies of several thousand words, built by speech therapists over years, priced between 150 and 300 dollars on iOS, up to 550 pounds for Grid 3 on desktop. Free and open alternatives exist too, CBoard (backed by UNICEF) and AsTeRICS Grid (University of Applied Sciences Vienna), both serious and actively maintained. None of that was missing from the world. What was missing was a simple grid, right now, for one specific kid, with nothing to get through before a finger can hit a tile.&lt;/p&gt;

&lt;p&gt;I spent a weekend, then several weeks, building &lt;a href="https://mesmots.anime-sanctuary.net/" rel="noopener noreferrer"&gt;Mes mots&lt;/a&gt;: Vue 3, TypeScript, no server, no account. The &lt;a href="https://github.com/ohugonnot/mes-mots" rel="noopener noreferrer"&gt;code is on GitHub&lt;/a&gt; under the AGPL license. Here's what building it taught me, and why it doesn't look like a typical AAC app.&lt;/p&gt;

&lt;h2&gt;
  
  
  A tile that never moves
&lt;/h2&gt;

&lt;p&gt;The spec fit in a conversation, not a document. A kid who can't read yet learns by the position of his finger, not by the word printed under the picture. If the "eat" tile moves to a different corner from one day to the next because a word got added elsewhere, the kid starts over from zero. That constraint drove the whole board architecture: tiles get added, they never reorganize themselves.&lt;/p&gt;

&lt;p&gt;That's not a claim dropped into a README. It's checked by tests, and by a mutation testing harness that makes sure those tests actually bite, more on that below.&lt;/p&gt;

&lt;h2&gt;
  
  
  Nothing leaves the tablet
&lt;/h2&gt;

&lt;p&gt;Second constraint, just as non-negotiable as the first: no data leaves the device. The photos added to a tile are the family's own, not generic pictograms. The voices recorded are the family's own, a kid recognizes his mother's voice before he recognizes a word. Sending that to a server, even encrypted, even for a perfectly harmless service, was out of the question.&lt;/p&gt;

&lt;p&gt;Concretely, everything lives in IndexedDB, four separate object stores accessed through a single local repository. The code comment spells out exactly what that means:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="cm"&gt;/**
 * Local configuration repository (requirement T1). Nothing leaves the tablet.
 *
 * Image and sound stores are keyed by tile id: a family photo or voice, never
 * the configuration itself, which only carries a text reference
 * (`perso/&amp;lt;tileId&amp;gt;`, see `customIdentifier` in planche.ts).
 */&lt;/span&gt;
&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;SchemaMesMots&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nx"&gt;DBSchema&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;config&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Configuration&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="nx"&gt;ConfigurationV2&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="nx"&gt;Planche&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nl"&gt;images&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Blob&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nl"&gt;sons&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Blob&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nl"&gt;journal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;EntreeJournal&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No account, no sync, no automatic cloud backup. The trade-off, acknowledged and reminded by the app itself, is that the family has to back up by hand. The backup format isn't proprietary: it's an &lt;code&gt;.obz&lt;/code&gt; file, the &lt;a href="https://www.openboardformat.org/" rel="noopener noreferrer"&gt;Open Board Format&lt;/a&gt; standard, readable by other AAC apps the day the family needs to switch tools.&lt;/p&gt;

&lt;p&gt;The app runs as a PWA, installable and usable in airplane mode. That deserves its own article, the pitfalls of an app that never asks for the network, and how it still gets updated. That's next.&lt;/p&gt;

&lt;h2&gt;
  
  
  Vue 3 with zero UI libraries
&lt;/h2&gt;

&lt;p&gt;No component library, no CSS framework. Every button, every tile, every transition is hand-written, in &lt;code&gt;&amp;lt;style scoped&amp;gt;&lt;/code&gt; per component. Not out of purism: for a grid of about fifteen tiles and two screens (child, parents), a component library adds more code to maintain than time it saves, and it imposes its own default accessibility where this one needed to be designed for a specific use case. CSS you write yourself, you know exactly what it does and why.&lt;/p&gt;

&lt;h2&gt;
  
  
  A mutation harness, because a green suite proves nothing
&lt;/h2&gt;

&lt;p&gt;611 Vitest unit tests, 234 Playwright end-to-end tests, across two profiles: an 800×1280 tablet and an iPhone 13 on Safari. On paper, comfortable coverage. Except a fully green suite only proves nothing broke, not that something is actually being checked.&lt;/p&gt;

&lt;p&gt;A typical unit test calls a function, compares the result to what it expects, and turns green as soon as the call succeeds, even if the assertion barely checks anything. The only way to know whether a test actually bites is to deliberately break the code it's supposed to protect, and check that the test goes red. That's what mutation testing does, and that's what &lt;code&gt;mutation.sh&lt;/code&gt; does in the project: one mutation per backlog task, not per file, pattern by pattern.&lt;/p&gt;

&lt;p&gt;The comment at the top of the script explains why it exists: "at step E1 the audio interruption could have vanished entirely without a single check flinching." In other words, a full regression on interrupting audio, not a detail, an entire behavior, had slipped through more than 600 green tests. The matching mutation pattern caught it.&lt;/p&gt;

&lt;p&gt;The script has its own set of traps to stay honest. The first one: a forgotten &lt;code&gt;vite preview&lt;/code&gt; server on port 4173 would keep serving the old code on every mutation, and every mutation would "pass" as caught while the tests actually run against code that never changed.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# A forgotten server on the port would serve the old code and the mutation would pass as caught.&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;ss &lt;span class="nt"&gt;-lptn&lt;/span&gt; &lt;span class="s1"&gt;'sport = :4173'&lt;/span&gt; 2&amp;gt;/dev/null | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-q&lt;/span&gt; LISTEN&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"  port 4173 already in use, end-to-end mutations would be skewed"&lt;/span&gt;
  &lt;span class="nb"&gt;exit &lt;/span&gt;1
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second trap is subtler: a mutation that breaks compilation leaves &lt;code&gt;dist/&lt;/code&gt; untouched, so &lt;code&gt;vite preview&lt;/code&gt; keeps serving the old bundle. Without a &lt;code&gt;vite build&lt;/code&gt; before every end-to-end attempt, the harness would blame the tests for missing the mutation, when it was really the server serving code that never got rebuilt.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it doesn't do, and why that matters
&lt;/h2&gt;

&lt;p&gt;No eye tracking, no switch access, it's used with a finger. No vocabulary of several thousand words built over years by speech therapists, the way Proloquo2Go or LAMP Words for Life carry. No grammar, no conjugation: you place words, you don't build complex sentences. No multi-device sync, by choice, not by oversight.&lt;/p&gt;

&lt;p&gt;It's also not a medical device or a treatment. An AAC app is chosen together with a speech therapist, who knows the child and can say whether a grid of pictograms fits, and which ones. This app was written for one specific child, based on needs his family described. I'm publishing it because it might serve someone else, not because it would suit everyone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;The hardest part wasn't Vue, or IndexedDB, or even the mutation harness. It was resisting the urge to add more: more words, more options, more settings. A kid who can't read yet gains nothing from a grid of two hundred tiles. He needs ten, that never move.&lt;/p&gt;

&lt;p&gt;The code is out there if someone else needs it. So is the demo.&lt;/p&gt;

</description>
      <category>vue3</category>
      <category>typescript</category>
      <category>a11y</category>
      <category>testing</category>
    </item>
    <item>
      <title>My captcha never protected against spam, only against laziness</title>
      <dc:creator>Odilon HUGONNOT</dc:creator>
      <pubDate>Tue, 01 Sep 2026 09:00:03 +0000</pubDate>
      <link>https://dev.to/ohugonnot/my-captcha-never-protected-against-spam-only-against-laziness-cki</link>
      <guid>https://dev.to/ohugonnot/my-captcha-never-protected-against-spam-only-against-laziness-cki</guid>
      <description>&lt;p&gt;Back in March, a bot named RobertqueRy spammed a post about gRPC with an ad for online gambling in Bangladesh. I added a honeypot field, a CSRF token, an IP-based rate limit and a math captcha. In the conclusion of &lt;a href="https://www.web-developpeur.com/en/blog/premier-spam-anti-bot-captcha-php" rel="noopener noreferrer"&gt;that article&lt;/a&gt;, I wrote a line I should have taken more seriously: "a dedicated bot that parses the HTML could bypass it." Five months later, two comments showed up on the article that explains how my comment system works. The target is almost funny. The payload wasn't: a car rental ad for Marrakech, hardcoded link included.&lt;/p&gt;

&lt;p&gt;All four layers let the bot through. Not one crack, a clean bypass.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually failed
&lt;/h2&gt;

&lt;p&gt;The honeypot is a hidden field only a bot fills in. Empty: the bot didn't fill it, it loaded the page and read the form correctly. The CSRF token changes every session and must match the one sent with the POST: present and valid, so the bot made a real GET request before its POST, like a normal browser. The rate limit allows 3 comments per IP every 10 minutes: only one comment posted, nothing to trigger. That left the captcha, my last line of defense, a single-digit addition displayed in plain text on the page.&lt;/p&gt;

&lt;p&gt;That's where my March mistake came back to bite me. The captcha wasn't rendered as an image, wasn't obfuscated in any way. It was just two numbers and a &lt;code&gt;+&lt;/code&gt; sign, in plain text, in the HTML:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;label&lt;/span&gt; &lt;span class="na"&gt;for=&lt;/span&gt;&lt;span class="s"&gt;"bk-captcha"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Anti-spam check: 6 + 1 = ?&lt;span class="nt"&gt;&amp;lt;/label&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A bot that downloads the page and greps the digits doesn't even need to pretend it understands addition. It extracts the numbers, computes the sum, and posts. That's exactly the test I ended up writing myself to verify the fix, without thinking twice about it: a script that loads the page, pulls the CSRF token and the two captcha operands out with two lines of &lt;code&gt;grep&lt;/code&gt;, computes the sum, and posts. Three minutes of bash on a Friday morning. If I can automate solving my own captcha in three minutes, a real spam bot automated it a long time ago.&lt;/p&gt;

&lt;h2&gt;
  
  
  The real problem: I was checking the wrong thing
&lt;/h2&gt;

&lt;p&gt;Honeypot, CSRF, rate limit, captcha: all four layers answer the same question, "did a human fill out this form normally?" The bot that spammed me answered yes to all four. It loaded the page, left the trap empty, grabbed the right token, respected the rate limit, and solved a single-digit sum. From its perspective, it was a human visitor filling out a form once. What none of these layers ever asks is &lt;em&gt;what the comment actually contains&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;That's the gap. A whole system for verifying who the visitor is, and zero lines looking at what they're trying to publish. Two comments pushing car rentals in Marrakech and Cesme sailed through four security layers without a single one reading their content.&lt;/p&gt;

&lt;h2&gt;
  
  
  The real filter: judge the content, not the visitor
&lt;/h2&gt;

&lt;p&gt;The site runs three near-identical comment forms (blog, book reviews, skill pages), each with its own handler file, deliberately duplicated rather than centralized in a shared lib: three small independent PHP files are cheaper to reason about than one shared abstraction. I added the same block to all three. Two rules:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Anti-spam: external links and common spam-comment keywords&lt;/span&gt;
&lt;span class="nv"&gt;$linkText&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$author&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;' '&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$content&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nv"&gt;$hasExternalLink&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nb"&gt;preg_match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/&amp;lt;a\b/i'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$linkText&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nv"&gt;$hasExternalLink&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;preg_match_all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/https?:\/\/\S+|www\.\S+/i'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$linkText&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$urlMatches&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$urlMatches&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nb"&gt;preg_match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/^(https?:\/\/)?(www\.)?web-developpeur\.com/i'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$url&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nv"&gt;$hasExternalLink&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$hasExternalLink&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// reject: external link&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nv"&gt;$spamWords&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'rent a car'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'car rental'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'car hire'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'escort'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'casino'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'viagra'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'cialis'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'forex trading'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'crypto signals'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'seo services'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'backlink'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'loan offer'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$spamWords&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$spamWord&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;stripos&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$content&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$spamWord&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;stripos&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$author&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$spamWord&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// reject: spam keyword&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No external links, none of the keywords that show up in 90% of comment spam. It's not elegant, it's not machine learning, it's a list of patterns describing the spam I actually received. And unlike the captcha, it asks nothing of the visitor: someone writing a real comment almost never needs to paste a link or say "rent a car."&lt;/p&gt;

&lt;h2&gt;
  
  
  The trap of a filter that's too strict
&lt;/h2&gt;

&lt;p&gt;Almost never, not never. One reader, Domenico, once flagged a broken page by pasting the failing URL into a comment. A "zero links" filter would reject that bug report exactly the way it rejects a car rental ad, and I'd only find out if Domenico pushed back. That's the danger of a filter that's too broad: it doesn't warn you when it's wrong, it just silently rejects.&lt;/p&gt;

&lt;p&gt;The fix lives in the same regex: links to &lt;code&gt;web-developpeur.com&lt;/code&gt; itself stay allowed. The filter blocks whatever pulls the reader off the site, not whatever helps them report a problem on it. The distinction is one line of code, but I only thought of it because I had the real case in front of me.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing the fix with the bot's own trick
&lt;/h2&gt;

&lt;p&gt;A passing &lt;code&gt;php -l&lt;/code&gt; proves nothing about the form's actual behavior. To verify, I replayed exactly what a half-competent bot does: load the live page, pull the CSRF token and captcha operands out of it, compute the sum, and post a real comment with &lt;code&gt;curl&lt;/code&gt;. Two runs: one comment with an external link, one clean.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;PAGE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; cookies.txt &lt;span class="s2"&gt;"https://www.web-developpeur.com/blog/deuxieme-spam-commentaires-liens-php"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;CSRF&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PAGE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-oE&lt;/span&gt; &lt;span class="s1"&gt;'name="csrf_token" value="[^"]*"'&lt;/span&gt; | &lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'s/.*value="([^"]*)"/\1/'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="c"&gt;# ... same captcha-reading move a bot would make&lt;/span&gt;

curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-b&lt;/span&gt; cookies.txt &lt;span class="nt"&gt;-D&lt;/span&gt; - &lt;span class="s2"&gt;"https://www.web-developpeur.com/blog/comment-handler"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data-urlencode&lt;/span&gt; &lt;span class="s2"&gt;"content=Check my site https://spam-example.test for deals"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data-urlencode&lt;/span&gt; &lt;span class="s2"&gt;"csrf_token=&lt;/span&gt;&lt;span class="nv"&gt;$CSRF&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--data-urlencode&lt;/span&gt; &lt;span class="s2"&gt;"captcha=&lt;/span&gt;&lt;span class="nv"&gt;$SUM&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;grep &lt;/span&gt;Location
&lt;span class="c"&gt;# -&amp;gt; comment_error=Only+links+to+this+site+are+allowed...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The external link got rejected, the clean comment got published. That second test comment landed in the real production comment file, exactly like the bot's comment five months earlier. I deleted it by hand over FTP within the minute, before it sat there in front of real readers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;A plain-text captcha doesn't test whether you're human, it tests whether you can parse a DOM. I spent five months believing my four layers protected against spam, when they protected against one specific kind of bot: the one that posts blind without reading the page. Against a bot that reads it, they're just friction.&lt;/p&gt;

&lt;p&gt;Real protection was never about proving a visitor is human. It's about looking at what they're trying to publish. That sounds obvious written down. It wasn't, until two car rental ads for Morocco reminded me.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;📚 The full Web Security course, free and interactive&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Telling real security from the appearance of security is the core of the &lt;a href="https://www.web-developpeur.com/en/learning/securite/" rel="noopener noreferrer"&gt;Web Security course&lt;/a&gt;: OWASP, authentication, CSRF, access control, with runnable attack labs right in the page.&lt;/p&gt;

</description>
      <category>php</category>
      <category>antispam</category>
      <category>security</category>
      <category>comments</category>
    </item>
    <item>
      <title>A news-watch bot, an 8,900-character prompt, and three rules that never reached the model</title>
      <dc:creator>Odilon HUGONNOT</dc:creator>
      <pubDate>Wed, 19 Aug 2026 09:00:03 +0000</pubDate>
      <link>https://dev.to/ohugonnot/a-news-watch-bot-an-8900-character-prompt-and-three-rules-that-never-reached-the-model-1b5b</link>
      <guid>https://dev.to/ohugonnot/a-news-watch-bot-an-8900-character-prompt-and-three-rules-that-never-reached-the-model-1b5b</guid>
      <description>&lt;p&gt;The item sits at the top of the feed. "Last trading day for the first US spot Bitcoin ETF to shut down." Tagged as verified fact. Three sources, all dated August 3rd.&lt;/p&gt;

&lt;p&gt;It is August 17th.&lt;/p&gt;

&lt;p&gt;Fourteen days between the most recent source and the event it announces. A shutdown can be postponed. A vote can be adjourned. And I had written a rule for exactly that case a week earlier, a rule that says in plain words never to copy a two-week-old announcement as if it were still true.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup, for anyone arriving cold
&lt;/h2&gt;

&lt;p&gt;This site runs an &lt;strong&gt;automated watch&lt;/strong&gt;. Every 24 hours a scheduled task wakes a Node script that calls a language model with web search access, asks it to sweep the last few hours of crypto and financial news, and demands strict JSON in return. That JSON feeds a public page: a list of items, each with a title, a summary, its sources and their publication dates.&lt;/p&gt;

&lt;p&gt;The model does not decide alone what it brings back. It receives a &lt;strong&gt;prompt&lt;/strong&gt;, meaning the instruction text sent on every call, here 8,900 characters acting as a specification: which categories to cover, which sources to favour, the expected output format, and freshness rules defining what deserves to enter the feed. The prompt also requires a certainty label on every item, from &lt;code&gt;FAIT_VERIFIE&lt;/code&gt; when two independent primary sources agree, down to &lt;code&gt;SPECULATIF&lt;/code&gt; for a hypothesis.&lt;/p&gt;

&lt;p&gt;The part that matters here: this prompt is not a fixed text, it is a &lt;strong&gt;template&lt;/strong&gt;. It holds markers in double braces that the script fills in right before the call. &lt;code&gt;{{DATE}}&lt;/code&gt; becomes the current timestamp, &lt;code&gt;{{FREQUENCY_HOURS}}&lt;/code&gt; becomes the number of hours in the cycle, &lt;code&gt;{{PRICES}}&lt;/code&gt; becomes a block of prices pulled from an API. So the text the model receives is never exactly the one I read in my editor.&lt;/p&gt;

&lt;p&gt;That gap is where the whole story happens.&lt;/p&gt;

&lt;p&gt;The watch pipeline: scheduled task, template markers filled in, model call with web search, strict JSON, public page. The defect sits at the fill-in step. Scheduled task, every 24 h Template: markers get filled in the defect is here Model + web search Strict JSON, one object per item Public watch page&lt;/p&gt;

&lt;p&gt;The model never sees the template, only the text produced at the fill-in step. Whatever goes wrong there shows up nowhere else.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule existed, and it did nothing
&lt;/h2&gt;

&lt;p&gt;Here is the relevant passage, exactly as stored in the config:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;If you keep an item whose event falls in this cycle but whose sources are
ALL older than {{FREQUENCY_HOURS}}h, you may NOT publish it as is.
Check that it still holds:
- search for a RECENT source confirming the event is happening ;
- if you find one, add it to the sources and keep FAIT_VERIFIE ;
- otherwise downgrade to PROBABLE, and say in the summary that the
  deadline has not been reconfirmed.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The rule offers two outcomes and the output honours neither. No recent source added, no downgrade, no caveat in the summary. The item ships as verified fact, with the confidence of something confirmed that very morning.&lt;/p&gt;

&lt;p&gt;At this point the first reflex is always the same: the model disobeyed. Push harder, add more capitals, repeat the instruction in two places.&lt;/p&gt;

&lt;p&gt;That reflex is what cost me the most time.&lt;/p&gt;

&lt;h2&gt;
  
  
  The expensive reflex: blaming the wording
&lt;/h2&gt;

&lt;p&gt;I did what everyone does. I reread the prompt and found it poorly built, which it was: four separate blocks each declare themselves top priority, the freshness rule and the reconfirmation rule contradict each other on edge cases, and across 8,900 characters there is exactly one concrete example.&lt;/p&gt;

&lt;p&gt;So I rewrote it. Section tags instead of shouty headers, a priority block that explicitly ranks the families of rules, the freshness test turned into a numbered four-branch procedure, two worked examples including one deliberately downgraded item. From 8,900 to 11,800 characters, and a far clearer read.&lt;/p&gt;

&lt;p&gt;Then comes the only question that matters: better, but measured how?&lt;/p&gt;

&lt;p&gt;A prompt that returns JSON has the pleasant property of being gradeable without being read. The rule above translates into an executable test almost word for word:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;times&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sources&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;published_date&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;isFinite&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;newest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(...&lt;/span&gt;&lt;span class="nx"&gt;times&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;stale&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;newest&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;WINDOW_H&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="nx"&gt;_600_000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;hedged&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sr"&gt;/reconfirm|no recent source/i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// The rule: all sources outside the window =&amp;gt; PROBABLE + explicit caveat.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;violation&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;stale&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;certainty&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;FAIT_VERIFIE&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;hedged&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Six lines, and nobody's opinion on the beauty of the prompt carries any weight anymore. A run is now a number: how many items break the rule. All that is left is to run the old prompt and the new one, and compare.&lt;/p&gt;

&lt;h2&gt;
  
  
  Forty minutes for zero items
&lt;/h2&gt;

&lt;p&gt;The old prompt goes first. Ten minutes later the subprocess is killed on timeout. Automatic retry, ten more minutes, killed again. The new prompt takes over and does exactly the same thing. Four calls, forty minutes, zero items compared.&lt;/p&gt;

&lt;p&gt;At that point there are two ways forward. Shrug and say the API is slow today, or go and look. "The environment" is not a diagnosis, it is the name we give to whatever we have not measured.&lt;/p&gt;

&lt;p&gt;The good news is that this kind of call can be watched closely. The CLI's stream mode emits one JSON event per step, and timestamping each line as it comes out is enough to see where the time goes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;claude &lt;span class="nt"&gt;--print&lt;/span&gt; &lt;span class="nt"&gt;--output-format&lt;/span&gt; stream-json &lt;span class="nt"&gt;--verbose&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
       &lt;span class="nt"&gt;--tools&lt;/span&gt; &lt;span class="s1"&gt;'WebSearch,WebFetch'&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PROMPT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
| &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="nv"&gt;IFS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;read&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; line&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do &lt;/span&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%s\t%s\n'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%s.%N&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$line&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;done&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; trace.jsonl
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A small script then aggregates the gaps between events. Verdict:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;total: 618.9s across 492 events
   458.8s  74%  model generation between tools
    81.0s  13%  tool results
    70.7s  11%  writing the final answer
tool calls: 20 WebSearch, 6 WebFetch
longest wait outside the final answer: 7.6s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No holes. No sixty-second pause that would betray a backoff, and every quota event says the call is allowed. The closing event gives the rest: 343 seconds of API time out of 619 seconds of wall clock, the remainder being web search latency, roughly ten seconds per call across twenty-six calls.&lt;/p&gt;

&lt;p&gt;The job is simply long. The ceiling was ten minutes, the previous ten cycles landed between 296 and 445 seconds, and the margin looked comfortable until a busy news day ate it. The worst part is the retry design: it starts from scratch. A run killed at 599 seconds does not resume at 599, it restarts at 0 and gets killed a second time. Paying twice to fail twice.&lt;/p&gt;

&lt;p&gt;Ceiling raised to fifteen minutes. The next cycle came in at 836 seconds, precisely the zone where the old setting guaranteed an empty feed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug was one word
&lt;/h2&gt;

&lt;p&gt;That leaves the original question, the ignored rule. And here is a reflex worth its weight in gold: do not reread the prompt in your editor, look at what actually left the machine. The process is running, its arguments are readable.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ps &lt;span class="nt"&gt;-eo&lt;/span&gt; pid,etimes,args | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s1"&gt;'claude --print'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And in the text scrolling past, this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;A news item = a recent EVENT within the last {{FREQUENCY_HOURS}} hours.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The placeholder is still there. Not substituted. The model received, word for word, a rule with a hole where its threshold should be.&lt;/p&gt;

&lt;p&gt;The cause is the following line, which looks perfectly innocent:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;prompt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;rawPrompt&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;{{DATE}}&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;{{FREQUENCY_HOURS}}&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;frequencyHours&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;{{DEDUP_HINT}}&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;dedupHint&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In JavaScript, &lt;code&gt;String.prototype.replace&lt;/code&gt; called with a &lt;strong&gt;string&lt;/strong&gt; pattern replaces only the &lt;strong&gt;first&lt;/strong&gt; occurrence. You need a regular expression with the global flag, or &lt;code&gt;replaceAll&lt;/code&gt;, to reach the others. It has been in the spec forever, it throws no error, no linter warning, and it goes unnoticed as long as a template holds a single occurrence of each marker.&lt;/p&gt;

&lt;p&gt;This prompt held four of that particular marker. One substituted, three untouched.&lt;/p&gt;

&lt;p&gt;The template holds the same marker four times. Substitution fills only the first: the next three rules reach the model with a hole where their threshold should be. The prompt template What reaches the model Cycle mission {{FREQUENCY_HOURS}} Item freshness {{FREQUENCY_HOURS}} No recent article {{FREQUENCY_HOURS}} Deadline reconfirmation {{FREQUENCY_HOURS}} replaced untouched untouched untouched Cycle mission 24 hours Item freshness {{FREQUENCY_HOURS}} No recent article {{FREQUENCY_HOURS}} Deadline reconfirmation {{FREQUENCY_HOURS}} .replace() with a string pattern: first occurrence only&lt;/p&gt;

&lt;p&gt;Only one of the four rules gets its threshold. The other three arrive with a hole, and a rule without a threshold filters nothing.&lt;/p&gt;

&lt;p&gt;That detail changes the whole diagnosis. The model never disobeyed. It applied to the letter a text I had not read, because I was rereading the one in my editor rather than the one going over the wire. Pushing harder, adding capitals, repeating the instruction: none of it would have worked, because the problem was never the wording.&lt;/p&gt;

&lt;h2&gt;
  
  
  The same threshold lived in three places
&lt;/h2&gt;

&lt;p&gt;While fixing this I ran into the cousin of the same problem. The prompt hardcoded a 48-hour freshness window. The code computed it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;windowH&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;frequencyHours&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;1.5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;48&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a 24-hour cycle both land on 48. By luck, not by construction. Change the watch frequency and the two silently diverge without anything breaking: the prompt keeps announcing a threshold the pipeline no longer applies.&lt;/p&gt;

&lt;p&gt;And there was a third copy, in the log line that recomputed the formula on its own to display it. As a result the logs announced a 36-hour threshold while the code applied a different one. Debugging from logs that lie costs you an evening.&lt;/p&gt;

&lt;p&gt;The fix is not clever, and that is the point. One exported function owns the formula, the code uses it, and the prompt receives it through a dedicated marker instead of copying it by hand. A constant that code and prompt must agree on needs a single owner, exactly like between two modules.&lt;/p&gt;

&lt;h2&gt;
  
  
  What measurement did to my rewrite
&lt;/h2&gt;

&lt;p&gt;Once substitution was repaired, the comparison became possible. Three runs, the same six-line judge, the same day:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                          items   violations   duration
old prompt, as is             8         2         703 s
old prompt, fixed             9         0         836 s
my rewrite                    4         0         680 s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second row says it all. The one-word fix drops violations to zero, on the prompt I had judged badly written. My rewrite improves nothing on that criterion and returns half as many items, on the same news cycle.&lt;/p&gt;

&lt;p&gt;The reason sits in my own text. I had added a priority block ranking volume last, plus a sentence saying five solid items beat fifteen padded ones. The model obeyed. On a daily feed where nine sourced items were perfectly legitimate, it threw five away to please me.&lt;/p&gt;

&lt;p&gt;My prompt was better to read and worse to execute. I spent an hour writing the solution to a problem that did not exist, and the only piece worth keeping is the two worked examples: they are why the model produced a downgraded item flagged as probable with an explicit note that the deadline had not been reconfirmed. The rest goes in the bin, including the block I was proudest of.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I take away
&lt;/h2&gt;

&lt;p&gt;A prompt is code. Nobody judges a function by reading it aloud, you run it and look at what comes back. Yet put a prompt in front of people and everyone starts debating style and phrasing as if it were a cover letter.&lt;/p&gt;

&lt;p&gt;The three moves that actually produced knowledge here are utterly mundane. Read what goes over the wire rather than what sits in the editor. Timestamp the events rather than assume things are slow. Write six lines of automated judge rather than wonder whether it is better.&lt;/p&gt;

&lt;p&gt;And one more, the least pleasant: accept that measurement can invalidate your work. My rewrite was objectively clearer, better structured, nicer to read. It was also worse. Without the judge I would have shipped it, sincerely convinced I had improved the system.&lt;/p&gt;

&lt;p&gt;When a model ignores a rule, the first question is not "how do I word this better". It is "did the rule arrive".&lt;/p&gt;

</description>
      <category>promptengineering</category>
      <category>javascript</category>
      <category>debugging</category>
      <category>llm</category>
    </item>
    <item>
      <title>A hacked WordPress, two attackers, and the hole no antivirus saw</title>
      <dc:creator>Odilon HUGONNOT</dc:creator>
      <pubDate>Wed, 12 Aug 2026 09:00:03 +0000</pubDate>
      <link>https://dev.to/ohugonnot/a-hacked-wordpress-two-attackers-and-the-hole-no-antivirus-saw-hna</link>
      <guid>https://dev.to/ohugonnot/a-hacked-wordpress-two-attackers-and-the-hole-no-antivirus-saw-hna</guid>
      <description>&lt;p&gt;The site is down. Blank page, HTTP 200, no PHP error in the logs. Nothing to grab onto.&lt;/p&gt;

&lt;p&gt;Googlebot, meanwhile, gets full pages. Links to counterfeit shops, indexed for three days under the client's name. The site is dead for humans and very much alive for crawlers. That is the worst case: nobody sees the content dirtying the domain, and the domain gets dirty anyway.&lt;/p&gt;

&lt;p&gt;The antivirus has just finished its sweep. 373 files scanned, zero detections.&lt;/p&gt;

&lt;p&gt;It took three days to untangle: where they came in, what they left behind, and what had to change so none of them could do it again. The client, the domain and the addresses are anonymised. The rest is exact, including the times I chased the wrong lead. Those are usually the useful parts.&lt;/p&gt;

&lt;h2&gt;
  
  
  The blank page was not the problem
&lt;/h2&gt;

&lt;p&gt;The first file I open is &lt;code&gt;index.php&lt;/code&gt;. It has indeed been swapped for a trojaned version. Perfect suspect, case closed.&lt;/p&gt;

&lt;p&gt;Except that reading the code properly, the trojan never stops for a normal visitor. It serves the expected page and only deviates for search crawlers. Cloaking: one content for Google, another for you. That file had no reason to produce a blank page.&lt;/p&gt;

&lt;p&gt;Wrong lead. The real cause was dumber, and more brutal: the &lt;code&gt;wp-content&lt;/code&gt; directory had been renamed. Plugins, theme, media, everything WordPress looks for in there had become unreachable. The core booted, found no theme, rendered nothing. A whole site killed by an &lt;code&gt;mv&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Which leaves the question of why an attacker would sabotage the site he is milking. It makes no sense. A dead site earns nothing, and it brings the administrator running. That is exactly what you avoid when you sell links.&lt;/p&gt;

&lt;p&gt;The logs answered. There were two of them, and they hated each other.&lt;/p&gt;

&lt;p&gt;The second group in had dropped an &lt;code&gt;.htaccess&lt;/code&gt; allowing only its own backdoors. The earlier group's were locked out. That group came back a few hours later and took two 403s on its own tools, on a server it considered its own. Its answer was to re-drop its backdoor, then rename &lt;code&gt;wp-content&lt;/code&gt;. Three seconds separate the two actions in the log.&lt;/p&gt;

&lt;p&gt;So the sabotage was not aimed at the client. It was a message to a competitor. The site was only the ground they fought on.&lt;/p&gt;

&lt;h2&gt;
  
  
  Eighteen seconds
&lt;/h2&gt;

&lt;p&gt;Which left the question that brawl had hidden: how did they get in?&lt;/p&gt;

&lt;p&gt;To find out, you do not look for odd files. You look for what happened right before the first odd file appeared. An attacker deletes his files easily enough. The request that created them, far less so.&lt;/p&gt;

&lt;p&gt;So I timestamped every suspicious file, kept the oldest, and walked the access log back to that minute. Two lines are enough.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;04:04:35  POST /?wpmudev-hub&lt;span class="o"&gt;=&lt;/span&gt;&amp;lt;token&amp;gt;   200
04:04:53  GET  /&amp;lt;backdoor&amp;gt;.php          200
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Eighteen seconds between the call to the plugin and the first request to the webshell. Nobody types a random URL and lands on a file that did not exist twenty seconds earlier.&lt;/p&gt;

&lt;p&gt;That &lt;code&gt;wpmudev-hub&lt;/code&gt; parameter belongs to the WPMU DEV Dashboard, a multi-site management plugin. Versions up to 5.0.0 carry an authentication bypass, tracked as &lt;strong&gt;CVE-2026-15459&lt;/strong&gt;. It only opens in one case: when the service API key was never filled in. The plugin then accepts an anonymous call, and lets you install another plugin. Installing a plugin means running code. A hole does not need to be subtler than that.&lt;/p&gt;

&lt;p&gt;Two details turned the hypothesis into a diagnosis. In the database, the &lt;code&gt;wpmudev_apikey&lt;/code&gt; option was empty: the vulnerable state, exactly. And the nonce the plugin had stored matched the suffix of the attack URLs. This was no longer a plausible story. It was the trace of the exploitation, written by the plugin itself.&lt;/p&gt;

&lt;p&gt;Nine successful exploits, nine different addresses, the last one three days after the first. Nobody had bothered to close the door behind them.&lt;/p&gt;

&lt;p&gt;Intrusion timeline: anonymous call to the plugin, webshell eighteen seconds later, self-repair kit, cloaking, nine exploits over three days, then sabotage between rival groups. 04:04:35 The plugin accepts an anonymous call CVE-2026-15459, API key never filled in + 18 s The dropped webshell already answers the server now runs their code then Six files disguised as images and scripts the kit that puts the backdoor back then index.php swapped: spam for the crawlers the human visitor sees nothing 3 days Nine exploits, nine addresses the hole stays open the whole time finally Retaliation between groups: the site falls wp-content renamed, blank page&lt;/p&gt;

&lt;p&gt;Three days between the door left open and the outage that finally revealed it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the antivirus missed, and what I missed
&lt;/h2&gt;

&lt;p&gt;Finding the door is one thing. Finding everything they left behind is another. And there, my tools failed me one after the other.&lt;/p&gt;

&lt;p&gt;ClamAV first: 0 detections across 373 files. That is not a flaw in the product. It is the ceiling of signature matching, against code written to look like nothing.&lt;/p&gt;

&lt;p&gt;Next reflex, &lt;code&gt;grep base64_decode&lt;/code&gt;. Nothing either. Sensitive function names are assembled character by character at runtime, and command URLs are encoded. There is no string to find: the file does not contain the words that would give it away, it builds them.&lt;/p&gt;

&lt;p&gt;Then the modification times. They had been backdated to blend into the original install. A file dropped the previous week proudly showed the same date as the rest of WordPress core. Only the inode change time holds, the &lt;code&gt;ctime&lt;/code&gt;, because PHP cannot rewrite it. That is what dated the intrusion to the second. Without it I would still be looking.&lt;/p&gt;

&lt;p&gt;The neatest piece was the self-repair kit. Six files disguised as images and scripts, with names built to survive a quick review: &lt;code&gt;server-sied-renderr.min.js&lt;/code&gt;, &lt;code&gt;icon_twistedd.png&lt;/code&gt;. You read fast, you see a minified script and an icon, you move on. Each actually held a byte-for-byte copy of the trojaned &lt;code&gt;index.php&lt;/code&gt; and its &lt;code&gt;.htaccess&lt;/code&gt;. Fake &lt;code&gt;wp-login.php&lt;/code&gt; files put them back. Delete the backdoor without deleting the kit and you watch it return.&lt;/p&gt;

&lt;p&gt;And I missed a file on the first pass.&lt;/p&gt;

&lt;p&gt;To sweep recently modified files, I had excluded &lt;code&gt;wp-content/cache&lt;/code&gt; and its 78,000 legitimate files. An exclusion of convenience, made so the command would return something readable. A 468-byte remote loader had survived in there, with its own &lt;code&gt;.htaccess&lt;/code&gt; allowing it. I found it on the second pass, the one I ran with no exclusions at all, assuming the first had failed.&lt;/p&gt;

&lt;p&gt;That is the lesson I keep from the whole episode. An exclusion in a sweep is a hiding place you hand over. A directory too big to search does not need an exemption, it needs its own check.&lt;/p&gt;

&lt;h2&gt;
  
  
  Clean up, then make cleaning up pointless
&lt;/h2&gt;

&lt;p&gt;Once the inventory is genuinely complete, cleanup becomes the boring part. Good.&lt;/p&gt;

&lt;p&gt;WordPress core was compared file by file against the official archive of the same version, and every difference replaced. Exactly one was legitimate: the file carrying the install's language variant. Plugins were not cleaned but reinstalled from their official source. That is faster and safer, because nobody can review thirty plugins by hand. The custom theme could not be replaced. It was checked line by line. It was untouched.&lt;/p&gt;

&lt;p&gt;At that point the site is clean. It is also exactly as vulnerable as before.&lt;/p&gt;

&lt;p&gt;That is what most remediations forget: cleaning repairs the past, it writes nothing about the future. The part that counts starts here.&lt;/p&gt;

&lt;p&gt;The web server can no longer write a single line of code. The whole tree belongs to &lt;code&gt;root&lt;/code&gt;, the web server group is read-only, directories are 750 and files 640. A few directories have to stay writable, of course, since WordPress drops media and cache there. For those, PHP execution is denied at the Apache level:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight apache"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nl"&gt;DirectoryMatch&lt;/span&gt;&lt;span class="sr"&gt; "…/wp-content/(uploads|cache|upgrade)(/|$)"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;
&lt;/span&gt;    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nl"&gt;FilesMatch&lt;/span&gt;&lt;span class="sr"&gt; "\.(?i:php|phtml|php[0-9]|pht|cgi|pl|py|sh)$"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;
&lt;/span&gt;        &lt;span class="nc"&gt;Require&lt;/span&gt; &lt;span class="ss"&gt;all&lt;/span&gt; denied
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nl"&gt;FilesMatch&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;
&amp;lt;/&lt;/span&gt;&lt;span class="nl"&gt;DirectoryMatch&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On one side the places you can write to, on the other the places where PHP runs. The intersection is empty, and that is the whole point.&lt;/p&gt;

&lt;p&gt;Two disjoint sets: directories the web server can write to on one side, directories where PHP runs on the other. No directory belongs to both. Web server can write PHP runs uploads/ cache/ upgrade/ WordPress core plugins theme ∅ intersection&lt;/p&gt;

&lt;p&gt;Nowhere to drop code and run it.&lt;/p&gt;

&lt;p&gt;Replay the 04:04:35 attack against this setup. The vulnerable plugin still accepts the anonymous call. It still tries to write its file. It fails. The hole is intact, the exploitation is not.&lt;/p&gt;

&lt;h2&gt;
  
  
  The config that closes what was left
&lt;/h2&gt;

&lt;p&gt;Banning writes breaks one thing along the way, and not a small one: WordPress can no longer update itself. The &lt;code&gt;DISALLOW_FILE_MODS&lt;/code&gt; constant makes that block explicit, and removes plugin installation from the admin UI in the same move.&lt;/p&gt;

&lt;p&gt;That is deliberate, but it creates a new risk. A site that stops receiving patches becomes a different problem, and frankly a worse one than the one just fixed.&lt;/p&gt;

&lt;p&gt;The compensation is a weekly job run by &lt;code&gt;root&lt;/code&gt;. It flips the constant, dumps the database, updates, restores permissions, verifies core checksums, tests that the site still answers, rebuilds the monitoring baseline, and emails a report. The block holds against an intruder, and patches still land. It is the only arrangement I found that sacrifices neither.&lt;/p&gt;

&lt;p&gt;The rest of the config comes down to three questions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Who is still logged in?&lt;/strong&gt; Salts were regenerated. Every open session drops, the attacker's included.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Who can still trigger code?&lt;/strong&gt; WordPress's internal cron was disabled in favour of a system job. The internal one depends on traffic: it fires from outside, on a plain visit. That is a door that opens on request.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What is still visible from the street?&lt;/strong&gt; The login URL moved. The file editor is off, admin forced over HTTPS, TLS 1.0 and 1.1 refused. Files that talk too much are denied: debug logs, &lt;code&gt;readme&lt;/code&gt;, &lt;code&gt;composer.json&lt;/code&gt;, anything shaped like a &lt;code&gt;.env&lt;/code&gt;. And a &lt;code&gt;fail2ban&lt;/code&gt; jail bans on the first attempt against an exploitation pattern.&lt;/p&gt;

&lt;p&gt;No Content Security Policy, though, and I stand by that. The site loads a tag manager, two ad networks, a map and an external booking widget. A CSP written blind breaks the site silently. A CSP permissive enough to allow everything protects nothing while ticking the box in the audit. Better no CSP than a fake one. It is a project, not a config line.&lt;/p&gt;

&lt;p&gt;On secrets, the rule was simple: anything readable during the compromise is lost. Admin passwords, database credentials, SSH keys regenerated. One key unused for a year disappeared along the way. Third-party API keys were inventoried with their scope, to be rotated next.&lt;/p&gt;

&lt;p&gt;Then I looked at the backups. They existed: one a week at the host, kept on a 28-day rolling window, plus one full server snapshot a year.&lt;/p&gt;

&lt;p&gt;On paper that is reassuring. In this incident, much less so. The intrusion dates from 7 August and was only found on the 10th. With a weekly backup, the most recent one stood every chance of already containing the backdoor: restoring meant reinstalling the problem. And 28 days of retention leaves four restore points, when a compromise can sit quiet for months before showing itself.&lt;/p&gt;

&lt;p&gt;A backup is only worth what you know about its contents. That is why a reference state was frozen separately, once the site was clean: site archive, database dump, plugin list with versions, and a file of 11,784 SHA-256 hashes that stands as provable evidence of a clean state at a given date. It is not one more backup, it is the only restore point known to be clean.&lt;/p&gt;

&lt;p&gt;Along the way, four years of custom code that had never been versioned went into a Git repository, with an archive kept off the server. Until then, the only copy of the theme was the one running in production. On the machine that had just been breached.&lt;/p&gt;

&lt;h2&gt;
  
  
  Knowing before the attacker does
&lt;/h2&gt;

&lt;p&gt;A hardened site with no monitoring is a site you do not know has fallen again. Hardening lowers the probability, it does not zero it. Monitoring is what closes the gap.&lt;/p&gt;

&lt;p&gt;So a job runs every thirty minutes, and only sends mail when there is something to say. Silence is success.&lt;/p&gt;

&lt;p&gt;First check, core integrity. It asks wordpress.org about the version actually installed. I first compared against an archive frozen on disk, which produced a fine false alarm on the very first update: a frozen reference goes wrong on its own, without anyone doing anything wrong.&lt;/p&gt;

&lt;p&gt;Then comes the fingerprint. Roughly 11,500 files compared against a reference: every PHP file, every &lt;code&gt;.htaccess&lt;/code&gt;, and the theme's scripts and stylesheets. The cache, too volatile to sit in a fingerprint, gets its own check. Exactly the one that would have saved me from missing the 468-byte loader.&lt;/p&gt;

&lt;p&gt;Two more checks, dumb and effective: no executables in the media directory, and not one file in the must-use plugin directory. That last one is the handiest hiding place in WordPress, because nothing dropped there can be disabled from the admin UI.&lt;/p&gt;

&lt;p&gt;In the database, a sentinel watches fourteen sensitive values: site address, admin email, registration flag, default role, active theme, active plugins, and the full list of accounts, their roles and their application passwords. Creating a quiet admin account is any intruder's first move for persistence. It now raises an alert within thirty minutes.&lt;/p&gt;

&lt;p&gt;The last check requests the same page twice. Once as a browser, once as Googlebot, then compares response sizes. That is the cloaking signature. The very one serving spam while the site sat empty.&lt;/p&gt;

&lt;p&gt;There was still the trap that kills every monitoring setup, and it is not technical: fatigue. The first version alerted on any exploitation pattern in the logs, which came to 856 lines a day. Almost all of them generic scanners raking the whole internet and taking 404s. A daily 856-line report, you read it twice, then you file it unopened.&lt;/p&gt;

&lt;p&gt;I first sorted those patterns into two families, keeping only the ones aimed at this site. The noise dropped to almost nothing. Almost: twice a day a bot replayed the original hole, and the homepage answered it 200, since the plugin no longer exists. Two daily alerts for an attack that had become impossible.&lt;/p&gt;

&lt;p&gt;So we cut harder. Mail now only goes out when something &lt;em&gt;succeeded&lt;/em&gt;: a file appeared, a file changed, a database value moved, the site went down. Attempts are still logged on the server, available for an investigation, but they no longer wake anyone up.&lt;/p&gt;

&lt;p&gt;Two columns: on the left what is only logged, the attempts; on the right what sends mail, the real consequences. What stays in the log an exploit URL tried a scan of /.env, /xmlrpc.php a 404 on an old backdoor no mail What sends mail a PHP file added or changed a sensitive database value moved the vulnerable plugin reappearing the site going down or cloaking mail straight away Alert on the outcome, never on the intent.&lt;/p&gt;

&lt;p&gt;An attempt teaches nothing, they arrive by the thousand. A file that moves does.&lt;/p&gt;

&lt;p&gt;And the intrusion vector is no longer watched in the logs. What is watched is the condition that makes it possible: if the vulnerable plugin ever shows up again in the plugin directory, the alert fires. We no longer watch for someone trying the door. We watch that the door is still bricked up.&lt;/p&gt;

&lt;p&gt;An alert that fires every day for nothing stops being read. And that is the day the real one slips through.&lt;/p&gt;

&lt;h2&gt;
  
  
  The whole stack, ready to copy
&lt;/h2&gt;

&lt;p&gt;Everything above fits into three scheduled jobs and two scripts. Here they are in full, anonymised: paths, domain, database and addresses are replaced with example values, the rest is the code that actually runs.&lt;/p&gt;

&lt;p&gt;The cron first. Nothing exotic, and that is the point: a monitoring system you cannot read at a glance will never get debugged on the day it gets something wrong.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# /etc/cron.d/surveillance
# Monitoring: every 30 minutes
*/30 * * * *  root  /root/surveille.sh       &amp;gt;/dev/null 2&amp;gt;&amp;amp;1
# Updates: Tuesday 04:17, off-peak
17   4 * * 2  root  /root/maj.sh             &amp;gt;/dev/null 2&amp;gt;&amp;amp;1
# Antivirus: Sunday 03:17
17   3 * * 0  root  /root/scan-antivirus.sh  &amp;gt;/dev/null 2&amp;gt;&amp;amp;1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then the updates. This script exists only because &lt;code&gt;DISALLOW_FILE_MODS&lt;/code&gt; stops WordPress from updating itself. It lifts the lock, works, puts it back, verifies, and reports. Two details matter. The &lt;code&gt;trap&lt;/code&gt; on line 53, without which a crash halfway through would leave the site writable until the following week. And the guard on line 30: if the database dump fails, nothing gets updated. A safety net you believe is there and is not is worse than no net at all.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/bin/bash&lt;/span&gt;
&lt;span class="c"&gt;# WordPress updates driven by the system, not by WordPress.&lt;/span&gt;
&lt;span class="c"&gt;# DISALLOW_FILE_MODS stops WordPress from updating itself. That is&lt;/span&gt;
&lt;span class="c"&gt;# deliberate: www-data must not write to core. This script does it&lt;/span&gt;
&lt;span class="c"&gt;# instead, as root, with checks before and after.&lt;/span&gt;
&lt;span class="nv"&gt;SITE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/var/www/example.com/public
&lt;span class="nv"&gt;STATE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/var/lib/site-watch
&lt;span class="nv"&gt;DEST&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;alerte@example.com
&lt;span class="nv"&gt;FROM&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;surveillance@example.com
&lt;span class="nv"&gt;LOG&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/var/log/maj.log
&lt;span class="nb"&gt;cd&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SITE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;1

&lt;span class="nv"&gt;dispo&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;wp plugin list &lt;span class="nt"&gt;--update&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;available &lt;span class="nt"&gt;--field&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;name &lt;span class="nt"&gt;--allow-root&lt;/span&gt; 2&amp;gt;/dev/null&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;coeur&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;wp core check-update &lt;span class="nt"&gt;--minor&lt;/span&gt; &lt;span class="nt"&gt;--field&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;version &lt;span class="nt"&gt;--allow-root&lt;/span&gt; 2&amp;gt;/dev/null | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s2"&gt;"^[0-9]+&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-z&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$dispo&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-z&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$coeur&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; &lt;span class="s1"&gt;'+%F %T'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt; rien a mettre a jour"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LOG&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
    &lt;span class="nb"&gt;exit &lt;/span&gt;0
&lt;span class="k"&gt;fi

&lt;/span&gt;&lt;span class="nv"&gt;RAPPORT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"Mises a jour appliquees sur example.com le &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; &lt;span class="s1"&gt;'+%F a %H:%M UTC'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;.
"&lt;/span&gt;

&lt;span class="c"&gt;# Dump the database before touching anything. Sans elle on ne met rien a jour :&lt;/span&gt;
&lt;span class="c"&gt;# un filet de securite qu'on croit tendu et qui ne l'est pas vaut moins que pas&lt;/span&gt;
&lt;span class="c"&gt;# de filet du tout, parce qu'on prend le risque en pensant etre couvert.&lt;/span&gt;
&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; /root/backups
&lt;span class="nv"&gt;DUMP&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"/root/backups/db-avant-maj-&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; +%Y%m%dT%H%M%S&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;.sql.gz"&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; pipefail
mysqldump &lt;span class="nt"&gt;--single-transaction&lt;/span&gt; wordpress_prod 2&amp;gt;/dev/null | &lt;span class="nb"&gt;gzip&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DUMP&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nv"&gt;etat_dump&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$?&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; +o pipefail
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$etat_dump&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-ne&lt;/span&gt; 0 &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;stat&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; %s &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DUMP&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo &lt;/span&gt;0&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-lt&lt;/span&gt; 100000 &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;&lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DUMP&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
    &lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%s'&lt;/span&gt; &lt;span class="s2"&gt;"Mise a jour ANNULEE sur example.com le &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; &lt;span class="s1"&gt;'+%F a %H:%M UTC'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;.

La sauvegarde de la base a echoue, ou le fichier produit est trop petit pour
etre credible. Rien n'a ete mis a jour : on ne touche pas au site sans point
de retour. Verifier MySQL et l'espace disque, puis relancer /root/maj.sh."&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
        | mail &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"[MAJ] ECHEC sauvegarde, mise a jour annulee"&lt;/span&gt; &lt;span class="nt"&gt;-a&lt;/span&gt; &lt;span class="s2"&gt;"From: &lt;/span&gt;&lt;span class="nv"&gt;$FROM&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DEST&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
    &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; &lt;span class="s1"&gt;'+%F %T'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt; sauvegarde impossible, mise a jour annulee"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LOG&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
    &lt;span class="nb"&gt;exit &lt;/span&gt;1
&lt;span class="k"&gt;fi
&lt;/span&gt;find /root/backups &lt;span class="nt"&gt;-name&lt;/span&gt; &lt;span class="s1"&gt;'db-avant-maj-*.sql.gz'&lt;/span&gt; &lt;span class="nt"&gt;-mtime&lt;/span&gt; +30 &lt;span class="nt"&gt;-delete&lt;/span&gt; 2&amp;gt;/dev/null

&lt;span class="c"&gt;# DISALLOW_FILE_MODS also blocks wp-cli, so we lift it for the operation.&lt;/span&gt;
&lt;span class="c"&gt;# The trap puts the lock back whatever happens. Without it, a crashed wp or&lt;/span&gt;
&lt;span class="c"&gt;# a reboot mid-run would leave the site writable until the next Tuesday,&lt;/span&gt;
&lt;span class="c"&gt;# with nothing to signal it.&lt;/span&gt;
remettre_le_verrou&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s2"&gt;"s/define( 'DISALLOW_FILE_MODS', false );/define( 'DISALLOW_FILE_MODS', true );/"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SITE&lt;/span&gt;&lt;span class="s2"&gt;/wp-config.php"&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="nb"&gt;trap &lt;/span&gt;remettre_le_verrou EXIT INT TERM
&lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s2"&gt;"s/define( 'DISALLOW_FILE_MODS', true );/define( 'DISALLOW_FILE_MODS', false );/"&lt;/span&gt; wp-config.php

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$dispo&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;&lt;span class="nv"&gt;RAPPORT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$RAPPORT&lt;/span&gt;&lt;span class="s2"&gt;
## Plugins
&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;wp plugin update &lt;span class="nt"&gt;--all&lt;/span&gt; &lt;span class="nt"&gt;--allow-root&lt;/span&gt; 2&amp;gt;&amp;amp;1 | &lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-20&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;
"&lt;/span&gt;
&lt;span class="k"&gt;fi
if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$coeur&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;&lt;span class="nv"&gt;RAPPORT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$RAPPORT&lt;/span&gt;&lt;span class="s2"&gt;
## WordPress core (current branch)
&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;wp core update &lt;span class="nt"&gt;--minor&lt;/span&gt; &lt;span class="nt"&gt;--allow-root&lt;/span&gt; 2&amp;gt;&amp;amp;1 | &lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-6&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;
&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;wp core update-db &lt;span class="nt"&gt;--allow-root&lt;/span&gt; 2&amp;gt;&amp;amp;1 | &lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-2&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;
"&lt;/span&gt;
&lt;span class="k"&gt;fi

&lt;/span&gt;remettre_le_verrou

&lt;span class="c"&gt;# Files written by root must stay readable by www-data&lt;/span&gt;
&lt;span class="nb"&gt;chown&lt;/span&gt; &lt;span class="nt"&gt;-R&lt;/span&gt; root:www-data &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SITE&lt;/span&gt;&lt;span class="s2"&gt;/wp-admin"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SITE&lt;/span&gt;&lt;span class="s2"&gt;/wp-includes"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SITE&lt;/span&gt;&lt;span class="s2"&gt;/wp-content/plugins"&lt;/span&gt; 2&amp;gt;/dev/null
find &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SITE&lt;/span&gt;&lt;span class="s2"&gt;/wp-admin"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SITE&lt;/span&gt;&lt;span class="s2"&gt;/wp-includes"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SITE&lt;/span&gt;&lt;span class="s2"&gt;/wp-content/plugins"&lt;/span&gt; &lt;span class="nt"&gt;-type&lt;/span&gt; d &lt;span class="nt"&gt;-exec&lt;/span&gt; &lt;span class="nb"&gt;chmod &lt;/span&gt;750 &lt;span class="o"&gt;{}&lt;/span&gt; + 2&amp;gt;/dev/null
find &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SITE&lt;/span&gt;&lt;span class="s2"&gt;/wp-admin"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SITE&lt;/span&gt;&lt;span class="s2"&gt;/wp-includes"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SITE&lt;/span&gt;&lt;span class="s2"&gt;/wp-content/plugins"&lt;/span&gt; &lt;span class="nt"&gt;-type&lt;/span&gt; f &lt;span class="nt"&gt;-exec&lt;/span&gt; &lt;span class="nb"&gt;chmod &lt;/span&gt;640 &lt;span class="o"&gt;{}&lt;/span&gt; + 2&amp;gt;/dev/null

&lt;span class="c"&gt;# Verification&lt;/span&gt;
&lt;span class="nv"&gt;RAPPORT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$RAPPORT&lt;/span&gt;&lt;span class="s2"&gt;
## Verification after the update
&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;wp core verify-checksums &lt;span class="nt"&gt;--allow-root&lt;/span&gt; 2&amp;gt;&amp;amp;1 | &lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-3&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;
&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;wp plugin verify-checksums &lt;span class="nt"&gt;--all&lt;/span&gt; &lt;span class="nt"&gt;--allow-root&lt;/span&gt; 2&amp;gt;&amp;amp;1 | &lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-2&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;
"&lt;/span&gt;
&lt;span class="nv"&gt;code&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s1"&gt;'%{http_code}'&lt;/span&gt; &lt;span class="nt"&gt;--max-time&lt;/span&gt; 40 https://www.example.com/&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;RAPPORT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$RAPPORT&lt;/span&gt;&lt;span class="s2"&gt;
Page d'accueil apres mise a jour : HTTP &lt;/span&gt;&lt;span class="nv"&gt;$code&lt;/span&gt;&lt;span class="s2"&gt;
"&lt;/span&gt;
&lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$code&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="s2"&gt;"200"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nv"&gt;RAPPORT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$RAPPORT&lt;/span&gt;&lt;span class="s2"&gt;
ATTENTION : le site ne repond pas normalement. Restauration possible depuis /root/golden/ et les dumps /root/backups/.
"&lt;/span&gt;

&lt;span class="c"&gt;# The monitoring baseline must follow, or it will alert for nothing&lt;/span&gt;
/root/surveille.sh &lt;span class="nt"&gt;--rebaseline&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null 2&amp;gt;&amp;amp;1
&lt;span class="nv"&gt;RAPPORT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$RAPPORT&lt;/span&gt;&lt;span class="s2"&gt;
Empreinte de surveillance reconstruite.
"&lt;/span&gt;

&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%s'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$RAPPORT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | mail &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"[MAJ] example.com"&lt;/span&gt; &lt;span class="nt"&gt;-a&lt;/span&gt; &lt;span class="s2"&gt;"From: &lt;/span&gt;&lt;span class="nv"&gt;$FROM&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DEST&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; &lt;span class="s1"&gt;'+%F %T'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt; mises a jour appliquees, HTTP &lt;/span&gt;&lt;span class="nv"&gt;$code&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LOG&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the monitoring. Ten checks, only one of which reads the logs, and that one never sends mail. Email is reserved for what succeeded: a file that appeared, a file that changed, a database value that moved.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/bin/bash&lt;/span&gt;
&lt;span class="c"&gt;# Watches example.com. Sends mail ONLY when something is wrong.&lt;/span&gt;
&lt;span class="c"&gt;# Run from cron. Prints nothing when all is well: silence is success.&lt;/span&gt;

&lt;span class="nv"&gt;SITE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/var/www/example.com/public
&lt;span class="nv"&gt;REF&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/root/reference/wordpress
&lt;span class="nv"&gt;STATE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/var/lib/site-watch
&lt;span class="nv"&gt;DEST&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;alerte@example.com
&lt;span class="nv"&gt;FROM&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;surveillance@example.com
&lt;span class="nv"&gt;HOST&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;hostname&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$STATE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;chmod &lt;/span&gt;700 &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$STATE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="nv"&gt;ALERTS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;""&lt;/span&gt;
add&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;ALERTS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;ALERTS&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;
## &lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;&lt;span class="s2"&gt;
&lt;/span&gt;&lt;span class="nv"&gt;$2&lt;/span&gt;&lt;span class="s2"&gt;
"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;# --- 1. WordPress core integrity ---------------------------------------------&lt;/span&gt;
&lt;span class="c"&gt;# Ask wordpress.org about the version ACTUALLY installed: a frozen reference&lt;/span&gt;
&lt;span class="c"&gt;# archive goes wrong on the very first core update.&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="nb"&gt;command&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; wp &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;&lt;span class="nv"&gt;chk&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SITE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; wp core verify-checksums &lt;span class="nt"&gt;--allow-root&lt;/span&gt; 2&amp;gt;&amp;amp;1&lt;span class="si"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$chk&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-q&lt;/span&gt; &lt;span class="s2"&gt;"^Success:"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt;
        :   &lt;span class="c"&gt;# coeur conforme&lt;/span&gt;
    &lt;span class="k"&gt;elif &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$chk&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-qiE&lt;/span&gt; &lt;span class="s2"&gt;"couldn't fetch|failed to|could not resolve|error establishing"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt;
        :   &lt;span class="c"&gt;# wordpress.org injoignable : on ne crie pas au loup&lt;/span&gt;
    &lt;span class="k"&gt;else
        &lt;/span&gt;add &lt;span class="s2"&gt;"WordPress core integrity"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$chk&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-vE&lt;/span&gt; &lt;span class="s1"&gt;'^(Success|Warning: Could not)'&lt;/span&gt; | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-30&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;fi
else&lt;/span&gt;
    &lt;span class="c"&gt;# Un controle qui disparait en silence est pire qu'un controle absent :&lt;/span&gt;
    &lt;span class="c"&gt;# on croit etre surveille alors qu'on ne l'est plus.&lt;/span&gt;
    add &lt;span class="s2"&gt;"Controle du coeur impossible : wp-cli introuvable"&lt;/span&gt; &lt;span class="s2"&gt;"Reinstaller wp-cli, sinon l'integrite du coeur n'est plus verifiee."&lt;/span&gt;
&lt;span class="k"&gt;fi&lt;/span&gt;

&lt;span class="c"&gt;# --- 2. Any PHP file missing from the baseline fingerprint -------------------&lt;/span&gt;
&lt;span class="c"&gt;# Baseline taken after cleanup. After a legitimate WordPress update,&lt;/span&gt;
&lt;span class="c"&gt;# rebuild it with: /root/surveille.sh --rebaseline&lt;/span&gt;
&lt;span class="nv"&gt;BASE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$STATE&lt;/span&gt;&lt;span class="s2"&gt;/baseline-php.sha256"&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;1&lt;/span&gt;&lt;span class="k"&gt;:-}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"--rebaseline"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;find /var/www/example.com &lt;span class="se"&gt;\(&lt;/span&gt; &lt;span class="nt"&gt;-name&lt;/span&gt; &lt;span class="s2"&gt;"*.php"&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nt"&gt;-name&lt;/span&gt; &lt;span class="s2"&gt;".htaccess"&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nt"&gt;-path&lt;/span&gt; &lt;span class="s2"&gt;"*/themes/montheme/*.js"&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nt"&gt;-path&lt;/span&gt; &lt;span class="s2"&gt;"*/themes/montheme/*.css"&lt;/span&gt; &lt;span class="se"&gt;\)&lt;/span&gt; &lt;span class="nt"&gt;-not&lt;/span&gt; &lt;span class="nt"&gt;-path&lt;/span&gt; &lt;span class="s2"&gt;"*/cache/*"&lt;/span&gt; &lt;span class="nt"&gt;-not&lt;/span&gt; &lt;span class="nt"&gt;-path&lt;/span&gt; &lt;span class="s2"&gt;"*/node_modules/*"&lt;/span&gt; &lt;span class="nt"&gt;-not&lt;/span&gt; &lt;span class="nt"&gt;-path&lt;/span&gt; &lt;span class="s2"&gt;"*/.git/*"&lt;/span&gt; &lt;span class="nt"&gt;-print0&lt;/span&gt; 2&amp;gt;/dev/null &lt;span class="se"&gt;\&lt;/span&gt;
        | xargs &lt;span class="nt"&gt;-0&lt;/span&gt; &lt;span class="nb"&gt;sha256sum &lt;/span&gt;2&amp;gt;/dev/null | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$BASE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
    &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Reference reconstruite : &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt; &amp;lt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$BASE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt; fichiers PHP"&lt;/span&gt;
    &lt;span class="nb"&gt;exit &lt;/span&gt;0
&lt;span class="k"&gt;fi
if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$BASE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;&lt;span class="nv"&gt;current&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;mktemp&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
    find /var/www/example.com &lt;span class="se"&gt;\(&lt;/span&gt; &lt;span class="nt"&gt;-name&lt;/span&gt; &lt;span class="s2"&gt;"*.php"&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nt"&gt;-name&lt;/span&gt; &lt;span class="s2"&gt;".htaccess"&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nt"&gt;-path&lt;/span&gt; &lt;span class="s2"&gt;"*/themes/montheme/*.js"&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nt"&gt;-path&lt;/span&gt; &lt;span class="s2"&gt;"*/themes/montheme/*.css"&lt;/span&gt; &lt;span class="se"&gt;\)&lt;/span&gt; &lt;span class="nt"&gt;-not&lt;/span&gt; &lt;span class="nt"&gt;-path&lt;/span&gt; &lt;span class="s2"&gt;"*/cache/*"&lt;/span&gt; &lt;span class="nt"&gt;-not&lt;/span&gt; &lt;span class="nt"&gt;-path&lt;/span&gt; &lt;span class="s2"&gt;"*/node_modules/*"&lt;/span&gt; &lt;span class="nt"&gt;-not&lt;/span&gt; &lt;span class="nt"&gt;-path&lt;/span&gt; &lt;span class="s2"&gt;"*/.git/*"&lt;/span&gt; &lt;span class="nt"&gt;-print0&lt;/span&gt; 2&amp;gt;/dev/null &lt;span class="se"&gt;\&lt;/span&gt;
        | xargs &lt;span class="nt"&gt;-0&lt;/span&gt; &lt;span class="nb"&gt;sha256sum &lt;/span&gt;2&amp;gt;/dev/null | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$current&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
    &lt;span class="nv"&gt;drift&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;comm&lt;/span&gt; &lt;span class="nt"&gt;-13&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$BASE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$current&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'{print $2}'&lt;/span&gt; | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-30&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
    &lt;span class="nv"&gt;gone&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;comm&lt;/span&gt; &lt;span class="nt"&gt;-23&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$BASE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$current&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'{print $2}'&lt;/span&gt; | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-10&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
    &lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$current&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
    &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$drift&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; add &lt;span class="s2"&gt;"PHP files added or modified since the baseline"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$drift&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
    &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$gone&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; add &lt;span class="s2"&gt;"PHP files gone since the baseline"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$gone&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;else
    &lt;/span&gt;add &lt;span class="s2"&gt;"Baseline fingerprint missing"&lt;/span&gt; &lt;span class="s2"&gt;"Lancer : /root/surveille.sh --rebaseline"&lt;/span&gt;
&lt;span class="k"&gt;fi&lt;/span&gt;

&lt;span class="c"&gt;# --- 3. Executables in uploads (never normal) --------------------------------&lt;/span&gt;
&lt;span class="nv"&gt;badup&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;find &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SITE&lt;/span&gt;&lt;span class="s2"&gt;/wp-content/uploads"&lt;/span&gt; &lt;span class="nt"&gt;-type&lt;/span&gt; f &lt;span class="se"&gt;\(&lt;/span&gt; &lt;span class="nt"&gt;-iname&lt;/span&gt; &lt;span class="s2"&gt;"*.php*"&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nt"&gt;-iname&lt;/span&gt; &lt;span class="s2"&gt;"*.phtml"&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nt"&gt;-iname&lt;/span&gt; &lt;span class="s2"&gt;"*.cgi"&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nt"&gt;-iname&lt;/span&gt; &lt;span class="s2"&gt;"*.pl"&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nt"&gt;-iname&lt;/span&gt; &lt;span class="s2"&gt;"*.py"&lt;/span&gt; &lt;span class="se"&gt;\)&lt;/span&gt; 2&amp;gt;/dev/null | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="s2"&gt;"index.php$"&lt;/span&gt; | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-20&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$badup&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; add &lt;span class="s2"&gt;"Executable files in uploads/"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$badup&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="c"&gt;# --- 3b. Unexpected PHP inside the cache -------------------------------------&lt;/span&gt;
&lt;span class="c"&gt;# The baseline skips the cache (thousands of volatile files), which makes it&lt;/span&gt;
&lt;span class="c"&gt;# an ideal hiding place: a backdoor survived the cleanup in there. W3TC only&lt;/span&gt;
&lt;span class="c"&gt;# writes to the subdirectories listed below.&lt;/span&gt;
&lt;span class="nv"&gt;cachephp&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;find &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SITE&lt;/span&gt;&lt;span class="s2"&gt;/wp-content/cache"&lt;/span&gt; &lt;span class="nt"&gt;-name&lt;/span&gt; &lt;span class="s2"&gt;"*.php"&lt;/span&gt; 2&amp;gt;/dev/null &lt;span class="se"&gt;\&lt;/span&gt;
    | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-avE&lt;/span&gt; &lt;span class="s2"&gt;"/cache/(db|page_enhanced|minify|object|fragment|stats|tmp)/"&lt;/span&gt; | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-20&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$cachephp&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; add &lt;span class="s2"&gt;"Unexpected PHP file in the cache"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$cachephp&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="c"&gt;# --- 4. mu-plugins must stay empty -------------------------------------------&lt;/span&gt;
&lt;span class="nv"&gt;mu&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;find &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SITE&lt;/span&gt;&lt;span class="s2"&gt;/wp-content/mu-plugins"&lt;/span&gt; &lt;span class="nt"&gt;-type&lt;/span&gt; f 2&amp;gt;/dev/null | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-10&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$mu&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; add &lt;span class="s2"&gt;"Files found in mu-plugins (auto-loaded, cannot be disabled)"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$mu&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="c"&gt;# --- 4b. The plugin used as the entry vector must stay gone ------------------&lt;/span&gt;
&lt;span class="c"&gt;# We no longer watch for the `wpmudev-hub` call in the logs: with the plugin&lt;/span&gt;
&lt;span class="c"&gt;# uninstalled the parameter does nothing and the homepage answers 200 to every&lt;/span&gt;
&lt;span class="c"&gt;# passing bot, i.e. two pointless alerts a day. We now watch the condition&lt;/span&gt;
&lt;span class="c"&gt;# that would make the attack possible, not the attempt.&lt;/span&gt;
&lt;span class="nv"&gt;wpmu&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;find &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SITE&lt;/span&gt;&lt;span class="s2"&gt;/wp-content/plugins"&lt;/span&gt; &lt;span class="nt"&gt;-maxdepth&lt;/span&gt; 1 &lt;span class="se"&gt;\(&lt;/span&gt; &lt;span class="nt"&gt;-iname&lt;/span&gt; &lt;span class="s2"&gt;"*wpmudev*"&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nt"&gt;-iname&lt;/span&gt; &lt;span class="s2"&gt;"*wpmu-dev*"&lt;/span&gt; &lt;span class="se"&gt;\)&lt;/span&gt; 2&amp;gt;/dev/null | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-3&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$wpmu&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; add &lt;span class="s2"&gt;"The WPMU DEV plugin is back (the intrusion vector)"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$wpmu&lt;/span&gt;&lt;span class="s2"&gt;
Verifier la version : les 5.0.0 et anterieures portent CVE-2026-15459."&lt;/span&gt;

&lt;span class="c"&gt;# --- 5. Exploitation attempts: logged, NEVER emailed -------------------------&lt;/span&gt;
&lt;span class="c"&gt;# An attempt is not an event, it is internet background noise: bots try every&lt;/span&gt;
&lt;span class="c"&gt;# known URL on every site, all the time. Mail is reserved for what SUCCEEDED,&lt;/span&gt;
&lt;span class="c"&gt;# meaning a new file, a modified file or a changed database value (checks 1,&lt;/span&gt;
&lt;span class="c"&gt;# 2, 3, 3b, 4, 4b and 10). The trail stays in /var/log/tentatives.log for&lt;/span&gt;
&lt;span class="c"&gt;# any later investigation.&lt;/span&gt;
&lt;span class="c"&gt;# Only log what was never logged: otherwise the same events come back on&lt;/span&gt;
&lt;span class="c"&gt;# every run for as long as they sit inside the window.&lt;/span&gt;
&lt;span class="c"&gt;# TRUSTED: admin addresses, excluded so our own tests do not raise alerts.&lt;/span&gt;
&lt;span class="nv"&gt;TRUSTED&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"203.0.113.10 203.0.113.10 203.0.113.10"&lt;/span&gt;
&lt;span class="nv"&gt;SEEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$STATE&lt;/span&gt;&lt;span class="s2"&gt;/last-exploit-epoch"&lt;/span&gt;
&lt;span class="nv"&gt;last_epoch&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SEEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo &lt;/span&gt;0&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;max_epoch&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$last_epoch&lt;/span&gt;

&lt;span class="c"&gt;# Two families of patterns, two different rules.&lt;/span&gt;
&lt;span class="c"&gt;# CIBLE (targeted): what aims at this site, the entry vector and the webshells&lt;/span&gt;
&lt;span class="c"&gt;# dropped back then. Logged even on failure (403, 404, 503): touching those&lt;/span&gt;
&lt;span class="c"&gt;# means knowing the site's history. Two exceptions, where the request never&lt;/span&gt;
&lt;span class="c"&gt;# reached WordPress: the http-&amp;gt;https 301, which comes straight back over https&lt;/span&gt;
&lt;span class="c"&gt;# and would be counted twice, and the 421 SNI mismatch.&lt;/span&gt;
&lt;span class="c"&gt;# BALAYAGE (sweeps): scanners raking the whole internet, dozens a day, all of&lt;/span&gt;
&lt;span class="c"&gt;# them 404s. Logged only if the server answered something other than a refusal.&lt;/span&gt;
&lt;span class="c"&gt;# A daily alert about nothing stops being read, and that is the day the real&lt;/span&gt;
&lt;span class="c"&gt;# one slips through.&lt;/span&gt;
&lt;span class="c"&gt;# Deliberately absent: PHP-CGI argument injection (auto_prepend_file,&lt;/span&gt;
&lt;span class="c"&gt;# allow_url_include). PHP runs as mod_php, so the homepage answers 200 to every&lt;/span&gt;
&lt;span class="c"&gt;# attempt while none of them execute, verified with a marker that never came&lt;/span&gt;
&lt;span class="c"&gt;# back. If such an injection ever landed, it would leave a file, and check 2&lt;/span&gt;
&lt;span class="c"&gt;# is the one that would see it.&lt;/span&gt;
&lt;span class="nv"&gt;CIBLE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\?&lt;/span&gt;&lt;span class="s2"&gt;action=[A-Za-z0-9]{15,}|shell1&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="s2"&gt;php|shell2&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="s2"&gt;php|shell3"&lt;/span&gt;
&lt;span class="nv"&gt;BALAYAGE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"eval-stdin|/bin/sh|/&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="s2"&gt;env|/&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="s2"&gt;git/|phpinfo|wp-config&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="s2"&gt;php[.~]|/shell|alfa-?rex|/xmlrpc&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="s2"&gt;php"&lt;/span&gt;
&lt;span class="c"&gt;# Fichiers de travail dans $STATE (root, 700) et non dans /tmp : root y ecrit,&lt;/span&gt;
&lt;span class="c"&gt;# et /tmp est inscriptible par www-data. Le noyau bloque deja le detournement&lt;/span&gt;
&lt;span class="c"&gt;# par lien symbolique, mais un script de securite ne se repose pas sur un sysctl.&lt;/span&gt;
&lt;span class="nv"&gt;ACCES&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$STATE&lt;/span&gt;&lt;span class="s2"&gt;/acces-du-jour.txt"&lt;/span&gt;

&lt;span class="nv"&gt;YDAY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"yesterday"&lt;/span&gt; &lt;span class="s2"&gt;"+%d/%b/%Y"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;TODAY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; &lt;span class="s2"&gt;"+%d/%b/%Y"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;expl&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;""&lt;/span&gt;
&lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="nv"&gt;IFS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;read&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; line&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-z&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$line&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="k"&gt;continue
    &lt;/span&gt;&lt;span class="nv"&gt;ip&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;line&lt;/span&gt;&lt;span class="p"&gt;%% *&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="s2"&gt;" &lt;/span&gt;&lt;span class="nv"&gt;$TRUSTED&lt;/span&gt;&lt;span class="s2"&gt; "&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt;&lt;span class="s2"&gt;" &lt;/span&gt;&lt;span class="nv"&gt;$ip&lt;/span&gt;&lt;span class="s2"&gt; "&lt;/span&gt;&lt;span class="k"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt; &lt;span class="p"&gt;;;&lt;/span&gt; &lt;span class="k"&gt;esac&lt;/span&gt;
    &lt;span class="nv"&gt;ts&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%s'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$line&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-oE&lt;/span&gt; &lt;span class="s1"&gt;'[0-9]{2}/[A-Za-z]{3}/[0-9]{4}:[0-9:]{8}'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-z&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ts&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="k"&gt;continue
    &lt;/span&gt;&lt;span class="nv"&gt;ep&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%s'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ts&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;tr&lt;/span&gt; &lt;span class="s1"&gt;':'&lt;/span&gt; &lt;span class="s1"&gt;' '&lt;/span&gt; | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'{print $1" "$2":"$3":"$4}'&lt;/span&gt; | &lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="s1"&gt;'s|/| |g'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; +%s 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo &lt;/span&gt;0&lt;span class="si"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ep&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-le&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$last_epoch&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;
    &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ep&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-gt&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$max_epoch&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nv"&gt;max_epoch&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$ep&lt;/span&gt;
    &lt;span class="nv"&gt;expl&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;expl&lt;/span&gt;&lt;span class="k"&gt;}${&lt;/span&gt;&lt;span class="nv"&gt;line&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;
"&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;
&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;
    &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-ahE&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$YDAY&lt;/span&gt;&lt;span class="s2"&gt;|&lt;/span&gt;&lt;span class="nv"&gt;$TODAY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; /var/log/apache2/&lt;span class="k"&gt;*&lt;/span&gt;access&lt;span class="k"&gt;*&lt;/span&gt;.log 2&amp;gt;/dev/null &lt;span class="se"&gt;\&lt;/span&gt;
        | &lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'s/^[a-z0-9.-]*lesmontheme\.com:[0-9]+ //'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ACCES&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
    &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-aiE&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CIBLE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ACCES&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'$9 !~ /^(301|302|421)$/'&lt;/span&gt;
        &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-aiE&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$BALAYAGE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ACCES&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'$9 !~ /^(301|302|400|401|403|404|408|421|429|503)$/'&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt; | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'{printf "%-16s %s %s %s %s\n", $1, $4, $6, $7, $9}'&lt;/span&gt; | &lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="s1"&gt;'s/\[//'&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-40&lt;/span&gt;
    &lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ACCES&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="sh"&gt;
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$max_epoch&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SEEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$expl&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt;
    &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"===== &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; &lt;span class="s1"&gt;'+%F %T UTC'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt; ====="&lt;/span&gt;
        &lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%s'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$expl&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; /var/log/tentatives.log
    &lt;span class="c"&gt;# Nobody prunes this file, so bound it here or it grows forever.&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;stat&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; %s /var/log/tentatives.log 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo &lt;/span&gt;0&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-gt&lt;/span&gt; 5000000 &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
        &lt;/span&gt;&lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; 5000 /var/log/tentatives.log &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /var/log/tentatives.log.tmp &lt;span class="se"&gt;\&lt;/span&gt;
            &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;mv&lt;/span&gt; /var/log/tentatives.log.tmp /var/log/tentatives.log
    &lt;span class="k"&gt;fi
fi&lt;/span&gt;

&lt;span class="c"&gt;# --- 6. Is the site answering? -----------------------------------------------&lt;/span&gt;
&lt;span class="c"&gt;# Second try before crying wolf: an Apache reload or a passing network&lt;/span&gt;
&lt;span class="c"&gt;# hiccup must not raise an alert.&lt;/span&gt;
verifier_site&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    : &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$STATE&lt;/span&gt;&lt;span class="s2"&gt;/derniere-page.html"&lt;/span&gt;
    curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$STATE&lt;/span&gt;&lt;span class="s2"&gt;/derniere-page.html"&lt;/span&gt; &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s2"&gt;"%{http_code}"&lt;/span&gt; &lt;span class="nt"&gt;--max-time&lt;/span&gt; 25 &lt;span class="se"&gt;\&lt;/span&gt;
         &lt;span class="nt"&gt;-A&lt;/span&gt; &lt;span class="s2"&gt;"Mozilla/5.0 (surveillance)"&lt;/span&gt; https://www.example.com/ 2&amp;gt;/dev/null
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="nv"&gt;code&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;verifier_site&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$code&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="s2"&gt;"200"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$code&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="s2"&gt;"503"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;&lt;span class="nb"&gt;sleep &lt;/span&gt;20
    &lt;span class="nv"&gt;code&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;verifier_site&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;fi
&lt;/span&gt;&lt;span class="nv"&gt;size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;stat&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; %s &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$STATE&lt;/span&gt;&lt;span class="s2"&gt;/derniere-page.html"&lt;/span&gt; 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo &lt;/span&gt;0&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$code&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="s2"&gt;"200"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$code&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="s2"&gt;"503"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;add &lt;span class="s2"&gt;"The site is not answering normally"&lt;/span&gt; &lt;span class="s2"&gt;"code HTTP = &lt;/span&gt;&lt;span class="nv"&gt;$code&lt;/span&gt;&lt;span class="s2"&gt;, taille = &lt;/span&gt;&lt;span class="nv"&gt;$size&lt;/span&gt;&lt;span class="s2"&gt; octets"&lt;/span&gt;
&lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$code&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"200"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$size&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-lt&lt;/span&gt; 5000 &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;add &lt;span class="s2"&gt;"The site answers 200 but the page is nearly empty"&lt;/span&gt; &lt;span class="s2"&gt;"taille = &lt;/span&gt;&lt;span class="nv"&gt;$size&lt;/span&gt;&lt;span class="s2"&gt; octets (une page vide en 200 est exactement le symptome de l'incident d'aout 2026)"&lt;/span&gt;
&lt;span class="k"&gt;fi&lt;/span&gt;

&lt;span class="c"&gt;# --- 7. Cloaking: a bot and a human must get the same page -------------------&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$code&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"200"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;&lt;span class="nv"&gt;sbot&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s2"&gt;"%{size_download}"&lt;/span&gt; &lt;span class="nt"&gt;--max-time&lt;/span&gt; 25 &lt;span class="se"&gt;\&lt;/span&gt;
        &lt;span class="nt"&gt;-A&lt;/span&gt; &lt;span class="s2"&gt;"Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
        https://www.example.com/ 2&amp;gt;/dev/null&lt;span class="si"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$sbot&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$sbot&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-gt&lt;/span&gt; 0 &lt;span class="o"&gt;]&lt;/span&gt; 2&amp;gt;/dev/null&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
        &lt;/span&gt;&lt;span class="nv"&gt;diff&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$((&lt;/span&gt; sbot &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; size ? sbot &lt;span class="o"&gt;-&lt;/span&gt; size : size &lt;span class="o"&gt;-&lt;/span&gt; sbot &lt;span class="k"&gt;))&lt;/span&gt;
        &lt;span class="nv"&gt;limit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$((&lt;/span&gt; size &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="m"&gt;5&lt;/span&gt; &lt;span class="k"&gt;))&lt;/span&gt;
        &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$diff&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-gt&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$limit&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; add &lt;span class="s2"&gt;"Content differs by visitor (possible cloaking)"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
            &lt;span class="s2"&gt;"navigateur = &lt;/span&gt;&lt;span class="nv"&gt;$size&lt;/span&gt;&lt;span class="s2"&gt; octets, Googlebot = &lt;/span&gt;&lt;span class="nv"&gt;$sbot&lt;/span&gt;&lt;span class="s2"&gt; octets"&lt;/span&gt;
    &lt;span class="k"&gt;fi
fi&lt;/span&gt;

&lt;span class="c"&gt;# --- 8. Services and disk ----------------------------------------------------&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;svc &lt;span class="k"&gt;in &lt;/span&gt;apache2 mysql&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
    &lt;/span&gt;systemctl is-active &lt;span class="nt"&gt;--quiet&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$svc&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; add &lt;span class="s2"&gt;"Service stopped"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$svc&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;done
&lt;/span&gt;&lt;span class="nv"&gt;use&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;df&lt;/span&gt; &lt;span class="nt"&gt;--output&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;pcent / | &lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt; | &lt;span class="nb"&gt;tr&lt;/span&gt; &lt;span class="nt"&gt;-dc&lt;/span&gt; &lt;span class="s1"&gt;'0-9'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;use&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="nv"&gt;0&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-gt&lt;/span&gt; 85 &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; add &lt;span class="s2"&gt;"Disk space"&lt;/span&gt; &lt;span class="s2"&gt;"partition / occupee a &lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;use&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; %"&lt;/span&gt;

&lt;span class="c"&gt;# --- 9. Result of the weekly ClamAV scan (read here, not run here) -----------&lt;/span&gt;
&lt;span class="c"&gt;# The scan itself lives in /root/scan-antivirus.sh (weekly cron): too slow to&lt;/span&gt;
&lt;span class="c"&gt;# run on every pass.&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$STATE&lt;/span&gt;&lt;span class="s2"&gt;/clamscan-last.txt"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;&lt;span class="nv"&gt;hits&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-a&lt;/span&gt; &lt;span class="s2"&gt;"FOUND"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$STATE&lt;/span&gt;&lt;span class="s2"&gt;/clamscan-last.txt"&lt;/span&gt; 2&amp;gt;/dev/null | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-20&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$hits&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; add &lt;span class="s2"&gt;"ClamAV detections (last weekly scan)"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$hits&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;fi&lt;/span&gt;

&lt;span class="c"&gt;# --- 10. Database sentinel ---------------------------------------------------&lt;/span&gt;
&lt;span class="c"&gt;# An attacker with database access can act without touching a single file:&lt;/span&gt;
&lt;span class="c"&gt;# that is how the fake plugin got activated. So we watch the values that&lt;/span&gt;
&lt;span class="c"&gt;# matter, not the whole database.&lt;/span&gt;
&lt;span class="nv"&gt;DBREF&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$STATE&lt;/span&gt;&lt;span class="s2"&gt;/db-sentinelle.txt"&lt;/span&gt;
&lt;span class="nv"&gt;dbnow&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;mysql &lt;span class="nt"&gt;-N&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; wordpress_prod 2&amp;gt;/dev/null &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s2"&gt;"
SELECT CONCAT('option:', option_name, '=', LEFT(option_value, 400))
  FROM wp_options
 WHERE option_name IN ('siteurl','home','admin_email','users_can_register',
                       'default_role','template','stylesheet','active_plugins')
 ORDER BY option_name;
SELECT CONCAT('user:', u.ID, ':', u.user_login, ':', u.user_email, ':', IFNULL(m.meta_value,''))
  FROM wp_users u
  LEFT JOIN wp_usermeta m ON m.user_id = u.ID AND m.meta_key = 'wp_capabilities'
 ORDER BY u.ID;
SELECT CONCAT('apppass:', COUNT(*)) FROM wp_usermeta WHERE meta_key = '_application_passwords';
SELECT CONCAT('mu-plugin-option:', COUNT(*)) FROM wp_options WHERE option_value REGEXP 'eval&lt;/span&gt;&lt;span class="se"&gt;\\\\&lt;/span&gt;&lt;span class="s2"&gt;(|base64_decode|gzinflate';
"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$dbnow&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DBREF&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
        &lt;/span&gt;&lt;span class="nv"&gt;dbdiff&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;diff &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DBREF&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &amp;lt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%s\n'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$dbnow&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; 2&amp;gt;/dev/null | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'^[&amp;lt;&amp;gt;]'&lt;/span&gt; | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-20&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$dbdiff&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; add &lt;span class="s2"&gt;"Database: a sensitive value changed"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$dbdiff&lt;/span&gt;&lt;span class="s2"&gt;
(&amp;lt; = valeur de reference, &amp;gt; = valeur actuelle. Si le changement est legitime :
 /root/surveille.sh --rebaseline)"&lt;/span&gt;
    &lt;span class="k"&gt;else
        &lt;/span&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%s\n'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$dbnow&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DBREF&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;fi
elif&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DBREF&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt;
    &lt;span class="c"&gt;# La sentinelle a deja fonctionne : si elle ne rend plus rien, c'est la&lt;/span&gt;
    &lt;span class="c"&gt;# sentinelle qui est tombee, pas la base qui s'est videe.&lt;/span&gt;
    add &lt;span class="s2"&gt;"Sentinelle base de donnees muette"&lt;/span&gt; &lt;span class="s2"&gt;"La requete de controle ne rend plus rien.
Verifier que MySQL repond et que les acces de root sont valides, sinon les
comptes et les options ne sont plus surveilles du tout."&lt;/span&gt;
&lt;span class="k"&gt;fi&lt;/span&gt;

&lt;span class="c"&gt;# --- Sending -----------------------------------------------------------------&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ALERTS&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt;
    &lt;span class="c"&gt;# Dedup: do not report an identical anomaly again within 12 h, or a&lt;/span&gt;
    &lt;span class="c"&gt;# persistent one floods the mailbox.&lt;/span&gt;
    &lt;span class="nv"&gt;sig&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%s'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ALERTS&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;sha256sum&lt;/span&gt; | &lt;span class="nb"&gt;cut&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt;&lt;span class="s1"&gt;' '&lt;/span&gt; &lt;span class="nt"&gt;-f1&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
    &lt;span class="nv"&gt;last&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$STATE&lt;/span&gt;&lt;span class="s2"&gt;/last-alert-&lt;/span&gt;&lt;span class="nv"&gt;$sig&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$last&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="k"&gt;$((&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%s&lt;span class="si"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;stat&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; %Y &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$last&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt; &lt;span class="k"&gt;))&lt;/span&gt; &lt;span class="nt"&gt;-lt&lt;/span&gt; 43200 &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
        &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; &lt;span class="s1"&gt;'+%F %T'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt; anomalie identique deja signalee, mail supprime"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; /var/log/surveillance.log
        &lt;span class="nb"&gt;exit &lt;/span&gt;0
    &lt;span class="k"&gt;fi
    &lt;/span&gt;find &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$STATE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-name&lt;/span&gt; &lt;span class="s1"&gt;'last-alert-*'&lt;/span&gt; &lt;span class="nt"&gt;-mtime&lt;/span&gt; +2 &lt;span class="nt"&gt;-delete&lt;/span&gt; 2&amp;gt;/dev/null
    &lt;span class="nb"&gt;touch&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$last&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
    &lt;span class="c"&gt;# Always keep a local trace: the alert survives a failed send.&lt;/span&gt;
    &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"===== &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; &lt;span class="s1"&gt;'+%F %T UTC'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt; ====="&lt;/span&gt;
        &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ALERTS&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; /var/log/alertes.log
    &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Anomalies detectees sur &lt;/span&gt;&lt;span class="nv"&gt;$HOST&lt;/span&gt;&lt;span class="s2"&gt; (&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; &lt;span class="s1"&gt;'+%Y-%m-%d %H:%M UTC'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;)."&lt;/span&gt;
        &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Site : https://www.example.com/"&lt;/span&gt;
        &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Serveur : 203.0.113.10"&lt;/span&gt;
        &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ALERTS&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
        &lt;span class="nb"&gt;echo
        echo&lt;/span&gt; &lt;span class="s2"&gt;"--"&lt;/span&gt;
        &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Surveillance automatique installee apres l'incident du 10 aout 2026."&lt;/span&gt;
        &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Script : /root/surveille.sh — journal : /var/log/surveillance.log"&lt;/span&gt;
        &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Ce mail ne part que si un fichier ou une valeur en base a change."&lt;/span&gt;
        &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Les tentatives d'URL sont journalisees sans alerte : /var/log/tentatives.log"&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt; | mail &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"[ALERTE] example.com"&lt;/span&gt; &lt;span class="nt"&gt;-a&lt;/span&gt; &lt;span class="s2"&gt;"From: &lt;/span&gt;&lt;span class="nv"&gt;$FROM&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DEST&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
    &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; &lt;span class="s1"&gt;'+%F %T'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt; ALERTE envoyee"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; /var/log/surveillance.log
&lt;span class="k"&gt;else
    &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; &lt;span class="s1"&gt;'+%F %T'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt; OK"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; /var/log/surveillance.log
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One piece is not in this picture and cannot be: the backup cadence on the host side. One a week over 28 days is four restore points, none of them guaranteed clean. No script fixes that.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually made a difference
&lt;/h2&gt;

&lt;p&gt;I ran this remediation with Claude as a pair, and asked it to score the site's security before and after: 70, then 92. Let me say it straight away, that number means nothing. It adds up measures that do not carry the same weight, and it comes from the very party that did the work. It is good for one thing, and that is why I keep it: checking we are moving the right way.&lt;/p&gt;

&lt;p&gt;What deserves keeping is the breakdown. The antivirus saw nothing. The &lt;code&gt;grep&lt;/code&gt; on dangerous functions saw nothing. Modification times actively lied. The only checks that produced knowledge were the ones comparing the install against an outside reference: the official core hashes, and the &lt;code&gt;ctime&lt;/code&gt; the attacker cannot rewrite. Everything else cost me time.&lt;/p&gt;

&lt;p&gt;And the one change that would have stopped the intrusion is neither an antivirus, nor a fingerprint, nor a detection rule. It is a line of permissions. Recognising a specific attack gets bypassed by changing the attack. Making a whole class of attacks impossible does not.&lt;/p&gt;

&lt;p&gt;Detection is how you know. Architecture is what protects.&lt;/p&gt;

</description>
      <category>wordpress</category>
      <category>security</category>
      <category>forensics</category>
      <category>incident</category>
    </item>
    <item>
      <title>Wrapping Go errors: where, and mostly why</title>
      <dc:creator>Odilon HUGONNOT</dc:creator>
      <pubDate>Wed, 29 Jul 2026 09:00:02 +0000</pubDate>
      <link>https://dev.to/ohugonnot/wrapping-go-errors-where-and-mostly-why-1ckc</link>
      <guid>https://dev.to/ohugonnot/wrapping-go-errors-where-and-mostly-why-1ckc</guid>
      <description>&lt;p&gt;Mid-review on an order confirmation mailer, I confidently explained that the right call is to wrap an error once, at the package boundary, not at every internal step. The functions that build the message stay silent, only the public function that sends the email adds useful context. I even quoted Dave Cheney to back it up. Except while researching this article, I found out Dave Cheney changed his own mind since then, and the real rule isn't about where you wrap, it's about what you choose to expose.&lt;/p&gt;

&lt;h2&gt;
  
  
  The advice I just gave
&lt;/h2&gt;

&lt;p&gt;The code in question looked like this: two internal functions that build an email's content, and two public functions that call them before sending:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;buildOrderMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt; &lt;span class="n"&gt;Order&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;tpl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;loadTemplate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"order-confirm"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;Message&lt;/span&gt;&lt;span class="p"&gt;{},&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="c"&gt;// no wrap, propagate as-is&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;tpl&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Render&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;mailOrder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt; &lt;span class="n"&gt;Order&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;buildOrderMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"failed to build message for order %d: %w"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;sender&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The idea: wrapping at every layer produces a noisy message like &lt;code&gt;failed to send: failed to build message: failed to load template: open failed&lt;/code&gt;. Nobody reads those intermediate layers in production, only the final message and the root cause matter. Wrapping once, at the right spot, keeps the message readable and adds the context that actually carries value, here the order ID. That's a correct argument. It's just not the whole picture.&lt;/p&gt;

&lt;h2&gt;
  
  
  Except Dave Cheney changed his mind
&lt;/h2&gt;

&lt;p&gt;In 2016, Dave Cheney published &lt;em&gt;Don't just check errors, handle them gracefully&lt;/em&gt; and popularized &lt;code&gt;pkg/errors&lt;/code&gt;. His recommendation at the time: wrap at every layer with &lt;code&gt;errors.Wrap&lt;/code&gt;, specifically to build a readable stack of context without a traditional stack trace:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;ReadFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;([]&lt;/span&gt;&lt;span class="kt"&gt;byte&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Wrap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"open failed"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="c"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;ReadConfig&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;([]&lt;/span&gt;&lt;span class="kt"&gt;byte&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;ReadFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Wrap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"could not read config"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The final message becomes &lt;code&gt;could not read config: open failed: open /path: no such file or directory&lt;/code&gt;. Back then, the argument held: without wrapping at every level, a failure inside &lt;code&gt;authenticate()&lt;/code&gt; bubbles up to the top of the stack showing just "no such file or directory," with no clue where or why.&lt;/p&gt;

&lt;p&gt;Then in 2021, while looking for a maintainer to take over &lt;code&gt;pkg/errors&lt;/code&gt;, Cheney wrote that he no longer wraps his errors at all. His reason: structured logging (&lt;code&gt;slog&lt;/code&gt; fields rather than a concatenated string) carries that debug context better than nested layers of &lt;code&gt;Wrap&lt;/code&gt;. The creator of the pattern ended up finding it redundant with modern tooling.&lt;/p&gt;

&lt;p&gt;What matters here isn't picking a side between "always wrap" and "never wrap." It's understanding why both positions are defensible depending on what you're actually trying to solve: a readable production error message, or an API contract between packages.&lt;/p&gt;

&lt;h2&gt;
  
  
  The real question: what does %w commit you to
&lt;/h2&gt;

&lt;p&gt;Since Go 1.13, &lt;code&gt;%w&lt;/code&gt; in &lt;code&gt;fmt.Errorf&lt;/code&gt; does more than decorate a message. It makes the inner error inspectable via &lt;code&gt;errors.Is&lt;/code&gt; and &lt;code&gt;errors.As&lt;/code&gt; from any caller. That means &lt;code&gt;%w&lt;/code&gt; isn't a style choice, it's an API commitment:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// %w: exposing the inner error, callers can match on it&lt;/span&gt;
&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;LookupUser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;User&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;QueryRow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;...&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"lookup user %s: %w"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="c"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;// The caller can now do this, and rely on it staying true over time&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Is&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sql&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ErrNoRows&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusNotFound&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The classic trap: &lt;code&gt;LookupUser&lt;/code&gt; runs on &lt;code&gt;database/sql&lt;/code&gt; today. The day it switches to an ORM, or a cache gets added in front of the query, &lt;code&gt;sql.ErrNoRows&lt;/code&gt; stops bubbling up. The caller's &lt;code&gt;errors.Is&lt;/code&gt; check silently breaks, no compile error, just a 404 that quietly becomes a 500 in production. The &lt;code&gt;%w&lt;/code&gt; had turned an implementation detail (we use &lt;code&gt;database/sql&lt;/code&gt;) into a public promise.&lt;/p&gt;

&lt;p&gt;Which gives a simple rule straight from the Go 1.13 docs: default to &lt;code&gt;%v&lt;/code&gt;, because it preserves the abstraction and exposes nothing. Reach for &lt;code&gt;%w&lt;/code&gt; only when you consciously decide the caller needs to inspect the inner error, and in that case define your own sentinel rather than leaking a dependency's:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;ErrUserNotFound&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;New&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"user not found"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;LookupUser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;User&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;QueryRow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;...&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Is&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sql&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ErrNoRows&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"lookup user %s: %w"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ErrUserNotFound&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"lookup user %s: %v"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c"&gt;// internal detail, not exposed&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="c"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The caller now matches on &lt;code&gt;ErrUserNotFound&lt;/code&gt;, a sentinel the package owns. If &lt;code&gt;database/sql&lt;/code&gt; disappears tomorrow, the contract still holds.&lt;/p&gt;

&lt;h2&gt;
  
  
  The public/private rule that actually holds up
&lt;/h2&gt;

&lt;p&gt;Which leaves the "where" question. The most useful rule I found while digging comes from Efe Karakus's blog: wrap only at public boundaries, let private functions propagate as-is. His example makes the point well: a file path repeated three times in the error message, because every private layer kept adding its own redundant context.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// ❌ Every level wraps, even private functions&lt;/span&gt;
&lt;span class="c"&gt;// "failed to read config from /etc/x: failed to read file from /etc/x: failed to open /etc/x: ..."&lt;/span&gt;

&lt;span class="c"&gt;// ✅ Only public functions wrap&lt;/span&gt;
&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;readFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;([]&lt;/span&gt;&lt;span class="kt"&gt;byte&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="c"&gt;// private, propagate as-is&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;io&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ReadAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;Read&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;([]&lt;/span&gt;&lt;span class="kt"&gt;byte&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="c"&gt;// public, wraps once&lt;/span&gt;
    &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;readFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"failed to read config from %s: %w"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's exactly what the order confirmation mailer was already doing, and that's why the initial advice wasn't wrong. It was just incomplete: it answered "where," not "%w or %v."&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'm changing in the mailer
&lt;/h2&gt;

&lt;p&gt;Revised version, applying both criteria together: where to wrap (the public boundary), and what to expose (only when the caller needs to act on it):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;buildOrderMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt; &lt;span class="n"&gt;Order&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;tpl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;loadTemplate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"order-confirm"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;Message&lt;/span&gt;&lt;span class="p"&gt;{},&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="c"&gt;// private, propagate as-is&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;tpl&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Render&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;mailOrder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt; &lt;span class="n"&gt;Order&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;buildOrderMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c"&gt;// %v: the caller has nothing to match on for a template error,&lt;/span&gt;
        &lt;span class="c"&gt;// it just needs to know which order failed&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"failed to build message for order %d: %v"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;sender&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Is&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;smtp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ErrRateLimited&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="c"&gt;// %w here: the caller MUST be able to tell "retry later"&lt;/span&gt;
            &lt;span class="c"&gt;// apart from a permanent send failure&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"send order %d: %w"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;smtp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ErrRateLimited&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"send order %d: %v"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The difference comes down to one line: the SMTP rate limit is wrapped with &lt;code&gt;%w&lt;/code&gt; because the caller has a genuinely different action to take (retry with backoff). Everything else goes through &lt;code&gt;%v&lt;/code&gt;, useful context for a human reading logs, without promising anyone that &lt;code&gt;*template.Error&lt;/code&gt; will still be exposed ten versions from now.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;The real trap isn't picking the wrong side between "wrap everywhere" and "wrap once." It's treating &lt;code&gt;%w&lt;/code&gt; as a cosmetic message flourish when it's actually a contract clause. Once you ask "am I committing my package to always return this specific error," the position in the code becomes secondary. It also taught me to verify a quote before dropping it in a code review, even when it comes from a name as solid as Dave Cheney's.&lt;/p&gt;

</description>
      <category>go</category>
      <category>errors</category>
      <category>fmterrorf</category>
    </item>
    <item>
      <title>Meta Tags Generator: Title, Open Graph and Twitter Card at Once</title>
      <dc:creator>Odilon HUGONNOT</dc:creator>
      <pubDate>Tue, 28 Jul 2026 09:00:02 +0000</pubDate>
      <link>https://dev.to/ohugonnot/meta-tags-generator-title-open-graph-and-twitter-card-at-once-4dii</link>
      <guid>https://dev.to/ohugonnot/meta-tags-generator-title-open-graph-and-twitter-card-at-once-4dii</guid>
      <description>&lt;p&gt;The familiar scene: you publish a page, share it on LinkedIn, and the preview shows the wrong title, a blank description, and a random image pulled from somewhere on the page. Or worse, no title tag at all because you forgot the CMS doesn't generate one automatically.&lt;/p&gt;

&lt;p&gt;Meta tags live in the &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt; and are invisible on the page. That's exactly why they get forgotten. Yet they're what separates a link someone clicks from a link that looks like a phishing attempt.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three layers, three sets of rules
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Search engines&lt;/strong&gt; read &lt;code&gt;&amp;lt;title&amp;gt;&lt;/code&gt; (shown as the blue clickable line in results, 50-70 characters) and &lt;code&gt;&amp;lt;meta name="description"&amp;gt;&lt;/code&gt; (the grey snippet below it, 150-160 chars). Google can override your description with something it generates itself, but if you leave it blank, it picks the first paragraph it finds — rarely your best writing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Social platforms&lt;/strong&gt; (Facebook, LinkedIn, WhatsApp, Slack) read Open Graph tags: &lt;code&gt;og:title&lt;/code&gt;, &lt;code&gt;og:description&lt;/code&gt;, &lt;code&gt;og:image&lt;/code&gt; and &lt;code&gt;og:url&lt;/code&gt;. Without them, the preview is assembled from whatever the crawler finds first. The ideal OG image is 1200 × 630 px — large enough to be readable in a feed, small enough to load quickly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Twitter/X&lt;/strong&gt; has its own format, Twitter Cards, with &lt;code&gt;twitter:&lt;/code&gt; prefixed tags. In practice, Twitter falls back to OG tags, but the &lt;code&gt;twitter:card&lt;/code&gt; tag lets you choose between a small thumbnail (&lt;code&gt;summary&lt;/code&gt;) and a full-width image (&lt;code&gt;summary_large_image&lt;/code&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  The most common mistakes
&lt;/h2&gt;

&lt;p&gt;A title that's too long: Google truncates past roughly 600 pixels — not characters, because a capital "W" takes more space than a lowercase "i". A title that's too short wastes keyword real estate.&lt;/p&gt;

&lt;p&gt;A generic description copy-pasted across every page: "Welcome to our website. Browse our products and services." The description doesn't directly affect rankings, but it's your only chance to speak to someone who's hovering over your link and hasn't decided to click yet. A generic description loses that conversion.&lt;/p&gt;

&lt;p&gt;A missing OG image: the LinkedIn preview shows a blank square. The link looks broken.&lt;/p&gt;

&lt;p&gt;OG tags in French but not in English, or different content per language: social crawlers don't follow language redirects. They see whatever the server returns for the URL they request.&lt;/p&gt;

&lt;h2&gt;
  
  
  Using the generator
&lt;/h2&gt;

&lt;p&gt;I built a &lt;a href="https://www.web-developpeur.com/en/tools/meta-tags-generator/" rel="noopener noreferrer"&gt;free meta tags generator&lt;/a&gt; that outputs all three layers at once: SEO, Open Graph and Twitter Card.&lt;/p&gt;

&lt;p&gt;It shows a live preview of how your page will appear in Google (title + description + URL), on Facebook/LinkedIn (image + title + domain), and on Twitter (summary or large image). You see exactly what your visitors will see before you publish anything.&lt;/p&gt;

&lt;p&gt;Character counters turn red when you exceed the limits. A global SEO score (out of 100) checks the basics: title length, description present, canonical URL set, OG image provided, viewport defined.&lt;/p&gt;

&lt;p&gt;The generated code is ready to paste into your &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt;, in the recommended order (charset and viewport first, then OG tags).&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.web-developpeur.com/en/tools/meta-tags-generator/" rel="noopener noreferrer"&gt;Open the meta tags generator&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  A few rules worth keeping
&lt;/h2&gt;

&lt;p&gt;Put your main keyword early in the title, ideally in the first three words. The description doesn't directly affect Google rankings, but it drives click-through rate, which feeds back into position over time.&lt;/p&gt;

&lt;p&gt;The canonical tag (&lt;code&gt;&amp;lt;link rel="canonical"&amp;gt;&lt;/code&gt;) prevents duplicate content when a page is accessible from multiple URLs: with and without &lt;code&gt;www&lt;/code&gt;, with and without a trailing slash, with UTM parameters.&lt;/p&gt;

&lt;p&gt;To validate after publishing: the &lt;strong&gt;Facebook Sharing Debugger&lt;/strong&gt; (&lt;code&gt;developers.facebook.com/tools/debug&lt;/code&gt;), the &lt;strong&gt;Twitter Card Validator&lt;/strong&gt;, and &lt;strong&gt;Google Rich Results Test&lt;/strong&gt;. The generator lets you skip the publish-test-fix-republish loop by showing the result upfront.&lt;/p&gt;

</description>
      <category>seo</category>
      <category>metatags</category>
      <category>opengraph</category>
      <category>tools</category>
    </item>
    <item>
      <title>GOMAXPROCS and Kubernetes: Go App Throttled, How to Fix It</title>
      <dc:creator>Odilon HUGONNOT</dc:creator>
      <pubDate>Mon, 27 Jul 2026 09:00:03 +0000</pubDate>
      <link>https://dev.to/ohugonnot/gomaxprocs-and-kubernetes-go-app-throttled-how-to-fix-it-li6</link>
      <guid>https://dev.to/ohugonnot/gomaxprocs-and-kubernetes-go-app-throttled-how-to-fix-it-li6</guid>
      <description>&lt;p&gt;The Go pod is running in production. CPU limit set to 2, metrics look reasonable. But under load, P99 latencies spike intermittently with no obvious cause. No errors, no goroutine leaks, just latency blowing up on traffic bursts.&lt;/p&gt;

&lt;p&gt;The root cause is usually invisible: &lt;code&gt;GOMAXPROCS&lt;/code&gt; equals the number of CPUs on the physical node, not the container limit. Your Go app thinks it has 32 CPUs when it only has 2. The Linux kernel handles the gap in its own way — CFS throttling.&lt;/p&gt;

&lt;h2&gt;
  
  
  What GOMAXPROCS reads (and what it ignores)
&lt;/h2&gt;

&lt;p&gt;By default, the Go runtime computes &lt;code&gt;GOMAXPROCS&lt;/code&gt; via &lt;code&gt;runtime.NumCPU()&lt;/code&gt;, which reads the number of CPUs available at the OS level. On a 32-core Kubernetes node, that returns 32 — regardless of what &lt;code&gt;resources.limits.cpu&lt;/code&gt; says in your pod spec.&lt;/p&gt;

&lt;p&gt;Kubernetes CPU limits are enforced through Linux &lt;strong&gt;cgroups&lt;/strong&gt; (v1 or v2). Cgroups are transparent to processes: a pod with &lt;code&gt;limits.cpu: "2"&lt;/code&gt; doesn't see two virtual CPUs, it sees all the node's CPUs and gets suspended when it consumes too much. The Go runtime, historically, never read cgroups. It trusted the physical core count.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;package&lt;/span&gt; &lt;span class="n"&gt;main&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s"&gt;"fmt"&lt;/span&gt;
    &lt;span class="s"&gt;"runtime"&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;main&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c"&gt;// Inside a pod with limits.cpu: "2" on a 32-core node&lt;/span&gt;
    &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Println&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;runtime&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NumCPU&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;      &lt;span class="c"&gt;// → 32&lt;/span&gt;
    &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Println&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;runtime&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GOMAXPROCS&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="c"&gt;// → 32&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  CFS throttling: how the kernel slows you down
&lt;/h2&gt;

&lt;p&gt;The Linux CFS (Completely Fair Scheduler) enforces CPU limits via two cgroup parameters: &lt;code&gt;cpu.cfs_quota_us&lt;/code&gt; (allowed CPU time) and &lt;code&gt;cpu.cfs_period_us&lt;/code&gt; (measurement window, 100 ms by default). A pod limited to 2 CPUs gets at most 200 ms of CPU time per 100 ms window.&lt;/p&gt;

&lt;p&gt;When Go spawns 32 OS threads for 32 parallel goroutines, those threads compete for physical CPUs. Once their combined usage exceeds the cgroup quota within the current window, the kernel suspends all threads in the cgroup until the next window starts. That's throttling: a complete application freeze lasting anywhere from a few milliseconds to several tens of milliseconds. A handful of these per second is enough to tank your P99.&lt;/p&gt;

&lt;p&gt;The signal shows up in Kubernetes Prometheus metrics:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;container_cpu_cfs_throttled_periods_total
container_cpu_cfs_periods_total

&lt;span class="c"&gt;# Throttling ratio (should be near 0)&lt;/span&gt;
rate&lt;span class="o"&gt;(&lt;/span&gt;container_cpu_cfs_throttled_periods_total[5m]&lt;span class="o"&gt;)&lt;/span&gt; /
rate&lt;span class="o"&gt;(&lt;/span&gt;container_cpu_cfs_periods_total[5m]&lt;span class="o"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The fix: automaxprocs (and Go 1.25)
&lt;/h2&gt;

&lt;p&gt;Uber published &lt;code&gt;go.uber.org/automaxprocs&lt;/code&gt; exactly for this. The package reads the cgroup at startup (v1 or v2), computes the effective quota by dividing &lt;code&gt;quota_us / period_us&lt;/code&gt;, and calls &lt;code&gt;runtime.GOMAXPROCS(n)&lt;/code&gt; with the right value.&lt;/p&gt;

&lt;p&gt;Integration is a single blank import:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;package&lt;/span&gt; &lt;span class="n"&gt;main&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s"&gt;"fmt"&lt;/span&gt;
    &lt;span class="s"&gt;"runtime"&lt;/span&gt;

    &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="s"&gt;"go.uber.org/automaxprocs"&lt;/span&gt; &lt;span class="c"&gt;// reads cgroup in init()&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;main&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c"&gt;// Inside a pod with limits.cpu: "2"&lt;/span&gt;
    &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Println&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;runtime&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GOMAXPROCS&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="c"&gt;// → 2&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;go get go.uber.org/automaxprocs
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you'd rather avoid the dependency and know the value at deploy time, set it manually:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="s"&gt;"runtime"&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;init&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;runtime&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GOMAXPROCS&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c"&gt;// match your limits.cpu&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The downside of the manual approach: change the pod spec and forget to update the code, and you're back to square one. With &lt;code&gt;automaxprocs&lt;/code&gt;, the value tracks the cgroup automatically.&lt;/p&gt;

&lt;h3&gt;
  
  
  Go 1.25: fixed in the runtime itself
&lt;/h3&gt;

&lt;p&gt;Since Go 1.25 (August 2025), the runtime reads cgroups by default on Linux. If the process runs inside a container with a CPU quota lower than the node's core count, &lt;code&gt;GOMAXPROCS&lt;/code&gt; is adjusted automatically — no external dependency needed. Go 1.25 also watches for quota changes at runtime, which matters if Kubernetes adjusts resource limits on a live pod.&lt;/p&gt;

&lt;p&gt;Two GODEBUG options let you opt out if needed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Ignore cgroup quota (pre-1.25 behavior)&lt;/span&gt;
&lt;span class="nv"&gt;GODEBUG&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;containermaxprocs&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0

&lt;span class="c"&gt;# Keep the initial value, skip dynamic updates&lt;/span&gt;
&lt;span class="nv"&gt;GODEBUG&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;updatemaxprocs&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On Go 1.25 and above, &lt;code&gt;automaxprocs&lt;/code&gt; is no longer needed. On Go 1.24 and below, Uber's package remains the standard solution.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verify it's working
&lt;/h2&gt;

&lt;p&gt;To check the effective value inside a running pod:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Open a shell in the pod&lt;/span&gt;
kubectl &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-it&lt;/span&gt; &amp;lt;pod-name&amp;gt; &lt;span class="nt"&gt;--&lt;/span&gt; /bin/sh

&lt;span class="c"&gt;# Read the cgroup v2 quota&lt;/span&gt;
&lt;span class="nb"&gt;cat&lt;/span&gt; /sys/fs/cgroup/cpu.max
&lt;span class="c"&gt;# → 200000 100000  (= 2 CPUs: 200ms quota / 100ms period)&lt;/span&gt;

&lt;span class="c"&gt;# Read cgroup v1 values&lt;/span&gt;
&lt;span class="nb"&gt;cat&lt;/span&gt; /sys/fs/cgroup/cpu/cpu.cfs_quota_us
&lt;span class="nb"&gt;cat&lt;/span&gt; /sys/fs/cgroup/cpu/cpu.cfs_period_us
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you expose Go runtime metrics via Prometheus, the gauge &lt;code&gt;go_sched_gomaxprocs_threads&lt;/code&gt; (available with the &lt;code&gt;runtime/metrics&lt;/code&gt; collector since Go 1.17) shows the current value. It should match your CPU quota, not the node's core count.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;CFS throttling in Go Kubernetes workloads is a silent problem: no error logs, no obvious cause, just P99 latency spikes under load. The root cause is almost always the same — &lt;code&gt;GOMAXPROCS&lt;/code&gt; sized to the node, not the container.&lt;/p&gt;

&lt;p&gt;The fix is proportionate to the problem: one import or one runtime flag. On Go 1.25, it's handled by upgrading. On older versions, &lt;code&gt;automaxprocs&lt;/code&gt; has been battle-tested at Uber scale for years. Either way, it's a five-minute change with a measurable effect the next time your traffic spikes.&lt;/p&gt;

</description>
      <category>go</category>
      <category>kubernetes</category>
      <category>performance</category>
      <category>goroutines</category>
    </item>
    <item>
      <title>Go 1.25 testing/synctest: No More Flaky Concurrent Tests</title>
      <dc:creator>Odilon HUGONNOT</dc:creator>
      <pubDate>Sun, 26 Jul 2026 09:00:01 +0000</pubDate>
      <link>https://dev.to/ohugonnot/go-125-testingsynctest-no-more-flaky-concurrent-tests-1h90</link>
      <guid>https://dev.to/ohugonnot/go-125-testingsynctest-no-more-flaky-concurrent-tests-1h90</guid>
      <description>&lt;p&gt;Three green tests locally, two red on CI. The outcome depends on how busy the build server is that day. This isn't a logic bug — it's a &lt;code&gt;time.Sleep&lt;/code&gt; that assumes 100&amp;nbsp;ms is always enough for a goroutine to finish.&lt;/p&gt;

&lt;p&gt;That test doesn't verify anything. It gambles.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;testing/synctest&lt;/code&gt;, stable since Go&amp;nbsp;1.25 (August 2025), solves this at the root. No more guesswork, no more arbitrary waits: the clock advances on demand, inside an isolated bubble.&lt;/p&gt;

&lt;h2&gt;
  
  
  The test that lies
&lt;/h2&gt;

&lt;p&gt;Debounce is a classic example of time-dependent concurrent code: trigger an action only if no call has happened in the last N milliseconds. Here's a simple implementation.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;Debounce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fn&lt;/span&gt; &lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;delay&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;timer&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Timer&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;mu&lt;/span&gt; &lt;span class="n"&gt;sync&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Mutex&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;mu&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Lock&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;defer&lt;/span&gt; &lt;span class="n"&gt;mu&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Unlock&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;timer&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;timer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Stop&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="n"&gt;timer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AfterFunc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;delay&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the naive test that goes with it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// ✗ Fragile&lt;/span&gt;
&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;TestDebounce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;testing&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;T&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;called&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;
    &lt;span class="n"&gt;debounced&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;Debounce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;called&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="m"&gt;50&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Millisecond&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;debounced&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;debounced&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;debounced&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;200&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Millisecond&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c"&gt;// fingers crossed&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;called&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Fatalf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"want 1 call, got %d"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;called&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This passes locally because your machine is fast and 200&amp;nbsp;ms feels like plenty. On a loaded CI runner, the &lt;code&gt;time.AfterFunc&lt;/code&gt; goroutine may not have had a chance to run by the time &lt;code&gt;called&lt;/code&gt; is read. Flaky. No error message explains why.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;go test -race&lt;/code&gt; won't catch this either. There's no concurrent memory access at the same instant — the problem is purely temporal. Two different tools, two different failure modes.&lt;/p&gt;

&lt;h2&gt;
  
  
  What testing/synctest does
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;testing/synctest&lt;/code&gt; runs your test inside a "bubble". Inside the bubble, the &lt;code&gt;time&lt;/code&gt; package uses a fake clock controlled by the test. That clock doesn't tick on its own: it only advances when &lt;strong&gt;all goroutines in the bubble are durably blocked&lt;/strong&gt; — waiting on a channel, a mutex, a timer — but not on a syscall or network I/O.&lt;/p&gt;

&lt;p&gt;The Go&amp;nbsp;1.25 API has exactly two functions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;code&gt;synctest.Test(t, f)&lt;/code&gt; — runs &lt;code&gt;f&lt;/code&gt; in a new bubble and waits for every goroutine in that bubble to exit before returning to the test framework.&lt;/li&gt;
&lt;li&gt;  &lt;code&gt;synctest.Wait()&lt;/code&gt; — blocks until all other goroutines in the bubble are durably blocked. The explicit synchronization point: "background work is done for now."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Go&amp;nbsp;1.24 had an experimental preview behind &lt;code&gt;GOEXPERIMENT=synctest&lt;/code&gt; with a slightly different API (&lt;code&gt;synctest.Run()&lt;/code&gt; instead of &lt;code&gt;synctest.Test()&lt;/code&gt;). Go&amp;nbsp;1.25 graduated the package to the standard library and finalized the API. No flags, no build tags — direct import.&lt;/p&gt;

&lt;h2&gt;
  
  
  Before and after
&lt;/h2&gt;

&lt;p&gt;The same debounce test, rewritten with &lt;code&gt;testing/synctest&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// ✓ Deterministic&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="s"&gt;"testing/synctest"&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;TestDebounce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;testing&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;T&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;synctest&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;testing&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;T&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;called&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;
        &lt;span class="n"&gt;debounced&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;Debounce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;called&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="m"&gt;50&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Millisecond&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="n"&gt;debounced&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;debounced&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;debounced&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

        &lt;span class="c"&gt;// Advances the virtual clock by 100 ms instantly.&lt;/span&gt;
        &lt;span class="c"&gt;// The AfterFunc goroutine (fires at 50 ms) wakes up along the way.&lt;/span&gt;
        &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;100&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Millisecond&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;synctest&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Wait&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;called&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Fatalf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"want 1 call, got %d"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;called&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What happens inside the bubble:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt; The three &lt;code&gt;debounced()&lt;/code&gt; calls each schedule a &lt;code&gt;time.AfterFunc&lt;/code&gt; at 50&amp;nbsp;ms, canceling the previous one.&lt;/li&gt;
&lt;li&gt; &lt;code&gt;time.Sleep(100ms)&lt;/code&gt; blocks the test goroutine. All goroutines in the bubble are now durably blocked, so the runtime advances the virtual clock to 50&amp;nbsp;ms. The &lt;code&gt;AfterFunc&lt;/code&gt; goroutine wakes up and runs &lt;code&gt;called++&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt; The clock continues to 100&amp;nbsp;ms. The test goroutine wakes up. &lt;code&gt;synctest.Wait()&lt;/code&gt; confirms the &lt;code&gt;AfterFunc&lt;/code&gt; goroutine has finished.&lt;/li&gt;
&lt;li&gt; The assertion on &lt;code&gt;called&lt;/code&gt; is reliable. Always. No gambling.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Actual wall-clock time for the test: microseconds, not 200&amp;nbsp;ms.&lt;/p&gt;

&lt;h2&gt;
  
  
  What synctest doesn't cover
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;testing/synctest&lt;/code&gt; isn't a silver bullet. Three categories slip through.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Network I/O.&lt;/strong&gt; A goroutine blocked on a network read isn't "durably blocked" in synctest's terms — an external process can unblock it at any time. If your code makes real HTTP calls, &lt;code&gt;synctest.Wait()&lt;/code&gt; can't reason about its state. Fix: replace the network layer with an interface backed by a deterministic test implementation (&lt;code&gt;net.Pipe()&lt;/code&gt;, &lt;code&gt;httptest.Server&lt;/code&gt;, or a hand-rolled mock).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Syscalls and cgo.&lt;/strong&gt; Same reason: the Go runtime doesn't control what the OS or C code does in the background. These calls are not considered durably blocking.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Package-level &lt;code&gt;sync.WaitGroup&lt;/code&gt;&lt;/strong&gt; (&lt;code&gt;var wg sync.WaitGroup&lt;/code&gt;). These can't be associated with a bubble. Declare them inside the test function instead.&lt;/p&gt;

&lt;p&gt;Ideal use cases: timers, tickers, debounce, rate limiters, TTL caches, retry with backoff — anything that chains &lt;code&gt;time.After&lt;/code&gt;, &lt;code&gt;time.Sleep&lt;/code&gt;, or &lt;code&gt;time.AfterFunc&lt;/code&gt; with goroutines communicating over channels.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;go test -race&lt;/code&gt; remains the right tool for real data races. &lt;code&gt;testing/synctest&lt;/code&gt; targets a different class of failure: tests whose reliability depends on wall-clock timing. The two tools are orthogonal — use both.&lt;/p&gt;

&lt;p&gt;If you have &lt;code&gt;time.Sleep&lt;/code&gt; in your Go tests to "give the goroutine time to finish," that's the signal. &lt;code&gt;testing/synctest&lt;/code&gt; will do the job faster and correctly. Go&amp;nbsp;1.25 has been out since August 2025 — no flags, no configuration, just import it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;📚 Related reading.&lt;/strong&gt; If goroutines that never exit are your problem, the natural follow-up is &lt;a href="https://www.web-developpeur.com/en/blog/goroutine-leaks-golang" rel="noopener noreferrer"&gt;Goroutine leaks in Go: detect, understand, fix&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>go</category>
      <category>testing</category>
      <category>concurrency</category>
      <category>goroutines</category>
    </item>
    <item>
      <title>39,000 Torrents: The Bug My Green Benchmark Never Caught</title>
      <dc:creator>Odilon HUGONNOT</dc:creator>
      <pubDate>Sat, 25 Jul 2026 09:00:02 +0000</pubDate>
      <link>https://dev.to/ohugonnot/39000-torrents-the-bug-my-green-benchmark-never-caught-2im6</link>
      <guid>https://dev.to/ohugonnot/39000-torrents-the-bug-my-green-benchmark-never-caught-2im6</guid>
      <description>&lt;p&gt;&lt;code&gt;[Torrent911] Inception.2010.TRUEFRENCH.1080p.BluRay.x264-YIFY&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;That's a real filename. The goal: extract "Inception" and "2010" from it, query the TMDB API (The Movie Database, the open reference for film and TV metadata), and display the right card. Poster, rating, synopsis. Automatically, for a library of a few hundred files.&lt;/p&gt;

&lt;p&gt;Context: a self-hosted PHP media server. Think Plex, but lighter, no cloud, no account. The interface looks vaguely like Netflix. The hard part is automatic recognition. Torrent release names follow no formal standard: &lt;code&gt;[Torrent911]&lt;/code&gt; can come before the title, &lt;code&gt;TRUEFRENCH&lt;/code&gt; can interrupt it, a release group trails at the end. Films have a year. TV shows almost never do. Popular anime have episode numbers with four digits: &lt;code&gt;One.Piece.S01E1164.VOSTFR&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I had accumulated 39,188 real torrent names over the years: 22,058 films, 17,130 series. Enough to build an honest test bench. I used an AI assistant to replay variants quickly and analyze where the metrics leaked.&lt;/p&gt;

&lt;p&gt;Two things happened. First, a clean data-driven optimization: measure, iterate, stop at the right time. Then, a classic trap: the benchmark was green while a silent bug turned every single TV show into a film match.&lt;/p&gt;

&lt;p&gt;Two different lessons about what data measures — and what it doesn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  A minefield inside a string
&lt;/h2&gt;

&lt;p&gt;The pipeline has two distinct stages.&lt;/p&gt;

&lt;p&gt;Stage one: extract a clean title and a year from the filename. This is the foundation. If it returns "Inception 1080p BluRay x264" instead of "Inception", TMDB finds nothing useful. Release naming puts the title first and technical metadata after, but the variations are endless: site prefixes (&lt;code&gt;[Torrent911]&lt;/code&gt;), language tags (&lt;code&gt;VOSTFR&lt;/code&gt;, &lt;code&gt;TRUEFRENCH&lt;/code&gt;), codec (&lt;code&gt;x264&lt;/code&gt;, &lt;code&gt;H.265&lt;/code&gt;), resolution (&lt;code&gt;1080p&lt;/code&gt;, &lt;code&gt;4K&lt;/code&gt;), release groups.&lt;/p&gt;

&lt;p&gt;Stage two: query TMDB with the extracted title and pick the right result. A search for "Breaking Bad" returns dozens of results, including films and documentaries with the same name. You have to score and decide.&lt;/p&gt;

&lt;p&gt;Three metrics to measure extraction quality: &lt;strong&gt;cleanliness&lt;/strong&gt; (% of titles with no residual technical tag — a stray &lt;code&gt;1080p&lt;/code&gt;, &lt;code&gt;WEB-DL&lt;/code&gt;, or &lt;code&gt;S01E02&lt;/code&gt; that should have been stripped), &lt;strong&gt;coverage&lt;/strong&gt; (% of non-empty titles), and &lt;strong&gt;year recall&lt;/strong&gt; (how often the year is correctly extracted). Cleanliness is the main metric. A "dirty" title produces false positives or zero results on TMDB. Coverage was already near 100% from V0.&lt;/p&gt;

&lt;h2&gt;
  
  
  V0 → V4: measure before you optimize
&lt;/h2&gt;

&lt;p&gt;The test bench is entirely offline. It replays the extraction algorithm on all 39,188 corpus filenames, computes the metrics, and returns a report in a few seconds. Deterministic, 100% replayable, no network calls. This is where the AI assistant genuinely helps: building the bench, writing the variants, analyzing the 65 remaining leaks to find patterns. Not "the AI optimized the algorithm" — more like "the AI let me iterate five times in one hour".&lt;/p&gt;

&lt;p&gt;Version&lt;/p&gt;

&lt;p&gt;Approach&lt;/p&gt;

&lt;p&gt;Cleanliness&lt;/p&gt;

&lt;p&gt;Leaks&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;V0&lt;/strong&gt; — naive&lt;/p&gt;

&lt;p&gt;Separator normalization only (&lt;code&gt;.&lt;/code&gt; and &lt;code&gt;_&lt;/code&gt; → space)&lt;/p&gt;

&lt;p&gt;10.84%&lt;/p&gt;

&lt;p&gt;34,939&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;V1&lt;/strong&gt; — basic&lt;/p&gt;

&lt;p&gt;Strip 8 known tags (&lt;code&gt;1080p&lt;/code&gt;, &lt;code&gt;BluRay&lt;/code&gt;…)&lt;/p&gt;

&lt;p&gt;50.23%&lt;/p&gt;

&lt;p&gt;19,492&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;V2&lt;/strong&gt; — cut at 1st tag&lt;/p&gt;

&lt;p&gt;Truncate at the first technical marker found&lt;/p&gt;

&lt;p&gt;99.83%&lt;/p&gt;

&lt;p&gt;65&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;V3&lt;/strong&gt; — episode fix&lt;/p&gt;

&lt;p&gt;Extended episode pattern to 3-4 digits (&lt;code&gt;\d{0,4}&lt;/code&gt;)&lt;/p&gt;

&lt;p&gt;99.93%&lt;/p&gt;

&lt;p&gt;28&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;V4&lt;/strong&gt; — experimental&lt;/p&gt;

&lt;p&gt;Article normalization, bracket-rescue…&lt;/p&gt;

&lt;p&gt;99.93%&lt;/p&gt;

&lt;p&gt;29&lt;/p&gt;

&lt;p&gt;V0 applies basic separator normalization. 10.84% cleanliness, 34,939 leaks. Catastrophic baseline, but that's the honest starting point.&lt;/p&gt;

&lt;p&gt;V1 strips 8 known technical tags. 50%. The corpus has far more than eight conventions. This approach doesn't scale: every site and every release group has its own patterns.&lt;/p&gt;

&lt;p&gt;V2 introduces the central idea: don't try to enumerate all known tags, just &lt;strong&gt;truncate at the first technical marker found&lt;/strong&gt;. Release naming places the title at the start and technical metadata after. Cutting at the first tag brings cleanliness from 10.84% to 99.83%. 65 leaks remain across 39,000 names. This single shift in approach delivers 99% of the total gain.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Step 1 — bracket prefixes are stripped first, no enumeration needed:&lt;/span&gt;
&lt;span class="c1"&gt;// [Torrent911], [HorribleSubs], [FW]... one pattern covers them all.&lt;/span&gt;
&lt;span class="nv"&gt;$clean&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;preg_replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/\[.*?\]/'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$clean&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$clean&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;preg_replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/[._()[\]{}:]+/'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;' '&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$clean&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Step 2 — everything after (and including) the first technical marker is discarded.&lt;/span&gt;
&lt;span class="c1"&gt;// This is what takes cleanliness from 10.8% to 99.8%.&lt;/span&gt;
&lt;span class="nv"&gt;$title&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;preg_replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s1"&gt;'/\b(multi|vff|truefrench|french|vostfr|bluray|web-?dl|hdtv|x264|x265|hevc'&lt;/span&gt;
  &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'|10bit|remux|2160p|1080p|720p|480p|uhd|hdr|dts|aac|ac3|proper|repack'&lt;/span&gt;
  &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'|extended|directors?-?cut|complete|s\d{1,2}e?\d{0,4}|e\d{2,4})\b.*/i'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$clean&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// s\d{1,2}e?\d{0,4}: the {0,4} (instead of {0,2}) catches 3-4 digit episodes&lt;/span&gt;
&lt;span class="c1"&gt;// (One Piece S01E1164, Plus Belle La Vie S12E248) — found by analyzing 39,000 torrents.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;V3 fixes a case revealed by analyzing the 65 remaining leaks. Three and four digit episode numbers weren't captured: &lt;code&gt;S01E1164&lt;/code&gt; for One Piece, &lt;code&gt;S12E248&lt;/code&gt; for Plus Belle La Vie. One change in the regex (&lt;code&gt;\d{0,2}&lt;/code&gt; → &lt;code&gt;\d{0,4}&lt;/code&gt;) cuts the leak count in half, from 65 to 28. You can't guess this by looking at ten examples manually. The corpus forces it to appear in the statistics.&lt;/p&gt;

&lt;p&gt;V4 tests more aggressive normalization. Result: 29 leaks — one more than V3. The data says stop. Adding complexity to regress is a no.&lt;/p&gt;

&lt;p&gt;Extracted title cleanliness V0 to V4 — from 10% to 99.9% Extracted title cleanliness (% with no residual technical tag) 10.84% V0 naive 34,939 leaks 50.23% V1 basic cut 19,492 leaks 99.83% V2 cut at 1st tag 65 leaks 99.93% V3 + episode fix 28 leaks 99.93% V4 experimental 29 leaks&lt;/p&gt;

&lt;p&gt;V2 (truncate at the first technical tag) delivers 99% of the gain. V3 halves the remaining leaks with a single regex character change.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Three lessons from this part.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The big lever was binary. Going from "keep everything" to "cut at the first technical tag": 10.8% → 99.8% cleanliness in one idea. Refinement (V3, V4) comes after, not before.&lt;/p&gt;

&lt;p&gt;Large-scale data finds what the eye can't see. Without 39,000 examples, four-digit episode numbers stay invisible. The corpus forces them into the statistics.&lt;/p&gt;

&lt;p&gt;Know when to stop. V4 is more complex and slightly worse than V3. YAGNI proven by data, not assumed.&lt;/p&gt;

&lt;h2&gt;
  
  
  TMDB matching and the premature optimization
&lt;/h2&gt;

&lt;p&gt;Extracting a clean title is half the work. The other half: query TMDB and pick the right result from the candidates.&lt;/p&gt;

&lt;p&gt;The scoring combines several signals. Title similarity counts for 65% (normalized comparison across all title variants). Year gap: 15%. Type coherence (film or TV): 10 to 18% depending on confidence. TMDB popularity (vote count): 12%, so obscure namesakes lose to well-known titles. Above 55: strong match. Between 35 and 55: quarantine for manual review. Below: rejected.&lt;/p&gt;

&lt;p&gt;When the exact query doesn't produce a good result, the algorithm shortens it word by word until the confidence threshold is reached. A title with unusual punctuation may not match directly: we drop words from the end one at a time and keep the longest query that clears the threshold.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Releases often append a subtitle or language tag after the actual title.&lt;/span&gt;
&lt;span class="c1"&gt;// Drop words FROM THE END, one at a time; keep the LONGEST query that clears&lt;/span&gt;
&lt;span class="c1"&gt;// the confidence threshold (55). The title is always at the start.&lt;/span&gt;
&lt;span class="nv"&gt;$words&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;explode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;' '&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$title&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="nv"&gt;$minWords&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$words&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// at most 5 tries&lt;/span&gt;
&lt;span class="nv"&gt;$best&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nv"&gt;$bestScore&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$n&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$words&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="nv"&gt;$n&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nv"&gt;$minWords&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nv"&gt;$n&lt;/span&gt;&lt;span class="o"&gt;--&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$query&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;implode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;' '&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;array_slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$words&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$n&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;tmdb_search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$year&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$preferTv&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$cand&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$score&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;score_candidate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$year&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$cand&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$preferTv&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$score&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$bestScore&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;$bestScore&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$score&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nv"&gt;$best&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$cand&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$bestScore&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;55&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// reliable match → stop truncating&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This handles cases like "Avengers: Age of Ultron" (the colon disappears during cleanup) or foreign-language titles that don't match the English filename. Each iteration costs an API call, but avoids zero-match on titles with punctuation or special characters.&lt;/p&gt;

&lt;p&gt;This is where an "optimization" had quietly slipped into the code. The variable &lt;code&gt;$preferTv&lt;/code&gt; was supposed to signal to TMDB to prioritize TV results. To save API calls, I'd set the algorithm to query the "probable" type first (movie by default, unless the filename explicitly looked like a series), and only query tv &lt;em&gt;if there were no candidates at all&lt;/em&gt;. The network test bench showed flattering numbers: 90% match rate for films, 96.7% for series.&lt;/p&gt;

&lt;p&gt;I navigated it in real conditions. Every single TV show matched a film.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;Breaking Bad&lt;/strong&gt; → Breaking Bad Wolf (an indie film with the same name)&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Game of Thrones&lt;/strong&gt; → Game of Thrones: The Last Watch (an HBO documentary)&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;The Mandalorian&lt;/strong&gt; → The Mandalorian and Grogu (an upcoming film)&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;One Piece&lt;/strong&gt; → One Piece: Gold (a feature film)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;0 correct out of 11 series.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the benchmark was showing green
&lt;/h2&gt;

&lt;p&gt;The offline bench only measured extraction. Fair enough, that's its scope. It knew nothing about matching.&lt;/p&gt;

&lt;p&gt;The network bench, though, &lt;em&gt;provided&lt;/em&gt; the content type (movie or tv) directly from the known category of each corpus entry. It passed &lt;code&gt;$preferTv = true&lt;/code&gt; for series directly. It never exercised the automatic type detection, because it didn't need to: the right answer was already baked into the request parameters. The auto-detection that ran in production had never been tested. The aggregate metric was green on a path that real navigation never took.&lt;/p&gt;

&lt;p&gt;A benchmark tests what you tell it to test. Its blind spots are silent.&lt;/p&gt;

&lt;p&gt;Before and after the fix: film/TV type detection for TMDB matching BEFORE « Breaking Bad » type = movie (auto-guessed) TMDB: movies only Breaking Bad Wolf ✗ false positive AFTER « Breaking Bad » movie + tv in parallel scoring picks by popularity Breaking Bad (TV series) ✓ correct match&lt;/p&gt;

&lt;p&gt;The "save an API call" optimization removed tv from the circuit. Scoring can't choose between two types if it only sees one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: four levers
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Always query both types.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Movie and tv are now queried every time. Scoring decides. Breaking Bad (TV series on TMDB, high popularity) beats any obscure namesake film on popularity score alone. This was the central regression: saving one API call had broken the selection logic. The fix is literally removing the condition that blocked the second type.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Folder structure as a signal.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Parsing the filename to guess film or series is fragile. A show organized in seasons — "Show/Season 1/", "Show/01/", "Show/02/" — may have no &lt;code&gt;S01E01&lt;/code&gt; in its folder name at all. The folder structure doesn't lie.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// A folder is a SERIES if it has multiple episodes (≥ 2 videos at top level)&lt;/span&gt;
&lt;span class="c1"&gt;// OR numbered season subdirectories (Season 1, Saison 3, 01, 02…).&lt;/span&gt;
&lt;span class="c1"&gt;// A film has a single video file. Folder SHAPE beats parsing the filename.&lt;/span&gt;
&lt;span class="nv"&gt;$videoCount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nv"&gt;$seasonish&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;scandir&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$folder&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$sub&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$sub&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s1"&gt;'.'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;is_dir&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$folder&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="nv"&gt;$sub&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;preg_match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/^(s\d{1,2}\b|saison|season|saga|arc|part|\d{1,3}$)/i'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$sub&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
            &lt;span class="nv"&gt;$seasonish&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;elseif&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;in_array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;strtolower&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;pathinfo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$sub&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;PATHINFO_EXTENSION&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt; &lt;span class="nv"&gt;$VIDEO_EXTS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$videoCount&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nv"&gt;$isSeries&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$videoCount&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nv"&gt;$seasonish&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This signal is used to weight the type coherence bonus in the score. It doesn't replace querying both types; it refines the decision.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Multilingual title merging.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;TMDB returns multiple title variants per result: original, local, alternative. When you search for "Attack on Titan", TMDB may return "L'Attaque des Titans" (French) and "Shingeki no Kyojin" (Japanese). Deduplication by ID was keeping only the first title encountered: low similarity with the English query, potentially missed match.&lt;/p&gt;

&lt;p&gt;Fix: aggregate all title variants by ID and score against the best one. Unexpected bonus: Spirited Away went from a weak match to a strong match, because "Sen to Chihiro no Kamikakushi" (the Japanese original title) was already in TMDB data and contributed to the similarity score.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Reinforced type coherence bonus.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;When the folder structure clearly confirms a series (numbered subdirectories, dozens of video files), the tv type bonus in the score goes from 10% to 18%. The structural signal amplifies the scoring decision without overriding it.&lt;/p&gt;

&lt;p&gt;Result: 11 series and anime matched correctly at high confidence, 18 films stay films. 0 → 11 on series.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Full source code&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
ShareBox is open source. The algorithm described in this article lives in &lt;a href="https://github.com/ohugonnot/sharebox/blob/master/functions.php" rel="noopener noreferrer"&gt;functions.php&lt;/a&gt;: &lt;a href="https://github.com/ohugonnot/sharebox/blob/master/functions.php#L178" rel="noopener noreferrer"&gt;&lt;code&gt;extract_title_year()&lt;/code&gt;&lt;/a&gt; (extraction), &lt;a href="https://github.com/ohugonnot/sharebox/blob/master/functions.php#L629" rel="noopener noreferrer"&gt;&lt;code&gt;tmdb_match()&lt;/code&gt;&lt;/a&gt; (word-removal loop), &lt;a href="https://github.com/ohugonnot/sharebox/blob/master/functions.php#L675" rel="noopener noreferrer"&gt;&lt;code&gt;tmdb_score_candidate()&lt;/code&gt;&lt;/a&gt; (scoring).&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;The large-scale data brilliantly optimized extraction. From 10.8% to 99.9% cleanliness in three measured iterations, across 39,000 names. The corpus surfaced a blind spot (&lt;code&gt;S01E1164&lt;/code&gt;) no human would test manually, and it said stop when a more complex variant regressed by one unit.&lt;/p&gt;

&lt;p&gt;The matching failed differently. An API cost optimization caused a regression invisible to the aggregate metrics, because the benchmark was feeding itself the right answer. Thirty seconds of real navigation found what a 96.7% success rate had hidden.&lt;/p&gt;

&lt;p&gt;The real lesson: data optimizes brilliantly what it measures. A benchmark measures one path. It doesn't know others exist. The aggregate metric was an honest indicator; it wasn't a complete one. Testing in real conditions stays irreplaceable, even when everything is green.&lt;/p&gt;

</description>
      <category>matching</category>
      <category>algorithms</category>
      <category>benchmark</category>
      <category>selfhosting</category>
    </item>
  </channel>
</rss>
