package nucleitpl import ( "bytes" "context" "fmt" "io" "net/http" "regexp" "strings" "time" ) // Executor runs supported Nuclei templates against a target URL. type Executor struct { Client *http.Client Timeout time.Duration MaxBodyB int64 // response body cap; default 1MB UserAgent string } // NewExecutor builds an executor with sensible defaults. Pass a custom // *http.Client when you want connection pooling shared with the rest of // the scan (recommended). func NewExecutor(client *http.Client, timeout time.Duration) *Executor { if client == nil { client = &http.Client{Timeout: timeout} } if timeout == 0 { timeout = 15 * time.Second } return &Executor{ Client: client, Timeout: timeout, MaxBodyB: 1 * 1024 * 1024, UserAgent: "god-eye-v2-nuclei", } } // Match holds the successful match output for a single template/target. type Match struct { TemplateID string TemplateURL string // reference URL when present in info.reference Name string Severity string Description string Tags []string URL string // URL that matched Evidence string // short excerpt from the matching response CVEs []string // extracted from info.reference when possible Author string } // Run executes every HTTP request in the template against the given // base URL (e.g. "https://api.example.com"). Returns one Match per // request that succeeds. Non-matching requests produce no entries. // // Templating substitutions handled: {{BaseURL}}, {{Hostname}}, {{RootURL}}. func (e *Executor) Run(ctx context.Context, t *Template, baseURL string) []Match { if ok, _ := t.IsSupported(); !ok { return nil } var matches []Match for _, req := range t.Requests { for _, p := range req.Path { url := expandPath(p, baseURL) m, err := e.runOne(ctx, t, req, url) if err != nil || m == nil { continue } matches = append(matches, *m) } } return matches } // runOne sends one HTTP request, applies matchers, and returns a Match // when every matchers-condition group is satisfied. func (e *Executor) runOne(ctx context.Context, t *Template, req HTTPRequest, url string) (*Match, error) { method := strings.ToUpper(req.Method) if method == "" { method = "GET" } var body io.Reader if req.Body != "" { body = bytes.NewBufferString(req.Body) } r, err := http.NewRequestWithContext(ctx, method, url, body) if err != nil { return nil, err } for k, v := range req.Headers { r.Header.Set(k, v) } if r.Header.Get("User-Agent") == "" { r.Header.Set("User-Agent", e.UserAgent) } // Honor the redirects flag; default is NO redirect follow (safer // for vuln detection since a 3xx-based probe might be exactly what // we want to measure). client := e.Client if !req.Redirects { wrapped := *client wrapped.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse } client = &wrapped } resp, err := client.Do(r) if err != nil { return nil, err } defer resp.Body.Close() bodyBytes, _ := io.ReadAll(io.LimitReader(resp.Body, e.MaxBodyB)) // Apply matchers. condition := strings.ToLower(strings.TrimSpace(req.MatchersCondition)) if condition == "" { condition = "or" } fired := 0 for _, m := range req.Matchers { if matcherHits(m, resp, bodyBytes) { fired++ } } switch condition { case "and": if fired != len(req.Matchers) { return nil, nil } case "or": if fired == 0 { return nil, nil } default: if fired == 0 { return nil, nil } } return &Match{ TemplateID: t.ID, TemplateURL: firstRef(t.Info.Reference), Name: t.Info.Name, Severity: t.Severity(), Description: t.Info.Description, Tags: t.Tags(), URL: url, Evidence: evidenceSnippet(bodyBytes, resp), CVEs: extractCVEs(t.ID, t.Info.Reference), Author: t.Info.Author, }, nil } // matcherHits returns true when the matcher m fires against the response. // Respects m.Negative (inverts), m.Condition (and|or over word list), and // m.Part (header|body|response|all; default body). func matcherHits(m Matcher, resp *http.Response, body []byte) bool { hit := false switch m.Type { case "status": for _, code := range m.Status { if resp.StatusCode == code { hit = true break } } case "size": for _, sz := range m.Size { if len(body) == sz { hit = true break } } case "word": corpus := selectCorpus(m.Part, resp, body) hit = wordMatch(m, corpus) case "regex": corpus := selectCorpus(m.Part, resp, body) hit = regexMatch(m, corpus) } if m.Negative { return !hit } return hit } func selectCorpus(part string, resp *http.Response, body []byte) string { switch strings.ToLower(strings.TrimSpace(part)) { case "header": return formatHeaders(resp.Header) case "response", "all": return formatHeaders(resp.Header) + "\n\n" + string(body) case "body", "": return string(body) default: return string(body) } } func wordMatch(m Matcher, corpus string) bool { if len(m.Words) == 0 { return false } condition := strings.ToLower(strings.TrimSpace(m.Condition)) if condition == "" { condition = "or" } lower := strings.ToLower(corpus) if condition == "and" { for _, w := range m.Words { if !strings.Contains(lower, strings.ToLower(w)) { return false } } return true } // or for _, w := range m.Words { if strings.Contains(lower, strings.ToLower(w)) { return true } } return false } func regexMatch(m Matcher, corpus string) bool { if len(m.Regex) == 0 { return false } condition := strings.ToLower(strings.TrimSpace(m.Condition)) if condition == "" { condition = "or" } compiled := make([]*regexp.Regexp, 0, len(m.Regex)) for _, pat := range m.Regex { re, err := regexp.Compile(pat) if err != nil { continue } compiled = append(compiled, re) } if len(compiled) == 0 { return false } if condition == "and" { for _, re := range compiled { if !re.MatchString(corpus) { return false } } return true } for _, re := range compiled { if re.MatchString(corpus) { return true } } return false } // --- helpers ------------------------------------------------------------- // expandPath substitutes Nuclei template variables with real values. // {{BaseURL}} → baseURL unchanged ("https://example.com") // {{Hostname}} → host portion of baseURL // {{RootURL}} → scheme + host (no path) func expandPath(template, baseURL string) string { host := hostOnly(baseURL) root := rootURL(baseURL) out := strings.ReplaceAll(template, "{{BaseURL}}", baseURL) out = strings.ReplaceAll(out, "{{Hostname}}", host) out = strings.ReplaceAll(out, "{{RootURL}}", root) return out } func hostOnly(u string) string { s := strings.TrimPrefix(u, "https://") s = strings.TrimPrefix(s, "http://") if i := strings.IndexAny(s, "/?#"); i >= 0 { s = s[:i] } return s } func rootURL(u string) string { s := u scheme := "" switch { case strings.HasPrefix(s, "https://"): scheme = "https://" s = s[len("https://"):] case strings.HasPrefix(s, "http://"): scheme = "http://" s = s[len("http://"):] } if i := strings.IndexAny(s, "/?#"); i >= 0 { s = s[:i] } return scheme + s } func formatHeaders(h http.Header) string { var sb strings.Builder for k, vs := range h { for _, v := range vs { fmt.Fprintf(&sb, "%s: %s\n", k, v) } } return sb.String() } func evidenceSnippet(body []byte, resp *http.Response) string { const maxSnippet = 500 s := string(body) if len(s) > maxSnippet { s = s[:maxSnippet] + "…" } return fmt.Sprintf("HTTP %d — %s", resp.StatusCode, s) } // firstRef returns the first URL in the reference list (usually the // nuclei-templates source or the advisory). func firstRef(refs []string) string { for _, r := range refs { r = strings.TrimSpace(r) if r != "" { return r } } return "" } // extractCVEs scans the template ID and references for CVE IDs. func extractCVEs(id string, refs []string) []string { re := regexp.MustCompile(`(?i)CVE-\d{4}-\d{4,7}`) seen := make(map[string]bool) var out []string add := func(s string) { for _, m := range re.FindAllString(s, -1) { up := strings.ToUpper(m) if !seen[up] { seen[up] = true out = append(out, up) } } } add(id) for _, r := range refs { add(r) } return out }