<?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>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>
    <item>
      <title>Mathematics describes reality with absurd precision. Nobody really knows why.</title>
      <dc:creator>Odilon HUGONNOT</dc:creator>
      <pubDate>Fri, 24 Jul 2026 09:00:02 +0000</pubDate>
      <link>https://dev.to/ohugonnot/mathematics-describes-reality-with-absurd-precision-nobody-really-knows-why-4a0</link>
      <guid>https://dev.to/ohugonnot/mathematics-describes-reality-with-absurd-precision-nobody-really-knows-why-4a0</guid>
      <description>&lt;p&gt;In 1865, James Clerk Maxwell was reviewing his own equations. They described electricity and magnetism, two phenomena physicists of the time still treated as separate. Combining them, a number fell out of the calculation: the speed at which his theoretical electromagnetic waves would propagate was approximately 310,000 km/s.&lt;/p&gt;

&lt;p&gt;That was also, within experimental uncertainty, the speed of light measured by Fizeau sixteen years earlier.&lt;/p&gt;

&lt;p&gt;Maxwell drew the obvious conclusion: light is an electromagnetic wave. No experiment, no sensor — just symbols on paper. Hertz would confirm it experimentally in 1888, twenty-three years later.&lt;/p&gt;

&lt;p&gt;This kind of moment repeats throughout the history of science with an unsettling regularity. Mathematics developed for entirely abstract reasons ends up describing physical reality with a precision that has no business existing. In 1960, physicist Eugene Wigner gave the phenomenon a name. He called it "the unreasonable effectiveness of mathematics in the natural sciences." And he was honest enough to admit he had no explanation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Galileo raised the question, without the answer
&lt;/h2&gt;

&lt;p&gt;In 1623, Galileo published &lt;em&gt;Il Saggiatore&lt;/em&gt;, a scientific polemic responding to a Jesuit who disputed his work on comets. In passing, he slipped in an idea that would travel four centuries:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"The book of natural philosophy is perpetually open before our eyes, but it is written in characters different from those of our alphabet: triangles, squares, circles, spheres, cones, pyramids and other geometric figures."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This is intuition, not proof. Galileo did not demonstrate that nature is mathematical. He sensed it, stated it, and went back to his comets. In 1623, differential equations did not yet exist. Quantum mechanics, mathematical biology — nobody had the tools to verify it.&lt;/p&gt;

&lt;p&gt;What is striking is that he was right without being able to know it. That kind of prophecy draws its value entirely from what came after.&lt;/p&gt;

&lt;h2&gt;
  
  
  The list that makes your head spin
&lt;/h2&gt;

&lt;p&gt;Four examples. Any one of them could have passed for coincidence. Together they form a pattern.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Maxwell (1865).&lt;/strong&gt; Four equations unifying electricity and magnetism. Combining them, Maxwell derived the speed of an electromagnetic wave: c&amp;nbsp;=&amp;nbsp;1/√(ε₀μ₀) ≈ 300,000 km/s. That matched the speed of light measured by Fizeau in 1849. Maxwell concluded that light is an electromagnetic wave. Hertz confirmed it experimentally in 1888, twenty-three years later.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Schrödinger (1926).&lt;/strong&gt; The equation describing the evolution of a quantum system predicted the energy levels of the hydrogen atom with a precision the instruments of the time could not yet reach. Troubling detail: the wave function is inherently complex. Not a computational shortcut where you take the real part at the end. Complex numbers are in the foundations of quantum mechanics. Remove them and you have no quantum mechanics.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hodgkin and Huxley (1952).&lt;/strong&gt; Five differential equations to model the action potential of a neuron, based on measurements from the giant squid axon. They predicted the exact shape of the electrical spike a neuron produces, including details that the instruments of the time could not yet resolve. The model was right before the equipment existed to verify it. Nobel Prize in physiology, 1963.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lotka and Volterra (1925–1926).&lt;/strong&gt; Alfred Lotka published his equations in 1925 to describe oscillations in chemical reactions. Vito Volterra independently rediscovered them in 1926, prompted by his son-in-law, a biologist trying to explain oscillations of predator and prey fish populations in the Adriatic: World War I had reduced fishing, predators had multiplied, then prey had bounced back. Two people who did not know each other, working on different problems, arriving at the same equations.&lt;/p&gt;

&lt;h2&gt;
  
  
  Complex numbers: the most troubling case
&lt;/h2&gt;

&lt;p&gt;In 1545, Gerolamo Cardano published &lt;em&gt;Ars Magna&lt;/em&gt;. Solving cubic equations, he encountered square roots of negative numbers. He manipulated them — it worked algebraically — but called them "subtle as they are useless." In 1572, Rafael Bombelli systematized their rules in &lt;em&gt;Algebra&lt;/em&gt; and showed that you could find real roots of cubic equations by passing through imaginary territory.&lt;/p&gt;

&lt;p&gt;For about three hundred and fifty years, complex numbers were an abstract algebraic tool. Elegant, useful for certain calculations, but with no connection to physical reality. Physicists sometimes used them as a computational shortcut, but "at the end you take the real part" — the physical world stayed real.&lt;/p&gt;

&lt;p&gt;In 1926, Schrödinger wrote his equation. The wave function is intrinsically complex. Not a shortcut: complex numbers are in the foundations of quantum mechanics. Remove them, you have no quantum mechanics.&lt;/p&gt;

