263 lines
22 KiB
JavaScript
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();
|