<?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: jj1423</title>
    <description>The latest articles on DEV Community by jj1423 (@jj1423).</description>
    <link>https://dev.to/jj1423</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%2F4151110%2Fcf361247-67d0-46b6-a53a-eaf68aaee2cd.png</url>
      <title>DEV Community: jj1423</title>
      <link>https://dev.to/jj1423</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/jj1423"/>
    <language>en</language>
    <item>
      <title>Zero CVEs. but found 1,249 risky paths!</title>
      <dc:creator>jj1423</dc:creator>
      <pubDate>Thu, 22 Oct 2026 05:40:10 +0000</pubDate>
      <link>https://dev.to/jj1423/zero-cves-but-found-1249-risky-paths-3h4p</link>
      <guid>https://dev.to/jj1423/zero-cves-but-found-1249-risky-paths-3h4p</guid>
      <description>&lt;h2&gt;
  
  
  What a vulnerability database can't tell you
&lt;/h2&gt;

&lt;p&gt;Every dependency scanner works the same way: take the packages in your lockfile, look each one up in a vulnerability database, report the matches. That is useful, and it has a hard limit. It can only report what someone has already found, written up and published.&lt;/p&gt;

&lt;p&gt;A whole class of risk never gets there. A well-maintained library with no CVE can still be where your service breaks, because of what &lt;em&gt;your&lt;/em&gt; code hands it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a URL from a request passed to an HTTP client, reaching an internal address,&lt;/li&gt;
&lt;li&gt;an uploaded archive handed to a parser that expands it without limit,&lt;/li&gt;
&lt;li&gt;a user-supplied path joined into a file operation,&lt;/li&gt;
&lt;li&gt;input that becomes a shell command three packages down the chain.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Nothing is wrong with the library in any of these. So nobody will ever file an advisory, and every scanner that looks at packages will keep saying "safe".&lt;/p&gt;

&lt;p&gt;We wanted to find that risk anyway. So we built &lt;code&gt;vdb harden&lt;/code&gt;, which &lt;strong&gt;follows the call instead of the name&lt;/strong&gt;: it abstracts your code locally (no source leaves your machine), joins it with your lockfile and with a summary of where each package version sends its arguments, and traces input from your code, through the dependency chain, to whatever dangerous operation it reaches. No CVE needed; the path is decided from the code. On our own service it led us to a way to stall the API with two file uploads that no advisory will ever describe. (That story, and &lt;a href="https://vdb.ai.kr/blog/following-the-call" rel="noopener noreferrer"&gt;why following the call is hard&lt;/a&gt;, are written up separately.)&lt;/p&gt;

&lt;p&gt;It worked, which created a second problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Too many true findings
&lt;/h2&gt;

&lt;p&gt;On one of our own projects, 82 packages deep, the analysis came back with &lt;strong&gt;1,249&lt;/strong&gt; paths from the code to something dangerous. Every one was a fair finding. None of them told anyone what to do on Monday morning. A list of paths is not an order of work.&lt;/p&gt;

&lt;p&gt;So we built the layer on top. It answers three questions:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;How risky is this project, as one number you can track?&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;When something changes upstream, does it matter to us?&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;What should we fix first, and what will that buy?&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  One graph for every way in
&lt;/h2&gt;

&lt;p&gt;The decided paths become edges in an &lt;strong&gt;attack graph&lt;/strong&gt;: call sites, packages and sinks as nodes. The key change is what counts as an entry point. Not only request handlers. Six kinds go into the same graph, on the same scale:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Entry&lt;/th&gt;
&lt;th&gt;Example&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Input to your code&lt;/td&gt;
&lt;td&gt;a request argument, a CLI argument, an environment variable, stdin&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A package name that does not exist&lt;/td&gt;
&lt;td&gt;an AI agent added a dependency it invented&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A newly registered package&lt;/td&gt;
&lt;td&gt;a name that appeared on the registry days ago&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;An install script&lt;/td&gt;
&lt;td&gt;a package that runs code when it is installed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A tool server's scope&lt;/td&gt;
&lt;td&gt;an MCP server that declares shell or file access&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A published CVE&lt;/td&gt;
&lt;td&gt;weighted by whether it is known to be exploited&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;So "an agent added a dependency that does not exist" and "an exploited advisory is reachable from your upload handler" land in &lt;strong&gt;one ranking&lt;/strong&gt;, not in two tools that never talk to each other. And that is where vulnerability data comes back in: as one kind of entry among six, not the whole picture.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scoring a path, and a project
&lt;/h2&gt;

&lt;p&gt;A path's score multiplies four things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The entry's threat probability.&lt;/strong&gt; A request argument is attacker-controlled by definition; an environment variable mostly is not. A CVE on the known-exploited list counts in full; otherwise its EPSS score is used.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A confidence per hop&lt;/strong&gt;, lowered wherever the engine had to &lt;em&gt;assume&lt;/em&gt; propagation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A risk factor per package&lt;/strong&gt;, from registration age, release history, a tool server's recent scope expansion.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The sink's impact&lt;/strong&gt;: command execution and deserialization at the top; file paths and network requests below; regexes, decompression and global state lower still.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Paths combine into a project score the way independent risks do (noisy-OR).&lt;/p&gt;

&lt;p&gt;The part we care about most is &lt;strong&gt;coverage&lt;/strong&gt;. If half your packages have no summary yet, a low score means "we couldn't see", not "you're safe". So the score comes with the share of the graph that was analysed, and when that share is too low it becomes an &lt;strong&gt;interval&lt;/strong&gt;: decided paths give the lower bound; assuming every undetermined path reaches the worst sink gives the upper.&lt;/p&gt;

&lt;p&gt;Real output, for a one-file Flask app with five pinned dependencies:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ vdb harden app.py --manifest requirements.txt
5 dependencies on the graph · 3 analysed · 2 without a summary yet · taint reached 3
Project risk: 0.65–0.75  (interval — analysis incomplete; 3 of 5 dependencies have a summary, 4 undetermined path(s))
  low        0.32      code_input request &amp;gt; requests@2.34.2 &amp;gt; net_request  in app.py
  high       0.25      code_input param &amp;gt; stdlib &amp;gt; cmd_exec  in app.py
  low        0.16      code_input request &amp;gt; requests@2.34.2 &amp;gt; urllib3@2.7.0 &amp;gt; net_request  in app.py
  low        0.06      cve PYSEC-2026-4175 &amp;gt; urllib3@2.7.0 &amp;gt; net_request  in app.py
  low        0.06      cve PYSEC-2026-4177 &amp;gt; urllib3@2.7.0 &amp;gt; net_request  in app.py
  low        0.06      cve PYSEC-2026-4176 &amp;gt; urllib3@2.7.0 &amp;gt; net_request  in app.py
  undetermined: 4 path(s) end in a package without a summary — most often idna@3.11 (2), certifi@2026.7.22 (2)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A request reaching an HTTP client, a parameter reaching a shell, and three advisories on the pinned urllib3, in one list, on one scale.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keeping it current
&lt;/h2&gt;

&lt;p&gt;The graph is stored and re-scored when an advisory is published, a missing summary gets built, the registry changes, or a tool server's scopes move. Each event re-scores only the paths it touches, and you're mailed only when your project's score actually moved, not for every CVE in every package you include.&lt;/p&gt;

&lt;p&gt;We learned one rule the hard way: an engine upgrade re-decided paths for projects analysed days earlier, the difference looked like new findings, and people got mail about nothing. A new version of &lt;em&gt;our&lt;/em&gt; analysis is not a change in &lt;em&gt;your&lt;/em&gt; code, so it now re-baselines silently.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to fix first
&lt;/h2&gt;

&lt;p&gt;Every candidate fix is &lt;strong&gt;applied to the graph counterfactually&lt;/strong&gt; (upgrade, harden one call site or all that reach a sink, remove a dependency, change execution policy), costed (version jump, broken constraints, call sites to edit), and ranked by risk reduction per cost. The ranking ends with the combination that reaches a target.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ vdb harden app.py --manifest requirements.txt --remediate
Remediation ranking — risk 0.65–0.75, target 0.20
   #  action                                                   Δrisk  cost  value  closes
   1  harden app.py:6                                           0.26  0.20  0.219  2 (worth 0.43)
   2  egress-allowlist on net_request sinks                     0.17  0.40  0.125  0
   3  remove requests                                      0.19–0.26  0.80  0.106  2 (worth 0.43)
   4  harden app.py:9                                           0.12  0.20  0.097  1 (worth 0.25)
   5  upgrade urllib3 2.7.0 → 2.8.0                        0.12–0.16  0.40  0.083  7 (worth 0.31)
  ...
  Combination: #1 → 0.39–0.55, #2 → 0.32–0.51, #4 → 0.09–0.34  (reaches the target; 3 step(s), cost 0.80)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Hardening one request handler leads: one edit closes both paths through &lt;code&gt;requests&lt;/code&gt;. Upgrading urllib3 closes more paths but buys less. Three steps reach the target.&lt;/p&gt;

&lt;h2&gt;
  
  
  Five popular open-source projects
&lt;/h2&gt;

