// paza-retea.mjs (quantum exposure report, C12): the CONNECTION COUNTER loaded into the report process BEFORE the tool // (`node --import ./paza-retea.mjs raport.mjs ...`). It writes every attempt to leave the process as one JSON line to the journal // named by AERE_PAZA_JURNAL, and in blocking mode refuses any attempt outside the declared targets before a packet leaves. // // WHAT IT COVERS (measured by proba-expunere.mjs, control 5a: in blocking mode one planted exemplar of each path below, each named in the // journal and refused, the pipe forms against a listening local pipe and the DNS queries against a listening loopback DNS port, both of // which must receive nothing; in journal mode, where a planted call really runs, only toward 127.0.0.1: a worker's and a child's // connection, a connected UDP socket, a connection with its own lookup): // TCP net.Socket.prototype.connect, its arguments read as Node.js reads them: every TCP connection opened through net.Socket (net, // tls, http, https, http2, fetch/undici, WebSocket) -> { ev: "connect", host, port, allowed } // when the caller gives its own `lookup`, the address that lookup returns is journaled too -> { ev: "resolved", host, address } // PIPES the same method with a path (a Windows named pipe, a Unix socket, http's socketPath, or a string that is not a port), judged // on the path AS THE SYSTEM OPENS IT: on Windows it is normalized first (`\\.\pipe\..\UNC\host\pipe\x` is opened as // `\\.\UNC\host\pipe\x`, over SMB), and only `\\.\pipe\` / `\\?\pipe\` (a name without a backslash) is local; // every other form (UNC, `\??\UNC\...`, GLOBALROOT, a drive or relative path) is judged like a host // -> { ev: "connect", path, normalized, allowed } // UDP dgram.Socket.prototype.send, its arguments read as Node.js reads them (a connected socket: its remote address; a numeric // string port; no address = loopback) -> { ev: "udp", host, port, allowed } // addMembership / dropMembership and their source-specific forms (the system sends an IGMP / MLD report) // -> { ev: "udp", method, group, allowed } // DNS EVERY function of dns, dns.promises, dns.Resolver.prototype and dns.promises.Resolver.prototype that may query (all but // getServers, get/setDefaultResultOrder, cancel, setLocalAddress), so a query function this file does not name is judged too // -> { ev: "dns", method, host, allowed } // permitted only as `lookup` of a declared target name or of an IP literal (getaddrinfo, which sends nothing for a literal); // resolve*, reverse, lookupService and any other query go to a DNS server whatever host they name, and are refused // setServers (module, promises, and the base prototype of the Resolver classes): it decides where later queries go // -> { ev: "dns", method: "...setServers", allowed } // HANDLES process.binding(name): the deprecated door to the internal TCP / UDP / c-ares handles (tcp_wrap, udp_wrap, cares_wrap), // which connect without net.Socket; the CALL is journaled, and refused in blocking mode -> { ev: "binding", name, allowed } // in journal mode the handle is handed out, and the connections made through it are NOT journaled // ADDONS process.dlopen (what require() of a .node file calls): the LOAD is journaled, and refused in blocking mode // -> { ev: "addon", file, allowed } // WORKERS worker_threads.Worker (replaced by a constructor function, so the original is not its prototype): refused in blocking mode; // in journal mode the worker is started with this guard preloaded through `--require` in its execArgv (`--import` is NOT // applied to workers, measured on Node 24), so its own connections are journaled -> { ev: "worker", allowed } // CHILDREN child_process spawn/spawnSync/exec/execSync/execFile/execFileSync/fork, the ChildProcess class itself // (ChildProcess.prototype.spawn, where every asynchronous form ends) and process.execve: refused in blocking mode; in journal // mode NODE_OPTIONS carries `--require` of this guard to child Node.js processes that INHERIT the environment // -> { ev: "child", method, command, allowed } // WHAT IT DOES NOT COVER (declared, not measured as zero): in journal mode, the connections of a handle taken through process.binding // (only the call is seen), of a native addon (only the load is seen), of a child process started with its own `env` (the guard is not in // it; the start is seen), of programs other than Node.js (curl, ...) started as children (the start is seen), and of the program that // process.execve puts in place of this one on POSIX (the call is seen); in both modes any file opened on a path the system resolves over a // network (a UNC path, a network mount), whatever opens it (fs, import, node:sqlite: none is wrapped), WHERE a declared target name // resolves (the system's answer, or the caller's own `lookup`, whose answer is journaled for TCP and not judged), `internalBinding` (not // reachable without --expose-internals), node:quic where a build offers it (not wrapped; the Node.js 24.14 measured here has no such // module, with or without --experimental-quic), code written ON PURPOSE to reach the original functions some other way than the ones // named here (the guard measures the ordinary calls of the tool and of its libraries, it is not a sandbox), and anything outside this // Node.js process. In blocking mode every process.binding call, addon load, worker, child, execve, multicast membership and setServers // is refused, so none of those runs. The report tool uses none of these paths (its sources import no child_process, worker_threads, // native module, process.binding or lookupService; the test checks that). // // AERE_PAZA_TINTE "host:port,host:port" the declared targets; empty = nothing is allowed (mode 1); "*" = everything // AERE_PAZA_MOD "jurnal" (count only) or "blocheaza" (count AND refuse any attempt outside the declared targets: the socket is // destroyed with EAEREPAZA, a DNS query is answered with EAEREPAZA, a handle, an addon, a worker or a child process // is not started) // The journal's first line is { ev: "incarcat" }: a test that does not find it did not load the guard and measured nothing. // This file is a measuring instrument for the test; the report tool does not depend on it. import fs from 'node:fs'; import path from 'node:path'; import net from 'node:net'; import dgram from 'node:dgram'; import dns from 'node:dns'; import cp from 'node:child_process'; import wt from 'node:worker_threads'; import { fileURLToPath } from 'node:url'; import { syncBuiltinESMExports } from 'node:module'; const JURNAL = process.env.AERE_PAZA_JURNAL || ''; const MOD = process.env.AERE_PAZA_MOD === 'blocheaza' ? 'blocheaza' : 'jurnal'; const TINTE = String(process.env.AERE_PAZA_TINTE || '').split(',').map((x) => x.trim()).filter(Boolean); const TOT = TINTE.includes('*'); const PAZA_FISIER = fileURLToPath(import.meta.url); const scrie = (o) => { if (JURNAL) fs.appendFileSync(JURNAL, JSON.stringify({ t: new Date().toISOString(), pid: process.pid, tid: wt.threadId, ...o }) + '\n'); }; const permis = (host, port) => TOT || TINTE.includes(`${host}:${port}`) || TINTE.includes(String(host)); /** a DNS call is permitted only as `lookup` (getaddrinfo, the resolver every connection of the tool uses), and only for a declared target * name or an IP literal (getaddrinfo sends nothing for a literal). Every other DNS function (resolve*, reverse, lookupService, and any * query function a later Node.js adds) is a query to a DNS server, whichever host it names, and is refused in blocking mode: a list of * what is permitted, not of what is forbidden */ const permisNume = (host, metoda) => TOT || (metoda === 'lookup' && (net.isIP(String(host)) !== 0 || TINTE.some((t) => t === String(host) || t.startsWith(String(host) + ':')))); /** a LOCAL IPC path, judged on the path as the system will open it, not on the string given. On Windows the path is NORMALIZED first * (forward slashes to backslashes, `.` and `..` resolved: path.win32.normalize, which gives what Windows itself opens for a `\\.\` path: * `\\.\pipe\..\UNC\host\pipe\x` IS `\\.\UNC\host\pipe\x`, a connection over SMB), and only the local pipe namespace passes: * `\\.\pipe\` or `\\?\pipe\`, the name without a backslash (a pipe name cannot hold one). Everything else (a UNC path, a * `\??\` NT path such as `\??\UNC\host\...`, a GLOBALROOT device path, a drive or relative path, which may be a network mount) is judged * like a host (a NUL inside the path is refused by libuv itself, EINVAL, measured on Node 24). On POSIX a Unix socket path is local, * unless it starts with two slashes */ const BS = String.fromCharCode(92); // a backslash, built so that no tool on the way unescapes it const caleNormalizata = (p) => (process.platform === 'win32' ? path.win32.normalize(String(p).split('/').join(BS)) : String(p)); const caleLocala = (p) => { const s = String(p); if (process.platform !== 'win32') return !s.split('/').join(BS).startsWith(BS + BS); const n = caleNormalizata(s); const cap = n.slice(0, 9).toLowerCase(); const nume = n.slice(9); return (cap === BS + BS + '.' + BS + 'pipe' + BS || cap === BS + BS + '?' + BS + 'pipe' + BS) && nume.length > 0 && !nume.includes(BS); }; const eroare = (ce) => Object.assign(new Error(`AERE-PAZA: ${ce} is outside the declared targets (${TINTE.join(',') || 'none'}) and was refused before any packet left`), { code: 'EAEREPAZA' }); scrie({ ev: 'incarcat', mod: MOD, tinte: TINTE }); // child Node.js processes that inherit the environment load the guard through NODE_OPTIONS (journal mode; in blocking mode they are not // started at all). `--require` (not `--import`): it is applied synchronously, before the child's first line, and it is also what workers honour. process.env.NODE_OPTIONS = ((process.env.NODE_OPTIONS || '') + ' --require=' + JSON.stringify(PAZA_FISIER)).trim(); // ---------------------------------------------------------------- TCP /** the arguments of socket.connect read the way Node.js reads them (net.js normalizeArgs): an options object; a string that is not a * non-negative number is a PIPE path (`socket.connect('\\.\pipe\x')`); anything else is a port, with an optional host string after it */ const caNumar = (x) => { const n = Number(x); return n >= 0 ? n : false; }; function desfa(args) { const a0 = args[0]; const o = Array.isArray(a0) ? a0[0] : (a0 && typeof a0 === 'object' ? a0 : typeof a0 === 'string' && caNumar(a0) === false ? { path: a0 } : { port: a0, host: typeof args[1] === 'string' ? args[1] : undefined }); return { host: o.path ? null : String(o.host || 'localhost'), port: o.path ? null : Number(o.port), path: o.path || null, optiuni: o && typeof o === 'object' ? o : null }; } const connectOrig = net.Socket.prototype.connect; net.Socket.prototype.connect = function (...args) { const { host, port, path: cale, optiuni } = desfa(args); const ok = cale ? (TOT || caleLocala(cale)) : permis(host, port); scrie({ ev: 'connect', host, port, path: cale, ...(cale && process.platform === 'win32' ? { normalized: caleNormalizata(cale) } : {}), allowed: ok }); if (!ok && MOD === 'blocheaza') { setImmediate(() => this.destroy(eroare(cale ? `pipe ${cale}` : `${host}:${port}`))); return this; } // a caller-supplied lookup decides the address itself: journal what it returns, so that the reader knows where the socket went. // net.connect() hands this method its NORMALIZED arguments, an array [options, cb] marked with an internal symbol: the options are // replaced inside that same array (a new array would lose the mark), never mutated in the caller's object if (optiuni && typeof optiuni.lookup === 'function') { const lookupOrig = optiuni.lookup; const cuJurnal = { ...optiuni, lookup: (h, o, cb) => lookupOrig(h, o, (e, adr, fam) => { scrie({ ev: 'resolved', host: String(h), address: e ? null : (Array.isArray(adr) ? adr.map((x) => x.address).join(',') : String(adr)), error: e ? String(e.code || e.message).slice(0, 60) : null }); cb(e, adr, fam); }) }; if (Array.isArray(args[0])) args[0][0] = cuJurnal; else args[0] = cuJurnal; } return connectOrig.apply(this, args); }; // ---------------------------------------------------------------- UDP /** where a datagram goes, read the way Node.js reads send(msg[, offset, length][, port][, address][, callback]) (dgram.js): a CONNECTED * socket sends to its remote address (offset and length are not a port); an unconnected one takes (msg, offset, length, port, address) * when an address or a non-function port follows the length, else (msg, port, address); the port may be a numeric string; no address * means the loopback of the socket's family */ function tintaUdp(sock, a) { let r = null; try { r = sock.remoteAddress(); } catch { r = null; } if (r) return { host: r.address, port: r.port }; let [, offset, length, port, address] = a; if (!(address || (port && typeof port !== 'function'))) { port = offset; address = length; } if (typeof address === 'function') address = undefined; return { host: address ? String(address) : (sock.type === 'udp6' ? '::1' : '127.0.0.1'), port: Number(port) }; } const sendOrig = dgram.Socket.prototype.send; dgram.Socket.prototype.send = function (...args) { const { host, port } = tintaUdp(this, args); const ok = permis(host, port); scrie({ ev: 'udp', host, port, allowed: ok }); if (!ok && MOD === 'blocheaza') { const cb = args.find((x) => typeof x === 'function'); const e = eroare(`udp ${host}:${port}`); if (cb) setImmediate(() => cb(e)); else setImmediate(() => this.emit('error', e)); return; } return sendOrig.apply(this, args); }; // multicast membership: joining or leaving a group makes the system send an IGMP / MLD report on the chosen interface, a packet that no // send() call carries; it is journaled and refused in blocking mode, whatever the group for (const nume of ['addMembership', 'dropMembership', 'addSourceSpecificMembership', 'dropSourceSpecificMembership']) { const orig = dgram.Socket.prototype[nume]; if (typeof orig !== 'function') continue; dgram.Socket.prototype[nume] = function (...args) { scrie({ ev: 'udp', method: nume, group: args.map((x) => String(x)).join(' ').slice(0, 120), allowed: TOT }); if (!TOT && MOD === 'blocheaza') throw eroare(`udp ${nume} ${args.map((x) => String(x)).join(' ').slice(0, 80)}`); return orig.apply(this, args); }; } // ---------------------------------------------------------------- DNS: module functions, promises, Resolver instances // EVERY function of the module, of dns.promises and of the two Resolver prototypes is wrapped as a query, except the ones named here that // send nothing (setServers is wrapped on its own, below): a query function a later Node.js adds (Node 24 has resolveTlsa, which a fixed // list of names had missed) is judged too, instead of passing unseen const FARA_INTEROGARE = new Set(['Resolver', 'constructor', 'getServers', 'getDefaultResultOrder', 'setDefaultResultOrder', 'cancel', 'setLocalAddress', 'setServers']); function invelesteDns(obiect, eticheta, promisiuni) { for (const nume of Object.getOwnPropertyNames(obiect)) { if (FARA_INTEROGARE.has(nume)) continue; const d = Object.getOwnPropertyDescriptor(obiect, nume); if (!d || typeof d.value !== 'function') continue; const orig = d.value; obiect[nume] = promisiuni ? async function (host, ...rest) { const ok = permisNume(host, nume); scrie({ ev: 'dns', method: eticheta + nume, host: String(host), allowed: ok }); if (!ok && MOD === 'blocheaza') throw eroare(`dns ${nume} ${host}`); return orig.call(this, host, ...rest); } : function (host, ...rest) { const ok = permisNume(host, nume); scrie({ ev: 'dns', method: eticheta + nume, host: String(host), allowed: ok }); if (!ok && MOD === 'blocheaza') { const cb = rest.find((x) => typeof x === 'function'); const e = eroare(`dns ${nume} ${host}`); if (cb) { setImmediate(() => cb(e)); return; } throw e; } return orig.call(this, host, ...rest); }; } } invelesteDns(dns, 'dns.', false); invelesteDns(dns.promises, 'dns.promises.', true); invelesteDns(dns.Resolver.prototype, 'Resolver.', false); invelesteDns(dns.promises.Resolver.prototype, 'promises.Resolver.', true); // setServers decides WHERE every later query goes (any address and port, which no connection of this process shows): journaled and refused // in blocking mode. Wrapped on the module, on dns.promises and on the shared base prototype of the Resolver classes, where the instances // find it; a call that passes through one wrapper into another is journaled once let inSetServers = 0; const invelite = new WeakSet(); function invelesteServere(obiect, eticheta) { const orig = obiect && obiect.setServers; if (typeof orig !== 'function' || invelite.has(orig)) return; const f = function (servere, ...rest) { if (!inSetServers) { scrie({ ev: 'dns', method: eticheta + 'setServers', host: (Array.isArray(servere) ? servere.map(String).join(',') : String(servere)).slice(0, 160), allowed: TOT }); if (!TOT && MOD === 'blocheaza') throw eroare(`dns ${eticheta}setServers ${String(servere).slice(0, 80)}`); } inSetServers++; try { return orig.call(this, servere, ...rest); } finally { inSetServers--; } }; invelite.add(f); obiect.setServers = f; } invelesteServere(dns, 'dns.'); invelesteServere(dns.promises, 'dns.promises.'); invelesteServere(Object.getPrototypeOf(dns.Resolver.prototype), 'Resolver.'); invelesteServere(Object.getPrototypeOf(dns.promises.Resolver.prototype), 'promises.Resolver.'); // ---------------------------------------------------------------- internal handles: process.binding (tcp_wrap, udp_wrap, cares_wrap, ...) const bindingOrig = process.binding; if (typeof bindingOrig === 'function') { process.binding = function (nume, ...rest) { scrie({ ev: 'binding', name: String(nume).slice(0, 40), allowed: TOT }); if (!TOT && MOD === 'blocheaza') throw eroare(`process.binding(${String(nume).slice(0, 40)})`); return bindingOrig.call(this, nume, ...rest); }; } // ---------------------------------------------------------------- native addons: process.dlopen (what require() of a .node file calls) const dlopenOrig = process.dlopen; if (typeof dlopenOrig === 'function') { process.dlopen = function (modul, fisier, ...rest) { scrie({ ev: 'addon', file: String(fisier).slice(0, 160), allowed: TOT }); if (!TOT && MOD === 'blocheaza') throw eroare('native addon ' + String(fisier).slice(0, 80)); return dlopenOrig.call(this, modul, fisier, ...rest); }; } // ---------------------------------------------------------------- workers and child processes const WorkerOrig = wt.Worker; // a constructor FUNCTION, not a subclass: a subclass keeps the original as its prototype (Object.getPrototypeOf(Worker) would hand it out). // Instances keep the original prototype (instanceof, methods), whose `constructor` now names the guarded one, and the statics come from // EventEmitter as before function WorkerPaza(fisier, optiuni = {}) { if (!new.target) throw new TypeError("Class constructor Worker cannot be invoked without 'new'"); scrie({ ev: 'worker', file: String(fisier).slice(0, 160), allowed: TOT }); if (!TOT && MOD === 'blocheaza') throw eroare('worker ' + String(fisier).slice(0, 80)); // `--require` of this file loads the guard synchronously in the worker before its first line (`--import` in execArgv is ignored by workers) return Reflect.construct(WorkerOrig, [fisier, { ...optiuni, execArgv: [...(optiuni.execArgv || process.execArgv), '--require', PAZA_FISIER] }], new.target); } WorkerPaza.prototype = WorkerOrig.prototype; Object.defineProperty(WorkerOrig.prototype, 'constructor', { value: WorkerPaza, writable: true, configurable: true, enumerable: false }); Object.setPrototypeOf(WorkerPaza, Object.getPrototypeOf(WorkerOrig)); Object.defineProperty(WorkerPaza, 'name', { value: 'Worker' }); wt.Worker = WorkerPaza; let inFunctieCopil = 0; // a start already journaled by a function below is not journaled again by ChildProcess.prototype.spawn for (const nume of ['spawn', 'spawnSync', 'exec', 'execSync', 'execFile', 'execFileSync', 'fork']) { const orig = cp[nume]; if (typeof orig !== 'function') continue; cp[nume] = function (...args) { scrie({ ev: 'child', method: nume, command: String(args[0]).slice(0, 160), allowed: TOT }); if (!TOT && MOD === 'blocheaza') throw eroare(`child process ${nume} ${String(args[0]).slice(0, 80)}`); inFunctieCopil++; try { return orig.apply(this, args); } finally { inFunctieCopil--; } }; } // the class itself: `new ChildProcess().spawn({ file, args, envPairs })` starts a process without any of the functions above if (cp.ChildProcess && typeof cp.ChildProcess.prototype.spawn === 'function') { const spawnClasaOrig = cp.ChildProcess.prototype.spawn; cp.ChildProcess.prototype.spawn = function (optiuni, ...rest) { if (!inFunctieCopil) { const fisier = String(optiuni && optiuni.file).slice(0, 160); scrie({ ev: 'child', method: 'ChildProcess.spawn', command: fisier, allowed: TOT }); if (!TOT && MOD === 'blocheaza') throw eroare(`child process ChildProcess.spawn ${fisier.slice(0, 80)}`); } return spawnClasaOrig.call(this, optiuni, ...rest); }; } // process.execve (Node 23.11+, POSIX): replaces this process with another program; on Windows it throws, but the call is journaled first if (typeof process.execve === 'function') { const execveOrig = process.execve; process.execve = function (fisier, ...rest) { scrie({ ev: 'child', method: 'execve', command: String(fisier).slice(0, 160), allowed: TOT }); if (!TOT && MOD === 'blocheaza') throw eroare(`process.execve ${String(fisier).slice(0, 80)}`); return execveOrig.call(this, fisier, ...rest); }; } syncBuiltinESMExports();