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

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
}