Recovering a removed npm malware file
Recovering @goodjavascript/dotenv@1.0.0 from Software Heritage, checking its identity against jsDelivr, and inspecting its response-to-code execution path.
The npm tarball and jsDelivr file URLs for @goodjavascript/dotenv@1.0.0 returned HTTP 404. Software Heritage still held its 840-byte entry point, and jsDelivr retained a file digest that matched the recovered bytes. That was enough to recover the file, check its identity and inspect its behavior.
This case shows how an older archive snapshot and surviving CDN metadata can help recover and corroborate a release file after its usual download URLs stop serving it.
This is the scoped package @goodjavascript/dotenv, not the unscoped dotenv package. OSV advisory MAL-2026-11212 classifies the scoped package as malicious and describes version 1.0.0 in its analysis.
| Source | Observed result |
|---|---|
| npm release tarball | HTTP 404 |
jsDelivr index.js URL |
HTTP 404 |
| jsDelivr file manifest | Retained size and SHA-256 for index.js |
| Software Heritage | Recovered bytes matched that digest |
These are October 4, 2026 observations: the endpoint availability checks ran at 17:28 UTC, followed by the file identity check and a separate archive-discovery reproduction. The 404s describe those URLs at those times, not every possible copy of the release. My original investigation was submitted in July; the October checks are a reproduction for this article.
The missing piece
Before my July 31 submission, the advisory contained a generic compromise warning. The record saved earlier that day had no explanation of the entry point or execution trigger. Recovering the code would make those details inspectable.
The usual download paths were no longer useful in the October check. npm’s package metadata listed only 0.0.1-security, and the exact 1.0.0 metadata endpoint also returned 404. Software Heritage supplied the missing file; jsDelivr supplied an independently retained digest to check it against.
Finding the archived release
The July report already linked the archived release. For the October reproduction, I checked whether an investigator could reach it starting with the package name and version, without using the supplied release hash as a lookup key.
Software Heritage archives npm packages under their npm page URLs. This package’s origin is:
https://www.npmjs.com/package/@goodjavascript/dotenv
Using the documented origin and visit API, the discovery path was:
- Look up the package origin, then request its visits. Each visit identifies a snapshot.
- Inspect the snapshot branches for
releases/1.0.0, looking through the returned visits. - Follow the matching branch to its release object, with identifier
7f86f9ba0e8d621eda44b4412560d85ddf83fc83.
That independently reproduced the link in the July report. The dates above are archive visit dates, not package publication dates. The discovery observations retain both visits, their snapshot branches and the selected release.
Recovering the file and checking its identity
The release object points to a directory. Following its package/ entry reaches the archived package directory, which records the content hashes of package.json and index.js.
I retrieved and hashed package.json. It identifies @goodjavascript/dotenv, version 1.0.0, with index.js as its main entry point. I then hashed the recovered entry point and compared it with the archive directory entry and the jsDelivr manifest for that exact version:
File: index.js
Size: 840 bytes
SHA-256:
a5666532c367714568c5d112300e41d3c3fd6b8665c94f2bb98f5d74fc4d2d6c
All three digests agreed. jsDelivr encodes its SHA-256 in base64; the verifier decodes it before comparison. The recorded sizes also matched. This connects the recovered bytes to an archived package manifest and an independently retained file identity.
What static inspection established
The entry point is short and unobfuscated. I read it as text, without importing it or contacting its embedded destinations. It schedules a ten-second interval at module load. The exported config() function is empty; calling it is not what starts the timer.
The timer callback collects a host profile with systeminformation.getStaticData(), then attempts to POST it to a fixed HTTP address, with the operating-system UUID in the path. It parses the response as JSON and treats a truthy res.cute field as JavaScript.
Static excerpt, not a runnable example. The surrounding timer, network requests and context construction are omitted below. Comments are added; the shown statements are from the verified file.
// Inside the parsed-response callback; surrounding code omitted.
if (!res.cute) {
return;
}
// Omitted: ctx is built with a result callback and ...global.
vm.createContext(ctx);
const script = new vm.Script(res.cute); // Response text becomes code.
script.runInContext(ctx); // The compiled script is run.
The context’s result callback can POST data back, paired with a task identifier from the response. This response-to-code path supports the loader behavior described in the accepted advisory analysis. Node’s vm documentation warns that the module is not a security mechanism.
Why installation-script checks would miss this
The recovered manifest has no scripts field. Reviewing lifecycle scripts alone would miss this entry-point execution path: loading the module schedules the timer described above.
The interval calls unref(), so it does not keep an otherwise finished Node process alive. systeminformation must be resolvable, fetch must be available, and the process must stay alive long enough for the callback. Compiling and running response code also depends on the request succeeding and returning suitable data. Static inspection identifies that path; it does not show it ran on a victim.
Reproduce the recovery
Download and inspect the standalone Python verifier, then run it with Python 3:
python3 verify.py > result.json
It uses only the standard library. Starting from the package’s npm origin URL, it looks through the returned visits and snapshots for releases/1.0.0, follows the directory chain, checks the package manifest and compares the file hashes. It downloads the entry point into memory for hashing but never saves, installs or executes it. It contacts only Software Heritage and jsDelivr.
The case-specific lookup examines at most the first 20 visits. If further pagination would be needed to find the release, it stops with an explicit error rather than claiming the release is absent. Source failures, unexpected sizes and hash mismatches also produce a nonzero exit.
The verification object in the saved discovery run is:
{
"package": "@goodjavascript/dotenv",
"version": "1.0.0",
"file": "index.js",
"bytes": 840,
"sha256": "a5666532c367714568c5d112300e41d3c3fd6b8665c94f2bb98f5d74fc4d2d6c",
"archive_and_cdn_match": true
}
The full output includes the discovery steps, source URLs, request times, response hashes and selected manifest fields. The earlier file identity observations preserve the original October check, which began with the known release identifier. Neither download contains the recovered JavaScript. To reproduce the behavioral analysis, follow the identified archive file and inspect it as text; the verifier only checks identity.
My contribution to the public record
I submitted OpenSSF malicious-packages PR #1413 on July 31, 2026; it merged on August 3. It added the recovered file’s behavior, runtime trigger, network indicators and recovery references to an existing advisory. MAL-2026-11212 records my credit as ANALYST. The October reproduction adds the package-to-release lookup and downloadable verifier.
What the recovery does and does not establish
This recovered one entry-point file, with its package manifest checked for context. It did not reconstruct the complete npm tarball, recover the remote responses or establish execution on a victim. Future archive availability is not guaranteed.
A registry 404 need not end a file investigation. In this case, an older archive visit supplied the bytes and a retained CDN digest corroborated their identity. Keeping the origin, snapshot, manifest and file hashes together lets another investigator repeat the recovery and check the behavior against the same file.