<?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: VOIR LABS</title>
    <description>The latest articles on DEV Community by VOIR LABS (@voirlabs).</description>
    <link>https://dev.to/voirlabs</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%2F4242259%2F6c7b1f48-b0fd-4b74-a431-22993f766b20.jpg</url>
      <title>DEV Community: VOIR LABS</title>
      <link>https://dev.to/voirlabs</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/voirlabs"/>
    <language>en</language>
    <item>
      <title>Northflank is the ninth AI platform breach this month. Make a stolen AI key worth less.</title>
      <dc:creator>VOIR LABS</dc:creator>
      <pubDate>Fri, 13 Nov 2026 15:14:22 +0000</pubDate>
      <link>https://dev.to/voirlabs/northflank-is-the-ninth-ai-platform-breach-this-month-make-a-stolen-ai-key-worth-less-1ckj</link>
      <guid>https://dev.to/voirlabs/northflank-is-the-ninth-ai-platform-breach-this-month-make-a-stolen-ai-key-worth-less-1ckj</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://www.voirlabs.io/insights/stolen-ai-api-keys-platform-breaches?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=distribution" rel="noopener noreferrer"&gt;voirlabs.io&lt;/a&gt; by Moosa Jafri.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;An AI provider key stored as an environment variable on a hosting platform is only as safe as that platform, and in October nine of them disclosed incidents. The useful response is to plan for the next leak: make a stolen copy of your key able to do very little, and make its misuse loud within the first hour. This article walks through what attackers did with stolen keys, based on Wiz's investigation, and a small gateway design that limits the damage.&lt;/p&gt;

&lt;h2&gt;
  
  
  Nine platforms, one target: the environment variables
&lt;/h2&gt;

&lt;p&gt;On October 30, Wiz published "Black October for AI Developer Platforms", listing incidents at Fireworks AI, Andromeda, Zeabur, Lightning AI, OpenCode, Railway, Warp, CommandCode and Northflank. Its reading of the public reports: they "highlight targeted exploitation, explicitly hunting stored environment variables, with a focus on those granting AI access." It also says "there is no single vulnerability or dependency linking these attacks".&lt;/p&gt;

&lt;p&gt;The platforms' own words point the same way. Railway's October 21 disclosure says an unauthorized party "may have accessed encrypted environment variables and, for some of them, obtained the decrypted contents", and that it believes this "is part of a series of attacks recently targeting platform companies with the aim of compromising AI inference providers." BleepingComputer reported on October 20 that at Zeabur an attacker used "leaked internal credentials stored in plaintext" and stole "over 3.2 million environment variable records". On October 31 it reported Northflank's warning to customers: "Assume all environment variables have been decrypted. Disable/rotate all keys," followed by "Users have reported unauthorised use of AI API keys supplied to the Northflank platform".&lt;/p&gt;

&lt;p&gt;One label. Wiz has not confirmed that one group is behind all nine incidents, and Northflank has not said how attackers got in.&lt;/p&gt;

&lt;h2&gt;
  
  
  A stolen key goes quiet for days before the flood
&lt;/h2&gt;

&lt;p&gt;Wiz traced two enterprise Amazon Bedrock credentials that had been stolen through vendor breaches. Its timeline is the most useful part of the report, because it shows when a defender could have noticed.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Validation.&lt;/strong&gt; The first unauthorized activity on both keys came "within 30 minutes of the reported credential access within the vendor breach": a single InvokeModel call from a residential address, part of a sweep that tested stolen keys across organizations.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The quiet period.&lt;/strong&gt; Afterwards both keys saw "only a trickle of traffic: 14–120 calls a day from the same small Go client", which Wiz reads as a job checking that the keys still worked.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The probe.&lt;/strong&gt; On the first key, four days after validation, a proxy pool sent "one call per model across vendors": Amazon Nova, DeepSeek, gpt-oss, GLM, Kimi, Grok, GPT-6 Astra and older Claude models.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The flood.&lt;/strong&gt; Over thirty hours the first key made about 800,000 Bedrock calls, about 90% of them successful. The second made about 1.7 million in thirteen hours, over a million successful. Wiz estimates the two "likely ran up over $100k in usage" at list price, and says the traffic looked like "resold coding-agent traffic."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Two numbers are worth putting side by side. By our arithmetic, 1.7 million calls in thirteen hours is about 36 calls a second; during the quiet period the same key made at most 120 a day. And the probe asked for models across several vendors in one sweep. If an app only ever calls one or two models, a request for any other model is a signal that arrives days before the bill.&lt;/p&gt;

