Initial commit
This commit is contained in:
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
@@ -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
|
||||
33
auth.py
Normal file
33
auth.py
Normal file
@@ -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')
|
||||
1
build.bat
Normal file
1
build.bat
Normal file
@@ -0,0 +1 @@
|
||||
pyinstaller --noconfirm --icon "icon.ico" --console --onefile horsy.py
|
||||
1
build.bat.bak
Normal file
1
build.bat.bak
Normal file
@@ -0,0 +1 @@
|
||||
pyinstaller --nocinfirm --icon "icon.ico" --console --onefile horsy.py
|
||||
5
console.py
Normal file
5
console.py
Normal file
@@ -0,0 +1,5 @@
|
||||
import os
|
||||
|
||||
|
||||
def cls():
|
||||
os.system('cls' if os.name == 'nt' else 'clear')
|
||||
75
horsy.py
Normal file
75
horsy.py
Normal file
@@ -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...')
|
||||
40
horsy.spec
Normal file
40
horsy.spec
Normal file
@@ -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')
|
||||
138
manager.py
Normal file
138
manager.py
Normal file
@@ -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[/]")
|
||||
31
path.py
Normal file
31
path.py
Normal file
@@ -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
|
||||
6
requirements.txt
Normal file
6
requirements.txt
Normal file
@@ -0,0 +1,6 @@
|
||||
rich
|
||||
requests
|
||||
Cryptography
|
||||
Pyinstaller
|
||||
tqdm
|
||||
vt-py
|
||||
19
tui.py
Normal file
19
tui.py
Normal file
@@ -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('> ')
|
||||
142
uploader.py
Normal file
142
uploader.py
Normal file
@@ -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
|
||||
53
virustotal.py
Normal file
53
virustotal.py
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user