yellow.git


yellow

~ opinionated web scanning pipeline for pentesters in a hurry
git clone https://github.com/djnnvx/yellow.git
log | files | refs | archive | README | LICENSE

commit 601077b5f45964b773bd35829243510f47ef0b80
parent 22ccd8322a6fb51d268e41acf6aa632173ff3432
Author: djnn <[email protected]>
Date: Sat, 13 Jun 2026 15:29:09 +0900

feat(osint): alterx subdomain permutations

Diffstat:
MCHANGELOG.txt | 1+
MREADME.md | 4+++-
Mcli/parser.go | 2+-
Mgo.mod | 1+
Mgo.sum | 2++
Aosint/alterx.go | 141+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mosint/run.go | 24++++++++++--------------

7 files changed, 159 insertions(+), 16 deletions(-)

diff --git a/CHANGELOG.txt b/CHANGELOG.txt
@@ -5,6 +5,7 @@
 - feat(scan): add nuclei module for vulnerability scanning (opt-in via --nuclei; installs templates up front)
 - change(cli): gobuster is now opt-in via --gobuster (replaces the default-on --disable-dirbusting)
 - feat(osint): add gau historical URL discovery (saved to urls.txt; discovered hostnames merged into domains.txt)
+- feat(osint): add alterx active subdomain permutation (generates + resolves via dnsx within a time budget; live finds merged into domains.txt)
 - fix(scan): perform the real robots.txt fetch (was duplicating the sitemap scan)
 - fix(net): add HTTP client timeouts and close response bodies (prevents hangs and fd leaks)
 - fix(net): keep running when the public IP lookup fails instead of exiting the program
diff --git a/README.md b/README.md
@@ -66,7 +66,6 @@ scan:
 
 osint:
 
-* `alterx` (projectdiscovery) subdomain permutations to feed dnsx
 * `asnmap` (projectdiscovery) domain/org to ASN to netblocks for scope expansion
 
 other:
@@ -144,6 +143,9 @@ Run various scans to retrieve more targets, using OSINT techniques.
 
 osint also runs `gau` to pull historical URLs (Wayback, CommonCrawl, OTX, URLScan).
 
+osint also runs `alterx`: it generates permutations of the discovered subdomains, resolves
+them (time-bounded), and merges the live ones into `domains.txt` (and `alterx.txt`).
+
 ## 4.c Run active scans
 
 Run scans against the target actively. (You might want to use a proxy for this !)
