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

268 lines
6.1 KiB
Go

package store
import (
"context"
"sort"
"sync"
"time"
)
// MemoryStore is the default in-memory Store implementation. Thread-safe,
// suitable for single-process scans. Persistent backends (BoltDB for ASM /
// resume workflows) land in Fase 5; they will implement the same Store
// interface so callers need no changes.
type MemoryStore struct {
mu sync.RWMutex
hosts map[string]*Host
// perHostLocks serializes Upsert mutations per-host without blocking
// independent hosts. It's populated lazily and never cleared — the number
// of subdomains per scan is bounded (thousands, not millions).
perHostLocks map[string]*sync.Mutex
locksMu sync.Mutex
}
// NewMemoryStore creates an empty MemoryStore.
func NewMemoryStore() *MemoryStore {
return &MemoryStore{
hosts: make(map[string]*Host),
perHostLocks: make(map[string]*sync.Mutex),
}
}
// lockFor returns the mutex that protects mutations to subdomain, creating
// it lazily if needed.
func (s *MemoryStore) lockFor(subdomain string) *sync.Mutex {
s.locksMu.Lock()
defer s.locksMu.Unlock()
l, ok := s.perHostLocks[subdomain]
if !ok {
l = &sync.Mutex{}
s.perHostLocks[subdomain] = l
}
return l
}
// Upsert creates or updates the record for subdomain, invoking mutate under
// a per-host lock. Concurrent callers mutating different subdomains proceed
// in parallel; concurrent mutations of the same subdomain are serialized.
func (s *MemoryStore) Upsert(ctx context.Context, subdomain string, mutate func(*Host)) error {
if err := ctx.Err(); err != nil {
return err
}
if subdomain == "" {
return nil
}
hostLock := s.lockFor(subdomain)
hostLock.Lock()
defer hostLock.Unlock()
s.mu.Lock()
h, existed := s.hosts[subdomain]
if !existed {
h = &Host{
Subdomain: subdomain,
FirstSeen: time.Now(),
}
s.hosts[subdomain] = h
}
s.mu.Unlock()
if mutate != nil {
mutate(h)
}
h.LastUpdated = time.Now()
return nil
}
// Get returns a deep-enough copy of the record so the caller cannot
// accidentally mutate store state. Slice fields are copied; nested struct
// pointers (TLSFingerprint, Takeover) are shallow-copied — callers MUST treat
// the result as read-only.
func (s *MemoryStore) Get(ctx context.Context, subdomain string) (*Host, bool) {
s.mu.RLock()
h, ok := s.hosts[subdomain]
s.mu.RUnlock()
if !ok {
return nil, false
}
hostLock := s.lockFor(subdomain)
hostLock.Lock()
defer hostLock.Unlock()
return cloneHost(h), true
}
// All returns every host, sorted by subdomain. Each returned Host is a copy;
// mutations to the slice or its elements do not affect the store.
func (s *MemoryStore) All(ctx context.Context) []*Host {
s.mu.RLock()
names := make([]string, 0, len(s.hosts))
for name := range s.hosts {
names = append(names, name)
}
s.mu.RUnlock()
sort.Strings(names)
out := make([]*Host, 0, len(names))
for _, n := range names {
if h, ok := s.Get(ctx, n); ok {
out = append(out, h)
}
}
return out
}
// Count returns the number of hosts in the store.
func (s *MemoryStore) Count(ctx context.Context) int {
s.mu.RLock()
defer s.mu.RUnlock()
return len(s.hosts)
}
// Close is a no-op for MemoryStore; implemented to satisfy Store interface.
func (s *MemoryStore) Close() error { return nil }
// cloneHost returns a deep-enough copy that slice/map fields are detached.
func cloneHost(h *Host) *Host {
if h == nil {
return nil
}
c := *h
c.IPs = cloneStrings(h.IPs)
c.Technologies = cloneStrings(h.Technologies)
c.TLSAltNames = cloneStrings(h.TLSAltNames)
c.DiscoveredVia = cloneStrings(h.DiscoveredVia)
c.Ports = cloneInts(h.Ports)
c.Headers = cloneStringMap(h.Headers)
if h.TLSFingerprint != nil {
fp := *h.TLSFingerprint
fp.InternalHosts = cloneStrings(h.TLSFingerprint.InternalHosts)
c.TLSFingerprint = &fp
}
if h.Takeover != nil {
t := *h.Takeover
c.Takeover = &t
}
c.Vulnerabilities = cloneVulns(h.Vulnerabilities)
c.Secrets = cloneSecrets(h.Secrets)
c.CVEs = cloneCVEs(h.CVEs)
c.AIFindings = cloneAIFindings(h.AIFindings)
return &c
}
func cloneStrings(in []string) []string {
if len(in) == 0 {
return nil
}
out := make([]string, len(in))
copy(out, in)
return out
}
func cloneInts(in []int) []int {
if len(in) == 0 {
return nil
}
out := make([]int, len(in))
copy(out, in)
return out
}
func cloneStringMap(in map[string]string) map[string]string {
if len(in) == 0 {
return nil
}
out := make(map[string]string, len(in))
for k, v := range in {
out[k] = v
}
return out
}
func cloneVulns(in []Vulnerability) []Vulnerability {
if len(in) == 0 {
return nil
}
out := make([]Vulnerability, len(in))
for i, v := range in {
v.CVEs = cloneStrings(v.CVEs)
out[i] = v
}
return out
}
func cloneSecrets(in []Secret) []Secret {
if len(in) == 0 {
return nil
}
out := make([]Secret, len(in))
copy(out, in)
return out
}
func cloneCVEs(in []CVE) []CVE {
if len(in) == 0 {
return nil
}
out := make([]CVE, len(in))
copy(out, in)
return out
}
func cloneAIFindings(in []AIFinding) []AIFinding {
if len(in) == 0 {
return nil
}
out := make([]AIFinding, len(in))
for i, f := range in {
f.CVEs = cloneStrings(f.CVEs)
out[i] = f
}
return out
}
// AppendUnique helpers — exported for modules that want to append slice
// fields without introducing duplicates. Keeps mutation semantics in one place.
// AddDiscoveryMethod appends method to h.DiscoveredVia if not already present.
func AddDiscoveryMethod(h *Host, method string) {
for _, m := range h.DiscoveredVia {
if m == method {
return
}
}
h.DiscoveredVia = append(h.DiscoveredVia, method)
}
// AddIPs appends new IPs (dedup, in-place).
func AddIPs(h *Host, ips []string) {
seen := make(map[string]bool, len(h.IPs))
for _, ip := range h.IPs {
seen[ip] = true
}
for _, ip := range ips {
if ip == "" || seen[ip] {
continue
}
seen[ip] = true
h.IPs = append(h.IPs, ip)
}
}
// AddTechnologies appends new technologies (dedup, in-place).
func AddTechnologies(h *Host, tech []string) {
seen := make(map[string]bool, len(h.Technologies))
for _, t := range h.Technologies {
seen[t] = true
}
for _, t := range tech {
if t == "" || seen[t] {
continue
}
seen[t] = true
h.Technologies = append(h.Technologies, t)
}
}