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,176 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"velox-bot/internal/db/repos/securityrepo"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
)
|
||||
|
||||
// Invite links in a name: discord.gg/x, discord.com/invite/x, dsc.gg/x and
|
||||
// the common lookalikes. Matched case-insensitively against the lowercased
|
||||
// name; the pattern is fixed, never built from user input.
|
||||
var inviteRe = regexp.MustCompile(`(discord(app)?\.(gg|com/invite|io|me|li)|dsc\.gg|\.gg/)`)
|
||||
|
||||
var severity = map[string]int{
|
||||
securityrepo.ActionOff: 0, securityrepo.ActionAlert: 1, securityrepo.ActionTimeout: 2,
|
||||
securityrepo.ActionKick: 3, securityrepo.ActionBan: 4,
|
||||
}
|
||||
|
||||
// GateResult is what the Join Gate decided about one member.
|
||||
type GateResult struct {
|
||||
Action string // off, timeout, kick, ban
|
||||
Reason string
|
||||
}
|
||||
|
||||
// CheckJoin runs the Join Gate filters on a new member. When several match,
|
||||
// the strictest action wins. Bots are only checked for being unverified;
|
||||
// bots added by untrusted members are handled from the audit log.
|
||||
func CheckJoin(s *securityrepo.Settings, u *discordgo.User, now time.Time) GateResult {
|
||||
var res GateResult
|
||||
consider := func(action, reason string) {
|
||||
if severity[action] > severity[res.Action] {
|
||||
res = GateResult{Action: action, Reason: reason}
|
||||
}
|
||||
}
|
||||
res.Action = securityrepo.ActionOff
|
||||
if !s.JoinGateEnabled || u == nil {
|
||||
return res
|
||||
}
|
||||
|
||||
if u.Bot {
|
||||
if u.PublicFlags&discordgo.UserFlagVerifiedBot == 0 {
|
||||
consider(s.UnverifiedBot, "unverified bot")
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
names := strings.ToLower(u.Username + " " + u.GlobalName)
|
||||
if inviteRe.MatchString(names) {
|
||||
consider(s.AdvertisingName, "invite link in name")
|
||||
}
|
||||
if s.UsernameFilter != securityrepo.ActionOff {
|
||||
for _, p := range s.UsernamePatterns {
|
||||
p = strings.ToLower(strings.TrimSpace(p))
|
||||
if p != "" && strings.Contains(names, p) {
|
||||
consider(s.UsernameFilter, "blocked word in name")
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if created, err := discordgo.SnowflakeTimestamp(u.ID); err == nil {
|
||||
if now.Sub(created) < time.Duration(s.MinAccountAgeDays)*24*time.Hour {
|
||||
consider(s.NewAccount, fmt.Sprintf("account younger than %d days", s.MinAccountAgeDays))
|
||||
}
|
||||
}
|
||||
if u.Avatar == "" {
|
||||
consider(s.NoAvatar, "no avatar")
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// errProtected is returned when asked to punish the owner or the bot.
|
||||
var errProtected = errors.New("the owner and the bot are never punished")
|
||||
|
||||
// punish applies a member action. It never touches the owner or the bot.
|
||||
func (e *Engine) punish(guildID string, u *discordgo.User, action, reason string) error {
|
||||
if u == nil || u.ID == e.botID || e.IsOwner(guildID, u.ID) {
|
||||
return errProtected
|
||||
}
|
||||
reason = clip("Velox security: "+reason, 400)
|
||||
switch action {
|
||||
case securityrepo.ActionTimeout:
|
||||
until := e.now().Add(timeoutJoin)
|
||||
return e.d.GuildMemberTimeout(guildID, u.ID, &until, discordgo.WithAuditLogReason(reason))
|
||||
case securityrepo.ActionKick:
|
||||
return e.d.GuildMemberDeleteWithReason(guildID, u.ID, reason)
|
||||
case securityrepo.ActionBan:
|
||||
return e.d.GuildBanCreateWithReason(guildID, u.ID, reason, 1)
|
||||
}
|
||||
return errors.New("no action " + action)
|
||||
}
|
||||
|
||||
// OnMemberJoin runs every check for a new member: the join lock, the Join
|
||||
// Gate and raid detection. It reports whether the member was removed, so
|
||||
// welcome messages and default roles can be skipped.
|
||||
func (e *Engine) OnMemberJoin(ctx context.Context, guildID string, m *discordgo.Member) (removed bool) {
|
||||
if m == nil || m.User == nil {
|
||||
return false
|
||||
}
|
||||
s, _, err := e.load(ctx, guildID)
|
||||
if err != nil {
|
||||
log.Printf("security: settings for guild %s: %v", guildID, err)
|
||||
return false
|
||||
}
|
||||
|
||||
// 1. Join lockdown: everyone new is kicked or banned.
|
||||
if s.JoinLock == securityrepo.ActionKick || s.JoinLock == securityrepo.ActionBan {
|
||||
if err := e.punish(guildID, m.User, s.JoinLock, "joins are locked"); err != nil {
|
||||
if !errors.Is(err, errProtected) {
|
||||
log.Printf("security: join lock in guild %s: %v", guildID, err)
|
||||
}
|
||||
return false
|
||||
}
|
||||
e.raidJoin(ctx, guildID, s, m, true)
|
||||
return true
|
||||
}
|
||||
|
||||
// 2. Join Gate. A bot's verified flag isn't always in the join event, so
|
||||
// it's read from Discord; if that fails the bot is left alone rather than
|
||||
// risk kicking a verified one.
|
||||
u := m.User
|
||||
if u.Bot && s.JoinGateEnabled && s.UnverifiedBot != securityrepo.ActionOff {
|
||||
fresh, err := e.d.User(u.ID)
|
||||
if err != nil {
|
||||
log.Printf("security: looking up bot %s: %v", u.ID, err)
|
||||
a := e.raidJoin(ctx, guildID, s, m, false)
|
||||
return a == securityrepo.ActionKick || a == securityrepo.ActionBan
|
||||
}
|
||||
u = fresh
|
||||
}
|
||||
gate := CheckJoin(s, u, e.now())
|
||||
if gate.Action != securityrepo.ActionOff {
|
||||
if err := e.punish(guildID, m.User, gate.Action, gate.Reason); err != nil {
|
||||
if !errors.Is(err, errProtected) {
|
||||
log.Printf("security: join gate in guild %s: %v", guildID, err)
|
||||
}
|
||||
} else {
|
||||
removed = gate.Action == securityrepo.ActionKick || gate.Action == securityrepo.ActionBan
|
||||
// During a raid the summary lists everyone; one alert per
|
||||
// raider would flood the channel and Discord's rate limit.
|
||||
if !e.RaidActive(guildID) {
|
||||
e.event(ctx, guildID, securityrepo.EventJoinGate, e.botID, m.User.ID, gate.Action+": "+gate.Reason)
|
||||
e.send(ctx, guildID, s, alert{embed: &discordgo.MessageEmbed{
|
||||
Title: "Join Gate",
|
||||
Description: fmt.Sprintf("%s <@%s> (%s): %s.", actionPast(gate.Action), m.User.ID, safeName(m.User), gate.Reason),
|
||||
Color: colorWarn,
|
||||
}})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Raid detection, which counts every join, gated or not.
|
||||
if a := e.raidJoin(ctx, guildID, s, m, removed); a == securityrepo.ActionKick || a == securityrepo.ActionBan {
|
||||
removed = true
|
||||
}
|
||||
return removed
|
||||
}
|
||||
|
||||
func actionPast(a string) string {
|
||||
switch a {
|
||||
case securityrepo.ActionTimeout:
|
||||
return "Timed out"
|
||||
case securityrepo.ActionKick:
|
||||
return "Kicked"
|
||||
case securityrepo.ActionBan:
|
||||
return "Banned"
|
||||
}
|
||||
return "Flagged"
|
||||
}
|
||||
Reference in new issue
Block a user