&lt;p&gt;Three hundred and eighty-one years between the "useless" invention and the indispensable application.&lt;/p&gt;

&lt;p&gt;Gap between mathematical invention and physical application: complex numbers (381 years), Riemannian geometry (61 years), Maxwell's equations (23 years) MATHEMATICS PHYSICS Complex numbers Cardano, 1545 381 years Quantum mechanics Schrödinger, 1926 Riemannian geometry Riemann, 1854 61 years General relativity Einstein, 1915 Maxwell's equations Maxwell, 1865 23 years Hertz confirmation Hertz, 1888&lt;/p&gt;

&lt;p&gt;Math invented for abstract reasons, decades or centuries before becoming essential in physics.&lt;/p&gt;

&lt;p&gt;Riemannian geometry is worth noting here. In 1854, Bernhard Riemann developed an abstract theory of curved spaces in his Habilitation lecture at Göttingen. No physical application in sight. In 1915, Einstein used it as the language of general relativity. Sixty-one years.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wigner names the unease (1960)
&lt;/h2&gt;

&lt;p&gt;Eugene Wigner, a Hungarian-born American physicist, delivered a lecture at NYU in 1959, published the following year in &lt;em&gt;Communications in Pure and Applied Mathematics&lt;/em&gt; under the title "The Unreasonable Effectiveness of Mathematics in the Natural Sciences."&lt;/p&gt;

&lt;p&gt;The paper opens with an anecdote: two former high school classmates run into each other. One became a statistician. He shows the other an article on population trends. The other points to a curve and asks what that symbol represents. "The Gaussian distribution, the bell curve." "But we're in population statistics — what does this have to do with pi?" The statistician shrugs.&lt;/p&gt;

&lt;p&gt;Wigner builds the argument rigorously, example after example, and concludes:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"The miracle of the appropriateness of the language of mathematics for the formulation of the laws of physics is a wonderful gift which we neither understand nor deserve."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;What is remarkable about this text is its honesty. Wigner does not propose an explanation. He names the phenomenon, documents it, and admits he does not understand it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three answers, none satisfying
&lt;/h2&gt;

&lt;p&gt;Since 1960, responses have accumulated. None closes the debate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The universe is mathematics (Tegmark, 2014).&lt;/strong&gt; In &lt;em&gt;Our Mathematical Universe&lt;/em&gt;, physicist Max Tegmark pushes the idea to its limit: physical reality is not &lt;em&gt;described&lt;/em&gt; by a mathematical structure, it &lt;em&gt;is&lt;/em&gt; one. Every consistent mathematical structure exists physically somewhere in a mathematical multiverse. It is elegant. It is also unfalsifiable by construction, which makes it as much a philosophical position as a scientific one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It is survivorship bias (Hamming, 1980).&lt;/strong&gt; Richard Hamming, in an article in the &lt;em&gt;American Mathematical Monthly&lt;/em&gt;, turns the argument around: we only notice the math that works. Thousands of mathematical structures are developed without ever finding physical application. We select the mathematics to fit the problem, then present it as a miraculous coincidence. That is a genuine partial refutation. It does not account for complex numbers in quantum mechanics: there, the math does not "fit" the problem — it is the only language in which the problem can be written at all.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It is evolution (anthropic argument).&lt;/strong&gt; Our brain evolved to model the physical world. That the abstract structures it produces should fit that same world is perhaps not so surprising. This is the most sober explanation. It does not account for math developed in an algebraic ivory tower that turns out to be indispensable in physics three centuries later.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It is a genuine mystery (Wigner, Penrose).&lt;/strong&gt; Wigner himself, and Roger Penrose after him in &lt;em&gt;The Road to Reality&lt;/em&gt; (2004), maintain that the correspondence is too precise, too repeated, too deep to be an artifact. There is something here we do not understand. That is not a mystical position — it is honesty about the state of the question.&lt;/p&gt;

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

&lt;p&gt;What struck me digging into this is that the mystery is already in our code.&lt;/p&gt;

&lt;p&gt;Big O is an abstract mathematical structure. Floating point rests on binary mantissas and exponents, and its quirks (&lt;code&gt;0.1 + 0.2 !== 0.3&lt;/code&gt;) come directly from infinite fractions in base 2. Public key cryptography rests on properties of prime numbers that eighteenth-century mathematicians were studying out of pure curiosity. Artificial neural networks are linear algebra on matrices, nothing more.&lt;/p&gt;

&lt;p&gt;We use structures every day whose effectiveness at describing reality has no definitive explanation. We have just learned to stop finding that strange.&lt;/p&gt;

&lt;p&gt;Galileo had the intuition in 1623. Wigner named the problem in 1960. In 2026, the question remains open.&lt;/p&gt;

