- 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
155 lines
4.6 KiB
Go
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
|
|
}
|