Security: tune the Join Gate, raid mode, anti-nuke and heat with commands
- /security settings, joingate check|account-age, words add|remove|list, raid, antinuke set|limit, heat, alert-dms - Same rule as the rest of the configuration: owner or trusted, validated in the engine - schema.sql was missing security_settings.alert_dm, so the bot couldn't read security settings on a database built from it. Fixed, with an ALTER for existing installs - A test checks every command against Discord's limits Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
1 parent
5657d0789a
commit
d95ebf473e
10 files changed
+1020
-4
No files matched your search
@@ -153,6 +153,61 @@ func (f *fakeStore) SetJoinLock(_ context.Context, g int64, mode string) error {
|
||||
f.set(g).JoinLock = mode
|
||||
return nil
|
||||
}
|
||||
func (f *fakeStore) UpdateTuning(_ context.Context, g int64, t securityrepo.Tuning) error {
|
||||
s := f.set(g)
|
||||
str := func(dst *string, v *string) {
|
||||
if v != nil {
|
||||
*dst = *v
|
||||
}
|
||||
}
|
||||
num := func(dst *int, v *int) {
|
||||
if v != nil {
|
||||
*dst = *v
|
||||
}
|
||||
}
|
||||
flag := func(dst *bool, v *bool) {
|
||||
if v != nil {
|
||||
*dst = *v
|
||||
}
|
||||
}
|
||||
if t.AlertDM != nil {
|
||||
s.AlertDM = *t.AlertDM
|
||||
}
|
||||
str(&s.NoAvatar, t.NoAvatar)
|
||||
str(&s.NewAccount, t.NewAccount)
|
||||
num(&s.MinAccountAgeDays, t.MinAccountAgeDays)
|
||||
str(&s.AdvertisingName, t.AdvertisingName)
|
||||
str(&s.UsernameFilter, t.UsernameFilter)
|
||||
if t.UsernamePatterns != nil {
|
||||
s.UsernamePatterns = *t.UsernamePatterns
|
||||
}
|
||||
str(&s.UnverifiedBot, t.UnverifiedBot)
|
||||
str(&s.UnauthorizedBot, t.UnauthorizedBot)
|
||||
num(&s.RaidJoinCount, t.RaidJoinCount)
|
||||
num(&s.RaidJoinSeconds, t.RaidJoinSeconds)
|
||||
str(&s.RaidAction, t.RaidAction)
|
||||
flag(&s.RaidPauseInvites, t.RaidPauseInvites)
|
||||
num(&s.RaidQuietMinutes, t.RaidQuietMinutes)
|
||||
num(&s.AntiNukeWindowSeconds, t.AntiNukeWindowSeconds)
|
||||
if s.AntiNukeLimits == nil {
|
||||
s.AntiNukeLimits = securityrepo.DefaultLimits()
|
||||
}
|
||||
for k, v := range t.AntiNukeLimits {
|
||||
s.AntiNukeLimits[k] = v
|
||||
}
|
||||
flag(&s.AntiNukeLockServer, t.AntiNukeLockServer)
|
||||
flag(&s.AntiNukeAutoRestore, t.AntiNukeAutoRestore)
|
||||
str(&s.HeatStrictness, t.HeatStrictness)
|
||||
str(&s.HeatAction, t.HeatAction)
|
||||
num(&s.HeatTimeoutMinutes, t.HeatTimeoutMinutes)
|
||||
flag(&s.HeatEscalate, t.HeatEscalate)
|
||||
if t.HeatBlockedWords != nil {
|
||||
s.HeatBlockedWords = *t.HeatBlockedWords
|
||||
}
|
||||
num(&s.HeatPanicMembers, t.HeatPanicMembers)
|
||||
num(&s.HeatLockdownMentions, t.HeatLockdownMentions)
|
||||
return nil
|
||||
}
|
||||
func (f *fakeStore) ListTrusted(_ context.Context, g int64) ([]securityrepo.Trusted, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
|
||||
@@ -48,6 +48,7 @@ type Store interface {
|
||||
SetAlerts(ctx context.Context, guildID, channelID, roleID int64) error
|
||||
SetQuarantineRole(ctx context.Context, guildID, roleID int64) error
|
||||
SetJoinLock(ctx context.Context, guildID int64, mode string) error
|
||||
UpdateTuning(ctx context.Context, guildID int64, t securityrepo.Tuning) error
|
||||
ListTrusted(ctx context.Context, guildID int64) ([]securityrepo.Trusted, error)
|
||||
AddTrusted(ctx context.Context, guildID int64, t securityrepo.Trusted) error
|
||||
RemoveTrusted(ctx context.Context, guildID, targetID int64) (bool, error)
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"velox-bot/internal/db/repos/securityrepo"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
)
|
||||
|
||||
// Tuning the thresholds, filters and actions. Everything the Join Gate, raid
|
||||
// mode, anti-nuke and heat do can be adjusted from Discord with these calls;
|
||||
// a dashboard is never needed. They follow the same rules as the rest of the
|
||||
// configuration: the server owner or someone on the trusted list, checked
|
||||
// here and not left to Discord's command permissions.
|
||||
|
||||
const (
|
||||
maxWords = 50 // blocked words per list
|
||||
maxWordLength = 50
|
||||
)
|
||||
|
||||
var (
|
||||
filterActions = map[string]bool{securityrepo.ActionOff: true, securityrepo.ActionTimeout: true, securityrepo.ActionKick: true, securityrepo.ActionBan: true}
|
||||
botActions = map[string]bool{securityrepo.ActionOff: true, securityrepo.ActionKick: true, securityrepo.ActionBan: true}
|
||||
raidActions = map[string]bool{securityrepo.ActionAlert: true, securityrepo.ActionTimeout: true, securityrepo.ActionKick: true, securityrepo.ActionBan: true}
|
||||
heatActions = map[string]bool{securityrepo.ActionTimeout: true, securityrepo.ActionKick: true, securityrepo.ActionBan: true}
|
||||
strictnessSet = map[string]bool{"low": true, "medium": true, "high": true}
|
||||
nukeLimitKeys = map[string]bool{
|
||||
securityrepo.LimitChannelDelete: true, securityrepo.LimitChannelCreate: true, securityrepo.LimitRoleDelete: true,
|
||||
securityrepo.LimitRoleCreate: true, securityrepo.LimitBan: true, securityrepo.LimitKick: true, securityrepo.LimitWebhookCreate: true,
|
||||
}
|
||||
)
|
||||
|
||||
// CleanWords trims, lowercases and de-duplicates a list of blocked words and
|
||||
// rejects anything oversized or containing control characters. They are
|
||||
// matched as plain text, never as regular expressions.
|
||||
func CleanWords(in []string) ([]string, error) {
|
||||
seen := map[string]bool{}
|
||||
out := []string{}
|
||||
for _, w := range in {
|
||||
w = strings.ToLower(strings.TrimSpace(w))
|
||||
if w == "" || seen[w] {
|
||||
continue
|
||||
}
|
||||
if utf8.RuneCountInString(w) > maxWordLength {
|
||||
return nil, UserError("Each blocked word can be at most 50 characters.")
|
||||
}
|
||||
for _, r := range w {
|
||||
if unicode.IsControl(r) {
|
||||
return nil, UserError("Blocked words can't contain control characters.")
|
||||
}
|
||||
}
|
||||
seen[w] = true
|
||||
out = append(out, w)
|
||||
}
|
||||
if len(out) > maxWords {
|
||||
return nil, UserError("You can block at most 50 words.")
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func inRange(v *int, lo, hi int) bool { return v == nil || (*v >= lo && *v <= hi) }
|
||||
|
||||
func oneOf(v *string, allowed map[string]bool) bool { return v == nil || allowed[*v] }
|
||||
|
||||
// ValidateTuning returns a message for the first invalid field, or "". It
|
||||
// also cleans the word lists in place.
|
||||
func ValidateTuning(t *securityrepo.Tuning) error {
|
||||
for name, v := range map[string]*string{
|
||||
"No avatar": t.NoAvatar, "New account": t.NewAccount, "Invite in name": t.AdvertisingName, "Blocked words": t.UsernameFilter,
|
||||
} {
|
||||
if !oneOf(v, filterActions) {
|
||||
return UserError(name + " can be off, timeout, kick or ban.")
|
||||
}
|
||||
}
|
||||
if !oneOf(t.UnverifiedBot, botActions) || !oneOf(t.UnauthorizedBot, botActions) {
|
||||
return UserError("Bot filters can only be off, kick or ban.")
|
||||
}
|
||||
if !oneOf(t.RaidAction, raidActions) {
|
||||
return UserError("The raid action can be alert, timeout, kick or ban.")
|
||||
}
|
||||
if !oneOf(t.HeatStrictness, strictnessSet) {
|
||||
return UserError("Heat strictness must be low, medium or high.")
|
||||
}
|
||||
if !oneOf(t.HeatAction, heatActions) {
|
||||
return UserError("The heat action can be timeout, kick or ban.")
|
||||
}
|
||||
switch {
|
||||
case !inRange(t.MinAccountAgeDays, 1, 365):
|
||||
return UserError("Minimum account age must be between 1 and 365 days.")
|
||||
case !inRange(t.RaidJoinCount, 3, 100):
|
||||
return UserError("The raid join count must be between 3 and 100.")
|
||||
case !inRange(t.RaidJoinSeconds, 5, 300):
|
||||
return UserError("The raid window must be between 5 and 300 seconds.")
|
||||
case !inRange(t.RaidQuietMinutes, 1, 120):
|
||||
return UserError("The raid quiet period must be between 1 and 120 minutes.")
|
||||
case !inRange(t.AntiNukeWindowSeconds, 5, 300):
|
||||
return UserError("The anti-nuke window must be between 5 and 300 seconds.")
|
||||
case !inRange(t.HeatTimeoutMinutes, 1, 10080):
|
||||
return UserError("The heat timeout must be between 1 minute and 7 days (10080 minutes).")
|
||||
case !inRange(t.HeatPanicMembers, 0, 50):
|
||||
return UserError("Heat panic must be between 0 (off) and 50 members.")
|
||||
case t.HeatLockdownMentions != nil && *t.HeatLockdownMentions != 0 && (*t.HeatLockdownMentions < 5 || *t.HeatLockdownMentions > 1000):
|
||||
return UserError("The mention flood lockdown must be 0 (off) or between 5 and 1000 mentions.")
|
||||
}
|
||||
for k, v := range t.AntiNukeLimits {
|
||||
if !nukeLimitKeys[k] {
|
||||
return UserError("Unknown anti-nuke limit " + fmt.Sprintf("%q", k) + ".")
|
||||
}
|
||||
if v < 0 || v > 100 {
|
||||
return UserError("Anti-nuke limits must be between 0 and 100 (0 means never allowed).")
|
||||
}
|
||||
}
|
||||
if t.UsernamePatterns != nil {
|
||||
words, err := CleanWords(*t.UsernamePatterns)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
t.UsernamePatterns = &words
|
||||
}
|
||||
if t.HeatBlockedWords != nil {
|
||||
words, err := CleanWords(*t.HeatBlockedWords)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
t.HeatBlockedWords = &words
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Tune changes thresholds, filters and actions. Only the server owner or
|
||||
// someone on the trusted list may.
|
||||
func (e *Engine) Tune(ctx context.Context, guildID string, actor *discordgo.Member, t securityrepo.Tuning, summary string) error {
|
||||
if err := e.RequireTrusted(ctx, guildID, actor); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ValidateTuning(&t); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := e.st.UpdateTuning(ctx, parseID(guildID), t); err != nil {
|
||||
return err
|
||||
}
|
||||
e.Invalidate(guildID)
|
||||
e.event(ctx, guildID, "settings", actor.User.ID, "", summary)
|
||||
return nil
|
||||
}
|
||||
|
||||
// WordList names one of the two blocked word lists.
|
||||
type WordList string
|
||||
|
||||
const (
|
||||
UsernameWords WordList = "username" // checked against new members' names by the Join Gate
|
||||
ChatWords WordList = "chat" // checked against messages by heat
|
||||
)
|
||||
|
||||
// EditWords adds or removes one blocked word and returns the list as saved.
|
||||
func (e *Engine) EditWords(ctx context.Context, guildID string, actor *discordgo.Member, list WordList, add bool, word string) ([]string, error) {
|
||||
if err := e.RequireTrusted(ctx, guildID, actor); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
e.Invalidate(guildID) // read what is saved now, not what was cached
|
||||
cur, _, err := e.load(ctx, guildID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var words []string
|
||||
switch list {
|
||||
case UsernameWords:
|
||||
words = append(words, cur.UsernamePatterns...)
|
||||
case ChatWords:
|
||||
words = append(words, cur.HeatBlockedWords...)
|
||||
default:
|
||||
return nil, UserError("Unknown list.")
|
||||
}
|
||||
word = strings.ToLower(strings.TrimSpace(word))
|
||||
if word == "" {
|
||||
return nil, UserError("Give a word to block.")
|
||||
}
|
||||
if add {
|
||||
words = append(words, word)
|
||||
} else {
|
||||
kept := words[:0]
|
||||
for _, w := range words {
|
||||
if w != word {
|
||||
kept = append(kept, w)
|
||||
}
|
||||
}
|
||||
if len(kept) == len(words) {
|
||||
return nil, UserError("That word isn't on the list.")
|
||||
}
|
||||
words = kept
|
||||
}
|
||||
clean, err := CleanWords(words)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
t := securityrepo.Tuning{}
|
||||
if list == UsernameWords {
|
||||
t.UsernamePatterns = &clean
|
||||
} else {
|
||||
t.HeatBlockedWords = &clean
|
||||
}
|
||||
verb := "removed"
|
||||
if add {
|
||||
verb = "added"
|
||||
}
|
||||
if err := e.Tune(ctx, guildID, actor, t, fmt.Sprintf("%s word list: %s %q", list, verb, word)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return clean, nil
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"velox-bot/internal/db/repos/securityrepo"
|
||||
)
|
||||
|
||||
func ptr[T any](v T) *T { return &v }
|
||||
|
||||
func TestValidateTuning(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
t securityrepo.Tuning
|
||||
ok bool
|
||||
}{
|
||||
{"nothing to change is fine", securityrepo.Tuning{}, true},
|
||||
{"a filter action", securityrepo.Tuning{NoAvatar: ptr("timeout")}, true},
|
||||
{"an unknown filter action", securityrepo.Tuning{NoAvatar: ptr("nuke")}, false},
|
||||
{"bots can't be timed out", securityrepo.Tuning{UnverifiedBot: ptr("timeout")}, false},
|
||||
{"bots can be kicked", securityrepo.Tuning{UnauthorizedBot: ptr("kick")}, true},
|
||||
{"raid alert only", securityrepo.Tuning{RaidAction: ptr("alert")}, true},
|
||||
{"heat can't just alert", securityrepo.Tuning{HeatAction: ptr("alert")}, false},
|
||||
{"strictness", securityrepo.Tuning{HeatStrictness: ptr("extreme")}, false},
|
||||
{"account age low", securityrepo.Tuning{MinAccountAgeDays: ptr(0)}, false},
|
||||
{"account age high", securityrepo.Tuning{MinAccountAgeDays: ptr(366)}, false},
|
||||
{"raid join count edges", securityrepo.Tuning{RaidJoinCount: ptr(3)}, true},
|
||||
{"raid join count too low", securityrepo.Tuning{RaidJoinCount: ptr(2)}, false},
|
||||
{"raid window too long", securityrepo.Tuning{RaidJoinSeconds: ptr(301)}, false},
|
||||
{"quiet minutes", securityrepo.Tuning{RaidQuietMinutes: ptr(121)}, false},
|
||||
{"nuke window", securityrepo.Tuning{AntiNukeWindowSeconds: ptr(4)}, false},
|
||||
{"heat timeout max", securityrepo.Tuning{HeatTimeoutMinutes: ptr(10080)}, true},
|
||||
{"heat timeout over", securityrepo.Tuning{HeatTimeoutMinutes: ptr(10081)}, false},
|
||||
{"panic off", securityrepo.Tuning{HeatPanicMembers: ptr(0)}, true},
|
||||
{"panic too many", securityrepo.Tuning{HeatPanicMembers: ptr(51)}, false},
|
||||
{"mention flood off", securityrepo.Tuning{HeatLockdownMentions: ptr(0)}, true},
|
||||
{"mention flood too small", securityrepo.Tuning{HeatLockdownMentions: ptr(4)}, false},
|
||||
{"mention flood max", securityrepo.Tuning{HeatLockdownMentions: ptr(1000)}, true},
|
||||
{"a known limit", securityrepo.Tuning{AntiNukeLimits: map[string]int{"ban": 0}}, true},
|
||||
{"an unknown limit", securityrepo.Tuning{AntiNukeLimits: map[string]int{"ban_everyone": 1}}, false},
|
||||
{"a negative limit", securityrepo.Tuning{AntiNukeLimits: map[string]int{"ban": -1}}, false},
|
||||
{"a huge limit", securityrepo.Tuning{AntiNukeLimits: map[string]int{"ban": 101}}, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := ValidateTuning(&tc.t)
|
||||
if (err == nil) != tc.ok {
|
||||
t.Errorf("ValidateTuning = %v, want ok=%v", err, tc.ok)
|
||||
}
|
||||
var ue UserError
|
||||
if err != nil && !errors.As(err, &ue) {
|
||||
t.Errorf("a refusal must be a UserError the command can show: %T", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanWords(t *testing.T) {
|
||||
got, err := CleanWords([]string{" Spam ", "spam", "", "ADS", "ads "})
|
||||
if err != nil || len(got) != 2 || got[0] != "ads" || got[1] != "spam" {
|
||||
t.Errorf("trimmed, lowercased, de-duplicated and sorted: %v, %v", got, err)
|
||||
}
|
||||
if _, err := CleanWords([]string{strings.Repeat("x", 51)}); err == nil {
|
||||
t.Error("an oversized word is refused")
|
||||
}
|
||||
if _, err := CleanWords([]string{"bad\x00word"}); err == nil {
|
||||
t.Error("control characters are refused")
|
||||
}
|
||||
many := make([]string, 51)
|
||||
for i := range many {
|
||||
many[i] = strings.Repeat("a", i+1)
|
||||
}
|
||||
if _, err := CleanWords(many); err == nil {
|
||||
t.Error("more than 50 words is refused")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTuneNeedsTheOwnerOrTrusted(t *testing.T) {
|
||||
e, d, st, _ := testEngine(t)
|
||||
ctx := context.Background()
|
||||
owner := d.members["1"]
|
||||
admin := d.addMember("50")
|
||||
trustedUser := d.addMember("60")
|
||||
st.trusted[100] = []securityrepo.Trusted{{TargetID: 60, Kind: "user"}}
|
||||
refresh(e)
|
||||
|
||||
tun := securityrepo.Tuning{RaidJoinCount: ptr(20)}
|
||||
if err := e.Tune(ctx, guild, admin, tun, "x"); !errors.Is(err, ErrNotTrusted) {
|
||||
t.Errorf("an admin who isn't trusted must not tune: %v", err)
|
||||
}
|
||||
if err := e.Tune(ctx, guild, nil, tun, "x"); err == nil {
|
||||
t.Error("no actor, no change")
|
||||
}
|
||||
if st.set(100).RaidJoinCount == 20 {
|
||||
t.Fatal("nothing may be saved for a refused change")
|
||||
}
|
||||
if err := e.Tune(ctx, guild, owner, tun, "raid join count 20"); err != nil {
|
||||
t.Fatalf("the owner may: %v", err)
|
||||
}
|
||||
if st.set(100).RaidJoinCount != 20 {
|
||||
t.Error("the owner's change is saved")
|
||||
}
|
||||
if err := e.Tune(ctx, guild, trustedUser, securityrepo.Tuning{RaidJoinCount: ptr(2)}, "x"); err == nil {
|
||||
t.Error("an invalid value is refused even for the owner or trusted")
|
||||
}
|
||||
if st.set(100).RaidJoinCount != 20 {
|
||||
t.Error("an invalid change must leave the old value")
|
||||
}
|
||||
if len(st.events) == 0 || st.events[len(st.events)-1].Kind != "settings" {
|
||||
t.Errorf("the change is written to the history: %v", st.events)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditWords(t *testing.T) {
|
||||
e, d, st, _ := testEngine(t)
|
||||
ctx := context.Background()
|
||||
owner := d.members["1"]
|
||||
admin := d.addMember("50")
|
||||
|
||||
if _, err := e.EditWords(ctx, guild, admin, UsernameWords, true, "scam"); !errors.Is(err, ErrNotTrusted) {
|
||||
t.Errorf("untrusted: %v", err)
|
||||
}
|
||||
words, err := e.EditWords(ctx, guild, owner, UsernameWords, true, " SCAM ")
|
||||
if err != nil || len(words) != 1 || words[0] != "scam" {
|
||||
t.Fatalf("add: %v, %v", words, err)
|
||||
}
|
||||
if _, err := e.EditWords(ctx, guild, owner, ChatWords, true, "free nitro"); err != nil {
|
||||
t.Fatalf("the chat list is separate: %v", err)
|
||||
}
|
||||
s := st.set(100)
|
||||
if len(s.UsernamePatterns) != 1 || len(s.HeatBlockedWords) != 1 {
|
||||
t.Errorf("each list keeps its own words: %v %v", s.UsernamePatterns, s.HeatBlockedWords)
|
||||
}
|
||||
if _, err := e.EditWords(ctx, guild, owner, UsernameWords, false, "nothing"); err == nil {
|
||||
t.Error("removing a word that isn't there says so")
|
||||
}
|
||||
words, err = e.EditWords(ctx, guild, owner, UsernameWords, false, "scam")
|
||||
if err != nil || len(words) != 0 {
|
||||
t.Errorf("remove: %v, %v", words, err)
|
||||
}
|
||||
if _, err := e.EditWords(ctx, guild, owner, UsernameWords, true, " "); err == nil {
|
||||
t.Error("a blank word is refused")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user