&lt;h2&gt;
  
  
  The restricted key held for four hours
&lt;/h2&gt;

&lt;p&gt;The second key in Wiz's report was scoped. Its policy allowed Bedrock "only through us.* cross-region inference profiles", and the reseller pool that picked it up asked for global.* profiles. For four hours, thousands of calls failed. Then a checker found the model IDs that worked, and the flood began.&lt;/p&gt;

&lt;p&gt;That's the honest lesson on scoping. A narrow policy cost the attacker time and produced thousands of failed calls that someone could have alerted on. It didn't stop the abuse, because the allowed path was still worth money. Scope is a delay and an alarm; a ceiling on volume is what limits the bill.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep the provider key in one place you run
&lt;/h2&gt;

&lt;p&gt;Our approach for a team with several apps calling a model: the provider key lives only in a small gateway you operate, and each app gets its own token for that gateway. The app token is what sits in the hosting platform's environment variables. A sketch of how we would write the gateway's admission check, in TypeScript:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nc"&gt;AppPolicy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Set&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// the exact model IDs this app uses&lt;/span&gt;
  &lt;span class="n"&gt;callsPerHour&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// near normal use, far below the quota&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;policies&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;AppPolicy&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"support-bot"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"your-model-id"&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="n"&gt;callsPerHour&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;600&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;usage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;hour&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;calls&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;number&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// Returns null to admit; the gateway then adds the provider key.&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;admit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;appId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;policies&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="n"&gt;appId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;policy&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;"unknown app"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;policy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;model&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="nf"&gt;alert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;appId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sb"&gt;`asked for ${model}, which it never uses`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;"model not allowed"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;hour&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;3_600_000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;seen&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;usage&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="n"&gt;appId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;calls&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;seen&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;hour&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="n"&gt;hour&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="n"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;calls&lt;/span&gt; &lt;span class="o"&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;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="n"&gt;usage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;appId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;hour&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;calls&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;calls&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;callsPerHour&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;alert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;appId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sb"&gt;`${calls} calls this hour`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;"over hourly ceiling"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;declare&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;alert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;appId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;why&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice what a stolen app token is worth here. It reaches one model, so a catalog probe like the one in Wiz's report fails on its first unfamiliar model and pages someone. And at 36 calls a second, a ceiling of 600 calls an hour is reached in under twenty seconds, by our arithmetic, so a flood stops at the ceiling instead of running for thirteen hours. Set the ceiling from your own usage, with headroom for your busiest hour.&lt;/p&gt;

&lt;p&gt;Because the gateway is yours, revoking a leaked app token takes one change, and the provider key never needs to rotate across every app at once.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this costs you
&lt;/h2&gt;

&lt;p&gt;The gateway is now the secret worth stealing, so it doesn't belong on the same shared platforms. Run it where you already keep production secrets, and give it the cloud's short-lived credentials where your provider supports them, rather than a long-lived key in a variable.&lt;/p&gt;

&lt;p&gt;It adds a hop to every model call and one more service to keep up. Ceilings set too tight will block a real traffic spike, which means a support bot going quiet on your busiest day; start with alerts only and turn on blocking once you trust the numbers. And none of this replaces rotation. If your keys sat on any of the nine platforms, rotate them now and check your provider's billing for October.&lt;/p&gt;

