<?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>Web accessibility — free interactive course</title>
      <dc:creator>Odilon HUGONNOT</dc:creator>
      <pubDate>Wed, 14 Oct 2026 11:00:03 +0000</pubDate>
      <link>https://dev.to/ohugonnot/web-accessibility-free-interactive-course-221p</link>
      <guid>https://dev.to/ohugonnot/web-accessibility-free-interactive-course-221p</guid>
      <description>&lt;h1&gt;
  
  
  Web accessibility
&lt;/h1&gt;

&lt;p&gt;Covers the 2026 legal obligations (European Accessibility Act, RGAA) that most teams only discover once an audit lands on their desk.&lt;/p&gt;

&lt;p&gt;Free interactive web accessibility course in 7 lessons: semantic HTML, keyboard navigation and visible focus, alt text, accessible forms, WCAG contrast ratios, ARIA as a last resort, free testing tools.&lt;/p&gt;

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

</description>
      <category>learning</category>
      <category>webdev</category>
      <category>accessibilite</category>
    </item>
    <item>
      <title>Best Clean Code and Refactoring Books, and the Order to Read Them</title>
      <dc:creator>Odilon HUGONNOT</dc:creator>
      <pubDate>Wed, 14 Oct 2026 09:00:02 +0000</pubDate>
      <link>https://dev.to/ohugonnot/best-clean-code-and-refactoring-books-and-the-order-to-read-them-2c24</link>
      <guid>https://dev.to/ohugonnot/best-clean-code-and-refactoring-books-and-the-order-to-read-them-2c24</guid>
      <description>&lt;p&gt;Clean Code was the first text I handed to my students. Since then I have read the six other books fighting for the "clean code" shelf, and they do not all say the same thing. Sometimes they say the opposite.&lt;/p&gt;

&lt;p&gt;I wrote a reading note for each one. Here is the order I would follow if I were starting over, and what you can leave on the shelf.&lt;/p&gt;

&lt;h2&gt;
  
  
  The craft before the rules
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://www.web-developpeur.com/en/library/pragmatic-programmer/" rel="noopener noreferrer"&gt;&lt;em&gt;The Pragmatic Programmer&lt;/em&gt;&lt;/a&gt; by Thomas and Hunt (2019 edition, 8/10) is my way in. What strikes you first is the tone: few commandments, many stories, never a condescending look. And this edition publicly corrects the previous one. The Law of Demeter is demoted, DRY (do not duplicate a piece of knowledge) is clarified because people misread it, and on security the authors write "we were wrong". A book that owns its mistakes twenty years later earns trust for everything else.&lt;/p&gt;

&lt;p&gt;The limit is mechanical: 53 topics in 352 pages means skimming. The tooling chapter is thin, and the code samples jump from Ruby to Elixir to Clojure without digging into any of them. You read it for the stories and mental models, not the code. Skip the samples, keep the rest.&lt;/p&gt;

&lt;h2&gt;
  
  
  The dogma and its counterweight
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://www.web-developpeur.com/en/library/coder-proprement/" rel="noopener noreferrer"&gt;&lt;em&gt;Clean Code&lt;/em&gt;&lt;/a&gt; by Robert C. Martin (2008, 6.8/10) taught a generation that code is read ten times more than it is written. The chapters on naming, functions and comments have not aged a day. The rest is verbose Java, and the last three case-study chapters are 90 pages of listings nobody reads anymore, me included. One thing the fans forget: Martin himself admits that no research backs the short-functions rule, and that his own functions start out long and messy before being refined under tests. The "three lines maximum" dogma comes more from his readers than from the book.&lt;/p&gt;

&lt;p&gt;To hear the other bell, read &lt;a href="https://www.web-developpeur.com/en/library/philosophy-software-design/" rel="noopener noreferrer"&gt;&lt;em&gt;A Philosophy of Software Design&lt;/em&gt;&lt;/a&gt; by John Ousterhout (2021, 8/10). A hundred and ninety-six pages, and in my eyes the best software design book of the last twenty years. His deep module idea changed how I judge a class: I stopped counting lines and started comparing what the interface costs to learn with what it gives back. The examples circle around two or three cases (a text editor, RAMCloud) and smell like a lecture hall. And "make your modules deep" is easier to understand than to apply. Where the two books contradict each other, on function size and on comments, Ousterhout is almost always right.&lt;/p&gt;

