Add suspicious-signup sweep: suspend flagged signups with no activity
Watches every account flagged by IP-scrutiny or email-domain scrutiny at signup and, once it goes live, records a baseline post/follow count. A scheduled sweep (app/suspicious_sweep.py, run via cron on admin.yttrx.com) suspends any watch past SUSPICIOUS_GRACE_HOURS with zero new posts and zero new follows since that baseline; any activity clears the watch. Works whether yttrx is open-registration or requires moderator approval, since the watch starts at whichever event actually makes the account live (account.created vs account.approved), same dual handling the welcome flow already uses. Needs ABUSE_BOT_TOKEN re-minted with admin:read:accounts.
This commit is contained in:
+105
-2
@@ -170,6 +170,29 @@ CHECK_MAIL_HOLD_WELCOME = os.environ.get("CHECK_MAIL_HOLD_WELCOME", "true").lowe
|
||||
# Admin::EmailDomainBlock (blocks future signups from that domain).
|
||||
CHECK_MAIL_AUTO_DOMAIN_BLOCK = os.environ.get("CHECK_MAIL_AUTO_DOMAIN_BLOCK", "true").lower() in ("1", "true", "yes")
|
||||
|
||||
# --- Suspicious-signup sweep (flagged at signup, no activity in grace window) ---
|
||||
# Watches every account flagged by EITHER IP-scrutiny or email-domain scrutiny
|
||||
# at signup. Once the account goes live — account.created if signups are open
|
||||
# (or an already-approved account), account.approved if this instance requires
|
||||
# moderator approval, same dual event handling the welcome flow already uses —
|
||||
# a baseline post/follow count is recorded (see maybe_start_suspicious_watch).
|
||||
# A separate scheduled job (bin/welcomebot-suspicious sweep, see CLAUDE.md) run
|
||||
# via `python -m app.suspicious_sweep` looks for accounts past
|
||||
# SUSPICIOUS_GRACE_HOURS with zero new posts AND zero new follows since that
|
||||
# baseline, and takes SUSPICIOUS_ACTION on them.
|
||||
SUSPICIOUS_SWEEP_ENABLED = os.environ.get("SUSPICIOUS_SWEEP_ENABLED", "true").lower() in ("1", "true", "yes")
|
||||
# Hours of grace given to a flagged signup to show organic activity (a new
|
||||
# post or a new follow) before it's considered dormant/bot-like.
|
||||
SUSPICIOUS_GRACE_HOURS = int(os.environ.get("SUSPICIOUS_GRACE_HOURS", "24"))
|
||||
# "suspend" (agreed default — a flagged signup with zero organic activity in
|
||||
# the grace window is a strong bulk/bot-signup signal, stronger than the
|
||||
# report-driven abuse-bot's default "silence") or "silence".
|
||||
SUSPICIOUS_ACTION = os.environ.get("SUSPICIOUS_ACTION", "suspend").lower()
|
||||
# Rollout safety switch, same role as ABUSE_DRY_RUN — ships "false" (live)
|
||||
# here per an explicit decision to skip the usual dry-run rollout step for
|
||||
# this feature. Flip to "true" to pause without redeploying.
|
||||
SUSPICIOUS_DRY_RUN = os.environ.get("SUSPICIOUS_DRY_RUN", "false").lower() in ("1", "true", "yes")
|
||||
|
||||
if not WEBHOOK_SECRET:
|
||||
log.warning("WEBHOOK_SECRET is empty — signature verification will reject all requests.")
|
||||
if not BOT_ACCESS_TOKEN:
|
||||
@@ -248,6 +271,18 @@ def _init_db() -> None:
|
||||
" cached_at TEXT DEFAULT CURRENT_TIMESTAMP"
|
||||
")"
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS suspicious_watch ("
|
||||
" account_id TEXT PRIMARY KEY,"
|
||||
" acct TEXT,"
|
||||
" reasons TEXT,"
|
||||
" started_at TEXT DEFAULT CURRENT_TIMESTAMP,"
|
||||
" baseline_statuses_count INTEGER,"
|
||||
" baseline_following_count INTEGER,"
|
||||
" status TEXT DEFAULT 'pending',"
|
||||
" resolved_at TEXT"
|
||||
")"
|
||||
)
|
||||
|
||||
|
||||
@contextmanager
|
||||
@@ -387,6 +422,25 @@ def cache_check_mail(domain: str, is_disposable: bool, risk: int) -> None:
|
||||
)
|
||||
|
||||
|
||||
def already_watched_suspicious(account_id: str) -> bool:
|
||||
with _db() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT 1 FROM suspicious_watch WHERE account_id = ?", (account_id,)
|
||||
).fetchone()
|
||||
return row is not None
|
||||
|
||||
|
||||
def start_suspicious_watch(account_id: str, acct: str, reasons: str,
|
||||
statuses_count: int, following_count: int) -> None:
|
||||
with _db() as conn:
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO suspicious_watch "
|
||||
"(account_id, acct, reasons, baseline_statuses_count, baseline_following_count) "
|
||||
"VALUES (?, ?, ?, ?, ?)",
|
||||
(account_id, acct, reasons, statuses_count, following_count),
|
||||
)
|
||||
|
||||
|
||||
_init_db()
|
||||
|
||||
# --- Signature verification ------------------------------------------------
|
||||
@@ -542,7 +596,51 @@ def process_signup(account_id: str, acct: str, ip: str, email: str = "") -> None
|
||||
|
||||
if not hold:
|
||||
send_welcome(account_id, acct)
|
||||
# else: held — sent later if/when account.approved fires (human review).
|
||||
maybe_start_suspicious_watch(account_id, acct)
|
||||
# else: held — sent later if/when account.approved fires (human review),
|
||||
# which is also where maybe_start_suspicious_watch gets called.
|
||||
|
||||
|
||||
def maybe_start_suspicious_watch(account_id: str, acct: str) -> None:
|
||||
"""Start the suspicious-sweep grace-period clock for a flagged signup.
|
||||
|
||||
Called at the moment the account actually goes live — immediately in
|
||||
process_signup() when the welcome isn't held, or from the
|
||||
account.approved handler once a human clears the approval queue for a
|
||||
held one. Either path is a no-op if the account was never flagged (by
|
||||
IP-scrutiny or email-domain scrutiny) or is already being watched, so
|
||||
it's safe to call from both places unconditionally.
|
||||
"""
|
||||
if not SUSPICIOUS_SWEEP_ENABLED or already_watched_suspicious(account_id):
|
||||
return
|
||||
|
||||
ip_flagged = get_signup_flag(account_id)
|
||||
_, email_flagged = get_signup_email(account_id)
|
||||
if not (ip_flagged or email_flagged):
|
||||
return
|
||||
|
||||
reasons = ",".join(
|
||||
r for r, flagged in (("ip", ip_flagged), ("email", email_flagged)) if flagged
|
||||
)
|
||||
try:
|
||||
statuses_count, following_count = fetch_account_counts(account_id)
|
||||
except httpx.HTTPError as exc:
|
||||
log.error("failed to snapshot suspicious-watch baseline for acct=%s: %s", acct, exc)
|
||||
return
|
||||
|
||||
start_suspicious_watch(account_id, acct, reasons, statuses_count, following_count)
|
||||
log.info("started suspicious watch acct=%s reasons=%s baseline=(statuses=%d, following=%d)",
|
||||
acct, reasons, statuses_count, following_count)
|
||||
|
||||
|
||||
def fetch_account_counts(account_id: str) -> tuple[int, int]:
|
||||
"""Return (statuses_count, following_count) for an account via the public
|
||||
account API. Split out from maybe_start_suspicious_watch as a single seam
|
||||
to stub in tests."""
|
||||
resp = httpx.get(f"{MASTODON_BASE_URL}/api/v1/accounts/{account_id}", timeout=15.0)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
return data.get("statuses_count", 0), data.get("following_count", 0)
|
||||
|
||||
|
||||
# --- Mastodon API (abuse) --------------------------------------------------
|
||||
@@ -945,8 +1043,13 @@ def _handle_account_created(obj: dict, background: BackgroundTasks, event: str):
|
||||
else:
|
||||
# account.approved: a human has cleared the approval-required
|
||||
# registration gate. Always welcome (dedup makes this safe) — this is
|
||||
# what lifts a held welcome for a previously-flagged signup.
|
||||
# what lifts a held welcome for a previously-flagged signup. Also the
|
||||
# moment a held, flagged signup's suspicious-watch clock should start;
|
||||
# a no-op if it was never flagged or already started (open-signup
|
||||
# case, where account.approved may fire right alongside
|
||||
# account.created).
|
||||
background.add_task(send_welcome, account_id, acct)
|
||||
background.add_task(maybe_start_suspicious_watch, account_id, acct)
|
||||
return {"ok": True, "queued": acct}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
"""Suspicious-signup sweep.
|
||||
|
||||
Suspends (SUSPICIOUS_ACTION) accounts that were flagged at signup by either
|
||||
IP-scrutiny (datacenter/hosting IP) or email-domain scrutiny (disposable/
|
||||
high-risk domain) and show zero new posts AND zero new follows within
|
||||
SUSPICIOUS_GRACE_HOURS of going live.
|
||||
|
||||
Only acts on accounts app.main.maybe_start_suspicious_watch already recorded
|
||||
in the `suspicious_watch` table — this module doesn't touch signup
|
||||
classification itself, just resolves the grace-period clock those signals
|
||||
started.
|
||||
|
||||
Run on a schedule (cron on admin.yttrx.com, see CLAUDE.md):
|
||||
|
||||
docker exec yttrx-welcomebot python -m app.suspicious_sweep
|
||||
|
||||
Requires ABUSE_BOT_TOKEN to carry the admin:read:accounts scope in addition
|
||||
to its existing scopes (see CLAUDE.md's moderator-token-gotcha section) —
|
||||
without it, fetching current account state 403s and that account is skipped
|
||||
(logged as an error) until the token is re-minted.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import httpx
|
||||
|
||||
from app.main import (
|
||||
ABUSE_ALLOWLIST,
|
||||
MASTODON_BASE_URL,
|
||||
SUSPICIOUS_ACTION,
|
||||
SUSPICIOUS_DRY_RUN,
|
||||
SUSPICIOUS_GRACE_HOURS,
|
||||
SUSPICIOUS_SWEEP_ENABLED,
|
||||
ABUSE_SKIP_PRIVILEGED,
|
||||
_admin_headers,
|
||||
_db,
|
||||
apply_action,
|
||||
dm_moderator,
|
||||
)
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s %(levelname)s %(name)s %(message)s",
|
||||
)
|
||||
log = logging.getLogger("welcomebot.suspicious_sweep")
|
||||
|
||||
|
||||
def _pending_due() -> list[tuple]:
|
||||
cutoff = (datetime.now(timezone.utc) - timedelta(hours=SUSPICIOUS_GRACE_HOURS)).isoformat()
|
||||
with _db() as conn:
|
||||
return conn.execute(
|
||||
"SELECT account_id, acct, reasons, baseline_statuses_count, "
|
||||
" baseline_following_count "
|
||||
"FROM suspicious_watch WHERE status = 'pending' AND started_at <= ?",
|
||||
(cutoff,),
|
||||
).fetchall()
|
||||
|
||||
|
||||
def _resolve(account_id: str, status: str) -> None:
|
||||
with _db() as conn:
|
||||
conn.execute(
|
||||
"UPDATE suspicious_watch SET status = ?, resolved_at = CURRENT_TIMESTAMP "
|
||||
"WHERE account_id = ?",
|
||||
(status, account_id),
|
||||
)
|
||||
|
||||
|
||||
def _fetch_admin_account(account_id: str) -> dict | None:
|
||||
"""GET the Admin::Account entity (role, suspended/silenced, nested public
|
||||
account with live statuses_count/following_count). Returns None if the
|
||||
account no longer exists (e.g. deleted since being flagged)."""
|
||||
resp = httpx.get(
|
||||
f"{MASTODON_BASE_URL}/api/v1/admin/accounts/{account_id}",
|
||||
headers=_admin_headers(), timeout=15.0,
|
||||
)
|
||||
if resp.status_code == 404:
|
||||
return None
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
def _is_privileged(admin_acct: dict) -> bool:
|
||||
role = admin_acct.get("role") or {}
|
||||
try:
|
||||
role_id = int(role.get("id")) if role.get("id") is not None else None
|
||||
except (TypeError, ValueError):
|
||||
role_id = None
|
||||
return role_id is not None and role_id > 0
|
||||
|
||||
|
||||
def _is_allowlisted(acct: str) -> bool:
|
||||
return acct.split("@")[0].lower() in ABUSE_ALLOWLIST or acct.lower() in ABUSE_ALLOWLIST
|
||||
|
||||
|
||||
def sweep() -> None:
|
||||
if not SUSPICIOUS_SWEEP_ENABLED:
|
||||
log.info("SUSPICIOUS_SWEEP_ENABLED=false, nothing to do")
|
||||
return
|
||||
|
||||
due = _pending_due()
|
||||
log.info("%d flagged signup(s) past the %dh grace window", len(due), SUSPICIOUS_GRACE_HOURS)
|
||||
|
||||
for account_id, acct, reasons, baseline_statuses, baseline_following in due:
|
||||
try:
|
||||
admin_acct = _fetch_admin_account(account_id)
|
||||
except httpx.HTTPError as exc:
|
||||
log.error("acct=%s: failed to fetch admin account, will retry next sweep: %s", acct, exc)
|
||||
continue
|
||||
|
||||
if admin_acct is None:
|
||||
log.info("acct=%s no longer exists, clearing watch", acct)
|
||||
_resolve(account_id, "skipped-not-found")
|
||||
continue
|
||||
|
||||
if admin_acct.get("suspended") or admin_acct.get("silenced"):
|
||||
log.info("acct=%s already limited by another mechanism, clearing watch", acct)
|
||||
_resolve(account_id, "skipped-already-limited")
|
||||
continue
|
||||
|
||||
if (_is_privileged(admin_acct) and ABUSE_SKIP_PRIVILEGED) or _is_allowlisted(acct):
|
||||
log.info("acct=%s is staff/allowlisted, clearing watch", acct)
|
||||
_resolve(account_id, "skipped-privileged")
|
||||
continue
|
||||
|
||||
public = admin_acct.get("account") or {}
|
||||
statuses_count = public.get("statuses_count", 0)
|
||||
following_count = public.get("following_count", 0)
|
||||
|
||||
if statuses_count > baseline_statuses or following_count > baseline_following:
|
||||
log.info(
|
||||
"acct=%s showed activity since flagging (posts %d->%d, following %d->%d), clearing watch",
|
||||
acct, baseline_statuses, statuses_count, baseline_following, following_count,
|
||||
)
|
||||
_resolve(account_id, "cleared")
|
||||
continue
|
||||
|
||||
note = (
|
||||
f"Auto-{SUSPICIOUS_ACTION} by suspicious-sweep: flagged at signup ({reasons}), "
|
||||
f"no post or follow in {SUSPICIOUS_GRACE_HOURS}h since going live."
|
||||
)
|
||||
|
||||
if SUSPICIOUS_DRY_RUN:
|
||||
log.warning("[DRY-RUN] would %s acct=%s (reasons=%s, no activity in %dh)",
|
||||
SUSPICIOUS_ACTION, acct, reasons, SUSPICIOUS_GRACE_HOURS)
|
||||
_resolve(account_id, f"dry-run:{SUSPICIOUS_ACTION}")
|
||||
dm_moderator(f"[DRY-RUN] would {SUSPICIOUS_ACTION} @{acct} — flagged signup "
|
||||
f"({reasons}), no activity in {SUSPICIOUS_GRACE_HOURS}h.")
|
||||
continue
|
||||
|
||||
try:
|
||||
apply_action(account_id, SUSPICIOUS_ACTION, note)
|
||||
except httpx.HTTPError as exc:
|
||||
log.error("acct=%s: failed to %s, will retry next sweep: %s", acct, SUSPICIOUS_ACTION, exc)
|
||||
continue
|
||||
|
||||
_resolve(account_id, SUSPICIOUS_ACTION)
|
||||
log.warning("auto-%sd acct=%s — flagged signup (%s), no activity in %dh",
|
||||
SUSPICIOUS_ACTION, acct, reasons, SUSPICIOUS_GRACE_HOURS)
|
||||
dm_moderator(f"🚨 Auto-{SUSPICIOUS_ACTION}d @{acct} — flagged signup ({reasons}), "
|
||||
f"no post or follow in {SUSPICIOUS_GRACE_HOURS}h since going live.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sweep()
|
||||
Reference in New Issue
Block a user