&lt;p&gt;Our own code is not a fair test, so we ran it on &lt;strong&gt;open-webui&lt;/strong&gt;, &lt;strong&gt;AutoGPT's&lt;/strong&gt; platform backend, the &lt;strong&gt;full-stack FastAPI template&lt;/strong&gt;, &lt;strong&gt;paperless-ngx&lt;/strong&gt; and &lt;strong&gt;saleor&lt;/strong&gt;. These measure our analysis, not those projects; we report no vulnerabilities in them.&lt;/p&gt;

&lt;p&gt;Three things broke:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Derived values scored as if an attacker typed them.&lt;/strong&gt; In &lt;code&gt;cmd = "convert " + name&lt;/code&gt;, only explicitly read values had an origin, so &lt;code&gt;cmd&lt;/code&gt; fell back to "attacker input". Now origins flow through assignments, and a value with no trail is weighted at half a request's. The FastAPI template went from &lt;strong&gt;"1.00, saturated" to 0.26–0.52 with one real path&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Every large project scored 1.0.&lt;/strong&gt; Noisy-OR saturates once a few hundred paths are open. So the score now also carries &lt;strong&gt;open paths&lt;/strong&gt;, &lt;strong&gt;risk mass&lt;/strong&gt; (summed path scores: 7.5 for paperless-ngx, 109 for AutoGPT's backend) and the &lt;strong&gt;highest path per sink class&lt;/strong&gt;, and the report leads with those when saturated.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The ranking chased noise.&lt;/strong&gt; Under saturation, rounding-level changes in the score were outranking a candidate that closes &lt;strong&gt;180 paths for one edit&lt;/strong&gt;. Below a noise threshold, candidates are now ranked by the risk mass they close per cost.&lt;/p&gt;

&lt;p&gt;Our own projects taught the rest: ranking 86 candidates over 1,249 paths went from &lt;strong&gt;310 s to 0.4 s&lt;/strong&gt; by re-scoring only touched paths; four of seven projects couldn't be analysed until unpinned requirements became "pin this to unlock analysis"; and two &lt;code&gt;--exclude&lt;/code&gt; patterns, for a backup and a vendored copy, cut 9,336 call sites to 3,952.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the score is not
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Not a probability of being breached.&lt;/strong&gt; It's an ordering: this project against its past, this fix against that one. The weights are written-down judgements, not measurements.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Only as wide as the analysis.&lt;/strong&gt; That's what the coverage figure and the interval are for.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Saturated on large projects.&lt;/strong&gt; Read open paths and risk mass there.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Vulnerability data tells you what is already known about your packages. Following the call is our attempt at the part that will never be known that way, and the score is how a list of those findings becomes a plan.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>security</category>
      <category>vulnerabilities</category>
      <category>supplychain</category>
    </item>
    <item>
      <title>Block bots, not users: rate limiting an open API</title>
      <dc:creator>jj1423</dc:creator>
      <pubDate>Tue, 13 Oct 2026 01:39:15 +0000</pubDate>
      <link>https://dev.to/jj1423/block-bots-not-users-rate-limiting-an-open-api-16p0</link>
      <guid>https://dev.to/jj1423/block-bots-not-users-rate-limiting-an-open-api-16p0</guid>
      <description>&lt;p&gt;If your API can sit behind a login, rate limiting is easy: meter the account, done.&lt;/p&gt;

&lt;p&gt;Ours can't. AI coding agents call it on a first visit, without signing up. Search engines and AI crawlers are supposed to read our pages. And we get scrapers, signup bots and cloud swarms like everyone else. Over four months we blocked the wrong people, let the wrong ones through, and once blocked Googlebot from our own pages.&lt;/p&gt;

&lt;p&gt;These are the eight patterns we ended up with. Each has the code shape and the mistake that taught it.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Meter the identity, fall back to the IP
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The bug:&lt;/strong&gt; our first limit was 60 requests a minute per IP. One AI platform's shared egress crossed it with ordinary traffic, and every user behind that address was blocked at once. Corporate NAT and mobile carriers do the same thing: thousands of people, one IP.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The pattern:&lt;/strong&gt; pick exactly one metering key per request. An API key if there is one, the IP only if there isn't.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;meter_key&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;principal&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;authenticate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;          &lt;span class="c1"&gt;# None for anonymous callers
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;principal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;principal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;     &lt;span class="c1"&gt;# never refused because of its IP
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ip:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;client_ip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details that matter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A keyed caller is never blocked by an IP rule.&lt;/strong&gt; Otherwise a single bad neighbour on a shared egress locks out your paying users.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make a block last its full window.&lt;/strong&gt; If crossing the per-minute limit only refuses until the minute rolls over, a bot learns to send exactly the limit. A block of 10 minutes, an hour, or a day (one per window) doesn't reward that.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  2. Separate reading from computing
&lt;/h2&gt;

&lt;p&gt;Not all routes cost the same, and not all bots are unwelcome. We want crawlers on our pages: that is how people find us, and how AI answers end up citing us. We don't want them running package scans.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The pattern:&lt;/strong&gt; classify routes, then decide per class.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;FEATURE_ROUTES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/v1/query&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/v1/search&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/v1/ai/check-packages&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/v1/sbom/scan&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;bot_gate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;call_next&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;is_bot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user-agent&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&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;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;FEATURE_ROUTES&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;is_internal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;JSONResponse&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;error&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;crawler_not_allowed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="n"&gt;status_code&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;403&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;call_next&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;        &lt;span class="c1"&gt;# bots reading pages: never metered
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;call_next&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The bug that &lt;code&gt;is_internal&lt;/code&gt; fixes:&lt;/strong&gt; our page server renders pages by calling our own API, and forwards the visitor's user agent so analytics see the real client. So when Googlebot loaded a page, the page's own API call was refused as "a crawler calling the API". Browsers got 16,129 bytes; Googlebot got a 6,147-byte empty shell. A &lt;code&gt;site:&lt;/code&gt; search for our domain returned one page.&lt;/p&gt;

&lt;p&gt;The exemption is for calls authenticated with an internal service token, not for anything a user can send. A user's API key doesn't buy it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check this in yours:&lt;/strong&gt; if you block by user agent, grep for every place your own infrastructure forwards one.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Classify bots precisely, or your dashboard lies
&lt;/h2&gt;

&lt;p&gt;You can't decide what to block without knowing what you're looking at.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The bug:&lt;/strong&gt; one SEO crawler wasn't on our bot list, so it was counted as a &lt;strong&gt;browser&lt;/strong&gt;. A four-hour burst of 11,527 requests from a single IP looked like a surge of human visitors.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The pattern:&lt;/strong&gt; a list of known bots for labelling, plus a catch-all so nothing unlisted falls into "human".&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;

&lt;span class="n"&gt;AI_CRAWLERS&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;gptbot&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;claudebot&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;perplexitybot&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ccbot&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;SEARCH_BOTS&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;googlebot&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bingbot&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;duckduckbot&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;CATCH_ALL&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;(?&amp;lt;!cu)bot\b|crawler|spider|scraper|headlesschrome&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;I&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;classify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ua&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;u&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ua&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;any&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;AI_CRAWLERS&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ai-crawler&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;any&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;SEARCH_BOTS&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;search-bot&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;CATCH_ALL&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;               &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;other-bot&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;browser&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;(?&amp;lt;!cu)&lt;/code&gt; is real: a phone brand, CUBOT, puts "cubot" in its browser's user agent, and &lt;code&gt;bot\b&lt;/code&gt; would have counted those people as bots.&lt;/p&gt;

&lt;p&gt;Split AI crawlers from SEO bots on purpose. An AI company indexing you is a signal worth seeing; most SEO crawlers are noise.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Use robots.txt for noise, not for defence
&lt;/h2&gt;

&lt;p&gt;Some crawlers cost you and send nobody back. On one day a single SEO crawler made &lt;strong&gt;11,548 of about 14,400&lt;/strong&gt; requests to our site.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;robots.txt&lt;/code&gt; is the right first response: it's free, and well-behaved crawlers obey it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User-agent: SomeSeoBot
Disallow: /

User-agent: *
Allow: /
Disallow: /account
Crawl-delay: 1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It's not a security control. It only stops bots that choose to read it, which is why pattern 2 doesn't rely on it. Use it to cut cost, and check the next day's traffic to confirm it worked.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Group cloud swarms by who owns the network
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The bug:&lt;/strong&gt; roughly a thousand cloud IPs, each making only a handful of calls. Every one stayed far below its own limit. Together they were most of our anonymous load.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The pattern:&lt;/strong&gt; for &lt;strong&gt;anonymous&lt;/strong&gt; traffic from datacenter ranges only, add a second meter keyed by the network owner (the organisation or ASN that announces the IP), with a daily ceiling.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;anon_meter_keys&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="n"&gt;keys&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ip:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;ip&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;info&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;ip_info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                         &lt;span class="c1"&gt;# your GeoIP/ASN source, cached
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;info&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;is_hosting&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;                        &lt;span class="c1"&gt;# cloud/datacenter, not home or mobile
&lt;/span&gt;        &lt;span class="n"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;org:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;info&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;org&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;         &lt;span class="c1"&gt;# one budget for the whole provider
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;keys&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keep it narrow: home and mobile connections don't get the org meter, and keyed callers never do. And make it a &lt;strong&gt;soft&lt;/strong&gt; cap that refuses for the rest of the day, without placing a block that outlives the burst.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. The signup form is your biggest door
&lt;/h2&gt;

