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

303 lines
10 KiB
Go

// Package nucleitpl parses and executes a subset of the Nuclei YAML
// template format. The goal is to run community HTTP templates unchanged
// so God's Eye gets access to the ~8000-template ecosystem without
// reimplementing detections one-by-one.
//
// Supported subset (covers roughly 70% of HTTP templates in the public
// nuclei-templates repo at time of writing):
//
// - Top-level: id, info { name, severity, description, tags, author }
// - Protocol: requests: (aliased as http: in newer templates)
// - Per-request: method, path (with {{BaseURL}}/{{Hostname}} substitution),
// headers, body, redirects (bool), matchers-condition (and|or)
// - Matchers: type=word (word|part|condition),
// type=regex (regex|part),
// type=status (status),
// type=size (size)
// - Severity mapping: info/low/medium/high/critical
//
// Out of scope (templates using these are skipped with a reason logged):
//
// - Protocols other than http: dns, ssl, network, file, code, javascript,
// workflow, headless, flow
// - Pre-conditions, payloads, extractors, dynamic variables,
// stop-at-first-match, cluster, self-contained
// - Interactsh (OOB) — requires a callback server we don't ship yet
// - Fuzzing templates
//
// A skipped template logs via the returned diagnostic; the executor never
// panics on an unsupported template.
package nucleitpl
import (
"fmt"
"os"
"path/filepath"
"strings"
"gopkg.in/yaml.v3"
)
// Template is the parsed form of a Nuclei YAML file.
type Template struct {
ID string `yaml:"id"`
Info Info `yaml:"info"`
Requests []HTTPRequest `yaml:"requests,omitempty"`
HTTP []HTTPRequest `yaml:"http,omitempty"` // newer alias for requests
// Unsupported protocols — presence triggers skip with reason.
DNS interface{} `yaml:"dns,omitempty"`
SSL interface{} `yaml:"ssl,omitempty"`
Network interface{} `yaml:"network,omitempty"`
File interface{} `yaml:"file,omitempty"`
Code interface{} `yaml:"code,omitempty"`
Headless interface{} `yaml:"headless,omitempty"`
Workflow interface{} `yaml:"workflows,omitempty"`
// SourcePath is populated by Load so diagnostics can reference the file.
SourcePath string `yaml:"-"`
}
// Info is the template metadata block.
type Info struct {
Name string `yaml:"name"`
Author string `yaml:"author,omitempty"`
Severity string `yaml:"severity"`
Description string `yaml:"description,omitempty"`
Reference []string `yaml:"reference,omitempty"`
Tags string `yaml:"tags,omitempty"`
}
// HTTPRequest is one HTTP interaction in a template.
type HTTPRequest struct {
Method string `yaml:"method,omitempty"` // default GET
Path []string `yaml:"path"`
Headers map[string]string `yaml:"headers,omitempty"`
Body string `yaml:"body,omitempty"`
Redirects bool `yaml:"redirects,omitempty"`
MaxRedirects int `yaml:"max-redirects,omitempty"`
MatchersCondition string `yaml:"matchers-condition,omitempty"` // "and" | "or" (default "or")
Matchers []Matcher `yaml:"matchers"`
// Unsupported fields that, if present with values, trigger a skip.
Payloads interface{} `yaml:"payloads,omitempty"`
Extractors interface{} `yaml:"extractors,omitempty"`
Fuzzing interface{} `yaml:"fuzzing,omitempty"`
Unsafe bool `yaml:"unsafe,omitempty"`
Attack string `yaml:"attack,omitempty"`
Raw []string `yaml:"raw,omitempty"`
Pipeline bool `yaml:"pipeline,omitempty"`
Threads int `yaml:"threads,omitempty"`
StopAtFirst bool `yaml:"stop-at-first-match,omitempty"`
}
// Matcher is a single match rule within a request.
type Matcher struct {
Type string `yaml:"type"` // word | regex | status | size | dsl | binary
Part string `yaml:"part,omitempty"` // header | body | response (default body)
Condition string `yaml:"condition,omitempty"` // and | or (default or)
Negative bool `yaml:"negative,omitempty"`
Words []string `yaml:"words,omitempty"`
Regex []string `yaml:"regex,omitempty"`
Status []int `yaml:"status,omitempty"`
Size []int `yaml:"size,omitempty"`
// Unsupported — presence marks the matcher unusable.
DSL []string `yaml:"dsl,omitempty"`
Binary []string `yaml:"binary,omitempty"`
}
// Load parses a single YAML file into a Template. Malformed YAML or empty
// files return (nil, err); structurally valid YAML that references unused
// protocols still Load successfully — IsSupported/IsSupported reason tell
// the caller whether to execute it.
func Load(path string) (*Template, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var t Template
if err := yaml.Unmarshal(data, &t); err != nil {
return nil, fmt.Errorf("parse %s: %w", path, err)
}
if t.ID == "" {
return nil, fmt.Errorf("parse %s: missing id field", path)
}
t.SourcePath = path
// Normalize requests vs http alias.
if len(t.Requests) == 0 && len(t.HTTP) > 0 {
t.Requests = t.HTTP
}
return &t, nil
}
// LoadDir walks dir recursively, loads every .yaml / .yml file, and
// returns the slice of successfully-parsed templates. Parse errors are
// collected into the returned diagnostics slice but do not stop the walk.
func LoadDir(dir string) ([]*Template, []string, error) {
var tpls []*Template
var diags []string
err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil // skip unreadable files silently
}
if info.IsDir() {
return nil
}
ext := strings.ToLower(filepath.Ext(path))
if ext != ".yaml" && ext != ".yml" {
return nil
}
t, err := Load(path)
if err != nil {
diags = append(diags, fmt.Sprintf("parse %s: %v", path, err))
return nil
}
tpls = append(tpls, t)
return nil
})
return tpls, diags, err
}
// TargetsCurrentHost reports whether every request path in the template
// is scoped to the scanned host — i.e. uses {{BaseURL}}, {{Hostname}},
// {{RootURL}}, or a leading "/". Templates with absolute URLs to
// third-party services (common in OSINT / user-presence checks) would
// otherwise fire against unrelated hosts with unresolved placeholders
// like {{user}} — and their matchers often succeed on whatever generic
// response the third party returns, producing high-volume false
// positives against a single-target scan.
//
// Returns false + reason when any request path is off-host.
func (t *Template) TargetsCurrentHost() (bool, string) {
for i, r := range t.Requests {
for j, p := range r.Path {
ok := false
switch {
case strings.HasPrefix(p, "{{BaseURL}}"),
strings.HasPrefix(p, "{{Hostname}}"),
strings.HasPrefix(p, "{{RootURL}}"),
strings.HasPrefix(p, "/"):
ok = true
}
if !ok {
// Also allow the special case where the path is exactly
// a template variable (no literal text).
if p == "{{BaseURL}}" || p == "{{Hostname}}" || p == "{{RootURL}}" {
ok = true
}
}
if !ok {
return false, fmt.Sprintf("request[%d].path[%d] %q does not target the scanned host", i, j, truncateStr(p, 60))
}
}
}
return true, ""
}
func truncateStr(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "…"
}
// IsSupported returns (true, "") when the template uses only features
// understood by the executor. Templates that would need unsupported
// protocols, payloads, extractors, or fuzzing return (false, reason).
// Templates that target third-party hosts (OSINT-style user lookups)
// also return false to prevent spurious matches during targeted scans.
func (t *Template) IsSupported() (bool, string) {
if t == nil {
return false, "nil template"
}
if t.DNS != nil {
return false, "dns protocol (unsupported)"
}
if t.SSL != nil {
return false, "ssl protocol (unsupported)"
}
if t.Network != nil {
return false, "network protocol (unsupported)"
}
if t.File != nil {
return false, "file protocol (unsupported)"
}
if t.Code != nil {
return false, "code protocol (unsupported)"
}
if t.Headless != nil {
return false, "headless protocol (unsupported)"
}
if t.Workflow != nil {
return false, "workflow (unsupported)"
}
if len(t.Requests) == 0 {
return false, "no http requests"
}
for i, r := range t.Requests {
if r.Payloads != nil {
return false, fmt.Sprintf("request[%d] uses payloads (unsupported)", i)
}
if r.Extractors != nil {
// Tolerate extractors on the first pass; we ignore them.
// Templates with only extractors still run; their findings are
// just matcher-based.
}
if r.Fuzzing != nil {
return false, fmt.Sprintf("request[%d] uses fuzzing (unsupported)", i)
}
if r.Unsafe {
return false, fmt.Sprintf("request[%d] is unsafe (raw TCP)", i)
}
if len(r.Raw) > 0 {
return false, fmt.Sprintf("request[%d] uses raw (unsupported)", i)
}
if len(r.Path) == 0 {
return false, fmt.Sprintf("request[%d] has no path", i)
}
if len(r.Matchers) == 0 {
return false, fmt.Sprintf("request[%d] has no matchers", i)
}
for j, m := range r.Matchers {
switch m.Type {
case "word", "regex", "status", "size":
// supported
case "dsl", "binary":
return false, fmt.Sprintf("request[%d].matcher[%d] type=%s (unsupported)", i, j, m.Type)
default:
return false, fmt.Sprintf("request[%d].matcher[%d] type=%s (unknown)", i, j, m.Type)
}
}
}
// Scope check: skip templates that probe third-party hosts.
if ok, reason := t.TargetsCurrentHost(); !ok {
return false, reason
}
return true, ""
}
// Severity returns the OWASP-style severity, defaulting to "info" when
// the template omits it.
func (t *Template) Severity() string {
s := strings.ToLower(strings.TrimSpace(t.Info.Severity))
switch s {
case "critical", "high", "medium", "low", "info":
return s
default:
return "info"
}
}
// Tags returns the comma-separated tags as a string slice.
func (t *Template) Tags() []string {
if t.Info.Tags == "" {
return nil
}
var out []string
for _, p := range strings.Split(t.Info.Tags, ",") {
p = strings.TrimSpace(p)
if p != "" {
out = append(out, p)
}
}
return out
}