// 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 }