// Package honeypot tracks common vulnerability-scanner requests and serves an // inert decoy response. Its promoted IP list can be consumed by an ingress // controller or a separate synchronizer. package honeypot import ( "crypto/subtle" "encoding/json" "net" "net/http" "net/netip" "os" "strconv" "strings" "sync" "time" "git.tornberg.me/go-otel/internal/triage" ) const ( defaultThreshold = 3 defaultWindow = 15 * time.Minute defaultTTL = 1 * time.Hour maxTracked = 10000 maxPathsTracked = 64 ) type Config struct { Threshold int Window time.Duration TTL time.Duration Delay time.Duration Token string // MinPaths is how many distinct suspicious paths an IP must request // before it is promoted. A legitimate client repeatedly fetching one // odd path (a manifest.json) is not a scanner. Zero means 1. MinPaths int } type entry struct { Paths map[string]struct{} Count int FirstSeen time.Time LastSeen time.Time PromotedUntil time.Time } // IP describes an IP promoted for ingress honeypot routing. type IP struct { Address string `json:"ip"` Count int `json:"count"` Paths int `json:"paths"` FirstSeen time.Time `json:"first_seen"` LastSeen time.Time `json:"last_seen"` PromotedUntil time.Time `json:"promoted_until"` } type Tracker struct { threshold int window time.Duration ttl time.Duration delay time.Duration token string minPaths int mu sync.Mutex clients map[string]entry } func New(cfg Config) *Tracker { if cfg.Threshold <= 0 { cfg.Threshold = defaultThreshold } if cfg.Window <= 0 { cfg.Window = defaultWindow } if cfg.TTL <= 0 { cfg.TTL = defaultTTL } if cfg.Delay < 0 { cfg.Delay = 0 } if cfg.Delay > 10*time.Second { cfg.Delay = 10 * time.Second } if cfg.MinPaths < 1 { cfg.MinPaths = 1 } return &Tracker{threshold: cfg.Threshold, window: cfg.Window, ttl: cfg.TTL, delay: cfg.Delay, token: cfg.Token, minPaths: cfg.MinPaths, clients: make(map[string]entry)} } func ConfigFromEnv() Config { cfg := Config{Threshold: defaultThreshold, Window: defaultWindow, TTL: defaultTTL, Delay: 0, Token: os.Getenv("OTEL_HONEYPOT_TOKEN"), MinPaths: 3} if n, err := strconv.Atoi(os.Getenv("OTEL_HONEYPOT_MIN_PATHS")); err == nil && n > 0 { cfg.MinPaths = n } if n, err := strconv.Atoi(os.Getenv("OTEL_HONEYPOT_THRESHOLD")); err == nil && n > 0 { cfg.Threshold = n } if d, err := time.ParseDuration(os.Getenv("OTEL_HONEYPOT_WINDOW")); err == nil && d > 0 { cfg.Window = d } if d, err := time.ParseDuration(os.Getenv("OTEL_HONEYPOT_TTL")); err == nil && d > 0 { cfg.TTL = d } if d, err := time.ParseDuration(os.Getenv("OTEL_HONEYPOT_DELAY")); err == nil && d >= 0 { cfg.Delay = d } return cfg } // Middleware records scanner paths and returns decoys. All other requests // continue to the wrapped application unchanged. func (t *Tracker) Middleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if !isSuspiciousPath(r.URL.Path) { next.ServeHTTP(w, r) return } ip := clientIP(r) promoted := t.record(ip, r.URL.Path) if promoted && t.delay > 0 { timer := time.NewTimer(t.delay) select { case <-timer.C: case <-r.Context().Done(): if !timer.Stop() { <-timer.C } return } } w.Header().Set("Content-Type", "text/plain; charset=utf-8") w.Header().Set("Cache-Control", "no-store") w.WriteHeader(http.StatusOK) if strings.Contains(strings.ToLower(r.URL.Path), ".env") { _, _ = w.Write([]byte("# decoy configuration - no real secrets\nAPP_ENV=production\nAPP_DEBUG=false\nDB_HOST=127.0.0.1\nDB_DATABASE=example_decoy\nDB_USERNAME=decoy_reader\nDB_PASSWORD=not-a-real-password\n")) return } _, _ = w.Write([]byte("-- decoy diagnostic response\n-- no database connection was made\n-- DROP TABLE users;\n-- DROP TABLE sessions;\n-- inert text only\n")) }) } // Untrackable reports addresses that must never be tracked or blocked: // loopback, private, link-local, multicast, unspecified or unparsable. Behind // a proxy hop the observed peer is often 127.0.0.1 or a node address, and // promoting it would cut off the ingress itself. func Untrackable(ip string) bool { addr, err := netip.ParseAddr(strings.TrimSpace(ip)) if err != nil { return true } addr = addr.Unmap() if addr.IsLoopback() || addr.IsPrivate() || addr.IsLinkLocalUnicast() || addr.IsUnspecified() || addr.IsMulticast() { return true } for _, p := range reservedPrefixes { if p.Contains(addr) { return true } } return false } // reservedPrefixes are ranges the standard library helpers do not cover but // that are never a remote scanner: carrier-grade NAT (also used by Tailscale // and mobile carriers), IETF protocol assignments, benchmarking, and the // reserved/broadcast block. var reservedPrefixes = []netip.Prefix{ netip.MustParsePrefix("100.64.0.0/10"), netip.MustParsePrefix("192.0.0.0/24"), netip.MustParsePrefix("198.18.0.0/15"), netip.MustParsePrefix("240.0.0.0/4"), } func (t *Tracker) record(ip, path string) bool { if Untrackable(ip) { return false } now := time.Now() t.mu.Lock() defer t.mu.Unlock() state, ok := t.clients[ip] if !ok || now.Sub(state.LastSeen) > t.window { state = entry{FirstSeen: now, Paths: map[string]struct{}{}} } state.Count++ state.LastSeen = now if len(state.Paths) < maxPathsTracked { state.Paths[strings.ToLower(path)] = struct{}{} } if state.Count >= t.threshold && len(state.Paths) >= t.minPaths { state.PromotedUntil = now.Add(t.ttl) } t.clients[ip] = state if len(t.clients) > maxTracked { t.evictOldest() } return !state.PromotedUntil.IsZero() && now.Before(state.PromotedUntil) } // RecordTelemetry records a suspicious request observed in an OTLP span. This // is separate from Middleware because the request may have been handled by a // different application/ingress host while its telemetry is sent to this // collector. No decoy response is written here; this only updates the // promoted-IP feed. func (t *Tracker) RecordTelemetry(ip, path string) bool { ip = strings.TrimSpace(strings.SplitN(ip, ",", 2)[0]) if ip == "" || !(isSuspiciousPath(path) || triage.IsProbePath(path)) { return false } return t.record(ip, path) } // PromotedIPs returns a snapshot suitable for an ingress synchronizer. func (t *Tracker) PromotedIPs() []IP { now := time.Now() t.mu.Lock() defer t.mu.Unlock() result := make([]IP, 0) for address, state := range t.clients { if state.PromotedUntil.IsZero() || !now.Before(state.PromotedUntil) { continue } result = append(result, IP{Address: address, Count: state.Count, Paths: len(state.Paths), FirstSeen: state.FirstSeen, LastSeen: state.LastSeen, PromotedUntil: state.PromotedUntil}) } return result } // HandleIPs exposes the promoted list only when OTEL_HONEYPOT_TOKEN is set. // It is intended for a private ingress-side synchronizer, not public clients. func (t *Tracker) HandleIPs(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet || t.token == "" || !validToken(r, t.token) { w.Header().Set("WWW-Authenticate", `Bearer realm="honeypot"`) http.Error(w, "unauthorized", http.StatusUnauthorized) return } t.writeIPs(w) } // HandleIPsForUser is used behind the application's normal authenticated UI // middleware. The internal machine endpoint remains protected by Token. func (t *Tracker) HandleIPsForUser(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } t.writeIPs(w) } func (t *Tracker) writeIPs(w http.ResponseWriter) { w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "no-store") _ = json.NewEncoder(w).Encode(t.PromotedIPs()) } func validToken(r *http.Request, token string) bool { value := strings.TrimSpace(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")) if value == "" { value = r.Header.Get("X-Honeypot-Token") } return len(value) == len(token) && subtle.ConstantTimeCompare([]byte(value), []byte(token)) == 1 } func (t *Tracker) evictOldest() { var address string var oldest time.Time for ip, state := range t.clients { if address == "" || state.LastSeen.Before(oldest) { address, oldest = ip, state.LastSeen } } if address != "" { delete(t.clients, address) } } func isSuspiciousPath(path string) bool { path = strings.ToLower(path) for _, marker := range []string{ "/.env", "/.git/", "/.svn/", "/wp-", "/xmlrpc.php", "/phpmyadmin", "/pma/", "/vendor/phpunit", "/eval-stdin.php", "/cgi-bin/", "/shell.php", "/cmd.php", "/upload.php", "/v2/keys", "/var/task/", "/dashboard/env-", "/app.js", } { if strings.Contains(path, marker) { return true } } for _, suffix := range []string{".php", ".ini", ".bak", ".swp", ".yml", ".yaml", ".sql", ".json", ".config", ".cfg"} { if strings.HasSuffix(path, suffix) { return true } } return false } func clientIP(r *http.Request) string { // Prefer the single-value header normally overwritten by ingress-nginx. // X-Forwarded-For can contain client-supplied values when a proxy appends // rather than replaces it, so it is only a fallback. if real := strings.TrimSpace(r.Header.Get("X-Real-IP")); real != "" { return real } if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" { return strings.TrimSpace(strings.SplitN(forwarded, ",", 2)[0]) } host, _, err := net.SplitHostPort(r.RemoteAddr) if err == nil { return host } return r.RemoteAddr }