콘텐츠로 이동

Recovery: Anti-Nuke

길드의 비정상 이벤트 패턴을 감지하고 대응하는 sub-context. 4가지 감지 패턴(mass role/channel delete, mass kick, permission escalation)을 rolling window 로 추적하며, 이상 감지 시 자동 스냅샷 + 알림을 수행한다. Enterprise 는 자동 권한 박탈 opt-in.

Sub-context position

  • Parent — Recovery Core domain
  • Locationengine/recovery/subdomain/antinuke/
  • Sibling sub-contexts — Snapshot (긴급 스냅샷 트리거), Restore (auto-action 트리거), Sync (이벤트 소스)

Why this sub-context exists

Umbra 의 가치 제안 3기둥 중 Protect 를 담당한다. Snapshot + Restore 만 있으면 "사고 난 뒤 되돌림" 이 가능하지만, 사용자 입장에서는 "사고 발생 자체" 가 스트레스다. AntiNuke 는 사고 시작을 감지하여 다음을 수행한다.

  • 증거 보존 — 공격 진행 전에 상태 스냅샷
  • 즉시 알림 — Owner 가 사고를 모른 채 지나가지 않도록
  • 능동 대응 (Enterprise) — 공격자 권한 박탈로 피해 최소화

이 기능은 Pro 플랜의 차별화 포인트이자 MVP 에 포함되었다 (ADR-0026). "백업만 하는 봇" 과 "실시간으로 길드를 지키는 봇" 의 정체성 차이다.

Domain model

AntiNukeIncident

감지 이벤트 기록.

AntiNukeIncident
├─ id                   UUID v7       PK
├─ guild_id             UUID          → guild.guilds.id
├─ pattern              TEXT          'mass_role_delete' | 'mass_channel_delete' | 'mass_kick' | 'permission_escalation'
├─ detected_at          TIMESTAMPTZ
├─ window_start         TIMESTAMPTZ
├─ window_end           TIMESTAMPTZ
├─ events_count         INTEGER       감지된 이벤트 수
├─ threshold            INTEGER       적용된 임계값
├─ suspects             JSONB         [{ discord_user_id, audit_log_entries, confidence }]
├─ actions_taken        JSONB         ['snapshot_created', 'owner_notified', 'roles_revoked_from_X']
├─ snapshot_id          UUID          → recovery.snapshots.id (nullable, 실패 시 NULL)
├─ false_positive       BOOLEAN       사용자 피드백 (nullable)
├─ false_positive_note  TEXT
├─ created_at           TIMESTAMPTZ
└─ updated_at           TIMESTAMPTZ

DetectionPattern (enum)

DetectionPattern
  mass_role_delete          기본: 5분 내 5개 이상 역할 삭제
  mass_channel_delete       기본: 5분 내 3개 이상 채널 삭제
  mass_kick                 기본: 5분 내 10명 이상 추방
  permission_escalation     기존 멤버가 Administrator 권한 획득

각 패턴은 thresholdwindow_seconds 를 가지며 Guild 별 커스터마이징 가능 (Enterprise).

RollingWindow (workflow-internal state)

Temporal Workflow 가 메모리에 유지하는 rolling window. 패턴별 이벤트 타임스탬프 큐.

RollingWindow
├─ pattern      DetectionPattern
├─ events       deque[EventOccurrence]   시간순 정렬
├─ threshold    int
└─ window_dur   time.Duration

Aggregates

  • AntiNukeIncident — 독립 aggregate

Invariants

  • Workflow 1:1 with Guild — Guild 당 AntiNuke workflow 하나 (Bot 설치 시 시작, 강퇴 시 종료)
  • False positive 기록 가능 — 사용자가 "오탐" 피드백 가능, 임계값 튜닝 근거
  • Snapshot 생성 실패 무시 — AntiNuke 알림은 스냅샷 실패해도 발송 (최소한 Owner 는 알아야 함)
  • Auto-action 은 Enterprise 전용 + opt-in — 기본값 false
  • AntiNuke 비활성 시 workflow 는 idle — 이벤트 무시, incident 기록 없음

