Files
go-otel/internal/honeypot/honeypot.go
T
mats f30adf05af
Build and Publish / Metadata (push) Successful in 2s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m23s
all the changes
2026-09-19 23:25:59 +02:00

314 lines
9.4 KiB
Go

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