- /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]>
218 lines
7.3 KiB
Go
218 lines
7.3 KiB
Go
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
|
|
}
|