more otel
This commit is contained in:
1 parent
9620e3bfa6
commit
21f04d696b
88 files changed
+23419
-192
No files matched your search
@@ -0,0 +1,245 @@
|
||||
// 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"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultThreshold = 3
|
||||
defaultWindow = 15 * time.Minute
|
||||
defaultTTL = 1 * time.Hour
|
||||
maxTracked = 10000
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Threshold int
|
||||
Window time.Duration
|
||||
TTL time.Duration
|
||||
Delay time.Duration
|
||||
Token string
|
||||
}
|
||||
|
||||
type entry 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"`
|
||||
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
|
||||
|
||||
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
|
||||
}
|
||||
return &Tracker{threshold: cfg.Threshold, window: cfg.Window, ttl: cfg.TTL, delay: cfg.Delay, token: cfg.Token, clients: make(map[string]entry)}
|
||||
}
|
||||
|
||||
func ConfigFromEnv() Config {
|
||||
cfg := Config{Threshold: defaultThreshold, Window: defaultWindow, TTL: defaultTTL, Delay: 0, Token: os.Getenv("OTEL_HONEYPOT_TOKEN")}
|
||||
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)
|
||||
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"))
|
||||
})
|
||||
}
|
||||
|
||||
func (t *Tracker) record(ip string) bool {
|
||||
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}
|
||||
}
|
||||
state.Count++
|
||||
state.LastSeen = now
|
||||
if state.Count > t.threshold {
|
||||
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)
|
||||
}
|
||||
|
||||
// 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, 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
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package honeypot
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestPromotesAfterThresholdAndExpires(t *testing.T) {
|
||||
tracker := New(Config{Threshold: 2, Window: time.Minute, TTL: time.Millisecond, Token: "secret"})
|
||||
for i := 0; i < 3; i++ {
|
||||
req := httptest.NewRequest(http.MethodGet, "https://example.test/.env", nil)
|
||||
req.RemoteAddr = "192.0.2.10:1234"
|
||||
tracker.Middleware(http.HandlerFunc(http.NotFound)).ServeHTTP(httptest.NewRecorder(), req)
|
||||
}
|
||||
if got := len(tracker.PromotedIPs()); got != 1 {
|
||||
t.Fatalf("got %d promoted IPs, want 1", got)
|
||||
}
|
||||
time.Sleep(2 * time.Millisecond)
|
||||
if got := len(tracker.PromotedIPs()); got != 0 {
|
||||
t.Fatalf("got %d expired IPs, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleIPsRequiresToken(t *testing.T) {
|
||||
tracker := New(Config{Threshold: 1, Token: "secret"})
|
||||
recorder := httptest.NewRecorder()
|
||||
tracker.HandleIPs(recorder, httptest.NewRequest(http.MethodGet, "https://example.test/internal/honeypot/ips", nil))
|
||||
if recorder.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("got %d, want 401", recorder.Code)
|
||||
}
|
||||
recorder = httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "https://example.test/internal/honeypot/ips", nil)
|
||||
req.Header.Set("Authorization", "Bearer secret")
|
||||
tracker.HandleIPs(recorder, req)
|
||||
if recorder.Code != http.StatusOK || !strings.HasPrefix(recorder.Body.String(), "[]") {
|
||||
t.Fatalf("got %d and %q", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user