gitex2026/aasd/src/internal/scanner/probe.go
administrator b91406ada4 fix: email report URL, duplicate case; feat: Home button, email UI, scan timer; test: 44 unit tests; refactor: IsIP to pkg/netutil; docs: SMTP env vars
- Fix email report URL: /{token}.html → /visitor_{token}.html
- Remove duplicate case 'generating' in simulation.html polling
- Add defensive guard against empty Subdomains in SelectAndScan
- Add Home link and Start New Scan button to visitor report
- Replace QR code injection with email-send form in consultant report
- Add scan timer animation (barberpole + elapsed counter) to frontend
- Move IsIP() from scanner/probe.go to pkg/netutil for reuse
- Add 44 unit tests across scanner, report, and netutil packages
- Create Makefile with build/vet/test/deploy targets
- Document SMTP environment variables in README and AGENT.md
2026-04-29 07:30:42 +00:00

155 lines
4.6 KiB
Go

package scanner
import (
"bufio"
"context"
"fmt"
"net"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"time"
)
// DefaultSubdomains is a built-in fallback list used when no wordlist file
// is available. The file at projectRoot/subdomains.txt (one name per line,
// # for comments) is preferred and can contain thousands of entries.
var DefaultSubdomains = []string{
"api", "www", "admin", "mail", "webmail",
"dev", "staging", "app", "dashboard", "portal",
"blog", "docs", "support", "git", "cdn",
"static", "assets", "demo", "vpn", "remote",
"secure", "login", "auth", "gateway", "console",
"monitor", "status", "jenkins", "wiki", "forum",
"corp", "internal", "partner", "help", "mx",
"ns1", "ns2", "smtp", "imap", "pop3",
}
// hasWildcardDNS checks whether the domain uses wildcard DNS by resolving
// a random non-existent subdomain. If it resolves, wildcard is active.
func hasWildcardDNS(ctx context.Context, domain string) bool {
probes := []string{
fmt.Sprintf("zzz%d%d.%s", time.Now().UnixNano()%1000, 9999, domain),
fmt.Sprintf("x%sx.%s", fmt.Sprintf("%x", time.Now().UnixNano()%65535), domain),
}
for _, p := range probes {
addrs, err := net.DefaultResolver.LookupHost(ctx, p)
if err == nil && len(addrs) > 0 {
return true
}
}
return false
}
// ProbeSubdomains checks common subdomain names and returns those
// confirmed to serve real HTTPS content (valid TLS certificate).
//
// TLS certificate validation is the definitive signal — a wildcard DNS
// entry resolves for any name but has no valid certificate for arbitrary
// subdomains. Only real web services pass. Any real API in 2026 runs HTTPS.
//
// Loads subdomain names from projectRoot/subdomains.txt (one per line, # for comments).
// Falls back to a built-in list of ~40 names if the file doesn't exist.
//
// Health check timeout 3s, concurrency max 10, total probe < 15s for 40 names,
// scales linearly with wordlist size (e.g. ~2min for 500 names).
//
// If onProgress is non-nil, it is called periodically with (checked, total) counts
// so the caller can report progress to the user (e.g. "checking 142/5000").
func ProbeSubdomains(ctx context.Context, projectRoot, domain string, onProgress func(checked, total int)) []string {
var (
alive []string
mu sync.Mutex
wg sync.WaitGroup
)
if wildcard := hasWildcardDNS(ctx, domain); wildcard {
fmt.Printf("probe: wildcard DNS for %s — filtering by TLS cert\n", domain)
}
// Load subdomain names from file or fall back to built-in list
names := loadSubdomainList(projectRoot)
fmt.Printf("probe: checking %d subdomain names against %s\n", len(names), domain)
total := len(names)
sem := make(chan struct{}, 10)
client := &http.Client{Timeout: 3 * time.Second}
// Atomically count completed probes for progress reporting
var completed int32
for _, name := range names {
wg.Add(1)
go func(s string) {
defer wg.Done()
select {
case sem <- struct{}{}:
case <-ctx.Done():
return
}
defer func() { <-sem }()
// HTTPS GET — does DNS + TLS in one shot.
// Go's http.Client validates cert + hostname by default.
resp, err := client.Get("https://" + s + "." + domain)
if err == nil {
resp.Body.Close()
mu.Lock()
alive = append(alive, s+"."+domain)
mu.Unlock()
}
// Report progress
c := int(atomic.AddInt32(&completed, 1))
if onProgress != nil {
onProgress(c, total)
}
}(name)
}
wg.Wait()
return alive
}
// loadSubdomainList reads subdomain names from projectRoot/subdomains.txt.
// Each line is one name; lines starting with # and empty lines are ignored.
// Falls back to DefaultSubdomains if the file doesn't exist.
func loadSubdomainList(projectRoot string) []string {
path := filepath.Join(projectRoot, "subdomains.txt")
f, err := os.Open(path)
if err != nil {
fmt.Printf("probe: no subdomains.txt found, using default list (%d names)\n", len(DefaultSubdomains))
return DefaultSubdomains
}
defer f.Close()
seen := make(map[string]bool)
var names []string
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := scanner.Text()
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
name := strings.ToLower(line)
if !seen[name] {
seen[name] = true
names = append(names, name)
}
}
if err := scanner.Err(); err != nil {
fmt.Printf("probe: error reading subdomains.txt: %v, using default list\n", err)
return DefaultSubdomains
}
if len(names) == 0 {
fmt.Printf("probe: subdomains.txt is empty, using default list\n")
return DefaultSubdomains
}
fmt.Printf("probe: loaded %d names from subdomains.txt\n", len(names))
return names
}