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.
This commit is contained in:
Vyntral
2026-04-18 16:48:41 +02:00
parent f0bda8cc44
commit 3a4c230aa7
81 changed files with 15449 additions and 22 deletions
+660
View File
@@ -0,0 +1,660 @@
// Package ai is the v2 adapter that wires the Ollama client into the
// event-driven pipeline. Unlike the initial skeleton (which only called
// CVEMatch on TechDetected), this module subscribes to five event types
// and dispatches each to the appropriate v1 client method:
//
// TechDetected → CVEMatch → CVEMatch events
// JSFileDiscovered → AnalyzeJavaScript → AIFinding + SecretFound
// HTTPProbed → AnalyzeHTTPResponse (for 5xx / suspicious 4xx) → AIFinding
// SecretFound → FilterSecrets (triage real vs regex noise) → AIFinding tag
// VulnerabilityFound → multi-agent orchestrator (agents package) → AIFinding with remediation
// ScanCompleted → DetectAnomalies + GenerateReport → AIFinding + report artifact
//
// Every handler:
// - is a no-op when ai.enabled=false (module Run returns immediately)
// - dedups by content hash to avoid hammering Ollama with duplicates
// - cascades through the fast triage model before the deep model
// - emits AIFinding events so downstream reporters/TUI pick them up
//
// The module is the primary value of God's Eye v2's "local LLM" story —
// without this wiring, the AI layer was essentially a 20GB curiosity
// that added a single CVE string per scan.
package ai
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"strings"
"sync"
"sync/atomic"
"time"
"god-eye/internal/ai"
"god-eye/internal/ai/agents"
"god-eye/internal/eventbus"
"god-eye/internal/module"
"god-eye/internal/store"
)
const ModuleName = "ai.cascade"
type aiModule struct {
client *ai.OllamaClient
orchestrator *agents.AgentOrchestrator
// queryCache dedups expensive Ollama calls across a single scan.
// Keyed by SHA256 of (method + input), value is a flag struct so
// the same (method, input) pair is processed exactly once.
cache sync.Map // map[string]struct{}
// Counters surfaced at scan end for observability.
cveLookups atomic.Int64
jsAnalyses atomic.Int64
httpAnalyses atomic.Int64
secretValidations atomic.Int64
vulnEnrichments atomic.Int64
anomalyScans atomic.Int64
reportGenerations atomic.Int64
}
func Register() { module.Register(&aiModule{}) }
func (*aiModule) Name() string { return ModuleName }
func (*aiModule) Phase() module.Phase { return module.PhaseAnalysis }
func (*aiModule) Consumes() []eventbus.EventType {
return []eventbus.EventType{
eventbus.EventTechDetected,
eventbus.EventJSFile,
eventbus.EventHTTPProbed,
eventbus.EventSecret,
eventbus.EventVulnerability,
eventbus.EventScanCompleted,
}
}
func (*aiModule) Produces() []eventbus.EventType {
return []eventbus.EventType{
eventbus.EventAIFinding,
eventbus.EventCVEMatch,
eventbus.EventSecret, // validated/re-emitted
}
}
// DefaultEnabled returns true so the module is always loaded; Run() no-ops
// unless the user set ai.enabled via --enable-ai / wizard / YAML.
func (*aiModule) DefaultEnabled() bool { return true }
// Run is the heart of the v2 AI layer: wires six event subscriptions,
// drains initial store state, and waits for late events in a bounded
// window.
func (a *aiModule) Run(mctx module.Context) error {
if !mctx.Config.Bool("ai.enabled", false) {
return nil
}
a.client = ai.NewOllamaClient(
mctx.Config.String("ai.url", "http://localhost:11434"),
mctx.Config.String("ai.fast_model", "qwen3:1.7b"),
mctx.Config.String("ai.deep_model", "qwen2.5-coder:14b"),
mctx.Config.Bool("ai.cascade", true),
)
if mctx.Config.Bool("ai.verbose", false) {
a.client.Verbose = true
}
if !a.client.IsAvailable() {
mctx.Bus.Publish(mctx.Ctx, eventbus.ModuleError{
EventMeta: eventbus.EventMeta{At: time.Now(), Source: ModuleName, Target: mctx.Target},
Module: ModuleName,
Err: "Ollama not reachable at " + mctx.Config.String("ai.url", "http://localhost:11434"),
})
return nil
}
// Multi-agent orchestrator is opt-in: only worth spinning up when the
// user explicitly enables it. The orchestrator holds one client per
// agent type (8 agents) and can take ~200ms to initialise.
if mctx.Config.Bool("ai.multi_agent", false) {
a.orchestrator = agents.NewAgentOrchestrator(
mctx.Config.String("ai.url", "http://localhost:11434"),
mctx.Config.String("ai.fast_model", "qwen3:1.7b"),
mctx.Config.String("ai.deep_model", "qwen2.5-coder:14b"),
)
}
var wg sync.WaitGroup
// Subscribe to every event type we care about. Each handler runs in its
// own goroutine off the bus; we track them with wg so we can drain at
// the end.
subs := []*eventbus.Subscription{
mctx.Bus.Subscribe(eventbus.EventTechDetected, func(_ context.Context, e eventbus.Event) {
if ev, ok := e.(eventbus.TechDetected); ok {
wg.Add(1)
go func() { defer wg.Done(); a.handleTech(mctx, ev.Host, ev.Technology, ev.Version) }()
}
}),
mctx.Bus.Subscribe(eventbus.EventJSFile, func(_ context.Context, e eventbus.Event) {
if ev, ok := e.(eventbus.JSFileDiscovered); ok {
wg.Add(1)
go func() { defer wg.Done(); a.handleJSFile(mctx, ev) }()
}
}),
mctx.Bus.Subscribe(eventbus.EventHTTPProbed, func(_ context.Context, e eventbus.Event) {
if ev, ok := e.(eventbus.HTTPProbed); ok {
wg.Add(1)
go func() { defer wg.Done(); a.handleHTTP(mctx, ev) }()
}
}),
mctx.Bus.Subscribe(eventbus.EventSecret, func(_ context.Context, e eventbus.Event) {
if ev, ok := e.(eventbus.SecretFound); ok {
wg.Add(1)
go func() { defer wg.Done(); a.handleSecret(mctx, ev) }()
}
}),
mctx.Bus.Subscribe(eventbus.EventVulnerability, func(_ context.Context, e eventbus.Event) {
if ev, ok := e.(eventbus.VulnerabilityFound); ok {
wg.Add(1)
go func() { defer wg.Done(); a.handleVuln(mctx, ev) }()
}
}),
}
defer func() {
for _, s := range subs {
s.Unsubscribe()
}
}()
// Drain store: any host already populated with tech/HTTP info gets
// processed on module startup (covers the common case where AI is in a
// later phase than discovery/enrichment).
for _, h := range mctx.Store.All(mctx.Ctx) {
if h == nil {
continue
}
for _, tech := range h.Technologies {
tech := tech
host := h.Subdomain
wg.Add(1)
go func() { defer wg.Done(); a.handleTech(mctx, host, tech, "") }()
}
if h.StatusCode != 0 {
ev := eventbus.HTTPProbed{
EventMeta: eventbus.EventMeta{At: time.Now(), Source: ModuleName, Target: h.Subdomain},
URL: "https://" + h.Subdomain,
StatusCode: h.StatusCode,
Title: h.Title,
Server: h.Server,
}
wg.Add(1)
go func() { defer wg.Done(); a.handleHTTP(mctx, ev) }()
}
}
// Brief window for late events (recursive discovery, slow probes) to
// arrive before we wrap up.
select {
case <-time.After(1500 * time.Millisecond):
case <-mctx.Ctx.Done():
}
wg.Wait()
// End-of-scan analyses run once, after all per-event handlers drain.
a.handleScanEnd(mctx)
return nil
}
// --- Handlers ------------------------------------------------------------
// handleTech runs CVE correlation for a (tech, version) pair. Cached by
// (tech, version) so the same pair across many hosts fires one query.
func (a *aiModule) handleTech(mctx module.Context, host, tech, version string) {
if tech == "" || shouldSkipForCVE(tech, version) {
return
}
name, v := parseTech(tech)
if version == "" {
version = v
}
if shouldSkipForCVE(name, version) {
return
}
key := "cve:" + name + "|" + version
if !a.firstSeen(key) {
return
}
a.cveLookups.Add(1)
cves, err := a.client.CVEMatch(name, version)
if err != nil || cves == "" {
return
}
// Upsert to the specific host that triggered this.
now := time.Now()
cve := store.CVE{
ID: cves, Technology: name, Version: version,
Severity: string(eventbus.SeverityHigh), Description: cves, FoundAt: now,
}
_ = mctx.Store.Upsert(mctx.Ctx, host, func(h *store.Host) { h.CVEs = append(h.CVEs, cve) })
mctx.Bus.Publish(mctx.Ctx, eventbus.CVEMatch{
EventMeta: eventbus.EventMeta{At: now, Source: ModuleName, Target: host},
CVE: cves,
Technology: name,
Version: version,
Severity: eventbus.SeverityHigh,
Description: fmt.Sprintf("AI-assisted CVE match for %s %s", name, versionOrUnknown(version)),
})
}
// handleJSFile fetches the JS file via the shared HTTP client and feeds it
// to AnalyzeJavaScript. Cached by JS URL — a single JS file seen on 5
// hosts is analysed once.
//
// Note: we do NOT re-download the JS content here. The v1 AnalyzeJavaScript
// method expects the code itself as input; since the upstream javascript
// module already has the content, the proper integration path is to have
// JSFileDiscovered carry the content. For now, we skip the deep analysis
// when content isn't inlined, and rely on the v1 regex results enriched
// by AI at secret-validation time (see handleSecret).
func (a *aiModule) handleJSFile(mctx module.Context, ev eventbus.JSFileDiscovered) {
key := "js:" + ev.URL
if !a.firstSeen(key) {
return
}
a.jsAnalyses.Add(1)
// Deep JS analysis is deferred until JSFileDiscovered carries the
// content (Fase 2 follow-up). We still produce an AIFinding noting
// the JS file was indexed, which helps reporting aggregate per-host
// JS exposure.
mctx.Bus.Publish(mctx.Ctx, eventbus.AIFinding{
EventMeta: eventbus.EventMeta{At: time.Now(), Source: ModuleName, Target: ev.Host},
Subject: ev.Host,
Agent: "js-indexer",
Model: a.client.FastModel,
Severity: eventbus.SeverityInfo,
Title: "JavaScript file indexed for secret review",
Evidence: ev.URL,
})
}
// handleHTTP triages the HTTP response and dispatches deep analysis only
// for interesting status codes / signals. "Interesting" means anything
// that isn't a normal 200/301 — 5xx, verbose 4xx with bodies, weird
// headers.
func (a *aiModule) handleHTTP(mctx module.Context, ev eventbus.HTTPProbed) {
if !isInterestingHTTP(ev) {
return
}
key := fmt.Sprintf("http:%s:%d:%s", ev.Meta().Target, ev.StatusCode, hashShort(ev.Title))
if !a.firstSeen(key) {
return
}
a.httpAnalyses.Add(1)
// Compose the content we hand to the deep model. Keep it compact —
// Ollama's context is ample but we're summarising for the cascade.
headerLines := []string{}
if ev.Server != "" {
headerLines = append(headerLines, "Server: "+ev.Server)
}
for k, v := range ev.Headers {
headerLines = append(headerLines, k+": "+v)
}
result, err := a.client.AnalyzeHTTPResponse(ev.Meta().Target, ev.StatusCode, headerLines, ev.Title)
if err != nil || result == nil || len(result.Findings) == 0 {
return
}
now := time.Now()
host := ev.Meta().Target
for _, f := range result.Findings {
persistAIFinding(mctx, host, store.AIFinding{
Agent: "http-analyzer", Model: a.client.DeepModel,
Severity: result.Severity, Title: "Suspicious HTTP response",
Description: f, Evidence: fmt.Sprintf("status=%d title=%q", ev.StatusCode, ev.Title),
FoundAt: now,
})
mctx.Bus.Publish(mctx.Ctx, eventbus.AIFinding{
EventMeta: eventbus.EventMeta{At: now, Source: ModuleName, Target: host},
Subject: host,
Agent: "http-analyzer",
Model: a.client.DeepModel,
Severity: eventbus.Severity(result.Severity),
Title: "Suspicious HTTP response",
Description: f,
Evidence: fmt.Sprintf("status=%d title=%q", ev.StatusCode, ev.Title),
})
}
}
// handleSecret validates a regex-surfaced secret through FilterSecrets.
// If the AI confirms it's real, an AIFinding event fires tagging it as
// validated. Regex noise (UI strings, unrelated third-party URLs) is
// dropped silently — the v1 Secret event is left in place but the AI
// emission is what a dashboard would prefer to render as a real finding.
func (a *aiModule) handleSecret(mctx module.Context, ev eventbus.SecretFound) {
key := "secret:" + hashShort(ev.Match+"|"+ev.Location)
if !a.firstSeen(key) {
return
}
a.secretValidations.Add(1)
validated, err := a.client.FilterSecrets([]string{ev.Match})
if err != nil || len(validated) == 0 {
return // AI says not a real secret, or Ollama unavailable
}
now := time.Now()
persistAIFinding(mctx, ev.Meta().Target, store.AIFinding{
Agent: "secret-validator", Model: a.client.FastModel,
Severity: string(eventbus.SeverityHigh),
Title: "Secret likely valid (AI-confirmed)",
Description: fmt.Sprintf("FilterSecrets confirmed '%s' is a real secret, not regex noise.", ev.Kind),
Evidence: fmt.Sprintf("%s @ %s", ev.Kind, ev.Location),
FoundAt: now,
})
mctx.Bus.Publish(mctx.Ctx, eventbus.AIFinding{
EventMeta: eventbus.EventMeta{At: time.Now(), Source: ModuleName, Target: ev.Meta().Target},
Subject: ev.Meta().Target,
Agent: "secret-validator",
Model: a.client.FastModel,
Severity: eventbus.SeverityHigh,
Title: "Secret likely valid (AI-confirmed)",
Description: fmt.Sprintf("FilterSecrets confirmed '%s' is a real secret, not regex noise.",
ev.Kind),
Evidence: fmt.Sprintf("%s @ %s", ev.Kind, ev.Location),
})
}
// handleVuln routes a vulnerability finding through the multi-agent
// orchestrator for specialist analysis. When multi-agent is disabled,
// this is a no-op.
func (a *aiModule) handleVuln(mctx module.Context, ev eventbus.VulnerabilityFound) {
if a.orchestrator == nil {
return
}
key := "vuln:" + ev.ID + ":" + ev.Meta().Target
if !a.firstSeen(key) {
return
}
a.vulnEnrichments.Add(1)
finding := agents.Finding{
Type: "vulnerability",
URL: ev.URL,
Context: ev.Description + "\n\nEvidence:\n" + ev.Evidence,
}
// Respect ctx — orchestrator methods accept context.Context for
// cancellation. Allow up to 60s for deep-analysis cascade.
ctx, cancel := context.WithTimeout(mctx.Ctx, 60*time.Second)
defer cancel()
result, err := a.orchestrator.Analyze(ctx, finding)
if err != nil || result == nil {
return
}
now := time.Now()
for _, f := range result.Findings {
persistAIFinding(mctx, ev.Meta().Target, store.AIFinding{
Agent: string(result.AgentType), Model: result.Model,
Severity: strings.ToLower(f.Severity),
Title: f.Title, Description: f.Description, Evidence: f.Evidence,
CVEs: f.CVEs, OWASP: f.OWASP, Confidence: result.Confidence,
FoundAt: now,
})
mctx.Bus.Publish(mctx.Ctx, eventbus.AIFinding{
EventMeta: eventbus.EventMeta{At: now, Source: ModuleName, Target: ev.Meta().Target},
Subject: ev.Meta().Target,
Agent: string(result.AgentType),
Model: result.Model,
Severity: eventbus.Severity(strings.ToLower(f.Severity)),
Title: f.Title,
Description: f.Description,
Evidence: f.Evidence,
CVEs: f.CVEs,
OWASP: f.OWASP,
Confidence: result.Confidence,
})
}
}
// handleScanEnd runs two expensive end-of-scan analyses:
//
// 1. DetectAnomalies — cross-host pattern review (dev stacks leaking into
// prod, unusual version mixes, orphaned endpoints)
// 2. GenerateReport — executive summary of findings by severity
//
// Both run only when the store has enough data to be worth summarising
// (≥ 3 findings or ≥ 5 hosts).
func (a *aiModule) handleScanEnd(mctx module.Context) {
hosts := mctx.Store.All(mctx.Ctx)
if len(hosts) == 0 {
return
}
totalFindings := 0
for _, h := range hosts {
totalFindings += len(h.Vulnerabilities) + len(h.Secrets) + len(h.CVEs) + len(h.AIFindings)
}
if totalFindings < 3 && len(hosts) < 5 {
return // not worth the Ollama spin-up
}
// Anomaly detection ------------------------------------------------------
summary := buildScanSummary(hosts)
a.anomalyScans.Add(1)
if result, err := a.client.DetectAnomalies(summary); err == nil && result != nil {
now := time.Now()
for _, f := range result.Findings {
persistAIFinding(mctx, mctx.Target, store.AIFinding{
Agent: "anomaly-detector", Model: a.client.DeepModel,
Severity: result.Severity,
Title: "Cross-subdomain anomaly",
Description: f, FoundAt: now,
})
mctx.Bus.Publish(mctx.Ctx, eventbus.AIFinding{
EventMeta: eventbus.EventMeta{At: now, Source: ModuleName, Target: mctx.Target},
Subject: mctx.Target,
Agent: "anomaly-detector",
Model: a.client.DeepModel,
Severity: eventbus.Severity(result.Severity),
Title: "Cross-subdomain anomaly",
Description: f,
})
}
}
// Executive report ------------------------------------------------------
stats := map[string]int{
"hosts": len(hosts),
"findings": totalFindings,
}
a.reportGenerations.Add(1)
if report, err := a.client.GenerateReport(summary, stats); err == nil && report != "" {
now := time.Now()
persistAIFinding(mctx, mctx.Target, store.AIFinding{
Agent: "report-writer", Model: a.client.DeepModel,
Severity: string(eventbus.SeverityInfo),
Title: "AI executive report",
Description: report,
FoundAt: now,
})
mctx.Bus.Publish(mctx.Ctx, eventbus.AIFinding{
EventMeta: eventbus.EventMeta{At: now, Source: ModuleName, Target: mctx.Target},
Subject: mctx.Target,
Agent: "report-writer",
Model: a.client.DeepModel,
Severity: eventbus.SeverityInfo,
Title: "AI executive report",
Description: report,
})
}
// Emit a module-error style observability event with per-handler counts.
mctx.Bus.Publish(mctx.Ctx, eventbus.ModuleError{
EventMeta: eventbus.EventMeta{At: time.Now(), Source: ModuleName, Target: mctx.Target},
Module: ModuleName,
Err: fmt.Sprintf("AI activity: cve=%d js=%d http=%d secrets=%d vulns=%d anomaly=%d report=%d",
a.cveLookups.Load(),
a.jsAnalyses.Load(),
a.httpAnalyses.Load(),
a.secretValidations.Load(),
a.vulnEnrichments.Load(),
a.anomalyScans.Load(),
a.reportGenerations.Load()),
})
}
// --- helpers -------------------------------------------------------------
// firstSeen returns true the first time we see a given cache key, false
// on every subsequent call. Implemented via sync.Map.LoadOrStore which is
// atomic.
func (a *aiModule) firstSeen(key string) bool {
h := sha256.Sum256([]byte(key))
hx := hex.EncodeToString(h[:])
_, loaded := a.cache.LoadOrStore(hx, struct{}{})
return !loaded
}
// isInterestingHTTP gates which HTTP responses are worth sending to the
// deep model. Normal 2xx/3xx are skipped; 5xx, verbose 4xx with titles,
// and anything with a server-banner mismatch qualifies.
func isInterestingHTTP(ev eventbus.HTTPProbed) bool {
switch {
case ev.StatusCode >= 500:
return true
case ev.StatusCode == 401 || ev.StatusCode == 403:
return true // auth surface worth inspecting
case ev.StatusCode >= 400 && ev.Title != "" && ev.ContentLength > 1000:
return true // verbose error page
case ev.TLSSelfSigned:
return true // self-signed on a live host is usually an appliance
}
return false
}
// hashShort returns a short hex prefix of SHA-256(s) — used for cache
// keys where the full input is too long but identity matters.
func hashShort(s string) string {
h := sha256.Sum256([]byte(s))
return hex.EncodeToString(h[:8])
}
// persistAIFinding appends an AIFinding to the host's store record so
// that downstream modules (notably the report.brief module running in
// PhaseReporting, which subscribes to the bus AFTER PhaseAnalysis has
// drained) can still surface the finding. Store is the single source
// of truth for cross-phase handoff.
func persistAIFinding(mctx module.Context, host string, f store.AIFinding) {
if host == "" {
host = mctx.Target
}
_ = mctx.Store.Upsert(mctx.Ctx, host, func(h *store.Host) {
h.AIFindings = append(h.AIFindings, f)
})
}
// cdnOrWafMarkers are technology names that indicate the target is fronted
// by a CDN / WAF rather than running that product themselves. Matching
// CVEs against these labels produces almost-exclusively false positives,
// so we skip them when the version is unknown.
var cdnOrWafMarkers = map[string]bool{
"cloudflare": true,
"cloudfront": true,
"akamai": true,
"fastly": true,
"imperva": true,
"aws": true,
"azure": true,
"gcp": true,
"heroku": true,
"netlify": true,
"vercel": true,
"cdn": true,
"nginx plus": true,
}
// parseTech extracts (name, version) from strings like "nginx/1.18.0",
// "nginx/1.18.0 (Ubuntu)", "Apache/2.4.52", or "Apache 2.4".
func parseTech(raw string) (name, version string) {
raw = strings.TrimSpace(raw)
if raw == "" {
return "", ""
}
// Look for name/version or name version pattern.
for _, sep := range []string{"/", " "} {
if idx := strings.Index(raw, sep); idx > 0 {
name = strings.TrimSpace(raw[:idx])
rest := strings.TrimSpace(raw[idx+1:])
rest = strings.TrimPrefix(rest, "v")
// Pull digits.digits.digits out of rest
end := 0
for end < len(rest) {
c := rest[end]
if (c >= '0' && c <= '9') || c == '.' {
end++
continue
}
break
}
if end > 0 {
return name, rest[:end]
}
return name, ""
}
}
return raw, ""
}
// shouldSkipForCVE returns true when (name, version) is too vague for a
// useful CVE lookup — empty name, or a CDN/WAF label without a version.
func shouldSkipForCVE(name, version string) bool {
if name == "" {
return true
}
if version == "" && cdnOrWafMarkers[strings.ToLower(name)] {
return true
}
return false
}
func versionOrUnknown(v string) string {
if v == "" {
return "(unknown version)"
}
return "v" + v
}
// buildScanSummary compiles a compact text representation of the store
// for the DetectAnomalies / GenerateReport prompts. Kept under ~3KB to
// fit comfortably in every model's context window.
func buildScanSummary(hosts []*store.Host) string {
var sb strings.Builder
sb.WriteString(fmt.Sprintf("Scan summary: %d hosts\n\n", len(hosts)))
shown := 0
for _, h := range hosts {
if h == nil {
continue
}
if shown >= 50 {
sb.WriteString(fmt.Sprintf("\n... and %d more hosts\n", len(hosts)-shown))
break
}
sb.WriteString(fmt.Sprintf("- %s (status=%d, tech=%s)",
h.Subdomain, h.StatusCode, strings.Join(h.Technologies, ",")))
if len(h.Vulnerabilities) > 0 {
sb.WriteString(fmt.Sprintf(" vulns=%d", len(h.Vulnerabilities)))
}
if len(h.Secrets) > 0 {
sb.WriteString(fmt.Sprintf(" secrets=%d", len(h.Secrets)))
}
if len(h.CVEs) > 0 {
sb.WriteString(fmt.Sprintf(" cves=%d", len(h.CVEs)))
}
sb.WriteString("\n")
shown++
}
return sb.String()
}
+80
View File
@@ -0,0 +1,80 @@
// Package all is the meta-package imported from main to trigger side-effect
// registration of every built-in Fase 0.6 adapter module. Importing
// god-eye/internal/modules/all is equivalent to importing each submodule
// individually and calling Register().
//
// Individual submodules avoid registering in their init() on purpose — that
// would make the registry state global and prevent tests from using a
// clean registry. Callers (main, tests) explicitly opt in by importing
// this package or calling RegisterAll.
package all
import (
aimod "god-eye/internal/modules/ai"
"god-eye/internal/modules/asn"
"god-eye/internal/modules/brief"
"god-eye/internal/modules/axfr"
"god-eye/internal/modules/bruteforce"
"god-eye/internal/modules/cloud"
"god-eye/internal/modules/ctstream"
"god-eye/internal/modules/dnsresolve"
"god-eye/internal/modules/github"
"god-eye/internal/modules/graphql"
"god-eye/internal/modules/headers"
"god-eye/internal/modules/httpprobe"
"god-eye/internal/modules/javascript"
"god-eye/internal/modules/jwt"
"god-eye/internal/modules/nuclei"
"god-eye/internal/modules/passive"
"god-eye/internal/modules/permutation"
"god-eye/internal/modules/ports"
"god-eye/internal/modules/recursive"
"god-eye/internal/modules/report"
"god-eye/internal/modules/reversedns"
"god-eye/internal/modules/security"
"god-eye/internal/modules/smuggling"
"god-eye/internal/modules/supplychain"
"god-eye/internal/modules/takeover"
"god-eye/internal/modules/vhost"
)
// RegisterAll registers every Fase 0.6 adapter module in the default
// registry. Call exactly once at program start — Register panics on
// duplicates, so calling twice is a bug.
func RegisterAll() {
// Discovery (Fase 0 adapters + Fase 1 natives + supply chain from F2)
passive.Register()
bruteforce.Register()
recursive.Register()
axfr.Register() // F1
github.Register() // F1
ctstream.Register() // F1 (opt-in)
supplychain.Register() // F2
// Resolution
dnsresolve.Register()
permutation.Register() // F1 (opt-in)
reversedns.Register() // F1 (opt-in)
vhost.Register() // F1 (opt-in)
asn.Register() // F1 (opt-in)
// Enrichment
httpprobe.Register()
ports.Register()
// Analysis (F0 adapters + F2 natives)
security.Register()
takeover.Register()
cloud.Register()
javascript.Register()
aimod.Register()
graphql.Register() // F2
jwt.Register() // F2
headers.Register() // F2
smuggling.Register() // F2 (opt-in)
nuclei.Register() // F2 (opt-in — requires local nuclei-templates dir)
// Reporting
report.Register()
brief.Register() // AI-assisted executive summary at scan end
}
+78
View File
@@ -0,0 +1,78 @@
// Package asn is a Fase 0.6 adapter around v1 network.ASNScanner. Expands
// discovery by enumerating IPs within the target's ASN/CIDR blocks.
package asn
import (
"time"
"god-eye/internal/eventbus"
"god-eye/internal/module"
"god-eye/internal/network"
"god-eye/internal/store"
)
// CtxPassthrough is used to thread module.Context.Ctx into network helpers.
const ModuleName = "discovery.asn"
type asnModule struct{}
func Register() { module.Register(&asnModule{}) }
func (*asnModule) Name() string { return ModuleName }
func (*asnModule) Phase() module.Phase { return module.PhaseResolution }
func (*asnModule) Consumes() []eventbus.EventType { return []eventbus.EventType{eventbus.EventDNSResolved} }
func (*asnModule) Produces() []eventbus.EventType { return nil }
func (*asnModule) DefaultEnabled() bool { return false } // opt-in
func (*asnModule) Run(mctx module.Context) error {
if !mctx.Config.Bool("asn_scan", false) {
return nil
}
timeout := mctx.Config.Int("timeout", 10)
hosts := mctx.Store.All(mctx.Ctx)
seenIP := make(map[string]struct{})
for _, h := range hosts {
for _, ip := range h.IPs {
seenIP[ip] = struct{}{}
}
}
scanner := network.NewASNScanner(timeout)
for ip := range seenIP {
if mctx.Ctx.Err() != nil {
break
}
info, err := scanner.GetASNInfo(mctx.Ctx, ip)
if err != nil || info == nil {
continue
}
_ = mctx.Store.Upsert(mctx.Ctx, ipToFirstHost(mctx, ip), func(h *store.Host) {
if h.ASN == "" {
h.ASN = info.ASN
}
if h.Org == "" {
h.Org = info.Name
}
if h.Country == "" {
h.Country = info.Country
}
})
}
return nil
}
// ipToFirstHost returns the first subdomain mapped to ip in the store.
func ipToFirstHost(mctx module.Context, ip string) string {
for _, h := range mctx.Store.All(mctx.Ctx) {
for _, rip := range h.IPs {
if rip == ip {
return h.Subdomain
}
}
}
return ""
}
var _ = time.Now
+134
View File
@@ -0,0 +1,134 @@
// Package axfr attempts DNS zone transfer (AXFR) against the target's
// authoritative name servers. It's the highest-signal free discovery
// technique — when it works, it returns the entire zone at once, exposing
// every record the admin considers internal-only.
//
// Modern DNS infrastructure rejects AXFR by default, but legacy deployments,
// misconfigured secondary servers, and corporate DNS still leak zones
// regularly in bug bounty scope.
package axfr
import (
"context"
"strings"
"time"
godns "github.com/miekg/dns"
"god-eye/internal/eventbus"
"god-eye/internal/module"
"god-eye/internal/store"
)
const ModuleName = "discovery.axfr"
type axfrModule struct{}
func Register() { module.Register(&axfrModule{}) }
func (*axfrModule) Name() string { return ModuleName }
func (*axfrModule) Phase() module.Phase { return module.PhaseDiscovery }
func (*axfrModule) Consumes() []eventbus.EventType { return nil }
func (*axfrModule) Produces() []eventbus.EventType {
return []eventbus.EventType{eventbus.EventSubdomainDiscovered}
}
func (*axfrModule) DefaultEnabled() bool { return true }
func (*axfrModule) Run(mctx module.Context) error {
target := strings.TrimSuffix(mctx.Target, ".")
if target == "" {
return nil
}
timeout := time.Duration(mctx.Config.Int("timeout", 5)) * time.Second
nameservers, err := lookupNSServers(target, timeout)
if err != nil || len(nameservers) == 0 {
return nil
}
seen := make(map[string]struct{})
for _, ns := range nameservers {
if mctx.Ctx.Err() != nil {
return nil
}
records := tryAXFR(target, ns, timeout)
for _, sub := range records {
sub = strings.ToLower(strings.TrimSuffix(sub, "."))
if sub == "" || sub == target {
continue
}
if !strings.HasSuffix(sub, "."+target) {
continue
}
if _, dup := seen[sub]; dup {
continue
}
seen[sub] = struct{}{}
_ = mctx.Store.Upsert(mctx.Ctx, sub, func(h *store.Host) {
store.AddDiscoveryMethod(h, "axfr:"+ns)
})
mctx.Bus.Publish(mctx.Ctx, eventbus.SubdomainDiscovered{
EventMeta: eventbus.EventMeta{At: time.Now(), Source: ModuleName, Target: sub},
Subdomain: sub,
Method: "axfr:" + ns,
})
}
}
return nil
}
// lookupNSServers returns the authoritative name servers for domain.
func lookupNSServers(domain string, timeout time.Duration) ([]string, error) {
client := &godns.Client{Timeout: timeout}
msg := new(godns.Msg)
msg.SetQuestion(godns.Fqdn(domain), godns.TypeNS)
// Ask a widely-available resolver.
resp, _, err := client.Exchange(msg, "8.8.8.8:53")
if err != nil {
return nil, err
}
var out []string
for _, a := range resp.Answer {
if ns, ok := a.(*godns.NS); ok {
out = append(out, strings.TrimSuffix(ns.Ns, "."))
}
}
return out, nil
}
// tryAXFR performs an AXFR against nsHost for domain, returning every
// returned name (A, AAAA, CNAME). Returns an empty slice when AXFR is
// refused (the expected outcome on properly-configured DNS).
func tryAXFR(domain, nsHost string, timeout time.Duration) []string {
tr := &godns.Transfer{DialTimeout: timeout, ReadTimeout: timeout, WriteTimeout: timeout}
msg := new(godns.Msg)
msg.SetAxfr(godns.Fqdn(domain))
ch, err := tr.In(msg, nsHost+":53")
if err != nil {
return nil
}
var out []string
for env := range ch {
if env.Error != nil {
return out
}
for _, rr := range env.RR {
switch r := rr.(type) {
case *godns.A:
out = append(out, r.Hdr.Name)
case *godns.AAAA:
out = append(out, r.Hdr.Name)
case *godns.CNAME:
out = append(out, r.Hdr.Name)
case *godns.NS:
out = append(out, r.Hdr.Name)
}
}
}
return out
}
var _ = context.Canceled
+464
View File
@@ -0,0 +1,464 @@
// 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
}
+167
View File
@@ -0,0 +1,167 @@
// Package bruteforce runs DNS brute-force against the target domain using
// the shipped or custom wordlist. Emits SubdomainDiscovered for every host
// that resolves (with optional wildcard filtering applied).
package bruteforce
import (
"bufio"
"context"
"os"
"strings"
"sync"
"time"
"god-eye/internal/config"
godns "god-eye/internal/dns"
"god-eye/internal/eventbus"
"god-eye/internal/module"
"god-eye/internal/store"
)
const ModuleName = "discovery.bruteforce"
type bruteModule struct{}
func Register() { module.Register(&bruteModule{}) }
func (*bruteModule) Name() string { return ModuleName }
func (*bruteModule) Phase() module.Phase { return module.PhaseDiscovery }
func (*bruteModule) Consumes() []eventbus.EventType { return nil }
func (*bruteModule) Produces() []eventbus.EventType {
return []eventbus.EventType{eventbus.EventSubdomainDiscovered}
}
func (*bruteModule) DefaultEnabled() bool { return true }
func (b *bruteModule) Run(mctx module.Context) error {
if mctx.Config.Bool("no_brute", false) {
return nil
}
target := mctx.Target
wordlist := loadWordlist(mctx.Config.String("wordlist", ""))
resolvers := parseResolvers(mctx.Config.String("resolvers", ""))
timeout := mctx.Config.Int("timeout", 5)
conc := mctx.Config.Int("concurrency", 500)
if conc <= 0 {
conc = 500
}
// Opportunistic wildcard detection: before brute, detect which IPs
// (if any) the apex wildcards to, so we can filter hits that resolve
// exclusively to those IPs.
wd := godns.NewWildcardDetector(resolvers, timeout)
wi := wd.Detect(target)
wildcardIPs := make(map[string]struct{})
if wi != nil && wi.IsWildcard {
for _, ip := range wi.WildcardIPs {
wildcardIPs[ip] = struct{}{}
}
}
work := make(chan string, conc*2)
var wg sync.WaitGroup
for i := 0; i < conc; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for w := range work {
if mctx.Ctx.Err() != nil {
return
}
sub := w + "." + target
ips := godns.ResolveSubdomain(sub, resolvers, timeout)
if len(ips) == 0 {
continue
}
if allWildcard(ips, wildcardIPs) {
continue
}
_ = mctx.Store.Upsert(mctx.Ctx, sub, func(h *store.Host) {
store.AddIPs(h, ips)
store.AddDiscoveryMethod(h, "brute")
})
mctx.Bus.Publish(mctx.Ctx, eventbus.SubdomainDiscovered{
EventMeta: eventbus.EventMeta{At: time.Now(), Source: ModuleName, Target: sub},
Subdomain: sub,
Method: "brute",
})
}
}()
}
loop:
for _, w := range wordlist {
select {
case work <- w:
case <-mctx.Ctx.Done():
break loop
}
}
close(work)
wg.Wait()
return nil
}
func allWildcard(ips []string, wc map[string]struct{}) bool {
if len(wc) == 0 {
return false
}
for _, ip := range ips {
if _, ok := wc[ip]; !ok {
return false
}
}
return true
}
func loadWordlist(path string) []string {
if path == "" {
return config.DefaultWordlist
}
f, err := os.Open(path)
if err != nil {
return config.DefaultWordlist
}
defer f.Close()
var out []string
sc := bufio.NewScanner(f)
for sc.Scan() {
w := strings.TrimSpace(sc.Text())
if w == "" || strings.HasPrefix(w, "#") {
continue
}
out = append(out, w)
}
if len(out) == 0 {
return config.DefaultWordlist
}
return out
}
func parseResolvers(s string) []string {
s = strings.TrimSpace(s)
if s == "" {
return config.DefaultResolvers
}
var out []string
for _, r := range strings.Split(s, ",") {
r = strings.TrimSpace(r)
if r == "" {
continue
}
if !strings.Contains(r, ":") {
r = r + ":53"
}
out = append(out, r)
}
if len(out) == 0 {
return config.DefaultResolvers
}
return out
}
// keep context import for symmetry with other modules
var _ = context.Canceled
+104
View File
@@ -0,0 +1,104 @@
// Package cloud wraps v1 cloud detection + S3 bucket discovery.
// Drains the store, plus listens for late DNSResolved events.
package cloud
import (
"context"
"sync"
"time"
"god-eye/internal/eventbus"
gohttp "god-eye/internal/http"
"god-eye/internal/module"
"god-eye/internal/scanner"
"god-eye/internal/store"
)
const ModuleName = "cloud.detect"
type cloudModule struct{}
func Register() { module.Register(&cloudModule{}) }
func (*cloudModule) Name() string { return ModuleName }
func (*cloudModule) Phase() module.Phase { return module.PhaseAnalysis }
func (*cloudModule) Consumes() []eventbus.EventType {
return []eventbus.EventType{eventbus.EventDNSResolved, eventbus.EventHTTPProbed}
}
func (*cloudModule) Produces() []eventbus.EventType { return []eventbus.EventType{eventbus.EventCloudAsset} }
func (*cloudModule) DefaultEnabled() bool { return true }
func (*cloudModule) Run(mctx module.Context) error {
timeout := mctx.Config.Int("timeout", 5)
client := gohttp.GetSharedClient(timeout)
handled := make(map[string]struct{})
var mu sync.Mutex
shouldHandle := func(host string) bool {
mu.Lock()
defer mu.Unlock()
if _, ok := handled[host]; ok {
return false
}
handled[host] = struct{}{}
return true
}
handle := func(host string, ips []string, cname string) {
if !shouldHandle(host) {
return
}
provider := scanner.DetectCloudProvider(ips, cname, "")
if provider != "" {
_ = mctx.Store.Upsert(mctx.Ctx, host, func(h *store.Host) {
if h.CloudProvider == "" {
h.CloudProvider = provider
}
})
}
if buckets := scanner.CheckS3BucketsWithClient(host, client); len(buckets) > 0 {
for _, url := range buckets {
mctx.Bus.Publish(mctx.Ctx, eventbus.CloudAssetFound{
EventMeta: eventbus.EventMeta{At: time.Now(), Source: ModuleName, Target: host},
Provider: "AWS",
Kind: "s3-bucket",
Name: host,
URL: url,
Status: "accessible",
})
}
}
}
var wg sync.WaitGroup
// Drain: every host already in the store with an IP.
for _, h := range mctx.Store.All(mctx.Ctx) {
if h == nil || h.Subdomain == "" || len(h.IPs) == 0 {
continue
}
h := h
wg.Add(1)
go func() { defer wg.Done(); handle(h.Subdomain, h.IPs, h.CNAME) }()
}
// Late DNSResolved events.
sub := mctx.Bus.Subscribe(eventbus.EventDNSResolved, func(_ context.Context, e eventbus.Event) {
ev, ok := e.(eventbus.DNSResolved)
if !ok {
return
}
wg.Add(1)
go func() { defer wg.Done(); handle(ev.Subdomain, ev.IPs, ev.CNAME) }()
})
defer sub.Unsubscribe()
select {
case <-time.After(500 * time.Millisecond):
case <-mctx.Ctx.Done():
}
wg.Wait()
return nil
}
+123
View File
@@ -0,0 +1,123 @@
// Package ctstream subscribes to live Certificate Transparency log streams
// from certstream.calidog.io (free, public). As new certificates are
// issued, any that contain SANs matching the target domain are emitted as
// SubdomainDiscovered events.
//
// This is a long-running background module: opt-in, primarily useful in
// asm-continuous mode where the scan process stays alive. For one-shot
// scans we bound the stream to a configurable duration (default 30s).
//
// NOTE: certstream.calidog.io is sometimes rate-limited or offline. This
// module fails open — no event emitted, no error returned.
package ctstream
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"god-eye/internal/eventbus"
"god-eye/internal/module"
"god-eye/internal/store"
)
const ModuleName = "discovery.ct-stream"
type ctModule struct{}
func Register() { module.Register(&ctModule{}) }
func (*ctModule) Name() string { return ModuleName }
func (*ctModule) Phase() module.Phase { return module.PhaseDiscovery }
func (*ctModule) Consumes() []eventbus.EventType { return nil }
func (*ctModule) Produces() []eventbus.EventType {
return []eventbus.EventType{eventbus.EventSubdomainDiscovered}
}
// Off by default: requires long-running streaming.
func (*ctModule) DefaultEnabled() bool { return false }
func (*ctModule) Run(mctx module.Context) error {
if !mctx.Config.Bool("ct_stream", false) {
return nil
}
durationSec := mctx.Config.Int("ct_stream.duration_sec", 30)
if durationSec <= 0 {
durationSec = 30
}
target := mctx.Target
deadline := time.Now().Add(time.Duration(durationSec) * time.Second)
// Fallback path: poll crt.sh's JSON endpoint every 5s for the duration.
// This is not true streaming but delivers on the same promise (new
// certs seen during the scan) and works without websocket deps.
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
seen := make(map[string]struct{})
for time.Now().Before(deadline) {
if mctx.Ctx.Err() != nil {
return nil
}
subs := fetchRecentCerts(target)
for _, s := range subs {
s = strings.ToLower(strings.TrimSpace(s))
if s == "" || !strings.HasSuffix(s, target) {
continue
}
if _, dup := seen[s]; dup {
continue
}
seen[s] = struct{}{}
_ = mctx.Store.Upsert(mctx.Ctx, s, func(h *store.Host) {
store.AddDiscoveryMethod(h, "ct-stream")
})
mctx.Bus.Publish(mctx.Ctx, eventbus.SubdomainDiscovered{
EventMeta: eventbus.EventMeta{At: time.Now(), Source: ModuleName, Target: s},
Subdomain: s,
Method: "ct-stream",
})
}
select {
case <-ticker.C:
case <-mctx.Ctx.Done():
return nil
}
}
return nil
}
func fetchRecentCerts(target string) []string {
// crt.sh returns JSON with name_value fields; same as the v1 crtsh
// source but we use a tighter query.
q := "%." + target
u := fmt.Sprintf("https://crt.sh/?q=%s&output=json", url.QueryEscape(q))
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Get(u)
if err != nil {
return nil
}
defer resp.Body.Close()
var entries []struct {
NameValue string `json:"name_value"`
}
if err := json.NewDecoder(resp.Body).Decode(&entries); err != nil {
return nil
}
var out []string
for _, e := range entries {
for _, name := range strings.Split(e.NameValue, "\n") {
name = strings.TrimPrefix(strings.TrimSpace(name), "*.")
if name != "" {
out = append(out, name)
}
}
}
return out
}
+166
View File
@@ -0,0 +1,166 @@
// Package dnsresolve resolves every subdomain present in the store, plus
// any that arrive via late SubdomainDiscovered events while the module is
// running. Results (IPs, CNAME, PTR) are written back to the store AND
// announced via DNSResolved events for downstream enrichment modules.
//
// This module is idempotent: Upsert on the same subdomain twice is cheap.
package dnsresolve
import (
"context"
"strings"
"sync"
"time"
"god-eye/internal/config"
godns "god-eye/internal/dns"
"god-eye/internal/eventbus"
"god-eye/internal/module"
"god-eye/internal/store"
)
const ModuleName = "dns.resolver"
type resolverModule struct{}
func Register() { module.Register(&resolverModule{}) }
func (*resolverModule) Name() string { return ModuleName }
func (*resolverModule) Phase() module.Phase { return module.PhaseResolution }
func (*resolverModule) Consumes() []eventbus.EventType { return []eventbus.EventType{eventbus.EventSubdomainDiscovered} }
func (*resolverModule) Produces() []eventbus.EventType { return []eventbus.EventType{eventbus.EventDNSResolved} }
func (*resolverModule) DefaultEnabled() bool { return true }
func (m *resolverModule) Run(mctx module.Context) error {
resolvers := parseResolvers(mctx.Config.String("resolvers", ""))
timeout := mctx.Config.Int("timeout", 5)
conc := mctx.Config.Int("concurrency", 500)
if conc <= 0 {
conc = 500
}
// Dedup across drain + late events.
processed := make(map[string]struct{})
var processedMu sync.Mutex
shouldProcess := func(sub string) bool {
processedMu.Lock()
defer processedMu.Unlock()
if _, dup := processed[sub]; dup {
return false
}
processed[sub] = struct{}{}
return true
}
work := make(chan string, conc*2)
var wg sync.WaitGroup
for i := 0; i < conc; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for sub := range work {
m.resolveOne(mctx, sub, resolvers, timeout)
}
}()
}
// 1) Drain the store: every subdomain discovered so far goes in.
for _, h := range mctx.Store.All(mctx.Ctx) {
if h == nil || h.Subdomain == "" {
continue
}
if !shouldProcess(h.Subdomain) {
continue
}
select {
case work <- h.Subdomain:
case <-mctx.Ctx.Done():
close(work)
wg.Wait()
return nil
}
}
// 2) Keep listening for late events (e.g. from recursive discovery that
// runs in our own phase and produces new subdomains mid-resolution).
sub := mctx.Bus.Subscribe(eventbus.EventSubdomainDiscovered, func(_ context.Context, e eventbus.Event) {
ev, ok := e.(eventbus.SubdomainDiscovered)
if !ok {
return
}
if !shouldProcess(ev.Subdomain) {
return
}
select {
case work <- ev.Subdomain:
case <-mctx.Ctx.Done():
}
})
defer sub.Unsubscribe()
// 3) Give late events a short window to arrive (e.g. recursive module
// running concurrently in PhaseResolution). 1 second is enough — we
// already drained the store, so any straggler events here are rare.
select {
case <-time.After(1 * time.Second):
case <-mctx.Ctx.Done():
}
close(work)
wg.Wait()
return nil
}
func (m *resolverModule) resolveOne(mctx module.Context, sub string, resolvers []string, timeout int) {
if err := mctx.Ctx.Err(); err != nil {
return
}
ips := godns.ResolveSubdomain(sub, resolvers, timeout)
if len(ips) == 0 {
return
}
cname := godns.ResolveCNAME(sub, resolvers, timeout)
ptr := godns.ResolvePTR(ips[0], resolvers, timeout)
_ = mctx.Store.Upsert(mctx.Ctx, sub, func(h *store.Host) {
store.AddIPs(h, ips)
if cname != "" && h.CNAME == "" {
h.CNAME = cname
}
if ptr != "" && h.PTR == "" {
h.PTR = ptr
}
store.AddDiscoveryMethod(h, "resolved")
})
mctx.Bus.Publish(mctx.Ctx, eventbus.DNSResolved{
EventMeta: eventbus.EventMeta{At: time.Now(), Source: ModuleName, Target: sub},
Subdomain: sub,
IPs: ips,
CNAME: cname,
PTR: ptr,
})
}
func parseResolvers(s string) []string {
s = strings.TrimSpace(s)
if s == "" {
return config.DefaultResolvers
}
var out []string
for _, r := range strings.Split(s, ",") {
r = strings.TrimSpace(r)
if r == "" {
continue
}
if !strings.Contains(r, ":") {
r = r + ":53"
}
out = append(out, r)
}
if len(out) == 0 {
return config.DefaultResolvers
}
return out
}
+150
View File
@@ -0,0 +1,150 @@
// Package github discovers subdomains from public GitHub code via dorks.
// Uses the v3 REST Search API. Works anonymously at a very low rate
// (strict API limits); a token in the GITHUB_TOKEN env var lifts limits.
//
// Dorks used:
//
// "<domain>" in:file
// "api.<domain>" in:file
//
// The module only emits subdomains that match the target domain suffix.
package github
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
"sync"
"time"
"god-eye/internal/eventbus"
"god-eye/internal/module"
"god-eye/internal/sources"
"god-eye/internal/store"
)
const ModuleName = "discovery.github-dorks"
type ghModule struct{}
func Register() { module.Register(&ghModule{}) }
func (*ghModule) Name() string { return ModuleName }
func (*ghModule) Phase() module.Phase { return module.PhaseDiscovery }
func (*ghModule) Consumes() []eventbus.EventType { return nil }
func (*ghModule) Produces() []eventbus.EventType {
return []eventbus.EventType{eventbus.EventSubdomainDiscovered}
}
// Default-enabled so bug-bounty users get it for free. Falls back to
// no-op when unauthenticated requests hit rate limits.
func (*ghModule) DefaultEnabled() bool { return true }
func (*ghModule) Run(mctx module.Context) error {
target := mctx.Target
if target == "" {
return nil
}
token := os.Getenv("GITHUB_TOKEN")
timeout := time.Duration(mctx.Config.Int("timeout", 10)) * time.Second
client := &http.Client{Timeout: timeout}
// Two dorks run in parallel. Each returns up to 100 results per page.
dorks := []string{
fmt.Sprintf(`"%s"`, target),
fmt.Sprintf(`"api.%s"`, target),
}
seen := make(map[string]struct{})
var seenMu sync.Mutex
var wg sync.WaitGroup
for _, q := range dorks {
q := q
wg.Add(1)
go func() {
defer wg.Done()
hits := searchCode(client, q, token)
for _, text := range hits {
for _, sub := range sources.ExtractSubdomains(text, target) {
seenMu.Lock()
if _, dup := seen[sub]; dup {
seenMu.Unlock()
continue
}
seen[sub] = struct{}{}
seenMu.Unlock()
_ = mctx.Store.Upsert(mctx.Ctx, sub, func(h *store.Host) {
store.AddDiscoveryMethod(h, "github-dorks")
})
mctx.Bus.Publish(mctx.Ctx, eventbus.SubdomainDiscovered{
EventMeta: eventbus.EventMeta{At: time.Now(), Source: ModuleName, Target: sub},
Subdomain: sub,
Method: "github-dorks",
})
}
}
}()
}
wg.Wait()
return nil
}
// searchCode hits GitHub's code-search endpoint and returns text_matches
// fragments (the snippet fields containing the dorked domain). When
// unauthenticated it may silently return zero hits due to rate limiting;
// the module fails open.
func searchCode(client *http.Client, q, token string) []string {
u := "https://api.github.com/search/code?q=" + url.QueryEscape(q) + "&per_page=100"
req, err := http.NewRequest("GET", u, nil)
if err != nil {
return nil
}
req.Header.Set("Accept", "application/vnd.github.text-match+json")
req.Header.Set("User-Agent", "god-eye-v2")
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
resp, err := client.Do(req)
if err != nil {
return nil
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil
}
if resp.StatusCode == 403 || resp.StatusCode == 429 {
return nil
}
var parsed struct {
Items []struct {
TextMatches []struct {
Fragment string `json:"fragment"`
} `json:"text_matches"`
HTMLURL string `json:"html_url"`
} `json:"items"`
}
if err := json.Unmarshal(body, &parsed); err != nil {
return nil
}
var out []string
for _, it := range parsed.Items {
out = append(out, it.HTMLURL)
for _, tm := range it.TextMatches {
out = append(out, tm.Fragment)
}
}
return out
}
var _ = strings.TrimSpace
var _ = context.Canceled
+287
View File
@@ -0,0 +1,287 @@
// Package graphql detects exposed GraphQL endpoints and tests them for
// common misconfigurations: unauthenticated introspection, batched query
// abuse, and field-level auth bypass via aliases.
//
// Probes these paths on every HTTP-probed host:
//
// /graphql, /graphiql, /api/graphql, /v1/graphql, /v2/graphql,
// /query, /api/v1/graphql, /api/v2/graphql
//
// When an endpoint responds to introspection queries, we publish an
// APIFinding + VulnerabilityFound event with the schema size and entry
// points as evidence.
package graphql
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"strings"
"sync"
"time"
"god-eye/internal/eventbus"
gohttp "god-eye/internal/http"
"god-eye/internal/module"
"god-eye/internal/store"
)
const ModuleName = "vuln.graphql"
type gqlModule struct{}
func Register() { module.Register(&gqlModule{}) }
func (*gqlModule) Name() string { return ModuleName }
func (*gqlModule) Phase() module.Phase { return module.PhaseAnalysis }
func (*gqlModule) Consumes() []eventbus.EventType { return []eventbus.EventType{eventbus.EventHTTPProbed} }
func (*gqlModule) Produces() []eventbus.EventType {
return []eventbus.EventType{eventbus.EventAPIFinding, eventbus.EventVulnerability}
}
func (*gqlModule) DefaultEnabled() bool { return true }
var candidatePaths = []string{
"/graphql",
"/graphiql",
"/api/graphql",
"/v1/graphql",
"/v2/graphql",
"/query",
"/api/v1/graphql",
"/api/v2/graphql",
"/graphql/console",
"/graphql/v1",
"/graphql/v2",
"/playground",
}
// introspection is the minimal query that exposes the full schema. Sent
// with Content-Type: application/json.
const introspectionQuery = `{"query":"{__schema{queryType{name} mutationType{name} subscriptionType{name} types{name kind description fields{name} enumValues{name}}}}"}`
func (*gqlModule) Run(mctx module.Context) error {
timeout := mctx.Config.Int("timeout", 10)
client := gohttp.GetSharedClient(timeout)
processed := make(map[string]struct{})
var mu sync.Mutex
shouldProcess := func(host string) bool {
mu.Lock()
defer mu.Unlock()
if _, ok := processed[host]; ok {
return false
}
processed[host] = struct{}{}
return true
}
var wg sync.WaitGroup
// Drain store: every host that got a successful HTTP probe.
for _, h := range mctx.Store.All(mctx.Ctx) {
if h == nil || h.StatusCode == 0 {
continue
}
if !shouldProcess(h.Subdomain) {
continue
}
host := h.Subdomain
wg.Add(1)
go func() { defer wg.Done(); probeGraphQL(mctx, client, host) }()
}
// Late events.
sub := mctx.Bus.Subscribe(eventbus.EventHTTPProbed, func(_ context.Context, e eventbus.Event) {
ev, ok := e.(eventbus.HTTPProbed)
if !ok || ev.StatusCode == 0 {
return
}
host := ev.Meta().Target
if !shouldProcess(host) {
return
}
wg.Add(1)
go func() { defer wg.Done(); probeGraphQL(mctx, client, host) }()
})
defer sub.Unsubscribe()
select {
case <-time.After(500 * time.Millisecond):
case <-mctx.Ctx.Done():
}
wg.Wait()
return nil
}
func probeGraphQL(mctx module.Context, client *http.Client, host string) {
for _, p := range candidatePaths {
if mctx.Ctx.Err() != nil {
return
}
for _, scheme := range []string{"https://", "http://"} {
u := scheme + host + p
if finding := tryIntrospection(client, u); finding != nil {
publishFinding(mctx, host, u, finding)
return // one endpoint per host is enough — rest are typically aliases
}
}
}
}
type gqlFinding struct {
SchemaSize int
TypesCount int
HasMutation bool
HasSubscription bool
QueryTypeName string
Sample string // truncated introspection response
}
func tryIntrospection(client *http.Client, url string) *gqlFinding {
req, err := http.NewRequest("POST", url, bytes.NewBufferString(introspectionQuery))
if err != nil {
return nil
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "god-eye-v2")
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
resp, err := client.Do(req.WithContext(ctx))
if err != nil {
return nil
}
defer resp.Body.Close()
// Accept 2xx — the exact shape matters more than status.
if resp.StatusCode >= 400 {
return nil
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 512*1024))
if err != nil || len(body) < 30 {
return nil
}
// Parse the response; real GraphQL endpoints return {"data": {"__schema": ...}}
var parsed struct {
Data struct {
Schema struct {
QueryType map[string]interface{} `json:"queryType"`
MutationType map[string]interface{} `json:"mutationType"`
SubscriptionType map[string]interface{} `json:"subscriptionType"`
Types []struct {
Name string `json:"name"`
Kind string `json:"kind"`
} `json:"types"`
} `json:"__schema"`
} `json:"data"`
}
if err := json.Unmarshal(body, &parsed); err != nil {
return nil
}
if parsed.Data.Schema.QueryType == nil {
return nil
}
fnd := &gqlFinding{
SchemaSize: len(body),
TypesCount: len(parsed.Data.Schema.Types),
HasMutation: parsed.Data.Schema.MutationType != nil,
HasSubscription: parsed.Data.Schema.SubscriptionType != nil,
}
if n, ok := parsed.Data.Schema.QueryType["name"].(string); ok {
fnd.QueryTypeName = n
}
if len(body) > 500 {
fnd.Sample = string(body[:500]) + "…"
} else {
fnd.Sample = string(body)
}
return fnd
}
func publishFinding(mctx module.Context, host, url string, f *gqlFinding) {
now := time.Now()
severity := eventbus.SeverityMedium
if f.HasMutation {
severity = eventbus.SeverityHigh
}
_ = mctx.Store.Upsert(mctx.Ctx, host, func(h *store.Host) {
h.Vulnerabilities = append(h.Vulnerabilities, store.Vulnerability{
ID: "graphql-introspection",
Title: "GraphQL Introspection Enabled",
Description: describe(f),
Severity: string(severity),
URL: url,
Evidence: f.Sample,
Remediation: "Disable introspection in production GraphQL servers (e.g. Apollo: introspection:false, GraphQL Yoga: introspection:{disable:true}).",
OWASP: "A05:2021-Security Misconfiguration",
FoundAt: now,
})
})
mctx.Bus.Publish(mctx.Ctx, eventbus.VulnerabilityFound{
EventMeta: eventbus.EventMeta{At: now, Source: ModuleName, Target: host},
ID: "graphql-introspection",
Title: "GraphQL Introspection Enabled",
Description: describe(f),
Severity: severity,
URL: url,
Evidence: f.Sample,
Remediation: "Disable introspection in production GraphQL servers.",
OWASP: "A05:2021-Security Misconfiguration",
})
mctx.Bus.Publish(mctx.Ctx, eventbus.APIFinding{
EventMeta: eventbus.EventMeta{At: now, Source: ModuleName, Target: host},
Kind: "graphql-introspection",
URL: url,
Issue: describe(f),
Severity: severity,
})
}
func describe(f *gqlFinding) string {
parts := []string{"GraphQL endpoint leaks full schema via unauthenticated introspection."}
if f.TypesCount > 0 {
parts = append(parts, "Types: "+itoa(f.TypesCount)+".")
}
if f.HasMutation {
parts = append(parts, "Mutations enabled — attacker can enumerate write operations.")
}
if f.HasSubscription {
parts = append(parts, "Subscriptions enabled.")
}
if f.QueryTypeName != "" {
parts = append(parts, "Query root: "+f.QueryTypeName)
}
return strings.Join(parts, " ")
}
func itoa(n int) string {
// Small inline formatter avoids importing strconv just for this.
if n == 0 {
return "0"
}
var buf [20]byte
i := len(buf)
neg := n < 0
if neg {
n = -n
}
for n > 0 {
i--
buf[i] = byte('0' + n%10)
n /= 10
}
if neg {
i--
buf[i] = '-'
}
return string(buf[i:])
}
+253
View File
@@ -0,0 +1,253 @@
// Package headers performs a detailed inspection of HTTP response headers
// and reports every missing or misconfigured security control. Unlike v1's
// lightweight header check, this module flags each issue as an individual
// VulnerabilityFound event with remediation guidance aligned to OWASP
// Secure Headers Project.
package headers
import (
"context"
"net/http"
"strings"
"sync"
"time"
"god-eye/internal/eventbus"
gohttp "god-eye/internal/http"
"god-eye/internal/module"
"god-eye/internal/store"
)
const ModuleName = "vuln.security-headers"
type hdrModule struct{}
func Register() { module.Register(&hdrModule{}) }
func (*hdrModule) Name() string { return ModuleName }
func (*hdrModule) Phase() module.Phase { return module.PhaseAnalysis }
func (*hdrModule) Consumes() []eventbus.EventType { return []eventbus.EventType{eventbus.EventHTTPProbed} }
func (*hdrModule) Produces() []eventbus.EventType {
return []eventbus.EventType{eventbus.EventVulnerability}
}
func (*hdrModule) DefaultEnabled() bool { return true }
func (*hdrModule) Run(mctx module.Context) error {
timeout := mctx.Config.Int("timeout", 10)
client := gohttp.GetSharedClient(timeout)
processed := make(map[string]struct{})
var mu sync.Mutex
shouldProcess := func(host string) bool {
mu.Lock()
defer mu.Unlock()
if _, ok := processed[host]; ok {
return false
}
processed[host] = struct{}{}
return true
}
var wg sync.WaitGroup
// Drain the store.
for _, h := range mctx.Store.All(mctx.Ctx) {
if h == nil || h.StatusCode == 0 {
continue
}
if !shouldProcess(h.Subdomain) {
continue
}
host := h.Subdomain
wg.Add(1)
go func() { defer wg.Done(); inspect(mctx, client, host) }()
}
// Late events.
sub := mctx.Bus.Subscribe(eventbus.EventHTTPProbed, func(_ context.Context, e eventbus.Event) {
ev, ok := e.(eventbus.HTTPProbed)
if !ok || ev.StatusCode == 0 {
return
}
host := ev.Meta().Target
if !shouldProcess(host) {
return
}
wg.Add(1)
go func() { defer wg.Done(); inspect(mctx, client, host) }()
})
defer sub.Unsubscribe()
select {
case <-time.After(500 * time.Millisecond):
case <-mctx.Ctx.Done():
}
wg.Wait()
return nil
}
func inspect(mctx module.Context, client *http.Client, host string) {
req, err := http.NewRequest("GET", "https://"+host, nil)
if err != nil {
return
}
req.Header.Set("User-Agent", "god-eye-v2")
resp, err := client.Do(req)
if err != nil {
return
}
defer resp.Body.Close()
issues := assess(resp.Header)
if len(issues) == 0 {
return
}
_ = mctx.Store.Upsert(mctx.Ctx, host, func(h *store.Host) {
now := time.Now()
for _, iss := range issues {
h.Vulnerabilities = append(h.Vulnerabilities, store.Vulnerability{
ID: iss.id,
Title: iss.title,
Description: iss.desc,
Severity: string(iss.sev),
URL: "https://" + host,
Remediation: iss.fix,
OWASP: "A05:2021-Security Misconfiguration",
FoundAt: now,
})
}
})
for _, iss := range issues {
mctx.Bus.Publish(mctx.Ctx, eventbus.VulnerabilityFound{
EventMeta: eventbus.EventMeta{At: time.Now(), Source: ModuleName, Target: host},
ID: iss.id,
Title: iss.title,
Description: iss.desc,
Severity: iss.sev,
URL: "https://" + host,
Remediation: iss.fix,
OWASP: "A05:2021-Security Misconfiguration",
})
}
}
type issue struct {
id, title, desc, fix string
sev eventbus.Severity
}
func assess(h http.Header) []issue {
var out []issue
hasHeader := func(k string) bool { return strings.TrimSpace(h.Get(k)) != "" }
if !hasHeader("Strict-Transport-Security") {
out = append(out, issue{
id: "hdr-missing-hsts",
title: "Missing Strict-Transport-Security",
desc: "HSTS is absent; clients may accept plaintext downgrades.",
fix: "Add: Strict-Transport-Security: max-age=63072000; includeSubDomains; preload",
sev: eventbus.SeverityMedium,
})
} else if hsts := h.Get("Strict-Transport-Security"); !strings.Contains(strings.ToLower(hsts), "max-age=") ||
!strings.Contains(strings.ToLower(hsts), "includesubdomains") {
out = append(out, issue{
id: "hdr-weak-hsts",
title: "Weak HSTS policy",
desc: "HSTS set but missing includeSubDomains and/or sufficient max-age.",
fix: "Use: max-age=63072000; includeSubDomains; preload",
sev: eventbus.SeverityLow,
})
}
if !hasHeader("Content-Security-Policy") {
out = append(out, issue{
id: "hdr-missing-csp",
title: "Missing Content-Security-Policy",
desc: "No CSP header; XSS mitigations rely solely on upstream filtering.",
fix: "Deploy a nonce-based CSP restricting script-src, object-src 'none'.",
sev: eventbus.SeverityMedium,
})
} else if strings.Contains(strings.ToLower(h.Get("Content-Security-Policy")), "unsafe-inline") {
out = append(out, issue{
id: "hdr-weak-csp",
title: "Weak CSP (allows unsafe-inline)",
desc: "CSP allows unsafe-inline, neutralizing most XSS protection.",
fix: "Remove unsafe-inline; use nonces or hashes.",
sev: eventbus.SeverityMedium,
})
}
if !hasHeader("X-Frame-Options") {
// Only flag if CSP doesn't include frame-ancestors.
csp := strings.ToLower(h.Get("Content-Security-Policy"))
if !strings.Contains(csp, "frame-ancestors") {
out = append(out, issue{
id: "hdr-missing-clickjack",
title: "Clickjacking not prevented",
desc: "Neither X-Frame-Options nor CSP frame-ancestors is set.",
fix: "Add: X-Frame-Options: DENY OR CSP with frame-ancestors 'none'.",
sev: eventbus.SeverityLow,
})
}
}
if !hasHeader("X-Content-Type-Options") {
out = append(out, issue{
id: "hdr-missing-nosniff",
title: "Missing X-Content-Type-Options",
desc: "MIME sniffing permitted; certain XSS escalations become easier.",
fix: "Add: X-Content-Type-Options: nosniff",
sev: eventbus.SeverityLow,
})
}
if !hasHeader("Referrer-Policy") {
out = append(out, issue{
id: "hdr-missing-referrer-policy",
title: "Missing Referrer-Policy",
desc: "Default browser Referrer-Policy leaks URLs to third parties.",
fix: "Add: Referrer-Policy: strict-origin-when-cross-origin",
sev: eventbus.SeverityLow,
})
}
if !hasHeader("Permissions-Policy") && !hasHeader("Feature-Policy") {
out = append(out, issue{
id: "hdr-missing-permissions-policy",
title: "Missing Permissions-Policy",
desc: "Browser features (camera, geolocation, USB, etc.) are unrestricted by default.",
fix: "Add: Permissions-Policy: camera=(), microphone=(), geolocation=()",
sev: eventbus.SeverityInfo,
})
}
// Dangerous information disclosure via default server banner.
if srv := h.Get("Server"); looksLikeBanner(srv) {
out = append(out, issue{
id: "hdr-server-banner",
title: "Server banner leaks version",
desc: "Server header exposes exact software + version: " + srv,
fix: "Strip or generalize via proxy/web-server config.",
sev: eventbus.SeverityInfo,
})
}
return out
}
func looksLikeBanner(s string) bool {
s = strings.ToLower(s)
return strings.Contains(s, "/") && (strings.Contains(s, ".") || anyDigit(s))
}
func anyDigit(s string) bool {
for _, r := range s {
if r >= '0' && r <= '9' {
return true
}
}
return false
}
+195
View File
@@ -0,0 +1,195 @@
// Package httpprobe probes every resolved host with HTTPS/HTTP and extracts
// status code, title, server, technology stack, and TLS information.
//
// Runs in PhaseEnrichment. Reads hosts from the store (not events) to avoid
// the phase-barrier race where late subscribers miss earlier events.
package httpprobe
import (
"context"
"crypto/tls"
"sync"
"time"
"god-eye/internal/eventbus"
gohttp "god-eye/internal/http"
"god-eye/internal/module"
"god-eye/internal/store"
)
const ModuleName = "http.probe"
type probeModule struct{}
func Register() { module.Register(&probeModule{}) }
func (*probeModule) Name() string { return ModuleName }
func (*probeModule) Phase() module.Phase { return module.PhaseEnrichment }
func (*probeModule) Consumes() []eventbus.EventType { return []eventbus.EventType{eventbus.EventDNSResolved} }
func (*probeModule) Produces() []eventbus.EventType {
return []eventbus.EventType{eventbus.EventHTTPProbed, eventbus.EventTLSAnalyzed, eventbus.EventTechDetected}
}
func (*probeModule) DefaultEnabled() bool { return true }
func (p *probeModule) Run(mctx module.Context) error {
if mctx.Config.Bool("no_probe", false) {
return nil
}
conc := mctx.Config.Int("concurrency", 500)
if conc <= 0 {
conc = 500
}
timeout := mctx.Config.Int("timeout", 5)
// Dedup across drain + late events.
processed := make(map[string]struct{})
var processedMu sync.Mutex
shouldProcess := func(host string) bool {
processedMu.Lock()
defer processedMu.Unlock()
if _, dup := processed[host]; dup {
return false
}
processed[host] = struct{}{}
return true
}
work := make(chan string, conc*2)
var wg sync.WaitGroup
for i := 0; i < conc; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for host := range work {
p.probeOne(mctx, host, timeout)
}
}()
}
// Drain: every host in the store with at least one IP is worth probing.
for _, h := range mctx.Store.All(mctx.Ctx) {
if h == nil || h.Subdomain == "" || len(h.IPs) == 0 {
continue
}
if !shouldProcess(h.Subdomain) {
continue
}
select {
case work <- h.Subdomain:
case <-mctx.Ctx.Done():
close(work)
wg.Wait()
return nil
}
}
// Also listen for late DNSResolved events (recursive/permutation running
// concurrently in other modules may produce new resolves during our
// phase — pick them up).
sub := mctx.Bus.Subscribe(eventbus.EventDNSResolved, func(_ context.Context, e eventbus.Event) {
ev, ok := e.(eventbus.DNSResolved)
if !ok || len(ev.IPs) == 0 {
return
}
if !shouldProcess(ev.Subdomain) {
return
}
select {
case work <- ev.Subdomain:
case <-mctx.Ctx.Done():
}
})
defer sub.Unsubscribe()
// Brief window for late arrivals.
select {
case <-time.After(500 * time.Millisecond):
case <-mctx.Ctx.Done():
}
close(work)
wg.Wait()
return nil
}
func (p *probeModule) probeOne(mctx module.Context, host string, timeout int) {
if mctx.Ctx.Err() != nil {
return
}
r := gohttp.ProbeHTTP(host, timeout)
if r == nil || r.StatusCode == 0 {
return
}
_ = mctx.Store.Upsert(mctx.Ctx, host, func(h *store.Host) {
h.StatusCode = r.StatusCode
h.ContentLength = r.ContentLength
h.Title = r.Title
h.Server = r.Server
if len(r.Tech) > 0 {
store.AddTechnologies(h, r.Tech)
}
h.ResponseMs = r.ResponseMs
h.TLSVersion = r.TLSVersion
h.TLSIssuer = r.TLSIssuer
h.TLSSelfSigned = r.TLSSelfSigned
if r.TLSExpiry != "" {
if tm, err := time.Parse("2006-01-02", r.TLSExpiry); err == nil {
h.TLSExpiry = tm
}
}
if r.TLSFingerprint != nil {
fp := *r.TLSFingerprint
h.TLSFingerprint = &store.TLSFingerprint{
Vendor: fp.Vendor,
Product: fp.Product,
Version: fp.Version,
ApplianceKind: fp.ApplianceType,
InternalHosts: append([]string(nil), fp.InternalHosts...),
}
}
})
mctx.Bus.Publish(mctx.Ctx, eventbus.HTTPProbed{
EventMeta: eventbus.EventMeta{At: time.Now(), Source: ModuleName, Target: host},
URL: "https://" + host,
StatusCode: r.StatusCode,
ContentLength: r.ContentLength,
Title: r.Title,
Server: r.Server,
Technologies: append([]string(nil), r.Tech...),
ResponseMs: r.ResponseMs,
TLSVersion: r.TLSVersion,
TLSSelfSigned: r.TLSSelfSigned,
})
for _, t := range r.Tech {
if t == "" {
continue
}
mctx.Bus.Publish(mctx.Ctx, eventbus.TechDetected{
EventMeta: eventbus.EventMeta{At: time.Now(), Source: ModuleName, Target: host},
Host: host,
Technology: t,
Confidence: 0.8,
})
}
if r.TLSFingerprint != nil {
mctx.Bus.Publish(mctx.Ctx, eventbus.TLSAnalyzed{
EventMeta: eventbus.EventMeta{At: time.Now(), Source: ModuleName, Target: host},
Host: host,
Version: r.TLSVersion,
Issuer: r.TLSIssuer,
SelfSigned: r.TLSSelfSigned,
Vendor: r.TLSFingerprint.Vendor,
Product: r.TLSFingerprint.Product,
ApplianceKind: r.TLSFingerprint.ApplianceType,
InternalHosts: append([]string(nil), r.TLSFingerprint.InternalHosts...),
})
}
}
// keep tls import stable
var _ = tls.VersionTLS13
+186
View File
@@ -0,0 +1,186 @@
// Package javascript downloads JS files from probed hosts and scans them
// for secrets with the v1 analyzer. Drains the store at start; also listens
// for late HTTPProbed events.
package javascript
import (
"context"
"strings"
"sync"
"time"
"god-eye/internal/eventbus"
gohttp "god-eye/internal/http"
"god-eye/internal/module"
"god-eye/internal/scanner"
"god-eye/internal/store"
)
// publicAPIDenylist covers well-known public/third-party APIs and font
// services that the v1 regex scanner flags as "API Endpoint" but which
// are never secrets. Matched case-insensitively as a substring.
var publicAPIDenylist = []string{
"fonts.googleapis.com",
"fonts.gstatic.com",
"www.googleapis.com",
"content.googleapis.com",
"api.fastmail.com",
"api.forwardemail.net",
"cdn.jsdelivr.net",
"cdnjs.cloudflare.com",
"unpkg.com",
}
// uiStringDenylist covers common UI labels / warning strings that trip
// the "Generic Password" regex but are clearly human-readable copy.
var uiStringDenylist = []string{
"change password",
"update password",
"reset password",
"confirm password",
"forgot password",
"set-initial-password",
"change-password",
"this is a very common password",
"masterpassword",
"password",
}
// isSecretFalsePositive applies cheap deterministic heuristics to weed
// out v1 regex noise. Does NOT replace AI triage (which is still the
// preferred filter once the ai module is enabled) — it only suppresses
// findings that are *definitely* not secrets.
func isSecretFalsePositive(secret string) bool {
low := strings.ToLower(strings.TrimSpace(secret))
for _, s := range publicAPIDenylist {
if strings.Contains(low, s) {
return true
}
}
for _, s := range uiStringDenylist {
if strings.Contains(low, s) {
return true
}
}
// Very short matches (< 8 chars of unique content) are almost always
// labels, not credentials. The v1 regex already strips the "[Kind] "
// prefix before passing to us; anything under 8 chars is noise.
if len(low) > 0 && len(low) < 8 {
return true
}
return false
}
const ModuleName = "js.analyzer"
type jsModule struct{}
func Register() { module.Register(&jsModule{}) }
func (*jsModule) Name() string { return ModuleName }
func (*jsModule) Phase() module.Phase { return module.PhaseAnalysis }
func (*jsModule) Consumes() []eventbus.EventType { return []eventbus.EventType{eventbus.EventHTTPProbed} }
func (*jsModule) Produces() []eventbus.EventType {
return []eventbus.EventType{eventbus.EventJSFile, eventbus.EventSecret}
}
func (*jsModule) DefaultEnabled() bool { return true }
func (*jsModule) Run(mctx module.Context) error {
timeout := mctx.Config.Int("timeout", 5)
client := gohttp.GetSharedClient(timeout)
processed := make(map[string]struct{})
var mu sync.Mutex
shouldProcess := func(host string) bool {
mu.Lock()
defer mu.Unlock()
if _, ok := processed[host]; ok {
return false
}
processed[host] = struct{}{}
return true
}
analyze := func(host string) {
if mctx.Ctx.Err() != nil {
return
}
jsFiles, secrets := scanner.AnalyzeJSFiles(host, client)
// Drop known-noise findings before they reach the store or bus.
filtered := secrets[:0]
for _, s := range secrets {
if isSecretFalsePositive(s) {
continue
}
filtered = append(filtered, s)
}
secrets = filtered
if len(jsFiles) == 0 && len(secrets) == 0 {
return
}
_ = mctx.Store.Upsert(mctx.Ctx, host, func(h *store.Host) {
for _, sec := range secrets {
h.Secrets = append(h.Secrets, store.Secret{
Kind: "js-regex",
Match: sec,
Severity: string(eventbus.SeverityHigh),
FoundAt: time.Now(),
})
}
})
for _, jsf := range jsFiles {
mctx.Bus.Publish(mctx.Ctx, eventbus.JSFileDiscovered{
EventMeta: eventbus.EventMeta{At: time.Now(), Source: ModuleName, Target: host},
URL: jsf,
Host: host,
})
}
for _, s := range secrets {
mctx.Bus.Publish(mctx.Ctx, eventbus.SecretFound{
EventMeta: eventbus.EventMeta{At: time.Now(), Source: ModuleName, Target: host},
Kind: "js-regex",
Match: s,
Location: "js-file",
Severity: eventbus.SeverityHigh,
})
}
}
var wg sync.WaitGroup
// Drain: every probed host (StatusCode > 0).
for _, h := range mctx.Store.All(mctx.Ctx) {
if h == nil || h.StatusCode == 0 {
continue
}
if !shouldProcess(h.Subdomain) {
continue
}
host := h.Subdomain
wg.Add(1)
go func() { defer wg.Done(); analyze(host) }()
}
// Late events.
sub := mctx.Bus.Subscribe(eventbus.EventHTTPProbed, func(_ context.Context, e eventbus.Event) {
ev, ok := e.(eventbus.HTTPProbed)
if !ok || ev.StatusCode == 0 {
return
}
host := ev.Meta().Target
if !shouldProcess(host) {
return
}
wg.Add(1)
go func() { defer wg.Done(); analyze(host) }()
})
defer sub.Unsubscribe()
select {
case <-time.After(500 * time.Millisecond):
case <-mctx.Ctx.Done():
}
wg.Wait()
return nil
}
+305
View File
@@ -0,0 +1,305 @@
// Package jwt scans responses for JWTs, decodes them, and flags
// security-relevant attributes: alg=none, weak HMAC secret (dictionary
// crack against common passwords), excessive expiration, missing claims.
//
// The brute-force list is intentionally tiny (~20 common secrets) — the
// goal is to surface obviously-weak keys, not to run offline hashcat. A
// proper cracker belongs in Fase 2's planned "auth" agent.
package jwt
import (
"context"
"crypto/hmac"
"crypto/sha256"
"crypto/sha512"
"encoding/base64"
"encoding/json"
"hash"
"io"
"net/http"
"regexp"
"strings"
"sync"
"time"
"god-eye/internal/eventbus"
gohttp "god-eye/internal/http"
"god-eye/internal/module"
"god-eye/internal/store"
)
const ModuleName = "vuln.jwt"
type jwtModule struct{}
func Register() { module.Register(&jwtModule{}) }
func (*jwtModule) Name() string { return ModuleName }
func (*jwtModule) Phase() module.Phase { return module.PhaseAnalysis }
func (*jwtModule) Consumes() []eventbus.EventType { return []eventbus.EventType{eventbus.EventHTTPProbed} }
func (*jwtModule) Produces() []eventbus.EventType {
return []eventbus.EventType{eventbus.EventVulnerability, eventbus.EventSecret}
}
func (*jwtModule) DefaultEnabled() bool { return true }
// jwtRegex matches the standard three-part base64url JWT shape.
var jwtRegex = regexp.MustCompile(`eyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]*`)
var weakSecrets = []string{
"secret", "password", "123456", "admin", "jwt", "jwtsecret",
"changeme", "default", "test", "dev", "secret_key", "mysecret",
"your-256-bit-secret", "your-secret-key", "super-secret",
"supersecret", "helloworld", "qwerty", "abc123", "letmein",
}
func (*jwtModule) Run(mctx module.Context) error {
timeout := mctx.Config.Int("timeout", 10)
client := gohttp.GetSharedClient(timeout)
processed := make(map[string]struct{})
var mu sync.Mutex
shouldProcess := func(host string) bool {
mu.Lock()
defer mu.Unlock()
if _, ok := processed[host]; ok {
return false
}
processed[host] = struct{}{}
return true
}
var wg sync.WaitGroup
for _, h := range mctx.Store.All(mctx.Ctx) {
if h == nil || h.StatusCode == 0 {
continue
}
if !shouldProcess(h.Subdomain) {
continue
}
host := h.Subdomain
wg.Add(1)
go func() { defer wg.Done(); scanHost(mctx, client, host) }()
}
sub := mctx.Bus.Subscribe(eventbus.EventHTTPProbed, func(_ context.Context, e eventbus.Event) {
ev, ok := e.(eventbus.HTTPProbed)
if !ok || ev.StatusCode == 0 {
return
}
host := ev.Meta().Target
if !shouldProcess(host) {
return
}
wg.Add(1)
go func() { defer wg.Done(); scanHost(mctx, client, host) }()
})
defer sub.Unsubscribe()
select {
case <-time.After(500 * time.Millisecond):
case <-mctx.Ctx.Done():
}
wg.Wait()
return nil
}
func scanHost(mctx module.Context, client *http.Client, host string) {
for _, scheme := range []string{"https://", "http://"} {
if mctx.Ctx.Err() != nil {
return
}
url := scheme + host
req, err := http.NewRequest("GET", url, nil)
if err != nil {
continue
}
req.Header.Set("User-Agent", "god-eye-v2")
resp, err := client.Do(req)
if err != nil {
continue
}
body, _ := io.ReadAll(io.LimitReader(resp.Body, 256*1024))
resp.Body.Close()
text := string(body)
// Also check Authorization + Set-Cookie response headers.
for _, h := range resp.Header.Values("Set-Cookie") {
text += "\n" + h
}
if auth := resp.Header.Get("Authorization"); auth != "" {
text += "\n" + auth
}
matches := jwtRegex.FindAllString(text, -1)
for _, tok := range uniqueStrings(matches) {
analyzeJWT(mctx, host, url, tok)
}
// One scheme is enough; avoid duplicate noise.
if len(matches) > 0 {
return
}
}
}
func analyzeJWT(mctx module.Context, host, url, token string) {
parts := strings.Split(token, ".")
if len(parts) != 3 {
return
}
header, err := base64Decode(parts[0])
if err != nil {
return
}
payload, err := base64Decode(parts[1])
if err != nil {
return
}
var h struct {
Alg string `json:"alg"`
Kid string `json:"kid"`
Typ string `json:"typ"`
}
if err := json.Unmarshal(header, &h); err != nil {
return
}
severity := eventbus.SeverityInfo
findings := []string{"JWT detected"}
if strings.EqualFold(h.Alg, "none") {
severity = eventbus.SeverityCritical
findings = append(findings, "alg=none accepted — no signature verification")
}
if strings.HasPrefix(strings.ToUpper(h.Alg), "HS") {
if cracked := tryWeakSecret(token, h.Alg, parts); cracked != "" {
severity = eventbus.SeverityCritical
findings = append(findings, "weak HMAC secret cracked: "+cracked)
}
}
if h.Kid != "" && looksInjectable(h.Kid) {
severity = maxSeverity(severity, eventbus.SeverityMedium)
findings = append(findings, "kid header may be injectable: "+h.Kid)
}
// Inspect payload for excessive expiry.
var claims map[string]interface{}
_ = json.Unmarshal(payload, &claims)
if exp, ok := claims["exp"].(float64); ok {
expAt := time.Unix(int64(exp), 0)
if time.Until(expAt) > 365*24*time.Hour {
severity = maxSeverity(severity, eventbus.SeverityLow)
findings = append(findings, "exp >1 year")
}
}
redacted := token
if len(redacted) > 40 {
redacted = redacted[:20] + "…" + redacted[len(redacted)-10:]
}
_ = mctx.Store.Upsert(mctx.Ctx, host, func(sh *store.Host) {
sh.Secrets = append(sh.Secrets, store.Secret{
Kind: "jwt",
Match: redacted,
Location: url,
Severity: string(severity),
Description: strings.Join(findings, "; "),
FoundAt: time.Now(),
})
})
mctx.Bus.Publish(mctx.Ctx, eventbus.SecretFound{
EventMeta: eventbus.EventMeta{At: time.Now(), Source: ModuleName, Target: host},
Kind: "jwt",
Match: redacted,
Location: url,
Severity: severity,
Description: strings.Join(findings, "; "),
})
if severity == eventbus.SeverityCritical || severity == eventbus.SeverityHigh {
mctx.Bus.Publish(mctx.Ctx, eventbus.VulnerabilityFound{
EventMeta: eventbus.EventMeta{At: time.Now(), Source: ModuleName, Target: host},
ID: "jwt-weak",
Title: "JWT Weakness",
Description: strings.Join(findings, "; "),
Severity: severity,
URL: url,
Evidence: redacted,
Remediation: "Use strong signing keys (256+ bits of entropy), refuse alg=none, rotate keys on compromise, short expiry.",
OWASP: "A02:2021-Cryptographic Failures",
})
}
}
func tryWeakSecret(token, alg string, parts []string) string {
signingInput := parts[0] + "." + parts[1]
sig, err := base64Decode(parts[2])
if err != nil {
return ""
}
var hashFn func() hash.Hash
switch strings.ToUpper(alg) {
case "HS256":
hashFn = sha256.New
case "HS384":
hashFn = func() hash.Hash { return sha512.New384() }
case "HS512":
hashFn = sha512.New
default:
return ""
}
for _, s := range weakSecrets {
mac := hmac.New(hashFn, []byte(s))
mac.Write([]byte(signingInput))
if hmac.Equal(mac.Sum(nil), sig) {
return s
}
}
return ""
}
// base64Decode unpads and decodes a JWT segment (URL-safe, no padding).
func base64Decode(s string) ([]byte, error) {
// Add padding if missing.
if m := len(s) % 4; m != 0 {
s += strings.Repeat("=", 4-m)
}
return base64.URLEncoding.DecodeString(s)
}
func looksInjectable(kid string) bool {
// kids that include path separators, SQL wildcards, or NUL-like
// sequences are worth flagging for manual review.
return strings.ContainsAny(kid, "/\\;'\"$`|")
}
func maxSeverity(a, b eventbus.Severity) eventbus.Severity {
rank := map[eventbus.Severity]int{
eventbus.SeverityInfo: 0, eventbus.SeverityLow: 1,
eventbus.SeverityMedium: 2, eventbus.SeverityHigh: 3, eventbus.SeverityCritical: 4,
}
if rank[a] >= rank[b] {
return a
}
return b
}
func uniqueStrings(in []string) []string {
seen := make(map[string]struct{})
out := make([]string, 0, len(in))
for _, s := range in {
if _, dup := seen[s]; dup {
continue
}
seen[s] = struct{}{}
out = append(out, s)
}
return out
}
+329
View File
@@ -0,0 +1,329 @@
// Package nuclei runs Nuclei-format YAML templates against every probed
// host. The actual executor lives in internal/nucleitpl; this module is
// the wiring that discovers templates on disk, fans out per host, and
// publishes matches as VulnerabilityFound events.
//
// Template discovery order:
// 1. --nuclei-templates flag (highest priority)
// 2. NUCLEI_TEMPLATES env var
// 3. ~/nuclei-templates (nuclei CLI default)
// 4. ~/.god-eye/nuclei-templates
//
// If no template directory is found AND nuclei_auto_download is true
// (default), God's Eye downloads the official projectdiscovery/nuclei-templates
// ZIP into ~/.god-eye/nuclei-templates, extracts only the .yaml/.yml files
// (path-traversal safe), and proceeds with the scan. The archive is
// ~40MB; first run takes 10-30 seconds depending on network, subsequent
// runs skip the download.
//
// Refresh the cache manually with: god-eye nuclei-update
//
// Only HTTP templates compatible with our executor subset run; others
// are counted as "skipped" and surfaced as a ModuleError event once per
// scan.
package nuclei
import (
"context"
"fmt"
"os"
"path/filepath"
"sync"
"time"
"god-eye/internal/eventbus"
gohttp "god-eye/internal/http"
"god-eye/internal/module"
"god-eye/internal/nucleitpl"
"god-eye/internal/store"
)
const ModuleName = "vuln.nuclei-compat"
type nucleiModule struct{}
func Register() { module.Register(&nucleiModule{}) }
func (*nucleiModule) Name() string { return ModuleName }
func (*nucleiModule) Phase() module.Phase { return module.PhaseAnalysis }
func (*nucleiModule) Consumes() []eventbus.EventType { return []eventbus.EventType{eventbus.EventHTTPProbed} }
func (*nucleiModule) Produces() []eventbus.EventType {
return []eventbus.EventType{eventbus.EventVulnerability, eventbus.EventCVEMatch}
}
// DefaultEnabled returns true so the registry always loads the module;
// Run() itself is a no-op unless `nuclei_scan` is set in the config
// (via --nuclei or YAML). Mirrors the ai.cascade module — keeps the
// module visible to selection logic while preserving opt-in semantics.
func (*nucleiModule) DefaultEnabled() bool { return true }
func (*nucleiModule) Run(mctx module.Context) error {
if !mctx.Config.Bool("nuclei_scan", false) {
return nil
}
tplDir := resolveTemplateDir(mctx)
if tplDir == "" {
// No templates found — try auto-download into ~/.god-eye/nuclei-templates
// unless the user explicitly disabled that fallback.
if !mctx.Config.Bool("nuclei_auto_download", true) {
mctx.Bus.Publish(mctx.Ctx, eventbus.ModuleError{
EventMeta: eventbus.EventMeta{At: time.Now(), Source: ModuleName, Target: mctx.Target},
Module: ModuleName,
Err: "no nuclei templates found and --nuclei-auto-download=false. Clone https://github.com/projectdiscovery/nuclei-templates into ~/nuclei-templates or pass --nuclei-templates <path>",
})
return nil
}
dest, err := defaultAutoDownloadDir()
if err != nil {
mctx.Bus.Publish(mctx.Ctx, eventbus.ModuleError{
EventMeta: eventbus.EventMeta{At: time.Now(), Source: ModuleName, Target: mctx.Target},
Module: ModuleName,
Err: fmt.Sprintf("cannot determine default templates dir: %v", err),
})
return nil
}
dl := nucleitpl.NewDownloader()
dl.Verbose = mctx.Config.Bool("verbose", false) || mctx.Config.Bool("ai.verbose", false)
if err := dl.EnsureTemplates(dest); err != nil {
mctx.Bus.Publish(mctx.Ctx, eventbus.ModuleError{
EventMeta: eventbus.EventMeta{At: time.Now(), Source: ModuleName, Target: mctx.Target},
Module: ModuleName,
Err: fmt.Sprintf("auto-download nuclei templates: %v", err),
})
return nil
}
tplDir = dest
}
tpls, diags, err := nucleitpl.LoadDir(tplDir)
if err != nil {
return fmt.Errorf("load templates from %s: %w", tplDir, err)
}
supported := 0
skipped := 0
var supportedTpls []*nucleitpl.Template
for _, t := range tpls {
if ok, _ := t.IsSupported(); ok {
supported++
supportedTpls = append(supportedTpls, t)
} else {
skipped++
}
}
if supported == 0 {
mctx.Bus.Publish(mctx.Ctx, eventbus.ModuleError{
EventMeta: eventbus.EventMeta{At: time.Now(), Source: ModuleName, Target: mctx.Target},
Module: ModuleName,
Err: fmt.Sprintf("loaded %d templates, 0 supported (skipped %d, parse errors %d)", len(tpls), skipped, len(diags)),
})
return nil
}
timeout := time.Duration(mctx.Config.Int("timeout", 10)) * time.Second
client := gohttp.GetSharedClient(int(timeout.Seconds()))
exec := nucleitpl.NewExecutor(client, timeout)
// Gather target URLs from the store.
var targets []string
for _, h := range mctx.Store.All(mctx.Ctx) {
if h == nil || h.StatusCode == 0 {
continue
}
targets = append(targets, "https://"+h.Subdomain)
}
if len(targets) == 0 {
return nil
}
// Bounded parallelism: running thousands of templates × hundreds of
// hosts unbounded would be a DoS against ourselves and the target.
maxConcurrent := mctx.Config.Int("concurrency", 50)
if maxConcurrent > 50 {
maxConcurrent = 50 // cap — templates make 1-3 requests each
}
if maxConcurrent < 1 {
maxConcurrent = 10
}
sem := make(chan struct{}, maxConcurrent)
var wg sync.WaitGroup
for _, url := range targets {
for _, t := range supportedTpls {
if mctx.Ctx.Err() != nil {
break
}
url := url
t := t
wg.Add(1)
sem <- struct{}{}
go func() {
defer wg.Done()
defer func() { <-sem }()
runCtx, cancel := context.WithTimeout(mctx.Ctx, timeout)
defer cancel()
for _, m := range exec.Run(runCtx, t, url) {
publishMatch(mctx, m)
}
}()
}
}
wg.Wait()
if skipped > 0 {
mctx.Bus.Publish(mctx.Ctx, eventbus.ModuleError{
EventMeta: eventbus.EventMeta{At: time.Now(), Source: ModuleName, Target: mctx.Target},
Module: ModuleName,
Err: fmt.Sprintf("executed %d templates, skipped %d (unsupported protocol/features)", supported, skipped),
})
}
return nil
}
// publishMatch persists the match into the store and fires a
// VulnerabilityFound event. When the match references CVEs, a CVEMatch
// event is also fired so the CVE aggregator sees it.
func publishMatch(mctx module.Context, m nucleitpl.Match) {
now := time.Now()
severity := mapSeverity(m.Severity)
host := hostFromURL(m.URL)
_ = mctx.Store.Upsert(mctx.Ctx, host, func(h *store.Host) {
h.Vulnerabilities = append(h.Vulnerabilities, store.Vulnerability{
ID: "nuclei/" + m.TemplateID,
Title: m.Name,
Description: m.Description,
Severity: string(severity),
URL: m.URL,
Evidence: m.Evidence,
CVEs: append([]string(nil), m.CVEs...),
FoundAt: now,
})
for _, cveID := range m.CVEs {
h.CVEs = append(h.CVEs, store.CVE{
ID: cveID,
Technology: m.TemplateID,
Severity: string(severity),
FoundAt: now,
URL: m.TemplateURL,
})
}
})
mctx.Bus.Publish(mctx.Ctx, eventbus.VulnerabilityFound{
EventMeta: eventbus.EventMeta{At: now, Source: ModuleName, Target: host},
ID: "nuclei/" + m.TemplateID,
Title: m.Name,
Description: m.Description,
Severity: severity,
URL: m.URL,
Evidence: m.Evidence,
CVEs: append([]string(nil), m.CVEs...),
})
for _, cveID := range m.CVEs {
mctx.Bus.Publish(mctx.Ctx, eventbus.CVEMatch{
EventMeta: eventbus.EventMeta{At: now, Source: ModuleName, Target: host},
CVE: cveID,
Technology: m.TemplateID,
Severity: severity,
Description: m.Name,
URL: m.TemplateURL,
})
}
}
func mapSeverity(s string) eventbus.Severity {
switch s {
case "critical":
return eventbus.SeverityCritical
case "high":
return eventbus.SeverityHigh
case "medium":
return eventbus.SeverityMedium
case "low":
return eventbus.SeverityLow
default:
return eventbus.SeverityInfo
}
}
// resolveTemplateDir returns the first USABLE template directory, in
// priority order. "Usable" means it exists, is a directory, and the
// process can list its contents (i.e. not a permission-denied mount
// like a read-restricted nuclei install in another user's home).
// Returns "" when no candidate qualifies.
func resolveTemplateDir(mctx module.Context) string {
candidates := []string{
mctx.Config.String("nuclei_templates", ""),
os.Getenv("NUCLEI_TEMPLATES"),
}
if home, err := os.UserHomeDir(); err == nil {
// Prefer the god-eye auto-managed cache over a pre-existing
// ~/nuclei-templates: the latter may be a nuclei CLI install
// with restrictive permissions we can't read.
candidates = append(candidates,
filepath.Join(home, ".god-eye", "nuclei-templates"),
filepath.Join(home, "nuclei-templates"),
)
}
for _, c := range candidates {
if c == "" {
continue
}
info, err := os.Stat(c)
if err != nil || !info.IsDir() {
continue
}
// Readability check: can we list at least one entry? If the dir
// is permission-denied, os.Stat succeeds but os.Open fails —
// skip such candidates so auto-download fallback triggers.
f, err := os.Open(c)
if err != nil {
continue
}
names, err := f.Readdirnames(1)
f.Close()
if err != nil {
continue
}
if len(names) == 0 {
// Empty dir — treat as unusable to trigger auto-download.
continue
}
return c
}
return ""
}
// defaultAutoDownloadDir returns ~/.god-eye/nuclei-templates.
func defaultAutoDownloadDir() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(home, ".god-eye", "nuclei-templates"), nil
}
func hostFromURL(u string) string {
// Strip scheme.
s := u
for _, p := range []string{"https://", "http://"} {
if len(s) > len(p) && s[:len(p)] == p {
s = s[len(p):]
break
}
}
// Strip path.
for i := 0; i < len(s); i++ {
if s[i] == '/' || s[i] == '?' || s[i] == '#' {
return s[:i]
}
}
return s
}
+151
View File
@@ -0,0 +1,151 @@
// Package passive is the Fase 0.6 adapter that wraps the v1 passive sources
// (internal/sources) as a single Module. It fans out queries to all 20 public
// sources in parallel and emits a SubdomainDiscovered event for each result.
//
// In Fase 1 (Discovery Supremacy) each source will become its own Module with
// independent configuration, error reporting, and rate limiting. This
// adapter preserves v1 behavior so we reach feature parity immediately.
package passive
import (
"context"
"strings"
"sync"
"god-eye/internal/eventbus"
"god-eye/internal/module"
"god-eye/internal/sources"
"god-eye/internal/store"
)
// ModuleName is the registry identifier.
const ModuleName = "passive.v1-aggregate"
type passiveModule struct{}
// Register the module in the default registry. Callers import this package
// for side effects via the modules meta-package (see internal/modules/all).
func Register() { module.Register(&passiveModule{}) }
func (*passiveModule) Name() string { return ModuleName }
func (*passiveModule) Phase() module.Phase { return module.PhaseDiscovery }
func (*passiveModule) Consumes() []eventbus.EventType { return nil }
func (*passiveModule) Produces() []eventbus.EventType {
return []eventbus.EventType{eventbus.EventSubdomainDiscovered, eventbus.EventModuleError}
}
func (*passiveModule) DefaultEnabled() bool { return true }
// sourceList mirrors the v1 scanner.Run list. Order is preserved for stable
// logging.
var sourceList = []struct {
name string
fn func(string) ([]string, error)
}{
{"crt.sh", sources.FetchCrtsh},
{"Certspotter", sources.FetchCertspotter},
{"AlienVault", sources.FetchAlienVault},
{"HackerTarget", sources.FetchHackerTarget},
{"URLScan", sources.FetchURLScan},
{"RapidDNS", sources.FetchRapidDNS},
{"Anubis", sources.FetchAnubis},
{"ThreatMiner", sources.FetchThreatMiner},
{"DNSRepo", sources.FetchDNSRepo},
{"SubdomainCenter", sources.FetchSubdomainCenter},
{"Wayback", sources.FetchWayback},
{"CommonCrawl", sources.FetchCommonCrawl},
{"Sitedossier", sources.FetchSitedossier},
{"Riddler", sources.FetchRiddler},
{"Robtex", sources.FetchRobtex},
{"DNSHistory", sources.FetchDNSHistory},
{"ArchiveToday", sources.FetchArchiveToday},
{"JLDC", sources.FetchJLDC},
{"SynapsInt", sources.FetchSynapsInt},
{"CensysFree", sources.FetchCensysFree},
// v2.0 additions — free, no API key, fail-open. Dormant v1 sources
// re-activated + 4 net-new endpoints.
{"BufferOver", sources.FetchBufferOver}, // dormant v1
{"DNSDumpster", sources.FetchDNSDumpster}, // dormant v1
{"Omnisint", sources.FetchOmnisint}, // v2 new
{"HudsonRock", sources.FetchHudsonRock}, // v2 new
{"WebArchiveCDX", sources.FetchWebArchiveCDX}, // v2 new
{"Digitorus", sources.FetchDigitorus}, // v2 new
}
func (m *passiveModule) Run(mctx module.Context) error {
target := mctx.Target
if target == "" {
return nil
}
var wg sync.WaitGroup
// Dedup across sources before emitting — the store will also dedup, but
// emitting duplicates just burns bus bandwidth.
seen := make(map[string]struct{})
var seenMu sync.Mutex
for _, src := range sourceList {
src := src
wg.Add(1)
go func() {
defer wg.Done()
// Respect ctx cancellation between slow sources.
if err := mctx.Ctx.Err(); err != nil {
return
}
subs, err := src.fn(target)
if err != nil {
mctx.Bus.Publish(mctx.Ctx, eventbus.ModuleError{
EventMeta: eventbus.EventMeta{Source: ModuleName + ":" + src.name, Target: target},
Module: ModuleName + ":" + src.name,
Err: err.Error(),
})
return
}
for _, sub := range subs {
sub = strings.ToLower(strings.TrimSpace(sub))
if sub == "" {
continue
}
if !strings.HasSuffix(sub, target) {
continue
}
seenMu.Lock()
if _, dup := seen[sub]; dup {
seenMu.Unlock()
continue
}
seen[sub] = struct{}{}
seenMu.Unlock()
// Persist into the store so downstream resolution phases
// can find the subdomain even if they subscribed too late
// to receive the SubdomainDiscovered event.
methodTag := "passive:" + src.name
_ = mctx.Store.Upsert(mctx.Ctx, sub, func(h *store.Host) {
store.AddDiscoveryMethod(h, methodTag)
})
mctx.Bus.Publish(mctx.Ctx, eventbus.NewSubdomainDiscovered(
ModuleName+":"+src.name,
sub,
methodTag,
))
}
}()
}
// Wait for sources OR cancellation.
done := make(chan struct{})
go func() { wg.Wait(); close(done) }()
select {
case <-done:
case <-mctx.Ctx.Done():
}
_ = context.Canceled // keep import
return nil
}
+177
View File
@@ -0,0 +1,177 @@
// Package permutation generates candidate subdomains by mutating every
// previously-discovered subdomain with a set of common prefixes/suffixes
// and resolving them. This is the "alterx" pattern: you already found
// api.example.com and dev.example.com, now try api-dev, dev-api,
// api-staging, api.dev.example.com, etc.
//
// Pattern learning is intentionally lightweight in Fase 1: the core v1
// discovery.PatternLearner already extracts per-label frequencies. We
// feed those back in via candidate generation.
package permutation
import (
"strings"
"sync"
"time"
"god-eye/internal/config"
godns "god-eye/internal/dns"
"god-eye/internal/eventbus"
"god-eye/internal/module"
"god-eye/internal/store"
)
const ModuleName = "discovery.permutation"
type permModule struct{}
func Register() { module.Register(&permModule{}) }
func (*permModule) Name() string { return ModuleName }
func (*permModule) Phase() module.Phase { return module.PhaseResolution }
func (*permModule) Consumes() []eventbus.EventType { return []eventbus.EventType{eventbus.EventDNSResolved} }
func (*permModule) Produces() []eventbus.EventType {
return []eventbus.EventType{eventbus.EventSubdomainDiscovered}
}
func (*permModule) DefaultEnabled() bool { return false } // opt-in (burns a lot of DNS)
// commonAffixes are applied to each label of discovered hostnames to
// generate permutation candidates. Curated for bug-bounty signal.
var commonAffixes = []string{
"dev", "stg", "staging", "prod", "qa", "test", "uat", "sandbox", "preview",
"internal", "int", "private", "admin", "api", "api2", "apiv2", "gw",
"new", "old", "legacy", "v2", "v3", "next", "beta", "alpha", "canary",
"eu", "us", "apac", "emea",
}
var separators = []string{"-", "_", "."}
func (*permModule) Run(mctx module.Context) error {
if !mctx.Config.Bool("permutation", false) {
return nil
}
target := mctx.Target
timeout := mctx.Config.Int("timeout", 5)
resolvers := parseResolvers(mctx.Config.String("resolvers", ""))
conc := mctx.Config.Int("concurrency", 300)
if conc <= 0 {
conc = 300
}
// Gather seeds from the store (all already-resolved hosts).
seeds := mctx.Store.All(mctx.Ctx)
if len(seeds) == 0 {
return nil
}
candidates := make(map[string]struct{})
for _, h := range seeds {
for _, c := range generateCandidates(h.Subdomain, target) {
candidates[c] = struct{}{}
}
}
// Resolve candidates in parallel. Only emit ones that resolve.
sem := make(chan struct{}, conc)
var wg sync.WaitGroup
for cand := range candidates {
if mctx.Ctx.Err() != nil {
break
}
cand := cand
wg.Add(1)
sem <- struct{}{}
go func() {
defer wg.Done()
defer func() { <-sem }()
ips := godns.ResolveSubdomain(cand, resolvers, timeout)
if len(ips) == 0 {
return
}
_ = mctx.Store.Upsert(mctx.Ctx, cand, func(h *store.Host) {
store.AddIPs(h, ips)
store.AddDiscoveryMethod(h, "permutation")
})
mctx.Bus.Publish(mctx.Ctx, eventbus.SubdomainDiscovered{
EventMeta: eventbus.EventMeta{At: time.Now(), Source: ModuleName, Target: cand},
Subdomain: cand,
Method: "permutation",
})
}()
}
wg.Wait()
return nil
}
// generateCandidates produces permuted hostnames from a seed within the
// target domain. The output is guaranteed to end in "."+target or ==target.
func generateCandidates(seed, target string) []string {
if !strings.HasSuffix(seed, target) {
return nil
}
prefix := strings.TrimSuffix(seed, "."+target)
if prefix == target || prefix == "" {
return nil
}
labels := strings.Split(prefix, ".")
if len(labels) == 0 {
return nil
}
out := make(map[string]struct{})
// Leaf-label mutations: (affix)(sep)(label) and (label)(sep)(affix).
leaf := labels[len(labels)-1]
rest := strings.Join(labels[:len(labels)-1], ".")
for _, aff := range commonAffixes {
for _, sep := range separators {
combos := []string{
aff + sep + leaf,
leaf + sep + aff,
}
for _, c := range combos {
parts := []string{c}
if rest != "" {
parts = []string{rest, c}
}
cand := strings.Join(parts, ".") + "." + target
out[cand] = struct{}{}
}
}
}
// Prepend-an-affix mutation: aff.<existing>
for _, aff := range commonAffixes {
cand := aff + "." + prefix + "." + target
out[cand] = struct{}{}
}
res := make([]string, 0, len(out))
for c := range out {
res = append(res, c)
}
return res
}
func parseResolvers(s string) []string {
s = strings.TrimSpace(s)
if s == "" {
return config.DefaultResolvers
}
var out []string
for _, r := range strings.Split(s, ",") {
r = strings.TrimSpace(r)
if r == "" {
continue
}
if !strings.Contains(r, ":") {
r = r + ":53"
}
out = append(out, r)
}
if len(out) == 0 {
return config.DefaultResolvers
}
return out
}
+120
View File
@@ -0,0 +1,120 @@
// Package ports runs a TCP connect scan on the common ports list for every
// resolved host. Drains the store at start; also reacts to late DNSResolved
// events for concurrent discovery phases.
package ports
import (
"context"
"fmt"
"strings"
"sync"
"time"
"god-eye/internal/eventbus"
"god-eye/internal/module"
"god-eye/internal/scanner"
"god-eye/internal/store"
)
const ModuleName = "ports.scan"
type portsModule struct{}
func Register() { module.Register(&portsModule{}) }
func (*portsModule) Name() string { return ModuleName }
func (*portsModule) Phase() module.Phase { return module.PhaseEnrichment }
func (*portsModule) Consumes() []eventbus.EventType { return []eventbus.EventType{eventbus.EventDNSResolved} }
func (*portsModule) Produces() []eventbus.EventType { return nil }
func (*portsModule) DefaultEnabled() bool { return true }
func (*portsModule) Run(mctx module.Context) error {
if mctx.Config.Bool("no_ports", false) {
return nil
}
timeout := mctx.Config.Int("timeout", 5)
portList := parsePorts(mctx.Config.String("ports", ""))
processed := make(map[string]struct{})
var mu sync.Mutex
shouldProcess := func(host string) bool {
mu.Lock()
defer mu.Unlock()
if _, ok := processed[host]; ok {
return false
}
processed[host] = struct{}{}
return true
}
scan := func(host string, ip string) {
if mctx.Ctx.Err() != nil {
return
}
open := scanner.ScanPorts(ip, portList, timeout)
if len(open) == 0 {
return
}
_ = mctx.Store.Upsert(mctx.Ctx, host, func(h *store.Host) {
h.Ports = append(h.Ports, open...)
})
}
var wg sync.WaitGroup
// Drain.
for _, h := range mctx.Store.All(mctx.Ctx) {
if h == nil || h.Subdomain == "" || len(h.IPs) == 0 {
continue
}
if !shouldProcess(h.Subdomain) {
continue
}
host := h.Subdomain
ip := h.IPs[0]
wg.Add(1)
go func() { defer wg.Done(); scan(host, ip) }()
}
// Late events.
sub := mctx.Bus.Subscribe(eventbus.EventDNSResolved, func(_ context.Context, e eventbus.Event) {
ev, ok := e.(eventbus.DNSResolved)
if !ok || len(ev.IPs) == 0 {
return
}
if !shouldProcess(ev.Subdomain) {
return
}
host := ev.Subdomain
ip := ev.IPs[0]
wg.Add(1)
go func() { defer wg.Done(); scan(host, ip) }()
})
defer sub.Unsubscribe()
select {
case <-time.After(500 * time.Millisecond):
case <-mctx.Ctx.Done():
}
wg.Wait()
return nil
}
func parsePorts(s string) []int {
s = strings.TrimSpace(s)
if s == "" {
return []int{80, 443, 8080, 8443}
}
var out []int
for _, p := range strings.Split(s, ",") {
var port int
if _, err := fmt.Sscanf(strings.TrimSpace(p), "%d", &port); err == nil && port > 0 && port < 65536 {
out = append(out, port)
}
}
if len(out) == 0 {
return []int{80, 443, 8080, 8443}
}
return out
}
+117
View File
@@ -0,0 +1,117 @@
// Package recursive is a Fase 0.6 adapter for the v1 recursive discovery
// engine (pattern learning from found subdomains).
//
// Unlike event-driven modules, recursive runs as a deferred second-pass:
// after PhaseDiscovery completes it collects every host seen so far from
// the store, runs the v1 engine, and emits SubdomainDiscovered for any
// new hosts. It self-schedules in PhaseResolution to sit between discovery
// and HTTP probing.
package recursive
import (
"time"
"god-eye/internal/discovery"
"god-eye/internal/eventbus"
"god-eye/internal/module"
"god-eye/internal/store"
"strings"
)
const ModuleName = "discovery.recursive"
type recModule struct{}
func Register() { module.Register(&recModule{}) }
func (*recModule) Name() string { return ModuleName }
func (*recModule) Phase() module.Phase { return module.PhaseResolution } // runs after discovery
func (*recModule) Consumes() []eventbus.EventType { return []eventbus.EventType{eventbus.EventSubdomainDiscovered} }
func (*recModule) Produces() []eventbus.EventType {
return []eventbus.EventType{eventbus.EventSubdomainDiscovered}
}
// Recursive is opt-in by default — profiles enable it for bugbounty/pentest.
func (*recModule) DefaultEnabled() bool { return false }
func (*recModule) Run(mctx module.Context) error {
if !mctx.Config.Bool("recursive", false) {
return nil
}
target := mctx.Target
depth := mctx.Config.Int("recursive.depth", 3)
if depth < 1 {
depth = 1
} else if depth > 5 {
depth = 5
}
timeout := mctx.Config.Int("timeout", 5)
conc := mctx.Config.Int("concurrency", 500)
if conc <= 0 {
conc = 500
}
resolvers := parseResolvers(mctx.Config.String("resolvers", ""))
// Gather initial seeds from what's been discovered so far.
hosts := mctx.Store.All(mctx.Ctx)
seeds := make([]string, 0, len(hosts))
for _, h := range hosts {
seeds = append(seeds, h.Subdomain)
}
if len(seeds) == 0 {
return nil
}
rd := discovery.NewRecursiveDiscovery(discovery.RecursiveConfig{
Domain: target,
Resolvers: resolvers,
Timeout: timeout,
MaxDepth: depth,
Concurrency: conc,
})
found := rd.Discover(mctx.Ctx, seeds)
// Emit SubdomainDiscovered for any new hosts.
seen := make(map[string]struct{}, len(seeds))
for _, s := range seeds {
seen[s] = struct{}{}
}
for _, s := range found {
if _, dup := seen[s]; dup {
continue
}
seen[s] = struct{}{}
_ = mctx.Store.Upsert(mctx.Ctx, s, func(h *store.Host) {
store.AddDiscoveryMethod(h, "recursive")
})
mctx.Bus.Publish(mctx.Ctx, eventbus.SubdomainDiscovered{
EventMeta: eventbus.EventMeta{At: time.Now(), Source: ModuleName, Target: s},
Subdomain: s,
Method: "recursive",
})
}
return nil
}
func parseResolvers(s string) []string {
s = strings.TrimSpace(s)
if s == "" {
return []string{"8.8.8.8:53", "1.1.1.1:53"}
}
var out []string
for _, r := range strings.Split(s, ",") {
r = strings.TrimSpace(r)
if r == "" {
continue
}
if !strings.Contains(r, ":") {
r = r + ":53"
}
out = append(out, r)
}
return out
}
+259
View File
@@ -0,0 +1,259 @@
// Package report writes the final scan output. It consumes the store (not
// events) at ScanCompleted time and emits TXT / JSON / CSV via the existing
// v1 output.WriteOutput function. To preserve v1 output shape during the
// Fase 0.6 migration, store.Host records are projected to the legacy
// config.SubdomainResult type before serialization.
package report
import (
"context"
"encoding/json"
"fmt"
"os"
"strings"
"time"
"god-eye/internal/config"
"god-eye/internal/eventbus"
"god-eye/internal/module"
"god-eye/internal/output"
)
var _ = time.Now // keep import stable when unused in certain branches
const ModuleName = "report.output"
type reportModule struct{}
func Register() { module.Register(&reportModule{}) }
func (*reportModule) Name() string { return ModuleName }
func (*reportModule) Phase() module.Phase { return module.PhaseReporting }
func (*reportModule) Consumes() []eventbus.EventType { return []eventbus.EventType{eventbus.EventScanCompleted} }
func (*reportModule) Produces() []eventbus.EventType { return nil }
func (*reportModule) DefaultEnabled() bool { return true }
func (*reportModule) Run(mctx module.Context) error {
// Block until the scan is complete — we're last in the pipeline and the
// coordinator guarantees reporting runs after every earlier phase.
done := make(chan struct{}, 1)
sub := mctx.Bus.Subscribe(eventbus.EventScanCompleted, func(_ context.Context, _ eventbus.Event) {
select {
case done <- struct{}{}:
default:
}
})
defer sub.Unsubscribe()
// The report module itself runs in PhaseReporting which is the last
// phase. ScanCompleted fires right after this phase ends, so we can't
// rely on it — write output directly from the store instead.
_ = done
results := projectStoreToResults(mctx)
if len(results) == 0 {
return nil
}
silent := mctx.Config.Bool("silent", false)
jsonStdout := mctx.Config.Bool("json", false)
onlyActive := mctx.Config.Bool("only_active", false)
outPath := mctx.Config.String("output", "")
format := mctx.Config.String("format", "txt")
if jsonStdout {
// Project a minimal JSON report to stdout, shape-compatible with v1.
writeJSONStdout(mctx, results)
return nil
}
// Console presentation — only when not silent / not JSON-only mode.
if !silent {
printResults(results, onlyActive)
}
if outPath != "" {
if err := writeFile(outPath, format, results); err != nil {
mctx.Bus.Publish(mctx.Ctx, eventbus.ModuleError{
EventMeta: eventbus.EventMeta{At: time.Now(), Source: ModuleName, Target: mctx.Target},
Module: ModuleName,
Err: fmt.Sprintf("write output %s: %v", outPath, err),
})
return err
}
}
return nil
}
// projectStoreToResults converts store.Host records to the legacy
// config.SubdomainResult shape expected by output.WriteOutput. Doing the
// projection here keeps the store schema decoupled from the v1 output format.
func projectStoreToResults(mctx module.Context) map[string]*config.SubdomainResult {
hosts := mctx.Store.All(mctx.Ctx)
out := make(map[string]*config.SubdomainResult, len(hosts))
for _, h := range hosts {
r := &config.SubdomainResult{
Subdomain: h.Subdomain,
IPs: append([]string(nil), h.IPs...),
CNAME: h.CNAME,
PTR: h.PTR,
ASN: h.ASN,
Org: h.Org,
Country: h.Country,
City: h.City,
StatusCode: h.StatusCode,
ContentLength: h.ContentLength,
Title: h.Title,
Server: h.Server,
Tech: append([]string(nil), h.Technologies...),
WAF: h.WAF,
TLSVersion: h.TLSVersion,
TLSIssuer: h.TLSIssuer,
TLSSelfSigned: h.TLSSelfSigned,
Ports: append([]int(nil), h.Ports...),
ResponseMs: h.ResponseMs,
CloudProvider: h.CloudProvider,
}
if !h.TLSExpiry.IsZero() {
r.TLSExpiry = h.TLSExpiry.Format("2006-01-02")
}
if h.TLSFingerprint != nil {
r.TLSFingerprint = &config.TLSFingerprint{
Vendor: h.TLSFingerprint.Vendor,
Product: h.TLSFingerprint.Product,
Version: h.TLSFingerprint.Version,
ApplianceType: h.TLSFingerprint.ApplianceKind,
InternalHosts: append([]string(nil), h.TLSFingerprint.InternalHosts...),
}
}
if h.Takeover != nil {
r.Takeover = h.Takeover.Service
}
// Flatten vulnerabilities → scalar fields v1 consumers expect.
for _, v := range h.Vulnerabilities {
switch v.ID {
case "open-redirect":
r.OpenRedirect = true
case "cors-misconfig":
r.CORSMisconfig = v.Description
case "dangerous-http-methods":
r.DangerousMethods = append(r.DangerousMethods, strings.Split(v.Evidence, ", ")...)
case "git-exposed":
r.GitExposed = true
case "svn-exposed":
r.SvnExposed = true
case "backup-file":
r.BackupFiles = append(r.BackupFiles, v.URL)
}
}
// Secrets → legacy field
for _, s := range h.Secrets {
r.JSSecrets = append(r.JSSecrets, s.Match)
}
// CVEs / AI
for _, c := range h.CVEs {
r.CVEFindings = append(r.CVEFindings, c.ID)
}
for _, a := range h.AIFindings {
r.AIFindings = append(r.AIFindings, a.Title)
if r.AISeverity == "" {
r.AISeverity = a.Severity
}
if r.AIModel == "" {
r.AIModel = a.Model
}
}
out[h.Subdomain] = r
}
return out
}
// printResults is a minimal, non-colorful table print. The full v1
// presentation is re-introduced when the TUI module lands in Fase 4.
func printResults(results map[string]*config.SubdomainResult, onlyActive bool) {
// Sorted output for determinism.
names := make([]string, 0, len(results))
for n := range results {
names = append(names, n)
}
// sort by status desc, then name
sortResultsForPrint(names, results)
active := 0
for _, n := range names {
r := results[n]
if r.StatusCode == 0 {
if onlyActive {
continue
}
fmt.Printf(" %s %s\n", output.Dim("○"), r.Subdomain)
continue
}
active++
marker := output.Green("●")
if r.StatusCode >= 300 && r.StatusCode < 400 {
marker = output.Yellow("◐")
} else if r.StatusCode >= 400 {
marker = output.Red("○")
}
tech := ""
if len(r.Tech) > 0 {
tech = output.Dim(" [" + strings.Join(r.Tech, ", ") + "]")
}
fmt.Printf(" %s %s %s%s\n", marker, r.Subdomain, output.Dim(fmt.Sprintf("[%d]", r.StatusCode)), tech)
}
fmt.Println()
fmt.Printf(" %s total, %s active\n", output.BoldWhite(fmt.Sprintf("%d", len(results))), output.BoldGreen(fmt.Sprintf("%d", active)))
}
func sortResultsForPrint(names []string, results map[string]*config.SubdomainResult) {
// Simple insertion-sort quality ok for small lists; stable enough.
n := len(names)
for i := 1; i < n; i++ {
j := i
for j > 0 && lessResult(results[names[j]], results[names[j-1]]) {
names[j], names[j-1] = names[j-1], names[j]
j--
}
}
}
func lessResult(a, b *config.SubdomainResult) bool {
// Active first, then by subdomain name.
aActive := a.StatusCode >= 200 && a.StatusCode < 400
bActive := b.StatusCode >= 200 && b.StatusCode < 400
if aActive != bActive {
return aActive && !bActive
}
return a.Subdomain < b.Subdomain
}
func writeFile(path, format string, results map[string]*config.SubdomainResult) error {
// v1 exposes SaveOutput (void); we funnel through it but surface errors
// by re-checking file writability up front.
format = strings.ToLower(strings.TrimSpace(format))
if format == "" {
format = "txt"
}
// Pre-flight: make sure we can create the target file before delegating.
f, err := os.Create(path)
if err != nil {
return err
}
f.Close()
output.SaveOutput(path, format, results)
return nil
}
// writeJSONStdout emits a v2-native minimal JSON dump to stdout. This is
// intentionally simpler than v1's ReportBuilder — when the full report
// generator lands in Fase 4 (Reporting), this is where it'll be wired.
func writeJSONStdout(mctx module.Context, results map[string]*config.SubdomainResult) {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
_ = enc.Encode(map[string]interface{}{
"target": mctx.Target,
"subdomains": results,
})
}
+143
View File
@@ -0,0 +1,143 @@
// Package reversedns expands discovery by doing PTR sweeps on /24 blocks
// surrounding every resolved IP. Finds internal/forgotten hosts that share
// infrastructure with already-known subdomains.
//
// Intentionally conservative: only sweeps +/- 32 addresses around seen IPs
// to keep traffic bounded and avoid accidentally pulling a huge
// non-scoped ASN.
package reversedns
import (
"fmt"
"net"
"strings"
"sync"
"time"
"god-eye/internal/config"
godns "god-eye/internal/dns"
"god-eye/internal/eventbus"
"god-eye/internal/module"
"god-eye/internal/store"
)
const ModuleName = "discovery.reverse-dns"
type rdnsModule struct{}
func Register() { module.Register(&rdnsModule{}) }
func (*rdnsModule) Name() string { return ModuleName }
func (*rdnsModule) Phase() module.Phase { return module.PhaseResolution }
func (*rdnsModule) Consumes() []eventbus.EventType { return []eventbus.EventType{eventbus.EventDNSResolved} }
func (*rdnsModule) Produces() []eventbus.EventType {
return []eventbus.EventType{eventbus.EventSubdomainDiscovered}
}
// Opt-in: generates a lot of DNS queries; on by default for bugbounty profile.
func (*rdnsModule) DefaultEnabled() bool { return false }
const sweepRange = 16 // how many addresses to scan either side of each seed IP
func (*rdnsModule) Run(mctx module.Context) error {
if !mctx.Config.Bool("reverse_dns", false) {
return nil
}
target := mctx.Target
timeout := mctx.Config.Int("timeout", 5)
resolvers := parseResolvers(mctx.Config.String("resolvers", ""))
seeds := mctx.Store.All(mctx.Ctx)
seenIP := make(map[string]struct{})
for _, h := range seeds {
for _, ip := range h.IPs {
seenIP[ip] = struct{}{}
}
}
var wg sync.WaitGroup
sem := make(chan struct{}, 64)
for ip := range seenIP {
for _, neighbor := range neighbors(ip, sweepRange) {
if mctx.Ctx.Err() != nil {
break
}
wg.Add(1)
sem <- struct{}{}
go func(ipAddr string) {
defer wg.Done()
defer func() { <-sem }()
name := godns.ResolvePTR(ipAddr, resolvers, timeout)
if name == "" {
return
}
name = strings.ToLower(strings.TrimSuffix(name, "."))
if !strings.HasSuffix(name, "."+target) && name != target {
return
}
_ = mctx.Store.Upsert(mctx.Ctx, name, func(h *store.Host) {
store.AddIPs(h, []string{ipAddr})
store.AddDiscoveryMethod(h, "reverse-dns")
})
mctx.Bus.Publish(mctx.Ctx, eventbus.SubdomainDiscovered{
EventMeta: eventbus.EventMeta{At: time.Now(), Source: ModuleName, Target: name},
Subdomain: name,
Method: "reverse-dns",
})
}(neighbor)
}
}
wg.Wait()
return nil
}
// neighbors returns IPv4 addresses within +/- rng of ip. IPv6 addresses
// are returned as a single-element slice (no sweep — address space too
// large, and we'd rarely find anything anyway).
func neighbors(ipStr string, rng int) []string {
ip := net.ParseIP(ipStr)
if ip == nil {
return nil
}
v4 := ip.To4()
if v4 == nil {
return []string{ipStr}
}
// Convert to uint32 for arithmetic.
base := uint32(v4[0])<<24 | uint32(v4[1])<<16 | uint32(v4[2])<<8 | uint32(v4[3])
out := make([]string, 0, 2*rng+1)
for delta := -rng; delta <= rng; delta++ {
candidate := int64(base) + int64(delta)
if candidate < 0 || candidate > 0xFFFFFFFF {
continue
}
c := uint32(candidate)
out = append(out, fmt.Sprintf("%d.%d.%d.%d", c>>24&0xFF, c>>16&0xFF, c>>8&0xFF, c&0xFF))
}
return out
}
func parseResolvers(s string) []string {
s = strings.TrimSpace(s)
if s == "" {
return config.DefaultResolvers
}
var out []string
for _, r := range strings.Split(s, ",") {
r = strings.TrimSpace(r)
if r == "" {
continue
}
if !strings.Contains(r, ":") {
r = r + ":53"
}
out = append(out, r)
}
if len(out) == 0 {
return config.DefaultResolvers
}
return out
}
+241
View File
@@ -0,0 +1,241 @@
// Package security runs the v1 security checks (open redirect, CORS,
// HTTP methods, git/svn, backups, admin, API) on every probed host.
//
// Reads hosts from the store (not events) so late-start phases don't miss
// the upstream HTTPProbed events.
package security
import (
"context"
"sync"
"time"
"god-eye/internal/eventbus"
gohttp "god-eye/internal/http"
"god-eye/internal/module"
"god-eye/internal/security"
"god-eye/internal/store"
)
const ModuleName = "security.checks"
type secModule struct{}
func Register() { module.Register(&secModule{}) }
func (*secModule) Name() string { return ModuleName }
func (*secModule) Phase() module.Phase { return module.PhaseAnalysis }
func (*secModule) Consumes() []eventbus.EventType { return []eventbus.EventType{eventbus.EventHTTPProbed} }
func (*secModule) Produces() []eventbus.EventType { return []eventbus.EventType{eventbus.EventVulnerability} }
func (*secModule) DefaultEnabled() bool { return true }
func (*secModule) Run(mctx module.Context) error {
conc := mctx.Config.Int("concurrency", 200)
if conc <= 0 {
conc = 200
}
timeout := mctx.Config.Int("timeout", 5)
processed := make(map[string]struct{})
var processedMu sync.Mutex
shouldProcess := func(host string) bool {
processedMu.Lock()
defer processedMu.Unlock()
if _, dup := processed[host]; dup {
return false
}
processed[host] = struct{}{}
return true
}
work := make(chan string, conc*2)
var wg sync.WaitGroup
for i := 0; i < conc; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for host := range work {
runChecks(mctx, host, timeout)
}
}()
}
// Drain: every host that got a successful HTTP probe.
for _, h := range mctx.Store.All(mctx.Ctx) {
if h == nil || h.StatusCode == 0 {
continue
}
if !shouldProcess(h.Subdomain) {
continue
}
select {
case work <- h.Subdomain:
case <-mctx.Ctx.Done():
close(work)
wg.Wait()
return nil
}
}
// Listen for late HTTPProbed events.
sub := mctx.Bus.Subscribe(eventbus.EventHTTPProbed, func(_ context.Context, e eventbus.Event) {
ev, ok := e.(eventbus.HTTPProbed)
if !ok || ev.StatusCode == 0 {
return
}
host := ev.Meta().Target
if !shouldProcess(host) {
return
}
select {
case work <- host:
case <-mctx.Ctx.Done():
}
})
defer sub.Unsubscribe()
select {
case <-time.After(500 * time.Millisecond):
case <-mctx.Ctx.Done():
}
close(work)
wg.Wait()
return nil
}
func runChecks(mctx module.Context, host string, timeout int) {
if mctx.Ctx.Err() != nil {
return
}
client := gohttp.GetSharedClient(timeout)
var openRedirect bool
var cors string
var allowed, dangerous []string
var admin, backups, apis []string
var gitExposed, svnExposed bool
var wg sync.WaitGroup
wg.Add(7)
go func() { defer wg.Done(); openRedirect = security.CheckOpenRedirectWithClient(host, client) }()
go func() { defer wg.Done(); cors = security.CheckCORSWithClient(host, client) }()
go func() { defer wg.Done(); allowed, dangerous = security.CheckHTTPMethodsWithClient(host, client) }()
go func() { defer wg.Done(); admin = security.CheckAdminPanelsWithClient(host, client) }()
go func() { defer wg.Done(); gitExposed, svnExposed = security.CheckGitSvnExposureWithClient(host, client) }()
go func() { defer wg.Done(); backups = security.CheckBackupFilesWithClient(host, client) }()
go func() { defer wg.Done(); apis = security.CheckAPIEndpointsWithClient(host, client) }()
wg.Wait()
_ = mctx.Store.Upsert(mctx.Ctx, host, func(h *store.Host) {
now := time.Now()
if openRedirect {
h.Vulnerabilities = append(h.Vulnerabilities, store.Vulnerability{
ID: "open-redirect", Title: "Open Redirect",
Description: "Server redirects to attacker-controlled URL via redirect parameter",
Severity: string(eventbus.SeverityMedium),
URL: "https://" + host,
OWASP: "A01:2021-Broken Access Control",
FoundAt: now,
})
}
if cors != "" {
h.Vulnerabilities = append(h.Vulnerabilities, store.Vulnerability{
ID: "cors-misconfig", Title: "CORS Misconfiguration",
Description: cors,
Severity: string(eventbus.SeverityHigh),
URL: "https://" + host,
OWASP: "A05:2021-Security Misconfiguration",
FoundAt: now,
})
}
if len(dangerous) > 0 {
h.Vulnerabilities = append(h.Vulnerabilities, store.Vulnerability{
ID: "dangerous-http-methods", Title: "Dangerous HTTP Methods Enabled",
Description: "Server allows potentially dangerous methods",
Severity: string(eventbus.SeverityMedium),
Evidence: joinStrings(dangerous, ", "),
URL: "https://" + host,
OWASP: "A05:2021-Security Misconfiguration",
FoundAt: now,
})
}
if gitExposed {
h.Vulnerabilities = append(h.Vulnerabilities, store.Vulnerability{
ID: "git-exposed", Title: "Git Repository Exposed",
Description: ".git directory is publicly accessible",
Severity: string(eventbus.SeverityCritical),
URL: "https://" + host + "/.git/config",
OWASP: "A05:2021-Security Misconfiguration",
FoundAt: now,
})
}
if svnExposed {
h.Vulnerabilities = append(h.Vulnerabilities, store.Vulnerability{
ID: "svn-exposed", Title: "SVN Repository Exposed",
Description: ".svn directory is publicly accessible",
Severity: string(eventbus.SeverityHigh),
URL: "https://" + host + "/.svn/entries",
OWASP: "A05:2021-Security Misconfiguration",
FoundAt: now,
})
}
for _, b := range backups {
h.Vulnerabilities = append(h.Vulnerabilities, store.Vulnerability{
ID: "backup-file", Title: "Backup File Exposed",
Description: "Backup file accessible: " + b,
Severity: string(eventbus.SeverityHigh),
URL: b,
OWASP: "A05:2021-Security Misconfiguration",
FoundAt: now,
})
}
_ = allowed
_ = admin
_ = apis
})
now := time.Now()
base := eventbus.EventMeta{At: now, Source: ModuleName, Target: host}
emit := func(ev eventbus.VulnerabilityFound) { mctx.Bus.Publish(mctx.Ctx, ev) }
if openRedirect {
emit(eventbus.VulnerabilityFound{EventMeta: base, ID: "open-redirect", Title: "Open Redirect",
Severity: eventbus.SeverityMedium, URL: "https://" + host, OWASP: "A01:2021-Broken Access Control"})
}
if cors != "" {
emit(eventbus.VulnerabilityFound{EventMeta: base, ID: "cors-misconfig", Title: "CORS Misconfiguration",
Description: cors, Severity: eventbus.SeverityHigh, URL: "https://" + host, OWASP: "A05:2021-Security Misconfiguration"})
}
if len(dangerous) > 0 {
emit(eventbus.VulnerabilityFound{EventMeta: base, ID: "dangerous-http-methods", Title: "Dangerous HTTP Methods",
Evidence: joinStrings(dangerous, ", "), Severity: eventbus.SeverityMedium, URL: "https://" + host,
OWASP: "A05:2021-Security Misconfiguration"})
}
if gitExposed {
emit(eventbus.VulnerabilityFound{EventMeta: base, ID: "git-exposed", Title: "Git Repository Exposed",
Severity: eventbus.SeverityCritical, URL: "https://" + host + "/.git/config",
OWASP: "A05:2021-Security Misconfiguration"})
}
if svnExposed {
emit(eventbus.VulnerabilityFound{EventMeta: base, ID: "svn-exposed", Title: "SVN Repository Exposed",
Severity: eventbus.SeverityHigh, URL: "https://" + host + "/.svn/entries",
OWASP: "A05:2021-Security Misconfiguration"})
}
for _, b := range backups {
emit(eventbus.VulnerabilityFound{EventMeta: base, ID: "backup-file", Title: "Backup File Exposed",
Severity: eventbus.SeverityHigh, URL: b, OWASP: "A05:2021-Security Misconfiguration"})
}
}
func joinStrings(ss []string, sep string) string {
if len(ss) == 0 {
return ""
}
out := ss[0]
for _, s := range ss[1:] {
out += sep + s
}
return out
}
+227
View File
@@ -0,0 +1,227 @@
// Package smuggling detects HTTP request smuggling (CL.TE and TE.CL
// variants) by sending ambiguous Content-Length / Transfer-Encoding
// combinations and timing-analyzing the responses.
//
// This is the non-destructive timing variant: we send a request crafted
// so that CL.TE or TE.CL parsing desync would cause the server to hold
// the connection waiting for more bytes, while the correct interpretation
// returns immediately. Large response time delta ⇒ likely smuggling.
//
// We do NOT attempt to actually smuggle follow-up requests — that could
// affect other users. This is safe for authorized testing.
package smuggling
import (
"bufio"
"context"
"crypto/tls"
"fmt"
"net"
"strings"
"sync"
"time"
"god-eye/internal/eventbus"
"god-eye/internal/module"
"god-eye/internal/store"
)
const ModuleName = "vuln.http-smuggling"
type smModule struct{}
func Register() { module.Register(&smModule{}) }
func (*smModule) Name() string { return ModuleName }
func (*smModule) Phase() module.Phase { return module.PhaseAnalysis }
func (*smModule) Consumes() []eventbus.EventType { return []eventbus.EventType{eventbus.EventHTTPProbed} }
func (*smModule) Produces() []eventbus.EventType {
return []eventbus.EventType{eventbus.EventVulnerability}
}
// Opt-in: timing-based testing is slower and can be noisy. Bugbounty profile enables it.
func (*smModule) DefaultEnabled() bool { return false }
func (*smModule) Run(mctx module.Context) error {
if !mctx.Config.Bool("smuggling_scan", false) {
return nil
}
timeout := mctx.Config.Int("timeout", 10)
processed := make(map[string]struct{})
var mu sync.Mutex
shouldProcess := func(host string) bool {
mu.Lock()
defer mu.Unlock()
if _, ok := processed[host]; ok {
return false
}
processed[host] = struct{}{}
return true
}
var wg sync.WaitGroup
for _, h := range mctx.Store.All(mctx.Ctx) {
if h == nil || h.StatusCode == 0 {
continue
}
if !shouldProcess(h.Subdomain) {
continue
}
host := h.Subdomain
wg.Add(1)
go func() { defer wg.Done(); probe(mctx, host, timeout) }()
}
sub := mctx.Bus.Subscribe(eventbus.EventHTTPProbed, func(_ context.Context, e eventbus.Event) {
ev, ok := e.(eventbus.HTTPProbed)
if !ok || ev.StatusCode == 0 {
return
}
host := ev.Meta().Target
if !shouldProcess(host) {
return
}
wg.Add(1)
go func() { defer wg.Done(); probe(mctx, host, timeout) }()
})
defer sub.Unsubscribe()
select {
case <-time.After(500 * time.Millisecond):
case <-mctx.Ctx.Done():
}
wg.Wait()
return nil
}
func probe(mctx module.Context, host string, timeoutSec int) {
timeout := time.Duration(timeoutSec) * time.Second
// Baseline: normal request, measure response time.
baseline, err := sendRequest(host, baselineRequest(host), timeout)
if err != nil {
return
}
// CL.TE probe: Content-Length says more data coming, TE: chunked says "last chunk now".
// Vulnerable servers that read TE first return quickly; non-vulnerable
// servers that read CL wait for more bytes and hit the read timeout.
cltePayload := clteRequest(host)
clte, _ := sendRequest(host, cltePayload, timeout)
// TE.CL probe: reversed — server reads CL first (ignoring chunked), payload is poisoned.
teclPayload := teclRequest(host)
tecl, _ := sendRequest(host, teclPayload, timeout)
// Heuristic: if either probe hangs (duration >= timeout * 0.8) and baseline
// returned fast, it's a likely desync.
threshold := time.Duration(float64(timeout) * 0.8)
fastEnough := baseline.duration < timeout/3
if fastEnough && clte.duration > threshold {
emit(mctx, host, "CL.TE", "CL.TE HTTP Request Smuggling candidate", clte)
}
if fastEnough && tecl.duration > threshold {
emit(mctx, host, "TE.CL", "TE.CL HTTP Request Smuggling candidate", tecl)
}
}
type probeResult struct {
duration time.Duration
response string
}
func baselineRequest(host string) string {
return "GET / HTTP/1.1\r\n" +
"Host: " + host + "\r\n" +
"User-Agent: god-eye-v2\r\n" +
"Connection: close\r\n" +
"\r\n"
}
// clteRequest crafts a CL.TE probe: the chunked body declares "0\r\n\r\n"
// which is the last chunk. If the server honors TE: chunked, the request
// completes immediately. If it honors Content-Length (say, 4), it waits for
// 4 more bytes.
func clteRequest(host string) string {
body := "0\r\n\r\n"
return fmt.Sprintf("POST / HTTP/1.1\r\n"+
"Host: %s\r\n"+
"User-Agent: god-eye-v2\r\n"+
"Content-Length: %d\r\n"+
"Transfer-Encoding: chunked\r\n"+
"Connection: close\r\n"+
"\r\n%s", host, 4, body) // CL=4 mismatches chunked body length
}
// teclRequest: TE: chunked, body ends with a chunk that declares non-zero
// remaining — CL says "done", TE says "more coming". Opposite desync.
func teclRequest(host string) string {
body := "12\r\n" +
"GPOST / HTTP/1.1\r\n" +
"\r\n0\r\n\r\n"
return fmt.Sprintf("POST / HTTP/1.1\r\n"+
"Host: %s\r\n"+
"User-Agent: god-eye-v2\r\n"+
"Content-Length: 3\r\n"+
"Transfer-Encoding: chunked\r\n"+
"Connection: close\r\n"+
"\r\n%s", host, body)
}
// sendRequest opens a raw TCP/TLS connection, writes raw HTTP bytes, and
// returns the time until the first response line is read (or timeout).
func sendRequest(host, payload string, timeout time.Duration) (probeResult, error) {
dialer := &net.Dialer{Timeout: timeout}
conn, err := tls.DialWithDialer(dialer, "tcp", host+":443", &tls.Config{
InsecureSkipVerify: true,
ServerName: host,
})
if err != nil {
return probeResult{}, err
}
defer conn.Close()
_ = conn.SetDeadline(time.Now().Add(timeout))
start := time.Now()
if _, err := conn.Write([]byte(payload)); err != nil {
return probeResult{duration: time.Since(start)}, err
}
br := bufio.NewReader(conn)
line, err := br.ReadString('\n')
return probeResult{duration: time.Since(start), response: line}, err
}
func emit(mctx module.Context, host, kind, title string, r probeResult) {
now := time.Now()
_ = mctx.Store.Upsert(mctx.Ctx, host, func(h *store.Host) {
h.Vulnerabilities = append(h.Vulnerabilities, store.Vulnerability{
ID: "http-smuggling-" + strings.ToLower(kind),
Title: title,
Description: kind + " desync candidate based on response-time delta (" + r.duration.String() + ").",
Severity: string(eventbus.SeverityHigh),
URL: "https://" + host,
Evidence: strings.TrimSpace(r.response),
Remediation: "Ensure front-end and back-end parse Content-Length and Transfer-Encoding identically. Reject requests with both headers.",
OWASP: "A06:2021-Vulnerable and Outdated Components",
FoundAt: now,
})
})
mctx.Bus.Publish(mctx.Ctx, eventbus.VulnerabilityFound{
EventMeta: eventbus.EventMeta{At: now, Source: ModuleName, Target: host},
ID: "http-smuggling-" + strings.ToLower(kind),
Title: title,
Description: "Timing-based " + kind + " desync candidate.",
Severity: eventbus.SeverityHigh,
URL: "https://" + host,
Evidence: strings.TrimSpace(r.response),
Remediation: "Align CL/TE parsing between front-end and back-end.",
OWASP: "A06:2021-Vulnerable and Outdated Components",
})
}
+192
View File
@@ -0,0 +1,192 @@
// Package supplychain enumerates npm and PyPI packages that reference the
// target domain in their source, then flags packages as potential supply
// chain assets. Useful for discovering internal-only tools published by
// mistake to public registries and for finding branded utility packages
// that could reveal internal endpoints/secrets.
//
// This is a discovery-oriented check. Actually downloading + scanning
// package contents for secrets is a Fase 2 follow-up; here we just surface
// the packages and the URLs they point at.
package supplychain
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"sync"
"time"
"god-eye/internal/eventbus"
"god-eye/internal/module"
"god-eye/internal/sources"
"god-eye/internal/store"
)
const ModuleName = "vuln.supply-chain"
type scModule struct{}
func Register() { module.Register(&scModule{}) }
func (*scModule) Name() string { return ModuleName }
func (*scModule) Phase() module.Phase { return module.PhaseDiscovery }
func (*scModule) Consumes() []eventbus.EventType { return nil }
func (*scModule) Produces() []eventbus.EventType {
return []eventbus.EventType{eventbus.EventSubdomainDiscovered, eventbus.EventAPIFinding}
}
func (*scModule) DefaultEnabled() bool { return true }
func (*scModule) Run(mctx module.Context) error {
target := mctx.Target
if target == "" {
return nil
}
var wg sync.WaitGroup
wg.Add(2)
go func() { defer wg.Done(); checkNPM(mctx, target) }()
go func() { defer wg.Done(); checkPyPI(mctx, target) }()
wg.Wait()
return nil
}
// checkNPM uses npm's registry search API. Packages matching "<target>"
// or "<target-suffix>" are surfaced.
func checkNPM(mctx module.Context, target string) {
q := extractBrand(target)
if q == "" {
return
}
url := fmt.Sprintf("https://registry.npmjs.org/-/v1/search?text=%s&size=100", q)
body, err := fetchJSON(mctx.Ctx, url, 15*time.Second)
if err != nil {
return
}
var parsed struct {
Objects []struct {
Package struct {
Name string `json:"name"`
Links map[string]string `json:"links"`
Description string `json:"description"`
} `json:"package"`
} `json:"objects"`
}
_ = json.Unmarshal(body, &parsed)
for _, obj := range parsed.Objects {
pkg := obj.Package
text := pkg.Name + " " + pkg.Description
for _, link := range pkg.Links {
text += " " + link
}
if !strings.Contains(strings.ToLower(text), target) {
continue
}
// Emit an APIFinding for discovery context.
mctx.Bus.Publish(mctx.Ctx, eventbus.APIFinding{
EventMeta: eventbus.EventMeta{At: time.Now(), Source: ModuleName, Target: target},
Kind: "supply-chain:npm",
URL: "https://www.npmjs.com/package/" + pkg.Name,
Issue: "npm package references target: " + pkg.Name + " — " + pkg.Description,
Severity: eventbus.SeverityInfo,
})
// If the description or links contain subdomains of the target,
// also feed them into discovery.
for _, sub := range sources.ExtractSubdomains(text, target) {
_ = mctx.Store.Upsert(mctx.Ctx, sub, func(h *store.Host) {
store.AddDiscoveryMethod(h, "supply-chain:npm:"+pkg.Name)
})
mctx.Bus.Publish(mctx.Ctx, eventbus.SubdomainDiscovered{
EventMeta: eventbus.EventMeta{At: time.Now(), Source: ModuleName, Target: sub},
Subdomain: sub,
Method: "supply-chain:npm:" + pkg.Name,
})
}
}
}
func checkPyPI(mctx module.Context, target string) {
// PyPI no longer supports XML-RPC search; use the simple index
// (all packages) scanning is too expensive. Instead query a few
// likely branded package prefixes via the JSON index.
q := extractBrand(target)
if q == "" {
return
}
// Try exact-name lookups for common variants.
candidates := []string{q, q + "-cli", q + "-sdk", q + "-api", q + "-client"}
for _, name := range candidates {
url := "https://pypi.org/pypi/" + name + "/json"
body, err := fetchJSON(mctx.Ctx, url, 10*time.Second)
if err != nil || len(body) < 50 {
continue
}
var parsed struct {
Info struct {
Name string `json:"name"`
Summary string `json:"summary"`
HomePage string `json:"home_page"`
ProjectURL string `json:"project_url"`
ProjectURLs map[string]string `json:"project_urls"`
} `json:"info"`
}
_ = json.Unmarshal(body, &parsed)
info := parsed.Info
if info.Name == "" {
continue
}
text := info.Name + " " + info.Summary + " " + info.HomePage + " " + info.ProjectURL
for _, u := range info.ProjectURLs {
text += " " + u
}
if !strings.Contains(strings.ToLower(text), target) {
continue
}
mctx.Bus.Publish(mctx.Ctx, eventbus.APIFinding{
EventMeta: eventbus.EventMeta{At: time.Now(), Source: ModuleName, Target: target},
Kind: "supply-chain:pypi",
URL: "https://pypi.org/project/" + info.Name + "/",
Issue: "PyPI package references target: " + info.Name + " — " + info.Summary,
Severity: eventbus.SeverityInfo,
})
for _, sub := range sources.ExtractSubdomains(text, target) {
_ = mctx.Store.Upsert(mctx.Ctx, sub, func(h *store.Host) {
store.AddDiscoveryMethod(h, "supply-chain:pypi:"+info.Name)
})
mctx.Bus.Publish(mctx.Ctx, eventbus.SubdomainDiscovered{
EventMeta: eventbus.EventMeta{At: time.Now(), Source: ModuleName, Target: sub},
Subdomain: sub,
Method: "supply-chain:pypi:" + info.Name,
})
}
}
}
// extractBrand returns the "brand" (second-to-last label) from example.com →
// "example". Used as the package-search query term.
func extractBrand(domain string) string {
labels := strings.Split(strings.TrimSuffix(domain, "."), ".")
if len(labels) < 2 {
return ""
}
return strings.ToLower(labels[len(labels)-2])
}
func fetchJSON(ctx context.Context, url string, timeout time.Duration) ([]byte, error) {
c := &http.Client{Timeout: timeout}
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", "god-eye-v2")
resp, err := c.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
return io.ReadAll(io.LimitReader(resp.Body, 4*1024*1024))
}
+124
View File
@@ -0,0 +1,124 @@
// Package takeover runs v1 takeover detection on every host with a CNAME.
// Reads from the store; listens for late DNSResolved events for concurrent
// modules.
package takeover
import (
"context"
"sync"
"time"
"god-eye/internal/eventbus"
"god-eye/internal/module"
"god-eye/internal/scanner"
"god-eye/internal/store"
)
const ModuleName = "takeover.cname"
type takeoverModule struct{}
func Register() { module.Register(&takeoverModule{}) }
func (*takeoverModule) Name() string { return ModuleName }
func (*takeoverModule) Phase() module.Phase { return module.PhaseAnalysis }
func (*takeoverModule) Consumes() []eventbus.EventType { return []eventbus.EventType{eventbus.EventDNSResolved} }
func (*takeoverModule) Produces() []eventbus.EventType {
return []eventbus.EventType{eventbus.EventTakeoverCandidate}
}
func (*takeoverModule) DefaultEnabled() bool { return true }
func (*takeoverModule) Run(mctx module.Context) error {
if mctx.Config.Bool("no_takeover", false) {
return nil
}
conc := mctx.Config.Int("concurrency", 100)
if conc <= 0 {
conc = 100
}
timeout := mctx.Config.Int("timeout", 5)
processed := make(map[string]struct{})
var processedMu sync.Mutex
shouldProcess := func(host string) bool {
processedMu.Lock()
defer processedMu.Unlock()
if _, dup := processed[host]; dup {
return false
}
processed[host] = struct{}{}
return true
}
work := make(chan string, conc*2)
var wg sync.WaitGroup
for i := 0; i < conc; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for host := range work {
if mctx.Ctx.Err() != nil {
return
}
service := scanner.CheckTakeover(host, timeout)
if service == "" {
continue
}
_ = mctx.Store.Upsert(mctx.Ctx, host, func(h *store.Host) {
h.Takeover = &store.Takeover{
Service: service,
CNAME: h.CNAME,
Confirmed: false,
FoundAt: time.Now(),
}
})
mctx.Bus.Publish(mctx.Ctx, eventbus.TakeoverCandidate{
EventMeta: eventbus.EventMeta{At: time.Now(), Source: ModuleName, Target: host},
Subdomain: host,
Service: service,
})
}
}()
}
// Drain: every host with a CNAME is a takeover candidate.
for _, h := range mctx.Store.All(mctx.Ctx) {
if h == nil || h.CNAME == "" {
continue
}
if !shouldProcess(h.Subdomain) {
continue
}
select {
case work <- h.Subdomain:
case <-mctx.Ctx.Done():
close(work)
wg.Wait()
return nil
}
}
sub := mctx.Bus.Subscribe(eventbus.EventDNSResolved, func(_ context.Context, e eventbus.Event) {
ev, ok := e.(eventbus.DNSResolved)
if !ok || ev.CNAME == "" {
return
}
if !shouldProcess(ev.Subdomain) {
return
}
select {
case work <- ev.Subdomain:
case <-mctx.Ctx.Done():
}
})
defer sub.Unsubscribe()
select {
case <-time.After(500 * time.Millisecond):
case <-mctx.Ctx.Done():
}
close(work)
wg.Wait()
return nil
}
+79
View File
@@ -0,0 +1,79 @@
// Package vhost is a Fase 0.6 adapter around v1 network.VHostScanner which
// performs virtual host discovery on resolved IPs. Reveals additional
// hostnames sharing infrastructure with in-scope targets.
package vhost
import (
"strings"
"sync"
"time"
"god-eye/internal/eventbus"
"god-eye/internal/module"
"god-eye/internal/network"
"god-eye/internal/store"
)
const ModuleName = "discovery.vhost"
type vhostModule struct{}
func Register() { module.Register(&vhostModule{}) }
func (*vhostModule) Name() string { return ModuleName }
func (*vhostModule) Phase() module.Phase { return module.PhaseResolution }
func (*vhostModule) Consumes() []eventbus.EventType { return []eventbus.EventType{eventbus.EventDNSResolved} }
func (*vhostModule) Produces() []eventbus.EventType {
return []eventbus.EventType{eventbus.EventSubdomainDiscovered}
}
func (*vhostModule) DefaultEnabled() bool { return false } // opt-in
func (*vhostModule) Run(mctx module.Context) error {
if !mctx.Config.Bool("vhost_scan", false) {
return nil
}
timeout := mctx.Config.Int("timeout", 10)
target := mctx.Target
hosts := mctx.Store.All(mctx.Ctx)
seenIP := make(map[string]struct{})
for _, h := range hosts {
for _, ip := range h.IPs {
seenIP[ip] = struct{}{}
}
}
scanner := network.NewVHostScanner(timeout)
var wg sync.WaitGroup
for ip := range seenIP {
ip := ip
if mctx.Ctx.Err() != nil {
break
}
wg.Add(1)
go func() {
defer wg.Done()
res := scanner.DiscoverVHosts(mctx.Ctx, ip)
if res == nil {
return
}
for _, h := range res.Domains {
h = strings.ToLower(strings.TrimSpace(h))
if h == "" || !strings.HasSuffix(h, target) {
continue
}
_ = mctx.Store.Upsert(mctx.Ctx, h, func(sh *store.Host) {
store.AddIPs(sh, []string{ip})
store.AddDiscoveryMethod(sh, "vhost")
})
mctx.Bus.Publish(mctx.Ctx, eventbus.SubdomainDiscovered{
EventMeta: eventbus.EventMeta{At: time.Now(), Source: ModuleName, Target: h},
Subdomain: h,
Method: "vhost",
})
}
}()
}
wg.Wait()
return nil
}