#!/usr/bin/env python
# License: MIT
# Copyright Joe Security 2018

"""
jbxapi.py serves two purposes.

 (1) a light wrapper around the REST API of Joe Sandbox
 (2) a command line script to interact with Joe Sandbox

"""

from __future__ import print_function
from __future__ import unicode_literals
from __future__ import division

import os
import sys
import io
import json
import copy
import argparse
import time
import itertools
import random
import errno
import shutil
import tempfile

try:
    import requests
except ImportError:
    print("Please install the Python 'requests' package via pip", file=sys.stderr)
    sys.exit(1)

__version__ = "3.8.0"

# API URL.
API_URL = "https://jbxcloud.joesecurity.org/api"
# for on-premise installations, use the following URL:
# API_URL = "http://" + webserveraddress + "/joesandbox/index.php/api"

# APIKEY, to generate goto user settings - API key
API_KEY = ""

# (for Joe Sandbox Cloud only)
# Set to True if you agree to the Terms and Conditions.
# https://jbxcloud.joesecurity.org/resources/termsandconditions.pdf
ACCEPT_TAC = False

# default submission parameters
# when specifying None, the server decides
UnsetBool = object()
submission_defaults = {
    # system selection, set to None for automatic selection
    # 'systems': ('w7', 'w7x64'),
    'systems': None,
    # comment for an analysis
    'comments': None,
    # maximum analysis time
    'analysis-time': None,
    # password for decrypting documents like MS Office and PDFs
    'document-password': None,
    # This password will be used to decrypt archives (zip, 7z, rar etc.). Default password is "infected".
    'archive-password': None,
    # Will start the sample with the given command-line argument. Currently only available for Windows analyzers.
    'command-line-argument': None,
    # country for routing internet through
    'localized-internet-country': None,
    # tags
    'tags': None,
    # enable internet access during analysis
    'internet-access': UnsetBool,
    # enable internet simulation during analysis
    'internet-simulation': UnsetBool,
    # lookup samples in the report cache
    'report-cache': UnsetBool,
    # hybrid code analysis
    'hybrid-code-analysis': UnsetBool,
    # hybrid decompilation
    'hybrid-decompilation': UnsetBool,
    # inspect ssl traffic
    'ssl-inspection': UnsetBool,
    # instrumentation of vba scripts
    'vba-instrumentation': UnsetBool,
    # instrumentation of javascript
    'js-instrumentation': UnsetBool,
    # traces Java JAR files
    'java-jar-tracing': UnsetBool,
    # traces .Net files
    'dotnet-tracing': UnsetBool,
    # send an e-mail upon completion of the analysis
    'email-notification': UnsetBool,
    # only run static analysis. Disables the dynamic analysis.
    'static-only': UnsetBool,
    # starts the Sample with normal user privileges
    'start-as-normal-user': UnsetBool,
    # tries to bypass time-aware samples which check the system date
    'anti-evasion-date': UnsetBool,
    # changes the locale, location, and keyboard layout of the analysis machine
    'language-and-locale': None,
    # Do not unpack archive files (zip, 7zip etc).
    'archive-no-unpack': UnsetBool,
    # Enable Hypervisor based Inspection
    "hypervisor-based-inspection": UnsetBool,
    # select fast mode for a faster but less thorough analysis
    'fast-mode': UnsetBool,
    # Enables secondary Results such as Yara rule generation, classification via Joe Sandbox Class as well as several detail reports.
    # Analysis will run faster if secondary results are not enabled.
    'secondary-results': UnsetBool,
    # Perform APK DEX code instrumentation. Only applies to Android analyzer. Default true.
    'apk-instrumentation': UnsetBool,
    # Perform AMSI unpacking. Only applies to Windows. Default true
    'amsi-unpacking': UnsetBool,
    # Use remote assistance. Only applies to Windows. Requires user interaction via the web UI. Default false
    'remote-assistance': UnsetBool,
    # Use view-only remote assistance. Only applies to Windows. Visible only through the web UI. Default false
    'remote-assistance-view-only': UnsetBool,
    # encryption password for analyses
    'encrypt-with-password': None,

    ## JOE SANDBOX CLOUD EXCLUSIVE PARAMETERS

    # export the report to Joe Sandbox View
    'export-to-jbxview': UnsetBool,
    # lookup the reputation of URLs and domains (Requires sending URLs third-party services.)
    'url-reputation': UnsetBool,
    # Delete the analysis after X days
    'delete-after-days': None,

    ## ON PREMISE EXCLUSIVE PARAMETERS

    # priority of submissions
    'priority': None,

    ## DEPRECATED PARAMETERS
    'office-files-password': None,
}

