Suspend immediately on strong IP-reputation signals (not vpn alone)
docker-build-push / build-push (push) Failing after 15s

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.
This commit is contained in:
pmb
2026-07-21 22:14:39 -07:00
parent 0b0842dc9d
commit 65c9bd5a9e
4 changed files with 221 additions and 26 deletions
+17
View File
@@ -123,6 +123,23 @@ IP_SCRUTINY_AUTO_IPBLOCK=true
# just signups). # just signups).
IP_SCRUTINY_IPBLOCK_SEVERITY=sign_up_block 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, # check-mail.org disposable/high-risk email domain scrutiny (roadmap item B,
# anti-abuse.md). Domain-only query (never the full email) against # anti-abuse.md). Domain-only query (never the full email) against
# POST https://api.check-mail.org/v2/, Authorization: Bearer <key>. # POST https://api.check-mail.org/v2/, Authorization: Bearer <key>.
+17
View File
@@ -88,6 +88,20 @@ Every `account.created` delivery already carries the signup IP for free
usual `ABUSE_SOURCES_*` distinct-reporter threshold is replaced by usual `ABUSE_SOURCES_*` distinct-reporter threshold is replaced by
`IP_SCRUTINY_ABUSE_THRESHOLD` (whichever is lower), since a flagged `IP_SCRUTINY_ABUSE_THRESHOLD` (whichever is lower), since a flagged
signup IP plus a report is a stronger combined signal than either alone. 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 `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 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_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_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_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_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_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 | | `CHECK_MAIL_DRY_RUN` | `true` — classify + DM only, no held welcome, no email_domain_block write, no report-triggered suspend |
+60 -3
View File
@@ -11,7 +11,9 @@ and dispatches by event:
tor/abuser via ipapi.is) and email domain (disposable/high-risk via tor/abuser via ipapi.is) and email domain (disposable/high-risk via
check-mail.org); a flagged check-mail.org); a flagged
signup gets more scrutiny (held welcome, auto ip_block/email_domain_block, 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 * ``report.created`` — evaluates the reported account and, when enough
*distinct* reporters have open reports against a young or dormant account, *distinct* reporters have open reports against a young or dormant account,
auto-**silences** it (reversible) and DMs a moderator for review. Uses a 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 # 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") 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) ----------- # --- Disposable/high-risk email signup scrutiny (check-mail.org) -----------
# Every account.created delivery already carries the signup email for free # Every account.created delivery already carries the signup email for free
# (Admin::Account.email). On each new local signup, the bot classifies the # (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 registration, never queued), account.approved will never be delivered for
it, so the hold is lifted immediately instead of waiting forever. 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) A signup flagged by BOTH signals at once (see SUSPICIOUS_COMBINED_ENABLED)
is auto-actioned immediately here rather than falling through to the is auto-actioned immediately here rather than falling through to the
hold/welcome path — no welcome, no suspicious-watch entry, since 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] = [] reasons: list[str] = []
hold = False hold = False
ip_flagged = False ip_flagged = False
ip_strong_flagged = False
email_flagged = False email_flagged = False
if IP_SCRUTINY_ENABLED and ip: 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) record_signup_ip(account_id, acct, ip, classification, org, ip_flagged)
if 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", log.warning("flagged signup acct=%s ip=%s classification=%s org=%s",
acct, ip, classification, org) acct, ip, classification, org)
if IP_SCRUTINY_AUTO_IPBLOCK and not IP_SCRUTINY_DRY_RUN: if IP_SCRUTINY_AUTO_IPBLOCK and not IP_SCRUTINY_DRY_RUN:
@@ -663,10 +691,39 @@ def process_signup(account_id: str, acct: str, ip: str, email: str = "",
reasons.append(f"{prefix}email domain {domain} (disposable={is_disposable}, risk={risk})") 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) 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 allowlisted = (acct.split("@")[0].lower() in ABUSE_ALLOWLIST
or acct.lower() in ABUSE_ALLOWLIST) or acct.lower() in ABUSE_ALLOWLIST)
if not allowlisted:
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)})."
)
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 = ( note = (
f"Auto-{SUSPICIOUS_COMBINED_ACTION} at signup: flagged by BOTH " f"Auto-{SUSPICIOUS_COMBINED_ACTION} at signup: flagged by BOTH "
f"IP-scrutiny and email-domain scrutiny ({'; '.join(reasons)})." f"IP-scrutiny and email-domain scrutiny ({'; '.join(reasons)})."
+104
View File
@@ -533,6 +533,109 @@ def combined_signal_tests():
assert ("505", "failsuspend") not in sent, "still held, falls back to the normal flagged path" 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", "[email protected]")
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", "[email protected]")
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", "[email protected]")
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", "[email protected]")
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", "[email protected]")
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", "[email protected]")
assert ("607", "failstrong") not in sent, "still held, falls back to the normal flagged path"
def suspicious_watch_tests(): def suspicious_watch_tests():
"""Drive maybe_start_suspicious_watch: baseline capture on flagged """Drive maybe_start_suspicious_watch: baseline capture on flagged
signups only, never on clean ones, and never twice for the same account.""" signups only, never on clean ones, and never twice for the same account."""
@@ -684,6 +787,7 @@ if __name__ == "__main__":
email_scrutiny_tests() email_scrutiny_tests()
range_cache_tests() range_cache_tests()
combined_signal_tests() combined_signal_tests()
strong_ip_suspend_tests()
suspicious_watch_tests() suspicious_watch_tests()
suspicious_sweep_tests() suspicious_sweep_tests()
print("ALL TESTS PASSED") print("ALL TESTS PASSED")