From 65c9bd5a9e807d6b11ef694c679f122d050206b4 Mon Sep 17 00:00:00 2001 From: waffle2k Date: Tue, 21 Jul 2026 22:14:39 -0700 Subject: [PATCH] Suspend immediately on strong IP-reputation signals (not vpn alone) datacenter/proxy/tor/abuser is a much cleaner bulk/bot-signup indicator than vpn, which also flags plenty of privacy-conscious real users. A strong-flagged signup now suspends immediately regardless of the email signal; vpn-only still gets the normal held-welcome/ipblock/lowered- threshold treatment, falling through to the existing combined-signal suspend only if also paired with a flagged email domain. --- .env.example | 17 ++++++++ README.md | 17 ++++++++ app/main.py | 109 ++++++++++++++++++++++++++++++++++++++------------ test_local.py | 104 +++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 221 insertions(+), 26 deletions(-) diff --git a/.env.example b/.env.example index 78b6720..e0acdf2 100644 --- a/.env.example +++ b/.env.example @@ -123,6 +123,23 @@ IP_SCRUTINY_AUTO_IPBLOCK=true # just signups). IP_SCRUTINY_IPBLOCK_SEVERITY=sign_up_block +# Strong IP-reputation signals — datacenter, proxy, tor, or an independently- +# scored abuser, but NOT vpn alone (which also flags plenty of privacy- +# conscious real users and is a weaker signal on its own). A signup flagged +# with any of these is suspended immediately at signup time, regardless of +# the email-domain signal, instead of just the held-welcome/auto-ipblock/ +# lowered-threshold treatment every other flagged signup gets. Master switch: +IP_SCRUTINY_STRONG_SUSPEND_ENABLED=true + +# Moderation action taken immediately on a strong IP signal: "suspend" +# (default) or "silence". +IP_SCRUTINY_STRONG_SUSPEND_ACTION=suspend + +# Rollout safety switch — ships "true" (dry-run) here, same reasoning as +# SUSPICIOUS_COMBINED_DRY_RUN, since this is a brand-new action path. Flip to +# "false" once the moderator DMs look right. +IP_SCRUTINY_STRONG_SUSPEND_DRY_RUN=true + # check-mail.org disposable/high-risk email domain scrutiny (roadmap item B, # anti-abuse.md). Domain-only query (never the full email) against # POST https://api.check-mail.org/v2/, Authorization: Bearer . diff --git a/README.md b/README.md index 90f958f..09dc8ae 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,20 @@ Every `account.created` delivery already carries the signup IP for free usual `ABUSE_SOURCES_*` distinct-reporter threshold is replaced by `IP_SCRUTINY_ABUSE_THRESHOLD` (whichever is lower), since a flagged signup IP plus a report is a stronger combined signal than either alone. +3. If the matched classification includes `datacenter`, `proxy`, `tor`, or + `abuser` — a *strong* signal, gated by `IP_SCRUTINY_STRONG_SUSPEND_ENABLED` + — the signup is **suspended immediately** at signup time instead of just + getting the held-welcome/ipblock/lowered-threshold treatment above, no + matter what the email-domain signal says. `vpn` alone is deliberately + excluded from this list (it also flags plenty of privacy-conscious real + users, and is a weaker bulk/bot-signup indicator on its own) — a vpn-only + flag still falls through to the normal path, though it's still caught by + the combined-signal suspend below if the email domain is *also* flagged. + `IP_SCRUTINY_STRONG_SUSPEND_ACTION` (default `suspend`) is the action + taken; `IP_SCRUTINY_STRONG_SUSPEND_DRY_RUN=true` (the shipped default) + logs + DMs what would happen without acting. Skips the held welcome and + suspicious-watch entry entirely, same shape as the combined-signal suspend + (§ below) — the account is already gone. `IP_SCRUTINY_DRY_RUN=true` (the shipped default) classifies and DMs a moderator without holding any welcome or writing any ip_block — keep it on @@ -221,6 +235,9 @@ Copy `.env.example` to `.env` and fill in: | `IP_SCRUTINY_ABUSE_THRESHOLD` | Distinct-reporter threshold used (if lower) for accounts with a flagged signup IP | | `IP_SCRUTINY_AUTO_IPBLOCK` | Auto-register a flagged signup's network (ipapi.is route, or its own `/32`/`/128` if no route) into Mastodon's `Admin::IpBlock` | | `IP_SCRUTINY_IPBLOCK_SEVERITY` | `sign_up_block` (default) — hard reject, no queue; other options: `sign_up_requires_approval` (soft, moderator queue), `no_access` | +| `IP_SCRUTINY_STRONG_SUSPEND_ENABLED` | Master switch — immediately suspend a signup whose IP is flagged `datacenter`/`proxy`/`tor`/`abuser` (not `vpn` alone), regardless of the email signal | +| `IP_SCRUTINY_STRONG_SUSPEND_ACTION` | `suspend` (default) or `silence`, applied immediately on a strong IP signal | +| `IP_SCRUTINY_STRONG_SUSPEND_DRY_RUN` | `true` (ships dry-run-first) — set `false` to act for real once the moderator DMs look right | | `CHECK_MAIL_ENABLED` | Master switch for disposable/high-risk email signup scrutiny | | `CHECK_MAIL_API_KEY` | check-mail.org API key; blank disables the check | | `CHECK_MAIL_DRY_RUN` | `true` — classify + DM only, no held welcome, no email_domain_block write, no report-triggered suspend | diff --git a/app/main.py b/app/main.py index 87f27df..b84936c 100644 --- a/app/main.py +++ b/app/main.py @@ -11,7 +11,9 @@ and dispatches by event: tor/abuser via ipapi.is) and email domain (disposable/high-risk via check-mail.org); a flagged signup gets more scrutiny (held welcome, auto ip_block/email_domain_block, - moderator DM) before falling through to a normal welcome. + moderator DM) before falling through to a normal welcome. A signup flagged + with a *strong* IP signal (datacenter/proxy/tor/abuser, not vpn alone) is + suspended immediately regardless of the email signal. * ``report.created`` — evaluates the reported account and, when enough *distinct* reporters have open reports against a young or dormant account, auto-**silences** it (reversible) and DMs a moderator for review. Uses a @@ -147,6 +149,23 @@ IP_SCRUTINY_AUTO_IPBLOCK = os.environ.get("IP_SCRUTINY_AUTO_IPBLOCK", "true").lo # severity: sign_up_requires_approval | sign_up_block | no_access IP_SCRUTINY_IPBLOCK_SEVERITY = os.environ.get("IP_SCRUTINY_IPBLOCK_SEVERITY", "sign_up_requires_approval") +# Signals stronger than vpn alone — vpn also flags plenty of privacy-conscious +# real users, but datacenter/proxy/tor/independently-scored-abuser is a much +# cleaner bulk/bot-signup indicator. A signup flagged with any of these +# suspends immediately at signup, regardless of the email-domain signal, +# instead of just the held-welcome/auto-ipblock/lowered-threshold treatment +# every other flagged signup gets. +IP_SCRUTINY_STRONG_SIGNALS = frozenset({"datacenter", "proxy", "tor", "abuser"}) +IP_SCRUTINY_STRONG_SUSPEND_ENABLED = os.environ.get( + "IP_SCRUTINY_STRONG_SUSPEND_ENABLED", "true" +).lower() in ("1", "true", "yes") +IP_SCRUTINY_STRONG_SUSPEND_ACTION = os.environ.get("IP_SCRUTINY_STRONG_SUSPEND_ACTION", "suspend").lower() +# Rollout safety switch — ships dry-run-first, same reasoning as +# SUSPICIOUS_COMBINED_DRY_RUN, since this is a brand-new action path. +IP_SCRUTINY_STRONG_SUSPEND_DRY_RUN = os.environ.get( + "IP_SCRUTINY_STRONG_SUSPEND_DRY_RUN", "true" +).lower() in ("1", "true", "yes") + # --- Disposable/high-risk email signup scrutiny (check-mail.org) ----------- # Every account.created delivery already carries the signup email for free # (Admin::Account.email). On each new local signup, the bot classifies the @@ -621,6 +640,13 @@ def process_signup(account_id: str, acct: str, ip: str, email: str = "", registration, never queued), account.approved will never be delivered for it, so the hold is lifted immediately instead of waiting forever. + A signup whose IP is flagged with a *strong* signal — datacenter, proxy, + tor, or abuser, but NOT vpn alone (see IP_SCRUTINY_STRONG_SIGNALS) — is + auto-actioned immediately here regardless of the email-domain signal, same + early-return shape as the combined-signal path below. A vpn-only flag + falls through to the normal hold/welcome path unless also combined with a + flagged email domain. + A signup flagged by BOTH signals at once (see SUSPICIOUS_COMBINED_ENABLED) is auto-actioned immediately here rather than falling through to the hold/welcome path — no welcome, no suspicious-watch entry, since the @@ -631,6 +657,7 @@ def process_signup(account_id: str, acct: str, ip: str, email: str = "", reasons: list[str] = [] hold = False ip_flagged = False + ip_strong_flagged = False email_flagged = False if IP_SCRUTINY_ENABLED and ip: @@ -638,6 +665,7 @@ def process_signup(account_id: str, acct: str, ip: str, email: str = "", record_signup_ip(account_id, acct, ip, classification, org, ip_flagged) if ip_flagged: + ip_strong_flagged = bool(set(classification.split("+")) & IP_SCRUTINY_STRONG_SIGNALS) log.warning("flagged signup acct=%s ip=%s classification=%s org=%s", acct, ip, classification, org) if IP_SCRUTINY_AUTO_IPBLOCK and not IP_SCRUTINY_DRY_RUN: @@ -663,36 +691,65 @@ def process_signup(account_id: str, acct: str, ip: str, email: str = "", reasons.append(f"{prefix}email domain {domain} (disposable={is_disposable}, risk={risk})") hold = hold or (CHECK_MAIL_HOLD_WELCOME and not CHECK_MAIL_DRY_RUN) - if ip_flagged and email_flagged and SUSPICIOUS_COMBINED_ENABLED: - allowlisted = (acct.split("@")[0].lower() in ABUSE_ALLOWLIST - or acct.lower() in ABUSE_ALLOWLIST) - if not allowlisted: - note = ( - f"Auto-{SUSPICIOUS_COMBINED_ACTION} at signup: flagged by BOTH " - f"IP-scrutiny and email-domain scrutiny ({'; '.join(reasons)})." + allowlisted = (acct.split("@")[0].lower() in ABUSE_ALLOWLIST + or acct.lower() in ABUSE_ALLOWLIST) + + if ip_strong_flagged and IP_SCRUTINY_STRONG_SUSPEND_ENABLED and not allowlisted: + action = IP_SCRUTINY_STRONG_SUSPEND_ACTION + note = ( + f"Auto-{action} at signup: signup IP flagged as {classification} " + f"({'; '.join(reasons)})." + ) + if IP_SCRUTINY_STRONG_SUSPEND_DRY_RUN: + log.warning("[DRY-RUN] would %s acct=%s immediately (strong IP signal: %s)", + action, acct, classification) + dm_moderator( + f"[DRY-RUN] would {action} @{acct} immediately — strong IP signal " + f"({'; '.join(reasons)})." ) - if SUSPICIOUS_COMBINED_DRY_RUN: - log.warning("[DRY-RUN] would %s acct=%s immediately (IP+email combined signal)", + else: + try: + apply_action(account_id, action, note) + except httpx.HTTPError as exc: + log.error("acct=%s: failed to immediately %s on strong IP signal, " + "falling back to the normal flagged-signup path: %s", + acct, action, exc) + else: + log.warning("auto-%sd acct=%s immediately — strong IP signup signal (%s)", + action, acct, classification) + dm_moderator( + f"🚨 Auto-{action}d @{acct} immediately — strong IP signal " + f"({'; '.join(reasons)})." + ) + return + + if ip_flagged and email_flagged and SUSPICIOUS_COMBINED_ENABLED and not allowlisted: + note = ( + f"Auto-{SUSPICIOUS_COMBINED_ACTION} at signup: flagged by BOTH " + f"IP-scrutiny and email-domain scrutiny ({'; '.join(reasons)})." + ) + if SUSPICIOUS_COMBINED_DRY_RUN: + log.warning("[DRY-RUN] would %s acct=%s immediately (IP+email combined signal)", + SUSPICIOUS_COMBINED_ACTION, acct) + dm_moderator( + f"[DRY-RUN] would {SUSPICIOUS_COMBINED_ACTION} @{acct} immediately — " + f"flagged by BOTH IP and email signals ({'; '.join(reasons)})." + ) + else: + try: + apply_action(account_id, SUSPICIOUS_COMBINED_ACTION, note) + except httpx.HTTPError as exc: + log.error("acct=%s: failed to immediately %s on combined signal, " + "falling back to the normal flagged-signup path: %s", + acct, SUSPICIOUS_COMBINED_ACTION, exc) + else: + log.warning("auto-%sd acct=%s immediately — combined IP+email signup signal", SUSPICIOUS_COMBINED_ACTION, acct) dm_moderator( - f"[DRY-RUN] would {SUSPICIOUS_COMBINED_ACTION} @{acct} immediately — " + f"🚨 Auto-{SUSPICIOUS_COMBINED_ACTION}d @{acct} immediately — " f"flagged by BOTH IP and email signals ({'; '.join(reasons)})." ) - else: - try: - apply_action(account_id, SUSPICIOUS_COMBINED_ACTION, note) - except httpx.HTTPError as exc: - log.error("acct=%s: failed to immediately %s on combined signal, " - "falling back to the normal flagged-signup path: %s", - acct, SUSPICIOUS_COMBINED_ACTION, exc) - else: - log.warning("auto-%sd acct=%s immediately — combined IP+email signup signal", - SUSPICIOUS_COMBINED_ACTION, acct) - dm_moderator( - f"🚨 Auto-{SUSPICIOUS_COMBINED_ACTION}d @{acct} immediately — " - f"flagged by BOTH IP and email signals ({'; '.join(reasons)})." - ) - return + return if not reasons: send_welcome(account_id, acct) diff --git a/test_local.py b/test_local.py index b07bfcf..cb4c2c9 100644 --- a/test_local.py +++ b/test_local.py @@ -533,6 +533,109 @@ def combined_signal_tests(): assert ("505", "failsuspend") not in sent, "still held, falls back to the normal flagged path" +def strong_ip_suspend_tests(): + """Drive process_signup's strong-IP-signal immediate-suspend path + (IP_SCRUTINY_STRONG_SUSPEND_*): datacenter/proxy/tor/abuser suspend + immediately regardless of the email signal, but vpn alone does not — it + still falls through to the normal hold/welcome path (or the existing + combined-signal suspend, if also paired with a flagged email domain).""" + sent = [] + dms = [] + actions = [] + ipblocks = [] + main.send_welcome = lambda account_id, acct: sent.append((account_id, acct)) + main.dm_moderator = lambda message: dms.append(message) + main.apply_action = lambda target_id, action, text: actions.append((target_id, action)) + main.register_ip_block = lambda ip, acct, org, cidr: ipblocks.append((ip, acct, org, cidr)) + main.register_email_domain_block = lambda domain, acct: None + main.fetch_account_counts = lambda account_id: (0, 0) + + main.IP_SCRUTINY_ENABLED = True + main.IP_SCRUTINY_DRY_RUN = False + main.IP_SCRUTINY_HOLD_WELCOME = True + main.IP_SCRUTINY_AUTO_IPBLOCK = True + main.CHECK_MAIL_ENABLED = True + main.CHECK_MAIL_API_KEY = "test-key" + main.CHECK_MAIL_DRY_RUN = False + main.CHECK_MAIL_HOLD_WELCOME = True + main.CHECK_MAIL_AUTO_DOMAIN_BLOCK = True + main.SUSPICIOUS_COMBINED_ENABLED = True + main.SUSPICIOUS_COMBINED_ACTION = "suspend" + main.SUSPICIOUS_COMBINED_DRY_RUN = False + main.ABUSE_ALLOWLIST = {"trustedstaff"} + main.IP_SCRUTINY_STRONG_SUSPEND_ENABLED = True + main.IP_SCRUTINY_STRONG_SUSPEND_ACTION = "suspend" + main.IP_SCRUTINY_STRONG_SUSPEND_DRY_RUN = False + + ip_classifications = { + "198.51.100.60": ("datacenter", "Example Cloud Hosting Inc", True, "198.51.100.48/28"), + "198.51.100.61": ("proxy", "Example Proxy Networks", True, "198.51.100.48/28"), + "198.51.100.62": ("vpn", "Example VPN Provider", True, "198.51.100.48/28"), + "198.51.100.63": ("vpn", "Example VPN Provider", True, "198.51.100.48/28"), + } + main.classify_signup_ip = lambda ip: ip_classifications[ip] + email_classifications = { + "temp-mail.org": (True, 99), + "gmail.com": (False, 5), + } + main.classify_email_domain = lambda domain: email_classifications[domain] + + # A. datacenter (strong), clean email -> suspended immediately regardless + # of the (non-flagged) email signal; ip_block still registered. + main.process_signup("601", "strongdc", "198.51.100.60", "strongdc@gmail.com") + assert ("601", "suspend") in actions, actions + assert ("601", "strongdc") not in sent, "must not welcome an immediately-suspended signup" + assert any("strongdc" in d and "strong IP signal" in d for d in dms), dms + assert ("198.51.100.60", "strongdc", "Example Cloud Hosting Inc", "198.51.100.48/28") in ipblocks, ipblocks + + # B. proxy (strong), no email at all -> still suspended immediately. + actions.clear(); sent.clear(); dms.clear() + main.process_signup("602", "strongproxy", "198.51.100.61") + assert ("602", "suspend") in actions, actions + assert ("602", "strongproxy") not in sent + + # C. vpn only (not strong), clean email -> NOT suspended, falls through to + # the normal held-welcome path. + actions.clear(); sent.clear(); dms.clear() + main.process_signup("603", "vpnonly", "198.51.100.62", "vpnonly@gmail.com") + assert ("603", "suspend") not in actions, actions + assert ("603", "vpnonly") not in sent, "still held by the individual IP-scrutiny hold" + + # D. vpn (not strong) + flagged email -> not caught by the strong path, + # but still caught by the existing combined-signal suspend. + actions.clear(); sent.clear(); dms.clear() + main.process_signup("604", "vpnplusemail", "198.51.100.63", "vpnplusemail@temp-mail.org") + assert ("604", "suspend") in actions, actions + assert ("604", "vpnplusemail") not in sent + assert any("vpnplusemail" in d and "BOTH" in d for d in dms), dms + + # E. allowlisted acct with a strong signal -> not suspended. + actions.clear(); sent.clear(); dms.clear() + main.process_signup("605", "trustedstaff", "198.51.100.60", "trustedstaff@gmail.com") + assert ("605", "suspend") not in actions, "allowlisted acct must not be auto-suspended" + + # F. strong signal, but IP_SCRUTINY_STRONG_SUSPEND_DRY_RUN -> DM only, no + # real suspend; falls through to the normal held-welcome path. + actions.clear(); sent.clear(); dms.clear() + main.IP_SCRUTINY_STRONG_SUSPEND_DRY_RUN = True + main.process_signup("606", "drystrong", "198.51.100.60", "drystrong@gmail.com") + assert ("606", "suspend") not in actions, "dry-run must not call apply_action" + assert any("[DRY-RUN]" in d and "drystrong" in d for d in dms), dms + assert ("606", "drystrong") not in sent, "still held by the individual IP-scrutiny hold" + + # G. strong signal, live, but apply_action fails -> falls back to the + # normal held-welcome path instead of silently dropping the signup. + actions.clear(); sent.clear(); dms.clear() + main.IP_SCRUTINY_STRONG_SUSPEND_DRY_RUN = False + + def boom(target_id, action, text): + raise main.httpx.HTTPError("boom") + + main.apply_action = boom + main.process_signup("607", "failstrong", "198.51.100.60", "failstrong@gmail.com") + assert ("607", "failstrong") not in sent, "still held, falls back to the normal flagged path" + + 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.""" @@ -684,6 +787,7 @@ if __name__ == "__main__": email_scrutiny_tests() range_cache_tests() combined_signal_tests() + strong_ip_suspend_tests() suspicious_watch_tests() suspicious_sweep_tests() print("ALL TESTS PASSED")