Files
Vyntral 3a4c230aa7 feat: v2.0 full rewrite — event-driven pipeline, AI + Nuclei + proxy
Complete architectural overhaul. Replaces the v0.1 monolithic scanner
with an event-driven pipeline of auto-registered modules.

Foundation (internal/):
- eventbus: typed pub/sub, 20 event types, race-safe, drop counter
- module: registry with phase-based selection
- store: thread-safe host store with per-host locks + deep-copy reads
- pipeline: coordinator with phase barriers + panic recovery
- config: 5 scan profiles + 3 AI tiers + YAML loader + auto-discovery

Modules (26 auto-registered across 6 phases):
- Discovery: passive (26 sources), bruteforce, recursive, AXFR, GitHub
  dorks, CT streaming, permutation, reverse DNS, vhost, ASN, supply
  chain (npm + PyPI)
- Enrichment: HTTP probe + tech fingerprint + TLS appliance ID, ports
- Analysis: security checks, takeover (110+ sigs), cloud, JavaScript,
  GraphQL, JWT, headers (OWASP), HTTP smuggling, AI cascade, Nuclei
- Reporting: TXT/JSON/CSV writer + AI scan brief

AI layer (internal/ai/ + internal/modules/ai/):
- Three profiles: lean (16 GB), balanced (32 GB MoE), heavy (64 GB)
- Six event-driven handlers: CVE, JS file, HTTP response, secret
  filter, multi-agent vuln enrichment, anomaly + executive report
- Content-hash cache dedups Ollama calls across hosts
- Auto-pull of missing models via /api/pull with streaming progress
- End-of-scan AI SCAN BRIEF in terminal with top chains + next actions

Nuclei compat layer (internal/nucleitpl/):
- Executes ~13k community templates (HTTP subset)
- Auto-download of nuclei-templates ZIP to ~/.god-eye/nuclei-templates
- Scope filter rejects off-host templates (eliminates OSINT FPs)

Operations:
- Interactive wizard (internal/wizard/) — zero-flag launch
- LivePrinter (internal/tui/) — colorized event stream
- Diff engine + scheduler (internal/diff, internal/scheduler) for
  continuous ASM monitoring with webhook alerts
- Proxy support (internal/proxyconf/): http / https / socks5 / socks5h
  + basic auth

Fixes #1 — native SOCKS5 / Tor compatibility via --proxy flag.

185 unit tests across 15 packages, all race-detector clean.
2026-04-18 16:48:41 +02:00

219 lines
5.2 KiB
Go