class JoeSandbox(object):
    def __init__(self, apikey=None, apiurl=None, accept_tac=None,
                       timeout=None, verify_ssl=True, retries=3,
                       proxies=None, user_agent=None):
        """
        Create a JoeSandbox object.

        Parameters:
          apikey:     the api key
          apiurl:     the api url
          accept_tac: Joe Sandbox Cloud requires accepting the Terms and Conditions.
                      https://jbxcloud.joesecurity.org/resources/termsandconditions.pdf
          timeout:    Timeout in seconds for accessing the API. Raises a ConnectionError on timeout.
          verify_ssl: Enable or disable checking SSL certificates.
          retries:    Number of times requests should be retried if they timeout.
          proxies:    Proxy settings, see the requests library for more information:
                      http://docs.python-requests.org/en/master/user/advanced/#proxies
          user_agent: The user agent. Use this when you write an integration with Joe Sandbox
                      so that it is possible to track how often an integration is being used.
        """

        if apikey is None:
            apikey = os.environ.get("JBX_API_KEY", API_KEY)

        if apiurl is None:
            apiurl = os.environ.get("JBX_API_URL", API_URL)

        if accept_tac is None:
            if "JBX_ACCEPT_TAC" in os.environ:
                accept_tac = os.environ.get("JBX_ACCEPT_TAC") == "1"
            else:
                accept_tac = ACCEPT_TAC

        self.apikey = apikey
        self.apiurl = apiurl.rstrip("/")
        self.accept_tac = accept_tac
        self.timeout = timeout
        self.retries = retries

        if user_agent:
            user_agent += " (jbxapi.py {})".format(__version__)
        else:
            user_agent = "jbxapi.py {}".format(__version__)

        self.session = requests.Session()
        self.session.verify = verify_ssl
        self.session.proxies = proxies
        self.session.headers.update({"User-Agent": user_agent})

    def analysis_list(self):
        """
        Fetch a list of all analyses.

        Consider using `analysis_list_paged` instead.
        """
        return list(self.analysis_list_paged())

    def analysis_list_paged(self):
        """
        Fetch all analyses. Returns an iterator.

        The returned iterator can throw an exception anytime `next()` is called on it.
        """

        pagination_next = None
        while True:
            response = self._post(self.apiurl + '/v2/analysis/list', data={
                "apikey": self.apikey,
                "pagination": "1",
                "pagination_next": pagination_next,
            })

            data = self._raise_or_extract(response)
            for item in data:
                yield item

            try:
                pagination_next = response.json()["pagination"]["next"]
            except KeyError:
                break

    def submit_sample(self, sample, cookbook=None, params={}, _extra_params={}):
        """
        Submit a sample and returns the submission id.

        Parameters:
          sample:       The sample to submit. Needs to be a file-like object or a tuple in
                        the shape (filename, file-like object).
          cookbook:     Uploads a cookbook together with the sample. Needs to be a file-like object or a
                        tuple in the shape (filename, file-like object)
          params:       Customize the sandbox parameters. They are described in more detail
                        in the default submission parameters.

        Example:

            import jbxapi

            joe = jbxapi.JoeSandbox(user_agent="My Integration")
            with open("sample.exe", "rb") as f:
                joe.submit_sample(f, params={"systems": ["w7"]})

        Example:

            import io, jbxapi

            joe = jbxapi.JoeSandbox(user_agent="My Integration")

            cookbook = io.BytesIO(b"cookbook content")
            with open("sample.exe", "rb") as f:
                joe.submit_sample(f, cookbook=cookbook)
        """
        self._check_user_parameters(params)

        files = {'sample': sample}
        if cookbook:
            files['cookbook'] = cookbook

        return self._submit(params, files, _extra_params=_extra_params)

    def submit_sample_url(self, url, params={}, _extra_params={}):
        """
        Submit a sample at a given URL for analysis.
        """
        self._check_user_parameters(params)
        params = copy.copy(params)
        params['sample-url'] = url
        return self._submit(params, _extra_params=_extra_params)

    def submit_url(self, url, params={}, _extra_params={}):
        """
        Submit a website for analysis.
        """
        self._check_user_parameters(params)
        params = copy.copy(params)
        params['url'] = url
        return self._submit(params, _extra_params=_extra_params)

    def submit_cookbook(self, cookbook, params={}, _extra_params={}):
        """
        Submit a cookbook.
        """
        self._check_user_parameters(params)
        files = {'cookbook': cookbook}
        return self._submit(params, files, _extra_params=_extra_params)

    def _prepare_params_for_submission(self, params):
        params['apikey'] = self.apikey
        params['accept-tac'] = "1" if self.accept_tac else "0"

        # rename array parameters
        params['systems[]'] = params.pop('systems', None)
        params['tags[]'] = params.pop('tags', None)

        # rename aliases
        if 'document-password' in params:
            params['office-files-password'] = params.pop('document-password')

        # submit booleans as "0" and "1"
        for key, value in params.items():
            try:
                default = submission_defaults[key]
            except KeyError:
                continue

            if default is True or default is False or default is UnsetBool:
                if value is None or value is UnsetBool:
                    params[key] = None
                else:
                    params[key] = "1" if value else "0"

        return params

    def _submit(self, params, files=None, _extra_params={}):
        data = copy.copy(submission_defaults)
        data.update(params)
        data = self._prepare_params_for_submission(data)
        data.update(_extra_params)

        response = self._post(self.apiurl + '/v2/submission/new', data=data, files=files)

        return self._raise_or_extract(response)

    def submission_info(self, submission_id):
        """
        Returns information about a submission including all the analysis ids.
        """
        response = self._post(self.apiurl + '/v2/submission/info', data={'apikey': self.apikey, 'submission_id': submission_id})

        return self._raise_or_extract(response)

    def submission_delete(self, submission_id):
        """
        Delete a submission.
        """
        response = self._post(self.apiurl + '/v2/submission/delete', data={'apikey': self.apikey, 'submission_id': submission_id})

        return self._raise_or_extract(response)

    def server_online(self):
        """
        Returns True if the Joe Sandbox servers are running or False if they are in maintenance mode.
        """
        response = self._post(self.apiurl + '/v2/server/online', data={'apikey': self.apikey})

        return self._raise_or_extract(response)

    def analysis_info(self, webid):
        """
        Show the status and most important attributes of an analysis.
        """
        response = self._post(self.apiurl + "/v2/analysis/info", data={'apikey': self.apikey, 'webid': webid})

        return self._raise_or_extract(response)

    def analysis_delete(self, webid):
        """
        Delete an analysis.
        """
        response = self._post(self.apiurl + "/v2/analysis/delete", data={'apikey': self.apikey, 'webid': webid})

        return self._raise_or_extract(response)

    def analysis_download(self, webid, type, run=None, file=None, password=None):
        """
        Download a resource for an analysis. E.g. the full report, binaries, screenshots.
        The full list of resources can be found in our API documentation.

        When `file` is given, the return value is the filename specified by the server,
        otherwise it's a tuple of (filename, bytes).

        Parameters:
            webid:    the webid of the analysis
            type:     the report type, e.g. 'html', 'bins'
            run:      specify the run. If it is None, let Joe Sandbox pick one
            file:     a writable file-like object (When omitted, the method returns
                      the data as a bytes object.)
            password: a password for decrypting a resource (see the
                      encrypt-with-password submission option)

        Example:

            name, json_report = joe.analysis_download(123456, 'jsonfixed')

        Example:

            with open("full_report.html", "wb") as f:
                name = joe.analysis_download(123456, "html", file=f)
        """

        # when no file is specified, we create our own
        if file is None:
            _file = io.BytesIO()
        else:
            _file = file

        # password-encrypted resources have to be stored in a temp file first
        if password:
            _decrypted_file = _file
            _file = tempfile.TemporaryFile()

        data = {
            'apikey': self.apikey,
            'webid': webid,
            'type': type,
            'run': run,
        }

        response = self._post(self.apiurl + "/v2/analysis/download", data=data, stream=True)

        try:
            filename = response.headers["Content-Disposition"].split("filename=")[1][1:-2]
        except Exception as e:
            filename = type

        # do standard error handling when encountering an error (i.e. throw an exception)
        if not response.ok:
            self._raise_or_extract(response)
            raise RuntimeError("Unreachable because statement above should raise.")

        try:
            for chunk in response.iter_content(1024):
                _file.write(chunk)
        except requests.exceptions.RequestException as e:
            raise ConnectionError(e)

        # decrypt temporary file
        if password:
            _file.seek(0)
            self._decrypt(_file, _decrypted_file, password)
            _file.close()
            _file = _decrypted_file

        # no user file means we return the content
        if file is None:
            return (filename, _file.getvalue())
        else:
            return filename

    def analysis_search(self, query):
        """
        Lists the webids of the analyses that match the given query.

        Searches in MD5, SHA1, SHA256, filename, cookbook name, comment, url and report id.
        """
        response = self._post(self.apiurl + "/v2/analysis/search", data={'apikey': self.apikey, 'q': query})

        return self._raise_or_extract(response)

    def server_systems(self):
        """
        Retrieve a list of available systems.
        """
        response = self._post(self.apiurl + "/v2/server/systems", data={'apikey': self.apikey})

        return self._raise_or_extract(response)

    def account_info(self):
        """
        Only available on Joe Sandbox Cloud

        Show information about the account.
        """
        response = self._post(self.apiurl + "/v2/account/info", data={'apikey': self.apikey})

        return self._raise_or_extract(response)

    def server_info(self):
        """
        Query information about the server.
        """
        response = self._post(self.apiurl + "/v2/server/info", data={'apikey': self.apikey})

        return self._raise_or_extract(response)

    def server_lia_countries(self):
        """
        Show the available localized internet anonymization countries.
        """
        response = self._post(self.apiurl + "/v2/server/lia_countries", data={'apikey': self.apikey})

        return self._raise_or_extract(response)

    def server_languages_and_locales(self):
        """
        Show the available languages and locales
        """
        response = self._post(self.apiurl + "/v2/server/languages_and_locales", data={'apikey': self.apikey})

        return self._raise_or_extract(response)

    def joelab_machine_info(self, machine):
        """
        Show JoeLab Machine info.
        """
        response = self._post(self.apiurl + "/v2/joelab/machine/info", data={'apikey': self.apikey,
                                                                             'machine': machine})

        return self._raise_or_extract(response)

    def joelab_images_list(self, machine):
        """
        List available images.
        """
        response = self._post(self.apiurl + "/v2/joelab/machine/info", data={'apikey': self.apikey,
                                                                             'machine': machine})

        return self._raise_or_extract(response)

    def joelab_images_reset(self, machine, image=None):
        """
        Reset the disk image of a machine.
        """
        response = self._post(self.apiurl + "/v2/joelab/machine/info", data={'apikey': self.apikey,
                                                                             'machine': machine,
                                                                             'accept-tac': "1" if self.accept_tac else "0",
                                                                             'image': image})
        return self._raise_or_extract(response)

    def joelab_filesystem_upload(self, machine, file, path=None):
        """
        Upload a file to a Joe Lab machine.

        Parameters:
          machine       The machine id.
          file:         The file to upload. Needs to be a file-like object or a tuple in
                        the shape (filename, file-like object).
        """

        data = {
            "apikey": self.apikey,
            "accept-tac": "1" if self.accept_tac else "0",
            "machine": machine,
            "path": path,
        }
        files = {'file': file}

        response = self._post(self.apiurl + '/v2/joelab/filesystem/upload', data=data, files=files)

        return self._raise_or_extract(response)

    def joelab_filesystem_download(self, machine, path, file):
        """
        Download a file from a Joe Lab machine.

        When `file` is given, the return value is the filename specified by the server,
        otherwise it's a tuple of (filename, bytes).

        Parameters:
            machine:  The machine id.
            path:     The path of the file on the Joe Lab machine.
            file:     a writable file-like object

        Example:

            with open("myfile.zip", "wb") as f:
                joe.joelab_filesystem_download("w7_10", "C:\\windows32\\myfile.zip", f)
        """

        data = {'apikey': self.apikey,
                'machine': machine,
                'path': path}

        response = self._post(self.apiurl + "/v2/joelab/filesystem/download", data=data, stream=True)

        # do standard error handling when encountering an error (i.e. throw an exception)
        if not response.ok:
            self._raise_or_extract(response)
            raise RuntimeError("Unreachable because statement above should raise.")

        try:
            for chunk in response.iter_content(1024):
                file.write(chunk)
        except requests.exceptions.RequestException as e:
            raise ConnectionError(e)

    def joelab_network_info(self, machine):
        """
        Show Network info
        """
        response = self._post(self.apiurl + "/v2/joelab/network/info", data={'apikey': self.apikey,
                                                                             'machine': machine})

        return self._raise_or_extract(response)

    def joelab_network_update(self, machine, settings):
        """
        Update the network settings.
        """

        params = dict(settings)

        # convert booleans to "0" and "1"
        if params["internet-enabled"] is not None:
            params["internet-enabled"] = "1" if params["internet-enabled"] else "0"

        params['apikey'] = self.apikey
        params['accept-tac'] = "1" if self.accept_tac else "0"
        params['machine'] = machine

        response = self._post(self.apiurl + "/v2/joelab/network/update", data=params)

        return self._raise_or_extract(response)

    def joelab_list_exitpoints(self):
        """
        List the available internet exit points.
        """
        response = self._post(self.apiurl + "/v2/joelab/internet-exitpoints/list", data={'apikey': self.apikey})

        return self._raise_or_extract(response)

    def _decrypt(self, source, target, password):
        """
        Decrypt encrypted files downloaded from a Joe Sandbox server.
        """

        try:
            import pyzipper
        except ImportError:
            raise NotImplementedError("Decryption requires Python 3 and the pyzipper library.")

        try:
            with pyzipper.AESZipFile(source) as myzip:
                infolist = myzip.infolist()
                assert(len(infolist) == 1)

                with myzip.open(infolist[0], pwd=password) as zipmember:
                    shutil.copyfileobj(zipmember, target)
        except Exception as e:
            raise JoeException(str(e))

    def _post(self, url, data=None, **kwargs):
        """
        Wrapper around requests.post which

            (a) always inserts a timeout
            (b) converts errors to ConnectionError
            (c) re-tries a few times
        """

        # convert file names to ASCII for old urllib versions if necessary
        _urllib3_fix_filenames(kwargs)

        # try the request a few times
        for i in itertools.count(1):
            try:
                return self.session.post(url, data=data, timeout=self.timeout, **kwargs)
            except requests.exceptions.Timeout as e:
                # exhausted all retries
                if i >= self.retries:
                    raise ConnectionError(e)
            except requests.exceptions.RequestException as e:
                raise ConnectionError(e)

            # exponential backoff
            max_backoff = 4 ** i / 10   # .4, 1.6, 6.4, 25.6, ...
            time.sleep(random.uniform(0, max_backoff))

    def _check_user_parameters(self, user_parameters):
        """
        Verifies that the parameter dict given by the user only contains
        known keys. This ensures that the user detects typos faster.
        """
        if not user_parameters:
            return

        # sanity check against typos
        for key in user_parameters:
            if key not in submission_defaults:
                raise ValueError("Unknown parameter {0}".format(key))

    def _raise_or_extract(self, response):
        """
        Raises an exception if the response indicates an API error.

        Otherwise returns the object at the 'data' key of the API response.
        """

        try:
            data = response.json()
        except ValueError:
            raise JoeException("The server responded with an unexpected format ({}). Is the API url correct?". format(response.status_code))

        try:
            if response.ok:
                return data['data']
            else:
                error = data['errors'][0]
                raise ApiError(error)
        except (KeyError, TypeError):
            raise JoeException("Unexpected data ({}). Is the API url correct?". format(response.status_code))

