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

226 lines
5.7 KiB
Go

// parity is a side-by-side comparison tool used to verify that the v2
// pipeline produces equivalent results to the v1 monolithic scanner for a
// given target domain. It runs both paths, captures JSON output from each,
// and diffs the subdomain lists + high-signal fields.
//
// Usage:
//
// go run ./tools/parity -d example.com
// go run ./tools/parity -d example.com --no-brute
//
// Exit codes:
//
// 0 — outputs match or differ only in fields we expect to differ
// 1 — meaningful divergence (subdomain set differs, status codes differ)
// 2 — runtime error (network, binary missing)
package main
import (
"encoding/json"
"flag"
"fmt"
"os"
"os/exec"
"sort"
"strings"
)
type scanResult struct {
Mode string `json:"mode"` // "v1" or "v2"
Target string `json:"target"`
Subdomains map[string]hostInfo `json:"subdomains"`
ErrorOutput string `json:"error,omitempty"`
}
type hostInfo struct {
Subdomain string `json:"subdomain"`
IPs []string `json:"ips,omitempty"`
CNAME string `json:"cname,omitempty"`
StatusCode int `json:"status_code,omitempty"`
Tech []string `json:"technologies,omitempty"`
CloudProvider string `json:"cloud_provider,omitempty"`
}
func main() {
target := flag.String("d", "", "target domain")
noBrute := flag.Bool("no-brute", false, "skip DNS brute-force (faster, less coverage)")
binary := flag.String("bin", "./god-eye", "path to god-eye binary")
flag.Parse()
if *target == "" {
fmt.Fprintln(os.Stderr, "usage: parity -d <domain> [--no-brute] [-bin ./god-eye]")
os.Exit(2)
}
if _, err := os.Stat(*binary); err != nil {
fmt.Fprintf(os.Stderr, "binary not found at %s: %v\n", *binary, err)
os.Exit(2)
}
fmt.Printf(">>> Running v1 scanner on %s…\n", *target)
v1, err := runScan(*binary, *target, *noBrute, false)
if err != nil {
fmt.Fprintf(os.Stderr, "v1 scan failed: %v\n", err)
os.Exit(2)
}
fmt.Printf(">>> Running v2 pipeline on %s…\n", *target)
v2, err := runScan(*binary, *target, *noBrute, true)
if err != nil {
fmt.Fprintf(os.Stderr, "v2 scan failed: %v\n", err)
os.Exit(2)
}
diff := compare(v1, v2)
printDiff(diff)
if diff.Meaningful() {
os.Exit(1)
}
}
func runScan(binary, target string, noBrute, useV2 bool) (*scanResult, error) {
args := []string{"-d", target, "--json", "--silent"}
if noBrute {
args = append(args, "--no-brute")
}
if useV2 {
args = append(args, "--pipeline")
}
cmd := exec.Command(binary, args...)
out, err := cmd.Output()
if err != nil {
return nil, fmt.Errorf("exec %v: %w (stderr: %s)", args, err, strings.TrimSpace(string(cmd.Stderr.(*os.File).Name())))
}
r := &scanResult{Target: target}
r.Subdomains = map[string]hostInfo{}
if useV2 {
r.Mode = "v2"
} else {
r.Mode = "v1"
}
// Both v1 and v2 emit JSON with a "subdomains" field whose shape differs
// slightly — v1 is an array, v2 is a map. Try both.
var shared struct {
Subdomains []hostInfo `json:"subdomains"`
}
if err := json.Unmarshal(out, &shared); err == nil && len(shared.Subdomains) > 0 {
for _, h := range shared.Subdomains {
r.Subdomains[h.Subdomain] = h
}
return r, nil
}
var v2obj struct {
Subdomains map[string]hostInfo `json:"subdomains"`
}
if err := json.Unmarshal(out, &v2obj); err == nil {
r.Subdomains = v2obj.Subdomains
return r, nil
}
return nil, fmt.Errorf("unable to parse JSON output (%d bytes): %s", len(out), strings.TrimSpace(string(out[:min(len(out), 200)])))
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
type diffReport struct {
Target string
OnlyInV1 []string
OnlyInV2 []string
Shared []string
StatusDiff map[string][2]int // subdomain → [v1 status, v2 status]
}
func (d *diffReport) Meaningful() bool {
// Ignore small diffs in discovery (sources can time out differently).
if len(d.OnlyInV1) > 2 || len(d.OnlyInV2) > 2 {
return true
}
if len(d.StatusDiff) > 0 {
return true
}
return false
}
func compare(v1, v2 *scanResult) *diffReport {
d := &diffReport{Target: v1.Target, StatusDiff: map[string][2]int{}}
v1set := keySet(v1.Subdomains)
v2set := keySet(v2.Subdomains)
for k := range v1set {
if _, ok := v2set[k]; !ok {
d.OnlyInV1 = append(d.OnlyInV1, k)
} else {
d.Shared = append(d.Shared, k)
}
}
for k := range v2set {
if _, ok := v1set[k]; !ok {
d.OnlyInV2 = append(d.OnlyInV2, k)
}
}
sort.Strings(d.OnlyInV1)
sort.Strings(d.OnlyInV2)
sort.Strings(d.Shared)
for _, k := range d.Shared {
a := v1.Subdomains[k].StatusCode
b := v2.Subdomains[k].StatusCode
if a != b && a != 0 && b != 0 {
d.StatusDiff[k] = [2]int{a, b}
}
}
return d
}
func keySet(m map[string]hostInfo) map[string]struct{} {
out := make(map[string]struct{}, len(m))
for k := range m {
out[k] = struct{}{}
}
return out
}
func printDiff(d *diffReport) {
fmt.Println()
fmt.Printf("=== Parity report for %s ===\n", d.Target)
fmt.Printf(" Shared subdomains: %d\n", len(d.Shared))
fmt.Printf(" Only in v1 : %d\n", len(d.OnlyInV1))
fmt.Printf(" Only in v2 : %d\n", len(d.OnlyInV2))
fmt.Printf(" Status code diff: %d\n", len(d.StatusDiff))
if len(d.OnlyInV1) > 0 {
fmt.Println(" -- v1 only --")
for _, s := range d.OnlyInV1 {
fmt.Println(" ", s)
}
}
if len(d.OnlyInV2) > 0 {
fmt.Println(" -- v2 only --")
for _, s := range d.OnlyInV2 {
fmt.Println(" ", s)
}
}
if len(d.StatusDiff) > 0 {
fmt.Println(" -- status differences --")
for k, pair := range d.StatusDiff {
fmt.Printf(" %s: v1=%d v2=%d\n", k, pair[0], pair[1])
}
}
if d.Meaningful() {
fmt.Println()
fmt.Println("RESULT: meaningful divergence detected.")
} else {
fmt.Println()
fmt.Println("RESULT: v1 and v2 agree (differences within acceptable noise).")
}
}