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

330 lines
9.7 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package nuclei runs Nuclei-format YAML templates against every probed
// host. The actual executor lives in internal/nucleitpl; this module is
// the wiring that discovers templates on disk, fans out per host, and
// publishes matches as VulnerabilityFound events.
//
// Template discovery order:
// 1. --nuclei-templates flag (highest priority)
// 2. NUCLEI_TEMPLATES env var
// 3. ~/nuclei-templates (nuclei CLI default)
// 4. ~/.god-eye/nuclei-templates
//
// If no template directory is found AND nuclei_auto_download is true
// (default), God's Eye downloads the official projectdiscovery/nuclei-templates
// ZIP into ~/.god-eye/nuclei-templates, extracts only the .yaml/.yml files
// (path-traversal safe), and proceeds with the scan. The archive is
// ~40MB; first run takes 10-30 seconds depending on network, subsequent
// runs skip the download.
//
// Refresh the cache manually with: god-eye nuclei-update
//
// Only HTTP templates compatible with our executor subset run; others
// are counted as "skipped" and surfaced as a ModuleError event once per
// scan.
package nuclei
import (
"context"
"fmt"
"os"
"path/filepath"
"sync"
"time"
"god-eye/internal/eventbus"
gohttp "god-eye/internal/http"
"god-eye/internal/module"
"god-eye/internal/nucleitpl"
"god-eye/internal/store"
)
const ModuleName = "vuln.nuclei-compat"
type nucleiModule struct{}
func Register() { module.Register(&nucleiModule{}) }
func (*nucleiModule) Name() string { return ModuleName }
func (*nucleiModule) Phase() module.Phase { return module.PhaseAnalysis }
func (*nucleiModule) Consumes() []eventbus.EventType { return []eventbus.EventType{eventbus.EventHTTPProbed} }
func (*nucleiModule) Produces() []eventbus.EventType {
return []eventbus.EventType{eventbus.EventVulnerability, eventbus.EventCVEMatch}
}
// DefaultEnabled returns true so the registry always loads the module;
// Run() itself is a no-op unless `nuclei_scan` is set in the config
// (via --nuclei or YAML). Mirrors the ai.cascade module — keeps the
// module visible to selection logic while preserving opt-in semantics.
func (*nucleiModule) DefaultEnabled() bool { return true }
func (*nucleiModule) Run(mctx module.Context) error {
if !mctx.Config.Bool("nuclei_scan", false) {
return nil
}
tplDir := resolveTemplateDir(mctx)
if tplDir == "" {
// No templates found — try auto-download into ~/.god-eye/nuclei-templates
// unless the user explicitly disabled that fallback.
if !mctx.Config.Bool("nuclei_auto_download", true) {
mctx.Bus.Publish(mctx.Ctx, eventbus.ModuleError{
EventMeta: eventbus.EventMeta{At: time.Now(), Source: ModuleName, Target: mctx.Target},
Module: ModuleName,
Err: "no nuclei templates found and --nuclei-auto-download=false. Clone https://github.com/projectdiscovery/nuclei-templates into ~/nuclei-templates or pass --nuclei-templates <path>",
})
return nil
}
dest, err := defaultAutoDownloadDir()
if err != nil {
mctx.Bus.Publish(mctx.Ctx, eventbus.ModuleError{
EventMeta: eventbus.EventMeta{At: time.Now(), Source: ModuleName, Target: mctx.Target},
Module: ModuleName,
Err: fmt.Sprintf("cannot determine default templates dir: %v", err),
})
return nil
}
dl := nucleitpl.NewDownloader()
dl.Verbose = mctx.Config.Bool("verbose", false) || mctx.Config.Bool("ai.verbose", false)
if err := dl.EnsureTemplates(dest); err != nil {
mctx.Bus.Publish(mctx.Ctx, eventbus.ModuleError{
EventMeta: eventbus.EventMeta{At: time.Now(), Source: ModuleName, Target: mctx.Target},
Module: ModuleName,
Err: fmt.Sprintf("auto-download nuclei templates: %v", err),
})
return nil
}
tplDir = dest
}
tpls, diags, err := nucleitpl.LoadDir(tplDir)
if err != nil {
return fmt.Errorf("load templates from %s: %w", tplDir, err)
}
supported := 0
skipped := 0
var supportedTpls []*nucleitpl.Template
for _, t := range tpls {
if ok, _ := t.IsSupported(); ok {
supported++
supportedTpls = append(supportedTpls, t)
} else {
skipped++
}
}
if supported == 0 {
mctx.Bus.Publish(mctx.Ctx, eventbus.ModuleError{
EventMeta: eventbus.EventMeta{At: time.Now(), Source: ModuleName, Target: mctx.Target},
Module: ModuleName,
Err: fmt.Sprintf("loaded %d templates, 0 supported (skipped %d, parse errors %d)", len(tpls), skipped, len(diags)),
})
return nil
}
timeout := time.Duration(mctx.Config.Int("timeout", 10)) * time.Second
client := gohttp.GetSharedClient(int(timeout.Seconds()))
exec := nucleitpl.NewExecutor(client, timeout)
// Gather target URLs from the store.
var targets []string
for _, h := range mctx.Store.All(mctx.Ctx) {
if h == nil || h.StatusCode == 0 {
continue
}
targets = append(targets, "https://"+h.Subdomain)
}
if len(targets) == 0 {
return nil
}
// Bounded parallelism: running thousands of templates × hundreds of
// hosts unbounded would be a DoS against ourselves and the target.
maxConcurrent := mctx.Config.Int("concurrency", 50)
if maxConcurrent > 50 {
maxConcurrent = 50 // cap — templates make 1-3 requests each
}
if maxConcurrent < 1 {
maxConcurrent = 10
}
sem := make(chan struct{}, maxConcurrent)
var wg sync.WaitGroup
for _, url := range targets {
for _, t := range supportedTpls {
if mctx.Ctx.Err() != nil {
break
}
url := url
t := t
wg.Add(1)
sem <- struct{}{}
go func() {
defer wg.Done()
defer func() { <-sem }()
runCtx, cancel := context.WithTimeout(mctx.Ctx, timeout)
defer cancel()
for _, m := range exec.Run(runCtx, t, url) {
publishMatch(mctx, m)
}
}()
}
}
wg.Wait()
if skipped > 0 {
mctx.Bus.Publish(mctx.Ctx, eventbus.ModuleError{
EventMeta: eventbus.EventMeta{At: time.Now(), Source: ModuleName, Target: mctx.Target},
Module: ModuleName,
Err: fmt.Sprintf("executed %d templates, skipped %d (unsupported protocol/features)", supported, skipped),
})
}
return nil
}
// publishMatch persists the match into the store and fires a
// VulnerabilityFound event. When the match references CVEs, a CVEMatch
// event is also fired so the CVE aggregator sees it.
func publishMatch(mctx module.Context, m nucleitpl.Match) {
now := time.Now()
severity := mapSeverity(m.Severity)
host := hostFromURL(m.URL)
_ = mctx.Store.Upsert(mctx.Ctx, host, func(h *store.Host) {
h.Vulnerabilities = append(h.Vulnerabilities, store.Vulnerability{
ID: "nuclei/" + m.TemplateID,
Title: m.Name,
Description: m.Description,
Severity: string(severity),
URL: m.URL,
Evidence: m.Evidence,
CVEs: append([]string(nil), m.CVEs...),
FoundAt: now,
})
for _, cveID := range m.CVEs {
h.CVEs = append(h.CVEs, store.CVE{
ID: cveID,
Technology: m.TemplateID,
Severity: string(severity),
FoundAt: now,
URL: m.TemplateURL,
})
}
})
mctx.Bus.Publish(mctx.Ctx, eventbus.VulnerabilityFound{
EventMeta: eventbus.EventMeta{At: now, Source: ModuleName, Target: host},
ID: "nuclei/" + m.TemplateID,
Title: m.Name,
Description: m.Description,
Severity: severity,
URL: m.URL,
Evidence: m.Evidence,
CVEs: append([]string(nil), m.CVEs...),
})
for _, cveID := range m.CVEs {
mctx.Bus.Publish(mctx.Ctx, eventbus.CVEMatch{
EventMeta: eventbus.EventMeta{At: now, Source: ModuleName, Target: host},
CVE: cveID,
Technology: m.TemplateID,
Severity: severity,
Description: m.Name,
URL: m.TemplateURL,
})
}
}
func mapSeverity(s string) eventbus.Severity {
switch s {
case "critical":
return eventbus.SeverityCritical
case "high":
return eventbus.SeverityHigh
case "medium":
return eventbus.SeverityMedium
case "low":
return eventbus.SeverityLow
default:
return eventbus.SeverityInfo
}
}
// resolveTemplateDir returns the first USABLE template directory, in
// priority order. "Usable" means it exists, is a directory, and the
// process can list its contents (i.e. not a permission-denied mount
// like a read-restricted nuclei install in another user's home).
// Returns "" when no candidate qualifies.
func resolveTemplateDir(mctx module.Context) string {
candidates := []string{
mctx.Config.String("nuclei_templates", ""),
os.Getenv("NUCLEI_TEMPLATES"),
}
if home, err := os.UserHomeDir(); err == nil {
// Prefer the god-eye auto-managed cache over a pre-existing
// ~/nuclei-templates: the latter may be a nuclei CLI install
// with restrictive permissions we can't read.
candidates = append(candidates,
filepath.Join(home, ".god-eye", "nuclei-templates"),
filepath.Join(home, "nuclei-templates"),
)
}
for _, c := range candidates {
if c == "" {
continue
}
info, err := os.Stat(c)
if err != nil || !info.IsDir() {
continue
}
// Readability check: can we list at least one entry? If the dir
// is permission-denied, os.Stat succeeds but os.Open fails —
// skip such candidates so auto-download fallback triggers.
f, err := os.Open(c)
if err != nil {
continue
}
names, err := f.Readdirnames(1)
f.Close()
if err != nil {
continue
}
if len(names) == 0 {
// Empty dir — treat as unusable to trigger auto-download.
continue
}
return c
}
return ""
}
// defaultAutoDownloadDir returns ~/.god-eye/nuclei-templates.
func defaultAutoDownloadDir() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(home, ".god-eye", "nuclei-templates"), nil
}
func hostFromURL(u string) string {
// Strip scheme.
s := u
for _, p := range []string{"https://", "http://"} {
if len(s) > len(p) && s[:len(p)] == p {
s = s[len(p):]
break
}
}
// Strip path.
for i := 0; i < len(s); i++ {
if s[i] == '/' || s[i] == '?' || s[i] == '#' {
return s[:i]
}
}
return s
}