ScuttleBot

scuttlebot / internal / bots / snitch / snitch.go
Source Blame History 349 lines
5ac549c… lmata 1 // Package snitch implements a surveillance bot that watches for erratic
5ac549c… lmata 2 // behaviour across IRC channels and alerts operators via DM or a
5ac549c… lmata 3 // dedicated alert channel.
5ac549c… lmata 4 //
5ac549c… lmata 5 // Detected conditions:
5ac549c… lmata 6 // - Message flooding (burst above threshold in a rolling window)
5ac549c… lmata 7 // - Rapid join/part cycling
5ac549c… lmata 8 // - Repeated malformed / non-JSON messages from registered agents
5ac549c… lmata 9 package snitch
5ac549c… lmata 10
5ac549c… lmata 11 import (
5ac549c… lmata 12 "context"
5ac549c… lmata 13 "fmt"
5ac549c… lmata 14 "log/slog"
5ac549c… lmata 15 "net"
5ac549c… lmata 16 "strconv"
bd16e1f… lmata 17 "strings"
5ac549c… lmata 18 "sync"
5ac549c… lmata 19 "time"
5ac549c… lmata 20
5ac549c… lmata 21 "github.com/lrstanley/girc"
e8d318d… noreply 22
e8d318d… noreply 23 "github.com/conflicthq/scuttlebot/internal/bots/cmdparse"
5ac549c… lmata 24 )
5ac549c… lmata 25
5ac549c… lmata 26 const defaultNick = "snitch"
5ac549c… lmata 27
5ac549c… lmata 28 // Config controls snitch's thresholds and alert destination.
5ac549c… lmata 29 type Config struct {
5ac549c… lmata 30 // IRCAddr is host:port of the Ergo IRC server.
5ac549c… lmata 31 IRCAddr string
5ac549c… lmata 32 // Nick is the IRC nick for the bot. Default: "snitch".
5ac549c… lmata 33 Nick string
5ac549c… lmata 34 // Password is the SASL PLAIN passphrase for the bot's NickServ account.
5ac549c… lmata 35 Password string
5ac549c… lmata 36
5ac549c… lmata 37 // AlertChannel is the channel to post alerts to (e.g. "#ops").
5ac549c… lmata 38 // If empty, alerts are sent only as DMs to AlertNicks.
5ac549c… lmata 39 AlertChannel string
5ac549c… lmata 40 // AlertNicks is the list of operator nicks to DM on an alert.
5ac549c… lmata 41 AlertNicks []string
5ac549c… lmata 42
5ac549c… lmata 43 // FloodMessages is the number of messages in FloodWindow that triggers
5ac549c… lmata 44 // a flood alert. Default: 10.
5ac549c… lmata 45 FloodMessages int
5ac549c… lmata 46 // FloodWindow is the rolling window for flood detection. Default: 5s.
5ac549c… lmata 47 FloodWindow time.Duration
5ac549c… lmata 48 // JoinPartThreshold is join+part events in JoinPartWindow to trigger alert. Default: 5.
5ac549c… lmata 49 JoinPartThreshold int
5ac549c… lmata 50 // JoinPartWindow is the rolling window for join/part cycling. Default: 30s.
5ac549c… lmata 51 JoinPartWindow time.Duration
3420a83… lmata 52
3420a83… lmata 53 // Channels is the list of channels to join on connect.
3420a83… lmata 54 Channels []string
f64fe5f… noreply 55
f64fe5f… noreply 56 // MonitorNicks is the list of nicks to track via IRC MONITOR.
f64fe5f… noreply 57 // Snitch will alert when a monitored nick goes offline unexpectedly.
f64fe5f… noreply 58 MonitorNicks []string
5ac549c… lmata 59 }
5ac549c… lmata 60
5ac549c… lmata 61 func (c *Config) setDefaults() {
5ac549c… lmata 62 if c.Nick == "" {
5ac549c… lmata 63 c.Nick = defaultNick
5ac549c… lmata 64 }
5ac549c… lmata 65 if c.FloodMessages == 0 {
5ac549c… lmata 66 c.FloodMessages = 10
5ac549c… lmata 67 }
5ac549c… lmata 68 if c.FloodWindow == 0 {
5ac549c… lmata 69 c.FloodWindow = 5 * time.Second
5ac549c… lmata 70 }
5ac549c… lmata 71 if c.JoinPartThreshold == 0 {
5ac549c… lmata 72 c.JoinPartThreshold = 5
5ac549c… lmata 73 }
5ac549c… lmata 74 if c.JoinPartWindow == 0 {
5ac549c… lmata 75 c.JoinPartWindow = 30 * time.Second
5ac549c… lmata 76 }
5ac549c… lmata 77 }
5ac549c… lmata 78
5ac549c… lmata 79 // nickWindow tracks event timestamps for a single nick in a single channel.
5ac549c… lmata 80 type nickWindow struct {
5ac549c… lmata 81 msgs []time.Time
5ac549c… lmata 82 joinPart []time.Time
5ac549c… lmata 83 }
5ac549c… lmata 84
5ac549c… lmata 85 func (nw *nickWindow) trim(now time.Time, msgWindow, jpWindow time.Duration) {
5ac549c… lmata 86 cutMsg := now.Add(-msgWindow)
5ac549c… lmata 87 filtered := nw.msgs[:0]
5ac549c… lmata 88 for _, t := range nw.msgs {
5ac549c… lmata 89 if t.After(cutMsg) {
5ac549c… lmata 90 filtered = append(filtered, t)
5ac549c… lmata 91 }
5ac549c… lmata 92 }
5ac549c… lmata 93 nw.msgs = filtered
5ac549c… lmata 94
5ac549c… lmata 95 cutJP := now.Add(-jpWindow)
5ac549c… lmata 96 filteredJP := nw.joinPart[:0]
5ac549c… lmata 97 for _, t := range nw.joinPart {
5ac549c… lmata 98 if t.After(cutJP) {
5ac549c… lmata 99 filteredJP = append(filteredJP, t)
5ac549c… lmata 100 }
5ac549c… lmata 101 }
5ac549c… lmata 102 nw.joinPart = filteredJP
5ac549c… lmata 103 }
5ac549c… lmata 104
5ac549c… lmata 105 // Bot is the snitch bot.
5ac549c… lmata 106 type Bot struct {
5ac549c… lmata 107 cfg Config
5ac549c… lmata 108 log *slog.Logger
5ac549c… lmata 109 client *girc.Client
5ac549c… lmata 110
5ac549c… lmata 111 mu sync.Mutex
5ac549c… lmata 112 windows map[string]map[string]*nickWindow // channel → nick → window
5ac549c… lmata 113 alerted map[string]time.Time // key → last alert time (cooldown)
5ac549c… lmata 114 }
5ac549c… lmata 115
5ac549c… lmata 116 // New creates a snitch Bot.
5ac549c… lmata 117 func New(cfg Config, log *slog.Logger) *Bot {
5ac549c… lmata 118 cfg.setDefaults()
5ac549c… lmata 119 return &Bot{
5ac549c… lmata 120 cfg: cfg,
5ac549c… lmata 121 log: log,
5ac549c… lmata 122 windows: make(map[string]map[string]*nickWindow),
5ac549c… lmata 123 alerted: make(map[string]time.Time),
5ac549c… lmata 124 }
5ac549c… lmata 125 }
5ac549c… lmata 126
5ac549c… lmata 127 // Start connects to IRC and begins surveillance. Blocks until ctx is done.
5ac549c… lmata 128 func (b *Bot) Start(ctx context.Context) error {
5ac549c… lmata 129 host, port, err := splitHostPort(b.cfg.IRCAddr)
5ac549c… lmata 130 if err != nil {
5ac549c… lmata 131 return fmt.Errorf("snitch: %w", err)
5ac549c… lmata 132 }
5ac549c… lmata 133
5ac549c… lmata 134 c := girc.New(girc.Config{
d924aea… lmata 135 Server: host,
d924aea… lmata 136 Port: port,
d924aea… lmata 137 Nick: b.cfg.Nick,
d924aea… lmata 138 User: b.cfg.Nick,
d924aea… lmata 139 Name: "scuttlebot snitch",
81587e6… lmata 140 SASL: &girc.SASLPlain{User: b.cfg.Nick, Pass: b.cfg.Password},
81587e6… lmata 141 PingDelay: 30 * time.Second,
81587e6… lmata 142 PingTimeout: 30 * time.Second,
5ac549c… lmata 143 })
5ac549c… lmata 144
5ac549c… lmata 145 c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
f64fe5f… noreply 146 cl.Cmd.Mode(cl.GetNick(), "+B")
3420a83… lmata 147 for _, ch := range b.cfg.Channels {
3420a83… lmata 148 cl.Cmd.Join(ch)
5ac549c… lmata 149 }
5ac549c… lmata 150 if b.cfg.AlertChannel != "" {
5ac549c… lmata 151 cl.Cmd.Join(b.cfg.AlertChannel)
3420a83… lmata 152 }
f64fe5f… noreply 153 if len(b.cfg.MonitorNicks) > 0 {
f64fe5f… noreply 154 cl.Cmd.SendRawf("MONITOR + %s", strings.Join(b.cfg.MonitorNicks, ","))
f64fe5f… noreply 155 }
3420a83… lmata 156 if b.log != nil {
f64fe5f… noreply 157 b.log.Info("snitch connected", "channels", b.cfg.Channels, "monitor", b.cfg.MonitorNicks)
f64fe5f… noreply 158 }
f64fe5f… noreply 159 })
f64fe5f… noreply 160
f64fe5f… noreply 161 // away-notify: track agents going idle or returning.
f64fe5f… noreply 162 c.Handlers.AddBg(girc.AWAY, func(_ *girc.Client, e girc.Event) {
f64fe5f… noreply 163 if e.Source == nil {
f64fe5f… noreply 164 return
f64fe5f… noreply 165 }
f64fe5f… noreply 166 nick := e.Source.Name
f64fe5f… noreply 167 reason := e.Last()
f64fe5f… noreply 168 if reason != "" {
f64fe5f… noreply 169 b.alert(fmt.Sprintf("agent away: %s (%s)", nick, reason))
f64fe5f… noreply 170 }
f64fe5f… noreply 171 })
f64fe5f… noreply 172
f64fe5f… noreply 173 c.Handlers.AddBg(girc.RPL_MONOFFLINE, func(_ *girc.Client, e girc.Event) {
f64fe5f… noreply 174 nicks := e.Last()
f64fe5f… noreply 175 for _, nick := range strings.Split(nicks, ",") {
f64fe5f… noreply 176 nick = strings.TrimSpace(nick)
f64fe5f… noreply 177 if nick == "" {
f64fe5f… noreply 178 continue
f64fe5f… noreply 179 }
f64fe5f… noreply 180 b.alert(fmt.Sprintf("monitored nick offline: %s", nick))
bd16e1f… lmata 181 }
bd16e1f… lmata 182 })
bd16e1f… lmata 183
bd16e1f… lmata 184 c.Handlers.AddBg(girc.INVITE, func(cl *girc.Client, e girc.Event) {
bd16e1f… lmata 185 if ch := e.Last(); strings.HasPrefix(ch, "#") {
bd16e1f… lmata 186 cl.Cmd.Join(ch)
5ac549c… lmata 187 }
5ac549c… lmata 188 })
5ac549c… lmata 189
5ac549c… lmata 190 c.Handlers.AddBg(girc.JOIN, func(_ *girc.Client, e girc.Event) {
5ac549c… lmata 191 if len(e.Params) < 1 || e.Source == nil || e.Source.Name == b.cfg.Nick {
5ac549c… lmata 192 return
5ac549c… lmata 193 }
5ac549c… lmata 194 b.recordJoinPart(e.Params[0], e.Source.Name)
5ac549c… lmata 195 })
5ac549c… lmata 196
5ac549c… lmata 197 c.Handlers.AddBg(girc.PART, func(_ *girc.Client, e girc.Event) {
5ac549c… lmata 198 if len(e.Params) < 1 || e.Source == nil {
5ac549c… lmata 199 return
5ac549c… lmata 200 }
5ac549c… lmata 201 b.recordJoinPart(e.Params[0], e.Source.Name)
5ac549c… lmata 202 })
5ac549c… lmata 203
e8d318d… noreply 204 router := cmdparse.NewRouter(b.cfg.Nick)
e8d318d… noreply 205 router.Register(cmdparse.Command{
e8d318d… noreply 206 Name: "status",
e8d318d… noreply 207 Usage: "STATUS",
e8d318d… noreply 208 Description: "show current active alerts",
e8d318d… noreply 209 Handler: func(_ *cmdparse.Context, _ string) string { return "not implemented yet" },
e8d318d… noreply 210 })
e8d318d… noreply 211 router.Register(cmdparse.Command{
e8d318d… noreply 212 Name: "acknowledge",
e8d318d… noreply 213 Usage: "ACKNOWLEDGE <alert-id>",
e8d318d… noreply 214 Description: "acknowledge an alert",
e8d318d… noreply 215 Handler: func(_ *cmdparse.Context, _ string) string { return "not implemented yet" },
e8d318d… noreply 216 })
e8d318d… noreply 217
5ac549c… lmata 218 c.Handlers.AddBg(girc.PRIVMSG, func(_ *girc.Client, e girc.Event) {
5ac549c… lmata 219 if len(e.Params) < 1 || e.Source == nil {
e8d318d… noreply 220 return
e8d318d… noreply 221 }
e8d318d… noreply 222 // Dispatch commands (DMs and channel messages).
e8d318d… noreply 223 if reply := router.Dispatch(e.Source.Name, e.Params[0], e.Last()); reply != nil {
e8d318d… noreply 224 c.Cmd.Message(reply.Target, reply.Text)
5ac549c… lmata 225 return
5ac549c… lmata 226 }
5ac549c… lmata 227 channel := e.Params[0]
5ac549c… lmata 228 nick := e.Source.Name
5ac549c… lmata 229 if nick == b.cfg.Nick {
5ac549c… lmata 230 return
5ac549c… lmata 231 }
5ac549c… lmata 232 b.recordMsg(channel, nick)
5ac549c… lmata 233 b.checkFlood(c, channel, nick)
5ac549c… lmata 234 })
5ac549c… lmata 235
5ac549c… lmata 236 b.client = c
5ac549c… lmata 237
5ac549c… lmata 238 errCh := make(chan error, 1)
5ac549c… lmata 239 go func() {
5ac549c… lmata 240 if err := c.Connect(); err != nil && ctx.Err() == nil {
5ac549c… lmata 241 errCh <- err
5ac549c… lmata 242 }
5ac549c… lmata 243 }()
5ac549c… lmata 244
5ac549c… lmata 245 select {
5ac549c… lmata 246 case <-ctx.Done():
5ac549c… lmata 247 c.Close()
5ac549c… lmata 248 return nil
5ac549c… lmata 249 case err := <-errCh:
5ac549c… lmata 250 return fmt.Errorf("snitch: irc: %w", err)
5ac549c… lmata 251 }
5ac549c… lmata 252 }
5ac549c… lmata 253
5ac549c… lmata 254 func (b *Bot) JoinChannel(channel string) {
5ac549c… lmata 255 if b.client != nil {
5ac549c… lmata 256 b.client.Cmd.Join(channel)
f64fe5f… noreply 257 }
f64fe5f… noreply 258 }
f64fe5f… noreply 259
f64fe5f… noreply 260 // MonitorAdd adds nicks to the MONITOR list at runtime.
f64fe5f… noreply 261 func (b *Bot) MonitorAdd(nicks ...string) {
f64fe5f… noreply 262 if b.client != nil && len(nicks) > 0 {
f64fe5f… noreply 263 b.client.Cmd.SendRawf("MONITOR + %s", strings.Join(nicks, ","))
f64fe5f… noreply 264 }
f64fe5f… noreply 265 }
f64fe5f… noreply 266
f64fe5f… noreply 267 // MonitorRemove removes nicks from the MONITOR list at runtime.
f64fe5f… noreply 268 func (b *Bot) MonitorRemove(nicks ...string) {
f64fe5f… noreply 269 if b.client != nil && len(nicks) > 0 {
f64fe5f… noreply 270 b.client.Cmd.SendRawf("MONITOR - %s", strings.Join(nicks, ","))
5ac549c… lmata 271 }
5ac549c… lmata 272 }
5ac549c… lmata 273
5ac549c… lmata 274 func (b *Bot) window(channel, nick string) *nickWindow {
5ac549c… lmata 275 if b.windows[channel] == nil {
5ac549c… lmata 276 b.windows[channel] = make(map[string]*nickWindow)
5ac549c… lmata 277 }
5ac549c… lmata 278 if b.windows[channel][nick] == nil {
5ac549c… lmata 279 b.windows[channel][nick] = &nickWindow{}
5ac549c… lmata 280 }
5ac549c… lmata 281 return b.windows[channel][nick]
5ac549c… lmata 282 }
5ac549c… lmata 283
5ac549c… lmata 284 func (b *Bot) recordMsg(channel, nick string) {
5ac549c… lmata 285 b.mu.Lock()
5ac549c… lmata 286 defer b.mu.Unlock()
5ac549c… lmata 287 now := time.Now()
5ac549c… lmata 288 w := b.window(channel, nick)
5ac549c… lmata 289 w.trim(now, b.cfg.FloodWindow, b.cfg.JoinPartWindow)
5ac549c… lmata 290 w.msgs = append(w.msgs, now)
5ac549c… lmata 291 }
5ac549c… lmata 292
5ac549c… lmata 293 func (b *Bot) recordJoinPart(channel, nick string) {
5ac549c… lmata 294 b.mu.Lock()
5ac549c… lmata 295 defer b.mu.Unlock()
5ac549c… lmata 296 now := time.Now()
5ac549c… lmata 297 w := b.window(channel, nick)
5ac549c… lmata 298 w.trim(now, b.cfg.FloodWindow, b.cfg.JoinPartWindow)
5ac549c… lmata 299 w.joinPart = append(w.joinPart, now)
5ac549c… lmata 300 if len(w.joinPart) >= b.cfg.JoinPartThreshold {
5ac549c… lmata 301 go b.alert(fmt.Sprintf("join/part cycling: %s in %s (%d events in %s)",
5ac549c… lmata 302 nick, channel, len(w.joinPart), b.cfg.JoinPartWindow))
5ac549c… lmata 303 w.joinPart = nil
5ac549c… lmata 304 }
5ac549c… lmata 305 }
5ac549c… lmata 306
5ac549c… lmata 307 func (b *Bot) checkFlood(c *girc.Client, channel, nick string) {
5ac549c… lmata 308 b.mu.Lock()
5ac549c… lmata 309 defer b.mu.Unlock()
5ac549c… lmata 310 now := time.Now()
5ac549c… lmata 311 w := b.window(channel, nick)
5ac549c… lmata 312 w.trim(now, b.cfg.FloodWindow, b.cfg.JoinPartWindow)
5ac549c… lmata 313 if len(w.msgs) >= b.cfg.FloodMessages {
5ac549c… lmata 314 key := "flood:" + channel + ":" + nick
5ac549c… lmata 315 if last, ok := b.alerted[key]; !ok || now.Sub(last) > 60*time.Second {
5ac549c… lmata 316 b.alerted[key] = now
5ac549c… lmata 317 go b.alert(fmt.Sprintf("flood detected: %s in %s (%d msgs in %s)",
5ac549c… lmata 318 nick, channel, len(w.msgs), b.cfg.FloodWindow))
5ac549c… lmata 319 }
5ac549c… lmata 320 }
5ac549c… lmata 321 }
5ac549c… lmata 322
5ac549c… lmata 323 func (b *Bot) alert(msg string) {
5ac549c… lmata 324 if b.client == nil {
5ac549c… lmata 325 return
5ac549c… lmata 326 }
5ac549c… lmata 327 if b.log != nil {
5ac549c… lmata 328 b.log.Warn("snitch alert", "msg", msg)
5ac549c… lmata 329 }
5ac549c… lmata 330 full := "[snitch] " + msg
5ac549c… lmata 331 if b.cfg.AlertChannel != "" {
5ac549c… lmata 332 b.client.Cmd.Message(b.cfg.AlertChannel, full)
5ac549c… lmata 333 }
5ac549c… lmata 334 for _, nick := range b.cfg.AlertNicks {
5ac549c… lmata 335 b.client.Cmd.Message(nick, full)
5ac549c… lmata 336 }
5ac549c… lmata 337 }
5ac549c… lmata 338
5ac549c… lmata 339 func splitHostPort(addr string) (string, int, error) {
5ac549c… lmata 340 host, portStr, err := net.SplitHostPort(addr)
5ac549c… lmata 341 if err != nil {
5ac549c… lmata 342 return "", 0, fmt.Errorf("invalid address %q: %w", addr, err)
5ac549c… lmata 343 }
5ac549c… lmata 344 port, err := strconv.Atoi(portStr)
5ac549c… lmata 345 if err != nil {
5ac549c… lmata 346 return "", 0, fmt.Errorf("invalid port in %q: %w", addr, err)
5ac549c… lmata 347 }
5ac549c… lmata 348 return host, port, nil
5ac549c… lmata 349 }

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button