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)) }