diff --git a/README.md b/README.md index aa9a7de..8e2ed76 100755 --- a/README.md +++ b/README.md @@ -88,21 +88,23 @@ To do: 5) Add more automated rdlist function with sane defaults - DONE 6) Add fully automated (should just return a web link and decryption password) reporting functions for users: - Description of why the report was made (what happened) -- User's ID -- Whois Data -- Account Data -- Query Data -- Pushers List +- User's ID - DONE +- Whois Data - DONE +- Account Data - DONE +- Query Data - DONE +- Pushers List - DONE +- IPs + ipinfo Data - DONE - List of the rooms the user is participating in, divided into 1:1 conversations and larger rooms -- The content of the messages they've sent (if they were sent to rooms your server is participating in) -- Copies of any media they've sent? +- Any other usernames associated with that IP +- Timestamp for when illegal material was accessed - Description of report format and contents (to guide the reader) 7) Add a room report function to create a properly formatted report for rdlist 8) Add a function to extract a users email or 3PID 9) Do room shutdowns in parallel? 10) Add function for probing the support email of another server automatically 11) Automated incident report email to other server owners for more scalable coordination -12) Automated public room joining and reminder if reporting email is not available +12) Automated public room joining and reminder if reporting email is not available? + *** ## rdlist Functionality @@ -161,3 +163,31 @@ The room was not found. ``` Note that this script before shutting these rooms down will save the state events to the "./state_events" folder, please keep this data as it's important for law enforcement. + + +*** +## One-touch Reporting + +WARNING: This section is under heavy development and shouldn't be used by anyone!!! + +This script can automatically generate reports about user accounts for law enforcement. + +It collects as much data about the target user account as possible, then packages it into an encrypted ZIP file that can be shared: +``` + +Please enter a number from the above menu, or enter 'q' or 'e' to exit. + +70 + +Please enter the username to automatically generate a report: michael + +... + +Report generated successfully on user: "michael" + +You can send this .zip file and password when reporting a user to law enforcement. + +Password: RwiFrw9zouhVO7Dy9kW7 +Encrypted .zip file location: ./reports/michael_2023-07-23_02-21-56.zip.aes +Encrypted .zip file size: 0.503927 MB +``` diff --git a/hardcoded_variables.py b/hardcoded_variables.py index 9f27e98..a27555d 100644 --- a/hardcoded_variables.py +++ b/hardcoded_variables.py @@ -1,12 +1,14 @@ ########################################################################### # These values can be hard coded for easier usage: # -homeserver_url = "matrix.example.org" -base_url = "example.org" -access_token = "" +homeserver_url = "matrix.example.org" # Your homeserver URL +base_url = "example.org" # Your base URL (appears in usernames) +access_token = "" # Your homeserver admin access token # ipinfo.io token -ipinfo_token = "" +ipinfo_token = "" # Leave blank to disable ipinfo.io lookups # rdlist specific -rdlist_bot_username = "mod_team" +rdlist_bot_username = "mod_team" # The username to perform automated room shutdowns rdlist_recommended_tags = ['hub_room_links', 'hub_room_trade', 'preban', 'degen_misc', 'beastiality', 'degen_porn', 'gore', 'snuff', 'degen_larp', 'hub_room_sussy', 'bot_spam', 'cfm', 'jailbait', 'bot_porn', 'toddlercon', 'loli', 'csam', 'tfm', 'degen_meet', 'stylized_3d_loli', '3d_loli'] +# report generator +report_folder = "./reports" # Reports folder name ########################################################################### diff --git a/ipinfo_commands.py b/ipinfo_commands.py new file mode 100644 index 0000000..4e5b1a7 --- /dev/null +++ b/ipinfo_commands.py @@ -0,0 +1,77 @@ + +import os +import requests +import json +import csv +import time +import socket +import hardcoded_variables +import user_commands + +def is_valid_ipv4(ip): + try: + socket.inet_pton(socket.AF_INET, ip) + except socket.error: # not a valid address + return False + return True + +def analyse_account_ip(preset_username): + if not preset_username: + preset_username = input("\nPlease enter a username to analyse their country of origin: ") + data = user_commands.whois_account(preset_username=preset_username) + + user_id = data['user_id'] + device_data = data['devices'] + + ip_info = {} + for device_id, device_info in device_data.items(): + for session in device_info['sessions']: + for connection in session['connections']: + ip = connection['ip'] + if is_valid_ipv4(ip) and len(hardcoded_variables.ipinfo_token) > 0: + res = requests.get(f"https://ipinfo.io/{ip}", + headers={"Authorization": f"Bearer {hardcoded_variables.ipinfo_token}"}) + if res.status_code == 200: + country = res.json().get('country') + ip_info[ip] = country + + if len(hardcoded_variables.ipinfo_token) == 0: + return {"user_id": user_id, "ip_info": "IPINFO DISABLED"} + else: + return {"user_id": user_id, "ip_info": ip_info} + +def analyse_multiple_account_ips(): + print("Analyse multiple user IPs selected") + user_list_location = input("\nPlease enter the path of the file containing a newline seperated list of Matrix usernames: ") + with open(user_list_location, newline='') as f: + reader = csv.reader(f) + data = list(reader) + print(len(data)) + + print("\n" + str(data)) + + output_file = None + if len(data) > 10: + file_confirmation = input("\nThere are more than 10 users. Would you like to save the output to a file? y/n?\n") + if file_confirmation.lower() in ("y", "yes"): + output_file = input("\nPlease enter the desired output file path:\n") + + analyse_confirmation = input("\n\nAre you sure you want to analyse the IP of all of these users? y/n?\n") + + if analyse_confirmation.lower() in ("y", "yes"): + x = 0 + while x <= (len(data) - 1): + output = analyse_account_ip(data[x][0]) + + # if output file is specified, append to file + if output_file: + with open(output_file, 'a') as f: + f.write(output + "\n") + x += 1 + time.sleep(1) + + if analyse_confirmation.lower() in ("n", "no"): + print("\nExiting...\n") + + if output_file and os.path.isfile(output_file): + print(f"Output saved to {output_file}") diff --git a/moderation_tool.py b/moderation_tool.py index db106ff..041c531 100755 --- a/moderation_tool.py +++ b/moderation_tool.py @@ -2,7 +2,9 @@ import user_commands import room_commands import server_commands +import ipinfo_commands import rdlist_commands +import report_commands import hardcoded_variables # check if homeserver url is hard coded, if not set it @@ -51,9 +53,9 @@ while pass_token == False: print("17) Set rate limit of a user account.") print("18) Delete rate limit of a user account.") print("19) Check if user account exists.") - print("\n#### Server Commands ####") - print("40) Delete and block a specific media.") - print("41) Purge remote media repository up to a certain date.") + print("\n#### Server Commands ####\t\t\t\t\t#### Report Generation ####") + print("40) Delete and block a specific media.\t\t\t\t70) Generate user report.") + print("41) Purge remote media repository up to a certain date.\t\t71) Decrypt user report .zip file.") print("42) Prepare database for copying events of multiple rooms.") print("\n#### rdlist ####") print("50) Block all rooms with specific rdlist tags.") @@ -142,9 +144,13 @@ while pass_token == False: elif menu_input == "51": rdlist_commands.block_recommended_rdlist_tags() elif menu_input == "60": - user_commands.analyse_account_ip('') + ipinfo_commands.analyse_account_ip('') elif menu_input == "61": - user_commands.analyse_multiple_account_ips() + ipinfo_commands.analyse_multiple_account_ips() + elif menu_input == "70": + report_commands.generate_user_report('') + elif menu_input == "71": + report_commands.decrypt_zip_file() elif menu_input == "q" or menu_input == "Q" or menu_input == "e" or menu_input == "E": print("\nExiting...\n") pass_token = True diff --git a/report_commands.py b/report_commands.py new file mode 100644 index 0000000..676668c --- /dev/null +++ b/report_commands.py @@ -0,0 +1,166 @@ + +import os +import json +import random +import string +import datetime +import zipfile +import pyAesCrypt +import user_commands +import room_commands +import ipinfo_commands +import hardcoded_variables + +# For testing the Report Generator, set this to True +testing_mode = False + +def get_report_folder(): + # Get report_folder from hardcoded_variables + report_folder = hardcoded_variables.report_folder + + # If report_folder ends with a slash, remove it + if report_folder.endswith(os.sep): + report_folder = report_folder[:-1] + + return report_folder + +def encrypt_user_folder(user_report_folder, username): + # Generate a strong random password + strong_password = ''.join(random.choice(string.ascii_letters + string.digits) for i in range(20)) + + # Get parent directory of user_report_folder + parent_directory = os.path.dirname(os.path.abspath(user_report_folder)) + + # Create the name of the .zip file including timestamp + zip_file_name = os.path.join(parent_directory, username + "_" + datetime.datetime.now().strftime("%Y-%m-%d_%H-%M-%S") + ".zip") + + # Create a .zip file of the specified folder + with zipfile.ZipFile(zip_file_name, 'w', zipfile.ZIP_DEFLATED) as zip_file: + for root, dirs, files in os.walk(user_report_folder): + for file in files: + zip_file.write(os.path.join(root, file), arcname=os.path.relpath(os.path.join(root, file), user_report_folder)) + + # Buffer size - 64K + bufferSize = 64 * 1024 + + # Encrypt the .zip file + pyAesCrypt.encryptFile(zip_file_name, zip_file_name + ".aes", strong_password, bufferSize) + + # Delete the original zip file + os.remove(zip_file_name) + + # You can return the password if you need to use it later, or you can directly print it here + return strong_password, zip_file_name + ".aes" + +def generate_user_report(preset_username): + if len(preset_username) == 0: + username = input("\nPlease enter the username to automatically generate a report: ") + username = user_commands.parse_username(username) + else: + username = user_commands.parse_username(preset_username) + + # Check if user exists + if user_commands.check_user_account_exists(username) == True: + print("\nUser exists, continuing with report generation.") + return + + # If report_folder ends in a slash, remove it + report_folder = get_report_folder() + + # Create report folders + user_report_folder = report_folder + "/" + username + "/" + + if os.path.exists(report_folder) == False: + os.mkdir(report_folder) + + if os.path.exists(user_report_folder) == False: + os.mkdir(user_report_folder) + + # Get user account data and write to ./report/username/account_data.json + account_data = user_commands.collect_account_data(username) + account_data_file = open(user_report_folder + "account_data.json", "w") + account_data_file.write(json.dumps(account_data, indent=4, sort_keys=True)) + account_data_file.close() + + # Get user pushers and write to ./report/username/pushers.json + pushers_data = user_commands.list_account_pushers(username) + pushers_file = open(user_report_folder + "pushers.json", "w") + pushers_file.write(json.dumps(pushers_data, indent=4, sort_keys=True)) + pushers_file.close() + + # Get whois data and write to ./report/username/whois.json + whois_data = user_commands.whois_account(username) + whois_file = open(user_report_folder + "whois.json", "w") + whois_file.write(json.dumps(whois_data, indent=4, sort_keys=True)) + whois_file.close() + + # Get query data and write to ./report/username/query.json + query_data = user_commands.query_account(username) + query_file = open(user_report_folder + "query.json", "w") + query_file.write(json.dumps(query_data, indent=4, sort_keys=True)) + query_file.close() + + # Get user joined rooms and write to ./report/username/joined_rooms.json + joined_rooms_dict = user_commands.list_joined_rooms(username) + joined_rooms_file = open(user_report_folder + "joined_rooms.json", "w") + joined_rooms_file.write(json.dumps(joined_rooms_dict, indent=4, sort_keys=True)) + joined_rooms_file.close() + + # Get user ipinfo and write to ./report/username/ipinfo.json + ipinfo = ipinfo_commands.analyse_account_ip(username) + ipinfo_file = open(user_report_folder + "ipinfo.json", "w") + ipinfo_file.write(json.dumps(ipinfo, indent=4, sort_keys=True)) + ipinfo_file.close() + + # For each room the user is in, get the room state and write to ./report/username/room_states/ + room_states_folder = user_report_folder + "room_states/" + if os.path.exists(room_states_folder) == False: + os.mkdir(room_states_folder) + + room_list = joined_rooms_dict.get('joined_rooms', []) + + count = 0 + for room in room_list: + count += 1 + room = room.split(" ")[0] + room_commands.export_room_state(room, room_states_folder) + if count > 4 and testing_mode == True: + break + + # For each room the user is in, get the room details and write to ./report/username/room_details/ + room_details_folder = user_report_folder + "room_details/" + if os.path.exists(room_details_folder) == False: + os.mkdir(room_details_folder) + + count = 0 + for room in room_list: + count += 1 + room = room.split(" ")[0] + room_details = room_commands.list_room_details(room) + room_details_file = open(room_details_folder + room + ".json", "w") + room_details_file.write(str(room_details)) + room_details_file.close() + if count > 4 and testing_mode == True: + break + + # Generate a random password, then encrypt the ./report/username/ folder to a timestamped .zip file + strong_password, encrypted_zip_file_name = encrypt_user_folder(user_report_folder, username) + + # Measure the size of the encrypted .zip file in MB + encrypted_zip_file_size = os.path.getsize(encrypted_zip_file_name) / 1000000 + + # Print the password and the encrypted .zip file name + print("\nReport generated successfully on user: \"" + username + "\"\n\nYou can send this .zip file and password when reporting a user to law enforcement.") + print("\nPassword: " + strong_password) + print("Encrypted .zip file location: " + encrypted_zip_file_name) + print("Encrypted .zip file size: " + str(encrypted_zip_file_size) + " MB\n") + +def decrypt_zip_file(): + # Ask user for the location of the encrypted .zip file + encrypted_zip_file_name = input("\nPlease enter the location of the encrypted .zip file: ") + # Ask user for the password + strong_password = input("Please enter the password: ") + # Decrypt the ZIP file into the same location as the encrypted ZIP file + pyAesCrypt.decryptFile(encrypted_zip_file_name, encrypted_zip_file_name[:-4], strong_password, 64 * 1024) + # Print the location of the decrypted ZIP file + print("\nDecrypted .zip file location: " + encrypted_zip_file_name[:-4] + "\n") diff --git a/room_commands.py b/room_commands.py index 2537833..6be0a6d 100644 --- a/room_commands.py +++ b/room_commands.py @@ -24,21 +24,28 @@ def list_room_details(preset_internal_ID): print("\n" + url + "\n") response = requests.get(url, headers=headers, verify=True) - print(response.text) + room_details_dict = json.loads(response.text) + print(json.dumps(room_details_dict, indent=4, sort_keys=True)) + + return room_details_dict # Example # $ curl -kXGET 'https://matrix.perthchat.org/_synapse/admin/v1/rooms/!OeqILBxiHahidSQQoC:matrix.org?access_token=ACCESS_TOKEN' -def export_room_state(preset_internal_ID): +def export_room_state(preset_internal_ID, preset_directory): # record the current directory location current_directory = os.getcwd() - + if preset_internal_ID == '': internal_ID = input("\nEnter the internal id of the room with which to export the 'state' of (Example: !OLkDvaYjpNrvmwnwdj:matrix.org): ") elif preset_internal_ID != '': internal_ID = preset_internal_ID - - room_dir = os.path.join(current_directory, "state_events") + + if preset_directory == '': + room_dir = os.path.join(current_directory, "state_events") + elif preset_directory != '': + room_dir = preset_directory + os.makedirs(room_dir, exist_ok=True) unix_time = int(time.time()) @@ -52,8 +59,9 @@ def export_room_state(preset_internal_ID): with open(filename, 'w') as f: f.write(response.text) - print(response.text) - return(response.text) + state_events_dict = json.loads(response.text) + + return state_events_dict # Example # $ curl -kXGET 'https://matrix.perthchat.org/_synapse/admin/v1/rooms/!OeqILBxiHahidSQQoC:matrix.org/state?access_token=ACCESS_TOKEN' @@ -71,7 +79,8 @@ def list_directory_rooms(): output = output.replace('\"room_id\":\"','\n') output = output.replace('\",\"name','\n\",\"name') - print(output) + + print(json.dumps(output, indent=4, sort_keys=True)) # Example # $ curl -kXGET https://matrix.perthchat.org/_matrix/client/r0/publicRooms?access_token=ACCESS_TOKEN @@ -244,7 +253,7 @@ def shutdown_room(preset_internal_ID,preset_user_ID,preset_new_room_name,preset_ new_room_name = input("\nPlease enter the room name of the muted violation room your users will be sent to: ") elif preset_new_room_name != '': new_room_name = preset_new_room_name - if preset_message == '': + if preset_message == '': message = input("\nPlease enter the shutdown message that will be displayed to users: ") elif preset_message != '': message = preset_message @@ -351,7 +360,7 @@ def shutdown_multiple_rooms(): preset_block_choice = input("\n Do you want to block these rooms? (This prevents your server users re-entering the room.) y/n? ") # Get the directory of the current script script_dir = os.path.dirname(os.path.realpath(__file__)) - room_list_data = [] + room_list_data = [] for file in file_list: print("Processing file: " + file) # Change the current working directory @@ -453,11 +462,11 @@ def purge_room_to_timestamp(preset_internal_ID, preset_timestamp): timestamp = input("\nEnter the epoch timestamp in microseconds (Example: 1661058683000): ") else: timestamp = preset_timestamp - + headers = {"Authorization": "Bearer " + hardcoded_variables.access_token, "Content-Type": "application/json"} data = {"delete_local_events": False, "purge_up_to_ts": int(timestamp)} url = f'https://{hardcoded_variables.homeserver_url}/_synapse/admin/v1/purge_history/{internal_ID}' - + response = requests.post(url, headers=headers, data=json.dumps(data)) print("\n", response.text, "\n") @@ -475,7 +484,7 @@ def purge_room_to_timestamp(preset_internal_ID, preset_timestamp): time.sleep(sleep_time) count += 1 sleep_time *= 2 - + url_status = f'https://{hardcoded_variables.homeserver_url}/_synapse/admin/v1/purge_history_status/{purge_id}' response = requests.get(url_status, headers=headers) response_json = response.json() diff --git a/user_commands.py b/user_commands.py index a7f7572..b5df538 100644 --- a/user_commands.py +++ b/user_commands.py @@ -210,7 +210,9 @@ def whois_account(preset_username, output_file=None): else: print(output_text) - return response.text + whois_account_dict = json.loads(response.text) + + return whois_account_dict # Example: # $ curl -kXGET https://matrix.perthchat.org/_matrix/client/r0/admin/whois/@dogpoo:perthchat.org?access_token=ACCESS_TOKEN @@ -251,76 +253,6 @@ def whois_multiple_accounts(): if output_file and os.path.isfile(output_file): print(f"Output saved to {output_file}") -def is_valid_ipv4(ip): - try: - socket.inet_pton(socket.AF_INET, ip) - except socket.error: # not a valid address - return False - return True - -def analyse_account_ip(preset_username): - if not preset_username: - preset_username = input("\nPlease enter a username to analyse their country of origin: ") - user_info = whois_account(preset_username=preset_username) - - data = json.loads(user_info) - - user_id = data['user_id'] - #print(f'user_id: {user_id}') - device_data = data['devices'] - #print(f'device_data: {device_data}') - - countries = [] - for device_id, device_info in device_data.items(): - for session in device_info['sessions']: - for connection in session['connections']: - ip = connection['ip'] - if is_valid_ipv4(ip): - res = requests.get(f"https://ipinfo.io/{ip}", - headers={"Authorization": f"Bearer {hardcoded_variables.ipinfo_token}"}) - if res.status_code == 200: - country = res.json().get('country') - countries.append(country) - - print(f"User: {user_id} from Countries: {countries}") - return(f"User: {user_id} from Countries: {countries}") - -def analyse_multiple_account_ips(): - print("Analyse multiple user IPs selected") - user_list_location = input("\nPlease enter the path of the file containing a newline seperated list of Matrix usernames: ") - with open(user_list_location, newline='') as f: - reader = csv.reader(f) - data = list(reader) - print(len(data)) - - print("\n" + str(data)) - - output_file = None - if len(data) > 10: - file_confirmation = input("\nThere are more than 10 users. Would you like to save the output to a file? y/n?\n") - if file_confirmation.lower() in ("y", "yes"): - output_file = input("\nPlease enter the desired output file path:\n") - - analyse_confirmation = input("\n\nAre you sure you want to analyse the IP of all of these users? y/n?\n") - - if analyse_confirmation.lower() in ("y", "yes"): - x = 0 - while x <= (len(data) - 1): - output = analyse_account_ip(data[x][0]) - - # if output file is specified, append to file - if output_file: - with open(output_file, 'a') as f: - f.write(output + "\n") - x += 1 - time.sleep(1) - - if analyse_confirmation.lower() in ("n", "no"): - print("\nExiting...\n") - - if output_file and os.path.isfile(output_file): - print(f"Output saved to {output_file}") - def list_joined_rooms(preset_username): if preset_username == '': username = input("\nPlease enter the username you wish to query: ") @@ -338,6 +270,10 @@ def list_joined_rooms(preset_username): else: print(f"Error querying joined rooms: {response.status_code}, {response.text}") + joined_rooms_dict = json.loads(response.text) + + return joined_rooms_dict + # Example: # $ curl -kXGET https://matrix.perthchat.org/_synapse/admin/v1/users/@dogpoo:perthchat.org/joined_rooms?access_token=ACCESS_TOKEN @@ -358,7 +294,7 @@ def list_accounts(): number_of_users = len(users) print("\nTotal amount of users: " + str(number_of_users)) - if number_of_users < 100: + if number_of_users < 100: print(users) else: accounts_output_file = input("\nThere are too many users to list here, please specify a filename to print this data too: ") @@ -389,7 +325,9 @@ def query_account(preset_username): else: print(f"Error querying account: {response.status_code}, {response.text}") - return response.text + query_account_dict = json.loads(response.text) + + return query_account_dict # Example: # $ curl -kX GET https://matrix.perthchat.org/_synapse/admin/v2/users/@billybob:perthchat.org?access_token=ACCESS_TOKEN @@ -435,7 +373,7 @@ def quarantine_users_media(): username = parse_username(username) url = f"https://{hardcoded_variables.homeserver_url}/_synapse/admin/v1/user/@{username}:{hardcoded_variables.base_url}/media/quarantine?access_token={hardcoded_variables.access_token}" - + print("\n" + url + "\n") response = requests.post(url, verify=True) @@ -464,7 +402,9 @@ def collect_account_data(preset_username): else: print(f"Error querying account: {response.status_code}, {response.text}") - return response.text + account_data_dict = json.loads(response.text) + + return account_data_dict # Example: # $ curl -X GET https://matrix.perthchat.org/_synapse/admin/v1/users/@dogpoo:perthchat.org/accountdata?access_token=ACCESS_TOKEN @@ -486,7 +426,9 @@ def list_account_pushers(preset_username): else: print(f"Error querying account: {response.status_code}, {response.text}") - return response.text + pusher_data_dict = json.loads(response.text) + + return pusher_data_dict # Example: # $ curl -X GET https://matrix.perthchat.org/_synapse/admin/v1/users/@dogpoo:perthchat.org/pushers @@ -569,12 +511,12 @@ def check_user_account_exists(preset_username): if response.status_code == 200: print("User ID is available.") + return True elif response.status_code == 400: print(f"User ID already taken.") + return False else: print(f"Error querying account: {response.status_code}, {response.text}") - return response.text - # Example: # $ curl -X GET /_synapse/admin/v1/username_available?username=dogpoo&access_token=ACCESS_TOKEN