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

230 lines
5.1 KiB
Go

package nucleitpl
import (
"os"
"path/filepath"
"testing"
)
const sampleSupported = `
id: test-basic-word-match
info:
name: Test Basic Word Match
author: vyntral
severity: high
description: Fires when response body contains 'phpinfo'
tags: exposure,php
reference:
- https://example.com/advisory/CVE-2021-12345
requests:
- method: GET
path:
- "{{BaseURL}}/phpinfo.php"
matchers:
- type: word
part: body
words:
- "PHP Version"
- type: status
status:
- 200
matchers-condition: and
`
const sampleUnsupportedDNS = `
id: test-dns
info:
name: Test DNS
severity: medium
dns:
- name: "{{FQDN}}"
type: TXT
matchers:
- type: word
words: ["v=spf"]
`
const sampleUnsupportedPayloads = `
id: test-payloads
info:
name: Test Payloads
severity: low
requests:
- method: GET
path:
- "{{BaseURL}}/{{word}}"
payloads:
word:
- admin
- backup
matchers:
- type: status
status: [200]
`
const sampleBadYAML = `
id: [unclosed
info:
name:
`
func writeTmp(t *testing.T, name, content string) string {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, name)
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
return path
}
func TestLoad_Supported(t *testing.T) {
path := writeTmp(t, "ok.yaml", sampleSupported)
tpl, err := Load(path)
if err != nil {
t.Fatal(err)
}
if tpl.ID != "test-basic-word-match" {
t.Errorf("ID = %q", tpl.ID)
}
if tpl.Info.Severity != "high" {
t.Errorf("Severity = %q", tpl.Info.Severity)
}
if len(tpl.Requests) != 1 {
t.Fatalf("Requests len = %d", len(tpl.Requests))
}
r := tpl.Requests[0]
if r.Path[0] != "{{BaseURL}}/phpinfo.php" {
t.Errorf("Path[0] = %q", r.Path[0])
}
if len(r.Matchers) != 2 {
t.Errorf("Matchers len = %d", len(r.Matchers))
}
if r.MatchersCondition != "and" {
t.Errorf("MatchersCondition = %q", r.MatchersCondition)
}
if ok, reason := tpl.IsSupported(); !ok {
t.Errorf("should be supported; reason=%q", reason)
}
if tags := tpl.Tags(); len(tags) != 2 || tags[0] != "exposure" {
t.Errorf("Tags = %v", tags)
}
}
func TestLoad_DNSUnsupported(t *testing.T) {
path := writeTmp(t, "dns.yaml", sampleUnsupportedDNS)
tpl, err := Load(path)
if err != nil {
t.Fatal(err)
}
ok, reason := tpl.IsSupported()
if ok {
t.Error("dns template should be unsupported")
}
if reason == "" {
t.Error("expected non-empty reason")
}
}
func TestLoad_PayloadsUnsupported(t *testing.T) {
path := writeTmp(t, "payloads.yaml", sampleUnsupportedPayloads)
tpl, err := Load(path)
if err != nil {
t.Fatal(err)
}
ok, reason := tpl.IsSupported()
if ok {
t.Error("payloads template should be unsupported")
}
if reason == "" {
t.Error("expected non-empty reason")
}
}
func TestLoad_BadYAML(t *testing.T) {
path := writeTmp(t, "bad.yaml", sampleBadYAML)
if _, err := Load(path); err == nil {
t.Error("expected parse error")
}
}
func TestLoad_MissingID(t *testing.T) {
path := writeTmp(t, "noid.yaml", "info:\n severity: low\n")
if _, err := Load(path); err == nil {
t.Error("expected missing id error")
}
}
func TestLoadDir(t *testing.T) {
dir := t.TempDir()
_ = os.WriteFile(filepath.Join(dir, "a.yaml"), []byte(sampleSupported), 0o644)
_ = os.WriteFile(filepath.Join(dir, "b.yaml"), []byte(sampleUnsupportedDNS), 0o644)
_ = os.WriteFile(filepath.Join(dir, "c.yml"), []byte(sampleSupported), 0o644)
_ = os.WriteFile(filepath.Join(dir, "d.bad"), []byte("???"), 0o644)
_ = os.WriteFile(filepath.Join(dir, "e.yaml"), []byte(sampleBadYAML), 0o644)
sub := filepath.Join(dir, "nested")
_ = os.MkdirAll(sub, 0o755)
_ = os.WriteFile(filepath.Join(sub, "f.yaml"), []byte(sampleSupported), 0o644)
tpls, diags, err := LoadDir(dir)
if err != nil {
t.Fatal(err)
}
// 3 supported (a, c, nested/f), 1 dns (b), 1 parse error (e). .bad ignored.
if got := len(tpls); got != 4 {
t.Errorf("loaded = %d, want 4 (3 supported + 1 dns)", got)
}
if len(diags) != 1 {
t.Errorf("diags = %d, want 1", len(diags))
}
}
func TestSeverity_Default(t *testing.T) {
tpl := &Template{Info: Info{Severity: "UNKNOWN"}}
if sev := tpl.Severity(); sev != "info" {
t.Errorf("got %q, want info", sev)
}
}
func TestSeverity_Normalized(t *testing.T) {
for input, want := range map[string]string{
"critical": "critical",
"HIGH": "high",
" Medium ": "medium",
"LOW": "low",
"info": "info",
"": "info",
} {
tpl := &Template{Info: Info{Severity: input}}
if got := tpl.Severity(); got != want {
t.Errorf("Severity(%q) = %q, want %q", input, got, want)
}
}
}
func TestHTTPAlias(t *testing.T) {
content := `
id: http-alias
info:
severity: low
http:
- method: GET
path: ["{{BaseURL}}/"]
matchers:
- type: status
status: [200]
`
path := writeTmp(t, "http.yaml", content)
tpl, err := Load(path)
if err != nil {
t.Fatal(err)
}
if len(tpl.Requests) != 1 {
t.Errorf("expected http: to be aliased to Requests, got %d", len(tpl.Requests))
}
if ok, _ := tpl.IsSupported(); !ok {
t.Error("http alias template should be supported")
}
}