mirror of
https://github.com/Vyntral/god-eye.git
synced 2026-06-10 00:03:55 +02:00
3a4c230aa7
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.
219 lines
5.2 KiB
Go
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")
|
|
}
|
|
}
|