&lt;h2&gt;
  
  
  The first hundred pages of construction
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://www.web-developpeur.com/en/library/code-complete/" rel="noopener noreferrer"&gt;&lt;em&gt;Code Complete&lt;/em&gt;&lt;/a&gt; by Steve McConnell (2004, 960 pages, 7.4/10) is the book I wish I had read at the start of my career, but for its first hundred pages, not its 960. The thesis on complexity, abstract data types, cohesive routines, defensive barricades: you usually learn all of that by trial and error, after humbling code reviews. The book shortcuts that painful education.&lt;/p&gt;

&lt;p&gt;What has aged is visible: samples in C++, Visual Basic 6 and Pascal, "code tuning" chapters about processor pipelines, a tools catalogue straight from a museum. What holds up is everything about humans: the numbers on code inspections, the psychology of debugging, complexity as the universal enemy. You do not need all of it. Parts I, II and VII plus chapters 20 and 21 make roughly 250 pages, and the rest is reference material you open now and then.&lt;/p&gt;

&lt;h2&gt;
  
  
  Refactoring and legacy, the two moves
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://www.web-developpeur.com/en/library/refactoring/" rel="noopener noreferrer"&gt;&lt;em&gt;Refactoring&lt;/em&gt;&lt;/a&gt; by Martin Fowler (2018, 8.2/10) holds the best code lesson I have ever read. Chapter 1 is seventy pages of watching someone work, every move explained, the code never broken. Fowler has a rare honesty: his central argument is labeled "unproven hypothesis". The limit is that half the book is a dictionary of transformations nobody reads in one sitting. Read chapters 1 to 4 like a novel and keep the rest within reach during code review. The Refactor menu in your editor exists because of this book.&lt;/p&gt;

&lt;p&gt;Then comes &lt;a href="https://www.web-developpeur.com/en/library/working-effectively-legacy-code/" rel="noopener noreferrer"&gt;&lt;em&gt;Working Effectively with Legacy Code&lt;/em&gt;&lt;/a&gt; by Michael Feathers (2004, 7/10), which redefines the word: legacy is not old code, it is code without tests. His characterization tests (which snapshot current behavior) answer a question nobody phrases well: how do you start testing when you do not know what "correct" means for this code? The seam model (places where you can change behavior without editing the code) teaches you to read code by looking for joints. Feathers also admits that his techniques often make code uglier in the short term, and that the scar is temporary. That frankness makes the book reliable.&lt;/p&gt;

&lt;p&gt;It is dated, and every example is Java, C++ or C. Part II, twenty chapters in FAQ format, often repeats the same four moves under a different class name. Open it at the chapter matching your problem of the day.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test first, to lose the fear
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://www.web-developpeur.com/en/library/tdd-by-example/" rel="noopener noreferrer"&gt;&lt;em&gt;Test-Driven Development: By Example&lt;/em&gt;&lt;/a&gt; by Kent Beck (2002, 240 pages, 7.6/10) does not sell a recipe, it shows a state of mind: the calm of knowing, every second, that the code still works. The fear of breaking things turned into quiet boredom. Since then I no longer write tests to "have coverage", I write them to move forward without dread.&lt;/p&gt;

&lt;p&gt;Part I, the money example, drags: you repeat the same micro-cycle dozens of times and you got it long before. The code is 2002 Java and Python 2. TDD is still debated, and Beck is the first to say no study settles it. Read it for the mindset, keep the cycle, the TODO list and "fake it" when you are stuck, and adapt the micro-steps to your own pace.&lt;/p&gt;

&lt;h2&gt;
  
  
  The order I would follow
&lt;/h2&gt;

&lt;p&gt;Book&lt;/p&gt;

&lt;p&gt;Pages&lt;/p&gt;

&lt;p&gt;Rating&lt;/p&gt;

&lt;p&gt;What it is for&lt;/p&gt;

&lt;p&gt;1&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.web-developpeur.com/en/library/pragmatic-programmer/" rel="noopener noreferrer"&gt;The Pragmatic Programmer&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;352&lt;/p&gt;