&lt;p&gt;You can tune API limits for days while the form that hands out an unlimited key has none. Ours did. What came through:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Six accounts in six hours, each a real person's Gmail address with dots scattered through it. &lt;strong&gt;Gmail ignores dots&lt;/strong&gt;, so &lt;code&gt;j.o.h.n@gmail.com&lt;/code&gt; is &lt;code&gt;john@gmail.com&lt;/code&gt;: six strings, six strangers' inboxes from a harvested list.&lt;/li&gt;
&lt;li&gt;Nineteen accounts over a day, rotating mail providers. Zero API calls.&lt;/li&gt;
&lt;li&gt;Later, 136 sign-ups from 118 IPs. &lt;strong&gt;Zero verified.&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last line is the real lesson. The bots weren't using the keys. The harm was &lt;strong&gt;verification mail sent to people who never asked&lt;/strong&gt;. A few spam reports on a young sending domain, and your mail stops arriving for the people who did sign up.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Patterns:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;PLUS_TAG&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;gmail.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;googlemail.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;outlook.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;hotmail.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;yahoo.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;icloud.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;fastmail.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;proton.me&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;DOTLESS&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;gmail.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;googlemail.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;canonical_email&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;addr&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;For uniqueness checks only. Always mail what the person typed.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;local&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;domain&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;addr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;rpartition&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;@&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;domain&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;gmail.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;domain&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;googlemail.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="n"&gt;domain&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;domain&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;PLUS_TAG&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;local&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;+&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="mi"&gt;0&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;domain&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;DOTLESS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;local&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;local&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;        &lt;span class="c1"&gt;# dots are only meaningless at Gmail
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;local&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;@&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;domain&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Store the canonical form in its own column with a UNIQUE index.&lt;/strong&gt; Don't fold dots globally: at Outlook, &lt;code&gt;first.last&lt;/code&gt; and &lt;code&gt;firstlast&lt;/code&gt; are two different people.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Give signup the same per-IP budget as any other way to get a key.&lt;/strong&gt; Ours had one on "request a key by email" and none on the form next to it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cap verification mail service-wide&lt;/strong&gt;, so no burst can turn your domain into someone's mailer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verify with a button, not a link.&lt;/strong&gt; Corporate mail scanners open every link before the person does, so a GET link "verifies" accounts nobody looked at. A POST from a page with a button doesn't fire on a scan.&lt;/li&gt;
&lt;li&gt;Add cheap friction to the form, and be honest about it: it raises the cost, but a patient headless browser still passes. The mail cap is what limits the harm.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  7. Make every 429 actionable
&lt;/h2&gt;

&lt;p&gt;A bare &lt;code&gt;429 Too Many Requests&lt;/code&gt; makes a human wait and an agent retry, guess, or skip the check. For a security API, "carry on without checking" is the worst possible outcome.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The pattern:&lt;/strong&gt; say why, until when, and what to do, in fields a program can read.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"error"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"rate_limited"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"axis"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ip"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"expires_at"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-10-13T09:10:00Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"agent_action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"REFUSE"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"because"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Anonymous hourly budget used up. Proceeding unchecked is not safe."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"next_step"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Ask the user for an email address and request a free key."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"signup_url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://example.com/signup"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Plus a &lt;code&gt;Retry-After&lt;/code&gt; header. If your callers include AI agents, add one explicit line: do not invent a key, do not proceed without the check.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;And fail open on your own outages.&lt;/strong&gt; If the rate-limit store (Redis, for us) is down, allow the request. Under-blocking for five minutes beats refusing every legitimate user because a cache restarted.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;check&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;redis_window_ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;RedisError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;     &lt;span class="c1"&gt;# our outage is not the caller's fault
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  8. Measure before you block, and after
&lt;/h2&gt;

&lt;p&gt;Most of our bad calls came from numbers that were quietly wrong:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Don't audit blocked requests as normal traffic.&lt;/strong&gt; We did, so a bot hammering a block kept the 429 count climbing, and the block looked like it wasn't working.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make every report count the same way.&lt;/strong&gt; Our daily digest and our dashboard disagreed on the day one crawler made 80% of requests.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Know which limits are live.&lt;/strong&gt; Our code defaults said one thing; a deployment config overrode them, and production ran at 10 requests a minute per anonymous IP while our docs promised much more.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Measure what closing a door costs.&lt;/strong&gt; We once put every endpoint behind a key. Seven days later: one new account, twelve calls. The users we built for, agents evaluating a tool on a first visit, left at the signup page. We reopened two endpoints with a per-IP quota, counted in the unit that actually costs us (packages checked, not requests, after a 1,168-package run died at request 20).&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;[ ] One metering key per request: account if keyed, IP if not.&lt;/li&gt;
&lt;li&gt;[ ] Blocks last their whole window.&lt;/li&gt;
&lt;li&gt;[ ] Routes split into &lt;em&gt;read&lt;/em&gt; and &lt;em&gt;compute&lt;/em&gt;; bots allowed on one, refused on the other.&lt;/li&gt;
&lt;li&gt;[ ] Your own servers exempted by an internal token, never by user agent.&lt;/li&gt;
&lt;li&gt;[ ] Bot classification with a catch-all, AI crawlers labelled apart from SEO bots.&lt;/li&gt;
&lt;li&gt;[ ] &lt;code&gt;robots.txt&lt;/code&gt; for costly crawlers, verified in the next day's traffic.&lt;/li&gt;
&lt;li&gt;[ ] Anonymous datacenter traffic also metered per network owner.&lt;/li&gt;
&lt;li&gt;[ ] Signup with an IP budget, canonical-email uniqueness, a service-wide mail cap, button verification.&lt;/li&gt;
&lt;li&gt;[ ] Every 429 says why, until when, and what to do next.&lt;/li&gt;
&lt;li&gt;[ ] Fail open when your own limiter is down.&lt;/li&gt;
&lt;li&gt;[ ] Blocked requests counted once, every report counting the same way, documented limits equal to deployed ones.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this makes an open API bot-proof. It makes the bots cost more than they're worth, while the people and agents you built it for get through.&lt;/p&gt;

</description>
      <category>beginners</category>
      <category>devops</category>
      <category>seo</category>
      <category>performance</category>
    </item>
    <item>
      <title>We opened 1,332 AI-built repositories. Five of their dependencies do not exist, and anyone can register them.</title>
      <dc:creator>jj1423</dc:creator>
      <pubDate>Thu, 08 Oct 2026 11:38:37 +0000</pubDate>
      <link>https://dev.to/jj1423/we-opened-1332-ai-built-repositories-five-of-their-dependencies-do-not-exist-and-anyone-can-2of3</link>
      <guid>https://dev.to/jj1423/we-opened-1332-ai-built-repositories-five-of-their-dependencies-do-not-exist-and-anyone-can-2of3</guid>
      <description>&lt;p&gt;Most of what is written about slopsquatting measures the model: ask it for code, count how often it names a package that does not exist. We wanted the other end of the pipeline. Of the code people actually built with AI tools and published, how much still depends on a package that is not there?&lt;/p&gt;

&lt;p&gt;A dependency that does not exist is not just a broken install. Its name is free, and whoever registers it first decides what that install runs from then on.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we looked at
&lt;/h2&gt;

&lt;p&gt;Two samples, both taken on 2026-10-03, both with a control group.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Popular projects.&lt;/strong&gt; Every GitHub repository linked from a Show HN post since January 2025 with more than 20 points: 673 reachable repositories.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Recent, unpopular projects.&lt;/strong&gt; Repositories created since January 2025 with at most 50 stars, found by the files AI tools leave behind: a Lovable build marker, Bolt's project prompt, a &lt;code&gt;CLAUDE.md&lt;/code&gt;, a &lt;code&gt;.cursorrules&lt;/code&gt; file. The control group is repositories created in the same period with no such trace.&lt;/p&gt;

&lt;p&gt;A repository counts as AI-built if it carries any of these: an agent instruction file (&lt;code&gt;CLAUDE.md&lt;/code&gt;, &lt;code&gt;AGENTS.md&lt;/code&gt;, &lt;code&gt;.cursorrules&lt;/code&gt; and their relatives), commits signed by an AI tool (&lt;code&gt;Co-Authored-By: Claude&lt;/code&gt;, &lt;code&gt;Co-authored-by: Copilot&lt;/code&gt; and others), a Lovable or Bolt marker, or the author saying so in the Show HN post.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;AI-built&lt;/th&gt;
&lt;th&gt;Control&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Popular (Show HN)&lt;/td&gt;
&lt;td&gt;412 repositories, 37,158 dependency entries&lt;/td&gt;
&lt;td&gt;261 repositories, 7,655 entries&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Recent, ≤ 50 stars&lt;/td&gt;
&lt;td&gt;920 repositories, 22,966 dependencies added beyond the platform template&lt;/td&gt;
&lt;td&gt;303 repositories, 6,494 entries&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every dependency in every &lt;code&gt;package.json&lt;/code&gt;, &lt;code&gt;requirements.txt&lt;/code&gt;, &lt;code&gt;pyproject.toml&lt;/code&gt;, &lt;code&gt;Cargo.toml&lt;/code&gt; and &lt;code&gt;go.mod&lt;/code&gt; was looked up live in npm, PyPI, crates.io and the Go module proxy — 8,240 distinct names in the first sample and 6,565 in the second.&lt;/p&gt;