diff --git a/cli/parser.go b/cli/parser.go
@@ -143,7 +143,7 @@ func GetParser(opts *StandardOptions) *cobra.Command {
 	var osintCmd = &cobra.Command{
 		Use:   "osint",
 		Short: "Run OSINT tools to retrieve IP addresses and interesting assets",
-		Long:  "Runs dorks, subfinder, assetfinder, dnsx & gau to find subdomains, assets & historical URLs",
+		Long:  "Runs dorks, subfinder, assetfinder, dnsx, gau & alterx to find subdomains, assets & historical URLs",
 		Args:  cobra.MinimumNArgs(0),
 		Run: func(cmd *cobra.Command, args []string) {
 
diff --git a/go.mod b/go.mod
@@ -8,6 +8,7 @@ require (
 	github.com/deckarep/golang-set/v2 v2.3.0
 	github.com/lc/gau/v2 v2.2.4
 	github.com/praetorian-inc/nerva v1.18.0
+	github.com/projectdiscovery/alterx v0.1.0
 	github.com/projectdiscovery/dnsx v1.2.3
 	github.com/projectdiscovery/goflags v0.1.74
 	github.com/projectdiscovery/httpx v1.9.0
diff --git a/go.sum b/go.sum
@@ -875,6 +875,8 @@ github.com/praetorian-inc/fingerprintx v1.1.15 h1:CVIxrIQARbmdk5h8E9tIJZbvFoY2sG
 github.com/praetorian-inc/fingerprintx v1.1.15/go.mod h1:hqRroITBwKpP8BOGF+n/A+qv9wSF7OSVinmu5NCyOUI=
 github.com/praetorian-inc/nerva v1.18.0 h1:KwvgHtfqd69afnqKKtXbBmiKx2X5bLWZ4LfcK5KAUX8=
 github.com/praetorian-inc/nerva v1.18.0/go.mod h1:FHEc0IZVvyCu8NjjErOsd7rY2e4Yo76apBeFdWpYSBE=
+github.com/projectdiscovery/alterx v0.1.0 h1:oMi/CV/RcaIvg21PbqBYqlGDtXodvEfPohQ8Y2BthO0=
+github.com/projectdiscovery/alterx v0.1.0/go.mod h1:PNilhIp5zjvgKD8A0sZ56RyZRBcOhCWlVp2O299nc4M=
 github.com/projectdiscovery/asnmap v1.1.1 h1:ImJiKIaACOT7HPx4Pabb5dksolzaFYsD1kID2iwsDqI=
 github.com/projectdiscovery/asnmap v1.1.1/go.mod h1:QT7jt9nQanj+Ucjr9BqGr1Q2veCCKSAVyUzLXfEcQ60=
 github.com/projectdiscovery/awesome-search-queries v0.0.0-20260411001519-47ff919ec921 h1:8LLtUicd7P3A+DrY2PUlq4DGkttViWnu/QbQ5ooo8Zc=
diff --git a/osint/alterx.go b/osint/alterx.go
@@ -0,0 +1,141 @@
+package osint
+
+import (
+	"context"
+	"fmt"
+	"net"
+	"os"
+	"strings"
+	"sync"
+	"time"
+
+	"evil.djnn.sh/djnn/yellow/core"
+	"github.com/projectdiscovery/alterx"
+	"github.com/projectdiscovery/dnsx/libs/dnsx"
+)
+
+const (
+	alterxLimit   = 5000
+	alterxWorkers = 40
+	alterxBudget  = 5 * time.Minute
+)
+
+type Alterx struct{}
+
+func (*Alterx) Name() string { return "alterx" }
+
+func (*Alterx) Run(ctx *core.Context) error {
+	fmt.Println("[+] Running alterx on", ctx.Domain)
+	if ctx.DryRun {
+		return nil
+	}
+
+	var inputs []string
+	for _, d := range ctx.Domains {
+		if d != "" && net.ParseIP(d) == nil {
+			inputs = append(inputs, d)
+		}
+	}
+	if len(inputs) == 0 {
+		fmt.Println("[!] alterx: no subdomains to permute, skipping")
+		return nil
+	}
+
+	mutator, err := alterx.New(&alterx.Options{Domains: inputs, Enrich: true})
+	if err != nil {
+		fmt.Printf("[!] alterx: %v\n", err)
+		return nil
+	}
+
+	// Execute ignores Options.Limit; cap here, draining fully so the producer can't leak
+	var candidates []string
+	for c := range mutator.Execute(context.Background()) {
+		if len(candidates) < alterxLimit {
+			candidates = append(candidates, c)
+		}
+	}
+	if len(candidates) == 0 {
+		fmt.Println("[!] alterx: generated no candidates, skipping")
+		return nil
+	}
+
+	live := resolveLive(candidates)
+
+	existing := map[string]struct{}{}
+	for _, d := range ctx.Domains {
+		existing[d] = struct{}{}
+	}
+	var added []string
+	for _, h := range live {
+		if _, ok := existing[h]; ok {
+			continue
+		}
+		existing[h] = struct{}{}
+		added = append(added, h)
+	}
+	ctx.Domains = append(ctx.Domains, added...)
+
+	outfile := fmt.Sprintf("%s/alterx.txt", ctx.ScanPath)
+	content := strings.Join(added, "\n")
+	if content != "" {
+		content += "\n"
+	}
+	if err := os.WriteFile(outfile, []byte(content), 0644); err != nil {
+		fmt.Printf("[!] alterx: could not write %s: %v\n", outfile, err)
+	}
+
+	fmt.Printf("[OSINT %s] alterx: %d candidates, %d live, %d new in %s\n", ctx.Domain, len(candidates), len(live), len(added), outfile)
+	return nil
+}
+
+func resolveLive(candidates []string) []string {
+	opts := dnsx.DefaultOptions
+	opts.MaxRetries = 2
+	client, err := dnsx.New(opts)
+	if err != nil {
+		fmt.Printf("[!] alterx: dns resolver init failed: %v\n", err)
+		return nil
+	}
+
+	ctx, cancel := context.WithTimeout(context.Background(), alterxBudget)
+	defer cancel()
+
+	jobs := make(chan string)
+	var (
+		mu   sync.Mutex
+		live []string
+		wg   sync.WaitGroup
+	)
+	for i := 0; i < alterxWorkers; i++ {
+		wg.Add(1)
+		go func() {
+			defer wg.Done()
+			for {
+				select {
+				case <-ctx.Done():
+					return
+				case name, ok := <-jobs:
+					if !ok {
+						return
+					}
+					if ips, err := client.Lookup(name); err == nil && len(ips) > 0 {
+						mu.Lock()
+						live = append(live, name)
+						mu.Unlock()
+					}
+				}
+			}
+		}()
+	}
+dispatch:
+	for _, c := range candidates {
+		select {
+		case <-ctx.Done():
+			break dispatch
+		case jobs <- c:
+		}
+	}
+	close(jobs)
+	wg.Wait()
+	return live
+}
diff --git a/osint/run.go b/osint/run.go
@@ -1,7 +1,6 @@
 package osint
 
 import (
-	"bytes"
 	"fmt"
 	"net"
 	"net/url"
@@ -45,7 +44,6 @@ func (opts *OsintOpts) SetEmailsFile(data string) {
 	opts.emailsFile = data
 }
 
-// context builds a Context from the current options.
 func (opts *OsintOpts) context() *core.Context {
 	return &core.Context{
 		Domain:     opts.domain,
@@ -85,29 +83,29 @@ func (opts *OsintOpts) Run() {
 		&Leaker{},
 	})
 
-	// aggregate the assets the enumeration modules wrote, then let shodan
-	// consume the unique list.
-	domains, buf := opts.aggregateDomains()
-	ctx.Domains = domains
+	ctx.Domains = opts.aggregateDomains()
 
-	core.RunModules(ctx, []core.Module{&Shodan{}})
+	core.RunModules(ctx, []core.Module{&Alterx{}, &Shodan{}})
 
 	uniqueOutfile := fmt.Sprintf("%s/domains.txt", opts.scanPath)
-	if err := os.WriteFile(uniqueOutfile, buf, 0644); err != nil {
+	content := strings.Join(ctx.Domains, "\n")
+	if content != "" {
+		content += "\n"
+	}
+	if err := os.WriteFile(uniqueOutfile, []byte(content), 0644); err != nil {
 		fmt.Printf("[!] osint: could not write %s: %v\n", uniqueOutfile, err)
 		return
 	}
 
-	fmt.Printf("[OSINT %s] Registered %v IP addresses and assets.\n", opts.domain, len(domains))
+	fmt.Printf("[OSINT %s] Registered %v IP addresses and assets.\n", opts.domain, len(ctx.Domains))
 	fmt.Printf("[OSINT %s] Location of unique domain names: %s\n", opts.domain, uniqueOutfile)
 	fmt.Printf("[OSINT %s] done.\n", opts.domain)
 
 	opts.RunCleanup()
 }
 
-func (opts *OsintOpts) aggregateDomains() ([]string, []byte) {
+func (opts *OsintOpts) aggregateDomains() []string {
 	var domains []string
-	var domainBuffer bytes.Buffer
 	seen := map[string]struct{}{}
 
 	add := func(candidate string) {
@@ -118,12 +116,10 @@ func (opts *OsintOpts) aggregateDomains() ([]string, []byte) {
 		if _, ok := seen[candidate]; ok {
 			return
 		}
-		// IPv4, or something that roughly looks like a domain; IPv6 carries colons that break downstream
 		ip := net.ParseIP(candidate)
 		if (ip != nil && ip.To4() != nil) || (ip == nil && !helper.StringHasUnwantedCharactersForDomainName(candidate)) {
 			seen[candidate] = struct{}{}
 			domains = append(domains, candidate)
-			domainBuffer.WriteString(candidate + "\n")
 		}
 	}
 
@@ -151,5 +147,5 @@ func (opts *OsintOpts) aggregateDomains() ([]string, []byte) {
 		}
 	}
 
-	return domains, domainBuffer.Bytes()
+	return domains
 }