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:
Diego Rodrigues de Sa e Souza
2026-06-17 14:57:45 -03:00
committed by GitHub
parent 5ea160140f
commit b2e54618f7
5 changed files with 182 additions and 0 deletions

View File

@@ -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}

View File

@@ -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;
}

View 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";
}
}

View File

@@ -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 =

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