class JoeException(Exception):
    pass

class ConnectionError(JoeException):
    pass

class ApiError(JoeException):
    def __new__(cls, raw):
        # select a more specific subclass if available
        if cls is ApiError:
            subclasses = {
                2: MissingParameterError,
                3: InvalidParameterError,
                4: InvalidApiKeyError,
                5: ServerOfflineError,
                6: InternalServerError,
                7: PermissionError,
            }

            try:
                cls = subclasses[raw["code"]]
            except KeyError:
                pass

        return super(ApiError, cls).__new__(cls, raw["message"])

    def __init__(self, raw):
        super(ApiError, self).__init__(raw["message"])
        self.raw = copy.deepcopy(raw)
        self.code = raw["code"]
        self.message = raw["message"]

class MissingParameterError(ApiError): pass
class InvalidParameterError(ApiError): pass
class InvalidApiKeyError(ApiError): pass
class ServerOfflineError(ApiError): pass
class InternalServerError(ApiError): pass
class PermissionError(ApiError): pass

def _cli_bytes_from_str(text):
    """
    Python 2/3 compatibility function to ensure that what is sent on the command line
    is converted into bytes. In Python 2 this is a no-op.
    """
    if isinstance(text, bytes):
        return text
    else:
        return text.encode("utf-8", errors="surrogateescape")