package scanner
import (
"os"
"path/filepath"
"reflect"
"sort"
"strings"
"testing"
"god-eye/internal/config"
)
func TestLoadWordlist(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "wordlist.txt")
content := `# comment line
api
admin
# another comment
dev
staging
test
`
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
got, err := LoadWordlist(path)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
want := []string{"api", "admin", "dev", "staging", "test"}
if !reflect.DeepEqual(got, want) {
t.Errorf("got %v, want %v", got, want)
}
}
func TestLoadWordlist_NonExistent(t *testing.T) {
_, err := LoadWordlist("/tmp/this-does-not-exist-xyz-abc.txt")
if err == nil {
t.Error("expected error for non-existent file")
}
}
func TestLoadWordlist_Empty(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "empty.txt")
os.WriteFile(path, []byte(""), 0o644)
got, err := LoadWordlist(path)
if err != nil {
t.Fatal(err)
}
if len(got) != 0 {
t.Errorf("expected empty result, got %v", got)
}
}
func TestLoadWordlist_CommentsOnly(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "comments.txt")
os.WriteFile(path, []byte("# only comments\n# and more\n"), 0o644)
got, _ := LoadWordlist(path)
if len(got) != 0 {
t.Errorf("expected empty result for comments-only file, got %v", got)
}
}
func TestParseResolvers(t *testing.T) {
tests := []struct {
name string
in string
want []string
}{
{
name: "empty uses defaults",
in: "",
want: config.DefaultResolvers,
},
{
name: "single with port",
in: "8.8.8.8:53",
want: []string{"8.8.8.8:53"},
},
{
name: "single without port adds :53",
in: "8.8.8.8",
want: []string{"8.8.8.8:53"},
},
{
name: "multiple with mixed ports",
in: "8.8.8.8,1.1.1.1:5353,9.9.9.9",
want: []string{"8.8.8.8:53", "1.1.1.1:5353", "9.9.9.9:53"},
},
{
name: "whitespace trimmed",
in: " 8.8.8.8 , 1.1.1.1 ",
want: []string{"8.8.8.8:53", "1.1.1.1:53"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := ParseResolvers(tt.in)
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("ParseResolvers(%q) = %v, want %v", tt.in, got, tt.want)
}
})
}
}
func TestParsePorts(t *testing.T) {
tests := []struct {
name string
in string
want []int
}{
{"empty uses defaults", "", []int{80, 443, 8080, 8443}},
{"single valid", "80", []int{80}},
{"multiple valid", "80,443,3000", []int{80, 443, 3000}},
{"whitespace", " 80 , 443 ", []int{80, 443}},
{"invalid silently dropped", "80,abc,443", []int{80, 443}},
{"out of range dropped", "80,99999,443", []int{80, 443}},
{"negative dropped", "80,-1,443", []int{80, 443}},
{"zero dropped", "0,80,443", []int{80, 443}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := ParsePorts(tt.in)
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("ParsePorts(%q) = %v, want %v", tt.in, got, tt.want)
}
})
}
}
func TestCountActive(t *testing.T) {
results := map[string]*config.SubdomainResult{
"a.example.com": {StatusCode: 200},
"b.example.com": {StatusCode: 301},
"c.example.com": {StatusCode: 404},
"d.example.com": {StatusCode: 500},
"e.example.com": {StatusCode: 0}, // not probed
}
got := countActive(results)
if got != 2 {
t.Errorf("countActive = %d, want 2", got)
}
}
func TestCountVulns(t *testing.T) {
results := map[string]*config.SubdomainResult{
"a.example.com": {OpenRedirect: true},
"b.example.com": {CORSMisconfig: "wildcard with credentials"},
"c.example.com": {DangerousMethods: []string{"PUT", "DELETE"}},
"d.example.com": {GitExposed: true},
"e.example.com": {BackupFiles: []string{"backup.sql"}},
"f.example.com": {StatusCode: 200}, // clean
}
got := countVulns(results)
if got != 5 {
t.Errorf("countVulns = %d, want 5", got)
}
}
func TestCountSubdomainsWithAI(t *testing.T) {
results := map[string]*config.SubdomainResult{
"a.example.com": {AIFindings: []string{"finding1"}},
"b.example.com": {AIFindings: []string{"f1", "f2"}},
"c.example.com": {}, // no AI findings
}
got := countSubdomainsWithAI(results)
if got != 2 {
t.Errorf("countSubdomainsWithAI = %d, want 2", got)
}
}
func TestBuildAISummary(t *testing.T) {
results := map[string]*config.SubdomainResult{
"a.example.com": {
AIFindings: []string{"Hardcoded API key", "Weak crypto"},
AISeverity: "critical",
CVEFindings: []string{"CVE-2021-12345"},
},
"b.example.com": {
AIFindings: []string{"Missing CSP"},
AISeverity: "medium",
},
"c.example.com": {
AIFindings: []string{"ignored"},
AISeverity: "info",
},
}
got := buildAISummary(results)
if got == "" {
t.Fatal("summary is empty")
}
// Must mention severities
mustContain := []string{"critical", "high", "medium", "CRITICAL", "MEDIUM", "Hardcoded API key", "CVE-2021-12345"}
for _, s := range mustContain {
if !strings.Contains(got, s) {
t.Errorf("summary missing expected token %q in:\n%s", s, got)
}
}
}
func TestSortedIntsInvariant(t *testing.T) {
// Sanity: whenever we sort ints we expect ascending order (tests ScanPorts sorting guarantee).
in := []int{443, 80, 8080, 22}
sort.Ints(in)
if !sort.IntsAreSorted(in) {
t.Error("sort.IntsAreSorted returned false after sort.Ints")
}
}