Files
velox-bot/internal/security/quarantine.go
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

188 lines
6.1 KiB
Go

package security
import (
"context"
"errors"
"fmt"
"log"
"time"
"velox-bot/internal/db/repos/securityrepo"
"github.com/bwmarrin/discordgo"
)
// usableQuarantineRole returns the configured quarantine role if it is safe
// to hand out right now: it exists, isn't @everyone or managed by an
// integration, and has none of the dangerous permissions. Checked at use, so
// a role that gained permissions since it was configured is never assigned.
func usableQuarantineRole(s *securityrepo.Settings, guildID string, roles []*discordgo.Role) string {
if s.QuarantineRoleID == 0 || id(s.QuarantineRoleID) == guildID {
return ""
}
for _, r := range roles {
if r.ID == id(s.QuarantineRoleID) {
if r.Managed || r.Permissions&DangerousPerms != 0 {
return ""
}
return r.ID
}
}
return ""
}
// Quarantine neutralises a member: every role it can remove is taken away
// (and saved, so it can be given back), the quarantine role is added if one
// is configured, and the member is timed out for the longest Discord allows.
// Bots are kicked instead, since their own integration role can't be
// removed. The owner and the bot itself are never touched.
func (e *Engine) Quarantine(ctx context.Context, guildID, userID, reason string) error {
if userID == "" || userID == e.botID || e.IsOwner(guildID, userID) {
return errors.New("refusing to quarantine the owner or the bot")
}
s, _, err := e.load(ctx, guildID)
if err != nil {
return err
}
m, err := e.d.GuildMember(guildID, userID)
if err != nil {
return fmt.Errorf("looking up the member: %w", err)
}
if m.User != nil && m.User.Bot {
return e.d.GuildMemberDeleteWithReason(guildID, userID, clip("Velox anti-nuke: "+reason, 400))
}
roles, err := e.d.GuildRoles(guildID)
if err != nil {
return fmt.Errorf("listing roles: %w", err)
}
managed := map[string]bool{}
for _, r := range roles {
if r.Managed {
managed[r.ID] = true
}
}
var keep []string
var previous []int64
for _, r := range m.Roles {
if managed[r] {
keep = append(keep, r) // can't be removed (e.g. a Nitro booster role)
} else {
previous = append(previous, parseID(r))
}
}
qRole := usableQuarantineRole(s, guildID, roles)
withRole := keep
if qRole != "" {
withRole = append(append([]string(nil), keep...), qRole)
}
// Save first: if Discord fails half-way, the roles are still on record.
if err := e.st.SaveQuarantine(ctx, securityrepo.Quarantine{
GuildID: parseID(guildID), UserID: parseID(userID), PreviousRoleIDs: previous, Reason: clip(reason, 300),
}); err != nil {
return fmt.Errorf("saving the quarantine: %w", err)
}
audit := discordgo.WithAuditLogReason(clip("Velox anti-nuke: "+reason, 400))
var errs []error
if _, err := e.d.GuildMemberEdit(guildID, userID, &discordgo.GuildMemberParams{Roles: &withRole}, audit); err != nil {
// A quarantine role Velox can't assign (e.g. moved above its own
// role) must not stop the roles from being removed: retry without it.
if qRole == "" {
errs = append(errs, fmt.Errorf("removing roles: %w", err))
} else if _, err2 := e.d.GuildMemberEdit(guildID, userID, &discordgo.GuildMemberParams{Roles: &keep}, audit); err2 != nil {
errs = append(errs, fmt.Errorf("removing roles: %w", err2))
} else {
log.Printf("security: quarantine role %s couldn't be assigned in guild %s (%v); roles were still removed", qRole, guildID, err)
}
}
until := e.now().Add(timeoutQuarantine)
if err := e.d.GuildMemberTimeout(guildID, userID, &until, audit); err != nil {
errs = append(errs, fmt.Errorf("timing out: %w", err))
}
e.event(ctx, guildID, securityrepo.EventQuarantined, e.botID, userID, reason)
return errors.Join(errs...)
}
// Release undoes a quarantine: the saved roles that still exist are given
// back, the quarantine role and the timeout are removed. Releasing relaxes
// protection, so callers must check the actor first.
func (e *Engine) Release(ctx context.Context, guildID, userID, by string) error {
rec, err := e.st.GetQuarantine(ctx, parseID(guildID), parseID(userID))
if err != nil {
return err
}
if rec == nil {
return UserError("That member isn't quarantined.")
}
s, _, err := e.load(ctx, guildID)
if err != nil {
return err
}
m, err := e.d.GuildMember(guildID, userID)
if err != nil {
// They left: there's nothing to give back.
if derr := e.st.DeleteQuarantine(ctx, parseID(guildID), parseID(userID)); derr != nil {
return derr
}
e.event(ctx, guildID, securityrepo.EventReleased, by, userID, "member had left")
return nil
}
roles, err := e.d.GuildRoles(guildID)
if err != nil {
return fmt.Errorf("listing roles: %w", err)
}
exists := map[string]bool{}
for _, r := range roles {
if !r.Managed && r.ID != guildID {
exists[r.ID] = true
}
}
qRole := ""
if s.QuarantineRoleID != 0 {
qRole = id(s.QuarantineRoleID)
}
seen := map[string]bool{}
var next []string
add := func(r string) {
if !seen[r] && r != qRole {
seen[r] = true
next = append(next, r)
}
}
for _, r := range m.Roles {
add(r)
}
for _, r := range rec.PreviousRoleIDs {
if exists[id(r)] {
add(id(r))
}
}
audit := discordgo.WithAuditLogReason("Velox security: quarantine released")
if _, err := e.d.GuildMemberEdit(guildID, userID, &discordgo.GuildMemberParams{Roles: &next}, audit); err != nil {
return fmt.Errorf("giving the roles back: %w", err)
}
if err := e.d.GuildMemberTimeout(guildID, userID, nil, audit); err != nil {
log.Printf("security: removing the timeout of %s in guild %s: %v", userID, guildID, err)
}
if err := e.st.DeleteQuarantine(ctx, parseID(guildID), parseID(userID)); err != nil {
return err
}
e.event(ctx, guildID, securityrepo.EventReleased, by, userID, fmt.Sprintf("%d roles given back", len(rec.PreviousRoleIDs)))
e.send(ctx, guildID, s, alert{embed: &discordgo.MessageEmbed{
Title: "Quarantine released",
Description: fmt.Sprintf("<@%s> was released by <@%s> and got their roles back.", userID, by),
Color: colorOK,
Timestamp: e.now().UTC().Format(time.RFC3339),
}})
return nil
}
// Quarantines lists who is quarantined in a guild.
func (e *Engine) Quarantines(ctx context.Context, guildID string) ([]securityrepo.Quarantine, error) {
return e.st.ListQuarantines(ctx, parseID(guildID))
}