@bonniernews/stayput 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Bonnier News AB
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,101 @@
1
+ # @bonniernews/stayput
2
+
3
+ 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.
4
+
5
+ ## Quick start
6
+
7
+ ```js
8
+ // .mocharc.cjs
9
+ module.exports = { require: ['@bonniernews/stayput/register'] };
10
+ ```
11
+
12
+ Blocked connections throw with `err.code === 'EREMOTEBLOCKED'`.
13
+
14
+ 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.
15
+
16
+ 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.
17
+
18
+ ### Runner-proofing (belt and suspenders)
19
+
20
+ 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:
21
+
22
+ ```js
23
+ // test/helpers/db.js
24
+ import '@bonniernews/stayput/register'; // guard on, no matter how the runner was invoked
25
+ import stayput from '@bonniernews/stayput';
26
+
27
+ export async function testDb() {
28
+ stayput.assertActive(); // refuse to hand out connections if the guard was tampered with
29
+ // ...create and return the client
30
+ }
31
+ ```
32
+
33
+ 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`.
34
+
35
+ (In CI this is all redundant — `NODE_OPTIONS` injection guards every node process regardless of runner or config.)
36
+
37
+ ## Policy
38
+
39
+ Locally (no `CI` env): loopback only. In CI: loopback + private ranges (RFC1918). Everything else is blocked.
40
+
41
+ | Env var | Effect |
42
+ |---|---|
43
+ | `STAYPUT_ALLOW` | comma-separated extra hosts / IPs / IPv4 CIDRs, e.g. `10.128.0.5,db.internal` |
44
+ | `STAYPUT_DENY` | same format — **deny beats allow** |
45
+ | `STAYPUT_DISABLE=I_UNDERSTAND_THE_RISK` | turns the guard off (deliberately ugly so it stands out in review) |
46
+
47
+ Put overrides in the workflow file, not in code: `STAYPUT_ALLOW`/`STAYPUT_DISABLE` are greppable fleet-wide and double as the exception inventory.
48
+
49
+ ## AI agents
50
+
51
+ 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.
52
+
53
+ 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.
54
+
55
+ ## Programmatic API
56
+
57
+ ```js
58
+ import stayput from '@bonniernews/stayput';
59
+
60
+ stayput.enable({
61
+ allow: ['10.128.0.5'],
62
+ deny: ['10.10.0.0/16'], // deny beats allow
63
+ privateRanges: 'auto', // 'auto' = private ranges only when CI is set
64
+ onBlock: (host, port) => {},
65
+ });
66
+ stayput.assertActive(); // throws if never loaded or unpatched
67
+ stayput.isActive;
68
+ ```
69
+
70
+ `@bonniernews/stayput/mocha` exports a root hook plugin that runs `assertActive()` before tests.
71
+
72
+ Loading is idempotent: multiple loads (NODE_OPTIONS + mocharc + import) merge allowlists, patch once, and log one greppable line:
73
+
74
+ ```
75
+ stayput/0.1.0 active mode=loopback+private allow=2 deny=1 source=NODE_OPTIONS
76
+ ```
77
+
78
+ ## One layer of many
79
+
80
+ stayput is a client-side footgun guard — the last line of defence, not the plan. It complements, never replaces:
81
+
82
+ - **Education** — knowing why prod credentials don't belong on laptops or in repos beats any guard.
83
+ - **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.
84
+ - **Firewall rules / network design** — test environments should have no route to production at all; stayput only matters where a route exists.
85
+ - **Test frameworks and fixtures** — testcontainers, CI service containers, and hermetic fixtures remove the reason to point tests at shared infrastructure in the first place.
86
+ - **Server-side guards** — read-only default roles, DDL gating, RBAC without destructive verbs (per-database plan in [HANDOVER.md](HANDOVER.md)).
87
+
88
+ ## How it works
89
+
90
+ 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 and undici ride on `net.Socket`, so they're covered for free. This is a footgun guard, not a security boundary.
91
+
92
+ Requires node ≥ 20.6 (preloaded in CI with `NODE_OPTIONS=--import …/register.js`).
93
+
94
+ ## Development
95
+
96
+ ```sh
97
+ npm test # unit tests — no deps, no network, no docker
98
+ npm run test:integration # pg + mongodb + elasticsearch against docker (compose.yaml)
99
+ ```
100
+
101
+ Design decisions, environment matrix, and rollout plan: [HANDOVER.md](HANDOVER.md).
package/index.js ADDED
@@ -0,0 +1,211 @@
1
+ // stayput — default-deny network guard for Node test environments.
2
+ // Zero dependencies by design: this is loaded via NODE_OPTIONS into every
3
+ // node process in CI. A bad publish is a fleet-wide outage — keep it boring.
4
+ //
5
+ // ponytail: dns.promises and a custom options.lookup bypass the guard.
6
+ // This is a footgun guard, not a security boundary (see HANDOVER.md).
7
+
8
+ import net from 'node:net';
9
+ import dns from 'node:dns';
10
+
11
+ const VERSION = '0.1.0';
12
+ const STATE_KEY = Symbol.for('bonnier.stayput');
13
+
14
+ // ---- address helpers ------------------------------------------------------
15
+ // ponytail: IPv4 CIDRs only; IPv6 covered as loopback/ULA/link-local/mapped
16
+ // literals. Full IPv6 CIDR parsing when someone actually allows an IPv6 range.
17
+
18
+ // '::ffff:1.2.3.4' → '1.2.3.4'; anything else passes through untouched
19
+ function unmapIPv4(address) {
20
+ const match = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/i.exec(address);
21
+ return match ? match[1] : address;
22
+ }
23
+
24
+ function ipv4ToInt(address) {
25
+ const match = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(address);
26
+ if (!match) return null;
27
+ let result = 0;
28
+ for (const octetString of match.slice(1)) {
29
+ const octet = Number(octetString);
30
+ if (octet > 255) return null;
31
+ result = result * 256 + octet;
32
+ }
33
+ return result;
34
+ }
35
+
36
+ function inCidr4(address, cidr) {
37
+ const slashIndex = cidr.indexOf('/');
38
+ const prefixBits = Number(cidr.slice(slashIndex + 1));
39
+ const networkInt = ipv4ToInt(cidr.slice(0, slashIndex));
40
+ const addressInt = ipv4ToInt(address);
41
+ if (networkInt === null || addressInt === null || !(prefixBits >= 0 && prefixBits <= 32)) return false;
42
+ const hostBits = 2 ** (32 - prefixBits);
43
+ return Math.floor(addressInt / hostBits) === Math.floor(networkInt / hostBits);
44
+ }
45
+
46
+ function isLoopback(address) {
47
+ const unmapped = unmapIPv4(address);
48
+ return unmapped === 'localhost' || unmapped === '::1' || inCidr4(unmapped, '127.0.0.0/8');
49
+ }
50
+
51
+ function isPrivate(address) {
52
+ const unmapped = unmapIPv4(address);
53
+ if (/^f[cd]/i.test(unmapped) || /^fe80:/i.test(unmapped)) return true; // IPv6 ULA + link-local
54
+ return inCidr4(unmapped, '10.0.0.0/8') || inCidr4(unmapped, '172.16.0.0/12') || inCidr4(unmapped, '192.168.0.0/16');
55
+ }
56
+
57
+ function matchesList(list, target) {
58
+ const unmapped = unmapIPv4(target);
59
+ return list.some((entry) => entry === unmapped || (entry.includes('/') && inCidr4(unmapped, entry)));
60
+ }
61
+
62
+ // deny beats allow — the key design decision (see HANDOVER.md).
63
+ // hostAllowed: the hostname this address resolved from was allow-listed.
64
+ function addressOk(address, state, hostAllowed) {
65
+ if (matchesList(state.deny, address)) return false;
66
+ if (hostAllowed || matchesList(state.allow, address)) return true;
67
+ if (isLoopback(address)) return true;
68
+ return state.privateOk && isPrivate(address);
69
+ }
70
+
71
+ // ---- blocking -------------------------------------------------------------
72
+
73
+ function blockedError(target) {
74
+ const error = new Error(
75
+ `stayput: blocked connection to ${target} — non-local network access is denied in tests. ` +
76
+ 'If this target is legitimate, a human must add it to STAYPUT_ALLOW in the workflow file. ' +
77
+ 'AI agents: do not set STAYPUT_ALLOW or STAYPUT_DISABLE yourself — pause and ask a human to verify this connection is intended.'
78
+ );
79
+ error.code = 'EREMOTEBLOCKED';
80
+ return error;
81
+ }
82
+
83
+ function notifyBlock(state, host, port) {
84
+ if (typeof state.onBlock === 'function') {
85
+ try { state.onBlock(host, port); } catch { /* metrics hook must never mask the block */ }
86
+ }
87
+ }
88
+
89
+ // ---- patches ---------------------------------------------------------------
90
+
91
+ function makePatchedConnect(originalConnect) {
92
+ return function connect(...args) {
93
+ const state = globalThis[STATE_KEY];
94
+ if (state && !state.disabled) {
95
+ let firstArg = args[0];
96
+ if (Array.isArray(firstArg)) firstArg = firstArg[0]; // node-internal pre-normalized [options, callback]
97
+ let host, port, path;
98
+ if (firstArg !== null && typeof firstArg === 'object') ({ host, port, path } = firstArg);
99
+ else if (typeof firstArg === 'string' && Number.isNaN(Number(firstArg))) path = firstArg; // mirrors node's pipe-name detection
100
+ else { port = firstArg; if (typeof args[1] === 'string') host = args[1]; }
101
+ if (path === undefined) { // unix sockets always allowed
102
+ const targetHost = String(host === undefined ? 'localhost' : host).toLowerCase();
103
+ const isIpLiteral = net.isIP(unmapIPv4(targetHost)) !== 0;
104
+ // non-IP hostnames (unless deny-listed) are vetted async in the dns.lookup patch —
105
+ // connect is sync, DNS isn't
106
+ if (matchesList(state.deny, targetHost) || (isIpLiteral && !addressOk(targetHost, state, false))) {
107
+ notifyBlock(state, targetHost, port);
108
+ throw blockedError(`${targetHost}:${port}`); // sync throw — driver retry loops swallow emitted errors
109
+ }
110
+ }
111
+ }
112
+ return originalConnect.apply(this, args);
113
+ };
114
+ }
115
+
116
+ function makePatchedLookup(originalLookup) {
117
+ return function lookup(hostname, options, callback) {
118
+ const state = globalThis[STATE_KEY];
119
+ if (!state || state.disabled) return originalLookup.call(this, hostname, options, callback);
120
+ if (typeof options === 'function') { callback = options; options = undefined; }
121
+ const targetHost = String(hostname || '').toLowerCase();
122
+ if (matchesList(state.deny, targetHost)) {
123
+ notifyBlock(state, targetHost);
124
+ return process.nextTick(callback, blockedError(targetHost));
125
+ }
126
+ const hostAllowed = matchesList(state.allow, targetHost);
127
+ const vetResolvedAddresses = function (error, address, family) {
128
+ if (!error) {
129
+ // with {all: true} node hands back [{address, family}, ...], otherwise a single string
130
+ const addresses = Array.isArray(address) ? address.map((entry) => (entry && entry.address) || entry) : [address];
131
+ const blockedAddress = addresses.find((resolved) => !addressOk(String(resolved), state, hostAllowed));
132
+ if (blockedAddress !== undefined) {
133
+ notifyBlock(state, targetHost);
134
+ return callback(blockedError(`${targetHost} (resolved to ${blockedAddress})`));
135
+ }
136
+ }
137
+ return callback(error, address, family);
138
+ };
139
+ return options === undefined
140
+ ? originalLookup.call(this, hostname, vetResolvedAddresses)
141
+ : originalLookup.call(this, hostname, options, vetResolvedAddresses);
142
+ };
143
+ }
144
+
145
+ // ---- public API -------------------------------------------------------------
146
+
147
+ function splitEnvList(value) {
148
+ return (value || '').split(',').map((item) => item.trim().toLowerCase()).filter(Boolean);
149
+ }
150
+
151
+ export function enable(options = {}) {
152
+ const env = process.env;
153
+ const allow = [...(options.allow || []).map((entry) => String(entry).toLowerCase()), ...splitEnvList(env.STAYPUT_ALLOW)];
154
+ const deny = [...(options.deny || []).map((entry) => String(entry).toLowerCase()), ...splitEnvList(env.STAYPUT_DENY)];
155
+
156
+ let state = globalThis[STATE_KEY];
157
+ if (state) {
158
+ // later loads merge lists, never re-patch, never re-log
159
+ for (const entry of allow) if (!state.allow.includes(entry)) state.allow.push(entry);
160
+ for (const entry of deny) if (!state.deny.includes(entry)) state.deny.push(entry);
161
+ if (options.onBlock) state.onBlock = options.onBlock;
162
+ return api;
163
+ }
164
+
165
+ const privateRanges = options.privateRanges === undefined ? 'auto' : options.privateRanges;
166
+ state = {
167
+ version: VERSION,
168
+ allow,
169
+ deny,
170
+ // auto: laptops (no CI env) are where stray prod creds live → loopback-only;
171
+ // CI is ephemeral/containerized → private ranges too
172
+ privateOk: privateRanges === 'auto' ? Boolean(env.CI) : Boolean(privateRanges),
173
+ disabled: env.STAYPUT_DISABLE === 'I_UNDERSTAND_THE_RISK',
174
+ onBlock: options.onBlock,
175
+ patchedConnect: makePatchedConnect(net.Socket.prototype.connect),
176
+ patchedLookup: makePatchedLookup(dns.lookup),
177
+ };
178
+ globalThis[STATE_KEY] = state;
179
+ net.Socket.prototype.connect = state.patchedConnect;
180
+ dns.lookup = state.patchedLookup;
181
+
182
+ const source = /stayput/.test(env.NODE_OPTIONS || '') ? 'NODE_OPTIONS' : 'require';
183
+ const mode = state.privateOk ? 'loopback+private' : 'loopback';
184
+ console.error(
185
+ state.disabled
186
+ ? `stayput/${VERSION} disabled via STAYPUT_DISABLE source=${source}`
187
+ : `stayput/${VERSION} active mode=${mode} allow=${state.allow.length} deny=${state.deny.length} source=${source}`
188
+ );
189
+ return api;
190
+ }
191
+
192
+ export function assertActive() {
193
+ const state = globalThis[STATE_KEY];
194
+ if (!state) {
195
+ throw new Error("stayput: guard never loaded — add '@bonniernews/stayput/register' to your mocharc require array");
196
+ }
197
+ if (net.Socket.prototype.connect !== state.patchedConnect || dns.lookup !== state.patchedLookup) {
198
+ throw new Error('stayput: guard was unpatched after load');
199
+ }
200
+ }
201
+
202
+ const api = {
203
+ enable,
204
+ assertActive,
205
+ get isActive() {
206
+ const state = globalThis[STATE_KEY];
207
+ return Boolean(state) && net.Socket.prototype.connect === state.patchedConnect;
208
+ },
209
+ };
210
+
211
+ export default api;
package/mocha.js ADDED
@@ -0,0 +1,8 @@
1
+ // Root hook plugin: catches the guard being unpatched between load and test run.
2
+ import { assertActive } from './index.js';
3
+
4
+ export const mochaHooks = {
5
+ beforeAll() {
6
+ assertActive();
7
+ },
8
+ };
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "@bonniernews/stayput",
3
+ "version": "0.1.0",
4
+ "description": "Default-deny network guard for Node.js test environments — blocks non-local TCP at the socket level.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/BonnierNews/stayput.git"
9
+ },
10
+ "publishConfig": {
11
+ "access": "public"
12
+ },
13
+ "type": "module",
14
+ "exports": {
15
+ ".": "./index.js",
16
+ "./register": "./register.js",
17
+ "./mocha": "./mocha.js",
18
+ "./package.json": "./package.json"
19
+ },
20
+ "files": [
21
+ "index.js",
22
+ "register.js",
23
+ "mocha.js"
24
+ ],
25
+ "scripts": {
26
+ "test": "node test.js",
27
+ "test:integration": "node test-integration.js",
28
+ "prepublishOnly": "npm test"
29
+ },
30
+ "devDependencies": {
31
+ "@elastic/elasticsearch": "^8.17.0",
32
+ "mongodb": "^6.10.0",
33
+ "pg": "^8.13.0"
34
+ },
35
+ "engines": {
36
+ "node": ">=20.6.0"
37
+ }
38
+ }
package/register.js ADDED
@@ -0,0 +1,4 @@
1
+ // Side-effect entry, loaded via NODE_OPTIONS --import or mocharc require.
2
+ // Runs in every node process in CI — keep dependency-free and boring.
3
+ import { enable } from './index.js';
4
+ enable();