Skip to content
Back to blog
Part 5 of 5 in series
Who's Knocking at the Door

A curated collection of articles exploring this topic in depth.

6 min read

Who Watches the Watch List?

After three months of noise from one PHP path, the filter changed. The small promotion raised a larger question about how much judgement belongs inside a telemetry system, and who exercises it.


The path /classwithtostring.php has spent three months auditioning for this site's probe filter. The filename is associated with PHP exploitation payloads. It turns up persistently. The number of distinct addresses asking for it has grown from around twenty to sixty. Judged purely as detection, it belongs on the curated list; it is exactly the sort of thing the list is for.

The same clause does both jobs#

Post 3 described the coupling: the query clause that decides what counts as a probe also decides whose individual data gets recorded. Known probes are logged in full, source IP and user agent included. Novel probes are only counted. Promoting /classwithtostring.php out of the novel tier would improve detection by identifying the source rather than just counting it.

That gave me pause, and for a while I dressed the pause up as principle. The path resolves to 404 and threatens nothing, so recording who asks for it buys me nothing. The trouble is that describes the whole curated list just as well: every path on the filter resolves to 404 here, and I record the sources probing /wp-login.php anyway. Refusing the promotion defended something, but it was the shape the list had in March, not any reader, and the shape of a list protects nobody.

The boundary this system actually holds runs between scanners and readers. So the only question a promotion has to answer is whether the pattern could ever match a reader. For PHP paths, the answer could not be cleaner. No page, feed, or image here has ever ended in .php, so no reader request ever will.

So /classwithtostring.php is on the list now and I expanded the wildcard to include everything PHP. The probe condition from post 3 gained one line, OR uri LIKE '%.php%', and lost the two wildcards. Alfa Shell, HelloPress, and every generic backdoor filename the first post catalogued are now instrumented as the automated traffic they are.

The filter change shipped alongside post 4, and because the queries re-read the same logs, the re-categorisation reached backwards as well as forwards. The novel share that post 4 showed climbing past 70 percent sits at 10 percent for the thirty days to late July, with the PHP class counted as the known probes they always were. I find that more reassuring than the old chart.

Probe summary and timeline from the security dashboard for the thirty days to 25 July 2026, showing 67,131 known probes and 7,554 novel probes, 10 percent of the total, with the novel tier reduced to occasional spikes(click to enlarge)

The test is the part that stays. A pattern joins the filter when no reader request could ever match it, and stays in the aggregate tier when there is any doubt. /classwithtostring.php took three months to pass a test I could have run in March, and the re-categorisation brings other new candidates into the spotlight.

None of this comes from treating privacy as a creed. I set out to avoid two things: handing my readers to Google in exchange for a dashboard, and collecting data I had no use for. The analytics pipeline that came out of those preferences happens never to read an individual reader's IP address or user agent, and I have written before about treating that as a design constraint rather than a feature. Security observability bent the constraint, narrowly: telling one scanner from another requires reading IPs and user agents after all. What kept the bend narrow is the rule the rest of this series has been circling, and it fits in one line. Instrument automated traffic, never identify the people who read the site.

Marking my own work#

The title asks who watches the watch list, and on this site there is exactly one candidate. So writing this post, I went to confirm the system actually behaved the way I keep describing it.

The bot fingerprint query buckets user agents to spot automation pretending to be a browser. To do that it was reading the user agent and counting distinct IPs across all traffic, readers included, rather than only the automated tier, although nothing leaked because it only ever emitted aggregate counts.

It genuinely slipped past code review because it was a reasonable-looking aggregation query, and the fix was one scoping clause. That combination makes it a good candidate for a fitness function to automatically verify that no query reads IPs outside the automated tier, rather than a passing review having to catch it next time.

The alternative is collecting everything#

Most telemetry systems keep detection and collection separate. They collect everything by default and decide what to look at when the data is displayed(opens in a new tab). That is efficient, it can answer questions nobody knew to ask at collection time, and it means the scope of collection never has to be discussed, because the scope is always everything. Coupling the two, the way this site does, is less efficient and harder to ignore: widening what the system watches means deciding, out loud, to widen what it keeps. The trade-off shows up at the point of the change instead of dissolving into a default. Security practice has been arriving at the same conclusion from the cost side: output-driven collection(opens in a new tab), where what you gather is decided by what you intend to do with it.

A site that returns 404 to every request is a low-stakes place to run that experiment. The mechanism is the same where the stakes are real, except there it usually runs out of sight, which is how a system ends up collecting more than anyone chose for it to.

The pressure will keep arriving. The .php promotion cleared the easy cases, and the novel tier keeps filling behind it: version control paths, health check endpoints, the occasional request that might one day collide with a real page. The tempting fix is automation: have a model read the novel tier and propose promotions, instead of me running the test by hand. That would read more than the pipeline reads today, which is exactly why it should arrive as a decision made with the boundary in view rather than as the path of least resistance.

The scanning data has been the fun part of this series: three months of campaigns, an edge chart rearranging itself, a filter drifting out of date, all of it observed by ten URI patterns and a handful of counters over logs that already existed. None of it needed the collect-everything default and the patterns I chose were enough because I knew what I wanted to see. The system that decides what to keep is the one I will still be making decisions about long after these particular scanners have moved on.