commit 001eb4b55598abbe0319148abbe0e47214f42a40 Author: BarsTiger Date: Sun Jan 23 21:38:12 2022 +0200 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..57786ad --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +# Client +**/__pycache__/ +/Source/Client/apps/ +/Source/Client/build/ +/Source/Client/sources/ +/Source/Client/*.spec +/Source/Client/*.bak +/Source/Client/config.cfg \ No newline at end of file diff --git a/auth.py b/auth.py new file mode 100644 index 0000000..015644d --- /dev/null +++ b/auth.py @@ -0,0 +1,33 @@ +import json + + +def get_auth(): + with open('config.cfg') as f: + config = json.load(f) + + try: + if config['auth']: + return config['auth'] + else: + raise Exception('No auth found') + except: + print('[!] No auth found, please login first') + print('email') + email = input('> ') + print('password') + password = input('> ') + config['auth'] = {'email': email, 'password': password} + with open('config.cfg', 'w') as f: + json.dump(config, f) + print('[OK] Auth created') + return config['auth'] + + +def del_auth(): + with open('config.cfg') as f: + config = json.load(f) + if config['auth']: + config['auth'] = None + with open('config.cfg', 'w') as f: + json.dump(config, f) + print('[OK] Auth deleted') diff --git a/build.bat b/build.bat new file mode 100644 index 0000000..0350cdf --- /dev/null +++ b/build.bat @@ -0,0 +1 @@ +pyinstaller --noconfirm --icon "icon.ico" --console --onefile horsy.py \ No newline at end of file diff --git a/build.bat.bak b/build.bat.bak new file mode 100644 index 0000000..83175ae --- /dev/null +++ b/build.bat.bak @@ -0,0 +1 @@ +pyinstaller --nocinfirm --icon "icon.ico" --console --onefile horsy.py \ No newline at end of file diff --git a/console.py b/console.py new file mode 100644 index 0000000..d2807c7 --- /dev/null +++ b/console.py @@ -0,0 +1,5 @@ +import os + + +def cls(): + os.system('cls' if os.name == 'nt' else 'clear') diff --git a/horsy.py b/horsy.py new file mode 100644 index 0000000..f7dd239 --- /dev/null +++ b/horsy.py @@ -0,0 +1,75 @@ +import argparse +import tui as tui +from console import cls +from manager import * +from virustotal import add_to_cfg +from uploader import upload + +# Getting the arguments +parser = argparse.ArgumentParser(description='horsy - the best package manager') +parser.add_argument('option', help='options for horsy (install/i | uninstall/un | source/s | update/u | list/l | ' + 'upload)', + choices=['install', 'i', 'uninstall', 'un', 'source', 's', 'update', 'u', 'list', 'l', 'upload'], + nargs='?') +parser.add_argument('app', help='app to install/uninstall/download source', nargs='?') +parser.add_argument('--vt', help='your virustotal api key (account -> api key in VT)', dest='vt_key') + +args = parser.parse_args() +option = args.option +app = args.app + +# Checking if the user has a new VT key +if args.vt_key: + if args.vt_key != 'disable': + add_to_cfg(args.vt_key) + else: + add_to_cfg(None) + + +# Checking directories and files +if not os.path.exists('apps'): + os.makedirs('apps') +if not os.path.exists('sources'): + os.makedirs('sources') +if not os.path.isfile('config.cfg'): + with open('config.cfg', 'w') as f: + f.write('{}') + +# Displaying the logo +os.system('title horsy') +cls() +print(''' + __ __ _______ ______ _______ __ __ +| | | || || _ | | || | | | +| |_| || _ || | || | _____|| |_| | +| || | | || |_||_ | |_____ | | +| || |_| || __ ||_____ ||_ _| +| _ || || | | | _____| | | | +|__| |__||_______||___| |_||_______| |___| +''') +isNoArgs = False + +# Checking if arguments are empty to use in-app CLI +if not args.option: + option = ['install', 'uninstall', 'source', 'update', 'list', 'upload'][tui.menu(['install app', 'uninstall app', + 'get source', 'update app', + 'list of installed apps', + 'upload your app'])] + isNoArgs = True + +if not args.app: + if option not in ['list', 'upload']: + print('\n') + app = tui.get(f'Select app to {option}') + +if option in 'upload': + upload() + +if option in ['install', 'i']: + install(app) + +if option in ['uninstall', 'un']: + uninstall(app) + +if isNoArgs: + input('[EXIT] Press enter to exit horsy...') diff --git a/horsy.spec b/horsy.spec new file mode 100644 index 0000000..c1090af --- /dev/null +++ b/horsy.spec @@ -0,0 +1,40 @@ +# -*- mode: python ; coding: utf-8 -*- + + +block_cipher = None + + +a = Analysis(['horsy.py'], + pathex=[], + binaries=[], + datas=[], + hiddenimports=[], + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + win_no_prefer_redirects=False, + win_private_assemblies=False, + cipher=block_cipher, + noarchive=False) +pyz = PYZ(a.pure, a.zipped_data, + cipher=block_cipher) + +exe = EXE(pyz, + a.scripts, + a.binaries, + a.zipfiles, + a.datas, + [], + name='horsy', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + upx_exclude=[], + runtime_tmpdir=None, + console=True, + disable_windowed_traceback=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None , icon='icon.ico') diff --git a/icon.ico b/icon.ico new file mode 100644 index 0000000..59e6844 Binary files /dev/null and b/icon.ico differ diff --git a/manager.py b/manager.py new file mode 100644 index 0000000..b099b71 --- /dev/null +++ b/manager.py @@ -0,0 +1,138 @@ +import json +import threading +from rich import print +import requests +import vars +from tqdm import tqdm +import os +import zipfile +from virustotal import get_key, scan_file, get_report + + +def install(package, is_gui=False): + horsypath = os.popen('echo %HORSYPATH%').read().replace('\n', '') + '/' + r = requests.get(f"{vars.protocol}{vars.server_url}/packages/json/{package}").text + try: + r = json.loads(r) + except: + print("[red]Error with unsupported message[/]") + return + try: + if r["message"] == "not found": + print("[red]Package not found[/]") + return + if r["message"] == "Internal server error": + print("[red]Internal server error[/]") + return + except: + pass + + try: + print(f"[green]App {r['name']} found, information loaded[/]") + + if not os.path.exists('{1}apps/{0}'.format(r['name'], horsypath)): + os.makedirs('{1}apps/{0}'.format(r['name'], horsypath)) + + if not is_gui: + print(f"Downloading {r['url'].split('/')[-1]}") + + chunk_size = 1024 + file_r = requests.get(r['url'], stream=True) + with open('{2}apps/{0}/{1}'.format(r['name'], r['url'].split('/')[-1], horsypath), "wb") as f: + pbar = tqdm(unit="B", unit_scale=True, total=int(file_r.headers['Content-Length'])) + for chunk in file_r.iter_content(chunk_size=chunk_size): + if chunk: + pbar.update(len(chunk)) + f.write(chunk) + pbar.close() + + print(f"Starting virustotal scan") + if not get_key(): + print(f"[red]Virustotal api key not found[/]") + print(f"You can add it by entering [bold]horsy --vt \[your key][/] in terminal") + else: + print(f"[green]Virustotal api key found[/]") + print(f"[italic white]If you want to disable scan, type [/][bold]horsy --vt disable[/]" + f"[italic white] in terminal[/]") + scan_file('{2}apps/{0}/{1}'.format(r['name'], r['url'].split('/')[-1], horsypath)) + print(f"[green]Virustotal scan finished[/]") + analysis = get_report('{2}apps/{0}/{1}'.format(r['name'], r['url'].split('/')[-1], horsypath)) + print(f"[green]You can see report by opening: [white]{analysis['link']}[/]") + print(f"{analysis['detect']['malicious']} antivirus flagged this file as malicious") + + print(f"[green][OK] Done[/]") + + def unzip(file, where): + with zipfile.ZipFile(file, 'r') as zip_ref: + zip_ref.extractall(where) + print(f"[green]Extracted[/]") + + if r['url'].split('.')[-1] == 'zip': + print(f"Extracting {r['url'].split('/')[-1]}") + unzip('{2}apps/{0}/{1}'.format(r['name'], r['url'].split('/')[-1], horsypath), + '{1}apps/{0}'.format(r['name'], horsypath)) + + if r['download']: + print(f"Found dependency") + if not is_gui: + print(f"Downloading {r['download'].split('/')[-1]}") + + chunk_size = 1024 + file_r = requests.get(r['download'], stream=True) + with open('{2}apps/{0}/{1}'.format(r['name'], r['download'].split('/')[-1], horsypath), "wb") as f: + pbar = tqdm(unit="B", unit_scale=True, total=int(file_r.headers['Content-Length'])) + for chunk in file_r.iter_content(chunk_size=chunk_size): + if chunk: + pbar.update(len(chunk)) + f.write(chunk) + pbar.close() + + print(f"Starting virustotal scan") + if not get_key(): + print(f"[red]Virustotal api key not found[/]") + print(f"You can add it by entering [italic white]horsy --vt \[your key][/] in terminal") + else: + print(f"[green]Virustotal api key found[/]") + scan_file('{2}apps/{0}/{1}'.format(r['name'], r['download'].split('/')[-1], horsypath)) + print(f"[green]Virustotal scan finished[/]") + analysis = get_report('{2}apps/{0}/{1}'.format(r['name'], r['download'].split('/')[-1], horsypath)) + print(f"[green]You can see report by opening: [white]{analysis['link']}[/]") + print(f"{analysis['detect']['malicious']} antivirus flagged this file as malicious") + if analysis['detect']['malicious'] > 0: + print(f"[red]Dependency can be malicious. It may run now, if this added to installation " + f"config[/]") + input("Press enter if you want continue, or ctrl+c to exit") + + if r['install']: + print(f"Found install option") + threading.Thread(target=os.system, args=('{2}apps/{0}/{1}'.format(r['name'], r['install'], horsypath),)) \ + .start() + + print(f"Generating launch script") + + with open('{1}apps/{0}.bat'.format(r['name'], horsypath), 'w') as f: + f.write(f"@ECHO off\n") + f.write(f"{horsypath}apps/{r['name']}/{r['run']} %*\n") + + print(f"[green][OK] All done![/]") + print(f"[green]You can run your app by entering [italic white]{r['name']}[/] in terminal[/]") + + except: + print("[red]Unexpected error[/]") + raise + return + + +def uninstall(package, is_gui=False): + horsypath = os.popen('echo %HORSYPATH%').read().replace('\n', '') + '/' + if not is_gui: + if os.path.exists('{1}apps/{0}'.format(package, horsypath)): + os.system('rmdir /s /q "{1}apps/{0}"'.format(package, horsypath)) + print(f"[green][OK] Files deleted[/]") + else: + print(f"[red]App {package} is not installed or doesn't have files[/]") + if os.path.isfile('{1}apps/{0}.bat'.format(package, horsypath)): + os.remove("{1}apps/{0}.bat".format(package, horsypath)) + print(f"[green][OK] Launch script deleted[/]") + else: + print(f"[red]App {package} is not installed or doesn't have launch script[/]") diff --git a/path.py b/path.py new file mode 100644 index 0000000..da0f0a6 --- /dev/null +++ b/path.py @@ -0,0 +1,31 @@ +# Module for PATH actions +import os + + +def add_to_path(program_path: str): + import winreg + + with winreg.ConnectRegistry(None, winreg.HKEY_CURRENT_USER) as root: # Get the current user's registry + with winreg.OpenKey(root, "Environment", 0, winreg.KEY_ALL_ACCESS) as key: # Open the environment key + existing_path_value = os.popen('echo %PATH%').read() # Get the existing path value + print(existing_path_value) + new_path_value = existing_path_value + ";" + program_path # Connect the new path to the existing path + winreg.SetValueEx(key, "PATH", 0, winreg.REG_EXPAND_SZ, new_path_value) # Update the path value + + +def delete_from_path(program_path: str): + import winreg + + with winreg.ConnectRegistry(None, winreg.HKEY_CURRENT_USER) as root: # Get the current user's registry + with winreg.OpenKey(root, "Environment", 0, winreg.KEY_ALL_ACCESS) as key: # Open the environment key + existing_path_value = os.popen('echo %PATH%').read() # Get the existing path value + new_path_value = existing_path_value.replace(program_path + ";", "") # Remove the program path from path + winreg.SetValueEx(key, "PATH", 0, winreg.REG_EXPAND_SZ, new_path_value) # Update the path value + + +def add_var(horsy_path: str): + import winreg + + with winreg.ConnectRegistry(None, winreg.HKEY_CURRENT_USER) as root: # Get the current user's registry + with winreg.OpenKey(root, "Environment", 0, winreg.KEY_ALL_ACCESS) as key: # Open the environment key + winreg.SetValueEx(key, "HORSYPATH", 0, winreg.REG_EXPAND_SZ, horsy_path) # Update the path value diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..8747cca --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +rich +requests +Cryptography +Pyinstaller +tqdm +vt-py \ No newline at end of file diff --git a/tui.py b/tui.py new file mode 100644 index 0000000..424b02a --- /dev/null +++ b/tui.py @@ -0,0 +1,19 @@ +def menu(options: list) -> int: + for i in range(len(options)): + print(str(i) + ' - ' + options[i]) + user_input = None + while user_input is None: + try: + user_input = int(input('\n> ')) + if user_input < 0 or user_input >= len(options): + user_input = None + print('Choose number between 0 and ' + str(len(options) - 1)) + except ValueError: + print('Choose number option') + + return user_input + + +def get(description: str) -> str: + print(description) + return input('> ') diff --git a/uploader.py b/uploader.py new file mode 100644 index 0000000..71c21f1 --- /dev/null +++ b/uploader.py @@ -0,0 +1,142 @@ +import json +import time +import requests +from rich import print +from auth import get_auth, del_auth +import re +import vars +import os + + +def matches(s): + return re.match("^[a-z_-]*$", s) is not None + + +def urlmatch(s): + return re.match("^https?://.*.(?:zip|exe)$", s) is not None + + +def upload(): + print('Welcome to the uploader') + print('Before starting, please make sure you have done your project and [blink]uploaded[/] it to any hosting ' + 'service or file sharing service') + input('[OK] Press enter to continue...') + auth = get_auth() + print('Please enter the name of your project. It should contain only lowercase letters, ' + 'underscores and dashes') + project_name = None + while project_name is None: + project_name = input('> ') + if not matches(project_name) or len(project_name) > 64 or len(project_name) < 3: + print('[red]Invalid project name[/red]') + project_name = None + + print('Please paste there project description. It should be a short text under 256 characters') + description = None + while description is None: + description = input('> ') + if len(description) > 256: + print('[red]Description is too long[/red]') + description = None + + print('Please paste there url of executable file. It should be a link to exe or zip file hosted somewhere. ' + 'If app needs dependencies or specific launch options (python, node, etc), you can add them later') + url = None + while url is None: + url = input('> ') + if not urlmatch(url): + print('[red]Invalid file url, also it should end on .exe or .zip[/red]') + url = None + + print('Please paste there url of your project on GitHub or somewhere else. It should be a link to source code ' + 'of your app. It can be archive, repository, site, whatever you want, optional but highly recommended.' + 'If you don\'t want to add it, just press Enter') + source_url = input('> ') + source_url = None if source_url == '' else source_url + + print('If your app needs any dependencies, please paste its link here. It can be exe of installer from official ' + 'site. If you don\'t want to add it, just press Enter') + download = None + while download is None: + download = input('> ') + if download == '': + download = None + break + if not urlmatch(download): + print('[red]Invalid download url[/red]') + download = None + + print('Please add which files should be run during installation. It should be an executable file name.' + 'If you don\'t want to add it, just press Enter') + install = input('> ') + install = None if install == '' else install + + print('Please specify main executable command. It can be executable file name (some-file.exe) or command, that ' + 'launches your script (python some-file.py, etc)') + run = None + while run is None: + run = input('> ') + if run == '': + print('[red]Please, specify runtime[/red]') + run = None + + request = { + 'auth': auth, + 'name': project_name, + 'description': description, + 'url': url, + 'sourceUrl': source_url, + 'download': download, + 'install': install, + 'run': run + } + + # request = { + # "auth": {"email": "meshko_a@dlit.dp.ua", "password": "VeryGoodPassword"}, + # "name": "testapp", + # "description": "Very good description", + # # "url": "https://github.com/Cactus-0/cabanchik/raw/main/dist/cabanchik.exe", + # "sourceUrl": "https://github.com/Cactus-0/cabanchik", + # "download": "https://www.python.org/ftp/python/3.10.2/python-3.10.2-amd64.exe", + # "install": "python-3.10.2-amd64.exe", + # "run": "cabanchik.exe" + # } + + r = None + while r is None: + try: + r = requests.post(vars.protocol + vars.server_url + '/packages/new', json=request).text + r = json.loads(r) + + if r['message'] == 'Unauthorized': + print('[red]Invalid credentials[/red]') + print('Deleting auth from config') + del_auth() + request['auth'] = get_auth() + print(r) + r = None + + elif r['message'] == 'Internal server error': + print('[red]Internal server error, request is broken[/red]') + break + + elif r['message'] == 'Invalid body': + print('[red]Invalid request body, try again[/red]') + break + + elif r['message'] == 'Success': + print('[green]Success, your project is created. You can install it by running[/] ' + '[i]horsy install {0}[/]'.format(request['name'])) + break + + else: + print('[red]Unknown error[/red]') + print('Server response:') + print(r) + break + except: + with open(f'error_{time.time()}.txt', 'w') as f: + f.write(str(r)) + print(f'[red]Something went wrong with unsupported error. You can check servers response in ' + f'{os.getcwd()}/{f.name}[/red]') + break diff --git a/vars.py b/vars.py new file mode 100644 index 0000000..8f3ba9b --- /dev/null +++ b/vars.py @@ -0,0 +1,2 @@ +protocol = "http://" +server_url = 'localhost:60666' diff --git a/virustotal.py b/virustotal.py new file mode 100644 index 0000000..ede231b --- /dev/null +++ b/virustotal.py @@ -0,0 +1,53 @@ +import json +import requests +import os +import hashlib + + +def add_to_cfg(key): + with open('config.cfg') as f: + config = json.load(f) + + config['vt-key'] = key + + with open('config.cfg', 'w') as f: + json.dump(config, f) + + +def get_key(): + with open('config.cfg') as f: + config = json.load(f) + + try: + return config['vt-key'] + except KeyError: + return None + + +def scan_file(filename): + api_url = 'https://www.virustotal.com/api/v3/files' + headers = {'x-apikey': get_key()} + with open(filename, 'rb') as file: + files = {'file': (filename, file)} + if os.path.getsize(filename) < 33554432: + response = requests.post(api_url, headers=headers, files=files) + return response.json()['data']['id'] + else: + api_url = 'https://www.virustotal.com/api/v3/files/upload_url' + response = requests.get(api_url, headers=headers) + response = requests.post(response.json()['data'], headers=headers, files=files) + return response.json()['data']['id'] + + +def get_report(filename): + hash_md5 = hashlib.md5() + with open(filename, "rb") as f: + for chunk in iter(lambda: f.read(4096), b""): + hash_md5.update(chunk) + api_url = 'https://www.virustotal.com/api/v3/files/' + hash_md5.hexdigest() + headers = {'x-apikey': get_key()} + response = requests.get(api_url, headers=headers) + analysis = dict() + analysis['detect'] = response.json()['data']['attributes']['last_analysis_stats'] + analysis['link'] = 'https://www.virustotal.com/gui/file/' + response.json()['data']['id'] + return analysis