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"));
+});