Skip to main content

Overview

envtrap wraps your process in a monitored execution environment. When you run envtrap run node app.js, it:
  1. Spawns your node process as a monitored child with specially configured environment variables and Node.js options
  2. Starts a native-RSA in-memory MITM TLS proxy on 127.0.0.1 and routes all HTTP/HTTPS traffic through it
  3. Automatically sets NO_PROXY/no_proxy to exclude loopback addresses and user-configured domains from the MITM engine
  4. Injects ESM customization hooks (--import hooks.mjs) that intercept node:dns and node:child_process for both ESM and CommonJS
  5. Establishes a MessageChannel between the parent and the ESM loader thread so secret map updates are synced in real time
  6. Wraps process.env in a Proxy to detect runtime credential rotations and broadcast them immediately
  7. Pipes the child’s stdout and stderr through a scanner in the parent envtrap process
  8. After the child exits, prints a run summary and writes a structured .envtrap-report.json report

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 native crypto.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 (mode 0600)
  • NODE_EXTRA_CA_CERTS is 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
System CA injection (for cross-language coverage) uses safe execFileSync argument arrays — not template string shell commands — preventing shell injection vulnerabilities:
  • Linux (Debian/Ubuntu): copies to /usr/local/share/ca-certificates/ and runs update-ca-certificates
  • Linux (RHEL/Fedora): copies to /etc/pki/ca-trust/source/anchors/ and runs update-ca-trust
  • macOS: runs security add-trusted-cert with argument array
  • Windows: runs certutil -addstore with 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.
On exit, envtrap removes the injected CA from the system trust store automatically.

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 with 403 Forbidden
  • Otherwise the request is forwarded to the upstream server
HTTPS requests (server.on('connect') — CONNECT tunnel):
  • envtrap intercepts the CONNECT handshake
  • 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 cumulative Buffer.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
Domains listed in 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, spawnSync
  • exec, execSync
  • execFile, execFileSync
  • fork
For every call, if 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 block mode: throws a synchronous Error before any OS fork occurs
dns wrapper intercepts:
  • lookup, resolve, resolve4, resolve6, resolveAny
  • resolveCname, resolveMx, resolveNaptr, resolveNs, resolvePtr, resolveSoa, resolveSrv, resolveTxt
  • All promise equivalents via dns.promises.*
For every resolution hostname:
  1. Secret match: If the hostname contains any active secret value, reports the leak and throws synchronously in block mode
  2. High-entropy DNS tunneling: Splits the hostname on . and checks each subdomain label — if any label has length ≥ entropyMinLength and 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 via process.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:
  1. A MessageChannel is created in the main thread. port2 is transferred to the ESM loader thread via module.register()’s data argument.
  2. The loader thread’s exported initialize(data) hook receives the port and begins listening for { type: 'secrets_update', secretsMap } messages.
  3. In the main thread, process.env is wrapped in a JavaScript Proxy. When any property is set or deleteProperty is 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
  4. Both ports call .unref() so they never keep the event loop alive.

Step 5 — stdout / stderr Stream Scanning

The child process is spawned with stdio: ['inherit', 'pipe', 'pipe']. The parent envtrap process reads every chunk from both streams. For each chunk:
  1. The content is scanned against all loaded secrets for exact string matches
  2. If a match is found, flag() is called (alert printed, JSONL log written)
  3. 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
  4. In block mode, the child is sent SIGTERM immediately
Path exclusions and pre-redaction in the hook: The 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.json file is written to the current working directory containing all events in machine-readable JSON
  • If a logFile was configured, all events were already appended in JSONL format during the run
  • The system CA certificate is removed from the OS trust store
Exit code behavior:
  • 0 — Child exited normally with no blocks triggered
  • 1 — A block-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