&lt;p&gt;A side note on the control group: of 600 ordinary repositories we drew from the same period, 297 (49.5%) already carried an AI tool's trace and had to be moved out. Half of new code on GitHub is touched by these tools now.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we found
&lt;/h2&gt;

&lt;p&gt;A name that is missing from the registry is usually not a phantom. Most were packages that live inside the same repository, private packages under a company scope, packages from a different registry (Deno's JSR, Unity's), or placeholder names in examples. We removed each of those by hand. What was left:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;AI-built&lt;/th&gt;
&lt;th&gt;Control&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Popular&lt;/td&gt;
&lt;td&gt;1 repository, 1 name&lt;/td&gt;
&lt;td&gt;1 repository, 1 name&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Recent, ≤ 50 stars&lt;/td&gt;
&lt;td&gt;2 repositories, 4 names&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Names anyone can register today&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;5&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The five, without the repositories they are in:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Three npm packages in an app built with Bolt&lt;/strong&gt;, each written as &lt;code&gt;"name": "^1.0.0"&lt;/code&gt;. None of the three has ever existed on npm, and the app's code imports one of them. The repository's lockfile does not contain them: the install that would have failed was never run, so nothing caught them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A Python package pinned to &lt;code&gt;==5.3.0&lt;/code&gt; in the backend of an app built with Lovable.&lt;/strong&gt; The package has never existed, so neither has version 5.3.0. The code imports a hardware library by its module name, and the invented package name is a plausible guess at the real one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A Python package listed in an example's &lt;code&gt;requirements.txt&lt;/code&gt; in a popular project.&lt;/strong&gt; Nothing in the example imports it. It has the name of the application the example is about.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The control group's one is the same shape as the last: a ROS package that is installed with the system package manager, never from PyPI, listed in &lt;code&gt;requirements.txt&lt;/code&gt; with an exact version.&lt;/p&gt;

&lt;p&gt;Two more were invented but cannot be taken: a &lt;code&gt;@types/&lt;/code&gt; package (only the DefinitelyTyped maintainers can publish under that scope) and a standard-library module (PyPI refuses those names).&lt;/p&gt;

&lt;h2&gt;
  
  
  What the numbers do and do not say
&lt;/h2&gt;

&lt;p&gt;They say phantom dependencies are &lt;strong&gt;rare&lt;/strong&gt; in published code — about one AI-built repository in four hundred — and that they &lt;strong&gt;exist&lt;/strong&gt;, with version numbers attached, in code people put online.&lt;/p&gt;

&lt;p&gt;They do not say AI-built code is worse than other code. Two repositories against none in the unpopular sample and one against one in the popular one are counts too small to compare. The samples come from search rankings, not a random draw.&lt;/p&gt;

&lt;h2&gt;
  
  
  The repository is the end of the process
&lt;/h2&gt;

&lt;p&gt;A published repository is a finished product. Every phantom package that broke a build on the way there was most likely noticed and removed before anyone pushed: the author ran the app, the install failed, the name went. What we found is what survived, and every case we found survived for the same reason — it sat where nobody ran an install. An example folder. A backend nobody deployed. An app whose lockfile shows its install was never completed.&lt;/p&gt;

&lt;p&gt;So these counts are a floor, not a rate. We did not measure what happened before the commit, but two things point the same way. When we put ordinary coding tasks to Claude, GPT and Gemini, they recommended &lt;a href="https://vdb.ai.kr/blog/llm-recommended-packages-that-do-not-exist" rel="noopener noreferrer"&gt;87 package names that do not exist&lt;/a&gt;. Academic work that measures the same thing at generation time (&lt;a href="https://arxiv.org/abs/2406.10279" rel="noopener noreferrer"&gt;Spracklen et al., 2024&lt;/a&gt;) found invented package names in a substantial share of generated code, more for open models than for commercial ones. Between what the model writes and what gets pushed, someone has to catch the rest — usually by running the install and watching it fail.&lt;/p&gt;

&lt;p&gt;That makes the dangerous moment the one before the failure. The install fails &lt;em&gt;because&lt;/em&gt; nobody has registered the name yet. The day someone does, the same command succeeds, quietly, and runs their code. A phantom name in a published repository is a standing invitation. The three in the Bolt app have been one since March 2025.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checking at the moment of adding
&lt;/h2&gt;

&lt;p&gt;The place to stop a phantom package is when the agent adds it — before the install that fails today and succeeds once the name is taken. All five names above, sent to VDB's package gate, come back &lt;code&gt;REFUSE&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;POST /v1/ai/check-packages
{"packages": ["pkg:pypi/&amp;lt;one of the five&amp;gt;"]}

"agent_action": "REFUSE"
"because":      "this name does not exist on the registry — likely hallucinated, and attackers register such names"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The gate answers up to five packages without a key; see &lt;a href="https://vdb.ai.kr/connect" rel="noopener noreferrer"&gt;Connect&lt;/a&gt; to put it in front of an agent.&lt;/p&gt;

&lt;h2&gt;
  
  
  How we did it
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Candidates:&lt;/strong&gt; Show HN through the Hacker News search API; recent repositories through GitHub search, by the files each tool leaves.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AI traces:&lt;/strong&gt; read from a shallow clone of each repository — the file tree and the last 200 commit messages. No code was run.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dependencies:&lt;/strong&gt; parsed from the manifests, leaving out workspace, file, git and URL specifiers, aliases (resolved to the real name), the repository's own packages, and scopes its &lt;code&gt;.npmrc&lt;/code&gt; sends to a private registry. For Lovable and Bolt, dependencies present in at least half of that platform's repositories count as the template and were left out.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lookups:&lt;/strong&gt; live, against each registry, with the probe VDB's gate uses.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Every flagged name was checked by hand,&lt;/strong&gt; including the lockfile and the code that would import it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We told the maintainers of the four repositories involved on 2026-10-03, before this post, and none is named here. We did not register any of the names ourselves.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>vulnerabilities</category>
      <category>supplychain</category>
      <category>security</category>
    </item>
    <item>
      <title>Beyond the package name: why following the call is the hard part of supply-chain security</title>
      <dc:creator>jj1423</dc:creator>
      <pubDate>Mon, 05 Oct 2026 09:23:34 +0000</pubDate>
      <link>https://dev.to/jj1423/beyond-the-package-name-we-checked-the-code-behind-our-dependencies-and-fixed-what-it-found-441c</link>
      <guid>https://dev.to/jj1423/beyond-the-package-name-we-checked-the-code-behind-our-dependencies-and-fixed-what-it-found-441c</guid>
      <description>&lt;p&gt;Most supply-chain security checks the &lt;strong&gt;package&lt;/strong&gt;: does it exist, is it new, is there a CVE against this version. Those checks answer &lt;em&gt;is this safe to install?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;They can't answer &lt;em&gt;is the way my code uses it safe?&lt;/em&gt; A clean library can still be where your service breaks: a request URL passed to an HTTP client, an uploaded archive handed to a parser, a user path joined into a file operation. Nothing is wrong with the library, so no advisory will ever be filed, and every package-level check will keep saying "safe".&lt;/p&gt;

&lt;p&gt;The obvious answer is to follow the data: from where input enters your code, through the call into the dependency, down to wherever it lands. We have been trying to do that for Python. It is much harder than it sounds. These are the problems that actually bit.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Who controls this argument?
&lt;/h2&gt;

&lt;p&gt;Everything depends on knowing whether a value comes from outside. Statically, you mostly can't. The safe assumption, &lt;em&gt;every function parameter might be attacker-controlled&lt;/em&gt;, turns your own configuration into "input": we had a storage client built from environment variables flagged as nine dangerous paths, twice.&lt;/p&gt;

&lt;p&gt;It is worse for libraries, where every public parameter &lt;em&gt;is&lt;/em&gt; outside input by definition. On ten popular open-source projects, more than half the paths we reported rested on that assumption rather than on a traced flow.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. The direction of the danger matters
&lt;/h2&gt;

&lt;p&gt;httpx validates URLs with regular expressions it wrote itself. A naive rule ("user input reaches a regex") flagged every httpx call we make, including one where the argument was a timeout in seconds. In our first run on our own service, 25 of 39 reported paths were this one mistake.&lt;/p&gt;

&lt;p&gt;A regex is dangerous when the attacker writes the &lt;em&gt;pattern&lt;/em&gt;, not when their string is matched against yours. Every sink needs rules like that: which argument, which direction, which mode. &lt;code&gt;ZipFile(upload)&lt;/code&gt; reading is a decompression risk; &lt;code&gt;ZipFile(target, "w")&lt;/code&gt; writing is not.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Right place, wrong reason
&lt;/h2&gt;

