Overview
envtrap wraps your process in a monitored execution environment. When you runenvtrap run node app.js, it:
- Spawns your
nodeprocess as a monitored child with specially configured environment variables and Node.js options - Starts a native-RSA in-memory MITM TLS proxy on
127.0.0.1and routes all HTTP/HTTPS traffic through it - Automatically sets
NO_PROXY/no_proxyto exclude loopback addresses and user-configured domains from the MITM engine - Injects ESM customization hooks (
--import hooks.mjs) that interceptnode:dnsandnode:child_processfor both ESM and CommonJS - Establishes a
MessageChannelbetween the parent and the ESM loader thread so secret map updates are synced in real time - Wraps
process.envin aProxyto detect runtime credential rotations and broadcast them immediately - Pipes the child’s
stdoutandstderrthrough a scanner in the parent envtrap process - After the child exits, prints a run summary and writes a structured
.envtrap-report.jsonreport
Architecture
Step 1 — In-Memory Certificate Authority
Before spawning the child, envtrap generates a 2048-bit RSA Root CA entirely in RAM using Node’s nativecrypto.generateKeyPairSync (C++ OpenSSL bindings).
- Key generation takes ~5ms (previously ~200ms using pure-JS
node-forge) - The private key never leaves memory and is never written to disk
- Only the public certificate PEM is written to
os.tmpdir()/envtrap-ca.crt(mode0600) NODE_EXTRA_CA_CERTSis set to this cert path so the child Node.js process trusts envtrap’s proxy- Domain-specific TLS certificates are generated on-demand and cached in memory per hostname
- Certificate serial numbers are generated using
crypto.randomBytes(20)— RFC 5280 compliant, no collision risk
execFileSync argument arrays — not template string shell commands — preventing shell injection vulnerabilities:
- Linux (Debian/Ubuntu): copies to
/usr/local/share/ca-certificates/and runsupdate-ca-certificates - Linux (RHEL/Fedora): copies to
/etc/pki/ca-trust/source/anchors/and runsupdate-ca-trust - macOS: runs
security add-trusted-certwith argument array - Windows: runs
certutil -addstorewith argument array (no UAC elevation required)
System CA injection is skipped silently if the process does not have root/administrator privileges. Only
NODE_EXTRA_CA_CERTS (Node.js-only trust) is used in that case.Step 2 — MITM TLS Proxy (network channel)
envtrap starts an HTTP server on 127.0.0.1 at a random OS-assigned port. All child process HTTP/HTTPS traffic is routed through it via standard proxy environment variables.
Automatic loopback bypass:
NO_PROXY and no_proxy are automatically set to localhost,127.0.0.1,::1,0.0.0.0,127.* plus any domains listed in exclusions.domains. This ensures local Redis, Docker services, and Kubernetes sidecars are never routed through the MITM engine.
Plain HTTP requests (server.on('request')):
- Request body, headers, and URL are scanned for active secrets
- If a secret is found and channel mode is
block, envtrap responds with403 Forbidden - Otherwise the request is forwarded to the upstream server
server.on('connect') — CONNECT tunnel):
- envtrap intercepts the
CONNECThandshake - Generates (or retrieves from cache) a domain-specific TLS certificate signed by the Root CA
- Impersonates the target server by creating an in-memory
tls.Server - Sliding-window chunk scanning: each incoming TCP chunk is scanned as
overlap(200 chars) + new_chunk— no cumulativeBuffer.concat, eliminating O(n²) memory pressure - If mode is
block, destroys the connection immediately - Otherwise establishes an upstream TLS socket and forwards data bidirectionally
- Maximum payload buffered for scanning: 1 MB
exclusions.domains bypass all scanning — their connections are forwarded directly without inspection, and they are also automatically added to NO_PROXY.
Step 3 — ESM + CJS Module Hooks (child_process and dns channels)
The hooks.mjs file is injected into the child process via NODE_OPTIONS="--import hooks.mjs". It runs before any user code and sets up two parallel interception paths.
CommonJS (require()) — Module.prototype.require patch
Overrides Module.prototype.require so that when any CJS module calls require('child_process') or require('dns'), it receives a wrapped module object instead of the native one.
ESM (import) — module.register() Customization Hooks
Registers a custom resolve hook via module.register(import.meta.url, { data: { port: port2 }, transferList: [port2] }). When any ESM import 'child_process' or import 'dns' is detected, the hook redirects to internal virtual URLs (envtrap:child_process and envtrap:dns) and injects wrapped implementations through the load hook.
The transferList carries a MessagePort to the loader thread — used for real-time secret synchronization (see Step 4).
What the wrappers intercept
child_process wrapper intercepts:
spawn,spawnSyncexec,execSyncexecFile,execFileSyncfork
options.env is present, each key is checked against the active secrets map. If a match is found:
- Reports to the parent via
stderr:[envtrap] Child process leak: secret "KEY" passed to: COMMAND - In
blockmode: throws a synchronousErrorbefore any OS fork occurs
dns wrapper intercepts:
lookup,resolve,resolve4,resolve6,resolveAnyresolveCname,resolveMx,resolveNaptr,resolveNs,resolvePtr,resolveSoa,resolveSrv,resolveTxt- All promise equivalents via
dns.promises.*
- Secret match: If the hostname contains any active secret value, reports the leak and throws synchronously in
blockmode - High-entropy DNS tunneling: Splits the hostname on
.and checks each subdomain label — if any label has length ≥entropyMinLengthand Shannon entropy ≥entropyThreshold, a tunneling warning is emitted
Step 4 — Real-Time Secret Synchronization
envtrap v2.1 introduces a live secrets synchronization path between the main thread and the ESM loader thread. The problem: Node.js ESM customization hooks run in an isolated loader thread. The secrets map was previously passed as a static JSON string viaprocess.env.__ENVTRAP_SECRETS_MAP__ at startup — meaning dynamically-rotated credentials (e.g. fetching a short-lived token from HashiCorp Vault) would never be detected.
The solution:
- A
MessageChannelis created in the main thread.port2is transferred to the ESM loader thread viamodule.register()’sdataargument. - The loader thread’s exported
initialize(data)hook receives the port and begins listening for{ type: 'secrets_update', secretsMap }messages. - In the main thread,
process.envis wrapped in a JavaScriptProxy. When any property issetordeletePropertyis called on it at runtime:- The mutation is reflected in the live
secretsMap - The updated map is immediately broadcast to the loader thread over
port1
- The mutation is reflected in the live
- Both ports call
.unref()so they never keep the event loop alive.
Step 5 — stdout / stderr Stream Scanning
The child process is spawned withstdio: ['inherit', 'pipe', 'pipe']. The parent envtrap process reads every chunk from both streams.
For each chunk:
- The content is scanned against all loaded secrets for exact string matches
- If a match is found,
flag()is called (alert printed, JSONL log written) - All known secret values are redacted in the output: replaced with
[REDACTED: SHA256:<first 8 hex chars>]before being written to the parent’s stdout/stderr - In
blockmode, the child is sentSIGTERMimmediately
hooks.mjs also overrides process.stdout.write and process.stderr.write inside the child process. Before writing to the pipe, it checks if the calling file (via stack trace) matches any glob in exclusions.paths. If it does, the chunk is pre-redacted to [REDACTED: PATH_EXCLUDED], meaning the parent’s scanner sees only sanitized output and no alert is raised.
Step 6 — Run Summary and Leak Report
After the child process exits:- The terminal prints a run summary grouped by channel showing leak count and secret names
- A structured
.envtrap-report.jsonfile is written to the current working directory containing all events in machine-readable JSON - If a
logFilewas configured, all events were already appended in JSONL format during the run - The system CA certificate is removed from the OS trust store
0— Child exited normally with no blocks triggered1— Ablock-mode detection caused the child to be killed, or the child itself exited with code 1- The child’s own exit code is forwarded if no block occurred
