#!/usr/bin/python3
#  Copyright (C) 2015 Canonical Ltd.
#
#  This script is distributed under the terms and conditions of the GNU General
#  Public License, Version 3 or later. See http://www.gnu.org/copyleft/gpl.html
#  for details.

import codecs
import optparse
import os
import re
import sys
import subprocess
import time
import LibAppArmor

DEBUGGING = False

#
# Helpers
#


def debug(out):
    '''Print debug message'''
    if DEBUGGING:
        try:
            sys.stderr.write("DEBUG: %s\n" % (out))
        except IOError:
            pass


def open_file_read(path):
    '''Open specified file read-only'''
    try:
        orig = codecs.open(path, 'r', "UTF-8")
    except Exception:
        raise

    return orig


def list_to_commas(l):
    return ", ".join(l)


def list_to_newlines(l):
    return "\n".join(l)


class ScanLogs:
    def __init__(self, log_file, snap_name=None, follow=False,
                 recommend=False, display="both"):
        self.recommend = recommend
        self.display = display
        self.scan_log(log_file, snap_name, follow)

    def _print_entry(self, entry):
        out = ""
        out += "= %s =\n" % entry['type']
        out += "Time: %s\n" % entry['time']
        out += "Log: %s\n" % entry['log']
        if 'msg' in entry:
            out += "%s\n" % entry['msg']
        if self.recommend and len(entry['recommendation']) > 0:
            out += "Suggestion"
            if len(entry['recommendation']) > 1:
                out += "s"
            out += ":\n"
            for r in entry['recommendation']:
                out += "* %s\n" % r

        sys.stdout.write("%s\n" % out)

    def scan_log(self, log_file, snap_name, follow):
        def _scan_line(line, snap_name):
            apparmor_re = re.compile("audit: type=1400 audit")
            seccomp_re = re.compile("audit: type=1326 audit")
            log_re = re.compile(
                r"^.* audit: type=[0-9]+ audit\([0-9]+\.[0-9]+:[0-9]+\): ")

            show = False
            rec = None

            entry = dict()
            entry['raw'] = line.rstrip()
            entry['time'] = line[0:15]
            entry['log'] = log_re.sub('', line.rstrip())

            if self.display != "seccomp" and apparmor_re.search(line):
                (show, rec, msg) = self.make_apparmor_recommendation(line,
                                                                     snap_name)
                if show:
                    entry['type'] = "AppArmor"
                if msg is not None:
                    entry['msg'] = msg
            elif self.display != "apparmor" and seccomp_re.search(line):
                (show, rec, entry['log'], msg) = \
                    self.make_seccomp_recommendation(line, snap_name,
                                                     entry['log'])
                if show:
                    entry['type'] = "Seccomp"
                    entry['msg'] = msg

            entry['recommendation'] = rec

            if show:
                self._print_entry(entry)

        try:
            log = open_file_read(log_file)
        except PermissionError:
            raise ScanLogsException("Could not open '%s'" % log_file)

        if follow:
            try:
                while True:
                    line = log.readline()
                    if line != '':
                        _scan_line(line, snap_name)
                    else:
                        time.sleep(0.5)
            except KeyboardInterrupt:
                sys.stdout.write('\n')
                pass
        else:
            for line in log:
                _scan_line(line, snap_name)

    def apparmor_caps_allowing_rule(self, rule_re):
        # This is where logprof would be handy
        matched_caps = []

        # TODO: Search releases other than ubuntu-core/15.04 too
        dirs = ["/usr/share/apparmor/easyprof/policygroups/ubuntu-core/15.04",
                "/var/lib/snappy/apparmor/policygroups"]
        for cap_dir in dirs:
            for cap in os.listdir(cap_dir):
                if cap == "networking":  # FIXME: not correct for personal
                    continue
                for line in open_file_read(os.path.join(cap_dir, cap)):
                    if rule_re.search(line) and cap not in matched_caps:
                        matched_caps += [cap]

        if not matched_caps:
            return None

        return list_to_commas(sorted(matched_caps))

    def _aa_file(self, fn):
        s = fn
        proc_re = re.compile(r'^/proc/')
        s = proc_re.sub('@{PROC}/', s)

        proc_pid_re = re.compile(r'^@{PROC}/\d+/')
        s = proc_pid_re.sub('@{PROC}/@{pid}/', s)

        home_re = re.compile(r'^/home/[^/]+/')
        s = home_re.sub('@{HOME}/', s)

        return s

    def make_apparmor_recommendation(self, line, snap_name):
        rec = []
        show = True
        msg = None

        event = LibAppArmor.parse_record(line)
        if ' apparmor="STATUS" ' in line:
            show = False
        elif snap_name is not None and event.profile.startswith("%s_" %
                                                                snap_name):
            show = False

        # quick exit
        if not show:
            LibAppArmor.free_record(event)
            return (show, rec, msg)

        if event.operation is not None and event.operation == 'capable' and \
                event.name is not None:
            # capability rules
            rule_re = re.compile(r'^\s*capability\s+%s\s*,\s*(#.*)?$' %
                                 event.name)

            msg = "Capability: %s" % event.name
            rec.append("adjust program to not require 'CAP_%s' (see 'man 7 "
                       "capabilities')" % (event.name.upper()))
            if event.name != 'mac_admin':  # policy is pointless with this
                if event.name == 'sys_module':  # policy is pointless with this
                    rec.append("use 'snappy config ubuntu-core' to load "
                               "required modules")
                else:
                    rec.append("add 'capability %s,' to apparmor in "
                               "'security-policy'" % (event.name))
                    caps = self.apparmor_caps_allowing_rule(rule_re)
                    if caps is not None:
                        rec.append("add one of '%s' to 'caps'" % caps)
                    if event.name == "net_admin":
                        rec.append("do nothing "
                                   "(https://launchpad.net/bugs/1465724)")
                    else:
                        rec.append("do nothing if program otherwise works "
                                   "properly")
        elif event.operation is not None and event.operation == "exec" and \
                event.name is not None:
            # exec rules
            if event.name.startswith("/apps/bin/"):
                rec.append("adjust program to execute from directly from "
                           "SNAP_APP_PATH instead of /apps/bin")
            else:
                bn = os.path.basename(event.name)
                rec.append("adjust snap to ship '%s'" % bn)
                rec.append("adjust program to use relative paths if the snap "
                           "already ships '%s'" % bn)
                exec_re = re.compile(r'^\s*%s\s+\w*x*\s*,\s*(#.*)?$' %
                                     event.name)
                caps = self.apparmor_caps_allowing_rule(exec_re)
                if caps is not None:
                    rec.append("add one of '%s' to 'caps'" % caps)
                # try to match our alternation. This is where we need logprof
                alt_exec_re = re.compile(r'^(/usr)?/s?bin/%s' % bn)
                if alt_exec_re.search(event.name):
                    exec_re = re.compile(
                        r'^\s*/\{,usr/\}\{,s\}bin/%s\s+\w*x*\s*,\s*(#.*)?' %
                        bn)
                    caps = self.apparmor_caps_allowing_rule(exec_re)
                    if caps is not None:
                        rec.append("add one of '%s' to 'caps'" % caps)
        elif event.name is not None and event.name.startswith("/"):
            # file rules
            mask = 'write'
            if event.denied_mask == 'r':
                mask = 'read'
            msg = "File: %s (%s)" % (event.name, mask)

            nameservice_files = ['/etc/passwd', '/etc/group',
                                 '/etc/nsswitch.conf']
            if event.name.startswith("/dev/") or \
               event.name.startswith("/sys/class/gpio") or \
               event.name.startswith("/sys/devices/"):
                rec.append("use hw-assign or oem/gadget hardware assign to "
                           "access '%s'" % event.name)
            elif event.name.startswith("/apps/bin/"):
                rec.append("adjust program to access files in SNAP_APP_PATH "
                           "instead of /apps/bin")
            elif event.name.startswith("/apps/") and mask == 'write':
                rec.append("adjust program to not write to SNAP_APP_PATH")
            elif event.name.startswith("/var/tmp/"):
                rec.append("adjust program to use TMPDIR or /tmp")
            elif event.name.startswith("/var/log/"):
                rec.append("adjust program to write log files to "
                           "SNAP_APP_DATA_PATH")
            elif '/run/shm/snaps/' not in event.name and \
                 event.name.startswith("/var/run/") or \
                 event.name.startswith("/run/"):
                rec.append("adjust program to use SNAP_APP_DATA_PATH")
                rec.append("adjust program to use "
                           "/run/shm/snaps/SNAP_FULLNAME/SNAP_VERSION")
            else:
                for fn in [event.name, self._aa_file(event.name)]:
                    file_re = re.compile(r'^\s*%s\s+\w*%s\w*\s*,\s*(#.*)?$' %
                                         (event.name, mask[0]))
                    caps = self.apparmor_caps_allowing_rule(file_re)
                    if caps is not None:
                        rec.append("add one of '%s' to 'caps'" % caps)
                if mask == 'write':
                    rec.append("adjust program to write to SNAP_APP_DATA_PATH "
                               "or SNAP_APP_USER_DATA_PATH")
                elif mask == 'read' and not event.name.startswith("/proc/") \
                        and not event.name.startswith("/sys/"):
                    rec.append("adjust program to read necessary files from "
                               "SNAP_APP_PATH")
                rec.append("add '%s %s,' to apparmor in 'security-policy'" %
                           (self._aa_file(event.name), mask[0]))

                if event.name in nameservice_files:
                    rec.append("add '#include <abstractions/nameservice>' "
                               "to apparmor in 'security-policy'")
                    ns_re = re.compile(
                        r'^\s*#include\s+<abstractions/nameservice>\s*(#.*)?$')
                    caps = self.apparmor_caps_allowing_rule(ns_re)
                    if caps is not None:
                        rec.append("add one of '%s' to 'caps'" % caps)
        elif event.net_family is not None and event.net_family == "unix":
            # Libapparmor doesn't handle unix rules yet, so fake it
            addr_re = re.compile(r'.*\s+addr="([^\s]+)".*')
            if addr_re.search(line):
                addr = addr_re.sub('\\1', line.rstrip())
                rec.append("add '%s addr=\"%s\",' to apparmor in "
                           "'security-policy'" % (event.net_family, addr))
                # FIXME: unix rules can span multiple lines
                unix_re = re.compile(
                    r'^\s*unix\s+.*\s+addr=[\'"]?%s[\'"]?[\s,]?' % addr)
                caps = self.apparmor_caps_allowing_rule(unix_re)
                if caps is not None:
                    rec.append("add one of '%s' to 'caps'" % caps)
        elif event.net_family is not None:
            rec.append("add 'network %s %s,' to apparmor in "
                       "'security-policy'" % (event.net_family,
                           event.net_sock_type))
            net_re = re.compile(
                r'^\s*network\s+%s\s+%s\s*,\s*(# .*)?$' % (event.net_family,
                    event.net_sock_type))
            caps = self.apparmor_caps_allowing_rule(net_re)
            if caps is not None:
                rec.append("add one of '%s' to 'caps'" % caps)


        LibAppArmor.free_record(event)

        return (show, rec, msg)

    def seccomp_caps_allowing_syscall(self, syscall):
        matched_caps = []

        # TODO: Search releases other than 15.04, too
        dirs = ["/usr/share/seccomp/policygroups/ubuntu-core/15.04",
                "/var/lib/snappy/seccomp/policygroups"]
        syscall_re = re.compile(r'^\s*%s\s*(#.*)?$' % syscall)
        for cap_dir in dirs:
            for cap in os.listdir(cap_dir):
                if cap == "networking":  # FIXME: not correct for personal
                    continue
                for line in open_file_read(os.path.join(cap_dir, cap)):
                    if syscall_re.search(syscall) and cap not in matched_caps:
                        matched_caps += [cap]

        if not matched_caps:
            return None

        return list_to_commas(sorted(matched_caps))

    def make_seccomp_recommendation(self, line, snap_name, log):
        show = True
        rec = []
        msg = None

        syscall_re = re.compile("syscall=(\d+)")
        setuid_re = re.compile(r'^set((re|res)?[ug]id|groups)')

        syscall_key = syscall_re.search(line)
        if syscall_key:
            num = syscall_key.groups()[0]
            output = subprocess.check_output(["/usr/bin/scmp_sys_resolver",
                                              num], universal_newlines=True)
            syscall = output.rstrip()
            log = syscall_re.sub('\\1(%s)' % syscall, log)
            msg = "Syscall: %s" % syscall

            if 'chown' in syscall:
                rec.append("don't copy ownership of files (eg, use 'cp -r "
                           "--preserve=mode' instead of 'cp -a')")
                rec.append("adjust program to not use '%s'" % syscall)
            elif setuid_re.search(syscall):
                rec.append("adjust program to not use '%s' until per-snap "
                           "user/groups are supported" % syscall)
            else:
                rec.append("add '%s' to seccomp policy" % (syscall))
                caps = self.seccomp_caps_allowing_syscall(syscall)
                if caps is not None:
                    rec.append("add one of '%s' to 'caps'" % caps)
                rec.append("add '%s' to seccomp file in 'security-policy'" %
                           syscall)

        return (show, rec, log, msg)

