Suspend immediately on strong IP-reputation signals (not vpn alone)
docker-build-push / build-push (push) Failing after 15s
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:
@@ -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>.
|
||||
|
||||
@@ -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
@@ -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
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user