From dbcc378f5804173cf3e9cdd45f47761f0157e141 Mon Sep 17 00:00:00 2001 From: Veirt Date: Fri, 20 Mar 2026 23:27:14 +0800 Subject: [PATCH 1/4] feat: add shared wildcard detector package --- dns/wildcard/resolver.go | 303 ++++++++++++++++++++++++++++++++++ dns/wildcard/resolver_test.go | 174 +++++++++++++++++++ dns/wildcard/root.go | 31 ++++ dns/wildcard/types.go | 4 + go.mod | 1 + go.sum | 2 + 6 files changed, 515 insertions(+) create mode 100644 dns/wildcard/resolver.go create mode 100644 dns/wildcard/resolver_test.go create mode 100644 dns/wildcard/root.go create mode 100644 dns/wildcard/types.go diff --git a/dns/wildcard/resolver.go b/dns/wildcard/resolver.go new file mode 100644 index 00000000..65be2953 --- /dev/null +++ b/dns/wildcard/resolver.go @@ -0,0 +1,303 @@ +package wildcard + +import ( + "errors" + "strings" + "sync" + + mapsutil "github.com/projectdiscovery/utils/maps" + sliceutil "github.com/projectdiscovery/utils/slice" + stringsutil "github.com/projectdiscovery/utils/strings" + "github.com/rs/xid" +) + +const DefaultWildcardProbeCount = 3 +const reProbeCount = 2 + +var errDomainFound = errors.New("domain found") + +type probeState uint8 + +const ( + probeStateError probeState = iota + probeStateNoAnswers + probeStateResolved +) + +// Resolver represents a wildcard resolver extracted from shuffledns. +type Resolver struct { + Domains *sliceutil.SyncSlice[string] + lookup LookupFunc + + levelAnswersNormalCache *mapsutil.SyncLockMap[string, struct{}] + wildcardAnswersCache *mapsutil.SyncLockMap[string, wildcardAnswerCacheValue] + + probeCount int +} + +type wildcardAnswerCacheValue struct { + IPS *mapsutil.SyncLockMap[string, struct{}] +} + +func mapValues(m *mapsutil.SyncLockMap[string, struct{}]) map[string]struct{} { + values := make(map[string]struct{}) + if m == nil { + return values + } + + _ = m.Iterate(func(key string, value struct{}) error { + values[key] = value + return nil + }) + + return values +} + +// NewResolver initializes and creates a new resolver to find wildcards. +func NewResolver(domains []string, lookup LookupFunc) *Resolver { + fqdns := sliceutil.NewSyncSlice[string]() + fqdns.Append(domains...) + return NewResolverWithDomains(fqdns, lookup) +} + +// NewResolverWithDomains initializes a resolver with a pre-built domain slice. +func NewResolverWithDomains(domains *sliceutil.SyncSlice[string], lookup LookupFunc) *Resolver { + if domains == nil { + domains = sliceutil.NewSyncSlice[string]() + } + + return &Resolver{ + Domains: domains, + lookup: lookup, + levelAnswersNormalCache: mapsutil.NewSyncLockMap[string, struct{}](), + wildcardAnswersCache: mapsutil.NewSyncLockMap[string, wildcardAnswerCacheValue](), + probeCount: DefaultWildcardProbeCount, + } +} + +// SetProbeCount sets the number of probes to use for wildcard detection. +// Higher values improve detection of wildcards using DNS round-robin. +func (w *Resolver) SetProbeCount(count int) { + if count > 0 { + w.probeCount = count + } +} + +// probeWildcardIPs probes the given wildcard pattern multiple times concurrently and returns all IPs found. +// If the first probe returns no answers, the level is treated as a normal level. +// Transport or resolver errors are returned separately so callers do not cache them as normal answers. +// First query is executed sequentially for early exit, remaining queries run in parallel. +func (w *Resolver) probeWildcardIPs(pattern string, count int) ([]string, probeState) { + if count <= 0 { + return nil, probeStateNoAnswers + } + + ips := sliceutil.NewSyncSlice[string]() + + probe := func() ([]string, probeState) { + probeHost := strings.ReplaceAll(pattern, "*.", xid.New().String()+".") + answers, err := w.lookup(probeHost) + if err != nil { + return nil, probeStateError + } + if len(answers) == 0 { + return nil, probeStateNoAnswers + } + return answers, probeStateResolved + } + + resultIPs, state := probe() + if state != probeStateResolved { + return nil, state + } + if len(resultIPs) > 0 { + ips.Append(resultIPs...) + } + + if count == 1 { + if ips.Len() == 0 { + return nil, probeStateNoAnswers + } + return sliceutil.Dedupe(ips.Slice), probeStateResolved + } + + var wg sync.WaitGroup + for i := 1; i < count; i++ { + wg.Add(1) + go func() { + defer wg.Done() + + resultIPs, state := probe() + if state == probeStateResolved && len(resultIPs) > 0 { + ips.Append(resultIPs...) + } + }() + } + + wg.Wait() + + if ips.Len() == 0 { + return nil, probeStateNoAnswers + } + + return sliceutil.Dedupe(ips.Slice), probeStateResolved +} + +// generateWildcardPermutations generates wildcard permutations for a given subdomain +// and domain. It generates permutations for each level of the subdomain +// in reverse order. +func generateWildcardPermutations(subdomain, domain string) []string { + var hosts []string + subdomainTokens := strings.Split(subdomain, ".") + + var builder strings.Builder + builder.Grow(len(subdomain) + len(domain) + 5) + + // Iterate from the reverse order. This way we generate the roots + // first and allows us to do filtering faster, by trying out the root + // like *.example.com first, and *.child.example.com in that order. + // If we get matches for the root, we can skip the child and rest. + builder.WriteString("*.") + builder.WriteString(domain) + hosts = append(hosts, builder.String()) + builder.Reset() + + for i := len(subdomainTokens); i > 1; i-- { + _, _ = builder.WriteString("*.") + _, _ = builder.WriteString(strings.Join(subdomainTokens[i-1:], ".")) + _, _ = builder.WriteRune('.') + _, _ = builder.WriteString(domain) + hosts = append(hosts, builder.String()) + builder.Reset() + } + return hosts +} + +// LookupHost returns wildcard IP addresses of a wildcard if it's a wildcard. +// To determine this, we split the target host by dots, generate wildcard +// permutations for each level of the matched domain, and probe those levels. +// If any of the host IPs overlap with wildcard answers collected for those +// levels, the host is treated as wildcard-backed. +func (w *Resolver) LookupHost(host string, knownIPs []string) (bool, map[string]struct{}) { + wildcards := make(map[string]struct{}) + + var domain string + w.Domains.Each(func(i int, domainCandidate string) error { + if stringsutil.HasSuffixAny(host, "."+domainCandidate) { + domain = domainCandidate + return errDomainFound + } + return nil + }) + + // Ignore records without a matching domain. This may be interesting for + // dangling-domain detection later, but wildcard matching intentionally skips it. + if domain == "" { + return false, nil + } + + subdomainPart := strings.TrimSuffix(host, "."+domain) + + // create the wildcard generation prefix. + // We use a rand prefix at the beginning like %rand%.domain.tld + // A permutation is generated for each level of the subdomain. + hosts := generateWildcardPermutations(subdomainPart, domain) + + // Iterate over all the hosts generated for rand. + for _, h := range hosts { + h = strings.TrimSuffix(h, ".") + + original := h + + // Check if we have already resolved this host level successfully + // and if so, use the cached answer + // + // ex. *.campaigns.google.com is a wildcard so we cache it + // and it is used always for resolutions in future. + cachedValue, cachedValueOk := w.wildcardAnswersCache.Get(original) + if cachedValueOk { + for _, knownIP := range knownIPs { + if _, ipExists := cachedValue.IPS.Get(knownIP); ipExists { + return true, mapValues(cachedValue.IPS) + } + } + if extraIPs, state := w.probeWildcardIPs(original, reProbeCount); state == probeStateResolved && len(extraIPs) > 0 { + for _, record := range extraIPs { + wildcards[record] = struct{}{} + _ = cachedValue.IPS.Set(record, struct{}{}) + } + _ = w.wildcardAnswersCache.Set(original, cachedValue) + for _, knownIP := range knownIPs { + if _, ipExists := cachedValue.IPS.Get(knownIP); ipExists { + return true, mapValues(cachedValue.IPS) + } + } + } + } + + // Check if this level already produced a normal response with no wildcard answers. + // Example: *.google.com is not a wildcard and returns NXDOMAIN, + // so future checks at that level can be skipped. + if _, ok := w.levelAnswersNormalCache.Get(original); ok { + continue + } + + probeIPs, state := w.probeWildcardIPs(original, w.probeCount) + if state == probeStateNoAnswers { + _ = w.levelAnswersNormalCache.Set(original, struct{}{}) + continue + } + if state != probeStateResolved { + continue + } + + if len(probeIPs) > 0 { + if !cachedValueOk { + cachedValue.IPS = mapsutil.NewSyncLockMap[string, struct{}]() + } + for _, record := range probeIPs { + wildcards[record] = struct{}{} + _ = cachedValue.IPS.Set(record, struct{}{}) + } + _ = w.wildcardAnswersCache.Set(original, cachedValue) + for _, knownIP := range knownIPs { + if _, ipExists := cachedValue.IPS.Get(knownIP); ipExists { + return true, mapValues(cachedValue.IPS) + } + } + + for i := 0; i < w.probeCount; i++ { + answers, err := w.lookup(host) + if err == nil { + for _, record := range answers { + if _, ipExists := cachedValue.IPS.Get(record); ipExists { + return true, mapValues(cachedValue.IPS) + } + } + } + } + } + } + + for _, knownIP := range knownIPs { + if _, ok := wildcards[knownIP]; ok { + return true, wildcards + } + } + + return false, wildcards +} + +func (w *Resolver) GetAllWildcardIPs() map[string]struct{} { + ips := make(map[string]struct{}) + + _ = w.wildcardAnswersCache.Iterate(func(key string, value wildcardAnswerCacheValue) error { + for ip := range mapValues(value.IPS) { + if _, ok := ips[ip]; !ok { + ips[ip] = struct{}{} + } + } + return nil + }) + return ips +} diff --git a/dns/wildcard/resolver_test.go b/dns/wildcard/resolver_test.go new file mode 100644 index 00000000..9900eb29 --- /dev/null +++ b/dns/wildcard/resolver_test.go @@ -0,0 +1,174 @@ +package wildcard + +import ( + "errors" + "fmt" + "net" + "strings" + "testing" + + sliceutil "github.com/projectdiscovery/utils/slice" + "github.com/stretchr/testify/require" +) + +func TestGenerateWildcardPermutations(t *testing.T) { + var tests = []struct { + subdomain string + domain string + expected []string + }{ + {"test", "example.com", []string{"*.example.com"}}, + {"abc.test", "example.com", []string{"*.example.com", "*.test.example.com"}}, + {"xyz.abc.test", "example.com", []string{"*.example.com", "*.test.example.com", "*.abc.test.example.com"}}, + } + for _, test := range tests { + t.Run(fmt.Sprintf("%s.%s", test.subdomain, test.domain), func(t *testing.T) { + result := generateWildcardPermutations(test.subdomain, test.domain) + require.Equal(t, test.expected, result) + }) + } +} + +func TestResolverLookupHostSkipsUnknownDomain(t *testing.T) { + resolver := NewResolver([]string{"example.com"}, func(host string) ([]string, error) { + t.Fatalf("unexpected lookup for %s", host) + return nil, nil + }) + + isWildcard, wildcards := resolver.LookupHost("www.other.com", []string{"1.1.1.1"}) + require.False(t, isWildcard) + require.Nil(t, wildcards) +} + +func TestResolverLookupHostReturnsCachedWildcardIPs(t *testing.T) { + resolver := NewResolver([]string{"example.com"}, func(host string) ([]string, error) { + return []string{"1.1.1.1", "2.2.2.2"}, nil + }) + + isWildcard, wildcards := resolver.LookupHost("www.example.com", []string{"1.1.1.1"}) + require.True(t, isWildcard) + require.Equal(t, map[string]struct{}{"1.1.1.1": {}, "2.2.2.2": {}}, wildcards) + require.Equal(t, wildcards, resolver.GetAllWildcardIPs()) +} + +func TestResolverLookupHostReturnsObservedWildcardIPsForNonMatch(t *testing.T) { + resolver := NewResolver([]string{"example.com"}, func(host string) ([]string, error) { + if host == "www.example.com" { + return []string{"3.3.3.3"}, nil + } + return []string{"1.1.1.1", "2.2.2.2"}, nil + }) + + isWildcard, wildcards := resolver.LookupHost("www.example.com", []string{"3.3.3.3"}) + require.False(t, isWildcard) + require.Equal(t, map[string]struct{}{"1.1.1.1": {}, "2.2.2.2": {}}, wildcards) +} + +func TestResolverLookupHostReprobesCachedWildcard(t *testing.T) { + count := 0 + resolver := NewResolver([]string{"example.com"}, func(host string) ([]string, error) { + count++ + if count == 1 { + return []string{"1.1.1.1"}, nil + } + return []string{"1.1.1.1", "2.2.2.2"}, nil + }) + + isWildcard, _ := resolver.LookupHost("www.example.com", []string{"1.1.1.1"}) + require.True(t, isWildcard) + + isWildcard, wildcards := resolver.LookupHost("api.example.com", []string{"2.2.2.2"}) + require.True(t, isWildcard) + require.Contains(t, wildcards, "2.2.2.2") +} + +func TestResolverLookupHostRevalidatesCurrentHost(t *testing.T) { + hostLookups := 0 + resolver := NewResolver([]string{"example.com"}, func(host string) ([]string, error) { + if host == "target.example.com" { + hostLookups++ + if hostLookups < 3 { + return []string{"3.3.3.3"}, nil + } + return []string{"2.2.2.2"}, nil + } + return []string{"2.2.2.2"}, nil + }) + + isWildcard, wildcards := resolver.LookupHost("target.example.com", []string{"3.3.3.3"}) + require.True(t, isWildcard) + require.Contains(t, wildcards, "2.2.2.2") + require.Equal(t, 3, hostLookups) +} + +func TestResolverLookupHostIgnoresProbeErrors(t *testing.T) { + resolver := NewResolver([]string{"example.com"}, func(host string) ([]string, error) { + return nil, errors.New("lookup failed") + }) + + isWildcard, wildcards := resolver.LookupHost("www.example.com", []string{"1.1.1.1"}) + require.False(t, isWildcard) + require.Empty(t, wildcards) +} + +func TestResolverLookupHostDoesNotCacheProbeErrorsAsNormal(t *testing.T) { + probeCalls := 0 + resolver := NewResolver([]string{"example.com"}, func(host string) ([]string, error) { + switch host { + case "target.example.com", "api.example.com": + return []string{"1.1.1.1"}, nil + default: + if strings.HasSuffix(host, ".example.com") { + probeCalls++ + if probeCalls == 1 { + return nil, errors.New("temporary failure") + } + return []string{"1.1.1.1"}, nil + } + return nil, nil + } + }) + + isWildcard, wildcards := resolver.LookupHost("target.example.com", []string{"1.1.1.1"}) + require.False(t, isWildcard) + require.Empty(t, wildcards) + + isWildcard, wildcards = resolver.LookupHost("api.example.com", []string{"1.1.1.1"}) + require.True(t, isWildcard) + require.Equal(t, map[string]struct{}{"1.1.1.1": {}}, wildcards) +} + +func TestRegistrableRoot(t *testing.T) { + tests := []struct { + name string + host string + root string + ok bool + }{ + {name: "fqdn", host: "WWW.Example.COM.", root: "example.com", ok: true}, + {name: "multi level suffix", host: "Api.Foo.Co.Uk.", root: "foo.co.uk", ok: true}, + {name: "wildcard input", host: "*.sub.example.com", root: "example.com", ok: true}, + {name: "ip address", host: net.ParseIP("127.0.0.1").String(), ok: false}, + {name: "invalid host", host: "localhost", ok: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + root, ok := RegistrableRoot(tt.host) + require.Equal(t, tt.ok, ok) + require.Equal(t, tt.root, root) + }) + } +} + +func TestNewResolverWithDomainsSharesDomainSlice(t *testing.T) { + domains := sliceutil.NewSyncSlice[string]() + domains.Append("example.com") + resolver := NewResolverWithDomains(domains, func(host string) ([]string, error) { + return []string{"1.1.1.1"}, nil + }) + + domains.Append("example.org") + isWildcard, _ := resolver.LookupHost("www.example.org", []string{"1.1.1.1"}) + require.True(t, isWildcard) +} diff --git a/dns/wildcard/root.go b/dns/wildcard/root.go new file mode 100644 index 00000000..6bf85529 --- /dev/null +++ b/dns/wildcard/root.go @@ -0,0 +1,31 @@ +package wildcard + +import ( + "net" + "strings" + + "golang.org/x/net/publicsuffix" +) + +// RegistrableRoot returns the registrable root for the provided host. +// The input is normalized first by lowercasing it, trimming a leading *., and +// removing any trailing dot. IP literals and invalid hosts are rejected. +func RegistrableRoot(host string) (string, bool) { + host = strings.TrimSpace(strings.ToLower(host)) + host = strings.TrimPrefix(host, "*.") + host = strings.TrimSuffix(host, ".") + if host == "" { + return "", false + } + + if ip := net.ParseIP(host); ip != nil { + return "", false + } + + root, err := publicsuffix.EffectiveTLDPlusOne(host) + if err != nil || root == "" { + return "", false + } + + return root, true +} diff --git a/dns/wildcard/types.go b/dns/wildcard/types.go new file mode 100644 index 00000000..96ce0aec --- /dev/null +++ b/dns/wildcard/types.go @@ -0,0 +1,4 @@ +package wildcard + +// LookupFunc resolves a host and returns the address answers used for wildcard matching. +type LookupFunc func(host string) ([]string, error) diff --git a/go.mod b/go.mod index b84519dc..c44a08fc 100644 --- a/go.mod +++ b/go.mod @@ -89,6 +89,7 @@ require ( github.com/projectdiscovery/retryabledns v1.0.113 // indirect github.com/refraction-networking/utls v1.8.2 // indirect github.com/rivo/uniseg v0.4.7 // indirect + github.com/rs/xid v1.5.0 // indirect github.com/shoenig/go-m1cpu v0.1.6 // indirect github.com/sorairolake/lzip-go v0.3.8 // indirect github.com/spf13/afero v1.15.0 // indirect diff --git a/go.sum b/go.sum index 5bd328d0..6d1371c3 100644 --- a/go.sum +++ b/go.sum @@ -302,6 +302,8 @@ github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= +github.com/rs/xid v1.5.0 h1:mKX4bl4iPYJtEIxp6CYiUuLQ/8DYMoz0PUdtGgMFRVc= +github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk= github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d h1:hrujxIzL1woJ7AwssoOcM/tq5JjjG2yYOc8odClEiXA= github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d/go.mod h1:uugorj2VCxiV1x+LzaIdVa9b4S4qGAcH6cbhh4qVxOU= From 571372e3f7eb8f42b87238f14d90d6ab2794e7bf Mon Sep 17 00:00:00 2001 From: Mzack9999 Date: Fri, 20 Mar 2026 17:46:53 +0100 Subject: [PATCH 2/4] fix data races --- dns/wildcard/resolver_test.go | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/dns/wildcard/resolver_test.go b/dns/wildcard/resolver_test.go index 9900eb29..c039c834 100644 --- a/dns/wildcard/resolver_test.go +++ b/dns/wildcard/resolver_test.go @@ -5,6 +5,7 @@ import ( "fmt" "net" "strings" + "sync/atomic" "testing" sliceutil "github.com/projectdiscovery/utils/slice" @@ -65,10 +66,10 @@ func TestResolverLookupHostReturnsObservedWildcardIPsForNonMatch(t *testing.T) { } func TestResolverLookupHostReprobesCachedWildcard(t *testing.T) { - count := 0 + var count atomic.Int32 resolver := NewResolver([]string{"example.com"}, func(host string) ([]string, error) { - count++ - if count == 1 { + c := count.Add(1) + if c == 1 { return []string{"1.1.1.1"}, nil } return []string{"1.1.1.1", "2.2.2.2"}, nil @@ -112,15 +113,15 @@ func TestResolverLookupHostIgnoresProbeErrors(t *testing.T) { } func TestResolverLookupHostDoesNotCacheProbeErrorsAsNormal(t *testing.T) { - probeCalls := 0 + var probeCalls atomic.Int32 resolver := NewResolver([]string{"example.com"}, func(host string) ([]string, error) { switch host { case "target.example.com", "api.example.com": return []string{"1.1.1.1"}, nil default: if strings.HasSuffix(host, ".example.com") { - probeCalls++ - if probeCalls == 1 { + c := probeCalls.Add(1) + if c == 1 { return nil, errors.New("temporary failure") } return []string{"1.1.1.1"}, nil From 1981696271377ff269c8ca882c79fe96a93b147a Mon Sep 17 00:00:00 2001 From: Mzack9999 Date: Fri, 20 Mar 2026 17:58:12 +0100 Subject: [PATCH 3/4] fix test --- process/interrupt_windows_test.go | 30 ++++++++++++++++++------------ 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/process/interrupt_windows_test.go b/process/interrupt_windows_test.go index ad7d0710..04522912 100644 --- a/process/interrupt_windows_test.go +++ b/process/interrupt_windows_test.go @@ -2,19 +2,25 @@ package process -import "testing" +import ( + "os" + "os/exec" + "testing" +) func TestSendInterrupt(t *testing.T) { - // On Windows CI (GitHub Actions), GenerateConsoleCtrlEvent may not work - // as expected without a proper console attached. - // This test verifies the function doesn't panic and the syscall loads correctly. - defer func() { - if r := recover(); r != nil { - t.Fatalf("SendInterrupt panicked: %v", r) - } - }() + // Re-exec the test in a child process so the CTRL_BREAK_EVENT does not + // propagate to sibling processes (e.g. the Go compiler running in + // parallel during "go test ./..."). + if os.Getenv("TEST_SEND_INTERRUPT_CHILD") == "1" { + SendInterrupt() + return + } - // Just verify it doesn't crash - the actual signal delivery - // depends on console configuration which varies in CI - SendInterrupt() + cmd := exec.Command(os.Args[0], "-test.run=^TestSendInterrupt$") + cmd.Env = append(os.Environ(), "TEST_SEND_INTERRUPT_CHILD=1") + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("child process failed: %v\n%s", err, out) + } } From 6631f5369bf04bdc2912127715e3a41aa0f3d241 Mon Sep 17 00:00:00 2001 From: Mzack9999 Date: Fri, 20 Mar 2026 18:14:35 +0100 Subject: [PATCH 4/4] . --- process/interrupt_windows_test.go | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/process/interrupt_windows_test.go b/process/interrupt_windows_test.go index 04522912..530eaa22 100644 --- a/process/interrupt_windows_test.go +++ b/process/interrupt_windows_test.go @@ -5,13 +5,14 @@ package process import ( "os" "os/exec" + "syscall" "testing" ) func TestSendInterrupt(t *testing.T) { - // Re-exec the test in a child process so the CTRL_BREAK_EVENT does not - // propagate to sibling processes (e.g. the Go compiler running in - // parallel during "go test ./..."). + // Re-exec in a child with its own process group so the CTRL_BREAK_EVENT + // stays isolated and does not kill sibling processes (e.g. the Go + // compiler running in parallel during "go test ./..."). if os.Getenv("TEST_SEND_INTERRUPT_CHILD") == "1" { SendInterrupt() return @@ -19,6 +20,7 @@ func TestSendInterrupt(t *testing.T) { cmd := exec.Command(os.Args[0], "-test.run=^TestSendInterrupt$") cmd.Env = append(os.Environ(), "TEST_SEND_INTERRUPT_CHILD=1") + cmd.SysProcAttr = &syscall.SysProcAttr{CreationFlags: syscall.CREATE_NEW_PROCESS_GROUP} out, err := cmd.CombinedOutput() if err != nil { t.Fatalf("child process failed: %v\n%s", err, out)