Files
Vyntral 3a4c230aa7 feat: v2.0 full rewrite — event-driven pipeline, AI + Nuclei + proxy
Complete architectural overhaul. Replaces the v0.1 monolithic scanner
with an event-driven pipeline of auto-registered modules.

Foundation (internal/):
- eventbus: typed pub/sub, 20 event types, race-safe, drop counter
- module: registry with phase-based selection
- store: thread-safe host store with per-host locks + deep-copy reads
- pipeline: coordinator with phase barriers + panic recovery
- config: 5 scan profiles + 3 AI tiers + YAML loader + auto-discovery

Modules (26 auto-registered across 6 phases):
- Discovery: passive (26 sources), bruteforce, recursive, AXFR, GitHub
  dorks, CT streaming, permutation, reverse DNS, vhost, ASN, supply
  chain (npm + PyPI)
- Enrichment: HTTP probe + tech fingerprint + TLS appliance ID, ports
- Analysis: security checks, takeover (110+ sigs), cloud, JavaScript,
  GraphQL, JWT, headers (OWASP), HTTP smuggling, AI cascade, Nuclei
- Reporting: TXT/JSON/CSV writer + AI scan brief

AI layer (internal/ai/ + internal/modules/ai/):
- Three profiles: lean (16 GB), balanced (32 GB MoE), heavy (64 GB)
- Six event-driven handlers: CVE, JS file, HTTP response, secret
  filter, multi-agent vuln enrichment, anomaly + executive report
- Content-hash cache dedups Ollama calls across hosts
- Auto-pull of missing models via /api/pull with streaming progress
- End-of-scan AI SCAN BRIEF in terminal with top chains + next actions

Nuclei compat layer (internal/nucleitpl/):
- Executes ~13k community templates (HTTP subset)
- Auto-download of nuclei-templates ZIP to ~/.god-eye/nuclei-templates
- Scope filter rejects off-host templates (eliminates OSINT FPs)

Operations:
- Interactive wizard (internal/wizard/) — zero-flag launch
- LivePrinter (internal/tui/) — colorized event stream
- Diff engine + scheduler (internal/diff, internal/scheduler) for
  continuous ASM monitoring with webhook alerts
- Proxy support (internal/proxyconf/): http / https / socks5 / socks5h
  + basic auth

Fixes #1 — native SOCKS5 / Tor compatibility via --proxy flag.

185 unit tests across 15 packages, all race-detector clean.
2026-04-18 16:48:41 +02:00

306 lines
7.9 KiB
Go

// 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
}