State machine

AntiNuke workflow (per guild)

stateDiagram-v2
    [*] --> Idle : Workflow started
    Idle --> Watching : AntiNuke enabled (license)
    Watching --> Idle : AntiNuke disabled
    Watching --> Reacting : Pattern detected
    Reacting --> Watching : Response complete
    Watching --> [*] : Bot kicked / guild deleted
    Idle --> [*] : Bot kicked / guild deleted

Incident lifecycle

stateDiagram-v2
    [*] --> Detected : Pattern matched
    Detected --> Responding : Actions in progress
    Responding --> Completed : All actions done
    Completed --> Reviewed : User provides feedback (optional)

Domain events

Published

Event Trigger Payload Subscribers
AntiNukeTriggered 패턴 감지 guild_id, pattern, events_count, suspects Notification (긴급, preference override), Audit
AntiNukeActioned 대응 완료 guild_id, actions_taken Notification, Audit

Consumed

Source Channel Action
Discord Gateway (role/channel delete, member remove, role update) Bot 프로세스 → Temporal signal Rolling window 에 이벤트 추가
License 상태 변화 Workflow signal AntiNuke enable/disable 반영

Discord 이벤트 수신 경로는 Sync 와 별개:

  • Sync: 길드 상태를 DB 에 반영
  • AntiNuke: 이벤트 타임스탬프를 rolling window 에 적재

두 경로가 이벤트를 중복 수신 하지만 목적이 다르다.

AntiNuke Workflow (Temporal)

Temporal Workflow 로 장기 실행. 길드당 하나.

func AntiNukeWorkflow(ctx workflow.Context, guildID uuid.UUID) error {
    windows := newRollingWindows(defaultThresholds)
    antinukeEnabled := true

    for {
        selector := workflow.NewSelector(ctx)

        // 이벤트 signal
        selector.AddReceive(workflow.GetSignalChannel(ctx, "event"), func(c, more) {
            var ev EventSignal
            c.Receive(ctx, &ev)
            if !antinukeEnabled { return }

            window := windows[ev.Pattern]
            window.Push(ev)
            window.EvictOld(workflow.Now(ctx))

            if window.Len() >= window.Threshold() {
                respondToDetection(ctx, guildID, ev.Pattern, window)
                window.Clear()  // 같은 패턴의 연속 트리거 방지
            }
        })

        // 설정 업데이트 signal
        selector.AddReceive(workflow.GetSignalChannel(ctx, "config_update"), func(c, more) {
            var cfg ConfigUpdate
            c.Receive(ctx, &cfg)
            antinukeEnabled = cfg.Enabled
            windows.UpdateThresholds(cfg.Thresholds)
        })

        // 종료 signal
        selector.AddReceive(workflow.GetSignalChannel(ctx, "shutdown"), func(c, more) {
            c.Receive(ctx, nil)
            return
        })

        // 정기 evict (1분마다)
        timer := workflow.NewTimer(ctx, time.Minute)
        selector.AddFuture(timer, func(f) {
            now := workflow.Now(ctx)
            for _, w := range windows { w.EvictOld(now) }
        })

        selector.Select(ctx)
    }
}

func respondToDetection(ctx, guildID, pattern, window) {
    // 1. Audit log fetch → suspect 추정
    suspects := executeActivity(IdentifySuspects, guildID, pattern, window.Range())

    // 2. 긴급 스냅샷 (실패해도 계속)
    snapID, _ := executeActivity(CreateAntiNukeSnapshot, guildID, pattern)

    // 3. Incident 기록
    incidentID := executeActivity(RecordIncident, guildID, pattern, window, suspects, snapID)

    // 4. AntiNukeTriggered 이벤트 발행 (Notification 긴급 알림)
    executeActivity(PublishAntiNukeTriggered, incidentID, guildID, pattern, suspects)

    // 5. Enterprise auto-action
    if isEnterpriseAutoActionEnabled(guildID) {
        for _, suspect := range suspects {
            if suspect.Confidence > 0.8 {
                executeActivity(RevokeMemberRoles, guildID, suspect.DiscordUserID)
            }
        }
        executeActivity(PublishAntiNukeActioned, incidentID, actionsTaken)
    }
}