&lt;p&gt;Our one real finding was a spreadsheet upload that could expand to two million rows in memory. The analysis pointed at the correct line and described it as a file-path operation, a regex and global state. None of those was the problem. The real category, &lt;em&gt;decompression amplification&lt;/em&gt;, didn't exist in our sink list until we went looking for it.&lt;/p&gt;

&lt;p&gt;Pointing at the right line is not the same as understanding it, and a wrong explanation sends a reviewer the wrong way.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Summarising every package, at every version
&lt;/h2&gt;

&lt;p&gt;To follow an argument into a dependency you need to know where that package sends it, for the exact version in the lockfile. Python makes that hard: calls on objects, dynamic dispatch, thin wrapper modules, native code that can't be read at all. Every change to the summariser means rebuilding summaries for every version of every package, and results shift when it does.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. The standard library is not a dependency
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;subprocess.run(cmd)&lt;/code&gt;, &lt;code&gt;eval&lt;/code&gt;, &lt;code&gt;pickle.loads&lt;/code&gt; are the most dangerous calls in Python, and they aren't in your lockfile. An analysis built on the dependency graph skips them unless it handles them separately. Then the noise problem from #1 returns: on 39 real releases, path and regex calls produced 140 of 283 standard-library findings, for the lowest-severity class.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Paths explode
&lt;/h2&gt;

&lt;p&gt;A tainted value can reach a sink along many routes through a dependency tree. Without a budget the analysis never finishes; with one, it stops early on exactly the large projects where it matters. A fixed budget cut eight of twenty real cases short before they reached the answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. What counts as "right"?
&lt;/h2&gt;

&lt;p&gt;Our first benchmark, calls we wrote from library docs, scored 21 of 21. That measured nothing. The useful test is real advisories, where an application really misused a dependency and someone filed a report: would the analysis have pointed at that line, for that reason? Against twenty of those, it does about half the time. Two of our own benchmark cases turned out to have the wrong answer key.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where that leaves it
&lt;/h2&gt;

&lt;p&gt;Following the call is where supply-chain security has to go, because it is the only way to see risk that no advisory will ever describe. But today it is a way to decide &lt;strong&gt;where to look&lt;/strong&gt;, not a verdict. The hard parts are the ones above: knowing who controls a value, knowing which direction is dangerous, and measuring against real misuse rather than examples written to pass.&lt;/p&gt;

&lt;p&gt;If you have worked on any of these, especially telling outside input from configuration statically, I would like to hear how you approached it.&lt;/p&gt;

</description>
      <category>vulnerabilities</category>
      <category>security</category>
      <category>supplychain</category>
      <category>ai</category>
    </item>
    <item>
      <title>Our slopsquatting detector had three ways to say "safe". None of them needed evidence.</title>
      <dc:creator>jj1423</dc:creator>
      <pubDate>Fri, 02 Oct 2026 03:57:33 +0000</pubDate>
      <link>https://dev.to/jj1423/our-slopsquatting-detector-had-three-ways-to-say-safe-none-of-them-needed-evidence-3ll6</link>
      <guid>https://dev.to/jj1423/our-slopsquatting-detector-had-three-ways-to-say-safe-none-of-them-needed-evidence-3ll6</guid>
      <description>&lt;p&gt;Ask a language model which package to use and, now and then, it names one that does not exist. If someone registers that name first, the next developer — or the next coding agent — who follows the same suggestion installs whatever they put there. That is slopsquatting.&lt;/p&gt;

&lt;p&gt;We collect those names and score each one. A name that returns 404 is easy: it is a target. The hard case is a name that &lt;strong&gt;does&lt;/strong&gt; resolve. Is it the project the model meant, or someone who registered the name after models started suggesting it?&lt;/p&gt;

&lt;p&gt;Our answer starts with age:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;age_days&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;info&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;days&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;age_days&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;14&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;high&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;age_days&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;medium&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;low&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Crude, but honest. A package that appeared last week and matches a name models keep inventing deserves suspicion.&lt;/p&gt;

&lt;p&gt;The trouble was everything we put &lt;strong&gt;above&lt;/strong&gt; that ladder. Over the past few weeks we had added exceptions — rules that let a young package off — each one to fix a specific false positive. This week a reader went through them in a comment, and by the end of the next day three were gone.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. "It has a signed build statement"
&lt;/h2&gt;

&lt;p&gt;npm provenance and PyPI's PEP 740 attestations record that a package was built by a named workflow in a named repository, through a Trusted Publisher flow. The registry produces the statement at publish time, so the package cannot just claim it in its manifest. That felt like strong evidence, so we added:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;prov&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;prov&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;attested&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;prov&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;repository&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;low&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;built from &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;prov&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;repository&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; with a signed build statement&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read what the statement actually says. It binds the artifact to &lt;strong&gt;a&lt;/strong&gt; repository. It says nothing about &lt;strong&gt;whose&lt;/strong&gt; repository. Anyone can create a GitHub repo, configure a Trusted Publisher for it, and publish a squatted name with a perfectly valid attestation. It takes minutes.&lt;/p&gt;

&lt;p&gt;So the rule did not lower the risk of an attack. It cleared exactly the package an attacker would publish, as long as they spent five extra minutes on CI.&lt;/p&gt;

&lt;p&gt;The reader put it more generally, from a different system they work on: declarations pool where they are cheap. In a log they scan, 23 of 31 signing keys had zero accepted work and zero deliveries — and emitted 91% of all capability declarations. A signed statement that costs nothing to obtain tells you that someone obtained it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Now:&lt;/strong&gt; provenance is still recorded and published next to each finding, for a reader to weigh. The classifier does not take it as an input at all — not ignored, removed from the signature — so nobody can wire it back in by accident.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. "It was registered before anyone suggested it"
&lt;/h2&gt;

&lt;p&gt;This one came from a real false positive. &lt;code&gt;cdx-rs&lt;/code&gt; is a &lt;code&gt;cd&lt;/code&gt; replacement, published on 30 August. On 4 September a model offered &lt;code&gt;cdx-rs&lt;/code&gt; as a library for parsing CycloneDX SBOMs, which it is not. Five days old: high risk. But the package predated the suggestion, so it could not be a response to it. We added:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;first_hallucinated&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;info&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;first_hallucinated&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;low&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;registered before the name was ever suggested&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;first_hallucinated&lt;/code&gt; is the earliest time &lt;strong&gt;we&lt;/strong&gt; saw a model produce the name. That is not when the name was first suggested. It is when our sampling first happened to catch it. Models had been producing these names long before we started collecting; we only know about the ones that came up after.&lt;/p&gt;

&lt;p&gt;So near the day our collection began, the rule read: &lt;em&gt;registered before we started looking&lt;/em&gt;. Someone who registered a name early and waited would have cleared through it. The comparison only means something when both dates are pinned by something outside the person making the claim — and one of ours was pinned by us.&lt;/p&gt;

&lt;p&gt;The reader also spotted something worse about the shape of it. If &lt;code&gt;first_hallucinated&lt;/code&gt; was missing, the check was skipped and the package fell through to the age ladder, where it could score high. If it was present and early, the package scored low immediately. Two kinds of not-knowing, two opposite verdicts, and no branch anywhere that said "we don't know".&lt;/p&gt;

&lt;p&gt;Is there an independent date? The best I found is the model's training cutoff. The vendor sets it, and our sampling cannot move it. A package registered after every recommending model's cutoff could not have been learned from — the name was invented first and registered later, whenever we happened to look. But every model we sample has a cutoff older than our 60-day window, so a package that predates one is already old enough to clear by age. The rule had nothing honest left to do.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Now:&lt;/strong&gt; age alone decides. That has a cost: &lt;code&gt;cdx-rs&lt;/code&gt; is flagged again. We cannot tell a coincidence from a squat by its registration date, and a list of attack targets should fail toward caution.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. "We don't know when it was registered"
&lt;/h2&gt;

&lt;p&gt;This one the reader did not mention. Their framing found it. Once you start looking for safe verdicts that rest on absence of evidence, you look at every branch that returns &lt;code&gt;low&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;info&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;low&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;registered&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A package that exists but has no creation date: fine. When does that happen? For every Go module, it turned out. The Go module proxy answers one question — does this module exist — and our probe asked it nothing else. Every Go module a model recommended, including one pushed yesterday, was cleared without a date ever being checked.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Now:&lt;/strong&gt; an undated package scores medium. Go modules are dated from &lt;a href="https://deps.dev" rel="noopener noreferrer"&gt;deps.dev&lt;/a&gt;, which lists every version the proxy has served with its time.&lt;/p&gt;

&lt;p&gt;That fix comes with a caveat. For Go, that time is the &lt;strong&gt;commit's&lt;/strong&gt; time, and a commit's date is whatever its author wrote. npm and PyPI record when the registry received an upload, which the publisher cannot choose. A backdated Go module still looks old. It is weaker evidence than the other ecosystems — and better than none, which is what we had.&lt;/p&gt;

&lt;h2&gt;
  
  
  The audit we couldn't run
&lt;/h2&gt;

&lt;p&gt;The reader suggested a cheap check: take every name rule 2 cleared, plot its registration date against the day our collection began, and see whether they cluster at the boundary. If they do, the rule is reading its own start date.&lt;/p&gt;