&lt;p&gt;8&lt;/p&gt;

&lt;p&gt;The craft's mental models&lt;/p&gt;

&lt;p&gt;2&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.web-developpeur.com/en/library/coder-proprement/" rel="noopener noreferrer"&gt;Clean Code&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;464&lt;/p&gt;

&lt;p&gt;6.8&lt;/p&gt;

&lt;p&gt;Naming, functions, comments&lt;/p&gt;

&lt;p&gt;3&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.web-developpeur.com/en/library/philosophy-software-design/" rel="noopener noreferrer"&gt;A Philosophy of Software Design&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;196&lt;/p&gt;

&lt;p&gt;8&lt;/p&gt;

&lt;p&gt;The counterweight: deep modules&lt;/p&gt;

&lt;p&gt;4&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.web-developpeur.com/en/library/refactoring/" rel="noopener noreferrer"&gt;Refactoring&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;448&lt;/p&gt;

&lt;p&gt;8.2&lt;/p&gt;

&lt;p&gt;Chapters 1 to 4, then the catalog as needed&lt;/p&gt;

&lt;p&gt;5&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.web-developpeur.com/en/library/tdd-by-example/" rel="noopener noreferrer"&gt;TDD by Example&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;240&lt;/p&gt;

&lt;p&gt;7.6&lt;/p&gt;

&lt;p&gt;Moving forward without fear of breaking&lt;/p&gt;

&lt;p&gt;6&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.web-developpeur.com/en/library/working-effectively-legacy-code/" rel="noopener noreferrer"&gt;Working Effectively with Legacy Code&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;440&lt;/p&gt;

&lt;p&gt;7&lt;/p&gt;

&lt;p&gt;Testing code that never had tests&lt;/p&gt;

&lt;p&gt;as needed&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.web-developpeur.com/en/library/code-complete/" rel="noopener noreferrer"&gt;Code Complete&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;960&lt;/p&gt;

&lt;p&gt;7.4&lt;/p&gt;

&lt;p&gt;The useful 250 pages, then reference&lt;/p&gt;

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

&lt;p&gt;The book I used to hand my students has the lowest rating on the list, 6.8/10. And it is Fowler and Feathers who hold the moves you actually use on a Monday morning, in code you did not write.&lt;/p&gt;

&lt;p&gt;If you want to see these moves applied to a real project, I wrote about &lt;a href="https://www.web-developpeur.com/en/blog/ordre-audit-symfony-legacy" rel="noopener noreferrer"&gt;the order of a legacy Symfony audit&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;I stitched the marrow of dozens of tech books into a single thread, from bits and Big O up to the AI era: that is &lt;a href="https://www.web-developpeur.com/en/library/le-grand-livre/" rel="noopener noreferrer"&gt;&lt;em&gt;The Developer's Great Book&lt;/em&gt;&lt;/a&gt;. Four of these books (&lt;em&gt;Clean Code&lt;/em&gt;, &lt;em&gt;A Philosophy of Software Design&lt;/em&gt;, &lt;em&gt;The Pragmatic Programmer&lt;/em&gt;, &lt;em&gt;Refactoring&lt;/em&gt;) are melted into it at the "Writing for humans" level, and three others (&lt;em&gt;TDD by Example&lt;/em&gt;, &lt;em&gt;Working Effectively with Legacy Code&lt;/em&gt;, &lt;em&gt;Code Complete&lt;/em&gt;) at the "Prove it, ship it, hold the load" level.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;📚 Other comparisons.&lt;/strong&gt; &lt;a href="https://www.web-developpeur.com/en/blog/livres-devops-equipes-ordre-lecture" rel="noopener noreferrer"&gt;Best DevOps and Engineering Team Books, and the Order to Read Them&lt;/a&gt; and &lt;a href="https://www.web-developpeur.com/en/blog/livres-event-sourcing-cqrs-ordre-lecture" rel="noopener noreferrer"&gt;Best Event Sourcing and CQRS Books, and the Order to Read Them&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>cleancode</category>
      <category>refactoring</category>
      <category>tests</category>
      <category>books</category>
    </item>
    <item>
      <title>Best DevOps and Engineering Team Books, and the Order to Read Them</title>
      <dc:creator>Odilon HUGONNOT</dc:creator>
      <pubDate>Tue, 13 Oct 2026 09:00:02 +0000</pubDate>
      <link>https://dev.to/ohugonnot/best-devops-and-engineering-team-books-and-the-order-to-read-them-2gbm</link>
      <guid>https://dev.to/ohugonnot/best-devops-and-engineering-team-books-and-the-order-to-read-them-2gbm</guid>
      <description>&lt;p&gt;Ten years after reading it, people still say "we have a Brent problem." Brent is a character in a novel about servers that keep crashing: the person nothing ships without, whose knowledge never leaves his head. If your last production release happened on a Saturday night, you know who yours is.&lt;/p&gt;

