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" }