</description>
      <category>mathematics</category>
      <category>physics</category>
      <category>science</category>
      <category>culture</category>
    </item>
    <item>
      <title>My Claude Code Skills in Production: What the Audit Taught Me</title>
      <dc:creator>Odilon HUGONNOT</dc:creator>
      <pubDate>Thu, 23 Jul 2026 09:00:01 +0000</pubDate>
      <link>https://dev.to/ohugonnot/my-claude-code-skills-in-production-what-the-audit-taught-me-2eeg</link>
      <guid>https://dev.to/ohugonnot/my-claude-code-skills-in-production-what-the-audit-taught-me-2eeg</guid>
      <description>&lt;p&gt;A Claude Code skill is a Markdown file you drop into &lt;code&gt;~/.claude/skills/&lt;/code&gt;. It describes a complete workflow: when to trigger it, how to execute it step by step, what it should produce. Claude reads it at the right moment and follows it. No manual command, no copy-pasted prompt. You say "publish this article" and the skill chains the checks, image generation, commit, deploy, and LinkedIn draft on its own.&lt;/p&gt;

&lt;p&gt;I have nine in production on this site. Eight work well. One is problematic. And that one is the most interesting to analyze.&lt;/p&gt;

&lt;h2&gt;
  
  
  Nine skills, nine domains
&lt;/h2&gt;

&lt;p&gt;To understand what follows, here's what my nine skills do. Each covers a specific domain of my workflow:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;blog-article&lt;/strong&gt;: write and publish an article (FR + EN, OG image, deploy, LinkedIn, dev.to)&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;blog-fix&lt;/strong&gt;: fix a typo or bug on an already-published article without relaunching everything&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;fiche-livre&lt;/strong&gt;: read a technical book PDF and produce a complete book review (summary, 5-axis radar, SVG diagrams, bilingual)&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;outil-factory&lt;/strong&gt;: create or iterate on a free web tool (/outils/), with radar scoring and Playwright tests&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;veille-debug&lt;/strong&gt;: diagnose bugs in the automated tech watch system (AI article generation, cron, registry)&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;vide-contexte&lt;/strong&gt;: extract non-obvious insights from the current session to persistent memory before clearing context&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;deep-research&lt;/strong&gt;: launch a multi-source search with adversarial verification and cited synthesis&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;frontend-design&lt;/strong&gt;: visual direction guide to avoid producing generic Bootstrap output&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;feature-loop&lt;/strong&gt;: manage the full feature cycle (spec, implementation, review, tests, merge)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Eight work cleanly. The ninth, &lt;code&gt;feature-loop&lt;/code&gt;, clocks in at 1,002 lines and roughly 25,000 tokens. That's the one a recent audit put under the microscope.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a skills audit actually looks at
&lt;/h2&gt;

&lt;p&gt;Auditing a skill means measuring four things: raw length (lines, tokens), structure (body versus on-demand reference files), the density of urgency markers in the text, and what remains visible in long context, when Claude is on iteration 3 of a loaded session.&lt;/p&gt;

&lt;p&gt;On the eight healthy skills, the numbers are clear: between 70 and 257 lines, 1,000 to 7,700 tokens, simple structure. &lt;code&gt;feature-loop&lt;/code&gt; stands alone: 1,002 lines, 25,000 tokens, and text saturated with NON-NEGOTIABLE / NEVER / MANDATORY / LOCKED at every paragraph.&lt;/p&gt;

&lt;p&gt;The diagnosis fits in one sentence: when everything is critical, nothing is.&lt;/p&gt;

&lt;h2&gt;
  
  
  Salience dilution
&lt;/h2&gt;

&lt;p&gt;When Claude loads a skill, it loads the entire body. Everything in there competes for the model's attention. If ten rules are all marked MANDATORY, the model has no way to decide which one to prioritize when there's a conflict or a context constraint. It doesn't arbitrate: it averages. And averaging ten MANDATORY rules gives zero clear priority.&lt;/p&gt;

&lt;p&gt;The invariants that actually change behavior — the ones that break the workflow if forgotten — drown in token micro-optimizations and design justifications. Result: the most important rules are followed with the same reliability as the least important. Which is: not always.&lt;/p&gt;

&lt;p&gt;On the eight healthy skills, critical rules fit in ten lines maximum. The rest is either absent or in reference files loaded at the step that needs them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The silent drift in long context
&lt;/h2&gt;

&lt;p&gt;A skill loads at trigger time. But a work session accumulates: file reads, tool outputs, exchanges, iterations. By iteration 3 of a complex feature, &lt;code&gt;feature-loop&lt;/code&gt;'s SKILL.md is buried under thousands of tokens of accumulated context.&lt;/p&gt;

&lt;p&gt;The rules at the top of the file, Claude still sees. The ones on page 8, less so. This isn't a bug. It's the physics of attention: a language model gives more weight to recent tokens and to tokens at the beginning of context. What's in the middle of a 25,000-token file, buried under tool outputs, is structurally less well processed.&lt;/p&gt;

&lt;p&gt;The fix isn't to move everything to the top. It's to extract what doesn't belong in the body: design justifications, complete examples, 50-line bash recipes. Those belong in a &lt;code&gt;references/&lt;/code&gt; file loaded at the exact step that needs them. The skill body stays an execution contract, not an encyclopedia.&lt;/p&gt;

&lt;h2&gt;
  
  
  What distinguishes the skills that hold
&lt;/h2&gt;

