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,584 @@
|
||||
// Package securityrepo stores the anti-raid and anti-nuke settings, the
|
||||
// trusted list, quarantines, locked channels, the event history and the
|
||||
// dashboard's requests. Discord IDs are int64; 0 means "not set".
|
||||
package securityrepo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Repo struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewRepo(db *sql.DB) *Repo {
|
||||
return &Repo{db: db}
|
||||
}
|
||||
|
||||
// Filter actions.
|
||||
const (
|
||||
ActionOff = "off"
|
||||
ActionAlert = "alert" // raid mode only: alert staff, touch nobody
|
||||
ActionTimeout = "timeout"
|
||||
ActionKick = "kick"
|
||||
ActionBan = "ban"
|
||||
)
|
||||
|
||||
// Anti-nuke limit keys.
|
||||
const (
|
||||
LimitChannelDelete = "channel_delete"
|
||||
LimitChannelCreate = "channel_create"
|
||||
LimitRoleDelete = "role_delete"
|
||||
LimitRoleCreate = "role_create"
|
||||
LimitBan = "ban"
|
||||
LimitKick = "kick"
|
||||
LimitWebhookCreate = "webhook_create"
|
||||
)
|
||||
|
||||
// DefaultLimits mirrors the column default in the migration.
|
||||
func DefaultLimits() map[string]int {
|
||||
return map[string]int{
|
||||
LimitChannelDelete: 2, LimitChannelCreate: 5, LimitRoleDelete: 2, LimitRoleCreate: 5,
|
||||
LimitBan: 3, LimitKick: 5, LimitWebhookCreate: 3,
|
||||
}
|
||||
}
|
||||
|
||||
type Settings struct {
|
||||
GuildID int64
|
||||
AlertChannelID int64
|
||||
AlertRoleID int64
|
||||
AlertDM bool
|
||||
|
||||
JoinGateEnabled bool
|
||||
NoAvatar string
|
||||
NewAccount string
|
||||
MinAccountAgeDays int
|
||||
AdvertisingName string
|
||||
UsernameFilter string
|
||||
UsernamePatterns []string
|
||||
UnverifiedBot string
|
||||
UnauthorizedBot string
|
||||
AntiRaidEnabled bool
|
||||
RaidJoinCount int
|
||||
RaidJoinSeconds int
|
||||
RaidAction string
|
||||
RaidPauseInvites bool
|
||||
RaidQuietMinutes int
|
||||
AntiNukeEnabled bool
|
||||
AntiNukeWindowSeconds int
|
||||
AntiNukeLimits map[string]int
|
||||
AntiNukeLockServer bool
|
||||
QuarantineRoleID int64
|
||||
AntiNukeAutoRestore bool
|
||||
HeatEnabled bool
|
||||
HeatStrictness string
|
||||
HeatAction string
|
||||
HeatTimeoutMinutes int
|
||||
HeatEscalate bool
|
||||
HeatBlockedWords []string
|
||||
HeatPanicMembers int
|
||||
HeatLockdownMentions int
|
||||
JoinLock string
|
||||
|
||||
// Configured is false when the guild has no row yet; the fields then
|
||||
// hold the defaults, with every system off.
|
||||
Configured bool
|
||||
}
|
||||
|
||||
func Defaults(guildID int64) *Settings {
|
||||
return &Settings{
|
||||
GuildID: guildID,
|
||||
AlertDM: true,
|
||||
NoAvatar: ActionOff, NewAccount: ActionOff, MinAccountAgeDays: 7, AdvertisingName: ActionOff,
|
||||
UsernameFilter: ActionOff, UnverifiedBot: ActionOff, UnauthorizedBot: ActionKick,
|
||||
RaidJoinCount: 10, RaidJoinSeconds: 10, RaidAction: ActionKick, RaidPauseInvites: true, RaidQuietMinutes: 10,
|
||||
AntiNukeWindowSeconds: 10, AntiNukeLimits: DefaultLimits(),
|
||||
HeatStrictness: "medium", HeatAction: ActionTimeout, HeatTimeoutMinutes: 10, HeatEscalate: true, HeatPanicMembers: 3,
|
||||
JoinLock: ActionOff,
|
||||
}
|
||||
}
|
||||
|
||||
func nullID(v int64) sql.NullInt64 { return sql.NullInt64{Int64: v, Valid: v != 0} }
|
||||
|
||||
func (r *Repo) GetSettings(ctx context.Context, guildID int64) (*Settings, error) {
|
||||
const q = `
|
||||
SELECT alert_channel_id, alert_role_id, alert_dm,
|
||||
joingate_enabled, joingate_no_avatar, joingate_new_account, joingate_min_account_age_days,
|
||||
joingate_advertising_name, joingate_username_filter, to_json(joingate_username_patterns),
|
||||
joingate_unverified_bot, joingate_unauthorized_bot,
|
||||
antiraid_enabled, raid_join_count, raid_join_seconds, raid_action, raid_pause_invites, raid_quiet_minutes,
|
||||
antinuke_enabled, antinuke_window_seconds, antinuke_limits, antinuke_lock_server, quarantine_role_id,
|
||||
antinuke_auto_restore, heat_enabled, heat_strictness, heat_action, heat_timeout_minutes, heat_escalate,
|
||||
to_json(heat_blocked_words), heat_panic_members, heat_lockdown_mentions,
|
||||
join_lock
|
||||
FROM security_settings WHERE guild_id = $1`
|
||||
s := &Settings{GuildID: guildID, Configured: true}
|
||||
var alertCh, alertRole, qRole sql.NullInt64
|
||||
var patterns, words []byte
|
||||
var limits []byte
|
||||
err := r.db.QueryRowContext(ctx, q, guildID).Scan(
|
||||
&alertCh, &alertRole, &s.AlertDM,
|
||||
&s.JoinGateEnabled, &s.NoAvatar, &s.NewAccount, &s.MinAccountAgeDays,
|
||||
&s.AdvertisingName, &s.UsernameFilter, &patterns,
|
||||
&s.UnverifiedBot, &s.UnauthorizedBot,
|
||||
&s.AntiRaidEnabled, &s.RaidJoinCount, &s.RaidJoinSeconds, &s.RaidAction, &s.RaidPauseInvites, &s.RaidQuietMinutes,
|
||||
&s.AntiNukeEnabled, &s.AntiNukeWindowSeconds, &limits, &s.AntiNukeLockServer, &qRole,
|
||||
&s.AntiNukeAutoRestore, &s.HeatEnabled, &s.HeatStrictness, &s.HeatAction, &s.HeatTimeoutMinutes, &s.HeatEscalate,
|
||||
&words, &s.HeatPanicMembers, &s.HeatLockdownMentions,
|
||||
&s.JoinLock,
|
||||
)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Defaults(guildID), nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.AlertChannelID, s.AlertRoleID, s.QuarantineRoleID = alertCh.Int64, alertRole.Int64, qRole.Int64
|
||||
_ = json.Unmarshal(patterns, &s.UsernamePatterns)
|
||||
_ = json.Unmarshal(words, &s.HeatBlockedWords)
|
||||
s.AntiNukeLimits = DefaultLimits()
|
||||
if len(limits) > 0 {
|
||||
// Saved values override the defaults key by key, so a limit added
|
||||
// in a later version still has a value for older rows.
|
||||
var saved map[string]int
|
||||
if err := json.Unmarshal(limits, &saved); err == nil {
|
||||
for k, v := range saved {
|
||||
s.AntiNukeLimits[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// ensureRow makes sure the guild has a settings row so single-column updates
|
||||
// below can be plain UPDATEs.
|
||||
func (r *Repo) ensureRow(ctx context.Context, guildID int64) error {
|
||||
_, err := r.db.ExecContext(ctx, `INSERT INTO security_settings (guild_id) VALUES ($1) ON CONFLICT DO NOTHING`, guildID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *Repo) update(ctx context.Context, guildID int64, set string, args ...any) error {
|
||||
if err := r.ensureRow(ctx, guildID); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := r.db.ExecContext(ctx, `UPDATE security_settings SET `+set+` WHERE guild_id = $1`, append([]any{guildID}, args...)...)
|
||||
return err
|
||||
}
|
||||
|
||||
// Feature names accepted by SetEnabled.
|
||||
const (
|
||||
FeatureJoinGate = "joingate"
|
||||
FeatureAntiRaid = "antiraid"
|
||||
FeatureAntiNuke = "antinuke"
|
||||
FeatureHeat = "heat"
|
||||
)
|
||||
|
||||
func (r *Repo) SetEnabled(ctx context.Context, guildID int64, feature string, on bool) error {
|
||||
col := map[string]string{FeatureJoinGate: "joingate_enabled", FeatureAntiRaid: "antiraid_enabled", FeatureAntiNuke: "antinuke_enabled", FeatureHeat: "heat_enabled"}[feature]
|
||||
if col == "" {
|
||||
return errors.New("unknown feature " + feature)
|
||||
}
|
||||
return r.update(ctx, guildID, col+` = $2`, on)
|
||||
}
|
||||
|
||||
func (r *Repo) SetAlerts(ctx context.Context, guildID, channelID, roleID int64) error {
|
||||
return r.update(ctx, guildID, `alert_channel_id = $2, alert_role_id = $3`, nullID(channelID), nullID(roleID))
|
||||
}
|
||||
|
||||
func (r *Repo) SetQuarantineRole(ctx context.Context, guildID, roleID int64) error {
|
||||
return r.update(ctx, guildID, `quarantine_role_id = $2`, nullID(roleID))
|
||||
}
|
||||
|
||||
func (r *Repo) SetJoinLock(ctx context.Context, guildID int64, mode string) error {
|
||||
return r.update(ctx, guildID, `join_lock = $2`, mode)
|
||||
}
|
||||
|
||||
// ---- Trusted ----
|
||||
|
||||
type Trusted struct {
|
||||
TargetID int64
|
||||
Kind string // "user" or "role"
|
||||
AddedBy int64
|
||||
}
|
||||
|
||||
func (r *Repo) ListTrusted(ctx context.Context, guildID int64) ([]Trusted, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `SELECT target_id, kind, added_by FROM security_trusted WHERE guild_id = $1 ORDER BY created_at`, guildID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Trusted
|
||||
for rows.Next() {
|
||||
var t Trusted
|
||||
if err := rows.Scan(&t.TargetID, &t.Kind, &t.AddedBy); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, t)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (r *Repo) AddTrusted(ctx context.Context, guildID int64, t Trusted) error {
|
||||
_, err := r.db.ExecContext(ctx, `
|
||||
INSERT INTO security_trusted (guild_id, target_id, kind, added_by) VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (guild_id, target_id) DO UPDATE SET kind = EXCLUDED.kind`, guildID, t.TargetID, t.Kind, t.AddedBy)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *Repo) RemoveTrusted(ctx context.Context, guildID, targetID int64) (bool, error) {
|
||||
res, err := r.db.ExecContext(ctx, `DELETE FROM security_trusted WHERE guild_id = $1 AND target_id = $2`, guildID, targetID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
return n > 0, err
|
||||
}
|
||||
|
||||
// ---- Quarantine ----
|
||||
|
||||
type Quarantine struct {
|
||||
GuildID int64
|
||||
UserID int64
|
||||
PreviousRoleIDs []int64
|
||||
Reason string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// SaveQuarantine records a quarantine. If the member is already quarantined
|
||||
// the first record is kept, since its roles are the ones to give back.
|
||||
func (r *Repo) SaveQuarantine(ctx context.Context, q Quarantine) error {
|
||||
_, err := r.db.ExecContext(ctx, `
|
||||
INSERT INTO security_quarantines (guild_id, user_id, previous_role_ids, reason)
|
||||
VALUES ($1, $2, COALESCE($3::bigint[], '{}'), $4)
|
||||
ON CONFLICT (guild_id, user_id) DO NOTHING`, q.GuildID, q.UserID, q.PreviousRoleIDs, q.Reason)
|
||||
return err
|
||||
}
|
||||
|
||||
func scanQuarantine(row interface{ Scan(...any) error }) (Quarantine, error) {
|
||||
var q Quarantine
|
||||
var roles []byte
|
||||
err := row.Scan(&q.GuildID, &q.UserID, &roles, &q.Reason, &q.CreatedAt)
|
||||
if err == nil {
|
||||
err = json.Unmarshal(roles, &q.PreviousRoleIDs)
|
||||
}
|
||||
return q, err
|
||||
}
|
||||
|
||||
// GetQuarantine returns nil when the member isn't quarantined.
|
||||
func (r *Repo) GetQuarantine(ctx context.Context, guildID, userID int64) (*Quarantine, error) {
|
||||
q, err := scanQuarantine(r.db.QueryRowContext(ctx, `
|
||||
SELECT guild_id, user_id, to_json(previous_role_ids), reason, created_at FROM security_quarantines
|
||||
WHERE guild_id = $1 AND user_id = $2`, guildID, userID))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &q, nil
|
||||
}
|
||||
|
||||
func (r *Repo) ListQuarantines(ctx context.Context, guildID int64) ([]Quarantine, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT guild_id, user_id, to_json(previous_role_ids), reason, created_at FROM security_quarantines
|
||||
WHERE guild_id = $1 ORDER BY created_at DESC`, guildID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Quarantine
|
||||
for rows.Next() {
|
||||
q, err := scanQuarantine(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, q)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (r *Repo) DeleteQuarantine(ctx context.Context, guildID, userID int64) error {
|
||||
_, err := r.db.ExecContext(ctx, `DELETE FROM security_quarantines WHERE guild_id = $1 AND user_id = $2`, guildID, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
// ---- Lockdowns ----
|
||||
|
||||
type Lockdown struct {
|
||||
GuildID int64
|
||||
ChannelID int64
|
||||
HadOverwrite bool
|
||||
PreviousAllow int64
|
||||
PreviousDeny int64
|
||||
ServerWide bool
|
||||
LockedBy int64
|
||||
}
|
||||
|
||||
// SaveLockdown remembers a channel's @everyone overwrite from before it was
|
||||
// locked. A channel that is already locked keeps its first record, which is
|
||||
// the state to go back to.
|
||||
func (r *Repo) SaveLockdown(ctx context.Context, l Lockdown) error {
|
||||
_, err := r.db.ExecContext(ctx, `
|
||||
INSERT INTO security_lockdowns (guild_id, channel_id, had_overwrite, previous_allow, previous_deny, server_wide, locked_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
ON CONFLICT (guild_id, channel_id) DO NOTHING`,
|
||||
l.GuildID, l.ChannelID, l.HadOverwrite, l.PreviousAllow, l.PreviousDeny, l.ServerWide, l.LockedBy)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetLockdown returns nil when the channel isn't locked by the bot.
|
||||
func (r *Repo) GetLockdown(ctx context.Context, guildID, channelID int64) (*Lockdown, error) {
|
||||
var l Lockdown
|
||||
err := r.db.QueryRowContext(ctx, `
|
||||
SELECT guild_id, channel_id, had_overwrite, previous_allow, previous_deny, server_wide, locked_by
|
||||
FROM security_lockdowns WHERE guild_id = $1 AND channel_id = $2`, guildID, channelID).
|
||||
Scan(&l.GuildID, &l.ChannelID, &l.HadOverwrite, &l.PreviousAllow, &l.PreviousDeny, &l.ServerWide, &l.LockedBy)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &l, nil
|
||||
}
|
||||
|
||||
// ListLockdowns lists locked channels; serverWideOnly limits it to the ones a
|
||||
// server lockdown locked.
|
||||
func (r *Repo) ListLockdowns(ctx context.Context, guildID int64, serverWideOnly bool) ([]Lockdown, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT guild_id, channel_id, had_overwrite, previous_allow, previous_deny, server_wide, locked_by
|
||||
FROM security_lockdowns WHERE guild_id = $1 AND (NOT $2 OR server_wide) ORDER BY locked_at`, guildID, serverWideOnly)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Lockdown
|
||||
for rows.Next() {
|
||||
var l Lockdown
|
||||
if err := rows.Scan(&l.GuildID, &l.ChannelID, &l.HadOverwrite, &l.PreviousAllow, &l.PreviousDeny, &l.ServerWide, &l.LockedBy); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, l)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (r *Repo) DeleteLockdown(ctx context.Context, guildID, channelID int64) error {
|
||||
_, err := r.db.ExecContext(ctx, `DELETE FROM security_lockdowns WHERE guild_id = $1 AND channel_id = $2`, guildID, channelID)
|
||||
return err
|
||||
}
|
||||
|
||||
// ---- Events ----
|
||||
|
||||
type Event struct {
|
||||
GuildID int64
|
||||
Kind string
|
||||
ActorID int64
|
||||
TargetID int64
|
||||
Detail string
|
||||
}
|
||||
|
||||
// Event kinds.
|
||||
const (
|
||||
EventJoinGate = "joingate"
|
||||
EventRaidStarted = "raid_started"
|
||||
EventRaidEnded = "raid_ended"
|
||||
EventNuke = "nuke"
|
||||
EventQuarantined = "quarantined"
|
||||
EventReleased = "released"
|
||||
EventUnauthorizedBot = "unauthorized_bot"
|
||||
EventLocked = "locked"
|
||||
EventUnlocked = "unlocked"
|
||||
EventHeat = "heat"
|
||||
EventHeatPanic = "heat_panic"
|
||||
EventSnapshot = "snapshot"
|
||||
EventRestored = "restored"
|
||||
)
|
||||
|
||||
func (r *Repo) AddEvent(ctx context.Context, e Event) error {
|
||||
_, err := r.db.ExecContext(ctx, `
|
||||
INSERT INTO security_events (guild_id, kind, actor_id, target_id, detail) VALUES ($1, $2, $3, $4, $5)`,
|
||||
e.GuildID, e.Kind, nullID(e.ActorID), nullID(e.TargetID), e.Detail)
|
||||
return err
|
||||
}
|
||||
|
||||
// ---- Dashboard commands ----
|
||||
|
||||
type Command struct {
|
||||
ID int64
|
||||
GuildID int64
|
||||
CommandType string
|
||||
Payload []byte
|
||||
RequestedBy int64
|
||||
}
|
||||
|
||||
func (r *Repo) ListUnprocessedCommands(ctx context.Context) ([]Command, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT id, guild_id, command_type, COALESCE(payload, '{}'::jsonb), requested_by
|
||||
FROM security_commands WHERE processed_at IS NULL ORDER BY id`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Command
|
||||
for rows.Next() {
|
||||
var c Command
|
||||
if err := rows.Scan(&c.ID, &c.GuildID, &c.CommandType, &c.Payload, &c.RequestedBy); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (r *Repo) MarkCommandProcessed(ctx context.Context, id int64) error {
|
||||
_, err := r.db.ExecContext(ctx, `UPDATE security_commands SET processed_at = NOW() WHERE id = $1`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// ---- Raids in progress ----
|
||||
|
||||
type Raid struct {
|
||||
GuildID int64
|
||||
StartedAt time.Time
|
||||
LastJoinAt time.Time
|
||||
InvitesPaused bool
|
||||
CaughtTotal int
|
||||
}
|
||||
|
||||
func (r *Repo) SaveRaid(ctx context.Context, x Raid) error {
|
||||
_, err := r.db.ExecContext(ctx, `
|
||||
INSERT INTO security_raids (guild_id, started_at, last_join_at, invites_paused, caught_total)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (guild_id) DO UPDATE SET
|
||||
last_join_at = EXCLUDED.last_join_at, invites_paused = EXCLUDED.invites_paused, caught_total = EXCLUDED.caught_total`,
|
||||
x.GuildID, x.StartedAt, x.LastJoinAt, x.InvitesPaused, x.CaughtTotal)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *Repo) ListRaids(ctx context.Context) ([]Raid, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `SELECT guild_id, started_at, last_join_at, invites_paused, caught_total FROM security_raids`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Raid
|
||||
for rows.Next() {
|
||||
var x Raid
|
||||
if err := rows.Scan(&x.GuildID, &x.StartedAt, &x.LastJoinAt, &x.InvitesPaused, &x.CaughtTotal); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, x)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// DeleteRaid ends a raid's record and its caught list.
|
||||
func (r *Repo) DeleteRaid(ctx context.Context, guildID int64) error {
|
||||
if _, err := r.db.ExecContext(ctx, `DELETE FROM security_raid_caught WHERE guild_id = $1`, guildID); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := r.db.ExecContext(ctx, `DELETE FROM security_raids WHERE guild_id = $1`, guildID)
|
||||
return err
|
||||
}
|
||||
|
||||
type Caught struct {
|
||||
UserID int64
|
||||
Action string
|
||||
Name string
|
||||
}
|
||||
|
||||
func (r *Repo) AddRaidCaught(ctx context.Context, guildID int64, c Caught) error {
|
||||
_, err := r.db.ExecContext(ctx, `
|
||||
INSERT INTO security_raid_caught (guild_id, user_id, action, name) VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (guild_id, user_id) DO NOTHING`, guildID, c.UserID, c.Action, c.Name)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *Repo) ListRaidCaught(ctx context.Context, guildID int64, limit int) ([]Caught, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT user_id, action, name FROM security_raid_caught WHERE guild_id = $1 ORDER BY caught_at LIMIT $2`, guildID, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Caught
|
||||
for rows.Next() {
|
||||
var c Caught
|
||||
if err := rows.Scan(&c.UserID, &c.Action, &c.Name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ---- Snapshots ----
|
||||
|
||||
const keepSnapshots = 3
|
||||
|
||||
type Snapshot struct {
|
||||
ID int64
|
||||
GuildID int64
|
||||
Data []byte
|
||||
Roles int
|
||||
Channels int
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// SaveSnapshot stores a snapshot and drops all but the newest three.
|
||||
func (r *Repo) SaveSnapshot(ctx context.Context, s Snapshot) error {
|
||||
if _, err := r.db.ExecContext(ctx, `
|
||||
INSERT INTO security_snapshots (guild_id, data, roles, channels) VALUES ($1, $2, $3, $4)`,
|
||||
s.GuildID, s.Data, s.Roles, s.Channels); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := r.db.ExecContext(ctx, `
|
||||
DELETE FROM security_snapshots WHERE guild_id = $1 AND id NOT IN (
|
||||
SELECT id FROM security_snapshots WHERE guild_id = $1 ORDER BY created_at DESC, id DESC LIMIT $2)`,
|
||||
s.GuildID, keepSnapshots)
|
||||
return err
|
||||
}
|
||||
|
||||
// LatestSnapshot returns nil when the guild has none.
|
||||
func (r *Repo) LatestSnapshot(ctx context.Context, guildID int64) (*Snapshot, error) {
|
||||
var s Snapshot
|
||||
err := r.db.QueryRowContext(ctx, `
|
||||
SELECT id, guild_id, data, roles, channels, created_at FROM security_snapshots
|
||||
WHERE guild_id = $1 ORDER BY created_at DESC, id DESC LIMIT 1`, guildID).
|
||||
Scan(&s.ID, &s.GuildID, &s.Data, &s.Roles, &s.Channels, &s.CreatedAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
// GuildsDueForSnapshot lists guilds with anti-nuke on whose newest snapshot
|
||||
// is older than maxAge (or that have none).
|
||||
func (r *Repo) GuildsDueForSnapshot(ctx context.Context, maxAge time.Duration) ([]int64, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT s.guild_id FROM security_settings s
|
||||
WHERE s.antinuke_enabled AND NOT EXISTS (
|
||||
SELECT 1 FROM security_snapshots p WHERE p.guild_id = s.guild_id AND p.created_at > NOW() - make_interval(secs => $1))`,
|
||||
maxAge.Seconds())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []int64
|
||||
for rows.Next() {
|
||||
var g int64
|
||||
if err := rows.Scan(&g); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, g)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user