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]>
208 lines
6.6 KiB
Go
208 lines
6.6 KiB
Go
package security
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
|
|
"velox-bot/internal/db/repos/securityrepo"
|
|
|
|
"github.com/bwmarrin/discordgo"
|
|
)
|
|
|
|
// The configuration calls below do their own permission checks, so every
|
|
// way in (slash commands, buttons, anything added later) gets the same rules:
|
|
//
|
|
// - switching a system on: Manage Server, or owner/trusted
|
|
// - switching a system off, alert and quarantine settings: owner/trusted
|
|
// - the trusted list: owner only
|
|
//
|
|
// actorPerms is the actor's permission set as Discord sent it with the
|
|
// interaction.
|
|
|
|
const maxTrusted = 25
|
|
|
|
func hasManageGuild(perms int64) bool {
|
|
return perms&(discordgo.PermissionAdministrator|discordgo.PermissionManageGuild) != 0
|
|
}
|
|
|
|
// SetFeature turns the Join Gate, raid mode or anti-nuke on or off.
|
|
func (e *Engine) SetFeature(ctx context.Context, guildID string, actor *discordgo.Member, actorPerms int64, feature string, on bool) error {
|
|
if actor == nil || actor.User == nil {
|
|
return ErrNotTrusted
|
|
}
|
|
switch feature {
|
|
case securityrepo.FeatureJoinGate, securityrepo.FeatureAntiRaid, securityrepo.FeatureAntiNuke, securityrepo.FeatureHeat:
|
|
default:
|
|
return UserError("Unknown feature.")
|
|
}
|
|
trustedErr := e.RequireTrusted(ctx, guildID, actor)
|
|
if !on && trustedErr != nil {
|
|
return trustedErr
|
|
}
|
|
if on && trustedErr != nil && !hasManageGuild(actorPerms) {
|
|
return UserError("You need Manage Server to turn security features on.")
|
|
}
|
|
if err := e.st.SetEnabled(ctx, parseID(guildID), feature, on); err != nil {
|
|
return err
|
|
}
|
|
e.Invalidate(guildID)
|
|
state := "off"
|
|
if on {
|
|
state = "on"
|
|
}
|
|
e.event(ctx, guildID, "settings", actor.User.ID, "", feature+" "+state)
|
|
return nil
|
|
}
|
|
|
|
func (e *Engine) roleByID(guildID, roleID string) (*discordgo.Role, []*discordgo.Role, error) {
|
|
roles, err := e.d.GuildRoles(guildID)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("listing roles: %w", err)
|
|
}
|
|
for _, r := range roles {
|
|
if r.ID == roleID {
|
|
return r, roles, nil
|
|
}
|
|
}
|
|
return nil, roles, UserError("That role isn't in this server.")
|
|
}
|
|
|
|
// SetAlerts sets where alerts go and which role they ping. Changing it could
|
|
// hide alerts from the staff, so it needs owner or trusted.
|
|
func (e *Engine) SetAlerts(ctx context.Context, guildID string, actor *discordgo.Member, channelID, roleID string) error {
|
|
if err := e.RequireTrusted(ctx, guildID, actor); err != nil {
|
|
return err
|
|
}
|
|
ch, err := e.d.Channel(channelID)
|
|
if err != nil || ch.GuildID != guildID {
|
|
return UserError("That channel isn't in this server.")
|
|
}
|
|
if ch.Type != discordgo.ChannelTypeGuildText && ch.Type != discordgo.ChannelTypeGuildNews {
|
|
return UserError("Alerts need a text channel.")
|
|
}
|
|
if roleID != "" {
|
|
if roleID == guildID {
|
|
return UserError("Alerts can't ping @everyone. Pick a staff role.")
|
|
}
|
|
if _, _, err := e.roleByID(guildID, roleID); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if err := e.st.SetAlerts(ctx, parseID(guildID), parseID(channelID), parseID(roleID)); err != nil {
|
|
return err
|
|
}
|
|
e.Invalidate(guildID)
|
|
e.event(ctx, guildID, "settings", actor.User.ID, channelID, "alerts channel and role changed")
|
|
return nil
|
|
}
|
|
|
|
// ValidateQuarantineRole checks a role can safely be the quarantine role:
|
|
// in this server, not @everyone, not managed, no dangerous permissions, and
|
|
// below Velox's highest role so the bot can assign it.
|
|
func (e *Engine) ValidateQuarantineRole(guildID, roleID string) error {
|
|
if roleID == guildID {
|
|
return UserError("@everyone can't be the quarantine role.")
|
|
}
|
|
role, roles, err := e.roleByID(guildID, roleID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if role.Managed {
|
|
return UserError("That role is managed by an integration and can't be assigned.")
|
|
}
|
|
if role.Permissions&DangerousPerms != 0 {
|
|
return UserError("The quarantine role can't have Administrator, Manage Server, Manage Roles or Manage Webhooks.")
|
|
}
|
|
me, err := e.d.GuildMember(guildID, e.botID)
|
|
if err != nil {
|
|
return fmt.Errorf("looking up Velox: %w", err)
|
|
}
|
|
top := -1
|
|
for _, r := range roles {
|
|
for _, mine := range me.Roles {
|
|
if r.ID == mine && r.Position > top {
|
|
top = r.Position
|
|
}
|
|
}
|
|
}
|
|
if role.Position >= top {
|
|
return UserError("That role is above Velox's highest role, so Velox couldn't assign it. Move it below Velox's role.")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// SetQuarantineRole sets (or with "" clears) the quarantine role.
|
|
func (e *Engine) SetQuarantineRole(ctx context.Context, guildID string, actor *discordgo.Member, roleID string) error {
|
|
if err := e.RequireTrusted(ctx, guildID, actor); err != nil {
|
|
return err
|
|
}
|
|
if roleID != "" {
|
|
if err := e.ValidateQuarantineRole(guildID, roleID); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if err := e.st.SetQuarantineRole(ctx, parseID(guildID), parseID(roleID)); err != nil {
|
|
return err
|
|
}
|
|
e.Invalidate(guildID)
|
|
e.event(ctx, guildID, "settings", actor.User.ID, roleID, "quarantine role changed")
|
|
return nil
|
|
}
|
|
|
|
// Trust adds a user or role to the trusted list. Owner only: anyone trusted
|
|
// can switch protection off, so trust must not spread on its own.
|
|
func (e *Engine) Trust(ctx context.Context, guildID string, actor *discordgo.Member, kind, targetID string) error {
|
|
if actor == nil || actor.User == nil || !e.IsOwner(guildID, actor.User.ID) {
|
|
return ErrNotOwner
|
|
}
|
|
_, trusted, err := e.load(ctx, guildID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(trusted) >= maxTrusted {
|
|
return UserError(fmt.Sprintf("The trusted list can hold at most %d entries.", maxTrusted))
|
|
}
|
|
switch kind {
|
|
case "role":
|
|
if targetID == guildID {
|
|
return UserError("Trusting @everyone would make everyone trusted.")
|
|
}
|
|
role, _, err := e.roleByID(guildID, targetID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if role.Managed {
|
|
return UserError("Integration roles can't be trusted; trust the bot user instead.")
|
|
}
|
|
case "user":
|
|
if _, err := e.d.GuildMember(guildID, targetID); err != nil {
|
|
return UserError("That user isn't in this server.")
|
|
}
|
|
default:
|
|
return UserError("Trust a user or a role.")
|
|
}
|
|
if err := e.st.AddTrusted(ctx, parseID(guildID), securityrepo.Trusted{TargetID: parseID(targetID), Kind: kind, AddedBy: parseID(actor.User.ID)}); err != nil {
|
|
return err
|
|
}
|
|
e.Invalidate(guildID)
|
|
e.event(ctx, guildID, "trusted_added", actor.User.ID, targetID, kind)
|
|
return nil
|
|
}
|
|
|
|
// Untrust removes a user or role from the trusted list. Owner only.
|
|
func (e *Engine) Untrust(ctx context.Context, guildID string, actor *discordgo.Member, targetID string) error {
|
|
if actor == nil || actor.User == nil || !e.IsOwner(guildID, actor.User.ID) {
|
|
return ErrNotOwner
|
|
}
|
|
ok, err := e.st.RemoveTrusted(ctx, parseID(guildID), parseID(targetID))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !ok {
|
|
return UserError("That isn't on the trusted list.")
|
|
}
|
|
e.Invalidate(guildID)
|
|
e.event(ctx, guildID, "trusted_removed", actor.User.ID, targetID, "")
|
|
return nil
|
|
}
|