- /music skip, pause, resume, stop, repeat, remove, move (DJ role) - /config view, clear, toggletwitch, streamers, beta-features (server owner) - /slvl status, remove-reward; /moderation bans, timeouts; /security history - /beta is now the bot owner's DM-only command: set, off, servers, remove-server, status - betasettings service and repo methods, with integration tests Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
193 lines
6.4 KiB
Go
193 lines
6.4 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.
|
|
// Events returns the server's recent security history, newest first.
|
|
func (e *Engine) Events(ctx context.Context, guildID string, limit int) ([]securityrepo.Event, error) {
|
|
return e.st.ListEvents(ctx, parseID(guildID), limit)
|
|
}
|
|
|
|
func (e *Engine) Quarantines(ctx context.Context, guildID string) ([]securityrepo.Quarantine, error) {
|
|
return e.st.ListQuarantines(ctx, parseID(guildID))
|
|
}
|