def cli(argv):
    def print_json(value, file=sys.stdout):
        print(json.dumps(value, indent=4, sort_keys=True), file=file)

    def analysis_list(joe, args):
        print_json(joe.analysis_list())

    def submit(joe, args):
        params = {name[6:]: value for name, value in vars(args).items()
                                  if name.startswith("param-") and value is not None}

        extra_params = {}
        for name, value in args.extra_params:
            values = extra_params.setdefault(name, [])
            values.append(value)

        if args.url_mode:
            print_json(joe.submit_url(args.sample, params=params, _extra_params=extra_params))
        elif args.sample_url_mode:
            print_json(joe.submit_sample_url(args.sample, params=params, _extra_params=extra_params))
        else:
            try:
                f_cookbook = open(args.cookbook, "rb") if args.cookbook is not None else None

                def _submit_file(path):
                    with open(path, "rb") as f:
                        print_json(joe.submit_sample(f, params=params, _extra_params=extra_params, cookbook=f_cookbook))

                if os.path.isdir(args.sample):
                    for dirpath, _, filenames in os.walk(args.sample):
                        for filename in filenames:
                            _submit_file(os.path.join(dirpath, filename))
                else:
                    _submit_file(args.sample)
            finally:
                if f_cookbook is not None:
                    f_cookbook.close()

    def submission_info(joe, args):
        print_json(joe.submission_info(args.submission_id))

    def submission_delete(joe, args):
        print_json(joe.submission_delete(args.submission_id))

    def server_online(joe, args):
        print_json(joe.server_online())

    def analysis_info(joe, args):
        print_json(joe.analysis_info(args.webid))

    def analysis_delete(joe, args):
        print_json(joe.analysis_delete(args.webid))

    def account_info(joe, args):
        print_json(joe.account_info())

    def server_info(joe, args):
        print_json(joe.server_info())

    def server_lia_countries(joe, args):
        print_json(joe.server_lia_countries())

    def server_languages_and_locales(joe, args):
        print_json(joe.server_languages_and_locales())

    def analysis_report(joe, args):
        (_, report) = joe.analysis_download(args.webid, type="irjsonfixed", run=args.run, password=args.password)
        try:
            print_json(json.loads(report))
        except json.JSONDecodeError as e:
            raise JoeException("Invalid response. Is the API url correct?")

    def analysis_download(joe, args):
        if args.dir is None:
            args.dir = args.webid
            try:
                os.mkdir(args.dir)
            except Exception as e:
                # ignore if it already exists
                if e.errno != errno.EEXIST:
                    raise

        paths = {}
        errors = []
        for type in args.types:
            try:
                (filename, data) = joe.analysis_download(args.webid, type=type, run=args.run, password=args.password)
            except ApiError as e:
                if not args.ignore_errors:
                    raise

                print(e.message, file=sys.stderr)
                paths[type] = None
                errors.append(e)
                continue

            path = os.path.join(args.dir, filename)
            paths[type] = os.path.abspath(path)
            try:
                with open(path, "wb") as f:
                    f.write(data)
            except Exception as e:
                # delete incomplete data in case of an exception
                os.remove(path)
                raise

        if errors and all(p is None for p in paths.values()):
            raise errors[0]

        print_json(paths)

    def analysis_search(joe, args):
        print_json(joe.analysis_search(args.searchterm))

    def server_systems(joe, args):
        print_json(joe.server_systems())

    def joelab_machine_info(joe, args):
        print_json(joe.joelab_machine_info(args.machine))

    def joelab_filesystem_upload(joe, args):
        with open(args.file, "rb") as f:
            print_json(joe.joelab_filesystem_upload(args.machine, f, args.path))

    def joelab_filesystem_download(joe, args):
        output_path = args.destination
        if os.path.isdir(output_path):
            filename = os.path.basename(args.path.replace("\\", "/"))
            output_path = os.path.join(output_path, filename)

        with open(output_path, "wb") as f:
            joe.joelab_filesystem_download(args.machine, args.path, f)

        print_json({"path": os.path.abspath(output_path)})

    def joelab_images_list(joe, args):
        print_json(joe.joelab_images_list(args.machine))

    def joelab_images_reset(joe, args):
        print_json(joe.joelab_images_reset(args.machine, args.image))

    def joelab_network_info(joe, args):
        print_json(joe.joelab_network_info(args.machine))

    def joelab_network_update(joe, args):
        print(args)
        return

        print_json(joe.joelab_network_update(args.machine, {
            "internet-enabled": args.enable_internet,
            "internet-exitpoint": args.internet_exitpoint,
        }))

    def joelab_exitpoints_list(joe, args):
        print_json(joe.joelab_list_exitpoints())

    # common arguments
    common_parser = argparse.ArgumentParser(add_help=False)
    common_group = common_parser.add_argument_group("common arguments")
    common_group.add_argument('--apiurl',
        help="Api Url (You can also set the env. variable JBX_API_URL.)")
    common_group.add_argument('--apikey',
        help="Api Key (You can also set the env. variable JBX_API_KEY.)")
    common_group.add_argument('--accept-tac', action='store_true', default=None,
        help="(Joe Sandbox Cloud only): Accept the terms and conditions: "
        "https://jbxcloud.joesecurity.org/download/termsandconditions.pdf "
        "(You can also set the env. variable ACCEPT_TAC=1.)")
    common_group.add_argument('--no-check-certificate', action="store_true",
        help="Do not check the server certificate.")
    common_group.add_argument('--version', action='store_true',
            help="Show version and exit.")

    parser = argparse.ArgumentParser(description="Joe Sandbox Web API")

    # add subparsers
    subparsers = parser.add_subparsers(metavar="<command>", title="commands")
    subparsers.required = True

    # submit <filepath>
    submit_parser = subparsers.add_parser('submit', parents=[common_parser],
            usage="%(prog)s [--apiurl APIKEY] [--apikey APIKEY] [--accept-tac]\n" +
                  24 * " " + "[parameters ...]\n" +
                  24 * " " + "[--url | --sample-url | --cookbook COOKBOOK]\n" +
                  24 * " " + "sample",
            help="Submit a sample to Joe Sandbox.")
    submit_parser.add_argument('sample',
            help="Path or URL to the sample.")

    group = submit_parser.add_argument_group("submission mode")
    submission_mode_parser = group.add_mutually_exclusive_group(required=False)
    # url submissions
    submission_mode_parser.add_argument('--url', dest="url_mode", action="store_true",
            help="Analyse the given URL instead of a sample.")
    # sample url submissions
    submission_mode_parser.add_argument('--sample-url', dest="sample_url_mode", action="store_true",
            help="Download the sample from the given url.")
    # cookbook submission
    submission_mode_parser.add_argument('--cookbook', dest="cookbook",
            help="Use the given cookbook.")

    submit_parser.add_argument('--param', dest="extra_params", default=[], action="append", nargs=2, metavar=("NAME", "VALUE"),
            help="Specify additional parameters.")
    submit_parser.set_defaults(func=submit)

    params = submit_parser.add_argument_group('analysis parameters')

    def add_bool_param(parser, *names, **kwargs):
        dest = kwargs.pop("dest")
        help = kwargs.pop("help", None)
        assert(not kwargs)

        negative_names = []
        for name in names:
            if name.startswith("--no-"):
                negative_names.append("-" + name[4:])
            else:
                negative_names.append("--no-" + name[2:])

        parser.add_argument(*names, dest=dest, action="store_true", default=None, help=help)
        parser.add_argument(*negative_names, dest=dest, action="store_false", default=None)

    params.add_argument("--comments", dest="param-comments", metavar="TEXT",
            help="Comment for the analysis.")
    params.add_argument("--system", dest="param-systems", action="append", metavar="SYSTEM",
            help="Select systems. Can be specified multiple times.")
    params.add_argument("--analysis-time", dest="param-analysis-time", metavar="SEC",
            help="Analysis time in seconds.")
    add_bool_param(params, "--internet", dest="param-internet-access",
            help="Enable Internet Access (on by default).")
    add_bool_param(params, "--internet-simulation", dest="param-internet-simulation",
            help="Enable Internet Simulation. No Internet Access is granted.")
    add_bool_param(params, "--cache", dest="param-report-cache",
            help="Check cache for a report before analyzing the sample.")
    params.add_argument("--document-password", dest="param-document-password", metavar="PASSWORD",
            help="Password for decrypting documents like MS Office and PDFs")
    params.add_argument("--archive-password", dest="param-archive-password", metavar="PASSWORD",
            help="This password will be used to decrypt archives (zip, 7z, rar etc.). Default password is 'infected'.")
    params.add_argument("--command-line-argument", dest="param-command-line-argument", metavar="TEXT",
            help="Will start the sample with the given command-line argument. Currently only available for Windows analyzers.")
    add_bool_param(params, "--hca", dest="param-hybrid-code-analysis",
            help="Enable hybrid code analysis (on by default).")
    add_bool_param(params, "--dec", dest="param-hybrid-decompilation",
            help="Enable hybrid decompilation.")
    add_bool_param(params, "--ssl-inspection", dest="param-ssl-inspection",
            help="Inspect SSL traffic")
    add_bool_param(params, "--vbainstr", dest="param-vba-instrumentation",
            help="Enable VBA script instrumentation (on by default).")
    add_bool_param(params, "--jsinstr", dest="param-js-instrumentation",
            help="Enable JavaScript instrumentation (on by default).")
    add_bool_param(params, "--java", dest="param-java-jar-tracing",
            help="Enable Java JAR tracing (on by default).")
    add_bool_param(params, "--net", dest="param-dotnet-tracing",
            help="Enable .Net tracing.")
    add_bool_param(params, "--normal-user", dest="param-start-as-normal-user",
            help="Start sample as normal user.")
    add_bool_param(params, "--anti-evasion-date", dest="param-anti-evasion-date",
            help="Bypass time-aware samples.")
    add_bool_param(params, "--no-unpack", "--archive-no-unpack", dest="param-archive-no-unpack",
            help="Do not unpack archive (zip, 7zip etc).")
    add_bool_param(params, "--hypervisor-based-inspection", dest="param-hypervisor-based-inspection",
            help="Enable Hypervisor based Inspection.")
    params.add_argument("--localized-internet-country", "--lia", dest="param-localized-internet-country", metavar="NAME",
            help="Country for routing internet traffic through.")
    params.add_argument("--language-and-locale", "--langloc", dest="param-language-and-locale", metavar="NAME",
            help="Language and locale to be set on Windows analyzer.")
    params.add_argument("--tag", dest="param-tags", action="append", metavar="TAG",
            help="Add tags to the analysis.")
    params.add_argument("--delete-after-days", "--delafter", type=int, dest="param-delete-after-days", metavar="DAYS",
            help="Delete analysis after X days.")
    add_bool_param(params, "--fast-mode", dest="param-fast-mode",
            help="Fast Mode focusses on fast analysis and detection versus deep forensic analysis.")
    add_bool_param(params, "--secondary-results", dest="param-secondary-results",
            help="Enables secondary results such as Yara rule generation, classification via Joe Sandbox Class as "
                 "well as several detail reports. "
                 "Analysis will run faster with disabled secondary results.")
    add_bool_param(params, "--apk-instrumentation", dest="param-apk-instrumentation",
            help="Perform APK DEX code instrumentation. Only applies to Android analyzer. Default on.")
    add_bool_param(params, "--amsi-unpacking", dest="param-amsi-unpacking",
            help="Perform AMSI unpacking. Only applies to Windows analyzer. Default on.")
    add_bool_param(params, "--remote-assistance", dest="param-remote-assistance",
            help="Use remote assistance. Only applies to Windows. Requires user interaction via the web UI. "
                 "Default off. If enabled, disables VBA instrumentation.")
    add_bool_param(params, "--remote-assistance-view-only", dest="param-remote-assistance-view-only",
            help="Use view-only remote assistance. Only applies to Windows. Visible only through the web UI. Default off.")
    params.add_argument("--encrypt-with-password", "--encrypt", type=_cli_bytes_from_str,
            dest="param-encrypt-with-password", metavar="PASSWORD",
            help="Encrypt the analysis data with the given password")

    # deprecated
    params.add_argument("--office-pw", dest="param-document-password", metavar="PASSWORD",
            help=argparse.SUPPRESS)

    # submission <command>
    submission_parser = subparsers.add_parser('submission',
            help="Manage submissions")
    submission_subparsers = submission_parser.add_subparsers(metavar="<submission command>", title="submission commands")
    submission_subparsers.required = True

    # submission info <submission_id>
    submission_info_parser = submission_subparsers.add_parser('info', parents=[common_parser],
            help="Show info about a submission.")
    submission_info_parser.add_argument('submission_id',
            help="Id of the submission.")
    submission_info_parser.set_defaults(func=submission_info)

    # submission delete <submission_id>
    submission_delete_parser = submission_subparsers.add_parser('delete', parents=[common_parser],
            help="Delete a submission.")
    submission_delete_parser.add_argument('submission_id',
            help="Id of the submission.")
    submission_delete_parser.set_defaults(func=submission_delete)

    # analysis <command>
    analysis_parser = subparsers.add_parser('analysis',
            help="Manage analyses")
    analysis_subparsers = analysis_parser.add_subparsers(metavar="<analysis command>", title="analysis commands")
    analysis_subparsers.required = True

    # analysis info
    analysis_info_parser = analysis_subparsers.add_parser('info', parents=[common_parser],
            help="Show information about an analysis.")
    analysis_info_parser.set_defaults(func=analysis_info)
    analysis_info_parser.add_argument('webid',
            help="Id of the analysis.")

    # analysis delete
    analysis_delete_parser = analysis_subparsers.add_parser('delete', parents=[common_parser],
            help="Delete an analysis.")
    analysis_delete_parser.set_defaults(func=analysis_delete)
    analysis_delete_parser.add_argument('webid',
            help="Id of the analysis.")

    # analysis list
    analysis_list_parser = analysis_subparsers.add_parser('list', parents=[common_parser],
            help="Show all submitted analyses.")
    analysis_list_parser.set_defaults(func=analysis_list)

    # analysis search <term>
    analysis_search_parser = analysis_subparsers.add_parser('search', parents=[common_parser],
            help="Search for analyses.")
    analysis_search_parser.add_argument('searchterm',
            help="Search term.")
    analysis_search_parser.set_defaults(func=analysis_search)

    # analysis report <id>
    report_parser = analysis_subparsers.add_parser('report', parents=[common_parser],
            help="Print the irjsonfixed report.")
    report_parser.add_argument('webid',
            help="Webid of the analysis.")
    report_parser.add_argument('--run', type=int,
            help="Select the run.")
    report_parser.add_argument('--password', type=_cli_bytes_from_str,
            help="Password for decrypting the report (see encrypt-with-password)")
    report_parser.set_defaults(func=analysis_report)

    # analysis download <id> [resource, resource, ...]
    download_parser = analysis_subparsers.add_parser('download', parents=[common_parser],
            help="Download resources of an analysis.")
    download_parser.add_argument('webid',
            help="Webid of the analysis.")
    download_parser.add_argument('--dir',
            help="Directory to store the reports in. "
                 "Defaults to <webid> in the current working directory. (Will be created.)")
    download_parser.add_argument('--run', type=int,
            help="Select the run. Omitting this option lets Joe Sandbox choose a run.")
    download_parser.add_argument('--ignore-errors', action="store_true",
            help="Report the paths as 'null' instead of aborting on the first error."
                 " In case no resource can be downloaded, an error is still raised.")
    download_parser.add_argument('--password', type=_cli_bytes_from_str,
            help="Password for decrypting the report (see encrypt-with-password)")
    download_parser.add_argument('types', nargs='*', default=['html'],
            help="Resource types to download. Consult the help for all types. "
                 "(default 'html')")
    download_parser.set_defaults(func=analysis_download)

    # account <command>
    account_parser = subparsers.add_parser('account',
            help="Query account info (Cloud Pro only)")
    account_subparsers = account_parser.add_subparsers(metavar="<command>", title="account commands")
    account_subparsers.required = True

    # account info
    account_info_parser = account_subparsers.add_parser('info', parents=[common_parser],
            help="Show information about the Joe Sandbox Cloud Pro account.")
    account_info_parser.set_defaults(func=account_info)

    # server
    server_parser = subparsers.add_parser('server',
            help="Query server info")
    server_subparsers = server_parser.add_subparsers(metavar="<server command>", title="server commands")
    server_subparsers.required = True

    # server online
    online_parser = server_subparsers.add_parser('online', parents=[common_parser],
            help="Determine whether the Joe Sandbox servers are online or in maintenance mode.")
    online_parser.set_defaults(func=server_online)

    # server info
    server_info_parser = server_subparsers.add_parser('info', parents=[common_parser],
            help="Show information about the server.")
    server_info_parser.set_defaults(func=server_info)

    # server systems
    server_systems_parser = server_subparsers.add_parser('systems', parents=[common_parser],
            help="List all available systems.")
    server_systems_parser.set_defaults(func=server_systems)

    # server lia countries
    server_lia_parser = server_subparsers.add_parser('lia_countries', parents=[common_parser],
            help="Show available localized internet anonymization countries.")
    server_lia_parser.set_defaults(func=server_lia_countries)

    # server languages_and_locales
    server_langloc_parser = server_subparsers.add_parser('languages_and_locales', parents=[common_parser],
            help="Show available languages and locales for Windows.")
    server_langloc_parser.set_defaults(func=server_languages_and_locales)

    # joelab <command>
    joelab_parser = subparsers.add_parser('joelab',
            help="Joe Lab Commands")
    joelab_subparsers = joelab_parser.add_subparsers(metavar="<command>", title="joelab commands")
    joelab_subparsers.required = True

    # joelab machine <command>
    joelab_machine_parser = joelab_subparsers.add_parser('machine',
            help="Machine Commands")
    joelab_machine_subparsers = joelab_machine_parser.add_subparsers(metavar="<command>", title="machine commands")
    joelab_machine_subparsers.required = True

    # joelab machine info
    joelab_machine_info_parser = joelab_machine_subparsers.add_parser('info', parents=[common_parser],
            help="Show machine info")
    joelab_machine_info_parser.add_argument("--machine", required=True, help="Joe Lab machine ID")
    joelab_machine_info_parser.set_defaults(func=joelab_machine_info)

    # joelab filesystem <command>
    joelab_filesystem_parser = joelab_subparsers.add_parser('filesystem',
            help="Filesystem Commands")
    joelab_filesystem_subparsers = joelab_filesystem_parser.add_subparsers(metavar="<command>", title="filesystem commands")
    joelab_filesystem_subparsers.required = True

    # joelab filesystem upload
    joelab_filesystem_upload_parser = joelab_filesystem_subparsers.add_parser('upload', parents=[common_parser],
            help="Upload a file to a Joe Lab machine")
    joelab_filesystem_upload_parser.add_argument("--machine", required=True, help="Machine ID")
    joelab_filesystem_upload_parser.add_argument("file", help="File to upload")
    joelab_filesystem_upload_parser.add_argument("--path", help="Path on the Joe Lab machine")
    joelab_filesystem_upload_parser.set_defaults(func=joelab_filesystem_upload)

    # joelab filesystem download
    joelab_filesystem_download_parser = joelab_filesystem_subparsers.add_parser('download', parents=[common_parser],
            help="Download a file")
    joelab_filesystem_download_parser.add_argument("--machine", required=True, help="Machine ID")
    joelab_filesystem_download_parser.add_argument("path", help="Path of file on the Joe Lab machine")
    joelab_filesystem_download_parser.add_argument("-d", "--destination", default=".", help="Destination", metavar="PATH")
    joelab_filesystem_download_parser.set_defaults(func=joelab_filesystem_download)

    # joelab images <command>
    joelab_images_parser = joelab_subparsers.add_parser('images',
            help="Images Commands")
    joelab_images_subparsers = joelab_images_parser.add_subparsers(metavar="<command>", title="images commands")
    joelab_images_subparsers.required = True

    # joelab images list
    joelab_images_list_parser = joelab_images_subparsers.add_parser('list', parents=[common_parser],
            help="List the stored images.")
    joelab_images_list_parser.add_argument("--machine", required=True, help="Joe Lab machine ID")
    joelab_images_list_parser.set_defaults(func=joelab_images_list)

    # joelab images reset
    joelab_images_reset_parser = joelab_images_subparsers.add_parser('reset', parents=[common_parser],
            help="Reset machine to an image")
    joelab_images_reset_parser.add_argument("--machine", required=True, help="Joe Lab machine ID")
    joelab_images_reset_parser.add_argument("--image", help="Image ID")
    joelab_images_reset_parser.set_defaults(func=joelab_images_reset)

    # joelab network <command>
    joelab_network_parser = joelab_subparsers.add_parser('network',
            help="Network Commands")
    joelab_network_subparsers = joelab_network_parser.add_subparsers(metavar="<command>", title="network commands")
    joelab_network_subparsers.required = True

    # joelab network info
    joelab_network_info_parser = joelab_network_subparsers.add_parser('info', parents=[common_parser],
            help="Get network info")
    joelab_network_info_parser.add_argument("--machine", required=True, help="Joe Lab machine ID")
    joelab_network_info_parser.set_defaults(func=joelab_network_info)

    # joelab network update
    joelab_network_update_parser = joelab_network_subparsers.add_parser('update', parents=[common_parser],
            help="Update the network settings of a Joe Lab Machine")
    joelab_network_update_parser.add_argument("--machine", required=True, help="Joe Lab machine ID")
    joelab_network_update_parser.add_argument("--enable-internet", dest="enable_internet", action="store_true", default=None,
            help="Enable Internet")
    joelab_network_update_parser.add_argument("--disable-internet", dest="enable_internet", action="store_false", default=None)
    joelab_network_update_parser.add_argument("--internet-exitpoint")
    joelab_network_update_parser.set_defaults(func=joelab_network_update)

    # joelab internet-exitpoints <command>
    joelab_exitpoints_parser = joelab_subparsers.add_parser('internet-exitpoints',
            help="Exitpoints Commands")
    joelab_exitpoints_subparsers = joelab_exitpoints_parser.add_subparsers(metavar="<command>", title="internet exitpoints commands")
    joelab_exitpoints_subparsers.required = True

    # joelab internet-exitpoints list
    joelab_exitpoints_list_parser = joelab_exitpoints_subparsers.add_parser('list', parents=[common_parser],
            help="List the available internet exitpoints")
    joelab_exitpoints_list_parser.set_defaults(func=joelab_exitpoints_list)

    # Parse common args first, this allows
    # i.e. jbxapi.py --apikey 1234 list
    # and  jbxapi.py list --apikey 1234
    common_args, remaining = common_parser.parse_known_args(argv)

    if common_args.version:
        print(__version__)
        sys.exit()

    args = parser.parse_args(remaining)
    # overwrite args with common_args
    vars(args).update(vars(common_args))

    # run command
    joe = JoeSandbox(apikey=args.apikey,
                     apiurl=args.apiurl,
                     accept_tac=args.accept_tac,
                     user_agent="CLI",
                     verify_ssl=not args.no_check_certificate)
    try:
        args.func(joe, args)
    except ApiError as e:
        print_json(e.raw)
        sys.exit(e.code + 100) # api errors start from 100
    except ConnectionError as e:
        print_json({
            "code": 1,
            "message": str(e),
        })
        sys.exit(3)
    except (OSError, IOError) as e:
        print_json({
            "code": 1,
            "message": str(e),
        })
        sys.exit(4)
    except JoeException as e:
        print_json({
            "code": 1,
            "message": str(e),
        })
        sys.exit(5)


