#!/opt/cloudlinux/venv/bin/python3 -sbb
# Copyright © Cloud Linux GmbH & Cloud Linux Software, Inc 2010-2019 All Rights Reserved
#
# Licensed under CLOUD LINUX LICENSE AGREEMENT
# http://cloudlinux.com/docs/LICENSE.TXT
import json
import os
import sys
import syslog
from typing import List


CONFIGS_DIR = '/etc/cagefs/filters'
LOG_AUTHPRIV = 10<<3

def dmesg(debug, msg, *args):
    if debug:
        print(msg % args)


def load_config(command_path):
    """
    Load JSON config by command name
    """
    try:
        name = os.path.basename(command_path)
        f = open(os.path.join(CONFIGS_DIR, "%s.json" % name), "r")
        full_config = json.load(f)
        f.close()
    except Exception:
        return None

    if len(full_config) == 1 and ("allow" in full_config or
                                  "deny" in full_config or
                                  "restrict_path" in full_config):
        # get full config if only `allow` or `deny` or `restrict_path` key present in it
        return full_config

    # find config for command path or get default
    return full_config.get(command_path, full_config.get("default", None))


def is_long_option(arg): # type: (str) -> bool
    """
    Return True if arg is a long option name, not a parameter of an option
    Long options start with a *double* dash.

    :param arg: option or parameter
    :type arg: string
    """
    return arg.startswith('--')

def is_short_option(arg): # type: (str) -> bool
    """
    Return True if arg is a short option name, not a parameter of an option
    Short options start with a *single* dash.

    :param arg: option or parameter
    :type arg: string
    """
    return arg.startswith('-') and not is_long_option(arg)

def is_same_option_type(arg1, arg2): # type: (str, str) -> bool
    """
    Return True if both arguments were options of the same type, either long or short.
    """
    same_short = is_short_option(arg1) and is_short_option(arg2)
    same_long = is_long_option(arg1) and is_long_option(arg2)
    return same_long or same_short

def is_flag_present(arg, flag, strict):
    # type: (str, str, bool) -> bool
    """
    Look for the flag inside the provided commandline argument.
    The search algorithm depends on the `strict` parameter.

    With strict processing:
    * short options are treated as possible clusters, and finding a match anywhere
    inside the argument string means that the flag is present.
    * long options are split on `=` to discard their values, then compared in entirety.

    Without it, the flag is simply compared to the start of the argument string.

    :param arg: Argument string to look inside of.
    :param flag: Flag to look for.
    :param strict: Strict processing switch.
    :raises RuntimeError: When the arg and the flag are both of the same
    option type, but arg somehow is neither a long nor a short option.
    :return: True if flag was found, False otherwise.
    """
    if strict:
        if not is_same_option_type(arg, flag):
            return False

        if is_long_option(arg):
            # To cover the case of the long arg having a value attached, we split on '='.
            # "--arg=10" -> ["--arg", "10"]
            return arg.split("=")[0] == flag
        elif is_short_option(arg):
            # This method of searching may potentially run into problems, for example:
            # "-p" in "-vf/etc/passwd" -> True
            # However, such cases are false positives that will *block* execution, not permit it
            # where it shouldn't be permitted.
            # They just mean that the user has to edit the arguments to separate them.
            return flag[1:] in arg
        else:
            # `not is_same_option_type` above should cover non-matching cases,
            # so we shouldn't reach this code. But just in case:
            raise ValueError("Argument and flag option types match, but arg is not an option")
    else:
        return arg.startswith(flag)


def has_denied_params(args, deny_list, strict_flag=False):
    # type: (List[str], List[str], bool) -> bool
    """
    Check if there are any forbidden options present in the arguments.

    :param args: The argument list to check, without the program name.
    :param deny_list: The list of forbidden options.
    :param strict_flag: Strict processing, see `is_flag_present`.
    :return: True if any forbidden flags are present, False otherwise.
    """
    for arg in args:
        # Do NOT treat "--" as a universal end-of-options sentinel here.
        # Some downstream commands (e.g. find -exec ... ;) keep interpreting
        # post-"--" tokens as active option-bearing expressions, so the deny
        # filter must keep enforcing on every argv token regardless of "--".
        for opt in deny_list:
            if is_flag_present(arg, opt, strict_flag):
                return True
    return False

