diff --git a/internal/divert/filter.go b/internal/divert/filter.go new file mode 100644 index 0000000..8102e25 --- /dev/null +++ b/internal/divert/filter.go @@ -0,0 +1,61 @@ +package divert + +import ( + "fmt" + "net" + "strings" +) + +// FilterParams collects the inputs needed to build a WinDivert filter +// expression for Drover's outbound capture. +type FilterParams struct { + // TargetPIDs is the set of PIDs whose outbound traffic should be + // captured (e.g. Discord variants). When empty, the resulting + // filter is "false" — captures nothing — which is the right + // behaviour while procscan reports zero Discord processes. + TargetPIDs []uint32 + + // OwnPID is drover.exe's own PID. Excluded from capture so our + // SOCKS5 traffic to the upstream proxy doesn't get re-captured. + OwnPID uint32 + + // UpstreamIP is the resolved IPv4 of the upstream SOCKS5 proxy. + // Excluded from capture as a second line of defence against + // self-loops. If unparseable, "0.0.0.0" is substituted (caller + // should validate before calling). + UpstreamIP string +} + +// BuildFilter returns a WinDivert filter expression string suitable +// for WinDivertOpen. The expression captures only outbound IPv4 TCP/UDP +// from the listed PIDs, excluding our own process and the upstream +// proxy's IP. +func BuildFilter(p FilterParams) string { + if len(p.TargetPIDs) == 0 { + return "false" + } + + upstream := p.UpstreamIP + if net.ParseIP(upstream).To4() == nil { + upstream = "0.0.0.0" + } + + pidClauses := make([]string, len(p.TargetPIDs)) + for i, pid := range p.TargetPIDs { + pidClauses[i] = fmt.Sprintf("processId == %d", pid) + } + pidClause := "(" + strings.Join(pidClauses, " or ") + ")" + + parts := []string{ + "outbound", + "(tcp or udp)", + "ip", + pidClause, + fmt.Sprintf("processId != %d", p.OwnPID), + fmt.Sprintf("ip.DstAddr != %s", upstream), + "not (ip.DstAddr >= 224.0.0.0 and ip.DstAddr <= 239.255.255.255)", + "not (ip.DstAddr >= 127.0.0.0 and ip.DstAddr <= 127.255.255.255)", + "not (ip.DstAddr >= 169.254.0.0 and ip.DstAddr <= 169.254.255.255)", + } + return strings.Join(parts, " and ") +} diff --git a/internal/divert/filter_test.go b/internal/divert/filter_test.go new file mode 100644 index 0000000..5679a62 --- /dev/null +++ b/internal/divert/filter_test.go @@ -0,0 +1,73 @@ +package divert + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestBuildFilter_HappyPath(t *testing.T) { + got := BuildFilter(FilterParams{ + TargetPIDs: []uint32{12345, 67890}, + OwnPID: 999, + UpstreamIP: "95.165.72.59", + }) + // Required clauses + assert.Contains(t, got, "outbound") + assert.Contains(t, got, "(tcp or udp)") + assert.Contains(t, got, "ip") + assert.Contains(t, got, "processId == 12345") + assert.Contains(t, got, "processId == 67890") + assert.Contains(t, got, "processId != 999") + assert.Contains(t, got, "ip.DstAddr != 95.165.72.59") + // Loopback / multicast / link-local exclusions + assert.Contains(t, got, "127.0.0.0") + assert.Contains(t, got, "224.0.0.0") + assert.Contains(t, got, "169.254.0.0") +} + +func TestBuildFilter_SinglePID(t *testing.T) { + got := BuildFilter(FilterParams{ + TargetPIDs: []uint32{42}, + OwnPID: 1, + UpstreamIP: "1.2.3.4", + }) + assert.Contains(t, got, "processId == 42") +} + +func TestBuildFilter_NoTargetPIDs(t *testing.T) { + // No Discord running. We still produce a syntactically valid filter + // that matches nothing (we can't pass an empty filter to WinDivert). + got := BuildFilter(FilterParams{ + TargetPIDs: nil, + OwnPID: 999, + UpstreamIP: "1.2.3.4", + }) + // "false" alone is a valid filter that captures nothing — perfect + // for "Discord not running" interim. + assert.Equal(t, "false", got) +} + +func TestBuildFilter_OwnPIDNotInTargets(t *testing.T) { + // Defensive: even if OwnPID accidentally appears in TargetPIDs, the + // processId != ownPid clause still excludes it. + got := BuildFilter(FilterParams{ + TargetPIDs: []uint32{999, 1234}, + OwnPID: 999, + UpstreamIP: "1.2.3.4", + }) + assert.Contains(t, got, "processId != 999") + // The exclusion takes precedence syntactically because of the AND. + assert.True(t, strings.Contains(got, "and processId != 999")) +} + +func TestBuildFilter_UpstreamIPv4Format(t *testing.T) { + got := BuildFilter(FilterParams{ + TargetPIDs: []uint32{1}, + OwnPID: 2, + UpstreamIP: "not-an-ip", + }) + // We just substitute a placeholder and document it. + assert.Contains(t, got, "ip.DstAddr != 0.0.0.0") +}