Detection patterns

mass_role_delete

  • Trigger event — Discord GUILD_ROLE_DELETE
  • Default threshold — 5 roles
  • Default window — 5 minutes
  • Rationale — 일반 운영은 역할 몇 개 재구성도 5분 안에 5개 이상 삭제 드뭄

mass_channel_delete

  • Trigger event — Discord CHANNEL_DELETE
  • Default threshold — 3 channels
  • Default window — 5 minutes
  • Rationale — 채널 삭제는 역할보다 더 파괴적 영향. 더 낮은 임계값.

mass_kick

  • Trigger event — Discord GUILD_MEMBER_REMOVE
  • Default threshold — 10 members
  • Default window — 5 minutes
  • Rationale — 자연스러운 멤버 이탈과 구분하기 위해 비교적 높은 임계값. Discord Audit Log 에서 추방/밴 타입 구분하여 false positive 완화.

permission_escalation

  • Trigger event — Discord GUILD_ROLE_UPDATE 또는 GUILD_MEMBER_UPDATE 로 Administrator 권한 획득
  • Threshold — 1 (즉시 트리거)
  • Window — N/A
  • Rationale — Administrator 권한 획득은 매우 드물며 대부분 악의적. 가이드는 사전에 역할 관리 정책 권장.

Suspect identification

Discord Audit Log 를 활용하여 가해자 추정.

IdentifySuspects Activity:
  1. Fetch audit_log entries for guild (최근 10분)
  2. Filter by action_type 관련 패턴:
     - mass_role_delete → MEMBER_ROLE_DELETE (executor_id)
     - mass_channel_delete → CHANNEL_DELETE
     - mass_kick → MEMBER_KICK
     - permission_escalation → MEMBER_ROLE_UPDATE with admin
  3. Aggregate by executor_id
  4. Confidence = min(1.0, action_count / threshold)
  5. Return top suspects (up to 3)

Ports

Inbound

// engine/recovery/subdomain/antinuke/port/service.go
type AntiNukeService interface {
    StartWorkflow(ctx, guildID) error  // Bot install 시
    StopWorkflow(ctx, guildID) error   // Bot kick 시
    UpdateConfig(ctx, guildID, enabled bool, autoAction bool, thresholds map[string]Threshold) error

    // Bot 프로세스가 이벤트 수신 시 호출
    SignalEvent(ctx, guildID, event AntiNukeEvent) error

    // 조회
    ListIncidents(ctx, guildID, opts) ([]*AntiNukeIncident, error)
    MarkFalsePositive(ctx, incidentID, note string) error
}

type AntiNukeEvent struct {
    Pattern DetectionPattern
    DiscordAggregateID string  // role_id / channel_id / user_id 등
    Timestamp time.Time
}

Outbound

type AntiNukeIncidentRepository interface {
    Insert(ctx, incident) error
    Update(ctx, incident) error
    ListByGuild(ctx, guildID, opts) ([]*AntiNukeIncident, error)
    GetByID(ctx, id) (*AntiNukeIncident, error)
}

type DiscordClient interface {
    FetchAuditLog(ctx, guildID, actionType int, before time.Time) ([]AuditLogEntry, error)
    RevokeMemberRoles(ctx, guildID, userID string) error
}

type SnapshotService interface {
    Create(ctx, guildID, trigger SnapshotTrigger, opts) (*Snapshot, error)
}

type RestoreService interface {
    Start(ctx, input) (*RestoreJob, error)  // Enterprise auto-restore (옵션)
}

type TemporalClient interface {
    StartAntiNukeWorkflow(ctx, guildID) (workflowID, error)
    SignalWorkflow(ctx, workflowID, signal, payload) error
    CancelWorkflow(ctx, workflowID, reason) error
}

type GuildConfigReader interface {
    GetConfig(ctx, guildID) (*GuildConfig, error)  // antinuke_enabled, thresholds 등
}