&lt;p&gt;The eight healthy skills share two characteristics that aren't obvious at first glance.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The description is a trigger, not a summary.&lt;/strong&gt; Each skill has a frontmatter with a short description. That's what Claude reads to decide whether to invoke it. A description that summarizes the skill ("this skill creates book reviews") is less effective than one that lists concrete situations ("trigger on: 'book review', 'summarize this book', a book PDF provided"). The first says what the skill is. The second says when to call it — which is the only thing that matters at trigger time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The body trusts the model.&lt;/strong&gt; Short skills don't over-specify. They give the contract (goal, output format, critical steps) and let Claude reason about the rest. An 80-line well-structured skill outperforms a 400-line over-specified one, because every one of those 80 lines has value and the model can hold them all in mind simultaneously. Over-specification is usually codified mistrust. And mistrust in 400 lines produces a skill even Claude can't follow.&lt;/p&gt;

&lt;h2&gt;
  
  
  The public repo: six installable skills
&lt;/h2&gt;

&lt;p&gt;I extracted six of my skills into a public repo: &lt;a href="https://github.com/ohugonnot/claude-skills" rel="noopener noreferrer"&gt;github.com/ohugonnot/claude-skills&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Six skills available, organized as a pipeline:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;issue-mr&lt;/strong&gt;: turn a vague idea into a well-formed issue, branch, and MR/PR shell&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;feature-loop&lt;/strong&gt;: autonomous quality-gated loop — writer, test-writer, and reviewer are separate agents&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;senior-review&lt;/strong&gt;: senior-level review by dimension (correctness, security, design, tests), blind reviewers&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;branch-wrap-up&lt;/strong&gt;: clean branch close-out (conventional commit, push, memory capture)&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;book-distill&lt;/strong&gt;: read a PDF and produce a verified reading note, citations checked word for word&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;vide-contexte&lt;/strong&gt;: extract non-deducible session insights to persistent memory before /clear&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Installation via the Claude Code marketplace in one line, or manually with a symlink:&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;# Via marketplace (recommended)&lt;/span&gt;
/plugin marketplace add ohugonnot/claude-skills
/plugin &lt;span class="nb"&gt;install &lt;/span&gt;feature-loop@web-developpeur-skills

&lt;span class="c"&gt;# Or manually&lt;/span&gt;
git clone https://github.com/ohugonnot/claude-skills.git ~/claude-skills
&lt;span class="nb"&gt;ln&lt;/span&gt; &lt;span class="nt"&gt;-s&lt;/span&gt; ~/claude-skills/plugins/vide-contexte/skills/vide-contexte ~/.claude/skills/vide-contexte
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Publishing these skills changed how I write them. A skill for personal use can lean on implicit context: project conventions, file structure, things I know without writing them. A public skill must work cold, on an unknown project, without a local CLAUDE.md. That constraint forces you to make explicit everything that was tacit. And an explicit skill is a better skill, even for personal use.&lt;/p&gt;

&lt;h2&gt;
  
  
  The search engine that changes discovery
&lt;/h2&gt;

&lt;p&gt;The real problem with skills isn't writing them. It's discovery. If you have twenty skills spread across sub-folders, knowing which one to call for a given need becomes a problem in itself. The natural reflex is to type the skill name, but you still need to remember the name.&lt;/p&gt;

&lt;p&gt;Claude Code now has a keyword-based skill search. Instead of memorizing the exact list, you can search by domain or intent: "something to publish an article", "a research skill", "to clean up context". The engine returns skills whose description matches.&lt;/p&gt;

&lt;p&gt;This mechanism changes how you write descriptions. If that's what gets indexed, the description must contain the words users will type when searching — not the internal terms you use to name the concept. The difference is subtle but real: "clean context before /clear" triggers on "empty the context", "save before clear", "extract the session". A description oriented toward searched usage, not technical definition.&lt;/p&gt;

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

&lt;p&gt;The real lesson from the audit isn't in the list of rules. It's in the gap between &lt;code&gt;feature-loop&lt;/code&gt; and the other eight. A skill that's too long doesn't break because it's unreadable. It breaks because it dilutes: critical rules drown in noise, and the model no longer has a way to know which ones deserve priority attention.&lt;/p&gt;

&lt;p&gt;A skill body isn't an exhaustive source of truth. It's an execution contract. The cleaner it is, the better it holds when context accumulates. Anything not strictly necessary for execution belongs elsewhere: in a reference file, in a script, or nowhere.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;🧩 Installable skills&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The five public skills are available at &lt;a href="https://github.com/ohugonnot/claude-skills" rel="noopener noreferrer"&gt;github.com/ohugonnot/claude-skills&lt;/a&gt;. For writing patterns from Anthropic's 17 official skills, see &lt;a href="https://www.web-developpeur.com/en/blog/skills-claude-code-patterns-officiels" rel="noopener noreferrer"&gt;The rule vs the practice&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>claudecode</category>
      <category>skills</category>
      <category>ai</category>
      <category>workflow</category>
    </item>
    <item>
      <title>Claude Code's 17 Official Skills: the Rule vs the Practice</title>
      <dc:creator>Odilon HUGONNOT</dc:creator>
      <pubDate>Wed, 22 Jul 2026 09:00:03 +0000</pubDate>
      <link>https://dev.to/ohugonnot/claude-codes-17-official-skills-the-rule-vs-the-practice-4f15</link>
      <guid>https://dev.to/ohugonnot/claude-codes-17-official-skills-the-rule-vs-the-practice-4f15</guid>
      <description>&lt;p&gt;Anthropic's docs are clear: a SKILL.md should be under 500 lines. Their own &lt;code&gt;docx&lt;/code&gt; skill is 590. I read all 17 of Anthropic's official skills, frontmatter by frontmatter, while building my own marketplace alongside. Every time, the same gap: the docs say one thing, the corpus does another. And the corpus is the one that's right.&lt;/p&gt;