&lt;p&gt;If you run one app with one key and little traffic, skip the gateway. Give the key the narrowest model scope your provider allows, set a spending alert, and keep it off shared hosting.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to take from Black October
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Treat every AI key in a hosting platform's environment variables as exposed the day that platform discloses a breach, and rotate it that day.&lt;/li&gt;
&lt;li&gt;Scope each key to the models it uses. Wiz's restricted key bought four hours and thousands of failed calls.&lt;/li&gt;
&lt;li&gt;Alert on the two signals in Wiz's timeline: a request for a model the app never uses, and calls far above the usual hour.&lt;/li&gt;
&lt;li&gt;Cap volume per app, because a scoped key can still be drained through its allowed path.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you want a second opinion on where your AI keys live and what a stolen one could reach, &lt;a href="https://calendly.com/voirlabs/30min?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=distribution" rel="noopener noreferrer"&gt;book a conversation&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Wiz Research, "Black October for AI Developer Platforms", Benjamin Read and Rami McCarthy, October 30, 2026: &lt;a href="https://www.wiz.io/blog/black-october-for-ai-developer-platforms" rel="noopener noreferrer"&gt;https://www.wiz.io/blog/black-october-for-ai-developer-platforms&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Railway, "Security Disclosure: Wednesday October 21st", Jacob Cooper, October 21, 2026: &lt;a href="https://blog.railway.com/p/security-disclosure-wednesday-october-21st" rel="noopener noreferrer"&gt;https://blog.railway.com/p/security-disclosure-wednesday-october-21st&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;BleepingComputer, "Northflank security breach leads to abuse of customers' AI API keys", Lawrence Abrams, October 31, 2026: &lt;a href="https://www.bleepingcomputer.com/news/security/northflank-security-breach-leads-to-abuse-of-customers-ai-api-keys/" rel="noopener noreferrer"&gt;https://www.bleepingcomputer.com/news/security/northflank-security-breach-leads-to-abuse-of-customers-ai-api-keys/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;BleepingComputer, "Zeabur shutting down after second security breach in two months", Bill Toulas, October 20, 2026: &lt;a href="https://www.bleepingcomputer.com/news/security/zeabur-shutting-down-after-second-security-breach-in-two-months/" rel="noopener noreferrer"&gt;https://www.bleepingcomputer.com/news/security/zeabur-shutting-down-after-second-security-breach-in-two-months/&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>security</category>
      <category>devops</category>
      <category>typescript</category>
    </item>
    <item>
      <title>A Web3 library shipped malware to npm with valid provenance. Block install scripts you never reviewed.</title>
      <dc:creator>VOIR LABS</dc:creator>
      <pubDate>Fri, 13 Nov 2026 15:13:46 +0000</pubDate>
      <link>https://dev.to/voirlabs/a-web3-library-shipped-malware-to-npm-with-valid-provenance-block-install-scripts-you-never-1dgg</link>
      <guid>https://dev.to/voirlabs/a-web3-library-shipped-malware-to-npm-with-valid-provenance-block-install-scripts-you-never-1dgg</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://www.voirlabs.io/insights/npm-install-script-malware-provenance?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=distribution" rel="noopener noreferrer"&gt;voirlabs.io&lt;/a&gt; by Asad Naeem.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;When npm install script malware turns up, the first suspect is a stolen npm token. This weekend's case points elsewhere: SafeDep found it "points to a compromised bot account or token, not a stolen npm token." A compromised release of @ethersphere/bee-js, the JavaScript client for the Swarm storage network, ran attacker code the moment it was installed, and it was published by the project's own release workflow with valid npm provenance. Every CI job, build server and coding agent that runs an install unattended runs that code with the machine's secrets. This article covers what happened, why the provenance badge did not help, and the check we would add so a new install script cannot run until a person has looked at it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What happened
&lt;/h2&gt;