type LicensingReader interface {
    Can(ctx, guildID, feature) (bool, error)
    GetPlanCode(ctx, guildID) (PlanCode, error)
}

type EventPublisher interface {
    Publish(ctx, event DomainEvent) error
}

Adapters

  • Persistenceengine/recovery/subdomain/antinuke/adapter/persistence/
  • Discordengine/recovery/subdomain/antinuke/adapter/discord/ — audit log fetch + role revoke
  • Temporalengine/recovery/subdomain/antinuke/adapter/temporal/ — workflow/activities
  • Eventengine/recovery/adapter/event/

Permission model

  • 감지/알림 — Pro 이상 (Feature ANTINUKE_DETECT)
  • 자동 대응 — Enterprise (Feature ANTINUKE_AUTO_ACTION), opt-in 필요 (guild_configs.antinuke_auto_action)
  • 설정 변경 — Guild MANAGE_GUILD 권한
  • Incident 조회 — 대시보드 (Pro 이상)
  • False positive 마킹 — Guild MANAGE_GUILD

Config customization

Pro 는 기본 임계값 사용. Enterprise 는 커스터마이징:

{
  "mass_role_delete": { "count": 3, "window_seconds": 180 },
  "mass_channel_delete": { "count": 2, "window_seconds": 180 },
  "mass_kick": { "count": 5, "window_seconds": 180 },
  "permission_escalation": { "count": 1, "window_seconds": 0 }
}

Enterprise 커스텀 설정은 guild.guild_configs.antinuke_thresholds JSONB 에 저장. 변경 시 workflow 에 config_update signal 전송.

False positive handling

  • 사용자가 대시보드에서 incident 조회 시 "이건 정상 운영이었습니다" 마킹 가능
  • false_positive = true 설정 + note 기록
  • 통계: 오탐률 계산으로 임계값 튜닝 근거
  • Phase 2: false positive 누적 시 해당 길드의 임계값 자동 조정 제안

Auto-action safeguards (Enterprise)

자동 권한 박탈은 잘못하면 Owner 신뢰 타격 → 안전장치:

  • Owner 면책 — Owner 의 권한은 절대 박탈 안 함
  • Confidence thresholdsuspect.Confidence > 0.8 만 대상
  • Action log — 모든 자동 대응은 actions_taken 에 상세 기록 + Discord DM 으로 Owner 에게 즉시 알림
  • Reversibility — 박탈된 역할은 DM 의 "취소" 버튼으로 즉시 복원 가능 (10분 TTL)
  • Manual opt-inguild_configs.antinuke_auto_action = true 는 Owner 가 대시보드에서 명시 체크해야

Failure modes

  • Audit log fetch 실패 — Suspect 추정 없이 알림만 발송 (suspects = [])
  • Snapshot 생성 실패 — 로그 남기고 알림은 계속 (Owner 가 모르는 게 더 위험)
  • Role revoke 실패 (Enterprise) — 실패한 action 은 actions_taken.failed_revokes 에 기록, Owner 알림
  • Workflow 프로세스 장애 — Temporal 이 재시작 시 이어서 실행. rolling window 는 메모리 state 라 재시작 시 손실되지만 이벤트 누수는 signal 재전송으로 복원되지 않음 (이 한계는 감수 — 중요 패턴은 공격 지속 시 다시 감지됨)
  • Enterprise 오탐 자동 박탈 — 사용자 피드백으로 false_positive 마킹 + Action log 에서 reverse
  • License downgrade 중 workflow — config_update signal 로 즉시 disable

See also

  • domain/recovery/overview.md — Recovery 전체
  • domain/recovery/snapshot.md — 긴급 스냅샷
  • domain/recovery/restore.md — auto-action (Enterprise)
  • flows/antinuke-trigger.md — 감지 시나리오
  • data/recovery-schema.md — DB 스키마
  • adr/0014-recovery-temporal-workflow.md — Temporal 채택
  • adr/0026-antinuke-mvp-inclusion.md — MVP 포함 결정
  • adr/0027-message-content-exclusion.md — 메시지 없이 감지