&lt;p&gt;Here's what actually comes out when you read the code instead of the docs.&lt;/p&gt;

&lt;h2&gt;
  
  
  The description is 90% of the skill
&lt;/h2&gt;

&lt;p&gt;A skill is useless if it doesn't trigger at the right moment. And what decides triggering isn't the body of the SKILL.md, it's its &lt;code&gt;description&lt;/code&gt; in the frontmatter. Claude reads the list of available skills (just name + description) and picks. So all of the "when to use it" must live in the description, not the body.&lt;/p&gt;

&lt;p&gt;The counter-intuitive part, which Anthropic repeats in its own skill-creator: &lt;strong&gt;Claude under-triggers skills&lt;/strong&gt; far more than it over-triggers them. It only consults a skill for a task it can't handle trivially. "Read this PDF" will never fire the PDF skill, even with a perfect description. Hence an explicit instruction: write descriptions that are a little &lt;em&gt;pushy&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;You see it in the code: the &lt;code&gt;xlsx&lt;/code&gt; skill says "Use this skill &lt;strong&gt;any time&lt;/strong&gt; a spreadsheet file is the primary input or output", &lt;code&gt;pptx&lt;/code&gt; says "any time a .pptx is involved &lt;strong&gt;in any way&lt;/strong&gt;". No shyness. You push triggering, you don't hold it back.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 500-line rule nobody follows
&lt;/h2&gt;

&lt;p&gt;"Keep the SKILL.md under 500 lines" is one of the most quoted rules. In practice, &lt;code&gt;docx&lt;/code&gt; is 590 lines, and the docs themselves add "feel free to go longer if needed". The others range from 32 to 404. The truth: 500 is a target, not a law.&lt;/p&gt;

&lt;p&gt;The real principle behind it isn't length, it's the context budget. The SKILL.md body loads on every trigger. If it's big because it holds the essentials, fine, go over. If it's big because it drags along detail that could live elsewhere, that's the fault. Size alone isn't the sin.&lt;/p&gt;

&lt;h2&gt;
  
  
  Progressive disclosure: mostly scripts, rarely references
&lt;/h2&gt;

&lt;p&gt;The central skill pattern is staged loading: metadata (name + description) always in context, the body loaded on trigger, and resources (&lt;code&gt;scripts/&lt;/code&gt;, &lt;code&gt;references/&lt;/code&gt;) only on demand. A script can even execute without being loaded into context.&lt;/p&gt;

&lt;p&gt;Except that in the corpus, &lt;strong&gt;11 skills out of 17 are a single SKILL.md, with no subfolder at all&lt;/strong&gt;. Splitting isn't the norm, it's the exception you reach for when the domain warrants it. And when you do split, it's mostly &lt;code&gt;scripts/&lt;/code&gt;: &lt;code&gt;pdf&lt;/code&gt;, &lt;code&gt;docx&lt;/code&gt;, &lt;code&gt;xlsx&lt;/code&gt;, &lt;code&gt;pptx&lt;/code&gt; are first and foremost script boxes for deterministic operations. The &lt;code&gt;references/&lt;/code&gt; (docs loaded on demand) appear on only two skills. The lesson: pull the deterministic stuff into executable code long before you pull prose into a side file.&lt;/p&gt;

&lt;h2&gt;
  
  
  The "NOT for" Anthropic never writes
&lt;/h2&gt;

&lt;p&gt;Writing my own skills, I systematically added a "NOT for X" clause to the description, to avoid false triggers. Reading the official ones, surprise: they almost never do it. Their descriptions list inclusions ("this includes…"), not exclusions.&lt;/p&gt;

&lt;p&gt;The reason fits in one word: their domains don't overlap. The PDF skill and the Excel skill can't be confused, so no exclusion is needed, and they'd rather maximize triggering. My case is different: my skills touch each other (reviewing a diff, shipping a feature, wrapping up a branch are adjacent). The "NOT for" lets me disambiguate between my own skills. So it isn't a universal rule, it's a response to overlap. If your skills don't compete, don't add it: you'd only reduce your triggering, the worst flaw of all.&lt;/p&gt;

&lt;h2&gt;
  
  
  How they actually optimize a description
&lt;/h2&gt;

&lt;p&gt;The most instructive part of the official skill-creator is that they don't guess a good description, they measure it. The protocol, unrolled:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;20 eval queries&lt;/strong&gt;: 8-10 that should trigger, 8-10 that shouldn't. Realistic and messy, the way a real user would type (file paths, personal context, typos, lowercase).&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;The negatives are near-misses&lt;/strong&gt;, not obvious ones. "Write a fibonacci function" as a negative for a PDF skill tests nothing. Good negatives share keywords with the skill but need something else.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Each query run 3 times&lt;/strong&gt; for a reliable trigger rate, then an improvement loop over 5 iterations.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;You pick the description by its score on a held-out test set&lt;/strong&gt; (60% train, 40% test), so you don't overfit the queries you already know.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It's an eval pipeline, not a finger in the air. Most people write a description by feel and move on. Anthropic treats it as the product.&lt;/p&gt;

