Files
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

558 lines
16 KiB
Go

package security
import (
"context"
"errors"
"fmt"
"io"
"sort"
"sync"
"time"
"velox-bot/internal/db/repos/securityrepo"
"github.com/bwmarrin/discordgo"
)
// fakeStore keeps everything in memory with the same rules as the SQL.
type fakeStore struct {
mu sync.Mutex
settings map[int64]*securityrepo.Settings
trusted map[int64][]securityrepo.Trusted
quarantines map[string]securityrepo.Quarantine
lockdowns map[string]securityrepo.Lockdown
events []securityrepo.Event
cmds []securityrepo.Command
done map[int64]bool
raids map[int64]securityrepo.Raid
caught map[int64][]securityrepo.Caught
snapshots []securityrepo.Snapshot
}
func newStore() *fakeStore {
return &fakeStore{
settings: map[int64]*securityrepo.Settings{}, trusted: map[int64][]securityrepo.Trusted{},
quarantines: map[string]securityrepo.Quarantine{}, lockdowns: map[string]securityrepo.Lockdown{}, done: map[int64]bool{},
raids: map[int64]securityrepo.Raid{}, caught: map[int64][]securityrepo.Caught{},
}
}
func (f *fakeStore) SaveRaid(_ context.Context, x securityrepo.Raid) error {
f.mu.Lock()
defer f.mu.Unlock()
if old, ok := f.raids[x.GuildID]; ok {
x.StartedAt = old.StartedAt
}
f.raids[x.GuildID] = x
return nil
}
func (f *fakeStore) ListRaids(context.Context) ([]securityrepo.Raid, error) {
f.mu.Lock()
defer f.mu.Unlock()
var out []securityrepo.Raid
for _, x := range f.raids {
out = append(out, x)
}
return out, nil
}
func (f *fakeStore) DeleteRaid(_ context.Context, g int64) error {
f.mu.Lock()
defer f.mu.Unlock()
delete(f.raids, g)
delete(f.caught, g)
return nil
}
func (f *fakeStore) AddRaidCaught(_ context.Context, g int64, c securityrepo.Caught) error {
f.mu.Lock()
defer f.mu.Unlock()
for _, x := range f.caught[g] {
if x.UserID == c.UserID {
return nil
}
}
f.caught[g] = append(f.caught[g], c)
return nil
}
func (f *fakeStore) ListRaidCaught(_ context.Context, g int64, limit int) ([]securityrepo.Caught, error) {
f.mu.Lock()
defer f.mu.Unlock()
out := f.caught[g]
if len(out) > limit {
out = out[:limit]
}
return append([]securityrepo.Caught(nil), out...), nil
}
func (f *fakeStore) SaveSnapshot(_ context.Context, s securityrepo.Snapshot) error {
f.mu.Lock()
defer f.mu.Unlock()
s.ID = int64(len(f.snapshots) + 1)
s.CreatedAt = time.Date(2026, 10, 20, 6, 0, 0, 0, time.UTC)
f.snapshots = append(f.snapshots, s)
return nil
}
func (f *fakeStore) LatestSnapshot(_ context.Context, g int64) (*securityrepo.Snapshot, error) {
f.mu.Lock()
defer f.mu.Unlock()
for i := len(f.snapshots) - 1; i >= 0; i-- {
if f.snapshots[i].GuildID == g {
s := f.snapshots[i]
return &s, nil
}
}
return nil, nil
}
func (f *fakeStore) GuildsDueForSnapshot(context.Context, time.Duration) ([]int64, error) {
return nil, nil
}
func key(a, b int64) string { return fmt.Sprint(a, "/", b) }
func (f *fakeStore) set(g int64) *securityrepo.Settings {
f.mu.Lock()
defer f.mu.Unlock()
s, ok := f.settings[g]
if !ok {
s = securityrepo.Defaults(g)
s.Configured = true
f.settings[g] = s
}
return s
}
func (f *fakeStore) GetSettings(_ context.Context, g int64) (*securityrepo.Settings, error) {
f.mu.Lock()
defer f.mu.Unlock()
if s, ok := f.settings[g]; ok {
cp := *s
return &cp, nil
}
return securityrepo.Defaults(g), nil
}
func (f *fakeStore) SetEnabled(_ context.Context, g int64, feature string, on bool) error {
s := f.set(g)
switch feature {
case securityrepo.FeatureJoinGate:
s.JoinGateEnabled = on
case securityrepo.FeatureAntiRaid:
s.AntiRaidEnabled = on
case securityrepo.FeatureAntiNuke:
s.AntiNukeEnabled = on
}
return nil
}
func (f *fakeStore) SetAlerts(_ context.Context, g, ch, role int64) error {
s := f.set(g)
s.AlertChannelID, s.AlertRoleID = ch, role
return nil
}
func (f *fakeStore) SetQuarantineRole(_ context.Context, g, role int64) error {
f.set(g).QuarantineRoleID = role
return nil
}
func (f *fakeStore) SetJoinLock(_ context.Context, g int64, mode string) error {
f.set(g).JoinLock = mode
return nil
}
func (f *fakeStore) ListTrusted(_ context.Context, g int64) ([]securityrepo.Trusted, error) {
f.mu.Lock()
defer f.mu.Unlock()
return append([]securityrepo.Trusted(nil), f.trusted[g]...), nil
}
func (f *fakeStore) AddTrusted(_ context.Context, g int64, t securityrepo.Trusted) error {
f.mu.Lock()
defer f.mu.Unlock()
f.trusted[g] = append(f.trusted[g], t)
return nil
}
func (f *fakeStore) RemoveTrusted(_ context.Context, g, target int64) (bool, error) {
f.mu.Lock()
defer f.mu.Unlock()
var keep []securityrepo.Trusted
found := false
for _, t := range f.trusted[g] {
if t.TargetID == target {
found = true
continue
}
keep = append(keep, t)
}
f.trusted[g] = keep
return found, nil
}
func (f *fakeStore) SaveQuarantine(_ context.Context, q securityrepo.Quarantine) error {
f.mu.Lock()
defer f.mu.Unlock()
if _, ok := f.quarantines[key(q.GuildID, q.UserID)]; !ok {
f.quarantines[key(q.GuildID, q.UserID)] = q
}
return nil
}
func (f *fakeStore) GetQuarantine(_ context.Context, g, u int64) (*securityrepo.Quarantine, error) {
f.mu.Lock()
defer f.mu.Unlock()
if q, ok := f.quarantines[key(g, u)]; ok {
return &q, nil
}
return nil, nil
}
func (f *fakeStore) ListQuarantines(_ context.Context, g int64) ([]securityrepo.Quarantine, error) {
f.mu.Lock()
defer f.mu.Unlock()
var out []securityrepo.Quarantine
for _, q := range f.quarantines {
if q.GuildID == g {
out = append(out, q)
}
}
return out, nil
}
func (f *fakeStore) DeleteQuarantine(_ context.Context, g, u int64) error {
f.mu.Lock()
defer f.mu.Unlock()
delete(f.quarantines, key(g, u))
return nil
}
func (f *fakeStore) SaveLockdown(_ context.Context, l securityrepo.Lockdown) error {
f.mu.Lock()
defer f.mu.Unlock()
if _, ok := f.lockdowns[key(l.GuildID, l.ChannelID)]; !ok {
f.lockdowns[key(l.GuildID, l.ChannelID)] = l
}
return nil
}
func (f *fakeStore) GetLockdown(_ context.Context, g, ch int64) (*securityrepo.Lockdown, error) {
f.mu.Lock()
defer f.mu.Unlock()
if l, ok := f.lockdowns[key(g, ch)]; ok {
return &l, nil
}
return nil, nil
}
func (f *fakeStore) ListLockdowns(_ context.Context, g int64, serverOnly bool) ([]securityrepo.Lockdown, error) {
f.mu.Lock()
defer f.mu.Unlock()
var out []securityrepo.Lockdown
for _, l := range f.lockdowns {
if l.GuildID == g && (!serverOnly || l.ServerWide) {
out = append(out, l)
}
}
sort.Slice(out, func(i, j int) bool { return out[i].ChannelID < out[j].ChannelID })
return out, nil
}
func (f *fakeStore) DeleteLockdown(_ context.Context, g, ch int64) error {
f.mu.Lock()
defer f.mu.Unlock()
delete(f.lockdowns, key(g, ch))
return nil
}
func (f *fakeStore) AddEvent(_ context.Context, e securityrepo.Event) error {
f.mu.Lock()
defer f.mu.Unlock()
f.events = append(f.events, e)
return nil
}
func (f *fakeStore) eventKinds() []string {
f.mu.Lock()
defer f.mu.Unlock()
var out []string
for _, e := range f.events {
out = append(out, e.Kind)
}
return out
}
func (f *fakeStore) ListUnprocessedCommands(context.Context) ([]securityrepo.Command, error) {
f.mu.Lock()
defer f.mu.Unlock()
var out []securityrepo.Command
for _, c := range f.cmds {
if !f.done[c.ID] {
out = append(out, c)
}
}
return out, nil
}
func (f *fakeStore) MarkCommandProcessed(_ context.Context, id int64) error {
f.mu.Lock()
defer f.mu.Unlock()
f.done[id] = true
return nil
}
// fakeDiscord is a tiny in-memory guild.
type fakeDiscord struct {
mu sync.Mutex
ownerID string
roles []*discordgo.Role
members map[string]*discordgo.Member
users map[string]*discordgo.User
channels map[string]*discordgo.Channel
kicked []string
banned []string
timeouts map[string]*time.Time
sent []*discordgo.MessageSend
sentTo []string
files map[string]string
requests []string // "METHOD url body"
deletedMsgs []string
nextID int
failEdits error
rejectRole string // edits that assign this role fail, like a role above Velox's
lookups int // member and role lookups, to check harmless entries cost nothing
closedDMs map[string]bool
guildName string
}
const guild = "100"
func newDiscord() *fakeDiscord {
return &fakeDiscord{
ownerID: "1",
roles: []*discordgo.Role{
{ID: guild, Name: "@everyone", Position: 0},
{ID: "200", Name: "Admin", Position: 5, Permissions: discordgo.PermissionAdministrator},
{ID: "201", Name: "Mod", Position: 3, Permissions: discordgo.PermissionKickMembers},
{ID: "202", Name: "Member", Position: 1},
{ID: "203", Name: "Quarantine", Position: 2},
{ID: "204", Name: "Booster", Position: 1, Managed: true},
{ID: "299", Name: "Velox", Position: 9, Managed: true, Permissions: discordgo.PermissionAdministrator},
},
members: map[string]*discordgo.Member{
"1": {User: &discordgo.User{ID: "1", Username: "owner"}},
"999": {User: &discordgo.User{ID: "999", Username: "velox", Bot: true}, Roles: []string{"299"}},
},
users: map[string]*discordgo.User{},
channels: map[string]*discordgo.Channel{},
timeouts: map[string]*time.Time{},
files: map[string]string{},
}
}
func (f *fakeDiscord) addMember(id string, roles ...string) *discordgo.Member {
f.mu.Lock()
defer f.mu.Unlock()
m := &discordgo.Member{User: &discordgo.User{ID: id, Username: "user" + id, Avatar: "a"}, Roles: roles}
f.members[id] = m
return m
}
func (f *fakeDiscord) Guild(g string, _ ...discordgo.RequestOption) (*discordgo.Guild, error) {
return &discordgo.Guild{ID: g, OwnerID: f.ownerID, Name: f.guildName}, nil
}
func (f *fakeDiscord) UserChannelCreate(u string, _ ...discordgo.RequestOption) (*discordgo.Channel, error) {
f.mu.Lock()
defer f.mu.Unlock()
if f.closedDMs[u] {
return nil, errors.New("Cannot send messages to this user")
}
return &discordgo.Channel{ID: "dm-" + u, Type: discordgo.ChannelTypeDM}, nil
}
// dmsTo lists who got a DM, in order.
func (f *fakeDiscord) dmsTo() []string {
f.mu.Lock()
defer f.mu.Unlock()
var out []string
for _, ch := range f.sentTo {
if len(ch) > 3 && ch[:3] == "dm-" {
out = append(out, ch[3:])
}
}
return out
}
func (f *fakeDiscord) User(id string, _ ...discordgo.RequestOption) (*discordgo.User, error) {
f.mu.Lock()
defer f.mu.Unlock()
if u, ok := f.users[id]; ok {
return u, nil
}
return nil, errors.New("unknown user")
}
func (f *fakeDiscord) GuildRoles(string, ...discordgo.RequestOption) ([]*discordgo.Role, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.lookups++
return f.roles, nil
}
func (f *fakeDiscord) GuildChannels(g string, _ ...discordgo.RequestOption) ([]*discordgo.Channel, error) {
f.mu.Lock()
defer f.mu.Unlock()
var out []*discordgo.Channel
for _, c := range f.channels {
if c.GuildID == g {
out = append(out, c)
}
}
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
return out, nil
}
func (f *fakeDiscord) GuildMember(_, u string, _ ...discordgo.RequestOption) (*discordgo.Member, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.lookups++
if m, ok := f.members[u]; ok {
cp := *m
cp.Roles = append([]string(nil), m.Roles...)
return &cp, nil
}
return nil, errors.New("unknown member")
}
func (f *fakeDiscord) GuildMemberEdit(_, u string, data *discordgo.GuildMemberParams, _ ...discordgo.RequestOption) (*discordgo.Member, error) {
f.mu.Lock()
defer f.mu.Unlock()
if f.failEdits != nil {
return nil, f.failEdits
}
if data.Roles != nil && f.rejectRole != "" {
for _, r := range *data.Roles {
if r == f.rejectRole {
return nil, errors.New("403 Missing Permissions")
}
}
}
m, ok := f.members[u]
if !ok {
return nil, errors.New("unknown member")
}
if data.Roles != nil {
m.Roles = append([]string(nil), (*data.Roles)...)
}
return m, nil
}
func (f *fakeDiscord) GuildMemberTimeout(_, u string, until *time.Time, _ ...discordgo.RequestOption) error {
f.mu.Lock()
defer f.mu.Unlock()
f.timeouts[u] = until
return nil
}
func (f *fakeDiscord) GuildMemberDeleteWithReason(_, u, _ string, _ ...discordgo.RequestOption) error {
f.mu.Lock()
defer f.mu.Unlock()
f.kicked = append(f.kicked, u)
delete(f.members, u)
return nil
}
func (f *fakeDiscord) GuildBanCreateWithReason(_, u, _ string, _ int, _ ...discordgo.RequestOption) error {
f.mu.Lock()
defer f.mu.Unlock()
f.banned = append(f.banned, u)
delete(f.members, u)
return nil
}
func (f *fakeDiscord) Channel(id string, _ ...discordgo.RequestOption) (*discordgo.Channel, error) {
f.mu.Lock()
defer f.mu.Unlock()
if c, ok := f.channels[id]; ok {
cp := *c
cp.PermissionOverwrites = nil
for _, o := range c.PermissionOverwrites {
oc := *o
cp.PermissionOverwrites = append(cp.PermissionOverwrites, &oc)
}
return &cp, nil
}
return nil, errors.New("unknown channel")
}
func (f *fakeDiscord) ChannelPermissionSet(ch, target string, t discordgo.PermissionOverwriteType, allow, deny int64, _ ...discordgo.RequestOption) error {
f.mu.Lock()
defer f.mu.Unlock()
c := f.channels[ch]
for _, o := range c.PermissionOverwrites {
if o.ID == target {
o.Allow, o.Deny = allow, deny
return nil
}
}
c.PermissionOverwrites = append(c.PermissionOverwrites, &discordgo.PermissionOverwrite{ID: target, Type: t, Allow: allow, Deny: deny})
return nil
}
func (f *fakeDiscord) ChannelPermissionDelete(ch, target string, _ ...discordgo.RequestOption) error {
f.mu.Lock()
defer f.mu.Unlock()
c := f.channels[ch]
var keep []*discordgo.PermissionOverwrite
for _, o := range c.PermissionOverwrites {
if o.ID != target {
keep = append(keep, o)
}
}
c.PermissionOverwrites = keep
return nil
}
func (f *fakeDiscord) ChannelMessageSendComplex(ch string, data *discordgo.MessageSend, _ ...discordgo.RequestOption) (*discordgo.Message, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.sentTo = append(f.sentTo, ch)
for _, file := range data.Files {
raw, _ := io.ReadAll(file.Reader)
f.files[file.Name] = string(raw)
}
f.sent = append(f.sent, data)
return &discordgo.Message{ID: "m"}, nil
}
func (f *fakeDiscord) RequestWithBucketID(method, url string, data interface{}, _ string, _ ...discordgo.RequestOption) ([]byte, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.requests = append(f.requests, fmt.Sprintf("%s %s %v", method, url, data))
return nil, nil
}
// sentCount counts channel alerts (not DMs).
func (f *fakeDiscord) sentCount() int {
return len(f.channelSends())
}
// channelSends lists messages sent to channels, leaving out DMs.
func (f *fakeDiscord) channelSends() []*discordgo.MessageSend {
f.mu.Lock()
defer f.mu.Unlock()
var out []*discordgo.MessageSend
for i, m := range f.sent {
if i >= len(f.sentTo) || len(f.sentTo[i]) < 3 || f.sentTo[i][:3] != "dm-" {
out = append(out, m)
}
}
return out
}
func (f *fakeDiscord) newID() string {
f.nextID++
return fmt.Sprint(8000 + f.nextID)
}
func (f *fakeDiscord) ChannelMessageDelete(ch, msg string, _ ...discordgo.RequestOption) error {
f.mu.Lock()
defer f.mu.Unlock()
f.deletedMsgs = append(f.deletedMsgs, msg)
return nil
}
func (f *fakeDiscord) GuildRoleCreate(_ string, p *discordgo.RoleParams, _ ...discordgo.RequestOption) (*discordgo.Role, error) {
f.mu.Lock()
defer f.mu.Unlock()
r := &discordgo.Role{ID: f.newID(), Name: p.Name}
if p.Permissions != nil {
r.Permissions = *p.Permissions
}
f.roles = append(f.roles, r)
return r, nil
}
func (f *fakeDiscord) GuildRoleEdit(_, roleID string, p *discordgo.RoleParams, _ ...discordgo.RequestOption) (*discordgo.Role, error) {
f.mu.Lock()
defer f.mu.Unlock()
for _, r := range f.roles {
if r.ID == roleID {
if p.Permissions != nil {
r.Permissions = *p.Permissions
}
return r, nil
}
}
return nil, errors.New("unknown role")
}
func (f *fakeDiscord) GuildChannelCreateComplex(g string, d discordgo.GuildChannelCreateData, _ ...discordgo.RequestOption) (*discordgo.Channel, error) {
f.mu.Lock()
defer f.mu.Unlock()
c := &discordgo.Channel{ID: f.newID(), GuildID: g, Name: d.Name, Type: d.Type, ParentID: d.ParentID, PermissionOverwrites: d.PermissionOverwrites}
f.channels[c.ID] = c
return c, nil
}