def main(argv=None):
    # Workaround for a bug in Python 2.7 where sys.argv arguments are converted to ASCII and
    # non-ascii characters are replaced with '?'.
    #
    # https://bugs.python.org/issue2128
    # https://stackoverflow.com/q/846850/
    if sys.version_info[0] == 2 and sys.platform.startswith('win32'):
        def win32_unicode_argv():
            """Uses shell32.GetCommandLineArgvW to get sys.argv as a list of Unicode strings.
            """

            from ctypes import POINTER, byref, cdll, c_int, windll
            from ctypes.wintypes import LPCWSTR, LPWSTR

            GetCommandLineW = cdll.kernel32.GetCommandLineW
            GetCommandLineW.argtypes = []
            GetCommandLineW.restype = LPCWSTR

            CommandLineToArgvW = windll.shell32.CommandLineToArgvW
            CommandLineToArgvW.argtypes = [LPCWSTR, POINTER(c_int)]
            CommandLineToArgvW.restype = POINTER(LPWSTR)

            cmd = GetCommandLineW()
            argc = c_int(0)
            argv = CommandLineToArgvW(cmd, byref(argc))
            if argc.value > 0:
                # Remove Python executable and commands if present
                start = argc.value - len(sys.argv)
                return [argv[i] for i in
                        xrange(start, argc.value)]

        sys.argv = win32_unicode_argv()

    cli(argv if argv is not None else sys.argv[1:])


