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

170 lines
4.0 KiB
Go

package wizard
import (
"bytes"
"errors"
"strings"
"testing"
)
// newPrompterWithInput builds a prompter whose stdin comes from the given
// string. Each line is terminated with \n.
func newPrompterWithInput(input string) (*prompter, *bytes.Buffer) {
buf := &bytes.Buffer{}
p := newPrompter(strings.NewReader(input), buf)
return p, buf
}
func TestChoose_DefaultAcceptsEmpty(t *testing.T) {
p, _ := newPrompterWithInput("\n")
n, err := p.choose("pick one", []string{"a", "b", "c"}, 2)
if err != nil {
t.Fatal(err)
}
if n != 2 {
t.Errorf("default = %d, want 2", n)
}
}
func TestChoose_ValidNumber(t *testing.T) {
p, _ := newPrompterWithInput("3\n")
n, _ := p.choose("pick one", []string{"a", "b", "c"}, 1)
if n != 3 {
t.Errorf("= %d, want 3", n)
}
}
func TestChoose_OutOfRangeReprompts(t *testing.T) {
p, _ := newPrompterWithInput("99\nfoo\n2\n")
n, err := p.choose("pick one", []string{"a", "b", "c"}, 1)
if err != nil {
t.Fatal(err)
}
if n != 2 {
t.Errorf("= %d, want 2", n)
}
}
func TestChoose_MaxAttemptsExhausted(t *testing.T) {
p, _ := newPrompterWithInput("bad\nbad\nbad\n")
p.max = 3
if _, err := p.choose("pick one", []string{"a", "b"}, 1); err == nil {
t.Error("expected error after max attempts")
}
}
func TestChoose_EOFCancels(t *testing.T) {
p, _ := newPrompterWithInput("")
_, err := p.choose("pick", []string{"a"}, 1)
if !errors.Is(err, ErrCancelled) {
t.Errorf("want ErrCancelled, got %v", err)
}
}
func TestYesNo_Default(t *testing.T) {
p, _ := newPrompterWithInput("\n")
got, _ := p.yesNo("ok?", true)
if !got {
t.Error("empty input should return default true")
}
p2, _ := newPrompterWithInput("\n")
got2, _ := p2.yesNo("ok?", false)
if got2 {
t.Error("empty input should return default false")
}
}
func TestYesNo_ParsesYesNo(t *testing.T) {
cases := map[string]bool{
"y\n": true,
"Y\n": true,
"yes\n": true,
"YES\n": true,
"s\n": true, // italian si
"si\n": true,
"n\n": false,
"no\n": false,
"NO\n": false,
}
for input, want := range cases {
p, _ := newPrompterWithInput(input)
got, err := p.yesNo("?", false)
if err != nil {
t.Errorf("input %q: %v", input, err)
continue
}
if got != want {
t.Errorf("input %q: got %v want %v", input, got, want)
}
}
}
func TestAskText_DefaultUsed(t *testing.T) {
p, _ := newPrompterWithInput("\n")
got, _ := p.askText("thing?", "sensible-default", false, nil)
if got != "sensible-default" {
t.Errorf("= %q, want sensible-default", got)
}
}
func TestAskText_OptionalEmpty(t *testing.T) {
p, _ := newPrompterWithInput("\n")
got, err := p.askText("optional?", "", false, nil)
if err != nil {
t.Fatal(err)
}
if got != "" {
t.Errorf("= %q, want empty", got)
}
}
func TestAskText_RequiredEmptyReprompts(t *testing.T) {
p, _ := newPrompterWithInput("\n\n\n") // 3 empty attempts
_, err := p.askText("required", "", true, nil)
if err == nil {
t.Error("expected error after 3 empty attempts")
}
}
func TestAskText_Validation(t *testing.T) {
p, _ := newPrompterWithInput("bad\ngood\n")
got, err := p.askText("domain", "", true, func(s string) error {
if s == "bad" {
return errors.New("no")
}
return nil
})
if err != nil {
t.Fatal(err)
}
if got != "good" {
t.Errorf("= %q, want good", got)
}
}
func TestAskDomain_InvalidReprompts(t *testing.T) {
p, _ := newPrompterWithInput("not_a_domain\nexample.com\n")
got, err := p.askDomain()
if err != nil {
t.Fatal(err)
}
if got != "example.com" {
t.Errorf("= %q, want example.com", got)
}
}
func TestAskDomain_AcceptsSchemePrefixThroughSanitization(t *testing.T) {
// askDomain sanitizes the input before validation — https://example.com
// becomes example.com and passes on the first try. The caller is
// responsible for calling SanitizeDomain again on the returned value.
p, _ := newPrompterWithInput("https://example.com\n")
got, err := p.askDomain()
if err != nil {
t.Fatal(err)
}
if got != "https://example.com" {
t.Errorf("= %q, want raw input preserved", got)
}
}