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,166 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"velox-bot/internal/db/repos/securityrepo"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
)
|
||||
|
||||
// LockBits are what a lockdown denies @everyone: talking, threads, reactions.
|
||||
const LockBits = discordgo.PermissionSendMessages | discordgo.PermissionSendMessagesInThreads |
|
||||
discordgo.PermissionCreatePublicThreads | discordgo.PermissionCreatePrivateThreads | discordgo.PermissionAddReactions
|
||||
|
||||
// lockable reports whether a channel type has a chat to lock.
|
||||
func lockable(t discordgo.ChannelType) bool {
|
||||
switch t {
|
||||
case discordgo.ChannelTypeGuildText, discordgo.ChannelTypeGuildNews, discordgo.ChannelTypeGuildForum,
|
||||
discordgo.ChannelTypeGuildVoice, discordgo.ChannelTypeGuildStageVoice, discordgo.ChannelTypeGuildMedia:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func everyoneOverwrite(ch *discordgo.Channel, guildID string) (allow, deny int64, found bool) {
|
||||
for _, o := range ch.PermissionOverwrites {
|
||||
if o.ID == guildID && o.Type == discordgo.PermissionOverwriteTypeRole {
|
||||
return o.Allow, o.Deny, true
|
||||
}
|
||||
}
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
// LockChannel stops @everyone from talking in a channel, remembering the
|
||||
// overwrite it had so UnlockChannel can put it back.
|
||||
func (e *Engine) LockChannel(ctx context.Context, guildID, channelID, by string, serverWide bool) error {
|
||||
ch, err := e.d.Channel(channelID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("looking up the channel: %w", err)
|
||||
}
|
||||
// Never act on a channel of another server, whatever ID was passed in.
|
||||
if ch.GuildID != guildID {
|
||||
return UserError("That channel isn't in this server.")
|
||||
}
|
||||
if !lockable(ch.Type) {
|
||||
return UserError("That channel has no chat to lock.")
|
||||
}
|
||||
allow, deny, had := everyoneOverwrite(ch, guildID)
|
||||
if err := e.st.SaveLockdown(ctx, securityrepo.Lockdown{
|
||||
GuildID: parseID(guildID), ChannelID: parseID(channelID), HadOverwrite: had,
|
||||
PreviousAllow: allow, PreviousDeny: deny, ServerWide: serverWide, LockedBy: parseID(by),
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := e.d.ChannelPermissionSet(channelID, guildID, discordgo.PermissionOverwriteTypeRole, allow&^LockBits, deny|LockBits,
|
||||
discordgo.WithAuditLogReason("Velox lockdown")); err != nil {
|
||||
_ = e.st.DeleteLockdown(ctx, parseID(guildID), parseID(channelID))
|
||||
return fmt.Errorf("locking: %w", err)
|
||||
}
|
||||
if !serverWide {
|
||||
e.event(ctx, guildID, securityrepo.EventLocked, by, channelID, "channel")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UnlockChannel restores the lock bits of @everyone's overwrite to what they
|
||||
// were before. Anything else changed in the overwrite during the lockdown is
|
||||
// kept. Unlocking relaxes protection, so callers must check the actor first.
|
||||
func (e *Engine) UnlockChannel(ctx context.Context, guildID, channelID, by string) error {
|
||||
rec, err := e.st.GetLockdown(ctx, parseID(guildID), parseID(channelID))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rec == nil {
|
||||
return UserError("That channel isn't locked by Velox.")
|
||||
}
|
||||
ch, err := e.d.Channel(channelID)
|
||||
if err != nil {
|
||||
// The channel is gone: nothing to unlock.
|
||||
return e.st.DeleteLockdown(ctx, rec.GuildID, rec.ChannelID)
|
||||
}
|
||||
allow, deny, _ := everyoneOverwrite(ch, guildID)
|
||||
allow = allow&^LockBits | rec.PreviousAllow&LockBits
|
||||
deny = deny&^LockBits | rec.PreviousDeny&LockBits
|
||||
|
||||
reason := discordgo.WithAuditLogReason("Velox unlock")
|
||||
if allow == 0 && deny == 0 && !rec.HadOverwrite {
|
||||
err = e.d.ChannelPermissionDelete(channelID, guildID, reason)
|
||||
} else {
|
||||
err = e.d.ChannelPermissionSet(channelID, guildID, discordgo.PermissionOverwriteTypeRole, allow, deny, reason)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("unlocking: %w", err)
|
||||
}
|
||||
if err := e.st.DeleteLockdown(ctx, rec.GuildID, rec.ChannelID); err != nil {
|
||||
return err
|
||||
}
|
||||
if !rec.ServerWide {
|
||||
e.event(ctx, guildID, securityrepo.EventUnlocked, by, channelID, "channel")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// LockServer locks every channel with a chat.
|
||||
func (e *Engine) LockServer(ctx context.Context, guildID, by string) (locked, failed int, err error) {
|
||||
chans, err := e.d.GuildChannels(guildID)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("listing channels: %w", err)
|
||||
}
|
||||
for _, ch := range chans {
|
||||
if !lockable(ch.Type) {
|
||||
continue
|
||||
}
|
||||
if err := e.LockChannel(ctx, guildID, ch.ID, by, true); err != nil {
|
||||
log.Printf("security: locking channel %s in guild %s: %v", ch.ID, guildID, err)
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
locked++
|
||||
}
|
||||
e.event(ctx, guildID, securityrepo.EventLocked, by, "", fmt.Sprintf("server: %d channels locked, %d failed", locked, failed))
|
||||
return locked, failed, nil
|
||||
}
|
||||
|
||||
// UnlockServer unlocks every channel the last server lockdown locked.
|
||||
func (e *Engine) UnlockServer(ctx context.Context, guildID, by string) (unlocked, failed int, err error) {
|
||||
recs, err := e.st.ListLockdowns(ctx, parseID(guildID), true)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
if len(recs) == 0 {
|
||||
return 0, 0, UserError("The server isn't locked by Velox.")
|
||||
}
|
||||
for _, r := range recs {
|
||||
if err := e.UnlockChannel(ctx, guildID, id(r.ChannelID), by); err != nil {
|
||||
log.Printf("security: unlocking channel %d in guild %s: %v", r.ChannelID, guildID, err)
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
unlocked++
|
||||
}
|
||||
e.event(ctx, guildID, securityrepo.EventUnlocked, by, "", fmt.Sprintf("server: %d channels unlocked, %d failed", unlocked, failed))
|
||||
return unlocked, failed, nil
|
||||
}
|
||||
|
||||
// SetJoinLock turns the join lockdown on ("kick" or "ban") or off. Turning it
|
||||
// off relaxes protection, so callers must check the actor first.
|
||||
func (e *Engine) SetJoinLock(ctx context.Context, guildID, mode, by string) error {
|
||||
switch mode {
|
||||
case securityrepo.ActionOff, securityrepo.ActionKick, securityrepo.ActionBan:
|
||||
default:
|
||||
return UserError("Joins can be locked with kick or ban.")
|
||||
}
|
||||
if err := e.st.SetJoinLock(ctx, parseID(guildID), mode); err != nil {
|
||||
return err
|
||||
}
|
||||
e.Invalidate(guildID)
|
||||
kind := securityrepo.EventLocked
|
||||
if mode == securityrepo.ActionOff {
|
||||
kind = securityrepo.EventUnlocked
|
||||
}
|
||||
e.event(ctx, guildID, kind, by, "", "joins: "+mode)
|
||||
return nil
|
||||
}
|
||||
Reference in new issue
Block a user