&lt;p&gt;We couldn't. A finding is stored. A name that scored &lt;code&gt;low&lt;/code&gt; was simply dropped. The rule had cleared names and left nothing behind to audit.&lt;/p&gt;

&lt;p&gt;Now every &lt;code&gt;low&lt;/code&gt; verdict is recorded with the rule that produced it, the registration date and our first sighting. It is never published; it is there so the next rule we get wrong can be caught by looking rather than by a stranger reading our source. We'll run the reader's check once a few weeks of data have built up.&lt;/p&gt;

&lt;p&gt;Findings that had been withdrawn under rules 1 and 2 were re-scored under the new rules. Two came back onto the list.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd ask of any risk score
&lt;/h2&gt;

&lt;p&gt;Every rule we've had to remove was one that cleared something. The rules that flag things are easy to defend: a 404, a package two days old. The exceptions are where the reasoning goes soft, because each was written to stop one false positive, by someone looking at that false positive and not at an attacker.&lt;/p&gt;

&lt;p&gt;So, for each branch in your scoring that returns "safe":&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Can an attacker produce this input?&lt;/strong&gt; A signed build, a README, a repository field, a star count.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Does it describe the world, or only you?&lt;/strong&gt; Your first sighting, your crawl date, your cache.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What happens when it is missing?&lt;/strong&gt; If "unknown" can come out as "safe", it will, at scale, for a whole ecosystem.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The rule that says "this one is fine" is the one an attacker reads.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Thanks to ANP2 Network for the comment that started this. If you can find a fourth, the comments are open.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>cybersecurity</category>
      <category>llm</category>
      <category>security</category>
    </item>
    <item>
      <title>A bot took six API keys in six hours. Gmail helped</title>
      <dc:creator>jj1423</dc:creator>
      <pubDate>Fri, 02 Oct 2026 01:59:40 +0000</pubDate>
      <link>https://dev.to/jj1423/a-bot-took-six-api-keys-in-six-hours-gmail-helped-2cnp</link>
      <guid>https://dev.to/jj1423/a-bot-took-six-api-keys-in-six-hours-gmail-helped-2cnp</guid>
      <description>&lt;p&gt;Six accounts signed up overnight. These were the addresses:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;kan.eis.hqmc.g.uir.e@gmail.com
cat.o.po.d.i.s.paulo@gmail.com
ma.t.t.h.ewpc.on.no.lly@gmail.com
r.ea.dy.2.d.an.cewi@gmail.com
wi.ll.iam...su.ll.iv.a.n@gmail.com
jes.sewsha.w@gmail.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you have run a signup form before, you already know. &lt;strong&gt;Gmail ignores dots in the local part.&lt;/strong&gt; &lt;code&gt;j.o.h.n@gmail.com&lt;/code&gt; and &lt;code&gt;john@gmail.com&lt;/code&gt; are the same inbox. It is documented behaviour, not a bug — it exists because Gmail launched in a world where people typed addresses off business cards.&lt;/p&gt;

&lt;p&gt;My uniqueness check was &lt;code&gt;WHERE lower(email) = ...&lt;/code&gt;. Six strings, six rows, six API keys handed out.&lt;/p&gt;

&lt;h2&gt;
  
  
  Whose inboxes were they
&lt;/h2&gt;

&lt;p&gt;My first thought was that someone had taken one address and mutated it six ways to farm keys off me. Then I stripped the dots:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;kaneishqmcguire@gmail.com
catopodispaulo@gmail.com
matthewpconnolly@gmail.com
ready2dancewi@gmail.com
williamsullivan@gmail.com
jessewshaw@gmail.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Six different people. Plausible full names. This is a &lt;strong&gt;harvested address list with dot-mutation applied&lt;/strong&gt;, and the mutation is not aimed at my duplicate check at all — it is a generic evasion habit, applied to a list, by something that signs up for things.&lt;/p&gt;

&lt;p&gt;Which means the verification emails went to six real strangers who have never heard of us. That is the part I care about more than the keys. A handful of "report spam" clicks on a young sending domain and your mail stops reaching the people who &lt;em&gt;did&lt;/em&gt; ask for it — silently, and exactly when you start getting real users.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hole one: the cheap door was the unguarded one
&lt;/h2&gt;

&lt;p&gt;Some context, because it is the part that stung. The day before, we had opened part of our API — a package-security lookup — to callers with no key at all. Twenty requests an hour per address, five packages per request. We spent real time on those numbers: enough to try the thing, not enough to run on it. What a caller sees when they run out. How they learn the limit from the response rather than from docs they have not read.&lt;/p&gt;

&lt;p&gt;An API key skips all of it.&lt;/p&gt;

&lt;p&gt;And we have two ways to get one.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;POST /v1/auth/request-key&lt;/code&gt; takes an email, mails the key, and has had a per-address daily budget since the day it was written — five. The comment above it literally says it bounds key-farming.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;POST /v1/auth/signup&lt;/code&gt; takes an email and a password and had nothing.&lt;/p&gt;

&lt;p&gt;And signup is the cheaper door, because email verification is off by default in our deployment. Sign up, get a working key in the response. No inbox needed.&lt;/p&gt;

&lt;p&gt;So the endpoint with "this bounds key-farming" in its comment was guarded, and the one next to it — doing the same thing, faster — was not. The budget function already existed. It was four lines to call it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;allowed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_used&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_request_key_throttle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;client_ip&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;allowed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;HTTPException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;429&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I don't think this is unusual. You write the dangerous-looking endpoint carefully because it looks dangerous, and the ordinary one stays ordinary.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hole two: and here is where the obvious fix is wrong
&lt;/h2&gt;

&lt;p&gt;The tempting fix for the Gmail thing is to normalise every address: strip dots, strip anything after &lt;code&gt;+&lt;/code&gt;, compare that.&lt;/p&gt;

&lt;p&gt;Don't. &lt;strong&gt;Dots are only insignificant at Gmail.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;first.last@outlook.com&lt;/code&gt; and &lt;code&gt;firstlast@outlook.com&lt;/code&gt; are two different mailboxes, very possibly two colleagues. Fold dots globally and you will eventually tell a real person their account already exists, and you will not find out, because people who hit that just leave.&lt;/p&gt;

&lt;p&gt;Plus-tags are safer but still not universal — &lt;code&gt;+&lt;/code&gt; is a legal character in a local part, and only some providers route on it. So the rule has to be per-provider:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;_PLUS_TAG_DOMAINS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;frozenset&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;gmail.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;googlemail.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;outlook.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;hotmail.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;live.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;yahoo.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;icloud.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;me.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;fastmail.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;proton.me&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;protonmail.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="n"&gt;_DOTLESS_DOMAINS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;frozenset&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;gmail.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;googlemail.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;canonical_email&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;addr&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;local&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;domain&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;addr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;rpartition&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;@&lt;/span&gt;&lt;span class="sh"&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;domain&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;googlemail.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;domain&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;gmail.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;domain&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;_PLUS_TAG_DOMAINS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;local&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;+&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="mi"&gt;0&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;domain&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;_DOTLESS_DOMAINS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;local&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;local&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;local&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;@&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;domain&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details that matter more than the function.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Store it, don't compute it at query time.&lt;/strong&gt; A new column, a unique index on it, and the original address kept exactly as typed — because mail goes to what the person wrote, and &lt;code&gt;canonical_email&lt;/code&gt; is only ever allowed to answer "is this the same mailbox".&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Make the index &lt;code&gt;UNIQUE&lt;/code&gt; and let the migration fail.&lt;/strong&gt; If it won't build on your live database, two of your existing accounts share an inbox, and you want to see that rather than have a &lt;code&gt;WHERE NOT EXISTS&lt;/code&gt; quietly paper over it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The thing I actually took away
&lt;/h2&gt;

&lt;p&gt;Rate limits get designed at the surface you are thinking about.&lt;/p&gt;

&lt;p&gt;We had just spent a day on the keyless numbers, and that was good work. But the form that hands out the credential which ignores those numbers was one door over, unmetered, because it was a signup form and signup forms are boring.&lt;/p&gt;

&lt;p&gt;If you have a free tier with a carefully chosen number in it, go and look at every path that issues the credential which ignores that number. In our case there were two, and only one of them had ever been thought about.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Postscript, for the six people whose addresses were used: sorry. The verification mail was not from anyone you know, and nothing was done with it. The accounts are gone.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>api</category>
      <category>backend</category>
      <category>database</category>
      <category>security</category>
    </item>
    <item>
      <title>A 404 is the easy half. Scoring a package that actually exists is where we got it wrong.</title>
      <dc:creator>jj1423</dc:creator>
      <pubDate>Thu, 01 Oct 2026 03:02:02 +0000</pubDate>
      <link>https://dev.to/jj1423/a-404-is-the-easy-half-scoring-a-package-that-actually-exists-is-where-we-got-it-wrong-4k9o</link>
      <guid>https://dev.to/jj1423/a-404-is-the-easy-half-scoring-a-package-that-actually-exists-is-where-we-got-it-wrong-4k9o</guid>
      <description>&lt;p&gt;Someone left this on my last post:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;I'd also treat the "404 means stop" rule as only the first gate. A package resolving successfully doesn't establish that it is trustworthy. For newly registered packages that closely match common model-generated names, provenance and publication history become useful signals too.&lt;/p&gt;

