
Malicious Packages on npm, PyPI, and Crates.io Caught Stealing SSH Keys and Cloud Credentials
What the reports say and why this campaign matters
A report published on May 24, 2026 described malicious packages across npm, PyPI, and crates.io that stole crypto wallet data, SSH keys, and cloud credentials from developer machines. That matters because this was not a single ecosystem bug. It was the same supply-chain pattern showing up in three different package-manager stacks.
The shape was simple:
- a package looked useful or ordinary,
- a maintainer or dependency path got trusted,
- install-time or build-time code ran,
- the package searched the local machine for secrets,
- the data left the host before anyone noticed.
That last step is the part teams miss. The attacker does not need to break into production first. A developer laptop, CI runner, or build container that already has access to better systems is enough.
The common thread across npm, PyPI, and crates.io
The package managers differ, but the trust model is similar. Each of them supports some form of executable behavior during dependency handling.
| Ecosystem | Common trigger | What gets trusted | Why it is dangerous |
|---|---|---|---|
| npm | lifecycle hooks like preinstall, install, and postinstall | the package’s scripts | code runs automatically when the dependency is added |
| PyPI | build steps, setup.py, PEP 517 build backends, import-time code | packaging metadata and build logic | code can run while building or the first time the module is imported |
| crates.io | build.rs, feature-gated code, transitive dependencies | build scripts and the dependency graph | build scripts execute during compilation, often before review catches them |
The main lesson is that none of these ecosystems need a browser exploit or a kernel exploit to become dangerous. They already have a mechanism that executes untrusted code inside a trusted environment.
Why credential theft from build machines is so damaging
A build machine is often more valuable than a laptop full of personal files. It can hold:
- SSH keys for Git access or server administration,
- cloud provider tokens for deployment,
- signing keys for artifacts,
- package registry credentials,
- browser sessions for admin consoles,
- container registry credentials,
- CI secrets that were never meant to leave the pipeline.
If an attacker steals a deploy token from a runner, they may not need to move laterally at all. They can use the trusted automation path to publish, deploy, or update infrastructure. That is why these incidents are not just “developer workstation hygiene” problems. They are identity theft for your delivery pipeline.
How malicious package installs usually turn into code execution
The exact implementation varies by ecosystem, but the pattern stays the same: the package author looks for a place where the manager will execute arbitrary code as part of the normal install or build flow.
npm lifecycle hooks and postinstall abuse
In npm, the highest-risk path is still lifecycle scripts. A package can define commands that run automatically when it is installed. That includes preinstall, install, and postinstall, and those scripts often run before the developer has even opened the package source.
That makes postinstall especially attractive. Many people still think of install as a download step, not an execution step. The attacker counts on that gap.
A safe review workflow starts by checking the manifest before installing:
npm view suspicious-package version scripts repository dist.tarball
npm pack suspicious-package --ignore-scripts
tar -tf suspicious-package-*.tgz | sed -n '1,40p'
What I usually check first:
- any lifecycle scripts in
package.json, - unusual use of
curl,wget,powershell,bash,node -e, orpython -c, - references to
os.homedir(),process.env,~/.ssh, or cloud SDK paths, - packages that do not need scripts but ship them anyway.
If a package only needs static JS and still includes a script hook, that is a red flag. It does not prove malicious intent, but it does raise the cost of trusting it.
PyPI install-time behavior and import-time traps
Python packaging has a different feel, but the risk is very similar. Code can run during build, especially when packages rely on legacy setup flows or custom build backends. Even when installation itself is clean, the first import can trigger top-level code in the module.
That gives attackers two entry points:
- build-time execution while the package is being prepared,
- import-time execution when a developer tests the package.
The safest route is to inspect the source distribution before installing it anywhere important.
pip download --no-deps --no-binary :all: suspicious-package -d /tmp/pypi-review
python -m tarfile -l /tmp/pypi-review/suspicious-package-*.tar.gz | sed -n '1,40p'
Then inspect the packaging files:
pyproject.tomlsetup.pysetup.cfgMANIFEST.in- top-level module files that execute on import
The thing I watch for is not just obvious network code. It is also the shape of the package. A package that claims to be a formatter, parser, or helper library should not be scanning SSH directories or reading browser storage.
Crates build scripts, feature flags, and transitive trust
Rust packages often feel safer because the ecosystem leans on compiled code and explicit dependencies. That confidence is useful, but it can also be misleading. build.rs is a full execution hook, and build scripts run during compilation with access to environment variables and the local filesystem.
Crates also introduce transitive trust. You may audit your direct dependency, but a build script can show up through a second or third layer of dependencies. Feature flags add another wrinkle: code can stay hidden until a build profile or target enables it.
For review, I usually inspect:
cargo metadata --format-version 1 --locked
cargo tree -e features
cargo build --locked --offline
Then I look at:
build.rs- features that enable native code or external tool execution,
- dependencies used only at build time,
- changes in maintainer ownership or publishing cadence.
A package that suddenly adds a build script after months of pure Rust releases deserves scrutiny. So does a dependency tree that starts pulling in native helpers for no obvious reason.
What the stolen data usually looks like on a developer machine
The report called out crypto wallets, SSH keys, and cloud credentials. On a real machine, that usually means the attacker was not chasing one giant secret. They were scraping whatever was easiest to grab quickly.
SSH private keys, agent sockets, and known_hosts traces
The obvious target is ~/.ssh. That includes private keys, config files, and any saved credentials used for Git or server access.
But attackers do not need only the key files themselves. They may also look at:
SSH_AUTH_SOCK, which points to an active agent socket,known_hosts, which reveals systems and hostnames you have connected to,config, which can reveal jump hosts, aliases, and preferred keys.
A stolen agent socket can matter even when the private key is not on disk. If the agent is unlocked, the attacker may be able to ask it to sign requests until the session ends. That is why a “no files were copied” report is not the same as “no credential exposure occurred.”
Cloud credentials in config files, env vars, and CLI caches
Cloud credentials usually live in more places than people expect:
- AWS shared credentials and config files,
- Google Cloud CLI authentication state,
- Azure CLI profiles and token caches,
- Docker registry auth files,
- Kubernetes config files,
- environment variables injected by the shell or CI system.
The danger is not limited to long-lived static keys. Refresh tokens, cached sessions, and metadata from CLI tools can be enough to mint new access. If the package stole a session token from a dev box, that token may still work long after the workstation is wiped.
Wallet extensions, browser profiles, and local app data
The report also mentioned crypto wallets. On a workstation, that often means browser-based wallet extensions, browser profile data, or local app storage.
The technical detail that matters is this: many desktop and browser apps keep encrypted or obfuscated state in user profile directories, and the local machine often holds the material needed to unwrap or reuse that state. An attacker does not need to understand every wallet implementation to know that a logged-in browser profile is worth searching.
For defenders, the practical takeaway is to treat browser sessions, wallet extensions, and application profiles as sensitive credential stores, not casual app state.
Reconstructing the attack chain without running the package unsafely
If you suspect a package, do not install it on your laptop and “see what happens.” Use a disposable environment with no production credentials, no reused SSH agent, and no shared home directory.
Review the manifest, lockfile, and publish metadata first
Start with the metadata, not the executable behavior.
For npm:
npm view suspicious-package version time scripts repository dist.tarball
For Python:
python -m pip index versions suspicious-package
For Rust:
cargo search suspicious-package
cargo info suspicious-package
What I want to know before anything else:
- when the package was published,
- whether the maintainer or namespace changed recently,
- whether the package version spiked with no history,
- whether scripts, build hooks, or native code were added recently,
- whether the lockfile actually points to the version you expected.
If the lockfile says one thing and the registry now serves another, that is a problem even if the package name looks familiar.
Trace filesystem reads, process launches, and outbound requests
When you do inspect behavior, do it in a sandbox that has empty credentials and recorded telemetry.
On Linux, strace is enough to show a lot of the shape:
strace -f -e trace=openat,execve,connect,sendto,recvfrom -o /tmp/install.trace \
npm install --ignore-scripts
For a more realistic test, run the package in a container or VM with:
- a read-only or empty home directory,
- no SSH agent forwarded in,
- no cloud CLI profiles mounted,
- outbound network logging,
- a separate test account.
Then look for patterns such as:
- reads from
~/.ssh,~/.aws,~/.config, browser profile folders, or token caches, - launches of shell interpreters, curl clients, or system utilities,
- DNS lookups or HTTP requests to unknown domains,
- environment variable enumeration.
Suspicious package behavior usually has a short signature. It reads a few files, starts one or two helper processes, and then makes a small number of network requests. Normal build noise is bigger and easier to explain.
Separate suspicious install-time activity from normal build noise
Not every filesystem read is malicious. Package managers and build tools often touch cache directories, compiler settings, or dependency manifests. The trick is to classify what you see.
A simple way to think about it:
| Observation | Often normal | More suspicious |
|---|---|---|
Reads package.json, pyproject.toml, Cargo.toml | yes | no |
| Reads compiler cache or build output directories | yes | maybe |
Reads ~/.ssh/id_* or known_hosts | no | yes |
| Reads cloud CLI token caches | no | yes |
Spawns sh, bash, cmd.exe, powershell, curl, wget | sometimes | much more suspicious if the package should not need them |
| Makes outbound requests during install | sometimes | suspicious if not documented |
The key is context. A packaging tool or native extension may need a compiler. A formatter usually does not need your SSH agent.
How to tell whether your project was exposed
Once a malicious package is confirmed, the next question is scope. You do not need perfect certainty about every file touched to know whether credentials need rotation.
Check CI logs, developer laptops, and dependency caches
I would split exposure review into three places:
- developer laptops,
- CI runners and ephemeral build agents,
- package caches and mirrors.
Look for installs that happened while the package was still available. If the package was later removed from the registry, that does not change the exposure window. Anyone who installed it before removal could still have executed the malicious code.
Search your logs for:
- dependency installation timestamps,
- unusual network egress during package install,
- build failures followed by repeated installs,
- changes in lockfiles that pulled the suspicious version,
- developer workstation telemetry that shows reads from sensitive directories.
Caches matter because they can preserve the malicious artifact after the registry entry disappears. A later build may reuse the cached package and replay the same compromise path.
Look for installs that happened before the package was removed
This is where incident response often gets sloppy. Teams see the package removed and assume the risk is over. It is not.
The real question is: which systems installed the package during the active window?
If you cannot answer that immediately, review:
- package manager logs,
- CI job history,
- developer shell histories where allowed by policy,
- build artifact timestamps,
- registry proxy logs, if you have them.
If the package was used in a monorepo or base image, assume the blast radius is larger than a single repo.
Decide what must be rotated, revoked, or reissued
Not every secret needs the same response, but package-install credential theft should push you toward aggressive rotation.
| Data type | Suggested response |
|---|---|
| SSH private keys | revoke and reissue if they were present on exposed hosts |
| SSH agent sessions | treat as exposed while the agent was active |
| Cloud access keys | rotate immediately |
| Cloud session tokens | revoke and reauthenticate |
| Package registry tokens | rotate and review publish history |
| Browser sessions for admin consoles | invalidate sessions and re-login |
| Wallet-related credentials or browser profile secrets | follow the application’s recovery and revocation process |
If a host had access to production and you cannot prove the malicious package was blocked before execution, assume the credentials were exposed.
Containment steps that actually reduce risk
Containment is about shrinking the attacker’s options, not writing a comforting incident note.
Quarantine affected hosts and stop new builds from the same environment
The first move is to isolate the machines that installed the package. That includes developer laptops if they had access to credentials, and especially CI runners if they were using shared secrets.
Practical actions:
- remove the host from the network or isolate it with policy controls,
- stop reusing the same runner image,
- pause deploys that depend on the exposed environment,
- capture telemetry before wiping anything.
If a runner image is compromised, replacing the image is better than trying to clean it in place.
Rotate SSH keys, cloud tokens, API keys, and wallet-related secrets
The report’s secret types point to a broad rotation scope.
Prioritize:
- SSH keys used for Git or server access,
- cloud provider access and refresh tokens,
- registry tokens,
- CI secrets,
- any wallet or browser sessions that were active on the host.
Rotate from the outside in. If one secret unlocks another system, start with the outermost access path and work inward. That keeps the attacker from using old credentials to mint fresh ones while you are still cleaning up.
Rebuild from known-good sources and clear cached dependencies
Do not trust a build environment that may have replayed the malicious package from cache.
That means:
- clearing package manager caches,
- rebuilding from pinned, known-good versions,
- restoring from trusted lockfiles,
- avoiding “latest” during the recovery window,
- rebuilding base images instead of patching in place.
If you use a private registry proxy or artifact mirror, check whether it cached the malicious package. The mirror is part of the trust boundary too.
Detection ideas for teams that consume packages at scale
The easiest way to catch this class of incident earlier is to make install-time behavior visible.
Hunt for unexpected network egress during dependency installation
A good baseline is to treat dependency installation as a monitored event, not a background chore.
Alert on:
- outbound HTTP or DNS during package install,
- requests to rare or newly registered domains,
- package installs that touch user profile paths,
- network traffic from build steps that usually run offline.
If your CI system can log network flows by job, this becomes much easier. If it cannot, capture at least process-level and DNS-level telemetry during builds.
Flag new binaries, shell commands, or script interpreters in install logs
Most malicious packages have to do something noisy to get useful data out. That something is often visible.
Watch for:
sh,bash,zsh,cmd.exe,powershell,curl,wget,Invoke-WebRequest,- ad hoc Python, Node, or Perl one-liners,
- base64 decoding or archive unpacking during install,
- unexpected native binaries appearing in the package tree.
A package that suddenly starts shelling out is not automatically malicious, but it should trigger review.
Baseline package provenance and watch for sudden maintainer changes
A lot of package abuse starts with trust drift rather than code diff drift.
Track:
- who maintains the package,
- when ownership or publish rights changed,
- whether the package went dormant and then came back active,
- whether a new version adds install hooks or build scripts,
- whether the release cadence changed sharply.
A package that is quiet for months and then publishes a fresh version with new execution hooks is worth a manual review, even if the version number looks modest.
Hardening npm, PyPI, and Cargo workflows against repeat incidents
The right response is not “never use dependencies.” It is to narrow what they can do and where they can do it.
Pin versions, verify lockfiles, and limit transitive trust
This is the boring but effective layer.
- pin versions in lockfiles,
- review dependency diffs before merging,
- avoid pulling new majors or unreviewed transitive updates automatically,
- reject packages that introduce build hooks without a strong reason,
- keep the dependency graph as small as practical.
A lockfile is not a defense by itself, but it does prevent surprise upgrades from silently widening the blast radius.
Use isolated build users, ephemeral credentials, and egress controls
Build systems should not have more access than they need.
Good defaults:
- dedicated build users with empty or minimal home directories,
- no long-lived SSH agent forwarding into build jobs,
- short-lived cloud credentials,
- separate identities for build, publish, and deploy,
- egress restrictions for jobs that do not need internet access,
- container or VM isolation for dependency resolution.
If a package tries to phone home and the build network blocks it, you have already reduced the impact.
Prefer short-lived secrets and centralized secret storage over local files
The report is a reminder that local files are convenient for attackers.
Better patterns:
- use workload identity or OIDC where possible,
- store secrets in a centralized manager,
- mint tokens just before use,
- scope tokens to the smallest practical permissions,
- make revocation easy and routine.
The fewer reusable secrets sitting on disk, the less useful an install-time compromise becomes.
A practical team checklist for the next sprint
Package review steps before merge
- Review any new dependency or major version bump.
- Reject packages that add install scripts, build scripts, or native helpers without a clear reason.
- Inspect
package.json,pyproject.toml, orCargo.tomlbefore pulling the dependency in. - Check whether the package reads from the filesystem or environment during install.
- Confirm the lockfile still points to the version you actually reviewed.
CI and workstation controls to add immediately
- Block outbound network access from build jobs that do not need it.
- Use separate credentials for build, test, and deploy.
- Forward no SSH agent by default.
- Keep developer and CI home directories free of long-lived cloud tokens where possible.
- Log dependency installation events and package-manager exit codes.
- Clear caches when you suspect a malicious artifact was pulled.
Incident response questions to answer before declaring containment
- Which hosts installed the package while it was available?
- Did any of those hosts have access to production, signing keys, or deploy tokens?
- Were any SSH keys, cloud tokens, or registry tokens present on those hosts?
- Did the package make outbound network requests during installation?
- Were cached copies of the package reused later?
- Have all affected secrets been rotated and all old sessions revoked?
If you cannot answer those questions, containment is probably not complete yet.
Further Reading
- npm lifecycle scripts documentation
- Python packaging user guide: build backends and
pyproject.toml - The Rust Reference: build scripts
- OWASP Software Supply Chain Security Top 10
Conclusion
The important detail in this campaign is not that one ecosystem was weaker than another. It is that all three ecosystems make it possible for package installs to execute code in a trusted environment, and developers often keep exactly the kind of credentials an attacker wants on that same machine.
So the defense is not “trust packages less” in the abstract. It is to make package install behavior visible, reduce what build environments can reach, and assume that any host that installed a malicious dependency may have exposed more than source code.
If you treat dependency installation like a controlled execution event instead of a file transfer, you will catch more of these incidents before they turn into credential theft.


