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

258 lines
7.5 KiB
Go

package module
import (
"context"
"reflect"
"sort"
"testing"
"god-eye/internal/eventbus"
)
// fakeModule is a minimal Module for tests.
type fakeModule struct {
name string
phase Phase
consumes []eventbus.EventType
produces []eventbus.EventType
defaultEnabled bool
runCalled bool
}
func (f *fakeModule) Name() string { return f.name }
func (f *fakeModule) Phase() Phase { return f.phase }
func (f *fakeModule) Consumes() []eventbus.EventType { return f.consumes }
func (f *fakeModule) Produces() []eventbus.EventType { return f.produces }
func (f *fakeModule) DefaultEnabled() bool { return f.defaultEnabled }
func (f *fakeModule) Run(mctx Context) error { f.runCalled = true; return nil }
// fakeConfig implements ConfigView for tests.
type fakeConfig struct {
profile string
enabled map[string]bool
}
func (c *fakeConfig) Profile() string { return c.profile }
func (c *fakeConfig) Bool(k string, fb bool) bool { return fb }
func (c *fakeConfig) Int(k string, fb int) int { return fb }
func (c *fakeConfig) String(k, fb string) string { return fb }
func (c *fakeConfig) Strings(k string) []string { return nil }
func (c *fakeConfig) ModuleEnabled(name string) bool { return c.enabled[name] }
func TestRegister_AndGet(t *testing.T) {
r := NewRegistry()
m := &fakeModule{name: "test.one", phase: PhaseDiscovery, defaultEnabled: true}
r.Register(m)
got, ok := r.Get("test.one")
if !ok {
t.Fatal("Get returned !ok for registered module")
}
if got != m {
t.Error("Get returned a different instance")
}
if _, ok := r.Get("not.present"); ok {
t.Error("Get returned ok for missing module")
}
}
func TestRegister_DuplicatePanic(t *testing.T) {
r := NewRegistry()
r.Register(&fakeModule{name: "dup", phase: PhaseDiscovery})
defer func() {
if recover() == nil {
t.Error("expected panic on duplicate registration")
}
}()
r.Register(&fakeModule{name: "dup", phase: PhaseDiscovery})
}
func TestRegister_NilPanic(t *testing.T) {
r := NewRegistry()
defer func() {
if recover() == nil {
t.Error("expected panic on nil module")
}
}()
r.Register(nil)
}
func TestRegister_EmptyNamePanic(t *testing.T) {
r := NewRegistry()
defer func() {
if recover() == nil {
t.Error("expected panic on empty name")
}
}()
r.Register(&fakeModule{name: "", phase: PhaseDiscovery})
}
func TestNames_InsertionOrder(t *testing.T) {
r := NewRegistry()
r.Register(&fakeModule{name: "zebra", phase: PhaseDiscovery})
r.Register(&fakeModule{name: "alpha", phase: PhaseDiscovery})
r.Register(&fakeModule{name: "middle", phase: PhaseDiscovery})
want := []string{"zebra", "alpha", "middle"}
got := r.Names()
if !reflect.DeepEqual(got, want) {
t.Errorf("Names order = %v, want %v", got, want)
}
}
func TestAll_ReturnsRegistered(t *testing.T) {
r := NewRegistry()
r.Register(&fakeModule{name: "a", phase: PhaseDiscovery})
r.Register(&fakeModule{name: "b", phase: PhaseAnalysis})
r.Register(&fakeModule{name: "c", phase: PhaseReporting})
if got := len(r.All()); got != 3 {
t.Errorf("All length = %d, want 3", got)
}
}
func TestByPhase_SortedByName(t *testing.T) {
r := NewRegistry()
r.Register(&fakeModule{name: "sources.zzz", phase: PhaseDiscovery})
r.Register(&fakeModule{name: "sources.aaa", phase: PhaseDiscovery})
r.Register(&fakeModule{name: "security.cors", phase: PhaseAnalysis})
r.Register(&fakeModule{name: "sources.mmm", phase: PhaseDiscovery})
got := r.ByPhase(PhaseDiscovery)
names := make([]string, len(got))
for i, m := range got {
names[i] = m.Name()
}
want := []string{"sources.aaa", "sources.mmm", "sources.zzz"}
if !reflect.DeepEqual(names, want) {
t.Errorf("ByPhase(discovery) = %v, want %v (sorted)", names, want)
}
if got := r.ByPhase(PhaseAnalysis); len(got) != 1 || got[0].Name() != "security.cors" {
t.Errorf("ByPhase(analysis) unexpected: %v", got)
}
if got := r.ByPhase(PhaseReporting); len(got) != 0 {
t.Errorf("ByPhase(reporting) should be empty, got %d", len(got))
}
}
func TestSelect_DefaultEnabled(t *testing.T) {
r := NewRegistry()
r.Register(&fakeModule{name: "on-by-default", phase: PhaseDiscovery, defaultEnabled: true})
r.Register(&fakeModule{name: "off-by-default", phase: PhaseDiscovery, defaultEnabled: false})
// nil config: module default governs
got := r.Select(nil)
names := moduleNames(got)
sort.Strings(names)
if !reflect.DeepEqual(names, []string{"on-by-default"}) {
t.Errorf("Select(nil) = %v, want [on-by-default]", names)
}
}
func TestSelect_ConfigEnablesOff(t *testing.T) {
r := NewRegistry()
r.Register(&fakeModule{name: "optin", phase: PhaseAnalysis, defaultEnabled: false})
r.Register(&fakeModule{name: "default-on", phase: PhaseAnalysis, defaultEnabled: true})
cfg := &fakeConfig{enabled: map[string]bool{"optin": true}}
got := r.Select(cfg)
names := moduleNames(got)
sort.Strings(names)
want := []string{"default-on", "optin"}
if !reflect.DeepEqual(names, want) {
t.Errorf("Select = %v, want %v", names, want)
}
}
func TestProducersOf_AndConsumersOf(t *testing.T) {
r := NewRegistry()
r.Register(&fakeModule{
name: "producer-a",
phase: PhaseDiscovery,
produces: []eventbus.EventType{eventbus.EventSubdomainDiscovered},
})
r.Register(&fakeModule{
name: "producer-b",
phase: PhaseDiscovery,
produces: []eventbus.EventType{eventbus.EventSubdomainDiscovered, eventbus.EventDNSResolved},
})
r.Register(&fakeModule{
name: "consumer",
phase: PhaseEnrichment,
consumes: []eventbus.EventType{eventbus.EventDNSResolved},
})
producers := r.ProducersOf(eventbus.EventSubdomainDiscovered)
names := moduleNames(producers)
sort.Strings(names)
want := []string{"producer-a", "producer-b"}
if !reflect.DeepEqual(names, want) {
t.Errorf("ProducersOf = %v, want %v", names, want)
}
consumers := r.ConsumersOf(eventbus.EventDNSResolved)
if len(consumers) != 1 || consumers[0].Name() != "consumer" {
t.Errorf("ConsumersOf unexpected: %v", consumers)
}
}
func TestReset(t *testing.T) {
r := NewRegistry()
r.Register(&fakeModule{name: "m1", phase: PhaseDiscovery, defaultEnabled: true})
r.Register(&fakeModule{name: "m2", phase: PhaseDiscovery, defaultEnabled: true})
if len(r.All()) != 2 {
t.Fatal("pre-reset: expected 2 modules")
}
r.Reset()
if len(r.All()) != 0 {
t.Errorf("post-reset: expected 0 modules, got %d", len(r.All()))
}
// Re-register after reset works
r.Register(&fakeModule{name: "m1", phase: PhaseDiscovery, defaultEnabled: true})
if len(r.All()) != 1 {
t.Errorf("post-reset re-register: expected 1, got %d", len(r.All()))
}
}
func TestDefault_Singleton(t *testing.T) {
a := Default()
b := Default()
if a != b {
t.Error("Default() returned different instances")
}
}
func TestRunContextCarriesFields(t *testing.T) {
// Sanity: Context struct is populated correctly — this is effectively a
// struct-init contract test to catch accidental field removals.
ctx := context.Background()
bus := eventbus.New(16)
defer bus.Close(context.Background())
mctx := Context{
Ctx: ctx,
Bus: bus,
Target: "example.com",
Profile: "bugbounty",
}
if mctx.Target != "example.com" {
t.Errorf("Target lost: %q", mctx.Target)
}
if mctx.Profile != "bugbounty" {
t.Errorf("Profile lost: %q", mctx.Profile)
}
if mctx.Bus != bus {
t.Error("Bus not retained")
}
}
func moduleNames(ms []Module) []string {
out := make([]string, len(ms))
for i, m := range ms {
out[i] = m.Name()
}
return out
}