├── LICENSE ├── README.md ├── add_action.py ├── crowdfms.py ├── fetch_file.py ├── lib ├── __init__.py ├── core.py ├── db.py └── objects.py ├── list_actions.py └── sample_details.py /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013, CrowdStrike, Inc. 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | 1. Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | 2. Redistributions in binary form must reproduce the above copyright notice, 10 | this list of conditions and the following disclaimer in the documentation 11 | and/or other materials provided with the distribution. 12 | 13 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 14 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 15 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 16 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR 17 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 18 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 19 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 20 | ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 21 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 22 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Copyright (C) 2013 CrowdStrike, Inc. 2 | This file is subject to the terms and conditions of the BSD License. 3 | See the file LICENSE in the main directory for details 4 | 5 | 6 | CrowdFMS is a framework for automating collection and processing of samples from 7 | VirusTotal, by leveraging the Private API system. This framework automatically 8 | downloads recent samples, which triggered an alert on the users YARA notification feed. 9 | 10 | Users can also specify a command to execute on these newly downloaded samples, 11 | based on their YARA rule name. For example, a user can specify that all samples 12 | that matched the YARA rule “Zeus”, be automatically submitted to Cuckoo sandbox. 13 | 14 | 15 | Python Modules: 16 | - sqlite3 17 | - shutil 18 | - argparse 19 | - requests 20 | - re 21 | - json 22 | - requests 23 | 24 | Please also place your API key in either .virustotal or ~/.virustotal 25 | 26 | Usage and Tools: 27 | crowdfms.py - Primary sample collection system 28 | 29 | sample_details - Fetch details about a sample stored in the local database 30 | + Usage: sample_details.py -f -HASH- # Hash can be either MD5, SHA1 or SHA256 31 | 32 | fetch_file - copy file from database to current working directory 33 | + Usage: fetch_file -f -HASH- # Hash can be either MD5, SHA1 or SHA256 34 | 35 | add_action - Add action to preform on new sample rule metch 36 | + Usage: add_action -y -Yara Rule Name- -c -Command to Execute- # Command to Execute must contain %s where sample path should go 37 | 38 | add_action - Add action to preform on new sample rule metch 39 | + Usage: add_action -y -Yara Rule Name- -c -Command to Execute- # Command to Execute must contain %s where sample path should go 40 | 41 | list_actions - List all Yara -> matches 42 | + Usage: list_actions 43 | -------------------------------------------------------------------------------- /add_action.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # Copyright (C) 2013 CrowdStrike, Inc. 3 | # This file is subject to the terms and conditions of the BSD License. 4 | # See the file LICENSE in the main directory for details 5 | 6 | 7 | import sys 8 | import os 9 | import argparse 10 | 11 | from lib.db import * 12 | 13 | def main(): 14 | parser = argparse.ArgumentParser() 15 | parser.add_argument("-c", "--command_path", help="Path to command to exec", required=True) 16 | parser.add_argument("-y", "--yara_rule", help="Yara Rule to match", required=True) 17 | 18 | args = parser.parse_args() 19 | command = args.command_path 20 | yara_rule = args.yara_rule 21 | 22 | if '%s' not in command: 23 | sys.exit("Please format the command with a %s where the path to the sample should be inserted") 24 | 25 | db_cursor = db_notif.cursor() 26 | sql_check_yara = "SELECT rulename FROM rule_actions WHERE rulename = ?" 27 | db_cursor.execute( sql_check_yara, ([yara_rule]) ) 28 | 29 | check_result = db_cursor.fetchone() 30 | 31 | if check_result is not None: 32 | sys.exit(" [X] Filter already exists for rule %s" % yara_rule) 33 | else: 34 | sql_insert = "INSERT INTO rule_actions VALUES ( ?, ?)" 35 | db_notif.execute(sql_insert, (yara_rule, command)) 36 | db_notif.commit() 37 | 38 | print "Action added for %s (%s)" % (yara_rule, command) 39 | 40 | if __name__ == "__main__": 41 | try: 42 | main() 43 | except KeyboardInterrupt: 44 | db_shutdown() 45 | sys.exit(0) 46 | 47 | 48 | -------------------------------------------------------------------------------- /crowdfms.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # Copyright (C) 2013 CrowdStrike, Inc. 3 | # This file is subject to the terms and conditions of the BSD License. 4 | # See the file LICENSE in the main directory for details 5 | 6 | import sys 7 | import os 8 | import thread 9 | import time 10 | 11 | from lib.core import funct_parse_rule_actions, func_pull_feed, func_to_epoch, func_download_sample, func_set_api_key, funct_run_rule_action 12 | from lib.objects import sample 13 | from lib.db import db_shutdown 14 | 15 | 16 | LOOP_TIME = 300 17 | STORAGE_PATH = "./samples/" 18 | RUN = True 19 | API_KEY = func_set_api_key() 20 | DEFAULT_ACTION = "" 21 | 22 | def main(): 23 | print " [+] Starting CrowdFMS" 24 | while(RUN): 25 | print " [+] Starting Loop" 26 | loop_pull_feed() 27 | print " [S] Sleeping %s" % LOOP_TIME 28 | time.sleep(LOOP_TIME) 29 | 30 | def startup(): 31 | global STORAGE_PATH 32 | if (os.path.isdir(STORAGE_PATH) == False): 33 | os.mkdir(STORAGE_PATH) 34 | 35 | 36 | def loop_pull_feed(): 37 | tmp_oldest = 999999999999 38 | tmp_newest = 0 39 | global LOOP_TIME 40 | 41 | rule_actions = funct_parse_rule_actions() 42 | 43 | json_notif_feed = func_pull_feed(API_KEY) 44 | if (json_notif_feed == 0): 45 | print "Problem pulling feed. Sleeping..." 46 | return 47 | 48 | for vt_notif in json_notif_feed["notifications"]: 49 | 50 | if (func_to_epoch(vt_notif["date"]) > tmp_newest): 51 | tmp_newest = func_to_epoch(vt_notif["date"]) 52 | 53 | if (func_to_epoch(vt_notif["date"]) < tmp_oldest): 54 | tmp_oldest = func_to_epoch(vt_notif["date"]) 55 | 56 | 57 | try: 58 | tmp_sample = sample() 59 | tmp_sample.define_sample( 60 | vt_notif["md5"], 61 | vt_notif["sha1"], 62 | vt_notif["sha256"], 63 | vt_notif["ruleset_name"], 64 | vt_notif["subject"], 65 | func_to_epoch(vt_notif["date"]), 66 | func_to_epoch(vt_notif["first_seen"]), 67 | (float(vt_notif["positives"]) / float(vt_notif["total"])), 68 | vt_notif["size"], 69 | 70 | ) 71 | 72 | except KeyError: 73 | sys.exit(" [X] Problem parsing VT feed") 74 | 75 | if (tmp_sample.check_new()): 76 | sample_path = func_download_sample(API_KEY, STORAGE_PATH, vt_notif["md5"]) 77 | tmp_sample.set_path( sample_path ) 78 | 79 | if ( tmp_sample.insert_db() == True ): 80 | tmp_sample.print_short() 81 | if '%s' in DEFAULT_ACTION: 82 | try: 83 | thread.start_new_thread( funct_run_rule_action, (DEFAULT_ACTION, sample_path ) ) 84 | except: 85 | funct_run_rule_action( DEFAULT_ACTION , sample_path ) 86 | 87 | 88 | 89 | if (str(vt_notif["subject"]) in rule_actions ): 90 | try: 91 | thread.start_new_thread( funct_run_rule_action, (rule_actions[vt_notif["subject"]], sample_path ) ) 92 | except: 93 | funct_run_rule_action( rule_actions[vt_notif["subject"]] , sample_path ) 94 | 95 | else: 96 | print " [-] Problem submitting sample to DB" 97 | 98 | if ( ((tmp_newest - tmp_oldest) < LOOP_TIME) and (LOOP_TIME > 60) ) : 99 | LOOP_TIME = (LOOP_TIME / 2) 100 | 101 | if __name__ == "__main__": 102 | try: 103 | startup() 104 | main() 105 | except KeyboardInterrupt: 106 | db_shutdown() 107 | sys.exit(" [X] Shutting Down") 108 | 109 | -------------------------------------------------------------------------------- /fetch_file.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # Copyright (C) 2013 CrowdStrike, Inc. 3 | # This file is subject to the terms and conditions of the BSD License. 4 | # See the file LICENSE in the main directory for details 5 | 6 | import sys 7 | import shutil 8 | import argparse 9 | 10 | from lib.core import * 11 | from lib.objects import * 12 | 13 | def main(): 14 | # Parse arguments and define user's supplied hash 15 | parser = argparse.ArgumentParser() 16 | parser.add_argument("-f", dest="file", help="Hash of file (MD5 / SHA1 / SHA256)", required=True) 17 | 18 | args = parser.parse_args() 19 | usr_hash = args.file 20 | 21 | db_hash = sample() 22 | db_hash.define_by_hash(usr_hash) 23 | 24 | try: 25 | shutil.copy( db_hash.path, usr_hash ) 26 | print usr_hash 27 | except: 28 | print "Failed to pull file" 29 | 30 | if __name__ == "__main__": 31 | try: 32 | main() 33 | except KeyboardInterrupt: 34 | db_shutdown() 35 | sys.exit(0) 36 | 37 | 38 | -------------------------------------------------------------------------------- /lib/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CrowdStrike/CrowdFMS/c9c3facfca20879ff9b28ddbe11514090317f6ec/lib/__init__.py -------------------------------------------------------------------------------- /lib/core.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # Copyright (C) 2013 CrowdStrike, Inc. 3 | # This file is subject to the terms and conditions of the BSD License. 4 | # See the file LICENSE in the main directory for details 5 | 6 | import sys 7 | import os 8 | import requests 9 | import re 10 | import json 11 | import time 12 | import subprocess 13 | import fcntl 14 | 15 | from lib.db import * 16 | 17 | # Read ~/.virustotal and read the first line. This file only needs the API string in it. 18 | def func_set_api_key(): 19 | try: 20 | if ( os.path.exists(os.path.expanduser('~') + '/.virustotal' ) ): 21 | with open( os.path.expanduser('~') + '/.virustotal' ) as handle_api_file: 22 | return func_parse_api_key(handle_api_file.readlines()) 23 | elif ( os.path.exists('.virustotal' ) ): 24 | with open( '.virustotal' ) as handle_api_file: 25 | return func_parse_api_key(handle_api_file.readlines()) 26 | else: 27 | sys.exit(" [X] Please Put API Key in ~/.virustotal or .virustotal") 28 | except IOError: 29 | sys.exit(" [X] Please Put API Key in ~/.virustotal or .virustotal") 30 | 31 | # Parse the API key and exit if the API key contains any non [A-Za-z0-9]+ 32 | def func_parse_api_key(lst_tmp_key): 33 | str_tmp_key = "".join(lst_tmp_key).rstrip() 34 | if re.match("^[A-Za-z0-9]+$", str_tmp_key): 35 | return str_tmp_key 36 | else: 37 | sys.exit(" [X] Problem with supplied API key formatting") 38 | 39 | # Pull the JSON feed from VT and return dict to main 40 | def func_pull_feed(str_api_key): 41 | req_user_agent = {'User-agent': 'VirusTotal FMS 1.0'} 42 | try: 43 | vt_request_results = requests.get("https://www.virustotal.com/intelligence/hunting/notifications-feed/?key=%s" % str_api_key, headers=req_user_agent) 44 | except: 45 | return 0 46 | try: 47 | return json.loads(vt_request_results.content) 48 | except ValueError: 49 | print vt_request_results.content 50 | return 0 51 | 52 | # Convert VT timestamps to Epoch Timestamp 53 | def func_to_epoch(str_timestamp): 54 | try: 55 | format = '%Y-%m-%d %H:%M:%S' 56 | return int(time.mktime(time.strptime(str_timestamp.rstrip(), format))) 57 | except: 58 | return 1 59 | 60 | # Download sample and store it to disk 61 | def func_download_sample(str_api_key, str_path, str_hash): 62 | save_path = str_path + '/' + str_hash[:3] + "/" + str_hash[3:6] + "/" + str_hash[6:9] + "/" 63 | 64 | if not os.path.exists(save_path): 65 | os.makedirs(save_path) 66 | 67 | req_user_agent = {'User-agent': 'VirusTotal FMS'} 68 | vt_request_results = requests.get("https://www.virustotal.com/intelligence/download/?hash=%s&apikey=%s" % (str_hash, str_api_key), headers=req_user_agent) 69 | 70 | 71 | with open(save_path + str_hash, "wb") as save_file: 72 | save_file.write(vt_request_results.content) 73 | 74 | return save_path + str_hash 75 | 76 | # Pull array of all rule specific actions 77 | def funct_parse_rule_actions(): 78 | db_cursor = db_notif.cursor() 79 | tmp_action_dict = {} 80 | sql_rule_actions = 'SELECT rulename, sys_command FROM rule_actions' 81 | for tmp_row in db_cursor.execute( sql_rule_actions ): 82 | tmp_action_dict[str(tmp_row[0])] = str(tmp_row[1]) 83 | 84 | return tmp_action_dict 85 | 86 | # Pull array of all rule specific actions 87 | def funct_run_rule_action(system_command, sample_path): 88 | if (os.path.isdir("./log") == False): 89 | os.mkdir("./log") 90 | 91 | handle_log_file = open("./log/external_commands.log" , "a") 92 | fcntl.flock(handle_log_file, fcntl.LOCK_EX) 93 | 94 | sys_command = system_command % sample_path 95 | 96 | handle_log_file.write("[+] Executing %s \n" % sys_command) 97 | print " [+] Executing %s " % sys_command 98 | 99 | subprocess.Popen(sys_command,stdout=handle_log_file, stderr=handle_log_file, shell=True) 100 | print " [+] External command execution complete" 101 | 102 | handle_log_file.close() 103 | return 0; 104 | 105 | 106 | 107 | 108 | 109 | 110 | -------------------------------------------------------------------------------- /lib/db.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # Copyright (C) 2013 CrowdStrike, Inc. 3 | # This file is subject to the terms and conditions of the BSD License. 4 | # See the file LICENSE in the main directory for details 5 | 6 | import os 7 | import sqlite3 8 | 9 | # initialize new database 10 | def db_initialize(): 11 | db_notif.execute('''CREATE TABLE samples( 12 | sample_md5 varchar(255) NOT NULL, 13 | sample_sha1 varchar(255) NOT NULL, 14 | sample_sha256 varchar(255) NOT NULL, 15 | sample_ruleset text, 16 | sample_rulename text, 17 | sample_added int(20), 18 | sample_first_seen int(20), 19 | sample_detectionratio real(5), 20 | sample_size int(10), 21 | sample_path text NOT NULL 22 | )''') 23 | 24 | db_notif.execute('''CREATE TABLE rule_actions( 25 | rulename text NOT NULL, 26 | sys_command text NOT NULL 27 | )''') 28 | 29 | db_notif.execute('''CREATE TABLE ruleset_actions( 30 | rulesetname text NOT NULL, 31 | sys_command text NOT NULL 32 | )''') 33 | 34 | db_notif.commit() 35 | 36 | # Check to see if file exists and if it does not, print and continue 37 | def db_pre_check(): 38 | if (os.path.isdir("./db") == False): 39 | os.mkdir("./db") 40 | 41 | try: 42 | with open( 'db/notification.db') as handle_db_check: 43 | return False 44 | except IOError: 45 | print " [+] Creating New Database" 46 | return True 47 | 48 | # Gracefully Shutdown connection to DB 49 | def db_shutdown(): 50 | db_notif.close() 51 | 52 | # On load of module 53 | bool_new_db = db_pre_check() 54 | db_notif = sqlite3.connect('db/notification.db') 55 | 56 | if (bool_new_db): 57 | db_initialize() 58 | -------------------------------------------------------------------------------- /lib/objects.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # Copyright (C) 2013 CrowdStrike, Inc. 3 | # This file is subject to the terms and conditions of the BSD License. 4 | # See the file LICENSE in the main directory for details 5 | 6 | import sys 7 | from lib.db import * 8 | 9 | # Create an object that defines each of the notifications from VT 10 | class sample(object): 11 | # Initialize new sample 12 | def __init__(self): 13 | pass 14 | 15 | # Define attributes of notification 16 | def define_sample(self, md5, sha1, sha256, ruleset_name, rule_name, notificaiton_date, first_seen, detection_ratio, size): 17 | self.md5 = md5 18 | self.sha1 = sha1 19 | self.sha256 = sha256 20 | self.ruleset_name = ruleset_name 21 | self.rule_name = rule_name 22 | self.notificaiton_date = notificaiton_date 23 | self.first_seen = first_seen 24 | self.detection_ratio = detection_ratio 25 | self.size = size 26 | 27 | # Populate attributes of sample by pulling them from the DB 28 | def define_by_hash(self, usr_hash): 29 | db_cursor = db_notif.cursor() 30 | 31 | # Parse length of user supplied hash to determine hash type 32 | if ( usr_hash.isalnum() == False ): 33 | sys.exit("Invalid Hash.") 34 | elif (len(usr_hash) == 32): 35 | sql_select_details = "SELECT * FROM samples WHERE sample_md5 = ? " 36 | elif (len(usr_hash) == 40): 37 | sql_select_details = "SELECT * FROM samples WHERE sample_sha1 = ? " 38 | elif (len(usr_hash) == 64): 39 | sql_select_details = "SELECT * FROM samples WHERE sample_sha256 = ? " 40 | else: 41 | sys.exit("Invalid Hash-") 42 | 43 | db_cursor.execute(sql_select_details, ([usr_hash])) 44 | 45 | db_result = db_cursor.fetchone() 46 | if db_result is None: 47 | sys.exit("Sample Not Found") 48 | else: 49 | try: 50 | self.define_sample( 51 | db_result[0], 52 | db_result[1], 53 | db_result[2], 54 | db_result[3], 55 | db_result[4], 56 | db_result[5], 57 | db_result[6], 58 | db_result[7], 59 | db_result[8] 60 | ) 61 | self.set_path(db_result[9]) 62 | except: 63 | sys.exit("Problem Parsing Hash") 64 | 65 | 66 | ''' 67 | long printing of notification object 68 | ex: 69 | [*] MD5 : 00000000000000000000000000000000 70 | SHA1 : 0000000000000000000000000000000000000000 71 | SHA256 : 0000000000000000000000000000000000000000000000000000000000000000 72 | Ruleset Name : TestRuleSet 73 | Rule Name : TestRule 74 | Notific. Date : 000000000 75 | First Seen : 000000000 76 | Detection Ratio : .00 77 | Size : 000 78 | ''' 79 | def print_self(self): 80 | print " [*] MD5 : %s" % self.md5 81 | print " SHA1 : %s" % self.sha1 82 | print " SHA256 : %s" % self.sha256 83 | print " Ruleset Name : %s" % self.ruleset_name 84 | print " Rule Name : %s" % self.rule_name 85 | print " Notific. Date : %s" % self.notificaiton_date 86 | print " First Seen : %s" % self.first_seen 87 | print " Detection Ratio : %s" % self.detection_ratio 88 | print " Size : %s" % self.size 89 | 90 | ''' 91 | short printing of notification object 92 | ex: 93 | [*] MD5 : 00000000000000000000000000000000 (Rulename : Test) 94 | ''' 95 | def print_short(self): 96 | print " [*] MD5 : %s (Rulename : %s) " % (self.md5, self.rule_name) 97 | 98 | # set path of sample 99 | def set_path(self, path): 100 | self.path = path 101 | 102 | 103 | # insert sample into database for storage 104 | def insert_db(self): 105 | values = [ 106 | self.md5, 107 | self.sha1, 108 | self.sha256, 109 | self.ruleset_name, 110 | self.rule_name, 111 | self.notificaiton_date, 112 | self.first_seen, 113 | self.detection_ratio, 114 | self.size, 115 | self.path 116 | ] 117 | 118 | sql_insert = "INSERT INTO samples VALUES ( ?, ?, ?, ?, ?, ?, ?, ?, ?, ? )" 119 | try: 120 | 121 | db_notif.execute(sql_insert, (values)) 122 | db_notif.commit() 123 | return True 124 | except: 125 | return False 126 | 127 | # Check to see if sample already exists in DB 128 | def check_new(self): 129 | db_cursor = db_notif.cursor() 130 | 131 | sql_check_new = "SELECT sample_md5 FROM samples WHERE sample_md5=? and sample_rulename=?" 132 | 133 | db_cursor.execute(sql_check_new, (self.md5, self.rule_name)) 134 | 135 | if db_cursor.fetchone() is None: 136 | return True 137 | else: 138 | return False 139 | 140 | 141 | 142 | 143 | 144 | -------------------------------------------------------------------------------- /list_actions.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # Copyright (C) 2013 CrowdStrike, Inc. 3 | # This file is subject to the terms and conditions of the BSD License. 4 | # See the file LICENSE in the main directory for details 5 | 6 | 7 | import sys 8 | import os 9 | 10 | from lib.db import db_shutdown 11 | from lib.core import funct_parse_rule_actions 12 | 13 | def main(): 14 | rule_actions = funct_parse_rule_actions() 15 | print " [!] %-*s: ( Command )\n" % (40, "Signature") 16 | for rule in rule_actions.keys(): 17 | print " [+] %-*s: %s " % (40,rule, rule_actions[rule]) 18 | 19 | if __name__ == "__main__": 20 | try: 21 | main() 22 | except KeyboardInterrupt: 23 | db_shutdown() 24 | sys.exit(0) 25 | 26 | 27 | -------------------------------------------------------------------------------- /sample_details.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # Copyright (C) 2013 CrowdStrike, Inc. 3 | # This file is subject to the terms and conditions of the BSD License. 4 | # See the file LICENSE in the main directory for details 5 | 6 | import argparse 7 | 8 | from lib.objects import * 9 | from lib.core import * 10 | 11 | def main(): 12 | # Parse arguments and define user's supplied hash 13 | parser = argparse.ArgumentParser() 14 | parser.add_argument("-f", dest="file", help="Hash of file (MD5 / SHA1 / SHA256", required=True) 15 | 16 | args = parser.parse_args() 17 | usr_hash = args.file 18 | 19 | db_hash = sample() 20 | db_hash.define_by_hash(usr_hash) 21 | db_hash.print_self() 22 | print " Path : %s" % db_hash.path 23 | 24 | 25 | if __name__ == "__main__": 26 | try: 27 | main() 28 | except KeyboardInterrupt: 29 | db_shutdown() 30 | sys.exit(0) 31 | 32 | 33 | --------------------------------------------------------------------------------