From b2e54618f71815db74c8ffcea252a747b3316945 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Wed, 17 Jun 2026 14:57:45 -0300 Subject: [PATCH] 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) --- .../components/RequestRow.tsx | 8 ++ src/mitm/inspector/agentBridgeHook.ts | 18 +++ src/mitm/inspector/processAttribution.ts | 107 ++++++++++++++++++ src/mitm/inspector/types.ts | 4 + .../inspector-process-attribution.test.ts | 45 ++++++++ 5 files changed, 182 insertions(+) create mode 100644 src/mitm/inspector/processAttribution.ts create mode 100644 tests/unit/inspector-process-attribution.test.ts diff --git a/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/RequestRow.tsx b/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/RequestRow.tsx index 1acf09e7a6..63a05a9fc0 100644 --- a/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/RequestRow.tsx +++ b/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/RequestRow.tsx @@ -69,6 +69,14 @@ export function RequestRow({ request, selected, onClick, onSameContext, style }: + {request.processName && ( + + ⚙ {request.processName} + + )}
{request.host} diff --git a/src/mitm/inspector/agentBridgeHook.ts b/src/mitm/inspector/agentBridgeHook.ts index 00d3e37243..70014ac603 100644 --- a/src/mitm/inspector/agentBridgeHook.ts +++ b/src/mitm/inspector/agentBridgeHook.ts @@ -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; } diff --git a/src/mitm/inspector/processAttribution.ts b/src/mitm/inspector/processAttribution.ts new file mode 100644 index 0000000000..4b241513ff --- /dev/null +++ b/src/mitm/inspector/processAttribution.ts @@ -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//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(); + +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"; + } +} diff --git a/src/mitm/inspector/types.ts b/src/mitm/inspector/types.ts index dff03e20f1..513d7596fb 100644 --- a/src/mitm/inspector/types.ts +++ b/src/mitm/inspector/types.ts @@ -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 = diff --git a/tests/unit/inspector-process-attribution.test.ts b/tests/unit/inspector-process-attribution.test.ts new file mode 100644 index 0000000000..47ebbb7d25 --- /dev/null +++ b/tests/unit/inspector-process-attribution.test.ts @@ -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")); +});