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:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+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()
|
||||
Executable
+93
@@ -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 <<EOF
|
||||
Usage: welcomebot-suspicious [options]
|
||||
|
||||
Print suspicious-watch records (acct, flag reasons, baseline post/follow
|
||||
counts, status, timestamps) from the welcomebot's persistent sqlite store,
|
||||
newest first.
|
||||
|
||||
Options:
|
||||
-n, --limit N Show the last N rows (default: $limit; 'all' for everything).
|
||||
--pending Only rows still waiting out the grace period.
|
||||
-a, --acct ACCT Only rows for this acct (exact match).
|
||||
-h, --help Show this help.
|
||||
|
||||
Examples:
|
||||
welcomebot-suspicious # last $limit watch records
|
||||
welcomebot-suspicious --pending # still in the grace window
|
||||
welcomebot-suspicious -n all
|
||||
welcomebot-suspicious -a someuser
|
||||
|
||||
Run the sweep itself (normally invoked by cron, see CLAUDE.md):
|
||||
ssh admin.yttrx.com 'docker exec yttrx-welcomebot python -m app.suspicious_sweep'
|
||||
EOF
|
||||
}
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
-n|--limit) shift; limit="${1:?--limit needs a value}" ;;
|
||||
--pending) pending_only=1 ;;
|
||||
-a|--acct) shift; acct_filter="${1:?--acct needs a value}" ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) echo "welcomebot-suspicious: unknown option: $1" >&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
|
||||
+152
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user