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:
FernandoJVideiraandClaude Opus 5.5 committed 2026-10-20 21:59:52 +01:00
1 parent d294b3463b
commit 3f87e68a2c
26 files changed
+6022

No files matched your search

@@ -0,0 +1,231 @@
//go:build integration
// Runs against a real Postgres with the dashboard API's migrations applied:
//
// SECURITY_TEST_DB=postgres://... go test -tags integration ./internal/db/repos/securityrepo
package securityrepo
import (
"context"
"database/sql"
"os"
"testing"
"time"
_ "github.com/jackc/pgx/v5/stdlib"
)
func testRepo(t *testing.T) (*Repo, int64) {
t.Helper()
dsn := os.Getenv("SECURITY_TEST_DB")
if dsn == "" {
t.Skip("SECURITY_TEST_DB not set")
}
db, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { db.Close() })
const g = 777
for _, tbl := range []string{"security_settings", "security_trusted", "security_quarantines", "security_lockdowns", "security_events", "security_commands"} {
if _, err := db.Exec(`DELETE FROM `+tbl+` WHERE guild_id = $1`, g); err != nil {
t.Fatal(err)
}
}
return NewRepo(db), g
}
func TestSettings(t *testing.T) {
r, g := testRepo(t)
ctx := context.Background()
s, err := r.GetSettings(ctx, g)
if err != nil || s.Configured || s.JoinGateEnabled || s.AntiNukeEnabled || s.UnauthorizedBot != ActionKick || s.AntiNukeLimits[LimitChannelDelete] != 2 || !s.AlertDM {
t.Fatalf("defaults wrong: %+v %v", s, err)
}
for _, f := range []string{FeatureJoinGate, FeatureAntiRaid, FeatureAntiNuke} {
if err := r.SetEnabled(ctx, g, f, true); err != nil {
t.Fatal(err)
}
}
if err := r.SetEnabled(ctx, g, "drop table", true); err == nil {
t.Error("unknown features must be refused, never put into SQL")
}
_ = r.SetAlerts(ctx, g, 10, 20)
if _, err := r.db.Exec(`UPDATE security_settings SET alert_dm = false WHERE guild_id = $1`, g); err != nil {
t.Fatal(err)
}
_ = r.SetQuarantineRole(ctx, g, 30)
_ = r.SetJoinLock(ctx, g, ActionBan)
if _, err := r.db.Exec(`UPDATE security_settings SET joingate_username_patterns = '{nitro,free}', antinuke_limits = '{"ban": 0}' WHERE guild_id = $1`, g); err != nil {
t.Fatal(err)
}
s, err = r.GetSettings(ctx, g)
if err != nil {
t.Fatal(err)
}
if !s.Configured || s.AlertDM || !s.JoinGateEnabled || !s.AntiRaidEnabled || !s.AntiNukeEnabled || s.AlertChannelID != 10 || s.AlertRoleID != 20 || s.QuarantineRoleID != 30 || s.JoinLock != ActionBan {
t.Errorf("saved settings wrong: %+v", s)
}
if len(s.UsernamePatterns) != 2 || s.AntiNukeLimits[LimitBan] != 0 || s.AntiNukeLimits[LimitKick] != 5 {
t.Errorf("patterns or limits wrong (saved limits override defaults key by key): %+v %+v", s.UsernamePatterns, s.AntiNukeLimits)
}
if err := r.SetJoinLock(ctx, g, "everyone"); err == nil {
t.Error("the database must refuse unknown join lock modes")
}
}
func TestTrustedQuarantineLockdown(t *testing.T) {
r, g := testRepo(t)
ctx := context.Background()
_ = r.AddTrusted(ctx, g, Trusted{TargetID: 5, Kind: "user", AddedBy: 1})
_ = r.AddTrusted(ctx, g, Trusted{TargetID: 6, Kind: "role", AddedBy: 1})
if err := r.AddTrusted(ctx, g, Trusted{TargetID: 7, Kind: "everyone", AddedBy: 1}); err == nil {
t.Error("unknown kinds must be refused")
}
list, _ := r.ListTrusted(ctx, g)
if len(list) != 2 {
t.Fatalf("trusted = %+v", list)
}
if ok, _ := r.RemoveTrusted(ctx, g, 5); !ok {
t.Error("remove should report true")
}
_ = r.SaveQuarantine(ctx, Quarantine{GuildID: g, UserID: 9, PreviousRoleIDs: []int64{1, 2}, Reason: "first"})
_ = r.SaveQuarantine(ctx, Quarantine{GuildID: g, UserID: 9, PreviousRoleIDs: nil, Reason: "second"})
q, _ := r.GetQuarantine(ctx, g, 9)
if q == nil || len(q.PreviousRoleIDs) != 2 || q.Reason != "first" {
t.Errorf("the first quarantine record must be kept: %+v", q)
}
qs, _ := r.ListQuarantines(ctx, g)
if len(qs) != 1 {
t.Errorf("list = %+v", qs)
}
_ = r.DeleteQuarantine(ctx, g, 9)
if q, _ := r.GetQuarantine(ctx, g, 9); q != nil {
t.Error("deleted")
}
_ = r.SaveLockdown(ctx, Lockdown{GuildID: g, ChannelID: 50, HadOverwrite: true, PreviousAllow: 1, PreviousDeny: 2, ServerWide: true, LockedBy: 1})
_ = r.SaveLockdown(ctx, Lockdown{GuildID: g, ChannelID: 50, HadOverwrite: false, PreviousAllow: 9, PreviousDeny: 9, LockedBy: 2})
_ = r.SaveLockdown(ctx, Lockdown{GuildID: g, ChannelID: 51, LockedBy: 1})
l, _ := r.GetLockdown(ctx, g, 50)
if l == nil || !l.HadOverwrite || l.PreviousAllow != 1 || !l.ServerWide {
t.Errorf("the first lockdown record must be kept: %+v", l)
}
if all, _ := r.ListLockdowns(ctx, g, false); len(all) != 2 {
t.Errorf("all = %+v", all)
}
if server, _ := r.ListLockdowns(ctx, g, true); len(server) != 1 {
t.Errorf("server-wide = %+v", server)
}
_ = r.DeleteLockdown(ctx, g, 50)
if l, _ := r.GetLockdown(ctx, g, 50); l != nil {
t.Error("deleted")
}
if err := r.AddEvent(ctx, Event{GuildID: g, Kind: EventNuke, ActorID: 3, Detail: "x"}); err != nil {
t.Fatal(err)
}
if _, err := r.db.Exec(`INSERT INTO security_commands (guild_id, command_type, payload, requested_by) VALUES ($1, 'release_quarantine', '{"user_id":"9"}', 1)`, g); err != nil {
t.Fatal(err)
}
if _, err := r.db.Exec(`INSERT INTO security_commands (guild_id, command_type, requested_by) VALUES ($1, 'grant_admin', 1)`, g); err == nil {
t.Error("unknown commands must be refused")
}
cmds, _ := r.ListUnprocessedCommands(ctx)
var mine *Command
for i := range cmds {
if cmds[i].GuildID == g {
mine = &cmds[i]
}
}
if mine == nil || string(mine.Payload) != `{"user_id": "9"}` {
t.Fatalf("command = %+v", mine)
}
_ = r.MarkCommandProcessed(ctx, mine.ID)
}
func TestRaidsSnapshotsAndHeatSettings(t *testing.T) {
r, g := testRepo(t)
ctx := context.Background()
if _, err := r.db.Exec(`DELETE FROM security_raids WHERE guild_id = $1`, g); err != nil {
t.Fatal(err)
}
_, _ = r.db.Exec(`DELETE FROM security_raid_caught WHERE guild_id = $1`, g)
_, _ = r.db.Exec(`DELETE FROM security_snapshots WHERE guild_id = $1`, g)
s, _ := r.GetSettings(ctx, g)
if s.HeatEnabled || s.HeatStrictness != "medium" || s.HeatTimeoutMinutes != 10 || !s.HeatEscalate || s.HeatPanicMembers != 3 {
t.Errorf("heat defaults wrong: %+v", s)
}
_ = r.SetEnabled(ctx, g, FeatureHeat, true)
if _, err := r.db.Exec(`UPDATE security_settings SET heat_blocked_words = '{scam}', antinuke_auto_restore = true WHERE guild_id = $1`, g); err != nil {
t.Fatal(err)
}
s, _ = r.GetSettings(ctx, g)
if !s.HeatEnabled || !s.AntiNukeAutoRestore || len(s.HeatBlockedWords) != 1 {
t.Errorf("heat settings not read back: %+v", s)
}
if _, err := r.db.Exec(`UPDATE security_settings SET heat_strictness = 'insane' WHERE guild_id = $1`, g); err == nil {
t.Error("unknown strictness must be refused by the database")
}
start := time.Now().Add(-time.Minute).UTC().Truncate(time.Millisecond)
_ = r.SaveRaid(ctx, Raid{GuildID: g, StartedAt: start, LastJoinAt: start, InvitesPaused: true, CaughtTotal: 1})
_ = r.SaveRaid(ctx, Raid{GuildID: g, StartedAt: time.Now(), LastJoinAt: start.Add(time.Second), InvitesPaused: true, CaughtTotal: 2})
_ = r.AddRaidCaught(ctx, g, Caught{UserID: 1, Action: "kick", Name: "a"})
_ = r.AddRaidCaught(ctx, g, Caught{UserID: 1, Action: "ban", Name: "a"})
_ = r.AddRaidCaught(ctx, g, Caught{UserID: 2, Action: "kick", Name: "b"})
raids, _ := r.ListRaids(ctx)
var mine *Raid
for i := range raids {
if raids[i].GuildID == g {
mine = &raids[i]
}
}
if mine == nil || !mine.StartedAt.Equal(start) || mine.CaughtTotal != 2 {
t.Fatalf("raid = %+v (the start time is kept on update)", mine)
}
caught, _ := r.ListRaidCaught(ctx, g, 10)
if len(caught) != 2 || caught[0].Action != "kick" {
t.Errorf("caught = %+v (one row per raider)", caught)
}
_ = r.DeleteRaid(ctx, g)
if c, _ := r.ListRaidCaught(ctx, g, 10); len(c) != 0 {
t.Error("deleting a raid clears its list")
}
for i := 0; i < 5; i++ {
if err := r.SaveSnapshot(ctx, Snapshot{GuildID: g, Data: []byte(`{"roles":[],"channels":[]}`), Roles: i}); err != nil {
t.Fatal(err)
}
}
var n int
_ = r.db.QueryRow(`SELECT COUNT(*) FROM security_snapshots WHERE guild_id = $1`, g).Scan(&n)
if n != 3 {
t.Errorf("only the last three snapshots are kept, have %d", n)
}
latest, _ := r.LatestSnapshot(ctx, g)
if latest == nil || latest.Roles != 4 {
t.Errorf("latest = %+v", latest)
}
_ = r.SetEnabled(ctx, g, FeatureAntiNuke, true)
due, _ := r.GuildsDueForSnapshot(ctx, time.Hour)
for _, d := range due {
if d == g {
t.Error("a guild with a fresh snapshot is not due")
}
}
due, _ = r.GuildsDueForSnapshot(ctx, 0)
found := false
for _, d := range due {
found = found || d == g
}
if !found {
t.Error("with a zero max age every anti-nuke guild is due")
}
}