mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-04 14:22:09 +03:00
feat(mitm): attribute intercepted requests to originating process (Gap 1) (#4085)
Integrated into release/v3.8.28 (Fast QG TIA red = 3 pre-existing timing flakes verified passing locally 82/82; PR own tests green)
This commit is contained in:
committed by
GitHub
parent
5ea160140f
commit
b2e54618f7
@@ -69,6 +69,14 @@ export function RequestRow({ request, selected, onClick, onSameContext, style }:
|
||||
<span className="shrink-0">
|
||||
<AgentEmoji agentId={request.agent} />
|
||||
</span>
|
||||
{request.processName && (
|
||||
<span
|
||||
className="text-text-muted shrink-0 font-mono opacity-70 truncate max-w-[120px]"
|
||||
title={request.pid ? `PID ${request.pid}` : undefined}
|
||||
>
|
||||
⚙ {request.processName}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-text-muted truncate font-mono mt-0.5">
|
||||
{request.host}
|
||||
|
||||
@@ -78,6 +78,24 @@ export async function recordRequestStart(
|
||||
};
|
||||
if (opts.sessionId) intercepted.sessionId = opts.sessionId;
|
||||
|
||||
// Best-effort process attribution (Linux only; no-op elsewhere). The proxy's
|
||||
// inbound socket remotePort is the client process's local ephemeral port,
|
||||
// which is what appears in that process's /proc/net/tcp local_address. Never
|
||||
// blocks capture — any failure leaves pid/processName unset. (Gap 1.)
|
||||
try {
|
||||
const { attributeProcess } = await import("./processAttribution.ts");
|
||||
const remotePort = opts.req.socket?.remotePort;
|
||||
if (typeof remotePort === "number") {
|
||||
const info = attributeProcess(remotePort);
|
||||
if (info) {
|
||||
intercepted.pid = info.pid;
|
||||
intercepted.processName = info.processName;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// attribution is best-effort — never block capture
|
||||
}
|
||||
|
||||
globalTrafficBuffer.push(intercepted);
|
||||
return intercepted;
|
||||
}
|
||||
|
||||
107
src/mitm/inspector/processAttribution.ts
Normal file
107
src/mitm/inspector/processAttribution.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Process attribution for the Traffic Inspector (Linux).
|
||||
*
|
||||
* Maps an inbound connection's *client* ephemeral port to the owning PID +
|
||||
* process name by reading /proc/net/tcp{,6} (port → socket inode) then scanning
|
||||
* /proc/<pid>/fd for a symlink to socket:[inode]. A short TTL cache mirrors
|
||||
* ProxyBridge's 1s PID cache to bound the cost of the procfs scan under load.
|
||||
*
|
||||
* Non-Linux platforms return null (stub) — macOS/Windows would need
|
||||
* lsof/GetExtendedTcpTable and are a follow-up. Attribution is always
|
||||
* best-effort: any failure resolves to null and never blocks capture. (Gap 1.)
|
||||
*/
|
||||
import fs from "node:fs";
|
||||
|
||||
const IS_LINUX = process.platform === "linux";
|
||||
const CACHE_TTL_MS = 1000;
|
||||
const cache = new Map<number, { value: ProcessInfo | null; expires: number }>();
|
||||
|
||||
export interface ProcessInfo {
|
||||
pid: number;
|
||||
processName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse /proc/net/tcp content and return the socket inode for `localPort`, or
|
||||
* null if no row matches. Pure + fixture-testable. The local_address column is
|
||||
* "HEXIP:HEXPORT"; the inode is column index 9 (after whitespace split).
|
||||
*/
|
||||
export function parseProcNetTcpForInode(content: string, localPort: number): string | null {
|
||||
const lines = content.split("\n");
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const cols = lines[i].trim().split(/\s+/);
|
||||
if (cols.length < 10) continue;
|
||||
const portHex = cols[1]?.split(":")[1];
|
||||
if (!portHex) continue;
|
||||
const port = parseInt(portHex, 16);
|
||||
if (Number.isNaN(port)) continue;
|
||||
if (port === localPort) return cols[9];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Best-effort PID + name for the process whose socket uses `localPort`. */
|
||||
export function attributeProcess(localPort: number): ProcessInfo | null {
|
||||
if (!IS_LINUX) return null;
|
||||
const now = Date.now();
|
||||
const hit = cache.get(localPort);
|
||||
if (hit && hit.expires > now) return hit.value;
|
||||
|
||||
let result: ProcessInfo | null = null;
|
||||
try {
|
||||
const inode = findInode(localPort);
|
||||
if (inode) {
|
||||
const pid = findPidByInode(inode);
|
||||
if (pid) result = { pid, processName: readProcessName(pid) };
|
||||
}
|
||||
} catch {
|
||||
result = null;
|
||||
}
|
||||
cache.set(localPort, { value: result, expires: now + CACHE_TTL_MS });
|
||||
return result;
|
||||
}
|
||||
|
||||
function findInode(localPort: number): string | null {
|
||||
for (const f of ["/proc/net/tcp", "/proc/net/tcp6"]) {
|
||||
try {
|
||||
const inode = parseProcNetTcpForInode(fs.readFileSync(f, "utf8"), localPort);
|
||||
if (inode && inode !== "0") return inode;
|
||||
} catch {
|
||||
// file may not exist (e.g. no tcp6) — continue
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function findPidByInode(inode: string): number | null {
|
||||
const target = `socket:[${inode}]`;
|
||||
let pids: string[];
|
||||
try {
|
||||
pids = fs.readdirSync("/proc").filter((d) => /^\d+$/.test(d));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
for (const pid of pids) {
|
||||
try {
|
||||
const fds = fs.readdirSync(`/proc/${pid}/fd`);
|
||||
for (const fd of fds) {
|
||||
try {
|
||||
if (fs.readlinkSync(`/proc/${pid}/fd/${fd}`) === target) return Number(pid);
|
||||
} catch {
|
||||
// fd vanished mid-scan — skip
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// process vanished or not readable — skip
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function readProcessName(pid: number): string {
|
||||
try {
|
||||
return fs.readFileSync(`/proc/${pid}/comm`, "utf8").trim() || "unknown";
|
||||
} catch {
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,8 @@ export interface InterceptedRequest {
|
||||
annotation?: string;
|
||||
sessionId?: string;
|
||||
note?: string;
|
||||
pid?: number; // originating process id (Linux only)
|
||||
processName?: string; // originating process name (Linux only)
|
||||
}
|
||||
|
||||
export const InterceptedRequestSchema = z.object({
|
||||
@@ -57,6 +59,8 @@ export const InterceptedRequestSchema = z.object({
|
||||
annotation: z.string().optional(),
|
||||
sessionId: z.string().uuid().optional(),
|
||||
note: z.string().optional(),
|
||||
pid: z.number().int().nonnegative().optional(),
|
||||
processName: z.string().optional(),
|
||||
});
|
||||
|
||||
export type NormalizedBlock =
|
||||
|
||||
45
tests/unit/inspector-process-attribution.test.ts
Normal file
45
tests/unit/inspector-process-attribution.test.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Gap 1: parse /proc/net/tcp to map a local port → socket inode. Pure parser,
|
||||
* fixture-driven — no real /proc access. The proxy sees the client process's
|
||||
* ephemeral port as the connection's remote port, which appears in that
|
||||
* process's /proc/net/tcp LOCAL address column — so we match on local_address.
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
parseProcNetTcpForInode,
|
||||
attributeProcess,
|
||||
} from "../../src/mitm/inspector/processAttribution.ts";
|
||||
|
||||
// Real /proc/net/tcp layout: sl local_address rem_address st tx/rx tr tm retr uid timeout inode ...
|
||||
// local 0100007F:1F90 = 127.0.0.1:8080 (1F90 hex = 8080), inode 45678 in column 9.
|
||||
const SAMPLE = [
|
||||
" sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode",
|
||||
" 0: 0100007F:1F90 0100007F:C001 01 00000000:00000000 00:00000000 00000000 1000 0 45678 1 0000 0",
|
||||
" 1: 0100007F:0050 00000000:0000 0A 00000000:00000000 00:00000000 00000000 1000 0 11111 1 0000 0",
|
||||
].join("\n");
|
||||
|
||||
test("parseProcNetTcpForInode finds the inode for a given local port (hex 1F90 = 8080)", () => {
|
||||
assert.equal(parseProcNetTcpForInode(SAMPLE, 8080), "45678");
|
||||
});
|
||||
|
||||
test("parseProcNetTcpForInode matches another row by its local port (hex 0050 = 80)", () => {
|
||||
assert.equal(parseProcNetTcpForInode(SAMPLE, 80), "11111");
|
||||
});
|
||||
|
||||
test("parseProcNetTcpForInode returns null when no row matches the local port", () => {
|
||||
assert.equal(parseProcNetTcpForInode(SAMPLE, 9999), null);
|
||||
});
|
||||
|
||||
test("parseProcNetTcpForInode tolerates malformed/short lines without throwing", () => {
|
||||
const garbage = "not a real proc table\n x: zzz\n";
|
||||
assert.equal(parseProcNetTcpForInode(garbage, 8080), null);
|
||||
});
|
||||
|
||||
test("attributeProcess returns null on non-Linux (stub) without throwing", () => {
|
||||
// On the CI/dev host this is Linux, but an unbound ephemeral port that no
|
||||
// socket owns must resolve to null rather than throw — exercises the
|
||||
// not-found path safely regardless of platform.
|
||||
const result = attributeProcess(0);
|
||||
assert.ok(result === null || (typeof result.pid === "number" && typeof result.processName === "string"));
|
||||
});
|
||||
Reference in New Issue
Block a user