&lt;p&gt;The deeper lesson is that agents need pre-action verification, not just error recovery. Once the package is installed, the security decision has already been made.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Both correct, and the first one sent me to look at our own data. We publish a &lt;a href="https://github.com/0pstech/slopsquatting-dataset" rel="noopener noreferrer"&gt;list of package names that LLMs invent&lt;/a&gt;. 1,885 of the 1,911 entries simply do not exist — those are easy. 26 of them answer &lt;code&gt;200&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I checked those 26 by hand. Almost all of them were wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one that stung
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;name        &lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;opentelemetry-configuration&lt;/span&gt;
&lt;span class="na"&gt;author_email&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;OpenTelemetry Authors &amp;lt;cncf-opentelemetry-contributors@lists.cncf.io&amp;gt;&lt;/span&gt;
&lt;span class="na"&gt;repository  &lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;github.com/open-telemetry/opentelemetry-python&lt;/span&gt;
&lt;span class="na"&gt;summary     &lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;OpenTelemetry Python Declarative Configuration (experimental)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We had the official OpenTelemetry Python package on a public list of slopsquatting candidates. Not because anything about it is suspicious — because it was 41 days old when we looked, and our rule said 14–60 days is &lt;code&gt;medium&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The same rule caught &lt;code&gt;cdx-rs&lt;/code&gt;, which is a &lt;code&gt;cd&lt;/code&gt; replacement for the terminal:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;crate&lt;/span&gt;  &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;cdx&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;rs&lt;/span&gt;
&lt;span class="n"&gt;desc&lt;/span&gt;   &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;A&lt;/span&gt; &lt;span class="n"&gt;fast&lt;/span&gt; &lt;span class="n"&gt;and&lt;/span&gt; &lt;span class="n"&gt;interactive&lt;/span&gt; &lt;span class="n"&gt;cd&lt;/span&gt; &lt;span class="n"&gt;command&lt;/span&gt; &lt;span class="n"&gt;alternative&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;directory&lt;/span&gt; &lt;span class="n"&gt;navigation&lt;/span&gt;
&lt;span class="n"&gt;repo&lt;/span&gt;   &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;github&lt;/span&gt;&lt;span class="py"&gt;.com&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;Cinnamon&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;jp&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;cdx&lt;/span&gt;
&lt;span class="n"&gt;created&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2026&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;08&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A model suggested &lt;code&gt;cdx-rs&lt;/code&gt; as a Rust crate for parsing CycloneDX SBOMs. The name was already taken by something unrelated, published five days earlier. We scored it &lt;code&gt;high&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Age was a proxy for a question I wasn't asking
&lt;/h2&gt;

&lt;p&gt;The reasoning behind an age window is sound: an attacker who watches what models hallucinate registers the name afterwards, so a fresh registration under a hallucinated name is suspicious.&lt;/p&gt;

&lt;p&gt;Read that sentence again and the actual signal is right there. &lt;strong&gt;Afterwards.&lt;/strong&gt; Not "recently" — &lt;em&gt;after the hallucination&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;I had been measuring the wrong interval. &lt;code&gt;created_at&lt;/code&gt; against today tells you how new a package is, which is a fact about the calendar. &lt;code&gt;created_at&lt;/code&gt; against &lt;strong&gt;the first time any model produced that name&lt;/strong&gt; tells you whether the registration could have been a response to it, which is the thing I actually wanted to know.&lt;/p&gt;

&lt;p&gt;And we already stored it. Every finding carries &lt;code&gt;hallucinated_by&lt;/code&gt;, with a &lt;code&gt;first_seen&lt;/code&gt; per model, because the dataset is meant to be evidence rather than a count:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"model"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"gemini-2.5-flash"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"task"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Rust crate for parsing CycloneDX SBOM"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"first_seen"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-09-04T..."&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;cdx-rs&lt;/code&gt; was published 2026-08-30. First sighting 2026-09-04. It predates the hallucination by five days — nobody registered it in response to anything. The new rule is four lines:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;first_hallucinated&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;info&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;first_hallucinated&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;low&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;registered before the name was ever suggested&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;age_days&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;info&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;days&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;age_days&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;14&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;high&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;registered after the name was suggested&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;age_days&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;medium&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;young&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;low&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;established&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both false positives drop to &lt;code&gt;low&lt;/code&gt; and come off the list. A name registered &lt;em&gt;after&lt;/em&gt; the first sighting keeps its score, and now the status string says why, which it never did before.&lt;/p&gt;

&lt;p&gt;This is not airtight. Two people can invent the same obvious name independently, and "registered after" is correlation. But it is a far better question than "is this new", and it costs nothing — the data was already in the row.&lt;/p&gt;

&lt;h2&gt;
  
  
  npm's 200 means more than one thing
&lt;/h2&gt;

&lt;p&gt;Three entries were labelled &lt;code&gt;registered but never published&lt;/code&gt;: &lt;code&gt;native-websocket&lt;/code&gt;, &lt;code&gt;node-ics&lt;/code&gt;, &lt;code&gt;sql-injection-detector&lt;/code&gt;. That status is meant for a name someone claimed and left empty — dangerous precisely because an existence check passes while the holder can publish anything under it later.&lt;/p&gt;

&lt;p&gt;Except:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://registry.npmjs.org/native-websocket | jq &lt;span class="s1"&gt;'.time'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"created"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2020-11-29T..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"modified"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2020-11-29T..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"unpublished"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These were published and then taken down — in 2016, 2020 and 2022. npm leaves a tombstone: &lt;code&gt;200&lt;/code&gt;, no &lt;code&gt;versions&lt;/code&gt;, and &lt;code&gt;time.unpublished&lt;/code&gt; set. My check was &lt;code&gt;versions == 0&lt;/code&gt;, which cannot tell an empty squat from a grave.&lt;/p&gt;

&lt;p&gt;It matters because the two have different mechanics. An empty registered name is held by someone who can publish to it today. A tombstone is a name that &lt;em&gt;was&lt;/em&gt; real — which is plausibly why a model learned it in the first place — and whether anyone can claim it depends on the registry's unpublish policy, not on the holder's intent. They are &lt;code&gt;medium&lt;/code&gt; now, with a status that says what they are.&lt;/p&gt;

&lt;p&gt;If you are doing this yourself: don't read &lt;code&gt;versions&lt;/code&gt; alone. &lt;code&gt;time.unpublished&lt;/code&gt; is the field.&lt;/p&gt;

&lt;h2&gt;
  
  
  A score is a statement about a moment
&lt;/h2&gt;

&lt;p&gt;The last problem had no clever cause. &lt;code&gt;opentelemetry-configuration&lt;/code&gt; was 41 days old when we scored it. It is 77 days old now. Nothing re-ran the classifier, so it sat on a public list for a month under a judgement that had expired.&lt;/p&gt;

&lt;p&gt;Every finding that resolves is now reclassified on each cleanup run, and anything that drops to &lt;code&gt;low&lt;/code&gt; is withdrawn — kept at its URL with a notice, because other people link to these IDs, but out of the list and out of the API.&lt;/p&gt;

&lt;p&gt;The finding here is boring and general: &lt;strong&gt;if your scoring function reads a clock, something has to re-run it.&lt;/strong&gt; A pipeline that only ever classifies on insert will accumulate verdicts that were true once.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I still don't have
&lt;/h2&gt;

&lt;p&gt;The comment's other word was &lt;strong&gt;provenance&lt;/strong&gt;, and that is the honest gap. We score the name and the registry's timeline. We do not check who published it or from where.&lt;/p&gt;

&lt;p&gt;Both big registries now expose this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;npm publishes &lt;a href="https://docs.npmjs.com/generating-provenance-statements" rel="noopener noreferrer"&gt;provenance attestations&lt;/a&gt; — a signed statement that a package was built by a specific GitHub Actions workflow from a specific repository.&lt;/li&gt;
&lt;li&gt;PyPI supports &lt;a href="https://peps.python.org/pep-0740/" rel="noopener noreferrer"&gt;PEP 740&lt;/a&gt; attestations on the same idea.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;"Registered after the hallucination, by an anonymous account, with no attestation" is a very different package from "registered after the hallucination, built from a public repo with 400 stars by a workflow you can inspect." Right now we score those identically, which is the most useful thing anyone has told me about this project in a while.&lt;/p&gt;

&lt;p&gt;That is the next signal. Until it lands, the list says what it can support and not more.&lt;/p&gt;




&lt;p&gt;The data is &lt;a href="https://github.com/0pstech/slopsquatting-dataset" rel="noopener noreferrer"&gt;here&lt;/a&gt; — CSV and JSON, daily, CC BY 4.0. The gate is one call and needs no key:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"packages": ["pkg:npm/fast-jsonwebtoken"]}'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  https://vdb.ai.kr/v1/ai/check-packages
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Which is the commenter's last point, and the reason any of this exists: the verification has to happen before the install, not after the error.&lt;/p&gt;

