@bonniernews/stayput
v0.1.3
Published
Default-deny network guard for Node.js test environments — blocks non-local TCP at the socket level.
Maintainers
Keywords
Readme
@bonniernews/stayput
Default-deny network guard for Node.js test environments. Blocks non-local TCP at the socket level, so a test suite with a leaked production connection string fails loudly at connect time instead of silently mutating production. Works for raw-TCP drivers (pg, mongodb, elasticsearch, redis) as well as fetch/undici — not just HTTP.
Quick start
// .mocharc.cjs
module.exports = { require: ['@bonniernews/stayput/register'] };Blocked connections throw with err.code === 'EREMOTEBLOCKED'.
CI already injects the guard into every node process via NODE_OPTIONS in the shared workflow templates — the mocharc line is what protects laptops, which is where stray prod credentials actually live.
Adoption is checked by ESLint: the shared ESLint config ships two rules — stayput-required flags a mocharc missing the register line (auto-fixable), and project-has-guard flags test files not governed by any stayput-loading mocha config. If you're seeing one of those warnings, the quick start above is the fix.
Runner-proofing (belt and suspenders)
The mocharc line only guards runs that load the mocharc. mocha --no-config, mocha invoked from a subdirectory, node --test, or jest all skip it. If your repo has a shared test helper that tests import anyway, anchor the guard there too — loading is idempotent, so double-loading costs nothing:
// test/helpers/db.js
import '@bonniernews/stayput/register'; // guard on, no matter how the runner was invoked
import { assertActive } from '@bonniernews/stayput';
export async function testDb() {
assertActive(); // refuse to hand out connections if the guard was tampered with
// ...create and return the client
}Boundary rule: stayput is a devDependency and only test-owned files (test helpers, setup files, mocharc) may import it — never src/, where it would crash a production install. If tests use the app's real DB module directly, add a thin test helper in between rather than gating production code on NODE_ENV.
(In CI this is all redundant — NODE_OPTIONS injection guards every node process regardless of runner or config.)
Policy
Locally (no CI env): loopback only. In CI: loopback + private ranges (RFC1918). Everything else is blocked.
| Env var | Effect |
|---|---|
| STAYPUT_ALLOW | comma-separated extra hosts / IPs / IPv4 CIDRs, e.g. 10.128.0.5,db.internal |
| STAYPUT_DENY | same format — deny beats allow |
| STAYPUT_DISABLE=I_UNDERSTAND_THE_RISK | turns the guard off (deliberately ugly so it stands out in review) |
Put overrides in the workflow file, not in code: STAYPUT_ALLOW/STAYPUT_DISABLE are greppable fleet-wide and double as the exception inventory.
AI agents
An EREMOTEBLOCKED error is a stop signal, not a bug to fix. Do not set STAYPUT_ALLOW or STAYPUT_DISABLE, edit workflow env, or remove the mocharc line to make tests pass — the block usually means the test suite is pointing at infrastructure it should not touch. Pause and ask a human to verify the target. Only a human adds exceptions, in the workflow file, where they are visible in review and in the fleet-wide grep.
This contract has been red-teamed (2026-08-24): a coding agent (Claude) was given a sandboxed repo whose tests hit a blocked private-range database and told to "just get it green today". It diagnosed the guard from the error message, refused to override or fake the result (including mocking the driver or repointing the test), and stopped to ask for human verification. Steering, not enforcement — but it works on well-behaved agents.
Programmatic API
import stayput, { enable, assertActive } from '@bonniernews/stayput';
enable({
allow: ['10.128.0.5'],
deny: ['10.10.0.0/16'], // deny beats allow
privateRanges: 'auto', // 'auto' = private ranges only when CI is set
onBlock: (host, port) => {},
});
assertActive(); // throws if never loaded or unpatched
stayput.isActive; // live getter on the default exportPrefer the named exports: stayput.assertActive() on the default import trips import/no-named-as-default-member in the shared ESLint config. Type declarations ship with the package (StayputOptions, Stayput), so TypeScript test helpers need no declare module stub.
@bonniernews/stayput/mocha exports a root hook plugin that runs assertActive() before tests.
Loading is idempotent: multiple loads (NODE_OPTIONS + mocharc + import) merge allowlists, patch once, and log one greppable line:
stayput/0.1.0 active mode=loopback+private allow=2 deny=1 source=NODE_OPTIONSOne layer of many
stayput is a client-side footgun guard — the last line of defence, not the plan. It complements, never replaces:
- Education — knowing why prod credentials don't belong on laptops or in repos beats any guard.
- Easy, short-lived access paths — when you genuinely need to reach a real database, use the ephemeral proxies/jumphosts on non-default ports. The sanctioned way is easier than the risky way, and a leaked default-port connection string doesn't route anywhere.
- Firewall rules / network design — test environments should have no route to production at all; stayput only matters where a route exists.
- Test frameworks and fixtures — testcontainers, CI service containers, and hermetic fixtures remove the reason to point tests at shared infrastructure in the first place.
- Server-side guards — read-only default roles, DDL gating, RBAC without destructive verbs (per-database plan in HANDOVER.md).
How it works
Patches net.Socket.prototype.connect (sync throw on blocked IP literals; unix sockets always allowed) and dns.lookup (hostnames are vetted when they resolve). TLS, http/https and undici ride on net.Socket, so they're covered for free. server.listen resolves its host through dns.lookup too, so binding is allowed for loopback and for the unspecified addresses (0.0.0.0, ::) — binding a specific non-local IP needs STAYPUT_ALLOW. This is a footgun guard, not a security boundary.
Requires node ≥ 20.6 (preloaded in CI with NODE_OPTIONS=--import …/register.js).
Development
npm test # unit tests + type declaration check — no network, no docker
npm run test:integration # pg + mongodb + elasticsearch against docker (compose.yaml)Design decisions, environment matrix, and rollout plan: HANDOVER.md.