&lt;p&gt;StepSecurity found that bee-js 13.3.0, "Published to npm on November 9, 2026, at 00:09:50 UTC, the release downloads and executes attacker-controlled JavaScript during installation." The payloads "target Swarm Desktop wallet files and attempt to install persistent access on Linux, macOS, and Windows."&lt;/p&gt;

&lt;p&gt;The change that made it work was small. SafeDep's analysis shows two edits to package.json: the version script moved from &lt;code&gt;prepare&lt;/code&gt; to &lt;code&gt;postinstall&lt;/code&gt;, and the scripts folder was added to the files shipped in the package. In SafeDep's words: "With postinstall, the script runs on every install."&lt;/p&gt;

&lt;p&gt;What it took is the part for wallet builders. The payload reads two files from the Swarm Desktop data folder. StepSecurity explains that "The swarm.key file is an encrypted Ethereum V3 keystore. The configuration can contain the password needed to decrypt it." SafeDep adds that 30 minutes later, the same code went into Swarm Desktop 0.56.2.&lt;/p&gt;

&lt;p&gt;npm removed 13.3.0 at about 01:24 UTC, according to SafeDep, so it was live for roughly 75 minutes. During that window, StepSecurity noted, npm "still mapped latest to 13.3.0".&lt;/p&gt;

&lt;h2&gt;
  
  
  Why provenance did not help
&lt;/h2&gt;

&lt;p&gt;Provenance answers one question: was this package built from this repository by this workflow? Here the answer was yes. StepSecurity: "The npm package therefore carries valid SLSA provenance linking it to the legitimate repository and workflow. The malicious code was already in the source that the workflow built."&lt;/p&gt;

&lt;p&gt;The attacker used the project's release bot identity to create a commit, a tag and a GitHub release, and the release started the normal publish workflow. SafeDep found that the workflow "runs tests only for code pushed to a branch, so it ran none here", and that "Anyone who can create a release in this repository can publish code to npm with provenance."&lt;/p&gt;

&lt;p&gt;Version ranges did the rest. StepSecurity lists Ethersphere's own tools depending on bee-js through ranges such as &lt;code&gt;^13.2.0&lt;/code&gt; and &lt;code&gt;^13.0.0&lt;/code&gt;, and notes that "These ranges permit 13.3.0." Any fresh install or lockfile update in that window could pick it up.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make install scripts opt-in
&lt;/h2&gt;

&lt;p&gt;The attack needed one thing on your side: a package manager that runs a dependency's &lt;code&gt;postinstall&lt;/code&gt; without asking. We would turn that off and keep a short list of packages that are allowed to run scripts. With npm, that means &lt;code&gt;ignore-scripts=true&lt;/code&gt; in &lt;code&gt;.npmrc&lt;/code&gt;, plus a CI check that fails when the lockfile adds a package with an install script nobody has reviewed. Here is a sketch of that check, reading the &lt;code&gt;hasInstallScript&lt;/code&gt; flag npm writes into package-lock.json:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Fail CI when a dependency with an install script appears&lt;/span&gt;
&lt;span class="c1"&gt;// that nobody has reviewed. Run it on every lockfile change.&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;readFileSync&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="s2"&gt;"node:fs"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nc"&gt;Entry&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;version&lt;/span&gt;&lt;span class="o"&gt;?:&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;hasInstallScript&lt;/span&gt;&lt;span class="o"&gt;?:&lt;/span&gt; &lt;span class="kt"&gt;boolean&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nc"&gt;Lock&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;packages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Entry&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// Packages a person has read and allowed to run on install.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="no"&gt;ALLOWED&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"esbuild"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"sharp"&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="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;readFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"package-lock.json"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"utf8"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;lock&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Lock&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;flagged&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lock&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;packages&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;hasInstallScript&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// "" is the root&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;path&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="s2"&gt;"node_modules/"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="no"&gt;ALLOWED&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;flagged&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sb"&gt;`${name}@${entry.version}`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;flagged&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nb"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Install scripts need review:"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;flagged&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;", "&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nb"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exit&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="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What to notice: bee-js would have failed this check the day it gained a &lt;code&gt;postinstall&lt;/code&gt;, even with valid provenance, because the check looks at what the package does on install and not at who built it. The allowlist is per package name, so a reviewed package that later changes its script still slips through. Pair it with a lockfile diff in review, and keep wallets, keystores and deploy keys off the machines that run installs.&lt;/p&gt;

