From ba1caad1b57e9237d32ee9793f50ed9666d76cf6 Mon Sep 17 00:00:00 2001 From: Waldek Mastykarz Date: Mon, 21 Sep 2026 14:21:25 +0200 Subject: [PATCH 1/3] Support watching child process traffic Add opt-in process-tree filtering that walks ancestor processes and caches decisions by PID and process start time. Closes dotnet/dev-proxy#1800 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Proxy/IProxyConfiguration.cs | 1 + .../TestProxyConfiguration.cs | 3 +- .../ProcessFilterTests.cs | 193 +++++++++++- .../Internal/ProcessFilter.cs | 289 +++++++++++++++++- DevProxy.Proxy.Kestrel/KestrelProxyEngine.cs | 5 +- DevProxy.Tests/Fakes.cs | 1 + DevProxy/Commands/DevProxyCommand.cs | 14 +- DevProxy/Proxy/ProxyConfiguration.cs | 1 + schemas/v4.0.0/rc.schema.json | 4 + skills/dev-proxy/references/configuration.md | 2 + 10 files changed, 499 insertions(+), 14 deletions(-) diff --git a/DevProxy.Abstractions/Proxy/IProxyConfiguration.cs b/DevProxy.Abstractions/Proxy/IProxyConfiguration.cs index 4d46bfc15..e2e67007c 100644 --- a/DevProxy.Abstractions/Proxy/IProxyConfiguration.cs +++ b/DevProxy.Abstractions/Proxy/IProxyConfiguration.cs @@ -49,4 +49,5 @@ public interface IProxyConfiguration bool ValidateSchemas { get; } IEnumerable WatchPids { get; set; } IEnumerable WatchProcessNames { get; set; } + bool WatchProcessTree { get; set; } } \ No newline at end of file diff --git a/DevProxy.Integration.Tests/TestProxyConfiguration.cs b/DevProxy.Integration.Tests/TestProxyConfiguration.cs index 86126d18d..57761b5e5 100644 --- a/DevProxy.Integration.Tests/TestProxyConfiguration.cs +++ b/DevProxy.Integration.Tests/TestProxyConfiguration.cs @@ -11,7 +11,7 @@ namespace DevProxy.Integration.Tests; /// /// Minimal for the integration harness. Only the /// members the Kestrel engine reads at boot (Port, IPAddress, AsSystemProxy, -/// WatchPids/WatchProcessNames) actually matter; the rest carry inert defaults. +/// WatchPids/WatchProcessNames/WatchProcessTree) actually matter; the rest carry inert defaults. /// internal sealed class TestProxyConfiguration : IProxyConfiguration { @@ -34,4 +34,5 @@ internal sealed class TestProxyConfiguration : IProxyConfiguration public bool ValidateSchemas => false; public IEnumerable WatchPids { get; set; } = []; public IEnumerable WatchProcessNames { get; set; } = []; + public bool WatchProcessTree { get; set; } } diff --git a/DevProxy.Proxy.Kestrel.Tests/ProcessFilterTests.cs b/DevProxy.Proxy.Kestrel.Tests/ProcessFilterTests.cs index c4c543875..638dd90b1 100644 --- a/DevProxy.Proxy.Kestrel.Tests/ProcessFilterTests.cs +++ b/DevProxy.Proxy.Kestrel.Tests/ProcessFilterTests.cs @@ -13,9 +13,21 @@ public class ProcessFilterTests private static ProcessFilter Filter( IEnumerable? pids = null, IEnumerable? names = null, + bool watchProcessTree = false, Func? resolvePid = null, - Func? resolveName = null) => - new(pids ?? [], names ?? [], resolvePid, resolveName); + Func? resolveName = null, + Func? resolveStartTime = null, + Func>? resolveParentPids = null, + Func? utcNow = null) => + new( + pids ?? [], + names ?? [], + watchProcessTree, + resolvePid, + resolveName, + resolveStartTime, + resolveParentPids, + utcNow); [Fact] public void IsEmpty_True_WhenNoFilterConfigured() @@ -103,6 +115,183 @@ public void IsWatchedProcess_PidWins_WithoutResolvingName() resolveName: _ => throw new InvalidOperationException("name lookup not needed")); Assert.True(filter.IsWatchedProcess(54321)); } + + [Fact] + public void IsWatchedProcess_False_ForChild_WhenProcessTreeDisabled() + { + var filter = Filter( + pids: [100], + resolvePid: _ => 300, + resolveParentPids: () => new Dictionary { [300] = 200, [200] = 100 }); + + Assert.False(filter.IsWatchedProcess(54321)); + } + + [Fact] + public void IsWatchedProcess_True_WhenAncestorPidMatches() + { + var filter = Filter( + pids: [100], + watchProcessTree: true, + resolvePid: _ => 300, + resolveName: _ => null, + resolveStartTime: pid => DateTimeOffset.UnixEpoch.AddSeconds(pid), + resolveParentPids: () => new Dictionary { [300] = 200, [200] = 100 }); + + Assert.True(filter.IsWatchedProcess(54321)); + } + + [Fact] + public void IsWatchedProcess_True_WhenAncestorNameMatches() + { + var names = new Dictionary + { + [100] = "Visual Studio Code", + [200] = "extension-host", + [300] = "node" + }; + var filter = Filter( + names: ["Visual Studio Code"], + watchProcessTree: true, + resolvePid: _ => 300, + resolveName: pid => names[pid], + resolveStartTime: pid => DateTimeOffset.UnixEpoch.AddSeconds(pid), + resolveParentPids: () => new Dictionary { [300] = 200, [200] = 100 }); + + Assert.True(filter.IsWatchedProcess(54321)); + } + + [Fact] + public void IsWatchedProcess_False_WhenNoAncestorMatches() + { + var filter = Filter( + names: ["Visual Studio Code"], + watchProcessTree: true, + resolvePid: _ => 300, + resolveName: pid => pid == 100 ? "terminal" : "node", + resolveStartTime: pid => DateTimeOffset.UnixEpoch.AddSeconds(pid), + resolveParentPids: () => new Dictionary { [300] = 200, [200] = 100 }); + + Assert.False(filter.IsWatchedProcess(54321)); + } + + [Fact] + public void IsWatchedProcess_UsesCachedDecision_ForSameProcessInstance() + { + var parentResolutionCount = 0; + var filter = Filter( + pids: [100], + watchProcessTree: true, + resolvePid: _ => 300, + resolveName: _ => null, + resolveStartTime: pid => DateTimeOffset.UnixEpoch.AddSeconds(pid), + resolveParentPids: () => + { + parentResolutionCount++; + return new Dictionary { [300] = 200, [200] = 100 }; + }); + + Assert.True(filter.IsWatchedProcess(54321)); + Assert.True(filter.IsWatchedProcess(54322)); + Assert.Equal(1, parentResolutionCount); + } + + [Fact] + public void IsWatchedProcess_DoesNotReuseCache_WhenPidIsReused() + { + var startTime = DateTimeOffset.UnixEpoch; + var parents = new Dictionary { [300] = 100 }; + var filter = Filter( + pids: [100], + watchProcessTree: true, + resolvePid: _ => 300, + resolveName: _ => null, + resolveStartTime: _ => startTime, + resolveParentPids: () => parents); + + Assert.True(filter.IsWatchedProcess(54321)); + + startTime = startTime.AddMinutes(1); + parents = new Dictionary { [300] = 400 }; + + Assert.False(filter.IsWatchedProcess(54322)); + } + + [Fact] + public void IsWatchedProcess_RefreshesExpiredCacheEntry() + { + var now = DateTimeOffset.UnixEpoch; + var parentResolutionCount = 0; + var filter = Filter( + pids: [100], + watchProcessTree: true, + resolvePid: _ => 300, + resolveName: _ => null, + resolveStartTime: pid => DateTimeOffset.UnixEpoch.AddSeconds(pid), + resolveParentPids: () => + { + parentResolutionCount++; + return new Dictionary { [300] = 100 }; + }, + utcNow: () => now); + + Assert.True(filter.IsWatchedProcess(54321)); + + now = now.AddMinutes(2); + + Assert.True(filter.IsWatchedProcess(54322)); + Assert.Equal(2, parentResolutionCount); + } + + [Fact] + public void IsWatchedProcess_StopsWhenParentGraphContainsCycle() + { + var filter = Filter( + pids: [100], + watchProcessTree: true, + resolvePid: _ => 300, + resolveName: _ => null, + resolveStartTime: pid => DateTimeOffset.UnixEpoch.AddSeconds(pid), + resolveParentPids: () => new Dictionary { [300] = 200, [200] = 300 }); + + Assert.False(filter.IsWatchedProcess(54321)); + } +} + +public class ProcessTreeResolverTests +{ + [Fact] + public void ParsePsOutput_ReturnsParentRelationships() + { + const string output = """ + 1 0 + 100 1 + 200 100 + 300 200 + """; + + var parents = ProcessTreeResolver.ParsePsOutput(output); + + Assert.Equal(0, parents[1]); + Assert.Equal(1, parents[100]); + Assert.Equal(100, parents[200]); + Assert.Equal(200, parents[300]); + } + + [Fact] + public void ParsePsOutput_IgnoresMalformedLines() + { + const string output = """ + PID PPID + invalid + 200 100 + """; + + var parents = ProcessTreeResolver.ParsePsOutput(output); + + Assert.Single(parents); + Assert.Equal(100, parents[200]); + } } public class LsofParserTests diff --git a/DevProxy.Proxy.Kestrel/Internal/ProcessFilter.cs b/DevProxy.Proxy.Kestrel/Internal/ProcessFilter.cs index 3066c557d..b7e6e3d94 100644 --- a/DevProxy.Proxy.Kestrel/Internal/ProcessFilter.cs +++ b/DevProxy.Proxy.Kestrel/Internal/ProcessFilter.cs @@ -2,9 +2,13 @@ // The .NET Foundation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System.Collections.Concurrent; using System.Diagnostics; using System.Globalization; +using System.Runtime.InteropServices; +using System.Runtime.Versioning; using System.Text.RegularExpressions; +using Microsoft.Win32.SafeHandles; namespace DevProxy.Proxy.Kestrel.Internal; @@ -22,31 +26,48 @@ namespace DevProxy.Proxy.Kestrel.Internal; /// /// /// -/// The PID resolver and name resolver are injectable so the decision logic can be -/// unit-tested without spawning real processes; the defaults shell out to -/// and . +/// The PID, process metadata, and process-tree resolvers are injectable so the decision +/// logic can be unit-tested without querying real processes; the defaults shell out to +/// / ps, use the Windows process snapshot +/// API, and read metadata through . /// /// internal sealed class ProcessFilter { + private static readonly TimeSpan CacheLifetime = TimeSpan.FromMinutes(1); + private const int MaxCacheEntries = 4096; + private readonly HashSet _pids; // Ordinal (case-sensitive) to match the Titanium engine's IEnumerable.Contains. private readonly HashSet _names; + private readonly bool _watchProcessTree; private readonly Func _resolvePid; private readonly Func _resolveName; + private readonly Func _resolveStartTime; + private readonly Func> _resolveParentPids; + private readonly Func _utcNow; + private readonly ConcurrentDictionary _cache = new(); public ProcessFilter( IEnumerable watchPids, IEnumerable watchProcessNames, + bool watchProcessTree = false, Func? resolvePid = null, - Func? resolveName = null) + Func? resolveName = null, + Func? resolveStartTime = null, + Func>? resolveParentPids = null, + Func? utcNow = null) { ArgumentNullException.ThrowIfNull(watchPids); ArgumentNullException.ThrowIfNull(watchProcessNames); _pids = [.. watchPids]; _names = new HashSet(watchProcessNames, StringComparer.Ordinal); + _watchProcessTree = watchProcessTree; _resolvePid = resolvePid ?? ConnectionProcessResolver.ResolveProcessId; _resolveName = resolveName ?? DefaultResolveName; + _resolveStartTime = resolveStartTime ?? DefaultResolveStartTime; + _resolveParentPids = resolveParentPids ?? ProcessTreeResolver.ResolveParentProcessIds; + _utcNow = utcNow ?? (() => DateTimeOffset.UtcNow); } /// True when no pid/name filter is configured — every process is watched. @@ -75,30 +96,280 @@ public bool IsWatchedProcess(int clientPort) return true; } - if (_names.Count > 0) + if (MatchesProcessName(pid.Value)) + { + return true; + } + + if (!_watchProcessTree) + { + return false; + } + + var cacheKeys = new List(); + var processKey = ResolveCacheKey(pid.Value); + if (processKey is not null) { - var name = _resolveName(pid.Value); - if (name is not null && _names.Contains(name)) + if (TryGetCachedDecision(processKey.Value, out var cached)) { + return cached; + } + cacheKeys.Add(processKey.Value); + } + + var parentPids = _resolveParentPids(); + if (parentPids.Count == 0) + { + return false; + } + + var visited = new HashSet { pid.Value }; + var currentPid = pid.Value; + var isWatched = false; + while (parentPids.TryGetValue(currentPid, out var parentPid) && + parentPid > 0 && + visited.Add(parentPid)) + { + var parentKey = ResolveCacheKey(parentPid); + if (parentKey is not null) + { + if (TryGetCachedDecision(parentKey.Value, out var cached)) + { + isWatched = cached; + break; + } + cacheKeys.Add(parentKey.Value); + } + + if (_pids.Contains(parentPid) || MatchesProcessName(parentPid)) + { + isWatched = true; + break; + } + + currentPid = parentPid; + } + + foreach (var key in cacheKeys) + { + CacheDecision(key, isWatched); + } + + return isWatched; + } + + private bool MatchesProcessName(int pid) + { + if (_names.Count == 0) + { + return false; + } + + var name = _resolveName(pid); + return name is not null && _names.Contains(name); + } + + private ProcessCacheKey? ResolveCacheKey(int pid) + { + var startTime = _resolveStartTime(pid); + return startTime is null ? null : new(pid, startTime.Value.UtcTicks); + } + + private bool TryGetCachedDecision(ProcessCacheKey key, out bool isWatched) + { + if (_cache.TryGetValue(key, out var entry)) + { + if (entry.ExpiresAt > _utcNow()) + { + isWatched = entry.IsWatched; return true; } + + _ = _cache.TryRemove(key, out _); } + isWatched = false; return false; } + private void CacheDecision(ProcessCacheKey key, bool isWatched) + { + var now = _utcNow(); + if (_cache.Count >= MaxCacheEntries) + { + foreach (var entry in _cache) + { + if (entry.Value.ExpiresAt <= now) + { + _ = _cache.TryRemove(entry.Key, out _); + } + } + + if (_cache.Count >= MaxCacheEntries) + { + _cache.Clear(); + } + } + + _cache[key] = new(isWatched, now.Add(CacheLifetime)); + } + private static string? DefaultResolveName(int pid) { try { return Process.GetProcessById(pid).ProcessName; } - catch (ArgumentException) + catch (Exception ex) when (ex is ArgumentException or InvalidOperationException or System.ComponentModel.Win32Exception) { - // Process has already exited. + // Process exited or its metadata is unavailable. return null; } } + + private static DateTimeOffset? DefaultResolveStartTime(int pid) + { + try + { + return Process.GetProcessById(pid).StartTime.ToUniversalTime(); + } + catch (Exception ex) when (ex is ArgumentException or InvalidOperationException or System.ComponentModel.Win32Exception) + { + return null; + } + } + + private readonly record struct ProcessCacheKey(int Pid, long StartTimeUtcTicks); + private readonly record struct CachedDecision(bool IsWatched, DateTimeOffset ExpiresAt); +} + +/// +/// Captures the current process parent relationships. A single snapshot is used for each +/// uncached process-tree decision so walking a deep hierarchy does not repeatedly query the OS. +/// +internal static class ProcessTreeResolver +{ + public static IReadOnlyDictionary ResolveParentProcessIds() + { + try + { + return OperatingSystem.IsWindows() + ? ResolveWindowsParentProcessIds() + : ResolveUnixParentProcessIds(); + } + catch (Exception ex) when (ex is InvalidOperationException or IOException or System.ComponentModel.Win32Exception) + { + return new Dictionary(); + } + } + + internal static Dictionary ParsePsOutput(string output) + { + ArgumentNullException.ThrowIfNull(output); + + var parentPids = new Dictionary(); + foreach (var line in output.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + var parts = line.Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (parts.Length >= 2 && + int.TryParse(parts[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out var pid) && + int.TryParse(parts[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out var parentPid)) + { + parentPids[pid] = parentPid; + } + } + + return parentPids; + } + + private static Dictionary ResolveUnixParentProcessIds() + { + var startInfo = new ProcessStartInfo + { + FileName = "ps", + UseShellExecute = false, + RedirectStandardOutput = true, + CreateNoWindow = true, + }; + startInfo.ArgumentList.Add("-axo"); + startInfo.ArgumentList.Add("pid=,ppid="); + + using var process = Process.Start(startInfo); + if (process is null) + { + return new Dictionary(); + } + + var output = process.StandardOutput.ReadToEnd(); + process.WaitForExit(); + return process.ExitCode == 0 + ? ParsePsOutput(output) + : new Dictionary(); + } + + [SupportedOSPlatform("windows")] + private static Dictionary ResolveWindowsParentProcessIds() + { + var parentPids = new Dictionary(); + using var snapshot = CreateToolhelp32Snapshot(Th32csSnapprocess, 0); + if (snapshot.IsInvalid) + { + return parentPids; + } + + var entry = new ProcessEntry32 + { + Size = (uint)Marshal.SizeOf() + }; + if (!Process32First(snapshot, ref entry)) + { + return parentPids; + } + + do + { + parentPids[(int)entry.ProcessId] = (int)entry.ParentProcessId; + entry.Size = (uint)Marshal.SizeOf(); + } + while (Process32Next(snapshot, ref entry)); + + return parentPids; + } + + private const uint Th32csSnapprocess = 0x00000002; + private const int MaxPath = 260; + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + private struct ProcessEntry32 + { + public uint Size; + public uint Usage; + public uint ProcessId; + public IntPtr DefaultHeapId; + public uint ModuleId; + public uint Threads; + public uint ParentProcessId; + public int PriorityClassBase; + public uint Flags; + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = MaxPath)] + public string ExecutableFile; + } + +#pragma warning disable SYSLIB1054 + [DefaultDllImportSearchPaths(DllImportSearchPath.System32)] + [DllImport("kernel32.dll", SetLastError = true)] + private static extern SafeFileHandle CreateToolhelp32Snapshot(uint flags, uint processId); + + [DefaultDllImportSearchPaths(DllImportSearchPath.System32)] + [DllImport("kernel32.dll", EntryPoint = "Process32FirstW", CharSet = CharSet.Unicode, SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool Process32First(SafeFileHandle snapshot, ref ProcessEntry32 entry); + + [DefaultDllImportSearchPaths(DllImportSearchPath.System32)] + [DllImport("kernel32.dll", EntryPoint = "Process32NextW", CharSet = CharSet.Unicode, SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool Process32Next(SafeFileHandle snapshot, ref ProcessEntry32 entry); +#pragma warning restore SYSLIB1054 } /// diff --git a/DevProxy.Proxy.Kestrel/KestrelProxyEngine.cs b/DevProxy.Proxy.Kestrel/KestrelProxyEngine.cs index 68b464372..1ad2ea120 100644 --- a/DevProxy.Proxy.Kestrel/KestrelProxyEngine.cs +++ b/DevProxy.Proxy.Kestrel/KestrelProxyEngine.cs @@ -54,7 +54,10 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) using var httpClient = new HttpClient(httpHandler, disposeHandler: false); var forwarder = new UpstreamForwarder(httpClient); var watchList = HostWatchList.FromUrls(urlsToWatch); - var processFilter = new ProcessFilter(configuration.WatchPids, configuration.WatchProcessNames); + var processFilter = new ProcessFilter( + configuration.WatchPids, + configuration.WatchProcessNames, + configuration.WatchProcessTree); var pipeline = new PluginPipeline( plugins, urlsToWatch, diff --git a/DevProxy.Tests/Fakes.cs b/DevProxy.Tests/Fakes.cs index 6913784c9..dfbf024f9 100644 --- a/DevProxy.Tests/Fakes.cs +++ b/DevProxy.Tests/Fakes.cs @@ -89,4 +89,5 @@ internal sealed class FakeProxyConfiguration : IProxyConfiguration public bool ValidateSchemas => false; public IEnumerable WatchPids { get; set; } = []; public IEnumerable WatchProcessNames { get; set; } = []; + public bool WatchProcessTree { get; set; } } diff --git a/DevProxy/Commands/DevProxyCommand.cs b/DevProxy/Commands/DevProxyCommand.cs index 00b9f6ae6..378d03578 100644 --- a/DevProxy/Commands/DevProxyCommand.cs +++ b/DevProxy/Commands/DevProxyCommand.cs @@ -29,6 +29,7 @@ sealed class DevProxyCommand : RootCommand internal const string RecordOptionName = "--record"; internal const string WatchPidsOptionName = "--watch-pids"; internal const string WatchProcessNamesOptionName = "--watch-process-names"; + internal const string WatchProcessTreeOptionName = "--watch-process-tree"; internal const string ConfigFileOptionName = "--config-file"; internal static readonly Option ConfigFileOption = new(ConfigFileOptionName, "-c") { @@ -436,6 +437,11 @@ private void ConfigureCommand() HelpName = "process-names", }; + var watchProcessTreeOption = new Option(WatchProcessTreeOptionName) + { + Description = "Also watch descendants of processes selected by --watch-pids or --watch-process-names" + }; + var noFirstRunOption = new Option(NoFirstRunOptionName) { Description = "Skip the first run experience" @@ -588,7 +594,8 @@ private void ConfigureCommand() timeoutOption, urlsToWatchOption, watchPidsOption, - watchProcessNamesOption + watchProcessNamesOption, + watchProcessTreeOption }; options.AddRange(_plugins .SelectMany(p => p.GetOptions()) @@ -678,6 +685,11 @@ private void ConfigureFromOptions(ParseResult parseResult) { _proxyConfiguration.WatchProcessNames = watchProcessNames; } + var watchProcessTree = parseResult.GetValueOrDefault(WatchProcessTreeOptionName); + if (watchProcessTree is not null) + { + _proxyConfiguration.WatchProcessTree = watchProcessTree.Value; + } var noFirstRun = parseResult.GetValueOrDefault(NoFirstRunOptionName); if (noFirstRun is not null) { diff --git a/DevProxy/Proxy/ProxyConfiguration.cs b/DevProxy/Proxy/ProxyConfiguration.cs index a4c5d5fd1..198d3cfde 100755 --- a/DevProxy/Proxy/ProxyConfiguration.cs +++ b/DevProxy/Proxy/ProxyConfiguration.cs @@ -50,6 +50,7 @@ public string ConfigFile public bool ValidateSchemas { get; set; } = true; public IEnumerable WatchPids { get; set; } = []; public IEnumerable WatchProcessNames { get; set; } = []; + public bool WatchProcessTree { get; set; } public ProxyConfiguration(IConfigurationRoot configurationRoot) { diff --git a/schemas/v4.0.0/rc.schema.json b/schemas/v4.0.0/rc.schema.json index 1525d8ee3..cb3f4582c 100644 --- a/schemas/v4.0.0/rc.schema.json +++ b/schemas/v4.0.0/rc.schema.json @@ -184,6 +184,10 @@ "type": "string" } }, + "watchProcessTree": { + "type": "boolean", + "description": "Whether to also watch descendants of processes selected by watchPids or watchProcessNames." + }, "showTimestamps": { "type": "boolean", "description": "Show timestamps in log output." diff --git a/skills/dev-proxy/references/configuration.md b/skills/dev-proxy/references/configuration.md index 38a56390e..e653301c0 100644 --- a/skills/dev-proxy/references/configuration.md +++ b/skills/dev-proxy/references/configuration.md @@ -133,6 +133,7 @@ Plugin-specific override: | `record` | off | `--record` | Start in recording mode | | `watchPids` | — | `--watch-pids` | Only intercept from these PIDs | | `watchProcessNames` | — | `--watch-process-names` | Only intercept from these processes | +| `watchProcessTree` | — | `--watch-process-tree` | Also intercept descendants of watched processes | | `asSystemProxy` | `true` | `--as-system-proxy` | Register as system proxy | | — | off | `--detach` | Run in detached (background) mode | | — | `text` | `--output` | Output format: `text` or `json` | @@ -241,6 +242,7 @@ Limit interception to specific processes: ```bash devproxy --watch-process-names msedge node devproxy --watch-pids 1234 5678 +devproxy --watch-process-names code --watch-process-tree ``` Or filter by request headers: From 407bcb870a907b730e038feb15ccd38ad6e17095 Mon Sep 17 00:00:00 2001 From: Waldek Mastykarz Date: Mon, 21 Sep 2026 17:03:22 +0200 Subject: [PATCH 2/3] Validate process ancestry safely Reject reused ancestor PIDs using process start times and dispose process handles after metadata lookup. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../ProcessFilterTests.cs | 35 +++++++++++++++ .../Internal/ProcessFilter.cs | 44 ++++++++++++------- 2 files changed, 64 insertions(+), 15 deletions(-) diff --git a/DevProxy.Proxy.Kestrel.Tests/ProcessFilterTests.cs b/DevProxy.Proxy.Kestrel.Tests/ProcessFilterTests.cs index 638dd90b1..948282a5f 100644 --- a/DevProxy.Proxy.Kestrel.Tests/ProcessFilterTests.cs +++ b/DevProxy.Proxy.Kestrel.Tests/ProcessFilterTests.cs @@ -217,6 +217,41 @@ public void IsWatchedProcess_DoesNotReuseCache_WhenPidIsReused() Assert.False(filter.IsWatchedProcess(54322)); } + [Fact] + public void IsWatchedProcess_False_WhenAncestorPidWasReused() + { + var processStartTimes = new Dictionary + { + [300] = DateTimeOffset.UnixEpoch.AddMinutes(1), + // PID 100 is listed as the creator of 300, but the process currently + // owning that PID started later and is therefore unrelated. + [100] = DateTimeOffset.UnixEpoch.AddMinutes(2) + }; + var filter = Filter( + names: ["Visual Studio Code"], + watchProcessTree: true, + resolvePid: _ => 300, + resolveName: pid => pid == 100 ? "Visual Studio Code" : "node", + resolveStartTime: pid => processStartTimes[pid], + resolveParentPids: () => new Dictionary { [300] = 100 }); + + Assert.False(filter.IsWatchedProcess(54321)); + } + + [Fact] + public void IsWatchedProcess_False_WhenAncestorStartTimeCannotBeResolved() + { + var filter = Filter( + pids: [100], + watchProcessTree: true, + resolvePid: _ => 300, + resolveName: _ => null, + resolveStartTime: pid => pid == 300 ? DateTimeOffset.UnixEpoch : null, + resolveParentPids: () => new Dictionary { [300] = 100 }); + + Assert.False(filter.IsWatchedProcess(54321)); + } + [Fact] public void IsWatchedProcess_RefreshesExpiredCacheEntry() { diff --git a/DevProxy.Proxy.Kestrel/Internal/ProcessFilter.cs b/DevProxy.Proxy.Kestrel/Internal/ProcessFilter.cs index b7e6e3d94..ee169333d 100644 --- a/DevProxy.Proxy.Kestrel/Internal/ProcessFilter.cs +++ b/DevProxy.Proxy.Kestrel/Internal/ProcessFilter.cs @@ -108,15 +108,19 @@ public bool IsWatchedProcess(int clientPort) var cacheKeys = new List(); var processKey = ResolveCacheKey(pid.Value); - if (processKey is not null) + if (processKey is null) { - if (TryGetCachedDecision(processKey.Value, out var cached)) - { - return cached; - } - cacheKeys.Add(processKey.Value); + // Process start time is required to validate that each parent PID still + // identifies the process that originally created its child. + return false; } + if (TryGetCachedDecision(processKey.Value, out var cached)) + { + return cached; + } + cacheKeys.Add(processKey.Value); + var parentPids = _resolveParentPids(); if (parentPids.Count == 0) { @@ -125,21 +129,28 @@ public bool IsWatchedProcess(int clientPort) var visited = new HashSet { pid.Value }; var currentPid = pid.Value; + var currentKey = processKey.Value; var isWatched = false; while (parentPids.TryGetValue(currentPid, out var parentPid) && parentPid > 0 && visited.Add(parentPid)) { var parentKey = ResolveCacheKey(parentPid); - if (parentKey is not null) + if (parentKey is null || + parentKey.Value.StartTimeUtcTicks > currentKey.StartTimeUtcTicks) { - if (TryGetCachedDecision(parentKey.Value, out var cached)) - { - isWatched = cached; - break; - } - cacheKeys.Add(parentKey.Value); + // PROCESSENTRY32 and similar process tables retain only the creator PID. + // If that PID was reused after the child started, it is no longer an + // ancestor. Missing start times cannot establish a safe relationship. + break; + } + + if (TryGetCachedDecision(parentKey.Value, out cached)) + { + isWatched = cached; + break; } + cacheKeys.Add(parentKey.Value); if (_pids.Contains(parentPid) || MatchesProcessName(parentPid)) { @@ -148,6 +159,7 @@ public bool IsWatchedProcess(int clientPort) } currentPid = parentPid; + currentKey = parentKey.Value; } foreach (var key in cacheKeys) @@ -218,7 +230,8 @@ private void CacheDecision(ProcessCacheKey key, bool isWatched) { try { - return Process.GetProcessById(pid).ProcessName; + using var process = Process.GetProcessById(pid); + return process.ProcessName; } catch (Exception ex) when (ex is ArgumentException or InvalidOperationException or System.ComponentModel.Win32Exception) { @@ -231,7 +244,8 @@ private void CacheDecision(ProcessCacheKey key, bool isWatched) { try { - return Process.GetProcessById(pid).StartTime.ToUniversalTime(); + using var process = Process.GetProcessById(pid); + return process.StartTime.ToUniversalTime(); } catch (Exception ex) when (ex is ArgumentException or InvalidOperationException or System.ComponentModel.Win32Exception) { From dfb43aff5af6b48c2e9d3588a251168518d5b8cf Mon Sep 17 00:00:00 2001 From: Waldek Mastykarz Date: Tue, 22 Sep 2026 13:35:45 +0200 Subject: [PATCH 3/3] Preserve proxy configuration compatibility Move process-tree support to an optional configuration interface so existing external IProxyConfiguration implementations continue to compile. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- DevProxy.Abstractions/Proxy/IProxyConfiguration.cs | 4 ++++ DevProxy.Integration.Tests/TestProxyConfiguration.cs | 2 +- DevProxy.Proxy.Kestrel/KestrelProxyEngine.cs | 3 ++- DevProxy.Tests/Fakes.cs | 2 +- DevProxy/Commands/DevProxyCommand.cs | 5 +++-- DevProxy/Proxy/ProxyConfiguration.cs | 2 +- 6 files changed, 12 insertions(+), 6 deletions(-) diff --git a/DevProxy.Abstractions/Proxy/IProxyConfiguration.cs b/DevProxy.Abstractions/Proxy/IProxyConfiguration.cs index e2e67007c..10866d445 100644 --- a/DevProxy.Abstractions/Proxy/IProxyConfiguration.cs +++ b/DevProxy.Abstractions/Proxy/IProxyConfiguration.cs @@ -49,5 +49,9 @@ public interface IProxyConfiguration bool ValidateSchemas { get; } IEnumerable WatchPids { get; set; } IEnumerable WatchProcessNames { get; set; } +} + +public interface IProcessTreeProxyConfiguration +{ bool WatchProcessTree { get; set; } } \ No newline at end of file diff --git a/DevProxy.Integration.Tests/TestProxyConfiguration.cs b/DevProxy.Integration.Tests/TestProxyConfiguration.cs index 57761b5e5..a3e7b3588 100644 --- a/DevProxy.Integration.Tests/TestProxyConfiguration.cs +++ b/DevProxy.Integration.Tests/TestProxyConfiguration.cs @@ -13,7 +13,7 @@ namespace DevProxy.Integration.Tests; /// members the Kestrel engine reads at boot (Port, IPAddress, AsSystemProxy, /// WatchPids/WatchProcessNames/WatchProcessTree) actually matter; the rest carry inert defaults. /// -internal sealed class TestProxyConfiguration : IProxyConfiguration +internal sealed class TestProxyConfiguration : IProxyConfiguration, IProcessTreeProxyConfiguration { public int ApiPort { get; set; } public bool AsSystemProxy { get; set; } diff --git a/DevProxy.Proxy.Kestrel/KestrelProxyEngine.cs b/DevProxy.Proxy.Kestrel/KestrelProxyEngine.cs index 1ad2ea120..20074f979 100644 --- a/DevProxy.Proxy.Kestrel/KestrelProxyEngine.cs +++ b/DevProxy.Proxy.Kestrel/KestrelProxyEngine.cs @@ -57,7 +57,8 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) var processFilter = new ProcessFilter( configuration.WatchPids, configuration.WatchProcessNames, - configuration.WatchProcessTree); + configuration is IProcessTreeProxyConfiguration processTreeConfiguration && + processTreeConfiguration.WatchProcessTree); var pipeline = new PluginPipeline( plugins, urlsToWatch, diff --git a/DevProxy.Tests/Fakes.cs b/DevProxy.Tests/Fakes.cs index dfbf024f9..a8c2cbcf0 100644 --- a/DevProxy.Tests/Fakes.cs +++ b/DevProxy.Tests/Fakes.cs @@ -68,7 +68,7 @@ internal sealed class RecordingConsole : ISystemConsole /// Minimal ; only Output/IPAddress/ApiPort/Record /// are read by the interactive console, the rest carry inert defaults. /// -internal sealed class FakeProxyConfiguration : IProxyConfiguration +internal sealed class FakeProxyConfiguration : IProxyConfiguration, IProcessTreeProxyConfiguration { public int ApiPort { get; set; } = 8897; public bool AsSystemProxy { get; set; } diff --git a/DevProxy/Commands/DevProxyCommand.cs b/DevProxy/Commands/DevProxyCommand.cs index 378d03578..b9fffd9ae 100644 --- a/DevProxy/Commands/DevProxyCommand.cs +++ b/DevProxy/Commands/DevProxyCommand.cs @@ -686,9 +686,10 @@ private void ConfigureFromOptions(ParseResult parseResult) _proxyConfiguration.WatchProcessNames = watchProcessNames; } var watchProcessTree = parseResult.GetValueOrDefault(WatchProcessTreeOptionName); - if (watchProcessTree is not null) + if (watchProcessTree is not null && + _proxyConfiguration is IProcessTreeProxyConfiguration processTreeConfiguration) { - _proxyConfiguration.WatchProcessTree = watchProcessTree.Value; + processTreeConfiguration.WatchProcessTree = watchProcessTree.Value; } var noFirstRun = parseResult.GetValueOrDefault(NoFirstRunOptionName); if (noFirstRun is not null) diff --git a/DevProxy/Proxy/ProxyConfiguration.cs b/DevProxy/Proxy/ProxyConfiguration.cs index 198d3cfde..5fa6cb94a 100755 --- a/DevProxy/Proxy/ProxyConfiguration.cs +++ b/DevProxy/Proxy/ProxyConfiguration.cs @@ -8,7 +8,7 @@ namespace DevProxy.Proxy; -sealed class ProxyConfiguration : IProxyConfiguration +sealed class ProxyConfiguration : IProxyConfiguration, IProcessTreeProxyConfiguration { private readonly IConfigurationRoot _configurationRoot;