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.9 KiB
Go

// Package wizard drives the interactive CLI setup flow. When god-eye is
// launched from a terminal with no target, the wizard asks a handful of
// questions (AI tier, model download consent, target, scan profile,
// live view, output) and returns a Choice the caller applies to Config.
//
// The wizard is terminal-only by design and uses the output package for
// consistent colors with the rest of the tool. It never modifies state
// itself — that's the caller's job.
package wizard
import (
"bufio"
"errors"
"fmt"
"io"
"os"
"strconv"
"strings"
"github.com/mattn/go-isatty"
"god-eye/internal/output"
"god-eye/internal/validator"
)
// ErrCancelled is returned when the user aborts the wizard (Ctrl-C or
// by answering "no" at the confirmation step).
var ErrCancelled = errors.New("wizard: cancelled")
// IsInteractive reports whether stdin/stdout are attached to a terminal.
// Callers use this to decide whether to prompt or fall back to
// non-interactive defaults.
func IsInteractive() bool {
return isatty.IsTerminal(os.Stdin.Fd()) && isatty.IsTerminal(os.Stdout.Fd())
}
// prompter is a thin wrapper around a bufio.Reader + writer with helpers
// that respect defaults and re-prompt on invalid input.
type prompter struct {
r *bufio.Reader
w io.Writer
max int // max re-prompts before giving up (default 3)
}
func newPrompter(in io.Reader, out io.Writer) *prompter {
return &prompter{r: bufio.NewReader(in), w: out, max: 3}
}
// printf writes to the prompter's writer. Allows swapping out os.Stdout
// in tests for a bytes.Buffer.
func (p *prompter) printf(format string, args ...interface{}) {
fmt.Fprintf(p.w, format, args...)
}
// readLine reads one line, trimming trailing whitespace. Returns EOF as
// a cancellation.
func (p *prompter) readLine() (string, error) {
line, err := p.r.ReadString('\n')
if err != nil && line == "" {
return "", err
}
return strings.TrimSpace(line), nil
}
// choose presents a numbered menu and returns the selected index. Empty
// input accepts def (1-based). Re-prompts on invalid input up to max.
func (p *prompter) choose(question string, options []string, def int) (int, error) {
p.printf("\n%s %s\n", output.BoldCyan("?"), output.BoldWhite(question))
for i, opt := range options {
marker := " "
if i+1 == def {
marker = output.Green("▸")
}
p.printf(" %s %s %s\n", marker, output.Yellow(fmt.Sprintf("%d)", i+1)), opt)
}
prompt := fmt.Sprintf(" %s ", output.Dim(fmt.Sprintf("Choice [%d]:", def)))
for attempt := 0; attempt < p.max; attempt++ {
p.printf("%s", prompt)
raw, err := p.readLine()
if err != nil {
return 0, ErrCancelled
}
if raw == "" {
return def, nil
}
n, err := strconv.Atoi(raw)
if err == nil && n >= 1 && n <= len(options) {
return n, nil
}
p.printf(" %s not a valid option\n", output.Red("✘"))
}
return 0, fmt.Errorf("too many invalid attempts")
}
// askText reads free-form text. When required is false an empty answer
// (after max attempts) returns "".
func (p *prompter) askText(question, def string, required bool, validate func(string) error) (string, error) {
defHint := ""
if def != "" {
defHint = fmt.Sprintf(" [%s]", def)
} else if !required {
defHint = " (empty to skip)"
}
p.printf("\n%s %s%s\n", output.BoldCyan("?"), output.BoldWhite(question), output.Dim(defHint))
for attempt := 0; attempt < p.max; attempt++ {
p.printf(" %s ", output.Dim(">"))
raw, err := p.readLine()
if err != nil {
return "", ErrCancelled
}
if raw == "" {
if def != "" {
return def, nil
}
if !required {
return "", nil
}
p.printf(" %s required\n", output.Red("✘"))
continue
}
if validate != nil {
if err := validate(raw); err != nil {
p.printf(" %s %v\n", output.Red("✘"), err)
continue
}
}
return raw, nil
}
return "", fmt.Errorf("too many invalid attempts")
}
// yesNo prompts for a boolean with a default. Accepts y/yes/n/no/empty
// (case-insensitive).
func (p *prompter) yesNo(question string, def bool) (bool, error) {
hint := "y/N"
if def {
hint = "Y/n"
}
p.printf("\n%s %s %s\n", output.BoldCyan("?"), output.BoldWhite(question), output.Dim("["+hint+"]"))
for attempt := 0; attempt < p.max; attempt++ {
p.printf(" %s ", output.Dim(">"))
raw, err := p.readLine()
if err != nil {
return false, ErrCancelled
}
if raw == "" {
return def, nil
}
switch strings.ToLower(raw) {
case "y", "yes", "si", "sì", "s":
return true, nil
case "n", "no":
return false, nil
}
p.printf(" %s answer y or n\n", output.Red("✘"))
}
return false, fmt.Errorf("too many invalid attempts")
}
// askDomain is a specialization of askText that validates via the
// validator package and strips URL-style prefixes.
func (p *prompter) askDomain() (string, error) {
return p.askText("Target domain", "", true, func(s string) error {
s = validator.SanitizeDomain(s)
v := validator.DefaultDomainValidator()
return v.ValidateDomain(s)
})
}