&lt;p&gt;If you maintain a package, the lesson from the other side is to put a person between a release and a publish. A GitHub environment with required reviewers on the publish job means a stolen bot token can still cut a release, but it cannot ship it to npm alone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this costs you
&lt;/h2&gt;

&lt;p&gt;Some packages need their install scripts: native modules and binary downloaders such as image or bundler tools. With scripts off, they break until you add them to the allowlist or run their build step yourself, and each one is a package someone has to read.&lt;/p&gt;

&lt;p&gt;The check only sees install scripts. A malicious package that does its damage when your code imports it passes untouched, and so does a compromised version of a package already on the allowlist. It narrows one common path and leaves the others.&lt;/p&gt;

&lt;p&gt;Pinning exact versions and installing from a lockfile stops a range from picking up a bad release mid-week, but it moves the risk to the day you update. Updating after a release has been public for a day or two, rather than within the hour, would have stepped around this one, at the cost of slower security fixes.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to take from this
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;If anything installed @ethersphere/bee-js 13.3.0 or Swarm Desktop 0.56.2, treat that machine as compromised and move any wallet it held.&lt;/li&gt;
&lt;li&gt;Read provenance as proof of where a package was built, not of what is in it.&lt;/li&gt;
&lt;li&gt;Turn off dependency install scripts by default and allow them per package after a review.&lt;/li&gt;
&lt;li&gt;Fail CI when the lockfile adds an install script nobody has reviewed.&lt;/li&gt;
&lt;li&gt;If you publish packages, require a person to approve the publish job.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If your team ships wallets, dApps or &lt;a href="https://www.voirlabs.io/services/web3-blockchain?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=distribution" rel="noopener noreferrer"&gt;Web3 products&lt;/a&gt; and wants a second pair of eyes on its build and release pipeline, &lt;a href="https://calendly.com/voirlabs/30min?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=distribution" rel="noopener noreferrer"&gt;book a conversation&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;StepSecurity, Ashish Kurmi, "@ethersphere/bee-js 13.3.0 Compromised", November 8, 2026: &lt;a href="https://www.stepsecurity.io/blog/ethersphere-bee-js-13-3-0-compromise" rel="noopener noreferrer"&gt;https://www.stepsecurity.io/blog/ethersphere-bee-js-13-3-0-compromise&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;SafeDep, "Compromised bee-js and Swarm Desktop Steal Swarm Wallet Keys", November 9, 2026: &lt;a href="https://safedep.io/blog/ethersphere-bee-js-npm-compromise" rel="noopener noreferrer"&gt;https://safedep.io/blog/ethersphere-bee-js-npm-compromise&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>security</category>
      <category>npm</category>
      <category>web3</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Docker's agent sandbox leaked through its copy command. Check every path that crosses the wall.</title>
      <dc:creator>VOIR LABS</dc:creator>
      <pubDate>Fri, 13 Nov 2026 15:13:11 +0000</pubDate>
      <link>https://dev.to/voirlabs/dockers-agent-sandbox-leaked-through-its-copy-command-check-every-path-that-crosses-the-wall-20e</link>
      <guid>https://dev.to/voirlabs/dockers-agent-sandbox-leaked-through-its-copy-command-check-every-path-that-crosses-the-wall-20e</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://www.voirlabs.io/insights/docker-sandbox-copy-symlink-escape?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=distribution" rel="noopener noreferrer"&gt;voirlabs.io&lt;/a&gt; by Ijlal Ishaq.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A sandbox for an AI agent is only as strong as the least guarded way in or out of it. Docker Sandboxes put a coding agent in its own virtual machine and let it see only the folders you approve, and that part held. The gap was the copy command next to it. By the end of this article you will know why that keeps happening, and what a file channel that respects the boundary looks like in code.&lt;/p&gt;

