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:
2026-07-05 10:22:39 -07:00
parent 656eec09c0
commit 9924e06b5a
7 changed files with 637 additions and 4 deletions
+105 -2
View File
@@ -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}