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).
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 <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
`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 |
+60 -3
View File
@@ -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,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})")
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:
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 = (
f"Auto-{SUSPICIOUS_COMBINED_ACTION} at signup: flagged by BOTH "
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"
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():
"""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")