aere-quantum/exposure-report/paza-retea.mjs

263 lines
22 KiB
JavaScript

// 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\<name>` / `\\?\pipe\<name>` (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\<name>` or `\\?\pipe\<name>`, 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();