mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-05 23:02:10 +03:00
fix(mitm): TPROXY OUTPUT-based recipe for local traffic (validated e2e on VPS) (#4156)
Integrated into release/v3.8.29
This commit is contained in:
committed by
GitHub
parent
9aae6b9158
commit
46c76103da
@@ -1,36 +1,48 @@
|
||||
/**
|
||||
* Fase 3 / Epic A — TPROXY transparent capture mode (Linux): command builder.
|
||||
*
|
||||
* This is the spike artifact for Gap 2: a 5th capture mode that intercepts TCP
|
||||
* transparently via Linux TPROXY + policy routing, WITHOUT spoofing `/etc/hosts`
|
||||
* or mutating OS-wide system-proxy settings (so it is headless-friendly and
|
||||
* auto-flushed on reboot). The kernel listener (IP_TRANSPARENT socket) and the
|
||||
* live execution wiring are gated on a real-Linux/VPS validation (Hard Rule
|
||||
* #18) and are intentionally NOT in this module.
|
||||
* A 5th capture mode that intercepts TCP transparently via Linux TPROXY + policy
|
||||
* routing, WITHOUT spoofing `/etc/hosts` or mutating OS-wide system-proxy
|
||||
* settings (headless-friendly, auto-flushed on reboot).
|
||||
*
|
||||
* What lives here is pure + unit-testable: the exact `iptables` / `ip` commands
|
||||
* for apply and revert, with the invariant that **revert is the precise inverse
|
||||
* of apply, in reverse order** — a crash must never leave a mangle rule behind
|
||||
* (the very invariant Fase 1 / `repairMitm()` establishes). When the Epic is
|
||||
* built, `setup.ts` will run these via `execFile` (arrays, never a shell string
|
||||
* — Hard Rule #13) and `repairMitm()` will additionally flush them.
|
||||
* ⚠️ OUTPUT-based recipe (validated e2e on the VPS, kernel 6.8.0): the MITM use
|
||||
* case is LOCAL outbound traffic (IDE agents on the same host), which TPROXY in
|
||||
* PREROUTING does NOT see — PREROUTING only sees *forwarded* traffic. So we mark
|
||||
* new local outbound connections in the `mangle OUTPUT` chain, an `ip rule`
|
||||
* reroutes the marked packets to local delivery (`lo`), and on re-entry the
|
||||
* `mangle PREROUTING` TPROXY target assigns them to the IP_TRANSPARENT listener.
|
||||
* (An earlier PREROUTING-only recipe was proven not to intercept local traffic.)
|
||||
*
|
||||
* Reference design (deep-research, confidence high):
|
||||
* iptables -t mangle -A PREROUTING -p tcp --dport 443 \
|
||||
* -j TPROXY --tproxy-mark 1 --on-port 8443
|
||||
* ip rule add fwmark 1 lookup 100
|
||||
* ip route add local default dev lo table 100
|
||||
* Validated recipe (test port 9999, fwmark 0x2333 — isolated from prod 443/80):
|
||||
* iptables -t mangle -A OUTPUT -p tcp --dport N [-m mark ! --mark BYPASS] -j MARK --set-mark M
|
||||
* ip rule add fwmark M lookup T
|
||||
* ip route add local 0.0.0.0/0 dev lo table T
|
||||
* iptables -t mangle -A PREROUTING -p tcp --dport N -m mark --mark M -j TPROXY --on-port L --tproxy-mark M
|
||||
* Result: client CONNECTED, listener saw orig-dest preserved (198.51.100.7:9999).
|
||||
*
|
||||
* Pure + unit-testable: the exact `iptables`/`ip` commands for apply and revert,
|
||||
* with the invariant that **revert is the precise inverse of apply, in reverse
|
||||
* order** — a crash must never leave a mangle rule behind (the Fase 1 /
|
||||
* `repairMitm()` invariant). `setup.ts` runs these via `execFile` (arrays, never
|
||||
* a shell string — Hard Rule #13).
|
||||
*
|
||||
* `bypassMark` (anti-loop): the SO_MARK the proxy sets on its OWN upstream
|
||||
* connections; the OUTPUT rule excludes it so the proxy's forwarded traffic is
|
||||
* not re-intercepted (infinite loop). When omitted, no exclusion is emitted
|
||||
* (fine for a metadata-only listener that never forwards).
|
||||
*/
|
||||
|
||||
export interface TproxyConfig {
|
||||
/** Destination TCP port to transparently intercept (e.g. 443). */
|
||||
dport: number;
|
||||
/** Firewall mark applied by TPROXY and matched by the ip rule (e.g. 1). */
|
||||
/** Firewall mark set on OUTPUT and matched by the ip rule + PREROUTING (e.g. 0x2333). */
|
||||
mark: number;
|
||||
/** Local port the IP_TRANSPARENT listener binds (e.g. 8443). */
|
||||
onPort: number;
|
||||
/** Policy-routing table id holding the `local default` route (e.g. 100). */
|
||||
/** Policy-routing table id holding the `local 0.0.0.0/0` route (e.g. 233). */
|
||||
routeTable: number;
|
||||
/** SO_MARK the proxy sets on its own upstream conns; excluded in OUTPUT (anti-loop). */
|
||||
bypassMark?: number;
|
||||
}
|
||||
|
||||
/** A single command to run via `execFile(bin, args)` — never a shell string. */
|
||||
@@ -45,8 +57,7 @@ function isPort(n: number): boolean {
|
||||
|
||||
/**
|
||||
* Validate a config before any command is built/run. Returns an error message
|
||||
* string, or null when the config is sane. (Cheap guard so the future execFile
|
||||
* path never shells out malformed numbers.)
|
||||
* string, or null when the config is sane.
|
||||
*/
|
||||
export function validateTproxyConfig(cfg: TproxyConfig): string | null {
|
||||
if (!isPort(cfg.dport)) return `dport must be a valid TCP port (1-65535), got ${cfg.dport}`;
|
||||
@@ -55,42 +66,57 @@ export function validateTproxyConfig(cfg: TproxyConfig): string | null {
|
||||
if (!Number.isInteger(cfg.routeTable) || cfg.routeTable < 1) {
|
||||
return `routeTable must be a positive integer, got ${cfg.routeTable}`;
|
||||
}
|
||||
if (cfg.bypassMark !== undefined) {
|
||||
if (!Number.isInteger(cfg.bypassMark) || cfg.bypassMark < 1) {
|
||||
return `bypassMark must be a positive integer when set, got ${cfg.bypassMark}`;
|
||||
}
|
||||
if (cfg.bypassMark === cfg.mark) return "bypassMark must differ from mark (anti-loop)";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The mangle PREROUTING rule spec, shared so -A and -D match exactly. */
|
||||
function tproxyRuleSpec(cfg: TproxyConfig): string[] {
|
||||
/** OUTPUT mangle rule spec (mark new local outbound conns), shared so -A/-D match. */
|
||||
function outputRuleSpec(cfg: TproxyConfig): string[] {
|
||||
const spec = ["-t", "mangle", "OUTPUT", "-p", "tcp", "--dport", String(cfg.dport)];
|
||||
if (cfg.bypassMark !== undefined) {
|
||||
spec.push("-m", "mark", "!", "--mark", String(cfg.bypassMark));
|
||||
}
|
||||
spec.push("-j", "MARK", "--set-mark", String(cfg.mark));
|
||||
return spec;
|
||||
}
|
||||
|
||||
/** PREROUTING mangle TPROXY rule spec (assign marked, rerouted packets to the listener). */
|
||||
function preroutingRuleSpec(cfg: TproxyConfig): string[] {
|
||||
return [
|
||||
"-t", "mangle",
|
||||
"PREROUTING",
|
||||
"-p", "tcp",
|
||||
"--dport", String(cfg.dport),
|
||||
"-j", "TPROXY",
|
||||
"--tproxy-mark", String(cfg.mark),
|
||||
"--on-port", String(cfg.onPort),
|
||||
"-t", "mangle", "PREROUTING",
|
||||
"-p", "tcp", "--dport", String(cfg.dport),
|
||||
"-m", "mark", "--mark", String(cfg.mark),
|
||||
"-j", "TPROXY", "--on-port", String(cfg.onPort), "--tproxy-mark", String(cfg.mark),
|
||||
];
|
||||
}
|
||||
|
||||
function iptables(op: "-A" | "-D", cfg: TproxyConfig): TproxyCommand {
|
||||
const [table, tableName, chain, ...rest] = tproxyRuleSpec(cfg);
|
||||
// Reassemble as: -t mangle <op> PREROUTING ...rest
|
||||
return { bin: "iptables", args: [table, tableName, op, chain, ...rest] };
|
||||
/** Build an iptables command from a `[-t table, CHAIN, ...rest]` spec + the op flag. */
|
||||
function iptables(op: "-A" | "-D", spec: string[]): TproxyCommand {
|
||||
const [t, table, chain, ...rest] = spec;
|
||||
return { bin: "iptables", args: [t, table, op, chain, ...rest] };
|
||||
}
|
||||
|
||||
/** Commands to enable TPROXY interception, in apply order. */
|
||||
/** Commands to enable TPROXY interception of local outbound traffic, in apply order. */
|
||||
export function buildTproxyApplyCommands(cfg: TproxyConfig): TproxyCommand[] {
|
||||
return [
|
||||
iptables("-A", cfg),
|
||||
{ bin: "ip", args: ["rule", "add", "fwmark", String(cfg.mark), "lookup", String(cfg.routeTable)] },
|
||||
{ bin: "ip", args: ["route", "add", "local", "default", "dev", "lo", "table", String(cfg.routeTable)] },
|
||||
{ bin: "ip", args: ["route", "add", "local", "0.0.0.0/0", "dev", "lo", "table", String(cfg.routeTable)] },
|
||||
iptables("-A", outputRuleSpec(cfg)),
|
||||
iptables("-A", preroutingRuleSpec(cfg)),
|
||||
];
|
||||
}
|
||||
|
||||
/** Commands to undo TPROXY interception — exact inverse of apply, reverse order. */
|
||||
export function buildTproxyRevertCommands(cfg: TproxyConfig): TproxyCommand[] {
|
||||
return [
|
||||
{ bin: "ip", args: ["route", "del", "local", "default", "dev", "lo", "table", String(cfg.routeTable)] },
|
||||
iptables("-D", preroutingRuleSpec(cfg)),
|
||||
iptables("-D", outputRuleSpec(cfg)),
|
||||
{ bin: "ip", args: ["route", "del", "local", "0.0.0.0/0", "dev", "lo", "table", String(cfg.routeTable)] },
|
||||
{ bin: "ip", args: ["rule", "del", "fwmark", String(cfg.mark), "lookup", String(cfg.routeTable)] },
|
||||
iptables("-D", cfg),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
/**
|
||||
* Fase 3 / Epic A spike — TPROXY transparent capture mode (Linux).
|
||||
* Fase 3 / Epic A — TPROXY command builder (OUTPUT-based recipe).
|
||||
*
|
||||
* The kernel wiring (IP_TRANSPARENT listener, live intercept) cannot be unit-
|
||||
* tested here — it needs CAP_NET_ADMIN + a real kernel and is gated on a VPS
|
||||
* live test (Hard Rule #18). What CAN be locked down now, with no root, is the
|
||||
* exact set of iptables / ip-rule / ip-route commands and the invariant that
|
||||
* revert is the precise inverse of apply (in reverse order). A leftover mangle
|
||||
* rule after a crash is the very failure Fase 1 set out to prevent, so this
|
||||
* builder is the spec the VPS spike will execute and the future execFile wiring
|
||||
* will consume. Commands are produced as {bin, args[]} for execFile — never a
|
||||
* shell string (Hard Rule #13).
|
||||
* The recipe was validated end-to-end on the VPS (kernel 6.8.0): a local
|
||||
* outbound connection to a test port was marked in OUTPUT, rerouted to local
|
||||
* delivery, and the PREROUTING TPROXY target assigned it to the IP_TRANSPARENT
|
||||
* listener (client CONNECTED, original destination preserved). These tests pin
|
||||
* the exact commands + the invariant that revert is the precise inverse of
|
||||
* apply, in reverse order (a leftover mangle rule after a crash is the failure
|
||||
* Fase 1 prevents). Commands are {bin, args[]} for execFile — never a shell
|
||||
* string (Hard Rule #13).
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
@@ -18,80 +17,92 @@ const { buildTproxyApplyCommands, buildTproxyRevertCommands, validateTproxyConfi
|
||||
"../../src/mitm/tproxy/commands.ts"
|
||||
);
|
||||
|
||||
const CFG = { dport: 443, mark: 1, onPort: 8443, routeTable: 100 };
|
||||
const CFG = { dport: 443, mark: 9011, onPort: 8443, routeTable: 233 };
|
||||
|
||||
test("apply builds the three TPROXY/policy-routing commands in order", () => {
|
||||
test("apply builds the 4 OUTPUT-based commands in order (ip rule, ip route, OUTPUT mark, PREROUTING TPROXY)", () => {
|
||||
const cmds = buildTproxyApplyCommands(CFG);
|
||||
assert.equal(cmds.length, 3);
|
||||
assert.equal(cmds.length, 4);
|
||||
|
||||
// 1) mangle PREROUTING TPROXY rule
|
||||
assert.deepEqual(cmds[0], {
|
||||
assert.deepEqual(cmds[0], { bin: "ip", args: ["rule", "add", "fwmark", "9011", "lookup", "233"] });
|
||||
assert.deepEqual(cmds[1], {
|
||||
bin: "ip",
|
||||
args: ["route", "add", "local", "0.0.0.0/0", "dev", "lo", "table", "233"],
|
||||
});
|
||||
// OUTPUT marks new local outbound connections to the target port
|
||||
assert.deepEqual(cmds[2], {
|
||||
bin: "iptables",
|
||||
args: ["-t", "mangle", "-A", "OUTPUT", "-p", "tcp", "--dport", "443", "-j", "MARK", "--set-mark", "9011"],
|
||||
});
|
||||
// PREROUTING TPROXY assigns the rerouted, marked packets to the listener
|
||||
assert.deepEqual(cmds[3], {
|
||||
bin: "iptables",
|
||||
args: [
|
||||
"-t", "mangle", "-A", "PREROUTING",
|
||||
"-p", "tcp", "--dport", "443",
|
||||
"-j", "TPROXY", "--tproxy-mark", "1", "--on-port", "8443",
|
||||
"-t", "mangle", "-A", "PREROUTING", "-p", "tcp", "--dport", "443",
|
||||
"-m", "mark", "--mark", "9011", "-j", "TPROXY", "--on-port", "8443", "--tproxy-mark", "9011",
|
||||
],
|
||||
});
|
||||
// 2) ip rule fwmark -> table
|
||||
assert.deepEqual(cmds[1], { bin: "ip", args: ["rule", "add", "fwmark", "1", "lookup", "100"] });
|
||||
// 3) local default route in that table
|
||||
assert.deepEqual(cmds[2], {
|
||||
bin: "ip",
|
||||
args: ["route", "add", "local", "default", "dev", "lo", "table", "100"],
|
||||
});
|
||||
});
|
||||
|
||||
test("bypassMark adds the anti-loop exclusion to the OUTPUT rule", () => {
|
||||
const cmds = buildTproxyApplyCommands({ ...CFG, bypassMark: 1337 });
|
||||
const output = cmds[2].args;
|
||||
assert.deepEqual(output, [
|
||||
"-t", "mangle", "-A", "OUTPUT", "-p", "tcp", "--dport", "443",
|
||||
"-m", "mark", "!", "--mark", "1337", "-j", "MARK", "--set-mark", "9011",
|
||||
]);
|
||||
});
|
||||
|
||||
test("every arg is a string (execFile-safe, Hard Rule #13)", () => {
|
||||
for (const cmd of [...buildTproxyApplyCommands(CFG), ...buildTproxyRevertCommands(CFG)]) {
|
||||
const all = [
|
||||
...buildTproxyApplyCommands(CFG),
|
||||
...buildTproxyRevertCommands(CFG),
|
||||
...buildTproxyApplyCommands({ ...CFG, bypassMark: 1337 }),
|
||||
];
|
||||
for (const cmd of all) {
|
||||
assert.ok(typeof cmd.bin === "string" && cmd.bin.length > 0);
|
||||
for (const a of cmd.args) assert.equal(typeof a, "string", `arg ${a} must be a string`);
|
||||
}
|
||||
});
|
||||
|
||||
test("revert is the exact inverse of apply, in reverse order", () => {
|
||||
const apply = buildTproxyApplyCommands(CFG);
|
||||
const revert = buildTproxyRevertCommands(CFG);
|
||||
assert.equal(revert.length, 3);
|
||||
assert.equal(revert.length, 4);
|
||||
|
||||
// route del (reverse order: route added last is torn down first)
|
||||
assert.deepEqual(revert[0], {
|
||||
bin: "ip",
|
||||
args: ["route", "del", "local", "default", "dev", "lo", "table", "100"],
|
||||
});
|
||||
// rule del
|
||||
assert.deepEqual(revert[1], { bin: "ip", args: ["rule", "del", "fwmark", "1", "lookup", "100"] });
|
||||
// iptables -D mirrors -A exactly except the operation flag
|
||||
// reverse order: PREROUTING -D, OUTPUT -D, route del, rule del
|
||||
assert.deepEqual(revert[0].args.slice(0, 4), ["-t", "mangle", "-D", "PREROUTING"]);
|
||||
assert.deepEqual(revert[1].args.slice(0, 4), ["-t", "mangle", "-D", "OUTPUT"]);
|
||||
assert.deepEqual(revert[2], {
|
||||
bin: "iptables",
|
||||
args: [
|
||||
"-t", "mangle", "-D", "PREROUTING",
|
||||
"-p", "tcp", "--dport", "443",
|
||||
"-j", "TPROXY", "--tproxy-mark", "1", "--on-port", "8443",
|
||||
],
|
||||
bin: "ip",
|
||||
args: ["route", "del", "local", "0.0.0.0/0", "dev", "lo", "table", "233"],
|
||||
});
|
||||
});
|
||||
assert.deepEqual(revert[3], { bin: "ip", args: ["rule", "del", "fwmark", "9011", "lookup", "233"] });
|
||||
|
||||
test("apply -A and revert -D differ only in the iptables operation flag", () => {
|
||||
const apply = buildTproxyApplyCommands(CFG)[0].args;
|
||||
const revert = buildTproxyRevertCommands(CFG)[2].args;
|
||||
// -A and -D rule specs match exactly except the op flag (so -D removes the exact -A rule)
|
||||
assert.deepEqual(
|
||||
apply.map((a) => (a === "-A" ? "OP" : a)),
|
||||
revert.map((a) => (a === "-D" ? "OP" : a)),
|
||||
"the rule spec must be identical so -D matches the exact -A rule"
|
||||
apply[3].args.map((a) => (a === "-A" ? "OP" : a)),
|
||||
revert[0].args.map((a) => (a === "-D" ? "OP" : a))
|
||||
);
|
||||
assert.deepEqual(
|
||||
apply[2].args.map((a) => (a === "-A" ? "OP" : a)),
|
||||
revert[1].args.map((a) => (a === "-D" ? "OP" : a))
|
||||
);
|
||||
});
|
||||
|
||||
test("config values flow into the commands (no hardcoding)", () => {
|
||||
const custom = { dport: 8443, mark: 7, onPort: 9999, routeTable: 200 };
|
||||
const cmds = buildTproxyApplyCommands(custom);
|
||||
assert.ok(cmds[0].args.includes("8443") && cmds[0].args.includes("9999") && cmds[0].args.includes("7"));
|
||||
assert.ok(cmds[1].args.includes("200"));
|
||||
assert.ok(cmds[2].args.includes("8443") && cmds[2].args.includes("7"));
|
||||
assert.ok(cmds[3].args.includes("9999") && cmds[3].args.includes("200") === false); // table not in TPROXY rule
|
||||
assert.ok(cmds[0].args.includes("200"));
|
||||
});
|
||||
|
||||
test("validateTproxyConfig accepts a sane config and rejects bad ports/marks", () => {
|
||||
test("validateTproxyConfig accepts a sane config and rejects bad values", () => {
|
||||
assert.equal(validateTproxyConfig(CFG), null);
|
||||
assert.equal(validateTproxyConfig({ ...CFG, bypassMark: 1337 }), null);
|
||||
assert.match(validateTproxyConfig({ ...CFG, dport: 0 }) ?? "", /dport/i);
|
||||
assert.match(validateTproxyConfig({ ...CFG, onPort: 70000 }) ?? "", /onPort/i);
|
||||
assert.match(validateTproxyConfig({ ...CFG, mark: 0 }) ?? "", /mark/i);
|
||||
assert.match(validateTproxyConfig({ ...CFG, routeTable: -1 }) ?? "", /table/i);
|
||||
assert.match(validateTproxyConfig({ ...CFG, bypassMark: CFG.mark }) ?? "", /bypassMark/i);
|
||||
});
|
||||
|
||||
@@ -26,14 +26,14 @@ function recorder(failOnIndex = -1) {
|
||||
return { calls, run };
|
||||
}
|
||||
|
||||
test("applyTproxy runs the 3 apply commands in order via the injected runner", async () => {
|
||||
test("applyTproxy runs the 4 OUTPUT-based apply commands in order via the injected runner", async () => {
|
||||
const r = recorder();
|
||||
await applyTproxy(CFG, r.run);
|
||||
assert.equal(r.calls.length, 3);
|
||||
assert.equal(r.calls[0].bin, "iptables");
|
||||
assert.deepEqual(r.calls[0].args.slice(0, 4), ["-t", "mangle", "-A", "PREROUTING"]);
|
||||
assert.deepEqual(r.calls[1], { bin: "ip", args: ["rule", "add", "fwmark", "1", "lookup", "100"] });
|
||||
assert.equal(r.calls[2].args[0], "route");
|
||||
assert.equal(r.calls.length, 4);
|
||||
assert.deepEqual(r.calls[0], { bin: "ip", args: ["rule", "add", "fwmark", "1", "lookup", "100"] });
|
||||
assert.equal(r.calls[1].args[0], "route");
|
||||
assert.deepEqual(r.calls[2].args.slice(0, 4), ["-t", "mangle", "-A", "OUTPUT"]);
|
||||
assert.deepEqual(r.calls[3].args.slice(0, 4), ["-t", "mangle", "-A", "PREROUTING"]);
|
||||
});
|
||||
|
||||
test("applyTproxy rejects an invalid config before running anything", async () => {
|
||||
@@ -43,21 +43,20 @@ test("applyTproxy rejects an invalid config before running anything", async () =
|
||||
});
|
||||
|
||||
test("applyTproxy runs a best-effort full revert when a command fails mid-way", async () => {
|
||||
const r = recorder(1); // fail on the 2nd apply command (ip rule add)
|
||||
const r = recorder(1); // fail on the 2nd apply command (ip route add)
|
||||
await assert.rejects(() => applyTproxy(CFG, r.run), /boom/);
|
||||
// apply[0], apply[1]=fail, then the 3 revert commands (best-effort cleanup)
|
||||
assert.equal(r.calls.length, 5);
|
||||
assert.deepEqual(r.calls[2], {
|
||||
bin: "ip",
|
||||
args: ["route", "del", "local", "default", "dev", "lo", "table", "100"],
|
||||
});
|
||||
assert.deepEqual(r.calls[4].args.slice(0, 4), ["-t", "mangle", "-D", "PREROUTING"]);
|
||||
// apply[0], apply[1]=fail, then the 4 revert commands (best-effort cleanup) = 6
|
||||
assert.equal(r.calls.length, 6);
|
||||
// first revert command tears down the PREROUTING rule (reverse order)
|
||||
assert.deepEqual(r.calls[2].args.slice(0, 4), ["-t", "mangle", "-D", "PREROUTING"]);
|
||||
// last revert command removes the ip rule
|
||||
assert.deepEqual(r.calls[5], { bin: "ip", args: ["rule", "del", "fwmark", "1", "lookup", "100"] });
|
||||
});
|
||||
|
||||
test("revertTproxy runs all 3 reverts best-effort even if one fails (idempotent)", async () => {
|
||||
test("revertTproxy runs all 4 reverts best-effort even if one fails (idempotent)", async () => {
|
||||
const r = recorder(1); // 2nd revert throws (e.g. rule not present)
|
||||
await revertTproxy(CFG, r.run); // must NOT throw
|
||||
assert.equal(r.calls.length, 3, "all three reverts attempted despite the failure");
|
||||
assert.equal(r.calls.length, 4, "all four reverts attempted despite the failure");
|
||||
});
|
||||
|
||||
test("every command the runner receives has string args (execFile-safe)", async () => {
|
||||
|
||||
Reference in New Issue
Block a user