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]>
This commit is contained in:
1 parent
d294b3463b
commit
3f87e68a2c
26 files changed
+6022
No files matched your search
@@ -0,0 +1,354 @@
|
||||
// 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)
|
||||
}
|
||||
Reference in new issue
Block a user