From 9924e06b5ab6602288dc9a1be44c0d13c0928ee3 Mon Sep 17 00:00:00 2001 From: waffle2k Date: Sun, 5 Jul 2026 10:22:39 -0700 Subject: [PATCH] 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. --- .env.example | 32 ++++++++ CLAUDE.md | 25 +++++- README.md | 65 +++++++++++++++ app/main.py | 107 +++++++++++++++++++++++- app/suspicious_sweep.py | 167 ++++++++++++++++++++++++++++++++++++++ bin/welcomebot-suspicious | 93 +++++++++++++++++++++ test_local.py | 152 ++++++++++++++++++++++++++++++++++ 7 files changed, 637 insertions(+), 4 deletions(-) create mode 100644 app/suspicious_sweep.py create mode 100755 bin/welcomebot-suspicious diff --git a/.env.example b/.env.example index 3c396d3..d66a129 100644 --- a/.env.example +++ b/.env.example @@ -145,3 +145,35 @@ CHECK_MAIL_HOLD_WELCOME=true # 403s and is logged as an error, but nothing else in the bot is affected. # Also gates the report-triggered suspend+block override in handle_report(). CHECK_MAIL_AUTO_DOMAIN_BLOCK=true + +# --- Suspicious-signup sweep (app/suspicious_sweep.py, run on a schedule) --- +# Watches every account flagged by EITHER IP-scrutiny or email-domain +# scrutiny at signup. Once the account goes live (account.created if open/ +# auto-approved, account.approved if this instance requires moderator +# approval — same dual handling the welcome flow already uses), a baseline +# post/follow count is recorded. A scheduled sweep (cron on admin.yttrx.com, +# `docker exec yttrx-welcomebot python -m app.suspicious_sweep`) then acts on +# any watch past SUSPICIOUS_GRACE_HOURS with zero new posts AND zero new +# follows since that baseline. Master switch: +SUSPICIOUS_SWEEP_ENABLED=true + +# 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=24 + +# Moderation action taken on a flagged signup with no activity in the grace +# window: "suspend" (agreed default — a stronger signal than the report- +# driven abuse-bot's default "silence", since this is zero organic activity, +# not just reports) or "silence". +SUSPICIOUS_ACTION=suspend + +# Rollout safety switch, same role as ABUSE_DRY_RUN — ships "false" (live) +# here by design; flip to "true" to pause without redeploying. +SUSPICIOUS_DRY_RUN=false + +# Requires ABUSE_BOT_TOKEN to additionally carry the admin:read:accounts +# scope (fetches current post/follow counts + role/suspended state via +# GET /api/v1/admin/accounts/:id) — see CLAUDE.md's moderator-token-gotcha +# section. Without it the sweep 403s per-account (logged as an error, that +# account is retried next sweep) but nothing else in the bot is affected. +# Reuses ABUSE_SKIP_PRIVILEGED and ABUSE_ALLOWLIST above — no separate vars. diff --git a/CLAUDE.md b/CLAUDE.md index d33ff2f..5d780f3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,11 +12,18 @@ A FastAPI webhook server. One Mastodon admin webhook delivers `account.created`, (datacenter/hosting) signups get more scrutiny (held welcome, ip_blocks registration, DM to moderator) before falling through to a normal welcome. - **account.approved** → always DM the welcome (dedup-safe; this is what - releases a held welcome once a human clears the approval queue). + releases a held welcome once a human clears the approval queue). Also where + a held, flagged signup's suspicious-watch baseline gets captured. - **report.created** → classify the reported account, count distinct reporters, and auto-**silence** young/dormant accounts past the threshold — lowered for accounts with a flagged signup IP (see README). +A separate scheduled module, `app/suspicious_sweep.py` (run via +`python -m app.suspicious_sweep`, not part of the webhook server), suspends +any IP- or email-flagged signup that shows zero new posts and zero new +follows within `SUSPICIOUS_GRACE_HOURS` of going live. See README.md's +"Suspicious-signup sweep" section for the full policy. + | Where | What | |---|---| | Workstation | Source of truth: `~/yttrx-welcomebot/` (this repo) | @@ -170,12 +177,18 @@ as an error; nothing else in the bot is affected). report-triggered suspend override) will 403 (logged as an error; the suspend itself still goes through since that only needs `admin:write:accounts`). +**The suspicious-signup sweep (`app/suspicious_sweep.py`) needs another scope, +`admin:read:accounts`**, to `GET /api/v1/admin/accounts/:id` (fetches current +post/follow counts + role/suspended state to decide whether to suspend) — +without it every sweep run 403s per-account (logged as an error, that account +is simply retried next run; nothing else in the bot is affected). + Mint a correctly-scoped token from the Rails console on mammut: ```bash ssh mammut 'docker exec $(docker ps -f name=live-web-1 -q) bin/rails runner " acct = Account.find_local(%q{bot}) -scopes = %q{read:statuses write:statuses admin:read:reports admin:write:accounts admin:write:ip_blocks admin:write:email_domain_blocks} +scopes = %q{read:statuses write:statuses admin:read:reports admin:write:accounts admin:write:ip_blocks admin:write:email_domain_blocks admin:read:accounts} app = Doorkeeper::Application.create!(name: %q{Abuse handler vN}, scopes: scopes, redirect_uri: %q{urn:ietf:wg:oauth:2.0:oob}) tok = Doorkeeper::AccessToken.create!(application_id: app.id, resource_owner_id: acct.user.id, scopes: scopes) puts tok.token @@ -213,6 +226,14 @@ curl -s -o /dev/null -w "%{http_code}\n" -X POST \ https://yttrx.com/api/v1/admin/email_domain_blocks ``` +And for `admin:read:accounts` (`403` = missing scope; `200`/`404` = authorized): + +```bash +curl -s -o /dev/null -w "%{http_code}\n" \ + -H "Authorization: Bearer $ABUSE_BOT_TOKEN" \ + https://yttrx.com/api/v1/admin/accounts/0 +``` + ## Mastodon side (mammut) - The webhook (Administration → Webhooks) is already subscribed to diff --git a/README.md b/README.md index 1eb6268..5da1b1b 100644 --- a/README.md +++ b/README.md @@ -124,14 +124,57 @@ Registering email_domain_blocks requires the `ABUSE_BOT_TOKEN` to carry the `admin:write:email_domain_blocks` scope in addition to its existing scopes (see `CLAUDE.md`). +## Suspicious-signup sweep + +A scheduled companion to the two signup-scrutiny signals above (`app/suspicious_sweep.py`, +run via `docker exec yttrx-welcomebot python -m app.suspicious_sweep`, e.g. hourly cron on +`admin.yttrx.com`): + +1. Any account flagged by **either** IP-scrutiny (datacenter/hosting) or + email-domain scrutiny (disposable/high-risk) has a grace-period clock + started the moment it goes live — `account.created` if signups are open + (or auto-approved), `account.approved` if this instance requires + moderator approval. Same dual-event handling the welcome flow already + uses, so it works in either registration mode without extra config. A + baseline snapshot of `statuses_count`/`following_count` is recorded at + that moment (`app.main.maybe_start_suspicious_watch`, table + `suspicious_watch`). +2. The sweep looks for watches past `SUSPICIOUS_GRACE_HOURS` (default 24) + and compares current counts to the baseline: + - **Any new post or new follow** since the baseline → cleared, never + rechecked. + - **Zero of either** → `SUSPICIOUS_ACTION` (default `suspend`) is applied, + the moderator is DMed, and the watch is marked resolved. +3. Already-suspended/silenced accounts, staff (`ABUSE_SKIP_PRIVILEGED`), and + `ABUSE_ALLOWLIST` handles are skipped exactly as elsewhere in this bot — + the sweep reuses those same settings rather than defining its own. + +Unlike the report-driven abuse-bot (default `silence`), this sweep defaults +to **`suspend`** — the reasoning is that zero organic activity in a full day +is a stronger bulk/bot-signup signal than a report alone, and Mastodon +suspensions have a server-side undo window. It also ships **live** +(`SUSPICIOUS_DRY_RUN=false`) rather than with the usual dry-run rollout step; +`SUSPICIOUS_DRY_RUN=true` remains available as a kill switch if you want to +pause it without redeploying. + +Requires `ABUSE_BOT_TOKEN` to additionally carry the **`admin:read:accounts`** +scope (fetches live counts + role/suspended state via +`GET /api/v1/admin/accounts/:id`) — see `CLAUDE.md`'s moderator-token-gotcha +section for the remint procedure. + +Inspect the watch list: `welcomebot-suspicious` / `welcomebot-suspicious --pending` +(see `bin/welcomebot-suspicious`). + ## Layout | Path | Purpose | |---|---| | `app/main.py` | The FastAPI app | +| `app/suspicious_sweep.py` | Scheduled sweep: suspends flagged signups with no activity (run via cron, not the webserver) | | `Dockerfile`, `docker-compose.yml` | Container build + run | | `.env.example` | Config template (copy to `.env`, never commit) | | `nginx/hooks.yttrx.com.conf` | nginx site for admin.yttrx.com | +| `bin/welcomebot-logs`, `bin/welcomebot-signups`, `bin/welcomebot-suspicious` | Ops CLIs installed to `/root/bin` on admin.yttrx.com | | `test_local.py` | Offline smoke test (no network) | ## Configuration @@ -172,6 +215,10 @@ Copy `.env.example` to `.env` and fill in: | `CHECK_MAIL_RISK_THRESHOLD` | Flag if `is_disposable` or `risk` ≥ this (default 80) | | `CHECK_MAIL_HOLD_WELCOME` | `true` — hold the welcome for a flagged signup until `account.approved` | | `CHECK_MAIL_AUTO_DOMAIN_BLOCK` | Auto-register a flagged domain into Mastodon's `Admin::EmailDomainBlock`; also gates the report-triggered override | +| `SUSPICIOUS_SWEEP_ENABLED` | Master switch for the suspicious-signup sweep (`app/suspicious_sweep.py`) | +| `SUSPICIOUS_GRACE_HOURS` | Hours a flagged signup has to post or follow someone before it's swept (default 24) | +| `SUSPICIOUS_ACTION` | `suspend` (default) or `silence`, applied to a flagged signup with zero activity in the grace window | +| `SUSPICIOUS_DRY_RUN` | `false` (ships live by design) — set `true` to log/DM only, no real action | ## Local test @@ -272,11 +319,29 @@ Register a throwaway test account on yttrx and confirm the `@welcome` bot DMs it. Then delete the test account (`tootctl accounts delete` on mammut) and the test toot. +### 6. Cron for the suspicious-signup sweep (on admin.yttrx.com) + +The sweep is not part of the always-running webhook server — it's invoked +on a schedule via `docker exec`. `ABUSE_BOT_TOKEN` must additionally carry +the `admin:read:accounts` scope (remint via the Rails-console snippet in +`CLAUDE.md`, same procedure as the other scope additions) before installing +this cron entry, or every sweep run 403s per-account: + +```bash +crontab -l | { cat; echo '17 * * * * docker exec yttrx-welcomebot python -m app.suspicious_sweep >> /var/log/welcomebot-suspicious-sweep.log 2>&1'; } | crontab - +``` + +Hourly (rather than daily like the dormant-sweep) since the grace window is +only `SUSPICIOUS_GRACE_HOURS` (default 24) — an hourly cadence keeps the gap +between crossing the deadline and being swept small. + ## Operations ```bash welcomebot-logs -f --abuse # convenience CLI on admin (see bin/welcomebot-logs) welcomebot-signups --flagged # signup-IP classification history (see bin/welcomebot-signups) +welcomebot-suspicious --pending # suspicious-sweep watch list (see bin/welcomebot-suspicious) +docker exec yttrx-welcomebot python -m app.suspicious_sweep # run the sweep manually docker compose logs -f welcomebot # watch deliveries docker compose restart welcomebot # after editing .env docker compose down && docker compose up -d --build # redeploy after code change diff --git a/app/main.py b/app/main.py index 58c1ca6..2273e78 100644 --- a/app/main.py +++ b/app/main.py @@ -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} diff --git a/app/suspicious_sweep.py b/app/suspicious_sweep.py new file mode 100644 index 0000000..d2a4fa9 --- /dev/null +++ b/app/suspicious_sweep.py @@ -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() diff --git a/bin/welcomebot-suspicious b/bin/welcomebot-suspicious new file mode 100755 index 0000000..0440f6d --- /dev/null +++ b/bin/welcomebot-suspicious @@ -0,0 +1,93 @@ +#!/usr/bin/env bash +# welcomebot-suspicious — inspect the suspicious-sweep watch list for yttrx-welcomebot. +# +# Installed at /usr/local/bin/welcomebot-suspicious on admin.yttrx.com. +# Source of truth: ~/yttrx-welcomebot/bin/welcomebot-suspicious (workstation). +# Reads the persistent `suspicious_watch` sqlite table — see app/main.py's +# maybe_start_suspicious_watch / app/suspicious_sweep.py. +set -euo pipefail + +CONTAINER="${WELCOMEBOT_CONTAINER:-yttrx-welcomebot}" +limit=20 +pending_only=0 +acct_filter="" + +usage() { + cat <&2; usage >&2; exit 2 ;; + esac + shift +done + +if ! command -v docker >/dev/null 2>&1; then + echo "welcomebot-suspicious: docker not found in PATH" >&2; exit 1 +fi +if ! docker inspect "$CONTAINER" >/dev/null 2>&1; then + echo "welcomebot-suspicious: container '$CONTAINER' not found (is it deployed/running?)" >&2; exit 1 +fi + +docker exec -i \ + -e SUS_LIMIT="$limit" \ + -e SUS_PENDING_ONLY="$pending_only" \ + -e SUS_ACCT="$acct_filter" \ + "$CONTAINER" python3 <<'PYEOF' +import os +import sqlite3 + +conn = sqlite3.connect("/data/welcomed.db") +where, params = [], [] +if os.environ.get("SUS_PENDING_ONLY") == "1": + where.append("status = 'pending'") +if os.environ.get("SUS_ACCT"): + where.append("acct = ?") + params.append(os.environ["SUS_ACCT"]) + +sql = ("SELECT acct, reasons, baseline_statuses_count, baseline_following_count, " + "status, started_at, resolved_at FROM suspicious_watch") +if where: + sql += " WHERE " + " AND ".join(where) +sql += " ORDER BY started_at DESC" + +limit = os.environ.get("SUS_LIMIT", "20") +if limit != "all": + sql += f" LIMIT {int(limit)}" + +rows = conn.execute(sql, params).fetchall() +if not rows: + print("(no matching suspicious-watch records)") +for acct, reasons, base_statuses, base_following, status, started_at, resolved_at in rows: + acct = acct or "" + reasons = reasons or "" + baseline = f"posts={base_statuses} following={base_following}" + resolved = resolved_at or "-" + print(f"{started_at} {acct:20s} {reasons:10s} {baseline:24s} {status:20s} resolved={resolved}") +PYEOF diff --git a/test_local.py b/test_local.py index 6a24997..7e89358 100644 --- a/test_local.py +++ b/test_local.py @@ -225,6 +225,9 @@ def ip_scrutiny_tests(): main.send_welcome = lambda account_id, acct: sent.append((account_id, acct)) main.dm_moderator = lambda message: dms.append(message) main.register_ip_block = lambda ip, acct, org: ipblocks.append((ip, acct, org)) + # A flagged-but-not-held signup starts the suspicious watch inline, which + # would otherwise hit the network for a baseline snapshot — stub it. + main.fetch_account_counts = lambda account_id: (0, 0) def classify(ip): return classifications[ip] @@ -302,6 +305,9 @@ def email_scrutiny_tests(): main.send_welcome = lambda account_id, acct: sent.append((account_id, acct)) main.dm_moderator = lambda message: dms.append(message) main.register_email_domain_block = lambda domain, acct: domain_blocks.append((domain, acct)) + # A flagged-but-not-held signup starts the suspicious watch inline, which + # would otherwise hit the network for a baseline snapshot — stub it. + main.fetch_account_counts = lambda account_id: (0, 0) classifications = { "gmail.com": (False, 5), @@ -387,9 +393,155 @@ def email_scrutiny_tests(): assert domain_blocks == [], domain_blocks +def suspicious_watch_tests(): + """Drive maybe_start_suspicious_watch: baseline capture on flagged + signups only, never on clean ones, and never twice for the same account.""" + main.SUSPICIOUS_SWEEP_ENABLED = True + counts = {"301": (2, 5), "302": (0, 0)} + main.fetch_account_counts = lambda account_id: counts[account_id] + + # A. flagged by IP only -> watch started with that baseline. + main.record_signup_ip("301", "watched1", "198.51.100.30", "datacenter", "Cloud Inc", True) + main.maybe_start_suspicious_watch("301", "watched1") + with main._db() as conn: + row = conn.execute( + "SELECT reasons, baseline_statuses_count, baseline_following_count, status " + "FROM suspicious_watch WHERE account_id = ?", ("301",), + ).fetchone() + assert row == ("ip", 2, 5, "pending"), row + + # B. calling again (e.g. account.created then account.approved both + # firing) does not reset/duplicate the baseline. + counts["301"] = (99, 99) + main.maybe_start_suspicious_watch("301", "watched1") + with main._db() as conn: + row = conn.execute( + "SELECT baseline_statuses_count FROM suspicious_watch WHERE account_id = ?", ("301",), + ).fetchone() + assert row == (2,), "re-calling must not overwrite the original baseline" + + # C. never flagged -> no watch row at all. + main.maybe_start_suspicious_watch("302", "clean3") + with main._db() as conn: + row = conn.execute( + "SELECT 1 FROM suspicious_watch WHERE account_id = ?", ("302",), + ).fetchone() + assert row is None, "an unflagged signup must never be watched" + + +def suspicious_sweep_tests(): + """Drive app.suspicious_sweep.sweep() directly against rows seeded into + the shared sqlite store, with the Mastodon admin-account fetch and + apply_action/dm_moderator stubbed out (no network).""" + import app.suspicious_sweep as sweep_mod + + sweep_mod.SUSPICIOUS_SWEEP_ENABLED = True + sweep_mod.SUSPICIOUS_GRACE_HOURS = 24 + sweep_mod.SUSPICIOUS_ACTION = "suspend" + sweep_mod.SUSPICIOUS_DRY_RUN = False + sweep_mod.ABUSE_SKIP_PRIVILEGED = True + sweep_mod.ABUSE_ALLOWLIST = {"trustedstaff"} + + def seed(account_id, acct, reasons, baseline_statuses, baseline_following, hours_ago): + started_at = (datetime.now(timezone.utc) - timedelta(hours=hours_ago)).isoformat() + with main._db() as conn: + conn.execute( + "INSERT INTO suspicious_watch " + "(account_id, acct, reasons, started_at, baseline_statuses_count, " + " baseline_following_count) VALUES (?, ?, ?, ?, ?, ?)", + (account_id, acct, reasons, started_at, baseline_statuses, baseline_following), + ) + + def status_of(account_id): + with main._db() as conn: + row = conn.execute( + "SELECT status FROM suspicious_watch WHERE account_id = ?", (account_id,), + ).fetchone() + return row[0] if row else None + + admin_accounts = {} + sweep_mod._fetch_admin_account = lambda account_id: admin_accounts[account_id] + + actions = [] + dms = [] + sweep_mod.apply_action = lambda target_id, action, text: actions.append((target_id, action)) + sweep_mod.dm_moderator = lambda message: dms.append(message) + + def public(statuses_count=0, following_count=0, **extra): + acct = {"statuses_count": statuses_count, "following_count": following_count} + return {"account": acct, **extra} + + # A. not yet due (started 1h ago, 24h grace) -> untouched, still pending. + seed("401", "toosoon", "ip", 0, 0, hours_ago=1) + admin_accounts["401"] = public(0, 0) + + # B. due, zero new posts/follows since baseline -> suspended. + seed("402", "dormantbot", "ip", 0, 0, hours_ago=25) + admin_accounts["402"] = public(0, 0) + + # C. due, but posted since baseline -> cleared, not suspended. + seed("403", "postedsince", "email", 3, 1, hours_ago=25) + admin_accounts["403"] = public(4, 1) + + # D. due, but followed someone since baseline -> cleared, not suspended. + seed("404", "followedsince", "ip,email", 0, 0, hours_ago=25) + admin_accounts["404"] = public(0, 1) + + # E. due, but already suspended/silenced by another mechanism -> skipped. + seed("405", "alreadylimited", "ip", 0, 0, hours_ago=25) + admin_accounts["405"] = public(0, 0, suspended=True) + + # F. due, but holds a staff role -> skipped, never suspended. + seed("406", "staffer", "ip", 0, 0, hours_ago=25) + admin_accounts["406"] = public(0, 0, role={"id": 3, "name": "Owner"}) + + # G. due, allowlisted by handle -> skipped. + seed("407", "trustedstaff", "ip", 0, 0, hours_ago=25) + admin_accounts["407"] = public(0, 0) + + sweep_mod.sweep() + + assert status_of("401") == "pending", "not-yet-due watch must be left alone" + assert ("402", "suspend") in actions, actions + assert status_of("402") == "suspend" + assert any("dormantbot" in d for d in dms), dms + + assert ("403", "suspend") not in actions, actions + assert status_of("403") == "cleared" + + assert ("404", "suspend") not in actions, actions + assert status_of("404") == "cleared" + + assert ("405", "suspend") not in actions, actions + assert status_of("405") == "skipped-already-limited" + + assert ("406", "suspend") not in actions, actions + assert status_of("406") == "skipped-privileged" + + assert ("407", "suspend") not in actions, actions + assert status_of("407") == "skipped-privileged" + + # H. re-running the sweep must not re-suspend or re-DM an already-resolved row. + before_actions, before_dms = len(actions), len(dms) + sweep_mod.sweep() + assert len(actions) == before_actions, "resolved rows must not be re-actioned" + assert len(dms) == before_dms, "resolved rows must not be re-DMed" + + # I. dry-run mode logs/DMs but takes no real action. + seed("408", "dryrunbot", "ip", 0, 0, hours_ago=25) + admin_accounts["408"] = public(0, 0) + sweep_mod.SUSPICIOUS_DRY_RUN = True + sweep_mod.sweep() + assert ("408", "suspend") not in actions, "dry-run must not call apply_action" + assert status_of("408") == "dry-run:suspend" + assert any("[DRY-RUN]" in d and "dryrunbot" in d for d in dms), dms + + if __name__ == "__main__": routing_tests() policy_tests() ip_scrutiny_tests() email_scrutiny_tests() + suspicious_watch_tests() + suspicious_sweep_tests() print("ALL TESTS PASSED")