def strict_extra_params(arg, allow_list):
    # type: (str, List[str]) -> bool
    """
    Strict variant of checking for non-allowed parameters.

    :param arg: Argument to check.
    :param allow_list: List of allowed options.
    :return: True if any non-allowed options are present, False otherwise.
    """
    # Split the short option cluster into separate values, then search the allow_list.
    # Like above, this'll run into the issue of false positives in cases of
    # short arguments with attached values, but it's pretty much nessesary
    # to allow for passing arbitrary arguments.
    if is_short_option(arg):
        # Short options inside filter files are listed with a dash.
        # Therefore, we append a dash for comparsion.
        arg_no_dash = arg[1:]
        opts_not_allowed = ("-"+opt not in allow_list for opt in arg_no_dash)
        if any(opts_not_allowed):
            return True

    # Long options are split on "=" to discard argument values.
    if is_long_option(arg):
        long_name = arg.split("=")[0]
        if long_name not in allow_list:
            return True
    return False


def has_extra_params(args, allow_list, strict_flag=False):
    # type: (List[str], List[str], bool) -> bool
    """
    Check if all used args are allowed for the program.

    :param args: The program's argv, without the program name.
    :param allow_list: A list of allowed arguments. Dashes in front of names are present.
    :param strict_flag: Strict processing flag, operates similarly to `is_flag_present`.
    :return: Returns True if there are any arguments not in the allowed list, False otherwise.
    """
    for arg in args:
        if strict_flag:
            # Do NOT treat "--" as a universal end-of-options sentinel here.
            # The allow-list must keep enforcing on every argv token because
            # not every downstream command honors "--" as the option
            # terminator (find -exec ... ; is the canonical counterexample).
            if strict_extra_params(arg, allow_list):
                return True
        else:
            if (is_short_option(arg) or is_long_option(arg)) and (arg not in allow_list):
                return True
    return False


def to_log(message, *args):
    """
    Wrapper for syslog or other logging system
    """
    syslog.openlog("cagefs.check_params")
    syslog.syslog(LOG_AUTHPRIV | syslog.LOG_PID, message % args)
    syslog.closelog()


def addslash(path):
    if path == '':
        return '/'
    if (path[-1] != '/'):
        return '%s/' % (path,)
    return path


def expanduser(path, user, home_dir):
    home_dir = addslash(os.path.realpath(home_dir))
    userpath = '~'+user
    if path == '~' or path.startswith('~/'):
        return os.path.realpath(path.replace('~', home_dir))
    if path == userpath or path.startswith(userpath+'/'):
        return os.path.realpath(path.replace(userpath, home_dir))
    return os.path.realpath(path)


def _is_relative_restrict_value(path):
    """
    Return True if `path` is a relative path value that cannot be safely
    validated against the user's home directory.

    Background (CLOS-4596 / F-31 path_traversal): check_params.py is invoked
    by cagefs.server (proxyexec) with cwd='/'; the target binary is later
    exec'd via execv() with cwd=client-supplied. `os.path.realpath()` on a
    relative value resolves against the *checker* process's cwd ('/'), so
    `home/<u>/x` is canonicalised to `/home/<u>/x` and accepted by the
    home-dir prefix check, but the binary opens the same relative argv against
    the client cwd and can be steered to follow attacker-planted symlinks
    outside the home.

    Tilde-prefixed values (`~`, `~/x`, `~user/x`) are relative too: the target
    binary is started with execv() — no shell — so it never expands `~`. Only
    this checker's expanduser() does, resolving `~/x` to a path inside home_dir
    and accepting it, while the binary opens the literal `~/x` against its
    (attacker-chosen) cwd (e.g. `~/some/../path` becomes `<cwd>/~/path`, a
    file the attacker controls). That is the same cwd-mismatch bypass, so a
    leading `~` must not exempt a value from rejection.

    We have no safe way to anchor a relative path here (the client cwd is
    not forwarded to check_params.py), so we reject relative restrict_path
    values outright.
    """
    if not path:
        # Empty value cannot be inside home_dir; treat as relative/unsafe.
        return True
    # os.path.isabs() is False for both plain relative paths and tilde forms
    # (`~...` — execv does not expand the tilde), so both are rejected.
    return not os.path.isabs(path)


