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

465 lines
12 KiB
Go

// Package brief renders the end-of-scan AI-assisted executive brief.
//
// It's the last module to run in PhaseReporting. It reads:
// - every host from the store (for severity / takeover / CVE rollups)
// - every AIFinding published during the scan (anomalies, executive
// report, per-host agent output)
//
// Then prints a framed summary block to stdout with:
//
// ▸ Findings counted by severity
// ▸ Top exploitable chains (critical + CVE pairs)
// ▸ AI-generated executive summary (if ai.enabled)
// ▸ Recommended next actions
//
// Suppressed when cfg.silent or cfg.json is true so machine-readable
// modes stay clean.
package brief
import (
"context"
"fmt"
"sort"
"strings"
"sync"
"time"
"god-eye/internal/eventbus"
"god-eye/internal/module"
"god-eye/internal/output"
"god-eye/internal/store"
)
const ModuleName = "report.brief"
type briefModule struct {
aiFindings []eventbus.AIFinding
execReport string // last executive-report AIFinding seen
execReportAt time.Time
mu sync.Mutex
}
func Register() { module.Register(&briefModule{}) }
func (*briefModule) Name() string { return ModuleName }
func (*briefModule) Phase() module.Phase { return module.PhaseReporting }
func (*briefModule) Consumes() []eventbus.EventType { return []eventbus.EventType{eventbus.EventAIFinding} }
func (*briefModule) Produces() []eventbus.EventType { return nil }
// DefaultEnabled: brief renders whenever the scan completes with any
// findings. Silent/json modes are suppressed inline (not at selection
// time) so the module can still collect AIFindings for exports.
func (*briefModule) DefaultEnabled() bool { return true }
func (b *briefModule) Run(mctx module.Context) error {
// Subscribe to AIFinding events and stash them locally so we can
// build a richer summary than just reading the store (the store
// doesn't retain AIFindings tagged with agent name / confidence).
sub := mctx.Bus.Subscribe(eventbus.EventAIFinding, func(_ context.Context, e eventbus.Event) {
ev, ok := e.(eventbus.AIFinding)
if !ok {
return
}
b.mu.Lock()
defer b.mu.Unlock()
b.aiFindings = append(b.aiFindings, ev)
if ev.Agent == "report-writer" && ev.Description != "" {
b.execReport = ev.Description
b.execReportAt = ev.Meta().At
}
})
defer sub.Unsubscribe()
// Give the AI module a chance to publish its end-of-scan events.
// The AI module runs in PhaseAnalysis; we're in PhaseReporting so
// its ScanCompleted-triggered publishes have already fired by the
// time we get here. A small buffer avoids losing late events.
select {
case <-time.After(400 * time.Millisecond):
case <-mctx.Ctx.Done():
}
if mctx.Config.Bool("silent", false) || mctx.Config.Bool("json", false) {
return nil
}
hosts := mctx.Store.All(mctx.Ctx)
if len(hosts) == 0 {
return nil
}
// Drain store-persisted AIFindings — these were written by the AI
// module during PhaseAnalysis. Live events alone miss them because
// brief subscribes after PhaseAnalysis has already drained.
b.mu.Lock()
for _, h := range hosts {
for _, f := range h.AIFindings {
b.aiFindings = append(b.aiFindings, eventbus.AIFinding{
EventMeta: eventbus.EventMeta{At: f.FoundAt, Source: "ai.cascade", Target: h.Subdomain},
Subject: h.Subdomain,
Agent: f.Agent,
Model: f.Model,
Severity: eventbus.Severity(f.Severity),
Title: f.Title,
Description: f.Description,
Evidence: f.Evidence,
CVEs: f.CVEs,
OWASP: f.OWASP,
Confidence: f.Confidence,
})
if f.Agent == "report-writer" && f.Description != "" && (b.execReport == "" || f.FoundAt.After(b.execReportAt)) {
b.execReport = f.Description
b.execReportAt = f.FoundAt
}
}
}
b.mu.Unlock()
b.render(mctx, hosts)
return nil
}
func (b *briefModule) render(mctx module.Context, hosts []*store.Host) {
b.mu.Lock()
aiFindings := append([]eventbus.AIFinding(nil), b.aiFindings...)
execReport := b.execReport
b.mu.Unlock()
sevCounts := tallySeverities(hosts, aiFindings)
topChains := buildChains(hosts)
recs := buildRecommendations(hosts, aiFindings)
aiActivity := tallyAIAgents(aiFindings)
fmt.Println()
title := fmt.Sprintf(" AI SCAN BRIEF — %s ", mctx.Target)
fmt.Println(output.BoldCyan(boxTop(title)))
writeLine := func(text string) {
fmt.Println(output.BoldCyan("│ ") + text)
}
// Section: stats
writeLine(output.BoldWhite("Totals"))
writeLine(fmt.Sprintf(" %s %d %s %d %s %d",
output.Dim("Hosts:"), len(hosts),
output.Dim("Active:"), countActive(hosts),
output.Dim("AI findings:"), len(aiFindings),
))
writeLine("")
// Section: severity breakdown
writeLine(output.BoldWhite("Findings by severity"))
sevOrder := []string{"critical", "high", "medium", "low", "info"}
for _, s := range sevOrder {
n := sevCounts[s]
if n == 0 {
continue
}
badge := sevBadge(s)
writeLine(fmt.Sprintf(" %s %s %d", badge, padRight(s, 9), n))
}
if len(sevCounts) == 0 {
writeLine(output.Dim(" (no scored findings)"))
}
writeLine("")
// Section: top exploitable chains
if len(topChains) > 0 {
writeLine(output.BoldWhite("Top exploitable chains"))
for i, c := range topChains {
if i >= 5 {
break
}
writeLine(" " + output.BoldYellow("▸ ") + c)
}
writeLine("")
}
// Section: AI agent activity
if len(aiActivity) > 0 {
writeLine(output.BoldWhite("AI agents that contributed"))
// Stable order by count desc.
type agg struct {
agent string
n int
}
agents := make([]agg, 0, len(aiActivity))
for name, n := range aiActivity {
agents = append(agents, agg{name, n})
}
sort.Slice(agents, func(i, j int) bool { return agents[i].n > agents[j].n })
for _, a := range agents {
writeLine(fmt.Sprintf(" %s %s %s",
output.Cyan("•"),
padRight(a.agent, 20),
output.Dim(fmt.Sprintf("%d findings", a.n)),
))
}
writeLine("")
}
// Section: AI executive report (prose)
if strings.TrimSpace(execReport) != "" {
writeLine(output.BoldWhite("AI executive summary"))
for _, line := range wrapText(strings.TrimSpace(execReport), 74) {
writeLine(output.Dim(" ") + line)
}
writeLine("")
}
// Section: recommendations
if len(recs) > 0 {
writeLine(output.BoldWhite("Recommended next actions"))
for i, r := range recs {
if i >= 5 {
break
}
writeLine(fmt.Sprintf(" %s %s", output.Green(fmt.Sprintf("%d.", i+1)), r))
}
writeLine("")
}
fmt.Println(output.BoldCyan(boxBottom()))
fmt.Println()
}
// --- helpers -------------------------------------------------------------
func tallySeverities(hosts []*store.Host, aiFindings []eventbus.AIFinding) map[string]int {
out := map[string]int{}
for _, h := range hosts {
for _, v := range h.Vulnerabilities {
out[strings.ToLower(v.Severity)]++
}
for _, c := range h.CVEs {
out[strings.ToLower(c.Severity)]++
}
for _, s := range h.Secrets {
out[strings.ToLower(s.Severity)]++
}
if h.Takeover != nil {
out["high"]++
}
}
for _, f := range aiFindings {
out[strings.ToLower(string(f.Severity))]++
}
return out
}
func countActive(hosts []*store.Host) int {
n := 0
for _, h := range hosts {
if h.StatusCode >= 200 && h.StatusCode < 400 {
n++
}
}
return n
}
// buildChains surfaces the most dangerous combinations. Right now the
// heuristic is coarse: hosts with ≥2 high+ findings, or any host with a
// confirmed takeover candidate, or any host whose tech triggered a CVE.
func buildChains(hosts []*store.Host) []string {
var chains []string
type scored struct {
text string
score int
}
var ranked []scored
for _, h := range hosts {
score := 0
bits := []string{}
for _, v := range h.Vulnerabilities {
if strings.EqualFold(v.Severity, "critical") {
score += 10
bits = append(bits, v.Title)
} else if strings.EqualFold(v.Severity, "high") {
score += 5
bits = append(bits, v.Title)
}
}
if h.Takeover != nil {
score += 8
bits = append(bits, "takeover→"+h.Takeover.Service)
}
for _, c := range h.CVEs {
if strings.EqualFold(c.Severity, "critical") || strings.EqualFold(c.Severity, "high") {
score += 6
bits = append(bits, fmt.Sprintf("%s@%s→%s", c.Technology, c.Version, firstCVE(c.ID)))
}
}
if score == 0 {
continue
}
desc := h.Subdomain
if len(bits) > 0 {
desc += " " + output.Dim("— "+strings.Join(dedupShort(bits), " + "))
}
ranked = append(ranked, scored{desc, score})
}
sort.Slice(ranked, func(i, j int) bool { return ranked[i].score > ranked[j].score })
for _, r := range ranked {
chains = append(chains, r.text)
}
return chains
}
func buildRecommendations(hosts []*store.Host, aiFindings []eventbus.AIFinding) []string {
seen := map[string]struct{}{}
var out []string
add := func(s string) {
if _, ok := seen[s]; ok {
return
}
seen[s] = struct{}{}
out = append(out, s)
}
// Pattern: Apache version → upgrade recommendation
for _, h := range hosts {
for _, c := range h.CVEs {
if c.Technology != "" && c.Version != "" {
add(fmt.Sprintf("Patch %s %s → vendor latest (affects %s)", c.Technology, c.Version, h.Subdomain))
}
}
if h.Takeover != nil {
add(fmt.Sprintf("Verify CNAME on %s before external party claims %s", h.Subdomain, h.Takeover.Service))
}
for _, s := range h.Secrets {
add(fmt.Sprintf("Rotate %s found in %s", s.Kind, h.Subdomain))
}
for _, v := range h.Vulnerabilities {
if strings.EqualFold(v.Severity, "critical") {
add(fmt.Sprintf("Remediate critical: %s on %s", v.Title, h.Subdomain))
}
}
}
// AI-surfaced recommendations (anomalies)
for _, f := range aiFindings {
if f.Agent == "anomaly-detector" && f.Description != "" {
add("Investigate anomaly: " + trimLine(f.Description, 80))
}
}
return out
}
func tallyAIAgents(aiFindings []eventbus.AIFinding) map[string]int {
out := map[string]int{}
for _, f := range aiFindings {
agent := f.Agent
if agent == "" {
agent = "unknown"
}
out[agent]++
}
return out
}
// --- rendering primitives ------------------------------------------------
const boxWidth = 76
func boxTop(title string) string {
line := strings.Repeat("─", boxWidth)
if len(title) >= boxWidth-4 {
title = title[:boxWidth-4]
}
prefix := "┌── "
suffix := " " + strings.Repeat("─", boxWidth-len(prefix)-len(title)-1) + "┐"
_ = line
return prefix + title + suffix
}
func boxBottom() string {
return "└" + strings.Repeat("─", boxWidth) + "┘"
}
func padRight(s string, n int) string {
if len(s) >= n {
return s
}
return s + strings.Repeat(" ", n-len(s))
}
func wrapText(s string, width int) []string {
words := strings.Fields(s)
if len(words) == 0 {
return nil
}
var lines []string
var cur strings.Builder
for _, w := range words {
if cur.Len() == 0 {
cur.WriteString(w)
continue
}
if cur.Len()+1+len(w) > width {
lines = append(lines, cur.String())
cur.Reset()
cur.WriteString(w)
} else {
cur.WriteByte(' ')
cur.WriteString(w)
}
}
if cur.Len() > 0 {
lines = append(lines, cur.String())
}
return lines
}
func sevBadge(s string) string {
switch strings.ToLower(s) {
case "critical":
return output.BgRed(" CRIT ")
case "high":
return output.Red("[HIGH]")
case "medium":
return output.Yellow("[MED] ")
case "low":
return output.Blue("[LOW] ")
default:
return output.Dim("[INFO]")
}
}
func firstCVE(ids string) string {
if i := strings.IndexAny(ids, ",("); i > 0 {
return strings.TrimSpace(ids[:i])
}
return ids
}
func dedupShort(in []string) []string {
seen := map[string]struct{}{}
var out []string
for _, s := range in {
if _, ok := seen[s]; ok {
continue
}
seen[s] = struct{}{}
if len(s) > 40 {
s = s[:37] + "…"
}
out = append(out, s)
}
return out
}
func trimLine(s string, n int) string {
s = strings.TrimSpace(s)
if i := strings.Index(s, "\n"); i > 0 {
s = s[:i]
}
if len(s) > n {
s = s[:n-1] + "…"
}
return s
}