testmetest / CementBeetleSteeple
testmetest npm campaign: native-addon camouflage, AI IDE hooks and OIDC trusted-publishing abuse link 42 hijacked repos to a hidden npm publisher
Posted · Updated · Spectrum Delta Research Team
Summary
JFrog Security Research (Oct 26, A/3) links 42 armed third-party GitHub repositories across 11 legitimate owners to the testmetest npm campaign, exposing the machinery behind it. Malicious npm releases deliver a worm that steals GitHub and npm credentials, which provide access to third-party repositories; inside hijacked repositories the campaign leaves smoke-test placeholders or armed payloads — execution hooks for AI coding assistants and IDEs (a .claude/settings.json SessionStart hook running 'node .github/init.js || bun .github/init.js', with equivalent triggers planted for Gemini, Cursor MCP configuration and VS Code folder-open tasks) plus 12.9–13.7 MB polymorphic wrappers that all decode to the same hidden CI-publisher payload. The recovered publisher's intended loop: short-lived chore/ci-<hex> branches carry a root-level ci.js and an Actions workflow, and the decoded publisher harvests GitHub tokens, searches files for token-shaped values, and implements GitHub OIDC-to-npm token exchange (the keyOwnerProof/publicKeyRequest trusted-publishing flow) to rebuild the named target package with an injected postinstall and attempt to publish it — feeding package delivery back into repository compromise. Malicious releases were published to npm (data-pipeline-testmetest@2.0.1 at 11:13:23 UTC on October 21, though the primary says that release was likely published manually or from a run subsequently deleted — no retained Actions run aligns with it). The campaign later switched camouflage: a precompiled native addon (addon.node / addon_win.node) containing no payload of its own, which at runtime locates its own folder, decrypts a dotfile named .napi_cache into JavaScript, drops it hidden in the temp directory and runs it as a detached node process — the primary's characterization is that the malicious logic moves 'out of readable JavaScript and into compiled code that refuses to run in CI, containers and debuggers'. The attacker-owned CementBeetleSteeple repository doubles as a live encrypted data sink, having received 27 data/snap-*.json envelopes (384,825 bytes, October 22–24) committed with the message 'chore: sync', and fake CodeQL workflows dumped real npm tokens from the environment into one-day artifacts.
Breakdown
Adversary
Unattributed operator behind the testmetest campaign
Procedures (2)
Postinstall writes hidden temp JS, runs it detached
Execution: postinstall addon drops hidden JavaScript and launches it as a detached node process
- T1059.007Command and Scripting Interpreter: JavaScript
Logs
Process-creation events (Sysmon EID 1 / auditd execve) · postinstall child process; detached node executing temp-drop file
File-creation events (Sysmon EID 11 / auditd watches) · .napi_cache in package dir; hidden /tmp/.<pid> and %TEMP%\.nc files
Detectors
No known detector
Click to expand remaining (1) procedureHide remaining procedures
Claude SessionStart hook configured to launch init.js
Execution: planted Claude SessionStart hook positioned to run init.js at session start
- T1546Event Triggered Execution
Logs
Process-creation events (Sysmon EID 1 / auditd execve) · postinstall child process; detached node executing temp-drop file
Detectors
No known detector
Indicators of compromise
Hunt only
((JFrog (https://research.jfrog.com/post/beyond-testmetest-42-hijacked-github-repos/), A/3)) — registry inventory of 25 package names and 82 known version records; malicious releases with versions: data-pipeline-testmetest 2.0.1 (published 11:13:23 UTC, October 21); event-bus-lite-testmetest 0.0.1/0.0.2/0.0.6; event-emitter-testmetest 1.0.6/1.0.7/3.0.0/3.0.1/4.1.0/5.0.0; log-rotate-testmetest 1.0.1; log-sink-testmetest 1.0.1; net-codec-testmetest 1.0.1; npm-publish-testmetest 1.0.1; pixel-map-testmetest 0.0.1/0.0.2/0.0.3; retry-utils-testmetest 1.0.3; test-brotli-testmetest 1.0.0; test-brotli2-testmetest 1.0.0; @iobandamwer/cache-helper 1.0.2–1.0.8; @iobandamwer/native-helper 0.0.1; path-stream-utils-testmetest 0.0.1/0.0.2 (each with JFrog Xray detection IDs: XRAY-1141820, XRAY-1143287, XRAY-1141833, XRAY-1141834, XRAY-1147293, XRAY-1143129, XRAY-1143130, XRAY-1147278, XRAY-1147281, XRAY-1147282 and others). Network: scanner C2 43[.]228[.]157[.]68:80 with download pattern /api/dl/<amd64|386|arm|arm64> and scanner path /tmp/.svc; exfiltration endpoint hxxps[:]//aiarmor[.]website/v1/api. Campaign markers: commit-search marker msync-3f7a; token dead-drop commit prefix 'AutoSyncComplete:'; live-sink commit message 'chore: sync'; publisher branch pattern chore/ci-<hex>; propagation branch pattern deps/upd-<digits>; infection commit marker 'shellpanel execution smoke'; commit trailer 'skip-checks:true'; shellpanel marker tool SHA-256 17614e98b613b17d0e1bb7125ab203715dd397661b897d8d18dbb77864c1839f. GitHub operator account: CementBeetleSteeple (17 campaign repositories; stable-scripts-9965 data sink with 27 data/snap-<timestamp>-<n>.json envelopes, 384,825 bytes, October 22 18:19:21 UTC through October 24 16:31:03 UTC); native addon loader hxxps[:]//raw[.]githubusercontent[.]com/CementBeetleSteeple/node-cache-utils/main/loader.js. Files: addon.node, addon_win.node, .napi_cache (XOR key 0x96, identical key and format across platforms); drop paths /tmp/.<pid>, /tmp/.hc (Linux), %TEMP%\.nc<PID>.js (Windows); payload path .github/init.js; fake CodeQL artifact scan-output; persistence services net-health-checker, pkg-runtime; persistence paths ~/.local/bin/net-health-checker.sh, ~/.config/net-health-checker/, ~/.local/share/runtimes/sync.py, /var/tmp/.fontconfig_state; AI/IDE hooks .claude/settings.json, .gemini/settings.json, .cursor/mcp.json, .cursor/rules/setup.mdc, .vscode/tasks.json. Assessed 2026-10-26, hunt-only — package names and the operator account are perishable registry/GitHub objects; the durable hunt value is behavioral: the native-addon postinstall pattern with .napi_cache, the planted AI IDE hook files, the chore/ci- publisher branches, the OIDC trusted-publishing exchange, and the 'chore: sync' sink commits.
Detailed
Registry inventory: 25 package names, 82 known version records. Malicious releases with versions (and JFrog Xray IDs where published): data-pipeline-testmetest 2.0.1 (XRAY-1141820; published 11:13:23 UTC, October 21); event-bus-lite-testmetest 0.0.1/0.0.2/0.0.6 (XRAY-1143287); event-emitter-testmetest 1.0.6/1.0.7/3.0.0/3.0.1/4.1.0/5.0.0 (XRAY-1141833); npm-publish-testmetest 1.0.1 (XRAY-1141834); retry-utils-testmetest 1.0.3 (XRAY-1147293); test-brotli-testmetest 1.0.0 (XRAY-1143129); test-brotli2-testmetest 1.0.0 (XRAY-1143130); @iobandamwer/cache-helper 1.0.2–1.0.8 (XRAY-1147278); @iobandamwer/native-helper 0.0.1 (XRAY-1147281); path-stream-utils-testmetest 0.0.1/0.0.2 (XRAY-1147282); plus log-rotate-testmetest 1.0.1, log-sink-testmetest 1.0.1, net-codec-testmetest 1.0.1, pixel-map-testmetest 0.0.1/0.0.2/0.0.3 — each with benign sibling versions distinguished in the primary's table. Network: scanner C2 43[.]228[.]157[.]68:80 (download pattern /api/dl/<amd64|386|arm|arm64>, scanner path /tmp/.svc); exfiltration endpoint hxxps[:]//aiarmor[.]website/v1/api; native addon loader hxxps[:]//raw[.]githubusercontent[.]com/CementBeetleSteeple/node-cache-utils/main/loader.js. Campaign markers: msync-3f7a (commit-search marker); 'AutoSyncComplete:' (token dead-drop commit prefix); 'chore: sync' (live-sink commit message); chore/ci-<hex> (publisher branches); deps/upd-<digits> (propagation branches); 'shellpanel execution smoke' (infection commit marker); 'skip-checks:true' (commit trailer); shellpanel marker tool SHA-256 17614e98b613b17d0e1bb7125ab203715dd397661b897d8d18dbb77864c1839f. GitHub operator account: CementBeetleSteeple — 17 campaign repositories, including stable-scripts-9965 (live data sink: 27 data/snap-<timestamp>-<n>.json envelopes, 384,825 bytes, October 22 18:19:21 UTC through October 24 16:31:03 UTC). Files and paths: addon.node, addon_win.node, .napi_cache (XOR key 0x96, identical across platforms); drop paths /tmp/.<pid> and /tmp/.hc (Linux), %TEMP%\.nc<PID>.js (Windows); payload path .github/init.js; fake CodeQL artifact scan-output (one-day expiry); persistence services net-health-checker, pkg-runtime; persistence paths ~/.local/bin/net-health-checker.sh, ~/.config/net-health-checker/, ~/.local/share/runtimes/sync.py, /var/tmp/.fontconfig_state. AI/IDE hooks: .claude/settings.json (SessionStart, matcher '*', 'node .github/init.js || bun .github/init.js'), .gemini/settings.json, .cursor/mcp.json, .cursor/rules/setup.mdc, .vscode/tasks.json. Publisher scale: 68 pushed chore/ci- branch heads, 16 distinct polymorphic ci.js builds (12.9–13.7 MB wrappers), decoded publisher ~2.2 MB; environment contract WORKFLOW_ID, REPO_ID_SUFFIX, TARGET_PACKAGES. Credential evidence: 29 captured scan-output artifacts with three distinct real-format npm tokens rotated across six repositories. All from the primary (JFrog (https://research.jfrog.com/post/beyond-testmetest-42-hijacked-github-repos/), A/3).
Sources
Grades are source reliability (A–F) and information credibility (1–6).
Questions, detections or sightings of testmetest / CementBeetleSteeple? Bring them to the community.
Discuss in the Tradecraft Slack