def check_path(user, homedir, command_path, args, restrict_path_list, debug = False):
    """
    Return True when args contain paths that refer outside of user's home directory
    :param args: parameters (options) from command line
    :type args: list of strings
    :param restrict_path_list: names of parameters (options) that should use paths inside user's home directory only
    :type restrict_path_list: list of strings
    """
    home_dir = addslash(os.path.realpath(homedir))
    for i, arg in enumerate(args):
        if arg in restrict_path_list:
            try:
                # path is specified in the next argument
                path = args[i+1]
            except IndexError:
                continue
            if _is_relative_restrict_value(path):
                # Relative values cannot be safely validated — the checker's
                # cwd differs from the target binary's cwd (cwd-mismatch
                # bypass, F-31). Deny.
                dmesg(debug, "Attempt to call program %s with relative path in %s %s parameters", command_path, args[i], args[i+1])
                to_log("Attempt to call program %s with relative path in %s %s parameters", command_path, args[i], args[i+1])
                return True
            path = expanduser(path, user, home_dir)
            path = addslash(path)
            if not path.startswith(home_dir):
                dmesg(debug, "Attempt to call program %s with %s %s parameters", command_path, args[i], args[i+1])
                to_log("Attempt to call program %s with %s %s parameters", command_path, args[i], args[i+1])
                return True
        else:
            for opt in restrict_path_list:
                if arg.startswith(opt):
                    # path is specified in the current argument
                    path = arg[len(opt):]
                    # strip the leading "=" of "--opt=path" so the path
                    # is normalised identically to how the program's own
                    # option parser will see it; otherwise "=/etc/x" is
                    # treated as a relative path by os.path.realpath and
                    # the home-dir prefix check is bypassed.
                    if path.startswith('='):
                        path = path[1:]
                    if _is_relative_restrict_value(path):
                        # Relative values cannot be safely validated (see
                        # _is_relative_restrict_value docstring, F-31). Deny.
                        dmesg(debug, "Attempt to call program %s with relative path in %s parameter", command_path, args[i])
                        to_log("Attempt to call program %s with relative path in %s parameter", command_path, args[i])
                        return True
                    path = expanduser(path, user, home_dir)
                    path = addslash(path)
                    if not path.startswith(home_dir):
                        dmesg(debug, "Attempt to call program %s with %s parameter", command_path, args[i])
                        to_log("Attempt to call program %s with %s parameter", command_path, args[i])
                        return True
    return False



def main(user, homedir, params, debug = False):
    """
    Program main function
    :params - list of strings that specify command and its parameters, such as ['/path/command', '-a', 'arg', '-C', '/path/to/config']
    """

    if len(params) == 0:
        dmesg(debug, 'No parameters specified')
        return 1

    # permit execution of any command when called without parameters
    if len(params) < 2:
        dmesg(debug, 'Command has no parameters. Allow execution of command %s', params[0])
        return 0

    command_path = params[0]
    args = params[1:]
    config = load_config(command_path)
    dmesg(debug, 'config: %s', str(config))

    if not config:
        dmesg(debug, 'Config not found or failed to load for command %s. Deny execution', command_path)
        to_log('Config not found or failed to load for command %s. Deny execution', command_path)
        return 2

    allow_list = config.get("allow", None)
    deny_list = config.get("deny", None)
    restrict_path_list = config.get("restrict_path", None)
    strict_flag = config.get("strict_options", False)

    if not (allow_list or deny_list or restrict_path_list):
        dmesg(debug, 'empty config for command %s - no allow/deny/restrict_path keys. Deny execution', command_path)
        to_log('empty config for command %s - no allow/deny/restrict_path keys. Deny execution', command_path)
        return 2

    if allow_list and deny_list:
        dmesg(debug, 'invalid config for command %s - both allow and deny lists are specified. Deny execution', command_path)
        to_log('invalid config for command %s - both allow and deny lists are specified. Deny execution', command_path)
        return 2

    if deny_list and has_denied_params(args, deny_list, strict_flag):
        dmesg(debug, "Attempt to call program %s with denied parameters", command_path)
        to_log("Attempt to call program %s with denied parameters", command_path)
        return 2

    if allow_list and has_extra_params(args, allow_list, strict_flag):
        dmesg(debug, "Attempt to call program %s with extra parameters", command_path)
        to_log("Attempt to call program %s with extra parameters", command_path)
        return 2

    if restrict_path_list and check_path(user, homedir, command_path, args, restrict_path_list, debug):
        return 2

    dmesg(debug, 'Execution allowed')
    return 0


if __name__ == "__main__":
    sys.exit(main(sys.argv[1], sys.argv[2], sys.argv[3:]))