&lt;h2&gt;
  
  
  At a glance: the assumption vs the practice
&lt;/h2&gt;

&lt;p&gt;The gap, row by row, across the 17 official skills:&lt;/p&gt;

&lt;p&gt;The common assumption&lt;/p&gt;

&lt;p&gt;What the official corpus does&lt;/p&gt;

&lt;p&gt;A SKILL.md is under 500 lines&lt;/p&gt;

&lt;p&gt;&lt;code&gt;docx&lt;/code&gt; is 590, and the docs add "go longer if needed"&lt;/p&gt;

&lt;p&gt;The description just says when to use the skill&lt;/p&gt;

&lt;p&gt;It must be &lt;em&gt;pushy&lt;/em&gt;: the real risk is under-triggering&lt;/p&gt;

&lt;p&gt;You split into &lt;code&gt;references/&lt;/code&gt; and &lt;code&gt;scripts/&lt;/code&gt; (progressive disclosure)&lt;/p&gt;

&lt;p&gt;11 of 17 skills are a single file; when you split, mostly &lt;code&gt;scripts/&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;You restrict the description ("NOT for") to avoid false triggers&lt;/p&gt;

&lt;p&gt;Almost never written: distinct domains, they maximize triggering&lt;/p&gt;

&lt;p&gt;A good description is written by feel&lt;/p&gt;

&lt;p&gt;It's measured: 20 queries, each run 3 times, a 5-iteration loop, scored on a held-out test set&lt;/p&gt;

&lt;h2&gt;
  
  
  What to remember
&lt;/h2&gt;

&lt;p&gt;The real lesson isn't in the list of rules, it's in the gap. The docs give clean heuristics; the corpus shows how competent people bend them when the ground demands it. Reading the 17 skills teaches more than reading the doc page, because you see the real trade-offs: going past 500 lines when the content deserves it, skipping the split two-thirds of the time, pushing triggering rather than restricting it.&lt;/p&gt;

&lt;p&gt;If you keep one thing: the risk isn't that your skill triggers too much, it's that it doesn't trigger at all. Write the description to be found.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;🧩 My skills, installable&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I built a marketplace of Claude Code skills from my real workflow, applying these patterns. Browse and install them on the &lt;a href="https://www.web-developpeur.com/en/skills" rel="noopener noreferrer"&gt;Skills page&lt;/a&gt;, including a &lt;a href="https://www.web-developpeur.com/en/skills/skill-builder" rel="noopener noreferrer"&gt;skill-builder&lt;/a&gt; skill that condenses these principles. To frame an agent, see also the &lt;a href="https://www.web-developpeur.com/blog/claude-md/" rel="noopener noreferrer"&gt;CLAUDE.md contexts&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently asked questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  How many lines should a SKILL.md be?
&lt;/h3&gt;

&lt;p&gt;Aim for under 500, but it's not a law: the official docx skill is 590. What matters is that the body doesn't drag detail that should live in a script or a reference file. Go over if the essential content warrants it, then add pointers to side files.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why doesn't my skill trigger?
&lt;/h3&gt;

&lt;p&gt;Almost always the description. Claude under-triggers by default and only consults a skill for a non-trivial task. Make the description more inclusive and "pushy" ("use this skill whenever… even if the user doesn't say…"), with real trigger phrases. And test: a one-step task will never trigger a skill, and that's normal.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do I need references/ and scripts/ folders?
&lt;/h3&gt;

&lt;p&gt;Not by default: 11 of the 17 official skills are a single SKILL.md. You split when the domain is heavy, and then mostly into scripts/ for deterministic work (file manipulation, validation). references/ only earns its place for large multi-variant domains.&lt;/p&gt;

&lt;h3&gt;
  
  
  Skill, CLAUDE.md or hook?
&lt;/h3&gt;

&lt;p&gt;Skill = a capability triggered by its description when the situation calls for it. CLAUDE.md = context always loaded for a specific project. Hook = deterministic automation on an event, enforced by the harness, not the model. If it's "always do X", it's a hook or a CLAUDE.md, not a skill.&lt;/p&gt;

</description>
      <category>claudecode</category>
      <category>skills</category>
      <category>ia</category>
      <category>promptengineering</category>
    </item>
    <item>
      <title>Event Sourcing and CQRS: A Practical Guide for Developers</title>
      <dc:creator>Odilon HUGONNOT</dc:creator>
      <pubDate>Tue, 21 Jul 2026 09:00:07 +0000</pubDate>
      <link>https://dev.to/ohugonnot/event-sourcing-and-cqrs-a-practical-guide-for-developers-m6o</link>
      <guid>https://dev.to/ohugonnot/event-sourcing-and-cqrs-a-practical-guide-for-developers-m6o</guid>
      <description>&lt;p&gt;The accountant walks up to your desk. "This account shows 150 dollars. Why?" You open the database, you look at the &lt;code&gt;balance&lt;/code&gt; column. It says 150. That's all it can say. The twenty operations that led to that number? Overwritten, one by one, on every &lt;code&gt;UPDATE&lt;/code&gt;. You can't answer.&lt;/p&gt;

&lt;p&gt;That's the problem Event Sourcing solves. And CQRS is the companion that makes it usable day to day. These two words sound scary, we associate them with Kafka, microservices and unicorn architectures. The reality is simpler, and more useful. This guide lays out the mental model, says when these patterns are worth it (and when they're not), then traces a concrete implementation path in Go with PostgreSQL.&lt;/p&gt;

