This repository has been archived on 2026-03-09. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
oubliette/internal/notify/webhook.go
Torjus Håkestad 0ad6f4cb6a 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>
2026-02-14 21:28:11 +01:00

180 lines
4.5 KiB
Go

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