&lt;h2&gt;
  
  
  The allowlist held. The copy command did not ask it.
&lt;/h2&gt;

&lt;p&gt;Docker Sandboxes runs agents such as Claude Code, Codex and Gemini CLI inside a microVM with its own kernel, filesystem and network. You grant it specific folders, usually the project workspace, and those folders are shared into the VM. Together they form an allowlist: the only parts of your machine the agent is supposed to reach.&lt;/p&gt;

&lt;p&gt;On November 3, Peyton Kennedy of Endor Labs published CVE-2026-103916, a flaw he found in &lt;code&gt;sbx cp&lt;/code&gt;, the command that moves files between the sandbox and your machine. The copy runs on your side, with your permissions, and never consults the allowlist. In Kennedy's words, the CLI "resolves symlinks in the paths it is given and acts on the resolution."&lt;/p&gt;

&lt;p&gt;The agent controls those links, because the workspace is granted read and write. So it can replace a folder in the workspace with a link to somewhere else on your machine. Inside the VM the link points nowhere, so planting it gains the agent nothing on its own. It takes effect when you run an ordinary copy that passes through that folder. Kennedy shows both directions: a copy out of the sandbox overwrites a file outside the workspace, and a copy in pulls a host file into the sandbox.&lt;/p&gt;

&lt;p&gt;The command did have a guard, the &lt;code&gt;-L&lt;/code&gt; flag, but it checks only the last part of the path. A symlink can sit in any of four positions, and in the researcher's table "Three of the four positions are open." Endor Labs rates it high, 8.2 on CVSS 3.1.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix took two tries
&lt;/h2&gt;

&lt;p&gt;Endor Labs reports that Docker first planned the fix for version 0.48.0 on October 13, and that "after the initial patch was live, the Docker team determined the fix was incomplete." Docker's own release notes for 0.51.0, dated November 3, confirm the final change: "&lt;code&gt;sbx cp&lt;/code&gt; blocks symbolic links in mounted directories from escaping onto other host paths." If you run Docker Sandboxes, upgrade to 0.51.0.&lt;/p&gt;

&lt;p&gt;This is also the second &lt;code&gt;sbx cp&lt;/code&gt; escape this year. An earlier one, CVE-2026-17106, was fixed in 0.38.0, and Kennedy notes that its containment still works. It checked paths inside the archive being copied, not where the copy itself started or landed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the second path gets missed
&lt;/h2&gt;

&lt;p&gt;Kennedy's own summary is the part worth keeping: "the isolation primitive worked, and the failure was a second path to the host that the security model did not account for." He found the same shape earlier this year in another agent sandbox, brig.&lt;/p&gt;

&lt;p&gt;The reason is ordinary. A sandbox is designed around its main channel, the shared folder, and that channel gets the allowlist and the tests. Side channels that move files on the operator's behalf, such as copy, export, download and artifact upload, look like the operator's own action, so they inherit the operator's trust. When the agent controls the path, that trust is spent wherever the agent points it.&lt;/p&gt;

&lt;p&gt;Anything that lets an agent produce files on a real machine has these side channels: a build step that collects outputs, a tool that saves an attachment, a job that syncs a report to shared storage. Each one needs the boundary checked again, at the moment it touches the disk.&lt;/p&gt;

&lt;h2&gt;
  
  
  A copy that stays inside the workspace
&lt;/h2&gt;