&lt;h2&gt;
  
  
  Event Sourcing in one sentence
&lt;/h2&gt;

&lt;p&gt;Instead of storing the current state of a piece of data, you store every event that led to that state. The current state is recomputed by replaying the events in order.&lt;/p&gt;

&lt;p&gt;A bank account is no longer a &lt;code&gt;balance = 150&lt;/code&gt; row. It's a list: &lt;code&gt;Credited(200)&lt;/code&gt;, &lt;code&gt;Debited(70)&lt;/code&gt;, &lt;code&gt;Credited(20)&lt;/code&gt;. The balance is the result of a computation, not a stored value. You've lost nothing, because you never overwrite anything. You only ever append.&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;// CRUD: you store the result. The history is overwritten.&lt;/span&gt;
&lt;span class="n"&gt;account&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;Account&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;Balance&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="m"&gt;150&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;// Event Sourcing: you store the facts. The balance is recomputed.&lt;/span&gt;
&lt;span class="n"&gt;events&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="n"&gt;Event&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;Credited&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="m"&gt;200&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="n"&gt;Debited&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="m"&gt;70&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="n"&gt;Credited&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="m"&gt;20&lt;/span&gt;&lt;span class="p"&gt;}}&lt;/span&gt;
&lt;span class="n"&gt;balance&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;replay&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;events&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c"&gt;// 150, rebuilt from the start&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An event is a past fact, immutable, named in the past tense: &lt;code&gt;OrderPlaced&lt;/code&gt;, &lt;code&gt;PaymentConfirmed&lt;/code&gt;, &lt;code&gt;AccountDebited&lt;/code&gt;. Once written, it never changes. That single decision shapes everything else.&lt;/p&gt;

&lt;h2&gt;
  
  
  CQRS in one sentence
&lt;/h2&gt;

&lt;p&gt;You separate write operations (the &lt;em&gt;commands&lt;/em&gt;, which change state) from read operations (the &lt;em&gt;queries&lt;/em&gt;, which read state). Two paths, two models.&lt;/p&gt;

&lt;p&gt;CQRS and Event Sourcing are two independent patterns. You can do CQRS without Event Sourcing, and the other way around. But in production they almost always go together: the write side produces events, and the read side serves from views computed off those events. The write model protects the business rules, the read model is optimized to be read fast.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three real wins
&lt;/h2&gt;

&lt;p&gt;You don't adopt Event Sourcing to look modern. You adopt it for three concrete things, and if none of them speaks to you, move on.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;A free, immutable audit log.&lt;/strong&gt; Every change is a timestamped event you never overwrite. In fintech, in healthcare, anywhere a regulator can ask "who did what, when", this is the argument that justifies everything else.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Temporal queries.&lt;/strong&gt; "What was the balance last Tuesday at 2pm?" becomes trivial: you replay the events up to that date. With a CRUD table, that question is impossible to answer.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Rebuilding.&lt;/strong&gt; A corrupted projection, a bug in a read view? You throw it away and recompute it from the events. The source of truth is intact by construction.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  When NOT to use Event Sourcing
&lt;/h2&gt;

&lt;p&gt;This is the section tutorials forget. Event Sourcing has a real cost: more code, a different mental model, a team to train. For most applications, a plain old CRUD with an audit table is more than enough.&lt;/p&gt;

&lt;p&gt;Avoid Event Sourcing if your domain is a simple form that saves rows, if history doesn't interest you, if nobody will ever ask you "and before?", or if the team is discovering the topic on a project already under pressure. The pattern shines on domains where the history IS the data: accounts, payments, inventory, bookings, complex business workflows. Elsewhere, it adds friction without paying off.&lt;/p&gt;

&lt;h2&gt;
  
  
  The complete mental model
&lt;/h2&gt;

&lt;p&gt;Three building blocks are enough to understand everything. Once they're clear, the rest is just implementation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The aggregate&lt;/strong&gt; is the guardian of business consistency. It's the one that says "no" when an operation is invalid (an account doesn't go below zero, an already-shipped order can't be cancelled). In Event Sourcing, the aggregate has no directly persisted state: its state is the result of replaying all its events from the beginning.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The command&lt;/strong&gt; is an intent ("debit this account by 70"). It goes through the aggregate, which validates, and which produces one or more events if it's allowed, or an error if it isn't. Never both.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The projection&lt;/strong&gt; is a view computed from the events. "The balance of every account" is a projection. "The list of pending orders" is another. These are what your &lt;em&gt;queries&lt;/em&gt; read, and you can throw them away and recompute them at will.&lt;/p&gt;

&lt;h2&gt;
  
  
  The implementation path, and where to dig deeper
&lt;/h2&gt;

