mirror of
https://github.com/Vyntral/god-eye.git
synced 2026-05-16 13:39:10 +02:00
3a4c230aa7
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.
303 lines
10 KiB
Go
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
|
|
}
|