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.
362 lines
8.2 KiB
Go
362 lines
8.2 KiB
Go
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
|
|
}
|