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.
465 lines
12 KiB
Go
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
|
|
}
|