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]>
188 lines
6.1 KiB
Go
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))
|
|
}
|