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:
+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