#
# End helpers
#


class ScanLogsException(Exception):
    '''This class represents ScanLogs exceptions'''
    def __init__(self, value):
        self.value = value

    def __str__(self):
        return repr(self.value)


def main():
    global DEBUGGING
    global sl_log_file

    parser = optparse.OptionParser()
    parser.add_option("-d", "--debug",
                      help="Show debugging output",
                      action='store_true',
                      default=False)
    parser.add_option("-t", "--template", "--security-template",
                      dest="template",
                      help="Use non-default policy template",
                      metavar="TEMPLATE",
                      default='default')
    parser.add_option("-p", "--policy-groups", "--caps",
                      type=str,
                      dest="policy_groups",
                      help="Comma-separated list of policy groups",
                      metavar="POLICYGROUPS")
    parser.add_option("-o", "--output-file",
                      dest="output_file",
                      help="Output to file (default to stdout)",
                      metavar="FILE",
                      default=None)
    parser.add_option("--policy-dir",
                      dest="policy_dir",
                      help="Use non-default policy directory",
                      metavar="DIR",
                      default=None)
    parser.add_option("--log-file",
                      dest="log_file",
                      help="Use non-default log directory",
                      metavar="DIR",
                      default=None)
    parser.add_option("-f", "--follow",
                      dest="follow",
                      help="Follow specified file",
                      action='store_true',
                      default=False)
    parser.add_option("-r", "--recommend",
                      dest="recommend",
                      help="Suggest a recommendation it possible",
                      action='store_true',
                      default=False)
    parser.add_option("--only-apparmor",
                      dest="only_apparmor",
                      help="Only show apparmor denials",
                      action='store_true',
                      default=False)
    parser.add_option("--only-seccomp",
                      dest="only_seccomp",
                      help="Only show seccomp denials",
                      action='store_true',
                      default=False)

    (opt, args) = parser.parse_args()

    if opt.debug:
        DEBUGGING = True

    if opt.policy_dir is not None and os.path.isdir(opt.policy_dir):
        global sl_system_policy_dir
        sl_system_policy_dir = opt.policy_dir
        debug("sl_system_policy_dir: %s" % opt.policy_dir)

    sl_log_file = "/var/log/syslog"
    if opt.log_file:
        sl_log_file = opt.log_file
        debug("sl_log_file: %s" % opt.log_file)

    output = subprocess.check_output(["sysctl", "kernel.printk_ratelimit"],
                                     universal_newlines=True)
    prev_rate = output.rstrip().split()[-1]
    if opt.follow:
        # Turn of kernel rate limiting if we are debugging with this tool
        subprocess.call(["sysctl", "-w", "kernel.printk_ratelimit=0"])

    display = "both"
    if opt.only_seccomp:
        display = "seccomp"
    elif opt.only_apparmor:
        display = "apparmor"

    try:
        ScanLogs(sl_log_file, follow=opt.follow, recommend=opt.recommend,
                 display=display)
    except ScanLogsException as e:
        sys.stderr.write("%s\n" % (str(e).strip('"')))
        sys.exit(1)
    except Exception:
        raise
    if opt.follow:
        subprocess.call(["sysctl", "-w",
                         "kernel.printk_ratelimit=%s" % prev_rate])

if __name__ == "__main__":
    sys.exit(main())