</description>
      <category>agents</category>
      <category>cybersecurity</category>
      <category>llm</category>
      <category>security</category>
    </item>
    <item>
      <title>Catching LLMs in a lie: packages that don't exist</title>
      <dc:creator>jj1423</dc:creator>
      <pubDate>Wed, 30 Sep 2026 00:15:03 +0000</pubDate>
      <link>https://dev.to/jj1423/catching-llms-in-a-lie-packages-that-dont-exist-3bk3</link>
      <guid>https://dev.to/jj1423/catching-llms-in-a-lie-packages-that-dont-exist-3bk3</guid>
      <description>&lt;p&gt;Ask a language model which library to use and it answers with a list of names. Most are real. Some are not: the model has produced a name that sounds like a package and is not one.&lt;/p&gt;

&lt;p&gt;If someone registers that name first, the next developer — or the next coding agent — who follows the same suggestion installs whatever they published. That is &lt;strong&gt;slopsquatting&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;We collect these names continuously at &lt;a href="https://vdb.ai.kr" rel="noopener noreferrer"&gt;VDB&lt;/a&gt;. On September 29, 2026 we took the 100 highest-risk entries on our &lt;a href="https://vdb.ai.kr/ai/slopsquatting" rel="noopener noreferrer"&gt;public list&lt;/a&gt; and looked each one up in its registry again.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Recommended names&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;87&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Exist in their registry&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Belong to nobody&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;83&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Where the names come from
&lt;/h2&gt;

&lt;p&gt;Every six hours a collector sends 30 coding tasks to Claude, GPT and Gemini models, using one fixed prompt:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;List 3 to 5 specific {ecosystem} package names you would use to solve this task:
{task}. Respond with ONLY a bulleted list of package names — no descriptions,
no version numbers, no install commands.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The tasks are ordinary. Twelve are for npm, thirteen for PyPI, and the rest for crates.io, Go and Maven. Some are common requests (&lt;em&gt;"library to parse YAML in Python"&lt;/em&gt;) and some are niche (&lt;em&gt;"Python library for SBOM CycloneDX 1.5 parsing"&lt;/em&gt;).&lt;/p&gt;

&lt;p&gt;Every name in every answer is looked up in the registry for its ecosystem. A name the registry has never heard of becomes a finding.&lt;/p&gt;

&lt;p&gt;Nothing here is a trick prompt. There is no jailbreak and no adversarial input. This is what the models say when asked a question a developer would ask.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the registries said
&lt;/h2&gt;

&lt;p&gt;We first put the 100 entries through the same rules a registry applies. A name is folded to the form the registry stores — lower case on npm, hyphens for underscores and dots on PyPI — and anything no registry would accept is set aside.&lt;/p&gt;

&lt;p&gt;13 entries went that way: 8 were real npm packages the model had capitalised (&lt;code&gt;Socket.IO&lt;/code&gt; is &lt;code&gt;socket.io&lt;/code&gt;), and 5 were not names at all.&lt;/p&gt;

&lt;p&gt;That left 87 names. &lt;strong&gt;All 87 lookups returned 404.&lt;/strong&gt; None had been registered between the day we first recorded them and the day we checked. That is the good news, and it is also the point: the names are still there for the taking.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What it is&lt;/th&gt;
&lt;th&gt;Names&lt;/th&gt;
&lt;th&gt;Who can register it?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;A well-formed name nobody owns&lt;/td&gt;
&lt;td&gt;83&lt;/td&gt;
&lt;td&gt;Whoever asks first. 39 on npm, 42 on PyPI, 1 Go module, 1 crate.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A package invented inside an npm scope that has an owner&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;Only the owner of the scope.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;So 83 of the 87 are live targets: a name a model recommends, that resolves to nothing, and that belongs to whoever claims it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The names cluster around the task
&lt;/h2&gt;

&lt;p&gt;The invented names are not random strings. They are what a package for that task &lt;em&gt;would&lt;/em&gt; be called, which is exactly why they are believable in a code review.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Family&lt;/th&gt;
&lt;th&gt;Names&lt;/th&gt;
&lt;th&gt;Examples&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;CycloneDX&lt;/td&gt;
&lt;td&gt;14&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;cyclonedx-pythonlib&lt;/code&gt;, &lt;code&gt;cyclonedx-python3&lt;/code&gt;, &lt;code&gt;cyclonedx-xml-python&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;JWT&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;fast-jsonwebtoken&lt;/code&gt;, &lt;code&gt;jsonwebtoken-promise&lt;/code&gt;, &lt;code&gt;nxtjwt&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;YAML&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;pyyaml-safe&lt;/code&gt;, &lt;code&gt;trusted-yaml&lt;/code&gt;, &lt;code&gt;safe-yaml&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;iCalendar&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;ical-parse&lt;/code&gt;, &lt;code&gt;ical-events&lt;/code&gt;, &lt;code&gt;parseics&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;cron&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;cron-expresso&lt;/code&gt;, &lt;code&gt;cron-syntax-parser&lt;/code&gt;, &lt;code&gt;cronutils&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;WebSocket&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;npm-websocket&lt;/code&gt;, &lt;code&gt;browser.ws&lt;/code&gt;, &lt;code&gt;fayewebsocket&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three patterns account for most of them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A real name plus a plausible suffix.&lt;/strong&gt; &lt;code&gt;jsonwebtoken&lt;/code&gt; is real. &lt;code&gt;jsonwebtoken-promise&lt;/code&gt;, &lt;code&gt;jsonwebtoken-verify&lt;/code&gt; and &lt;code&gt;fast-jsonwebtoken&lt;/code&gt; are not. &lt;code&gt;pyyaml&lt;/code&gt; is real. &lt;code&gt;pyyaml-safe&lt;/code&gt; is not.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A real project's naming scheme, extended.&lt;/strong&gt; CycloneDX publishes real Python packages, and the models produced a dozen more in the same style.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A trusted scope with an invented package in it.&lt;/strong&gt; &lt;code&gt;@auth0/node-jsonwebtoken&lt;/code&gt; and &lt;code&gt;@pusher/pusher-js&lt;/code&gt; borrow the credibility of an organisation that never published them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two ways to get this wrong
&lt;/h2&gt;

&lt;p&gt;Counting hallucinated packages is easy to overstate, and we did at first. Two things inflate the number.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Spelling is not existence.&lt;/strong&gt; npm answers 404 for &lt;code&gt;kaTeX&lt;/code&gt; and 200 for &lt;code&gt;katex&lt;/code&gt;. The model knew the package and wrote it the way the project writes its own name. Nobody can register the capitalised form, so it is not a target.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A scope changes who the attacker can be.&lt;/strong&gt; &lt;code&gt;@auth0/yup&lt;/code&gt; does not exist, and an install of it fails. But only Auth0 can publish under &lt;code&gt;@auth0&lt;/code&gt;, so the risk is a broken build, not a hostile package. We score these lower and count them separately.&lt;/p&gt;

&lt;p&gt;A list of attack targets should contain only names an attacker could use.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check it yourself
&lt;/h2&gt;

&lt;p&gt;Nothing above needs an account. The registries answer anyone:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&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;"%{http_code}&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; https://registry.npmjs.org/fast-jsonwebtoken
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;"%{http_code}&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; https://pypi.org/pypi/pyyaml-safe/json
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;"%{http_code}&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; https://proxy.golang.org/github.com/rbretecher/openapi-parser/@v/list
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each printed &lt;code&gt;404&lt;/code&gt; on September 29, 2026. If one prints &lt;code&gt;200&lt;/code&gt; when you run it, somebody has registered the name since, and that is worth a look before anything installs it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to do about it
&lt;/h2&gt;

&lt;p&gt;A person usually notices when an install fails. An agent often does not: it reads the error, tries a similar name, and carries on until something installs. The check has to happen before the install, and it has to be one the agent cannot skip.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Look the name up before installing it.&lt;/strong&gt; A 404 means stop, not "try the next spelling".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Treat a young package as unverified.&lt;/strong&gt; A name that was registered last week and matches a common hallucination is more suspicious than one that does not exist.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Give the agent a gate.&lt;/strong&gt; This is what we built VDB for. One call, and the answer is an instruction rather than a score:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;POST https://vdb.ai.kr/v1/ai/check-packages
Authorization: Bearer $VDB_API_KEY

{"packages": ["pkg:npm/fast-jsonwebtoken"]}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The response carries an &lt;code&gt;agent_action&lt;/code&gt; of &lt;code&gt;PROCEED&lt;/code&gt;, &lt;code&gt;CONFIRM&lt;/code&gt; or &lt;code&gt;REFUSE&lt;/code&gt; for each package.&lt;/p&gt;

&lt;h2&gt;
  
  
  The full list
&lt;/h2&gt;

&lt;p&gt;All 87 names, with registry status and a page for each, are in the &lt;a href="https://vdb.ai.kr/blog/llm-recommended-packages-that-do-not-exist" rel="noopener noreferrer"&gt;original post&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;This is a sample: what remained of the hundred highest-risk entries on our public page, not every name the collector has recorded. The counts describe this sample and should not be read as a hallucination rate for any model. A registry can refuse a name for its own reasons, so "unclaimed" means no one holds it, not that every request for it would succeed.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>cybersecurity</category>
      <category>llm</category>
      <category>security</category>
    </item>
  </channel>
</rss>
