feat: add human detection scoring and webhook notifications
Implement phase 2.1 (human detection) and 2.2 (notifications): - Detection scorer computes 0.0-1.0 human likelihood from keystroke timing variance, special key usage, typing speed, command diversity, and session duration - Webhook notifier sends JSON POST to configured endpoints with deduplication, custom headers, and event filtering - RecordingChannel gains an event callback for feeding keystrokes to the scorer without coupling shell and detection packages - Server wires scorer into session lifecycle with periodic updates and threshold-based notification triggers - Web UI shows human score in session tables with highlighting - New config sections: [detection] and [[notify.webhooks]] Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
179
internal/notify/webhook.go
Normal file
179
internal/notify/webhook.go
Normal file
@@ -0,0 +1,179 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.t-juice.club/torjus/oubliette/internal/config"
|
||||
)
|
||||
|
||||
// Event types.
|
||||
const (
|
||||
EventHumanDetected = "human_detected"
|
||||
EventSessionStarted = "session_started"
|
||||
)
|
||||
|
||||
// SessionInfo holds session data included in webhook payloads.
|
||||
type SessionInfo struct {
|
||||
ID string `json:"id"`
|
||||
IP string `json:"ip"`
|
||||
Username string `json:"username"`
|
||||
ShellName string `json:"shell_name"`
|
||||
HumanScore float64 `json:"human_score"`
|
||||
ConnectedAt string `json:"connected_at"`
|
||||
}
|
||||
|
||||
// webhookPayload is the JSON body sent to webhooks.
|
||||
type webhookPayload struct {
|
||||
Event string `json:"event"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
Session SessionInfo `json:"session"`
|
||||
}
|
||||
|
||||
// Notifier sends webhook notifications for honeypot events.
|
||||
type Notifier struct {
|
||||
webhooks []config.WebhookNotifyConfig
|
||||
logger *slog.Logger
|
||||
client *http.Client
|
||||
|
||||
mu sync.Mutex
|
||||
sent map[string]struct{} // dedup key: "sessionID:eventType"
|
||||
}
|
||||
|
||||
// NewNotifier creates a Notifier with the given webhook configurations.
|
||||
func NewNotifier(webhooks []config.WebhookNotifyConfig, logger *slog.Logger) *Notifier {
|
||||
return &Notifier{
|
||||
webhooks: webhooks,
|
||||
logger: logger,
|
||||
client: &http.Client{Timeout: 10 * time.Second},
|
||||
sent: make(map[string]struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// Notify sends a notification for the given event type and session.
|
||||
// Deduplicates by (sessionID, eventType) — each combination is sent at most once.
|
||||
func (n *Notifier) Notify(ctx context.Context, eventType string, session SessionInfo) {
|
||||
dedupKey := session.ID + ":" + eventType
|
||||
|
||||
n.mu.Lock()
|
||||
if _, ok := n.sent[dedupKey]; ok {
|
||||
n.mu.Unlock()
|
||||
return
|
||||
}
|
||||
n.sent[dedupKey] = struct{}{}
|
||||
n.mu.Unlock()
|
||||
|
||||
payload := webhookPayload{
|
||||
Event: eventType,
|
||||
Timestamp: time.Now().UTC().Format(time.RFC3339),
|
||||
Session: session,
|
||||
}
|
||||
|
||||
for _, wh := range n.webhooks {
|
||||
if !n.shouldSend(wh, eventType) {
|
||||
continue
|
||||
}
|
||||
go n.send(ctx, wh, payload)
|
||||
}
|
||||
}
|
||||
|
||||
// CleanupSession removes dedup state for a session.
|
||||
func (n *Notifier) CleanupSession(sessionID string) {
|
||||
n.mu.Lock()
|
||||
defer n.mu.Unlock()
|
||||
for key := range n.sent {
|
||||
if len(key) > len(sessionID) && key[:len(sessionID)+1] == sessionID+":" {
|
||||
delete(n.sent, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// shouldSend returns true if the webhook is configured to receive this event type.
|
||||
func (n *Notifier) shouldSend(wh config.WebhookNotifyConfig, eventType string) bool {
|
||||
if len(wh.Events) == 0 {
|
||||
return true // empty = all events
|
||||
}
|
||||
for _, ev := range wh.Events {
|
||||
if ev == eventType {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (n *Notifier) send(ctx context.Context, wh config.WebhookNotifyConfig, payload webhookPayload) {
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
n.logger.Error("failed to marshal webhook payload", "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, wh.URL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
n.logger.Error("failed to create webhook request", "err", err, "url", wh.URL)
|
||||
return
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
for k, v := range wh.Headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
|
||||
resp, err := n.client.Do(req)
|
||||
if err != nil {
|
||||
n.logger.Error("webhook request failed", "err", err, "url", wh.URL)
|
||||
return
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
n.logger.Warn("webhook returned error status",
|
||||
"url", wh.URL,
|
||||
"status", resp.StatusCode,
|
||||
"event", payload.Event,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
n.logger.Debug("webhook sent",
|
||||
"url", wh.URL,
|
||||
"event", payload.Event,
|
||||
"session_id", payload.Session.ID,
|
||||
)
|
||||
}
|
||||
|
||||
// FormatConnectedAt formats a time for use in SessionInfo.
|
||||
func FormatConnectedAt(t time.Time) string {
|
||||
return t.UTC().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
// NoopNotifier is a no-op notifier used when no webhooks are configured.
|
||||
type NoopNotifier struct{}
|
||||
|
||||
func (NoopNotifier) Notify(context.Context, string, SessionInfo) {}
|
||||
func (NoopNotifier) CleanupSession(string) {}
|
||||
|
||||
// Sender is the interface for sending notifications.
|
||||
type Sender interface {
|
||||
Notify(ctx context.Context, eventType string, session SessionInfo)
|
||||
CleanupSession(sessionID string)
|
||||
}
|
||||
|
||||
var (
|
||||
_ Sender = (*Notifier)(nil)
|
||||
_ Sender = NoopNotifier{}
|
||||
)
|
||||
|
||||
// NewSender creates a Sender from configuration. Returns a NoopNotifier
|
||||
// if no webhooks are configured.
|
||||
func NewSender(webhooks []config.WebhookNotifyConfig, logger *slog.Logger) Sender {
|
||||
if len(webhooks) == 0 {
|
||||
return NoopNotifier{}
|
||||
}
|
||||
return NewNotifier(webhooks, logger)
|
||||
}
|
||||
243
internal/notify/webhook_test.go
Normal file
243
internal/notify/webhook_test.go
Normal file
@@ -0,0 +1,243 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.t-juice.club/torjus/oubliette/internal/config"
|
||||
)
|
||||
|
||||
func testSession() SessionInfo {
|
||||
return SessionInfo{
|
||||
ID: "test-session-123",
|
||||
IP: "1.2.3.4",
|
||||
Username: "root",
|
||||
ShellName: "bash",
|
||||
HumanScore: 0.85,
|
||||
ConnectedAt: FormatConnectedAt(time.Now()),
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifier_PayloadStructure(t *testing.T) {
|
||||
var received webhookPayload
|
||||
var mu sync.Mutex
|
||||
done := make(chan struct{})
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if err := json.NewDecoder(r.Body).Decode(&received); err != nil {
|
||||
t.Errorf("failed to decode payload: %v", err)
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
close(done)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
webhooks := []config.WebhookNotifyConfig{
|
||||
{URL: srv.URL},
|
||||
}
|
||||
|
||||
n := NewNotifier(webhooks, slog.Default())
|
||||
session := testSession()
|
||||
n.Notify(context.Background(), EventHumanDetected, session)
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("timeout waiting for webhook")
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if received.Event != EventHumanDetected {
|
||||
t.Errorf("event: got %q, want %q", received.Event, EventHumanDetected)
|
||||
}
|
||||
if received.Session.ID != session.ID {
|
||||
t.Errorf("session ID: got %q, want %q", received.Session.ID, session.ID)
|
||||
}
|
||||
if received.Session.IP != session.IP {
|
||||
t.Errorf("session IP: got %q, want %q", received.Session.IP, session.IP)
|
||||
}
|
||||
if received.Session.HumanScore != session.HumanScore {
|
||||
t.Errorf("score: got %f, want %f", received.Session.HumanScore, session.HumanScore)
|
||||
}
|
||||
if received.Timestamp == "" {
|
||||
t.Error("timestamp should not be empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifier_CustomHeaders(t *testing.T) {
|
||||
var receivedHeaders http.Header
|
||||
done := make(chan struct{})
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
receivedHeaders = r.Header.Clone()
|
||||
w.WriteHeader(http.StatusOK)
|
||||
close(done)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
webhooks := []config.WebhookNotifyConfig{
|
||||
{
|
||||
URL: srv.URL,
|
||||
Headers: map[string]string{
|
||||
"Authorization": "Bearer test-token",
|
||||
"X-Custom": "my-value",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
n := NewNotifier(webhooks, slog.Default())
|
||||
n.Notify(context.Background(), EventSessionStarted, testSession())
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("timeout waiting for webhook")
|
||||
}
|
||||
|
||||
if got := receivedHeaders.Get("Authorization"); got != "Bearer test-token" {
|
||||
t.Errorf("Authorization header: got %q, want %q", got, "Bearer test-token")
|
||||
}
|
||||
if got := receivedHeaders.Get("X-Custom"); got != "my-value" {
|
||||
t.Errorf("X-Custom header: got %q, want %q", got, "my-value")
|
||||
}
|
||||
if got := receivedHeaders.Get("Content-Type"); got != "application/json" {
|
||||
t.Errorf("Content-Type: got %q, want %q", got, "application/json")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifier_Deduplication(t *testing.T) {
|
||||
var count int
|
||||
var mu sync.Mutex
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
mu.Lock()
|
||||
count++
|
||||
mu.Unlock()
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
webhooks := []config.WebhookNotifyConfig{{URL: srv.URL}}
|
||||
n := NewNotifier(webhooks, slog.Default())
|
||||
session := testSession()
|
||||
|
||||
// Send same event three times for the same session.
|
||||
for range 3 {
|
||||
n.Notify(context.Background(), EventHumanDetected, session)
|
||||
}
|
||||
|
||||
// Allow goroutines to complete.
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if count != 1 {
|
||||
t.Errorf("dedup: got %d sends, want 1", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifier_EventFiltering(t *testing.T) {
|
||||
var receivedEvents []string
|
||||
var mu sync.Mutex
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var payload webhookPayload
|
||||
_ = json.NewDecoder(r.Body).Decode(&payload)
|
||||
mu.Lock()
|
||||
receivedEvents = append(receivedEvents, payload.Event)
|
||||
mu.Unlock()
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
// Only subscribe to human_detected.
|
||||
webhooks := []config.WebhookNotifyConfig{
|
||||
{
|
||||
URL: srv.URL,
|
||||
Events: []string{EventHumanDetected},
|
||||
},
|
||||
}
|
||||
|
||||
n := NewNotifier(webhooks, slog.Default())
|
||||
session := testSession()
|
||||
|
||||
// Send both event types.
|
||||
n.Notify(context.Background(), EventSessionStarted, session)
|
||||
// Need a different session for human_detected to avoid dedup with same session.
|
||||
session2 := testSession()
|
||||
session2.ID = "test-session-456"
|
||||
n.Notify(context.Background(), EventHumanDetected, session2)
|
||||
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if len(receivedEvents) != 1 {
|
||||
t.Fatalf("event filtering: got %d events, want 1", len(receivedEvents))
|
||||
}
|
||||
if receivedEvents[0] != EventHumanDetected {
|
||||
t.Errorf("filtered event: got %q, want %q", receivedEvents[0], EventHumanDetected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifier_CleanupSession(t *testing.T) {
|
||||
var count int
|
||||
var mu sync.Mutex
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
mu.Lock()
|
||||
count++
|
||||
mu.Unlock()
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
webhooks := []config.WebhookNotifyConfig{{URL: srv.URL}}
|
||||
n := NewNotifier(webhooks, slog.Default())
|
||||
session := testSession()
|
||||
|
||||
n.Notify(context.Background(), EventHumanDetected, session)
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
|
||||
// Cleanup and resend — should work again.
|
||||
n.CleanupSession(session.ID)
|
||||
n.Notify(context.Background(), EventHumanDetected, session)
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if count != 2 {
|
||||
t.Errorf("after cleanup: got %d sends, want 2", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoopNotifier(t *testing.T) {
|
||||
// Should not panic.
|
||||
n := NoopNotifier{}
|
||||
n.Notify(context.Background(), EventHumanDetected, testSession())
|
||||
n.CleanupSession("test")
|
||||
}
|
||||
|
||||
func TestNewSender_NoWebhooks(t *testing.T) {
|
||||
sender := NewSender(nil, slog.Default())
|
||||
if _, ok := sender.(NoopNotifier); !ok {
|
||||
t.Errorf("expected NoopNotifier, got %T", sender)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewSender_WithWebhooks(t *testing.T) {
|
||||
webhooks := []config.WebhookNotifyConfig{{URL: "http://example.com"}}
|
||||
sender := NewSender(webhooks, slog.Default())
|
||||
if _, ok := sender.(*Notifier); !ok {
|
||||
t.Errorf("expected *Notifier, got %T", sender)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user