&lt;p&gt;I've written a series of articles that take each block in hand, with real, tested Go code. Here's the order that makes sense for learning, from the mental model to production.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt; &lt;strong&gt;The starting point:&lt;/strong&gt; understanding why writes must be serialized per aggregate while reads can be massively parallel, in &lt;a href="https://www.web-developpeur.com/en/blog/concurrence-parallelisme-go-event-sourcing" rel="noopener noreferrer"&gt;concurrency vs parallelism in Go applied to Event Sourcing&lt;/a&gt;. It's also the article that defines the concepts with no prerequisites.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;The aggregate:&lt;/strong&gt; the &lt;code&gt;Transition()&lt;/code&gt; function that replays events, and the &lt;code&gt;Clone()&lt;/code&gt; trap Go makes you forget (slices share their backing array and silently corrupt old states), in &lt;a href="https://www.web-developpeur.com/en/blog/cqrs-go-aggregate-transition-clone" rel="noopener noreferrer"&gt;CQRS in Go: the aggregate, Transition() and Clone()&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;The command handler:&lt;/strong&gt; the &lt;code&gt;Handle(ctx, state, cmd) (Events, error)&lt;/code&gt; signature that makes business logic testable without mocks or a database, in &lt;a href="https://www.web-developpeur.com/en/blog/cqrs-go-command-handlers-testabilite" rel="noopener noreferrer"&gt;side-effect-free command handlers&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Storage:&lt;/strong&gt; why PostgreSQL is enough as an event store, with an append-only table and optimistic locking via &lt;code&gt;UNIQUE(aggregate_id, version)&lt;/code&gt;, in &lt;a href="https://www.web-developpeur.com/en/blog/cqrs-go-postgresql-event-store" rel="noopener noreferrer"&gt;PostgreSQL as an event store&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Multiple aggregates cooperating:&lt;/strong&gt; event choreography over a central orchestrator, and sagas, in &lt;a href="https://www.web-developpeur.com/en/blog/cqrs-go-sagas-choreographie-events" rel="noopener noreferrer"&gt;sagas and event choreography&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Reliability against duplicates:&lt;/strong&gt; the idempotency key for retries and double-clicks, then the four idempotency layers of a complete CQRS system, in &lt;a href="https://www.web-developpeur.com/en/blog/idempotence-cqrs-event-sourcing-bases" rel="noopener noreferrer"&gt;the basics of idempotency&lt;/a&gt; then &lt;a href="https://www.web-developpeur.com/en/blog/idempotence-cqrs-event-sourcing-avance" rel="noopener noreferrer"&gt;commands, projections and outbox&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;The advanced case:&lt;/strong&gt; how to return a synchronous result to the HTTP client in an asynchronous system, and keep a consistent audit log, in &lt;a href="https://www.web-developpeur.com/en/blog/cqrs-pubsub-bridge-audit-atomique" rel="noopener noreferrer"&gt;the pubsub bridge and atomic audit&lt;/a&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The minimal stack that's enough
&lt;/h2&gt;

&lt;p&gt;The biggest trap in Event Sourcing isn't the concept, it's over-engineering. You get sold Kafka, EventStoreDB, a distributed message bus and three brokers before you even have a single aggregate that works.&lt;/p&gt;

&lt;p&gt;For 90% of projects, PostgreSQL alone does all the work. An append-only table for events, a &lt;code&gt;UNIQUE&lt;/code&gt; constraint to handle concurrency, polling or &lt;code&gt;LISTEN/NOTIFY&lt;/code&gt; for projections. Snapshots and an outbox to a broker only arrive when a real volume or integration problem demands them, not before. Start small. You'll add infrastructure the day the pain is real.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to remember
&lt;/h2&gt;

&lt;p&gt;Event Sourcing and CQRS aren't an architecture you adopt wholesale because it's fashionable. They're tools for a specific problem: when the history of your data matters as much as its current state. If a regulator, an accountant or a production bug might one day ask you "and before?", they're worth their cost.&lt;/p&gt;

&lt;p&gt;The right first step isn't to re-architect everything. It's to take a single aggregate that matters (an account, an order, a wallet), model it as events on a PostgreSQL table, and see what changes. The rest follows, block by block.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently asked questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What's the difference between Event Sourcing and CQRS?
&lt;/h3&gt;

&lt;p&gt;Event Sourcing describes how you store data: a sequence of immutable events rather than the current state. CQRS describes how you organize code: one path to write (commands), another to read (queries). They're two independent patterns, but in production you almost always combine them, because one feeds the other.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do you need Kafka or EventStoreDB for Event Sourcing?
&lt;/h3&gt;

&lt;p&gt;No. For the vast majority of projects, PostgreSQL is enough: an append-only table, a uniqueness constraint for concurrency, polling or LISTEN/NOTIFY for projections. Kafka and dedicated event stores answer specific volume or integration needs, not a prerequisite of the pattern.&lt;/p&gt;

&lt;h3&gt;
  
  
  Isn't Event Sourcing too complex for my project?
&lt;/h3&gt;

&lt;p&gt;Often, yes. If your domain is a simple CRUD with no need for history, a classic audit table is enough and costs far less. Event Sourcing is worth its cost when the history is the data: accounts, payments, inventory, bookings, or any domain where "who did what, when" is a real question.&lt;/p&gt;

&lt;h3&gt;
  
  
  Where do you start concretely?
&lt;/h3&gt;

&lt;p&gt;With a single aggregate that matters, modeled as events on a PostgreSQL table. Understand replay and the aggregate first, then the command handler, then storage, then idempotency. The linked article series follows exactly that order, with tested Go code.&lt;/p&gt;

</description>
      <category>eventsourcing</category>
      <category>cqrs</category>
      <category>architecture</category>
      <category>go</category>
    </item>
  </channel>
</rss>