&lt;p&gt;Around that novel sits a small library on shipping software and on how to organize the teams doing it. I have read six of them and written a reading note for each. Here is the order I would follow, and what you can leave on the shelf.&lt;/p&gt;

&lt;h2&gt;
  
  
  The novel that makes it click
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://www.web-developpeur.com/en/library/phoenix-project/" rel="noopener noreferrer"&gt;&lt;em&gt;The Phoenix Project&lt;/em&gt;&lt;/a&gt; (Gene Kim, Kevin Behr and George Spafford, 2013, 8.2/10) does what whole shelves of manuals fail at: it teaches flow management to someone who has never opened a management book. You go through three outages with the hero before the mentor explains anything, and by the time he does, you needed it. Unplanned work becomes a task board emptied by emergencies. The bottleneck becomes Brent.&lt;/p&gt;

&lt;p&gt;Let's be honest: as literature, it is an airport novel. Characters who are really functions, a cartoon villain, a mentor with an answer for everything. The setting (storage arrays, change approval boards) is 2013, and the transformation lands in four months, which is a fairy tale. In real life, count years. But the setting aged because the book won: deploying ten times a day, unthinkable then, is routine now. For modern recipes, my note points to &lt;em&gt;The DevOps Handbook&lt;/em&gt;, which I have not covered here.&lt;/p&gt;

&lt;h2&gt;
  
  
  The numbers that win the argument
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://www.web-developpeur.com/en/library/accelerate/" rel="noopener noreferrer"&gt;&lt;em&gt;Accelerate&lt;/em&gt;&lt;/a&gt; (Nicole Forsgren, Jez Humble and Gene Kim, 2018, 7.4/10) put numbers into a debate that only had slogans. Before it, "we should deploy more often" was a conference opinion. After it, it is the outcome of four years of research, replicated since. Burying the approval committee became my favorite meeting argument: I am not the one saying it, the study is.&lt;/p&gt;

&lt;p&gt;The limit: it argues a case, it is not a manual. You learn what to aim for, not how to get there. The reading is sometimes dry, repetitive near the end, and the ×46 and ×440 figures date from 2017. Keep the frame, not the values: capabilities over maturity, outcomes over activity. For current numbers, my note sends you to the free annual DORA reports.&lt;/p&gt;

&lt;h2&gt;
  
  
  The how, at last
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://www.web-developpeur.com/en/library/continuous-delivery/" rel="noopener noreferrer"&gt;&lt;em&gt;Continuous Delivery&lt;/em&gt;&lt;/a&gt; (Jez Humble and David Farley, 2010, 463 pages, 7.8/10) is the foundation of half my job. I never ran CruiseControl and I won't pretend otherwise. What the book gave me is the why behind the buttons I press: why one binary travels, why the same script everywhere, why you deploy without releasing. With those reasons, you stop tinkering and start designing.&lt;/p&gt;

&lt;p&gt;All the tooling has aged. Hudson, Ant, first-generation Puppet: mentally swap in GitHub Actions, Docker, Kubernetes, Flyway. Containers did not exist yet, Docker arrived in 2013. The book is thick and sometimes dry, and the authors themselves suggest dipping in rather than swallowing it whole. Do that. The principles have not aged a day: a modern CI pipeline is the deployment pipeline of chapter 5.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape of teams
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://www.web-developpeur.com/en/library/team-topologies/" rel="noopener noreferrer"&gt;&lt;em&gt;Team Topologies&lt;/em&gt;&lt;/a&gt; (Matthew Skelton and Manuel Pais, 2019, 8.2/10) is a rare management book: written for developers. Its real gift is vocabulary. Once you can say "that's a distributed monolith" or "we need an enabling team here," a vague friction becomes a nameable problem, and so a fixable one. The inverse Conway maneuver (design the teams to get the architecture you want, since architecture ends up copying the organization) is worth the read by itself.&lt;/p&gt;

