mirror of
https://github.com/Vyntral/god-eye.git
synced 2026-08-17 14:57:15 +02:00
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.
This commit is contained in:
@@ -0,0 +1,174 @@
|
||||
// Package proxyconf centralises outbound-proxy configuration for the
|
||||
// HTTP and (where possible) DNS clients used across God's Eye modules.
|
||||
//
|
||||
// Why this lives in its own package: every source/probe/module needs to
|
||||
// honour the same proxy setting, and duplicating URL parsing + dialer
|
||||
// wiring across `internal/http`, `internal/sources`, and individual
|
||||
// modules would be a fountain of bugs. This package is the single
|
||||
// source of truth.
|
||||
//
|
||||
// Supported schemes:
|
||||
//
|
||||
// "" → direct (no proxy)
|
||||
// http://host:port → HTTP CONNECT proxy (e.g. Burp, ZAP, mitmproxy)
|
||||
// https://host:port → HTTPS CONNECT proxy
|
||||
// socks5://host:port → SOCKS5 (DNS resolved locally by god-eye)
|
||||
// socks5h://host:port → SOCKS5 (DNS resolved by the proxy — Tor convention)
|
||||
//
|
||||
// Basic auth (http://user:pass@host) is honoured for every scheme.
|
||||
//
|
||||
// DNS-over-SOCKS caveat: Go's net package uses the OS resolver by default,
|
||||
// which does NOT route through SOCKS. `socks5h://` only applies to HTTP
|
||||
// requests — the brute-force DNS resolver (`internal/dns`) continues to
|
||||
// hit its configured resolvers directly. Users who need full Tor
|
||||
// isolation for DNS should run god-eye inside a torsocks-wrapped shell
|
||||
// or a netns with all traffic captured.
|
||||
package proxyconf
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/net/proxy"
|
||||
)
|
||||
|
||||
// DialFunc is the signature used by http.Transport.DialContext.
|
||||
type DialFunc func(ctx context.Context, network, addr string) (net.Conn, error)
|
||||
|
||||
// ProxyFunc is the signature used by http.Transport.Proxy.
|
||||
type ProxyFunc func(*http.Request) (*url.URL, error)
|
||||
|
||||
// Validate returns a descriptive error if proxyURL is non-empty and
|
||||
// doesn't parse to a supported scheme. Call this early (e.g. during
|
||||
// validator.ValidateXxx) so bad flags fail before module startup.
|
||||
func Validate(proxyURL string) error {
|
||||
proxyURL = strings.TrimSpace(proxyURL)
|
||||
if proxyURL == "" {
|
||||
return nil
|
||||
}
|
||||
u, err := url.Parse(proxyURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("proxy URL malformed: %w", err)
|
||||
}
|
||||
if u.Host == "" {
|
||||
return errors.New("proxy URL missing host:port")
|
||||
}
|
||||
switch strings.ToLower(u.Scheme) {
|
||||
case "http", "https", "socks5", "socks5h":
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("unsupported proxy scheme %q (use http/https/socks5/socks5h)", u.Scheme)
|
||||
}
|
||||
}
|
||||
|
||||
// BuildDialer returns a DialFunc that routes TCP through the configured
|
||||
// proxy. For HTTP(S) CONNECT proxies (handled at the transport layer via
|
||||
// Proxy field), this returns a direct dialer — the transport layer does
|
||||
// the CONNECT dance itself.
|
||||
//
|
||||
// For empty proxyURL, returns the direct-dialer from net.Dialer.
|
||||
func BuildDialer(proxyURL string, base *net.Dialer) (DialFunc, error) {
|
||||
if base == nil {
|
||||
base = &net.Dialer{}
|
||||
}
|
||||
if strings.TrimSpace(proxyURL) == "" {
|
||||
return base.DialContext, nil
|
||||
}
|
||||
u, err := url.Parse(proxyURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch strings.ToLower(u.Scheme) {
|
||||
case "http", "https":
|
||||
// CONNECT proxy — direct TCP, Transport.Proxy handles the handshake.
|
||||
return base.DialContext, nil
|
||||
case "socks5", "socks5h":
|
||||
var auth *proxy.Auth
|
||||
if u.User != nil {
|
||||
pass, _ := u.User.Password()
|
||||
auth = &proxy.Auth{User: u.User.Username(), Password: pass}
|
||||
}
|
||||
// proxy.Direct is the fallthrough dialer — we pass our base so
|
||||
// timeouts/keepalive settings are preserved.
|
||||
dialer, err := proxy.SOCKS5("tcp", u.Host, auth, &directAdapter{base: base})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create SOCKS5 dialer: %w", err)
|
||||
}
|
||||
if ctxDialer, ok := dialer.(proxy.ContextDialer); ok {
|
||||
return ctxDialer.DialContext, nil
|
||||
}
|
||||
// Older x/net versions: wrap non-context Dial with ctx-aware shim.
|
||||
return func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
type result struct {
|
||||
conn net.Conn
|
||||
err error
|
||||
}
|
||||
ch := make(chan result, 1)
|
||||
go func() {
|
||||
c, e := dialer.Dial(network, addr)
|
||||
ch <- result{c, e}
|
||||
}()
|
||||
select {
|
||||
case r := <-ch:
|
||||
return r.conn, r.err
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
}, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported proxy scheme: %s", u.Scheme)
|
||||
}
|
||||
}
|
||||
|
||||
// BuildProxyFunc returns the http.Transport.Proxy callback for HTTP(S)
|
||||
// CONNECT proxies. Returns nil for SOCKS5 (handled by the dialer) and
|
||||
// for empty proxyURL.
|
||||
func BuildProxyFunc(proxyURL string) (ProxyFunc, error) {
|
||||
if strings.TrimSpace(proxyURL) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
u, err := url.Parse(proxyURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch strings.ToLower(u.Scheme) {
|
||||
case "http", "https":
|
||||
return http.ProxyURL(u), nil
|
||||
case "socks5", "socks5h":
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("unsupported proxy scheme: %s", u.Scheme)
|
||||
}
|
||||
|
||||
// Humanize returns a redacted, user-facing description of the proxy.
|
||||
// Strips credentials so logs don't leak tokens.
|
||||
func Humanize(proxyURL string) string {
|
||||
proxyURL = strings.TrimSpace(proxyURL)
|
||||
if proxyURL == "" {
|
||||
return "direct (no proxy)"
|
||||
}
|
||||
u, err := url.Parse(proxyURL)
|
||||
if err != nil {
|
||||
return "invalid"
|
||||
}
|
||||
auth := ""
|
||||
if u.User != nil {
|
||||
auth = "(auth)@"
|
||||
}
|
||||
return fmt.Sprintf("%s://%s%s", u.Scheme, auth, u.Host)
|
||||
}
|
||||
|
||||
// directAdapter adapts a *net.Dialer to the proxy.Dialer interface so
|
||||
// our configured timeouts/keepalive flow through to the socks hop.
|
||||
type directAdapter struct {
|
||||
base *net.Dialer
|
||||
}
|
||||
|
||||
func (d *directAdapter) Dial(network, addr string) (net.Conn, error) {
|
||||
return d.base.Dial(network, addr)
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package proxyconf
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestValidate(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
wantErr bool
|
||||
}{
|
||||
{"", false},
|
||||
{"http://127.0.0.1:8080", false},
|
||||
{"https://proxy.corp:3128", false},
|
||||
{"socks5://127.0.0.1:9050", false},
|
||||
{"socks5h://127.0.0.1:9050", false},
|
||||
{"socks5h://user:pass@127.0.0.1:9050", false},
|
||||
{"ftp://x:21", true},
|
||||
{"socks4://x:1080", true},
|
||||
{"not a url", true},
|
||||
{"://nohost", true},
|
||||
{"http://", true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
err := Validate(c.in)
|
||||
if (err != nil) != c.wantErr {
|
||||
t.Errorf("Validate(%q) err=%v wantErr=%v", c.in, err, c.wantErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDialer_EmptyReturnsDirect(t *testing.T) {
|
||||
d, err := BuildDialer("", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if d == nil {
|
||||
t.Fatal("nil dialer")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDialer_SOCKS5Accepted(t *testing.T) {
|
||||
d, err := BuildDialer("socks5://127.0.0.1:9050", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("SOCKS5 should construct: %v", err)
|
||||
}
|
||||
if d == nil {
|
||||
t.Fatal("nil dialer")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDialer_SOCKS5WithAuth(t *testing.T) {
|
||||
d, err := BuildDialer("socks5h://user:pass@127.0.0.1:9050", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("auth SOCKS5 should construct: %v", err)
|
||||
}
|
||||
if d == nil {
|
||||
t.Fatal("nil dialer")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDialer_HTTPProxyPassthrough(t *testing.T) {
|
||||
// HTTP proxy uses Transport.Proxy; dialer should be direct-equivalent.
|
||||
d, err := BuildDialer("http://127.0.0.1:8080", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if d == nil {
|
||||
t.Fatal("nil dialer")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDialer_UnsupportedScheme(t *testing.T) {
|
||||
_, err := BuildDialer("ftp://127.0.0.1", nil)
|
||||
if err == nil {
|
||||
t.Error("expected error for unsupported scheme")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildProxyFunc_HTTPProxy(t *testing.T) {
|
||||
fn, err := BuildProxyFunc("http://127.0.0.1:8080")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if fn == nil {
|
||||
t.Fatal("http:// should yield non-nil ProxyFunc")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildProxyFunc_SOCKSReturnsNil(t *testing.T) {
|
||||
fn, err := BuildProxyFunc("socks5://127.0.0.1:9050")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if fn != nil {
|
||||
t.Error("SOCKS5 should return nil ProxyFunc (handled by dialer)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildProxyFunc_EmptyReturnsNil(t *testing.T) {
|
||||
fn, err := BuildProxyFunc("")
|
||||
if err != nil || fn != nil {
|
||||
t.Errorf("empty → (nil, nil), got (%v, %v)", fn, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHumanize(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"": "direct (no proxy)",
|
||||
"http://proxy.corp:3128": "http://proxy.corp:3128",
|
||||
"socks5://127.0.0.1:9050": "socks5://127.0.0.1:9050",
|
||||
"socks5h://user:secret@10.0.0.1:443": "socks5h://(auth)@10.0.0.1:443",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := Humanize(in); got != want {
|
||||
t.Errorf("Humanize(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHumanize_LeaksNoCredentials(t *testing.T) {
|
||||
const secret = "supersecret"
|
||||
h := Humanize("socks5://user:" + secret + "@127.0.0.1:9050")
|
||||
if contains(h, secret) {
|
||||
t.Errorf("Humanize leaked credentials: %s", h)
|
||||
}
|
||||
}
|
||||
|
||||
func contains(s, sub string) bool {
|
||||
for i := 0; i+len(sub) <= len(s); i++ {
|
||||
if s[i:i+len(sub)] == sub {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
Reference in New Issue
Block a user