Files
velox-bot/internal/security/security.go
T
FernandoJVideiraandClaude Opus 5.5 3f87e68a2c Add anti-raid and anti-nuke protection
Join Gate (account age, avatar, advertising and username filters, bot
checks), raid detection with invite pausing, anti-nuke quarantine of
admins who mass delete, manual and automatic lockdowns, a decaying heat
score against spam, role and channel snapshots with restore, and alert
DMs to the owner and trusted users. Everything that weakens protection
is limited to the owner and the trusted list.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 21:59:52 +01:00

355 lines
13 KiB
Go
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package security is the bot's anti-raid and anti-nuke system, modelled on
// Wick:
//
// - Join Gate: checks on every join (no avatar, new account, invite links
// or blocked words in the name, unverified bots), each with its own
// action, plus kicking bots added by anyone who isn't trusted.
// - Raid mode: a burst of joins starts it; raiders are timed out, kicked or
// banned, invites can be paused, staff are alerted, and it ends after a
// quiet period or when staff end it.
// - Anti-nuke: destructive actions (deleting channels or roles, mass bans
// and kicks, webhooks, dangerous permissions) are counted per person from
// the audit log; anyone who isn't the owner or trusted and goes over a
// limit is quarantined: roles removed and saved, timed out.
// - Lockdown: lock a channel, every channel, or joins, and unlock exactly
// back to how they were.
//
// Safety rules every entry point follows:
//
// - Turning protection off, changing limits, releasing a quarantine and
// unlocking need the server owner or someone on the trusted list.
// Editing the trusted list needs the owner. Discord's command
// permissions are never relied on, since admins can change them.
// - The owner and the bot itself are never punished.
// - Configured roles are checked when used, not just when saved: a
// quarantine role that gained dangerous permissions is not assigned.
// - Messages only ever ping the configured alert role, and user-controlled
// text (names) is escaped.
// - Memory per server is bounded, and settings are cached briefly so a
// raid of thousands of joins doesn't turn into thousands of queries.
package security
import (
"context"
"strconv"
"strings"
"sync"
"time"
"velox-bot/internal/db/repos/securityrepo"
"github.com/bwmarrin/discordgo"
)
// Store is the subset of securityrepo.Repo the system uses.
type Store interface {
GetSettings(ctx context.Context, guildID int64) (*securityrepo.Settings, error)
SetEnabled(ctx context.Context, guildID int64, feature string, on bool) error
SetAlerts(ctx context.Context, guildID, channelID, roleID int64) error
SetQuarantineRole(ctx context.Context, guildID, roleID int64) error
SetJoinLock(ctx context.Context, guildID int64, mode string) error
ListTrusted(ctx context.Context, guildID int64) ([]securityrepo.Trusted, error)
AddTrusted(ctx context.Context, guildID int64, t securityrepo.Trusted) error
RemoveTrusted(ctx context.Context, guildID, targetID int64) (bool, error)
SaveQuarantine(ctx context.Context, q securityrepo.Quarantine) error
GetQuarantine(ctx context.Context, guildID, userID int64) (*securityrepo.Quarantine, error)
ListQuarantines(ctx context.Context, guildID int64) ([]securityrepo.Quarantine, error)
DeleteQuarantine(ctx context.Context, guildID, userID int64) error
SaveLockdown(ctx context.Context, l securityrepo.Lockdown) error
GetLockdown(ctx context.Context, guildID, channelID int64) (*securityrepo.Lockdown, error)
ListLockdowns(ctx context.Context, guildID int64, serverWideOnly bool) ([]securityrepo.Lockdown, error)
DeleteLockdown(ctx context.Context, guildID, channelID int64) error
AddEvent(ctx context.Context, e securityrepo.Event) error
ListUnprocessedCommands(ctx context.Context) ([]securityrepo.Command, error)
MarkCommandProcessed(ctx context.Context, id int64) error
SaveRaid(ctx context.Context, x securityrepo.Raid) error
ListRaids(ctx context.Context) ([]securityrepo.Raid, error)
DeleteRaid(ctx context.Context, guildID int64) error
AddRaidCaught(ctx context.Context, guildID int64, c securityrepo.Caught) error
ListRaidCaught(ctx context.Context, guildID int64, limit int) ([]securityrepo.Caught, error)
SaveSnapshot(ctx context.Context, s securityrepo.Snapshot) error
LatestSnapshot(ctx context.Context, guildID int64) (*securityrepo.Snapshot, error)
GuildsDueForSnapshot(ctx context.Context, maxAge time.Duration) ([]int64, error)
}
// Discord is the subset of *discordgo.Session the system uses.
type Discord interface {
Guild(guildID string, options ...discordgo.RequestOption) (*discordgo.Guild, error)
UserChannelCreate(recipientID string, options ...discordgo.RequestOption) (*discordgo.Channel, error)
User(userID string, options ...discordgo.RequestOption) (*discordgo.User, error)
GuildRoles(guildID string, options ...discordgo.RequestOption) ([]*discordgo.Role, error)
GuildChannels(guildID string, options ...discordgo.RequestOption) ([]*discordgo.Channel, error)
GuildMember(guildID, userID string, options ...discordgo.RequestOption) (*discordgo.Member, error)
GuildMemberEdit(guildID, userID string, data *discordgo.GuildMemberParams, options ...discordgo.RequestOption) (*discordgo.Member, error)
GuildMemberTimeout(guildID, userID string, until *time.Time, options ...discordgo.RequestOption) error
GuildMemberDeleteWithReason(guildID, userID, reason string, options ...discordgo.RequestOption) error
GuildBanCreateWithReason(guildID, userID, reason string, days int, options ...discordgo.RequestOption) error
Channel(channelID string, options ...discordgo.RequestOption) (*discordgo.Channel, error)
ChannelPermissionSet(channelID, targetID string, targetType discordgo.PermissionOverwriteType, allow, deny int64, options ...discordgo.RequestOption) error
ChannelPermissionDelete(channelID, targetID string, options ...discordgo.RequestOption) error
ChannelMessageSendComplex(channelID string, data *discordgo.MessageSend, options ...discordgo.RequestOption) (*discordgo.Message, error)
RequestWithBucketID(method, urlStr string, data interface{}, bucketID string, options ...discordgo.RequestOption) ([]byte, error)
ChannelMessageDelete(channelID, messageID string, options ...discordgo.RequestOption) error
GuildRoleCreate(guildID string, data *discordgo.RoleParams, options ...discordgo.RequestOption) (*discordgo.Role, error)
GuildRoleEdit(guildID, roleID string, data *discordgo.RoleParams, options ...discordgo.RequestOption) (*discordgo.Role, error)
GuildChannelCreateComplex(guildID string, data discordgo.GuildChannelCreateData, options ...discordgo.RequestOption) (*discordgo.Channel, error)
}
// UserError is shown to the person who acted, as written.
type UserError string
func (e UserError) Error() string { return string(e) }
const (
ErrNotTrusted = UserError("Only the server owner or someone on the security trusted list can do that.")
ErrNotOwner = UserError("Only the server owner can change the trusted list.")
)
// Dangerous permissions: what a nuker needs, and what no quarantine or alert
// role may have.
const DangerousPerms = discordgo.PermissionAdministrator | discordgo.PermissionManageGuild |
discordgo.PermissionManageRoles | discordgo.PermissionManageWebhooks
const (
timeoutJoin = 24 * time.Hour
timeoutQuarantine = 28*24*time.Hour - time.Hour // Discord's maximum is 28 days
settingsTTL = 15 * time.Second
ownerTTL = 10 * time.Minute
)
// Engine holds the system's state. One per bot.
type Engine struct {
d Discord
st Store
botID string
now func() time.Time
mu sync.Mutex
settings map[string]cachedSettings
owners map[string]cachedOwner
raids map[string]*raid
nukes *nukeCounter
roleCache map[string]cachedRoles
heat map[string]*guildHeat
restoring map[string]bool
// syncDMs sends alert DMs before returning (tests); normally they go out
// in the background so an alert never waits on them.
syncDMs bool
// ChannelPerms reports a member's permissions in a channel from the
// gateway cache (no REST call), used to exempt moderators from heat.
// Nil means nobody is exempt by permission.
ChannelPerms func(userID, channelID string) (int64, error)
}
type cachedSettings struct {
s *securityrepo.Settings
trusted []securityrepo.Trusted
at time.Time
}
type cachedOwner struct {
id string
name string
at time.Time
}
func New(d Discord, st Store, botID string) *Engine {
return &Engine{
d: d, st: st, botID: botID, now: time.Now,
settings: map[string]cachedSettings{},
owners: map[string]cachedOwner{},
raids: map[string]*raid{},
nukes: newNukeCounter(),
heat: map[string]*guildHeat{},
restoring: map[string]bool{},
}
}
var (
defaultEngine *Engine
pendingStore Store
)
// Configure sets the store; Start creates the engine once the bot knows its
// own user ID (after the gateway connects).
func Configure(st Store) { pendingStore = st }
// Start creates the default engine for a connected session. It returns nil
// when no store was configured.
func Start(d Discord, botID string) *Engine {
if pendingStore == nil {
return nil
}
defaultEngine = New(d, pendingStore, botID)
return defaultEngine
}
func Init(e *Engine) { defaultEngine = e }
func Default() *Engine { return defaultEngine }
func parseID(s string) int64 {
n, _ := strconv.ParseInt(s, 10, 64)
return n
}
func id(n int64) string { return strconv.FormatInt(n, 10) }
// load returns the guild's settings and trusted list, cached briefly.
func (e *Engine) load(ctx context.Context, guildID string) (*securityrepo.Settings, []securityrepo.Trusted, error) {
e.mu.Lock()
c, ok := e.settings[guildID]
e.mu.Unlock()
if ok && e.now().Sub(c.at) < settingsTTL {
return c.s, c.trusted, nil
}
g := parseID(guildID)
s, err := e.st.GetSettings(ctx, g)
if err != nil {
return nil, nil, err
}
trusted, err := e.st.ListTrusted(ctx, g)
if err != nil {
return nil, nil, err
}
e.mu.Lock()
e.settings[guildID] = cachedSettings{s: s, trusted: trusted, at: e.now()}
e.mu.Unlock()
return s, trusted, nil
}
// Invalidate drops the cached settings after a change, so it applies at once.
func (e *Engine) Invalidate(guildID string) {
e.mu.Lock()
delete(e.settings, guildID)
e.mu.Unlock()
}
// Settings returns the current settings (for status displays).
func (e *Engine) Settings(ctx context.Context, guildID string) (*securityrepo.Settings, []securityrepo.Trusted, error) {
return e.load(ctx, guildID)
}
// OwnerID returns the guild owner's ID, cached.
func (e *Engine) OwnerID(guildID string) (string, error) {
e.mu.Lock()
c, ok := e.owners[guildID]
e.mu.Unlock()
if ok && e.now().Sub(c.at) < ownerTTL {
return c.id, nil
}
g, err := e.d.Guild(guildID)
if err != nil {
return "", err
}
e.mu.Lock()
e.owners[guildID] = cachedOwner{id: g.OwnerID, name: g.Name, at: e.now()}
e.mu.Unlock()
return g.OwnerID, nil
}
// guildName returns the guild's name (cached with the owner), or "".
func (e *Engine) guildName(guildID string) string {
if _, err := e.OwnerID(guildID); err != nil {
return ""
}
e.mu.Lock()
defer e.mu.Unlock()
return e.owners[guildID].name
}
// trustedBy reports whether a user (with their role IDs) is on the list.
func trustedBy(list []securityrepo.Trusted, userID string, roles []string) bool {
uid := parseID(userID)
for _, t := range list {
switch t.Kind {
case "user":
if t.TargetID == uid {
return true
}
case "role":
for _, r := range roles {
if parseID(r) == t.TargetID {
return true
}
}
}
}
return false
}
// IsTrusted reports whether a user is exempt from anti-nuke and may change
// security settings: the owner, the bot itself, or someone on the trusted
// list (directly or by role). When roles is nil the member is looked up.
// Any lookup failure counts as not trusted.
func (e *Engine) IsTrusted(ctx context.Context, guildID, userID string, roles []string) bool {
if userID == "" {
return false
}
if userID == e.botID {
return true
}
if owner, err := e.OwnerID(guildID); err == nil && owner == userID {
return true
}
_, trusted, err := e.load(ctx, guildID)
if err != nil {
return false
}
if roles == nil {
m, err := e.d.GuildMember(guildID, userID)
if err != nil {
return trustedBy(trusted, userID, nil)
}
roles = m.Roles
}
return trustedBy(trusted, userID, roles)
}
// IsOwner reports whether the user owns the guild. Lookup failures are "no".
func (e *Engine) IsOwner(guildID, userID string) bool {
owner, err := e.OwnerID(guildID)
return err == nil && owner != "" && owner == userID
}
// RequireTrusted returns ErrNotTrusted unless the actor is owner or trusted.
func (e *Engine) RequireTrusted(ctx context.Context, guildID string, actor *discordgo.Member) error {
if actor == nil || actor.User == nil || !e.IsTrusted(ctx, guildID, actor.User.ID, actor.Roles) {
return ErrNotTrusted
}
return nil
}
func (e *Engine) event(ctx context.Context, guildID string, kind string, actor, target string, detail string) {
_ = e.st.AddEvent(ctx, securityrepo.Event{
GuildID: parseID(guildID), Kind: kind, ActorID: parseID(actor), TargetID: parseID(target), Detail: clip(detail, 1000),
})
}
func clip(s string, n int) string {
if r := []rune(s); len(r) > n {
return string(r[:n-1]) + "…"
}
return s
}
// safeName renders a user-controlled name for an embed: markdown escaped and
// mentions defused (allowed mentions are restricted too, this is belt and
// braces).
func safeName(u *discordgo.User) string {
if u == nil {
return "unknown"
}
name := u.Username
if u.GlobalName != "" && u.GlobalName != u.Username {
name = u.GlobalName + " (" + u.Username + ")"
}
r := strings.NewReplacer(
`\`, `\\`, `*`, `\*`, `_`, `\_`, "`", "\\`", `~`, `\~`, `|`, `\|`, `>`, `\>`, `#`, `\#`,
`[`, `\[`, `]`, `\]`, `(`, `\(`, `)`, `\)`, `@`, "@​", `<`, "<​",
)
return clip(r.Replace(name), 80)
}