&lt;p&gt;It is repetitive, its case studies are thin, and it is about organization design: you often need management's blessing to apply anything. Don't expect a recipe to run solo on Monday morning. Read it as the companion to &lt;em&gt;Accelerate&lt;/em&gt; (the data) and &lt;em&gt;The Phoenix Project&lt;/em&gt; (the story).&lt;/p&gt;

&lt;h2&gt;
  
  
  The human side, and the old-timers
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://www.web-developpeur.com/en/library/peopleware/" rel="noopener noreferrer"&gt;&lt;em&gt;Peopleware&lt;/em&gt;&lt;/a&gt; (Tom DeMarco and Timothy Lister, 1999 edition, 8.2/10) showed, with data, that projects fail for human reasons. I am a developer, not a manager, so I won't play the expert. But the chapter on flow put words on something I have felt for years: that fifteen-minute climb back into a problem, and the rage of being yanked out of it, which almost no workplace protects. The 1987 world shows through (open offices, landline phones, zero remote work), and a few anecdotes land oddly. Read it less for the offices than for the mirror.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.web-developpeur.com/en/library/mythical-man-month/" rel="noopener noreferrer"&gt;&lt;em&gt;The Mythical Man-Month&lt;/em&gt;&lt;/a&gt; by Frederick Brooks (1995 edition, 7.8/10) was written in 1975 about a disaster from 1964. Brooks ran OS/360 and, instead of downplaying it, he dissected it. Every law comes from something that really broke. The technical parts date (program clerks, static overlays), the mechanisms do not: adding developers to a late project makes it later. Skip the 1975 vocabulary, keep the laws.&lt;/p&gt;

&lt;h2&gt;
  
  
  The order I would follow
&lt;/h2&gt;

&lt;p&gt;Book&lt;/p&gt;

&lt;p&gt;Pages&lt;/p&gt;

&lt;p&gt;Rating&lt;/p&gt;

&lt;p&gt;What it is for&lt;/p&gt;

&lt;p&gt;1&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.web-developpeur.com/en/library/phoenix-project/" rel="noopener noreferrer"&gt;The Phoenix Project&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;432&lt;/p&gt;

&lt;p&gt;8.2&lt;/p&gt;

&lt;p&gt;Understanding flow through a story&lt;/p&gt;

&lt;p&gt;2&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.web-developpeur.com/en/library/accelerate/" rel="noopener noreferrer"&gt;Accelerate&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;288&lt;/p&gt;

&lt;p&gt;7.4&lt;/p&gt;

&lt;p&gt;Convincing with data&lt;/p&gt;

&lt;p&gt;3&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.web-developpeur.com/en/library/continuous-delivery/" rel="noopener noreferrer"&gt;Continuous Delivery&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;463&lt;/p&gt;

&lt;p&gt;7.8&lt;/p&gt;

&lt;p&gt;The why of the pipeline, dip in&lt;/p&gt;

&lt;p&gt;4&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.web-developpeur.com/en/library/team-topologies/" rel="noopener noreferrer"&gt;Team Topologies&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;240&lt;/p&gt;

&lt;p&gt;8.2&lt;/p&gt;

&lt;p&gt;Naming and drawing teams&lt;/p&gt;

&lt;p&gt;5&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.web-developpeur.com/en/library/peopleware/" rel="noopener noreferrer"&gt;Peopleware&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;245&lt;/p&gt;

&lt;p&gt;8.2&lt;/p&gt;

&lt;p&gt;Protecting flow, reading people&lt;/p&gt;

&lt;p&gt;6&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.web-developpeur.com/en/library/mythical-man-month/" rel="noopener noreferrer"&gt;The Mythical Man-Month&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;322&lt;/p&gt;

&lt;p&gt;7.8&lt;/p&gt;

&lt;p&gt;Answering "let's add two people"&lt;/p&gt;

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