def _urllib3_fix_filenames(kwargs):
    """
    Remove non-ASCII characters from file names due to a limitation of the combination of
    urllib3 (via python-requests) and our server
    https://github.com/requests/requests/issues/2117
    Internal Ticket #3090
    """

    import urllib3

    # fixed in urllib3 1.25.2
    # https://github.com/urllib3/urllib3/pull/1492
    try:
        urllib_version = [int(p) for p in urllib3.__version__.split(".")]
    except Exception:
        print("Error parsing urllib version: " + urllib3.__version__, file=sys.stderr)
        return

    if urllib_version >= [1, 25, 2]:
        return

    if "files" in kwargs and kwargs["files"] is not None:
        acceptable_chars = "0123456789" + "abcdefghijklmnopqrstuvwxyz" + \
                           "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + " _-.,()[]{}"
        for param_name, fp in kwargs["files"].items():
            if isinstance(fp, (tuple, list)):
                filename, fp = fp
            else:
                filename = requests.utils.guess_filename(fp) or param_name

            def encode(char):
                try:
                    if char in acceptable_chars:
                        return char
                except UnicodeDecodeError:
                    pass
                return "x{:02x}".format(ord(char))
            filename = "".join(encode(x) for x in filename)

            kwargs["files"][param_name] = (filename, fp)


if __name__ == "__main__":
    main()