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]>
This commit is contained in:
1 parent
d294b3463b
commit
3f87e68a2c
26 files changed
+6022
No files matched your search
@@ -0,0 +1,328 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"velox-bot/internal/db/repos/securityrepo"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
)
|
||||
|
||||
// ---- Raid persistence ----
|
||||
|
||||
func TestRaidSurvivesARestart(t *testing.T) {
|
||||
e, d, st, now := testEngine(t)
|
||||
s := st.set(100)
|
||||
s.AntiRaidEnabled, s.RaidJoinCount, s.RaidAction, s.RaidQuietMinutes = true, 3, securityrepo.ActionKick, 5
|
||||
refresh(e)
|
||||
for i := 0; i < 4; i++ {
|
||||
e.OnMemberJoin(ctx, guild, d.addMember(snowflakeAt(now.AddDate(-1, 0, 0), i)))
|
||||
}
|
||||
if r, ok := st.raids[100]; !ok || !r.InvitesPaused || r.CaughtTotal != 4 || len(st.caught[100]) != 4 {
|
||||
t.Fatalf("raid not persisted: %+v caught=%d", st.raids[100], len(st.caught[100]))
|
||||
}
|
||||
|
||||
// The bot restarts: a fresh engine on the same database.
|
||||
e2 := New(d, st, "999")
|
||||
e2.now = e.now
|
||||
e2.ResumeRaids(ctx)
|
||||
if !e2.RaidActive(guild) {
|
||||
t.Fatal("raid mode must resume after a restart")
|
||||
}
|
||||
// A join after the restart is still treated as a raider.
|
||||
*now = now.Add(time.Minute)
|
||||
m := d.addMember(snowflakeAt(now.AddDate(-1, 0, 0), 50))
|
||||
e2.OnMemberJoin(ctx, guild, m)
|
||||
if !contains(d.kicked, m.User.ID) {
|
||||
t.Error("raid actions continue after a restart")
|
||||
}
|
||||
|
||||
// Quiet period: it ends, invites resume, the list covers both lifetimes.
|
||||
*now = now.Add(6 * time.Minute)
|
||||
e2.checkRaids(ctx)
|
||||
if e2.RaidActive(guild) {
|
||||
t.Fatal("the resumed raid should end after the quiet period")
|
||||
}
|
||||
if !strings.Contains(d.requests[len(d.requests)-1], "invites_disabled_until:<nil>") {
|
||||
t.Error("invites paused before the restart must be resumed")
|
||||
}
|
||||
var list string
|
||||
for name, content := range d.files {
|
||||
if strings.HasPrefix(name, "raid-") {
|
||||
list = content
|
||||
}
|
||||
}
|
||||
if strings.Count(list, "\tkick\t") != 5 {
|
||||
t.Errorf("summary should list all 5 raiders:\n%s", list)
|
||||
}
|
||||
if _, ok := st.raids[100]; ok {
|
||||
t.Error("the raid record should be cleared")
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Heat ----
|
||||
|
||||
func heatEngine(t *testing.T) (*Engine, *fakeDiscord, *fakeStore, *time.Time) {
|
||||
e, d, st, now := testEngine(t)
|
||||
s := st.set(100)
|
||||
s.HeatEnabled, s.HeatPanicMembers = true, 0
|
||||
refresh(e)
|
||||
return e, d, st, now
|
||||
}
|
||||
|
||||
var msgN int
|
||||
|
||||
func say(e *Engine, userID, content string, edit ...func(m *discordgo.MessageCreate)) *discordgo.MessageCreate {
|
||||
msgN++
|
||||
m := &discordgo.MessageCreate{Message: &discordgo.Message{
|
||||
ID: "msg" + string(rune('A'+msgN%26)) + strings.Repeat("x", msgN/26), GuildID: guild, ChannelID: "300", Content: content,
|
||||
Author: &discordgo.User{ID: userID, Username: "u" + userID},
|
||||
Member: &discordgo.Member{Roles: []string{}, JoinedAt: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)},
|
||||
}}
|
||||
for _, f := range edit {
|
||||
f(m)
|
||||
}
|
||||
e.OnMessage(ctx, m)
|
||||
return m
|
||||
}
|
||||
|
||||
func TestNormalChatNeverHeatsUp(t *testing.T) {
|
||||
e, d, _, now := heatEngine(t)
|
||||
for i := 0; i < 60; i++ {
|
||||
say(e, "50", "just talking about the game tonight, number "+string(rune('a'+i%26)))
|
||||
*now = now.Add(2 * time.Second)
|
||||
}
|
||||
if len(d.deletedMsgs) != 0 || len(d.timeouts) != 0 {
|
||||
t.Error("a message every two seconds is normal conversation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpamIsPunishedAndEscalates(t *testing.T) {
|
||||
e, d, st, now := heatEngine(t)
|
||||
var last *discordgo.MessageCreate
|
||||
for i := 0; i < 6 && d.timeouts["50"] == nil; i++ {
|
||||
last = say(e, "50", "BUY CHEAP STUFF NOW")
|
||||
*now = now.Add(200 * time.Millisecond)
|
||||
}
|
||||
first := d.timeouts["50"]
|
||||
if first == nil || first.Sub(*now) > 11*time.Minute || first.Sub(*now) < 9*time.Minute {
|
||||
t.Fatalf("repeated spam should earn the 10 minute timeout, got %v", first)
|
||||
}
|
||||
if !contains(d.deletedMsgs, last.ID) {
|
||||
t.Error("the message that maxed the heat should be deleted")
|
||||
}
|
||||
if !contains(st.eventKinds(), securityrepo.EventHeat) {
|
||||
t.Error("recorded")
|
||||
}
|
||||
|
||||
// A second offence the same day doubles it.
|
||||
*now = now.Add(time.Hour)
|
||||
d.timeouts["50"] = nil
|
||||
for i := 0; i < 6 && d.timeouts["50"] == nil; i++ {
|
||||
say(e, "50", "BUY CHEAP STUFF NOW")
|
||||
*now = now.Add(200 * time.Millisecond)
|
||||
}
|
||||
if second := d.timeouts["50"]; second == nil || second.Sub(*now) < 19*time.Minute {
|
||||
t.Errorf("second offence should be 20 minutes, got %v", second)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInviteLinksAndMassMentions(t *testing.T) {
|
||||
e, d, _, _ := heatEngine(t)
|
||||
say(e, "51", "join my server discord.gg/abcdef")
|
||||
if d.timeouts["51"] == nil {
|
||||
t.Error("an invite link is an instant max")
|
||||
}
|
||||
say(e, "52", "hey @everyone check this")
|
||||
say(e, "52", "@everyone again")
|
||||
if d.timeouts["52"] == nil {
|
||||
t.Error("repeated @everyone should max out fast")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeatExemptions(t *testing.T) {
|
||||
e, d, st, _ := heatEngine(t)
|
||||
st.trusted[100] = []securityrepo.Trusted{{TargetID: 60, Kind: "user"}}
|
||||
refresh(e)
|
||||
e.ChannelPerms = func(userID, _ string) (int64, error) {
|
||||
if userID == "61" {
|
||||
return discordgo.PermissionManageMessages, nil
|
||||
}
|
||||
return 0, nil
|
||||
}
|
||||
for _, who := range []string{"1", "60", "61"} {
|
||||
say(e, who, "discord.gg/x")
|
||||
}
|
||||
say(e, "62", "discord.gg/x", func(m *discordgo.MessageCreate) { m.Author.Bot = true })
|
||||
say(e, "63", "discord.gg/x", func(m *discordgo.MessageCreate) { m.WebhookID = "w" })
|
||||
if len(d.timeouts) != 0 || len(d.deletedMsgs) != 0 {
|
||||
t.Errorf("owner, trusted, moderators, bots and webhooks are exempt: %v", d.timeouts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeatPanicTimesOutNewMembers(t *testing.T) {
|
||||
e, d, st, now := heatEngine(t)
|
||||
st.set(100).HeatPanicMembers = 3
|
||||
refresh(e)
|
||||
for _, u := range []string{"70", "71", "72"} {
|
||||
say(e, u, "discord.gg/spam")
|
||||
}
|
||||
if !contains(st.eventKinds(), securityrepo.EventHeatPanic) {
|
||||
t.Fatal("three members maxing out within a minute starts heat panic")
|
||||
}
|
||||
newcomer := func(m *discordgo.MessageCreate) { m.Member.JoinedAt = now.Add(-time.Hour) }
|
||||
say(e, "80", "hello everyone", newcomer)
|
||||
if d.timeouts["80"] == nil {
|
||||
t.Error("someone who joined an hour ago speaking during panic is timed out")
|
||||
}
|
||||
say(e, "81", "hello everyone")
|
||||
if d.timeouts["81"] != nil {
|
||||
t.Error("established members are unaffected by panic")
|
||||
}
|
||||
*now = now.Add(11 * time.Minute)
|
||||
say(e, "82", "hello", newcomer)
|
||||
if d.timeouts["82"] != nil {
|
||||
t.Error("panic ends after 10 minutes")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMentionFloodLocksTheServer(t *testing.T) {
|
||||
e, d, st, _ := heatEngine(t)
|
||||
s := st.set(100)
|
||||
s.HeatLockdownMentions, s.HeatStrictness = 20, "low"
|
||||
refresh(e)
|
||||
d.channels["300"] = &discordgo.Channel{ID: "300", GuildID: guild, Type: discordgo.ChannelTypeGuildText}
|
||||
five := func(m *discordgo.MessageCreate) {
|
||||
m.Mentions = []*discordgo.User{{ID: "a"}, {ID: "b"}, {ID: "c"}, {ID: "d"}, {ID: "e"}}
|
||||
}
|
||||
for i, u := range []string{"90", "91", "92", "93"} {
|
||||
_ = i
|
||||
say(e, u, "hi", five)
|
||||
}
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
st.mu.Lock()
|
||||
n := len(st.lockdowns)
|
||||
st.mu.Unlock()
|
||||
if n > 0 {
|
||||
return
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
t.Error("20 mentions within 30 seconds should lock the server")
|
||||
}
|
||||
|
||||
func TestHeatAlertsAreCapped(t *testing.T) {
|
||||
e, d, _, _ := heatEngine(t)
|
||||
for i := 0; i < 20; i++ {
|
||||
say(e, string(rune('A'+i)), "discord.gg/x")
|
||||
}
|
||||
if n := d.sentCount(); n > heatAlertsPerMin {
|
||||
t.Errorf("a spam wave produced %d alerts, cap is %d", n, heatAlertsPerMin)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Snapshots and restore ----
|
||||
|
||||
func TestSnapshotAndRestore(t *testing.T) {
|
||||
e, d, st, _ := testEngine(t)
|
||||
d.channels["400"] = &discordgo.Channel{ID: "400", GuildID: guild, Type: discordgo.ChannelTypeGuildCategory, Name: "Staff"}
|
||||
d.channels["401"] = &discordgo.Channel{ID: "401", GuildID: guild, Type: discordgo.ChannelTypeGuildText, Name: "mod-chat", ParentID: "400",
|
||||
PermissionOverwrites: []*discordgo.PermissionOverwrite{
|
||||
{ID: guild, Type: discordgo.PermissionOverwriteTypeRole, Deny: discordgo.PermissionViewChannel},
|
||||
{ID: "201", Type: discordgo.PermissionOverwriteTypeRole, Allow: discordgo.PermissionViewChannel},
|
||||
}}
|
||||
d.channels["402"] = &discordgo.Channel{ID: "402", GuildID: guild, Type: discordgo.ChannelTypeGuildPublicThread, Name: "a thread"}
|
||||
|
||||
if _, err := e.Restore(ctx, guild, "1"); !errors.As(err, new(UserError)) {
|
||||
t.Fatalf("no snapshot yet should be a clear message: %v", err)
|
||||
}
|
||||
roles, channels, err := e.TakeSnapshot(ctx, guild, "1")
|
||||
if err != nil || roles != 5 || channels != 2 {
|
||||
t.Fatalf("snapshot = %d roles, %d channels, %v (managed roles and threads are skipped)", roles, channels, err)
|
||||
}
|
||||
var data snapshotData
|
||||
_ = json.Unmarshal(st.snapshots[0].Data, &data)
|
||||
for _, r := range data.Roles {
|
||||
if r.ID == "204" || r.ID == "299" {
|
||||
t.Error("integration roles must not be in the snapshot")
|
||||
}
|
||||
}
|
||||
|
||||
// The nuke: the Mod role and the whole Staff category are deleted, and
|
||||
// the Member role is given Administrator.
|
||||
var keep []*discordgo.Role
|
||||
for _, r := range d.roles {
|
||||
if r.ID != "201" {
|
||||
keep = append(keep, r)
|
||||
}
|
||||
if r.ID == "202" {
|
||||
r.Permissions |= discordgo.PermissionAdministrator
|
||||
}
|
||||
}
|
||||
d.roles = keep
|
||||
delete(d.channels, "400")
|
||||
delete(d.channels, "401")
|
||||
|
||||
res, err := e.Restore(ctx, guild, "1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.RolesCreated != 1 || res.RolesReverted != 1 || res.ChannelsCreated != 2 || res.Failed != 0 {
|
||||
t.Fatalf("restore result = %+v", res)
|
||||
}
|
||||
var newMod, newCat, newText *string
|
||||
for _, r := range d.roles {
|
||||
if r.Name == "Mod" {
|
||||
newMod = &r.ID
|
||||
}
|
||||
if r.ID == "202" && r.Permissions&discordgo.PermissionAdministrator != 0 {
|
||||
t.Error("Administrator added to Member should be reverted")
|
||||
}
|
||||
}
|
||||
for _, c := range d.channels {
|
||||
switch c.Name {
|
||||
case "Staff":
|
||||
newCat = &c.ID
|
||||
case "mod-chat":
|
||||
newText = &c.ID
|
||||
}
|
||||
}
|
||||
if newMod == nil || newCat == nil || newText == nil {
|
||||
t.Fatal("role or channels not recreated")
|
||||
}
|
||||
text := d.channels[*newText]
|
||||
if text.ParentID != *newCat {
|
||||
t.Error("the channel should go back into the recreated category")
|
||||
}
|
||||
foundMod := false
|
||||
for _, o := range text.PermissionOverwrites {
|
||||
if o.ID == *newMod && o.Allow == discordgo.PermissionViewChannel {
|
||||
foundMod = true
|
||||
}
|
||||
if o.ID == "201" {
|
||||
t.Error("overwrites must point at the recreated role, not the deleted one")
|
||||
}
|
||||
}
|
||||
if !foundMod {
|
||||
t.Errorf("the Mod overwrite should be restored: %+v", text.PermissionOverwrites)
|
||||
}
|
||||
|
||||
// Restoring again finds nothing missing.
|
||||
res, _ = e.Restore(ctx, guild, "1")
|
||||
if res.RolesCreated+res.ChannelsCreated+res.RolesReverted != 0 {
|
||||
t.Errorf("a second restore should change nothing: %+v", res)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOnlyOneRestoreAtATime(t *testing.T) {
|
||||
e, _, _, _ := testEngine(t)
|
||||
e.restoring[guild] = true
|
||||
if _, err := e.Restore(ctx, guild, "1"); err == nil || !strings.Contains(err.Error(), "already running") {
|
||||
t.Errorf("err = %v", err)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user