&lt;p&gt;Here is a sketch of how we would write the host side of a copy out of an agent's workspace in Node. It is ours, not Docker's code.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;constants&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;promises&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;fs&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="s2"&gt;"node:fs"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="s2"&gt;"node:path"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// Write bytes to a path inside the workspace, or refuse.&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;writeInside&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;workspace&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;relPath&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Uint8Array&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="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;root&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;realpath&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;workspace&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;target&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;root&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;relPath&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="c1"&gt;// Resolve every directory on the way, not only the file name.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;dir&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;realpath&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dirname&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dir&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="n"&gt;root&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;dir&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;root&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sep&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sb"&gt;`refused: ${relPath} resolves to ${dir}`&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="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;file&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;basename&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;target&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="c1"&gt;// O_NOFOLLOW: if the file itself is a link, fail.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;flags&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;constants&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="no"&gt;O_WRONLY&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;constants&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="no"&gt;O_CREAT&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;
    &lt;span class="n"&gt;constants&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="no"&gt;O_TRUNC&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;constants&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="no"&gt;O_NOFOLLOW&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;fh&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;file&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;flags&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mo"&gt;0o644&lt;/span&gt;&lt;span class="p"&gt;)&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;await&lt;/span&gt; &lt;span class="n"&gt;fh&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;writeFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;fh&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;file&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// print it, so a person sees where it landed&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice three things. The check runs on the resolved parent directory, which is exactly the position &lt;code&gt;-L&lt;/code&gt; left open. The final open refuses to follow a link in the last position. And the function returns the real path, so the caller can print it; Kennedy points out that a successful &lt;code&gt;sbx cp&lt;/code&gt; printed nothing, so a person watching had nothing to notice. A copy in is the same check on the source.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the sketch stops
&lt;/h2&gt;

&lt;p&gt;A path check and the write that follows it are two steps. An agent that can rewrite its workspace while the copy runs could swap a folder for a link in between. On Linux, &lt;code&gt;openat2&lt;/code&gt; with &lt;code&gt;RESOLVE_BENEATH&lt;/code&gt; makes the kernel enforce the boundary during the open itself, and Go code can use a library such as &lt;code&gt;cyphar/filepath-securejoin&lt;/code&gt;, which Kennedy notes was already linked into the &lt;code&gt;sbx&lt;/code&gt; binary. Use those where you can. The sketch shows the rule; the kernel enforces it.&lt;/p&gt;

&lt;p&gt;If your agent never writes outside a VM and nothing it produces is copied back, none of this applies. The moment its output lands on a machine you use, it does.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to take from it
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Upgrade Docker Sandboxes to 0.51.0, and until you have, resolve a path yourself before you copy to or from a workspace.&lt;/li&gt;
&lt;li&gt;List every channel that moves files between your agent and a real machine, not only the shared folder.&lt;/li&gt;
&lt;li&gt;Check the boundary on each one after resolving every part of the path, at the moment of the write.&lt;/li&gt;
&lt;li&gt;Show the resolved path whenever a copy succeeds.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you are putting agents in front of real systems and want the file paths checked before they ship, &lt;a href="https://calendly.com/voirlabs/30min?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=distribution" rel="noopener noreferrer"&gt;book a conversation&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Endor Labs, Peyton Kennedy, "Docker Sandboxes vulnerability lets AI agents access sensitive files outside their sandbox (CVE-2026-103916)", November 3, 2026: &lt;a href="https://www.endorlabs.com/learn/docker-sandboxes-vulnerability-lets-ai-agents-access-sensitive-files-outside-their-sandbox-cve-2026-103916" rel="noopener noreferrer"&gt;https://www.endorlabs.com/learn/docker-sandboxes-vulnerability-lets-ai-agents-access-sensitive-files-outside-their-sandbox-cve-2026-103916&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Docker, Docker Sandboxes release notes, version 0.51.0 (2026-11-03): &lt;a href="https://docs.docker.com/ai/sandboxes/release-notes/" rel="noopener noreferrer"&gt;https://docs.docker.com/ai/sandboxes/release-notes/&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>security</category>
      <category>docker</category>
      <category>node</category>
    </item>
  </channel>
</rss>