&lt;p&gt;The oldest of these six books, written in 1975, is also the one whose main law no revolution has cancelled: not Git, not agile, not the cloud. And the newest one on teams admits that its framework is not enough without culture and engineering practices. Delivery tools change every five years. Problems between people never do.&lt;/p&gt;

&lt;p&gt;I stitched the marrow of dozens of tech books into a single thread, from bits and Big O up to the AI era: that is &lt;a href="https://www.web-developpeur.com/en/library/le-grand-livre/" rel="noopener noreferrer"&gt;&lt;em&gt;The Developer's Great Book&lt;/em&gt;&lt;/a&gt;. &lt;em&gt;Continuous Delivery&lt;/em&gt;, &lt;em&gt;The Phoenix Project&lt;/em&gt;, &lt;em&gt;Accelerate&lt;/em&gt; and &lt;em&gt;Peopleware&lt;/em&gt; are folded into the level "Prove it, ship it, hold the load." &lt;em&gt;Team Topologies&lt;/em&gt;, &lt;em&gt;Peopleware&lt;/em&gt; and &lt;em&gt;The Mythical Man-Month&lt;/em&gt; come back at the level "Software is for humans."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;📚 Other comparisons.&lt;/strong&gt; &lt;a href="https://www.web-developpeur.com/en/blog/livres-code-propre-refactoring-ordre-lecture" rel="noopener noreferrer"&gt;Best Clean Code and Refactoring Books, and the Order to Read Them&lt;/a&gt; and &lt;a href="https://www.web-developpeur.com/en/blog/livres-event-sourcing-cqrs-ordre-lecture" rel="noopener noreferrer"&gt;Best Event Sourcing and CQRS Books, and the Order to Read Them&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>continuousdelivery</category>
      <category>teams</category>
      <category>management</category>
    </item>
    <item>
      <title>TypeScript — free interactive course</title>
      <dc:creator>Odilon HUGONNOT</dc:creator>
      <pubDate>Wed, 30 Sep 2026 11:00:04 +0000</pubDate>
      <link>https://dev.to/ohugonnot/typescript-free-interactive-course-5cgn</link>
      <guid>https://dev.to/ohugonnot/typescript-free-interactive-course-5cgn</guid>
      <description>&lt;h1&gt;
  
  
  TypeScript
&lt;/h1&gt;

&lt;p&gt;The lab runs a real TypeScript compiler in the browser, not a simulation. You see the exact type errors your IDE would show.&lt;/p&gt;

&lt;p&gt;Free interactive TypeScript course in 10 lessons: inference, interfaces, unions and narrowing, generics, utility types, compile time vs runtime, any vs unknown. With a real type-checker in your browser.&lt;/p&gt;

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

</description>
      <category>learning</category>
      <category>webdev</category>
      <category>typescript</category>
    </item>
    <item>
      <title>Offline PWA: the pitfalls of an app that never asks for the network</title>
      <dc:creator>Odilon HUGONNOT</dc:creator>
      <pubDate>Thu, 17 Sep 2026 09:00:02 +0000</pubDate>
      <link>https://dev.to/ohugonnot/offline-pwa-the-pitfalls-of-an-app-that-never-asks-for-the-network-fc7</link>
      <guid>https://dev.to/ohugonnot/offline-pwa-the-pitfalls-of-an-app-that-never-asks-for-the-network-fc7</guid>
      <description>&lt;p&gt;In the &lt;a href="https://www.web-developpeur.com/en/blog/caa-pictogrammes-enfant-autiste" rel="noopener noreferrer"&gt;previous article&lt;/a&gt;, I told the story of why I built Mes mots, a pictogram communication app for a kid who doesn't talk yet. One of the project's constraints is that it has to work in airplane mode, on a tablet that might not see wifi for days. This article is only about that: how you build something truly offline, and above all how it still gets updated without a permanent connection.&lt;/p&gt;

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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

&lt;/div&gt;



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

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

&lt;/div&gt;



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

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

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

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

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

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

&lt;/div&gt;



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

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

&lt;/div&gt;



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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

&lt;/span&gt;remettre_le_verrou

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

&lt;/div&gt;



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

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

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

</description>
      <category>go</category>
      <category>errors</category>
      <category>fmterrorf</category>
    </item>
  </channel>
</rss>
