#
#
# This file takes debsecan parser into simple python functions
#
## Original Credits ##
# debsecan - Debian Security Analyzer
# Copyright (C) 2005, 2006, 2007 Florian Weimer
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301 USA

VERSION = "0.4"

from io import StringIO

import re, sys, types, urllib.request, urllib.error, urllib.parse, zlib, apt_pkg, os.path, datetime

from ivulncheck_api.lib.config_api import Configuration as conf

import logging
logging.basicConfig(filename=conf.getLogfile(), level=logging.INFO)

from ivulncheck_api.lib import Vulns
from ivulncheck_api.lib import functions as f

apt_pkg.init()
try:
    version_compare = apt_pkg.version_compare
except AttributeError:
    version_compare = apt_pkg.VersionCompare
    

######################################################################
# From debian_support in the secure-testing repository.  Needs to be
# kept in sync manually.  (We duplicate here to avoid a library
# dependency, and make it easy to run the script even when it is not
# installed on the system.)

class ParseError(Exception):
    """An exception which is used to signal a parse failure.

    Attributes:

    filename - name of the file
    lineno - line number in the file
    msg - error message

    """

    def __init__(self, filename, lineno, msg):
        assert type(lineno) == int
        self.filename = filename
        self.lineno = lineno
        self.msg = msg

    def __str__(self):
        return self.msg

    def __repr__(self):
        return "ParseError(%s, %d, %s)" % (self.filename, self.line, self.msg)

    def printOut(self, file):
        """Writes a machine-parsable error message to file."""
        file.write("%s:%d: %s\n" % (self.filename, self.lineno, self.msg))
        file.flush()

class Version:
    """Version class which uses the original APT comparison algorithm."""
    def __init__(self, version):
        """Creates a new Version object."""
        assert type(version) == str, version
        #~ assert version <> ""
        self.version = version

    def __str__(self):
        return self.version

    def __repr__(self):
        return 'Version(%s)' % self.version

    # Python2
    #~ def __cmp__(self, other):
        #~ return version_compare(self.version, other)
        
    def compare(self, other):
        return version_compare(self.version, other)
        
    def __eq__(self, other):
        if version_compare(self.version, other) == 0:
            return True
        return False
        
    def __lt__(self, other):
        if version_compare(self.version, other) < 0:
            return True
        return False
        
    def __gt__(self, other):
        if version_compare(self.version, other) > 0:
            return True
        return False
            

class PackageFile:
    """A Debian package file.

    Objects of this class can be used to read Debian's Source and
    Packages files."""

    re_field = re.compile(r'^([A-Za-z][A-Za-z0-9-]+):(?:\s+(.*?))?\s*$')

    def __init__(self, name, fileObj=None):
        """Creates a new package file object.

        name - the name of the file the data comes from
        fileObj - an alternate data source; the default is to open the
                  file with the indicated name.
        """
        if fileObj is None:
            fileObj = safe_open(name)
        self.name = name
        self.file = fileObj
        self.lineno = 0
        # To skip first and second lines
        #~ self.file.readline()
        #~ self.file.readline()

    def __iter__(self):
        line = self.file.readline()
        self.lineno += 1
        pkg = []
        while line:
            if line == '\n':
                if len(pkg) == 0:
                    self.raiseSyntaxError('expected package record')
                yield pkg
                pkg = []
                line = self.file.readline()
                self.lineno += 1
                continue

            match = self.re_field.match(line)
            if not match:
                self.raiseSyntaxError("expected package field, got " + line)
            (name, contents) = match.groups()
            contents = contents or ''

            while True:
                line = self.file.readline()
                self.lineno += 1
                if line and line[0] in " \t":
                    ncontents = line[1:]
                    if ncontents:
                        if ncontents[-1] == '\n':
                            ncontents = ncontents[:-1]
                    else:
                        break
                    contents = "%s\n%s" % (contents, ncontents)
                else:
                    break
            pkg.append((name, contents))
        if pkg:
            yield pkg

    def raiseSyntaxError(self, msg, lineno=None):
        if lineno is None:
            lineno = self.lineno
        raise ParseError(self.name, lineno, msg)

# End of code from debian_support
######################################################################

# General support routines

def safe_open(name, mode="r"):
    try:
        return open(name, mode)
    except IOError as e:
        sys.stdout.write("error: could not open %s: %s\n" % (name, e.strerror))
        sys.exit(2)


# Vulnerabilities

class Vulnerability:
    """Stores a vulnerability name/package name combination."""

    urgency_conversion = {' ' : '',
                        'L' : 'low',
                        'M' : 'medium',
                        'H' : 'high'}

    def __init__(self, vuln_names, str):
        """Creates a new vulnerability object from a string."""
        (package, vnum, flags, unstable_version, other_versions) \
                  = str.split(',', 4)
        vnum = int(vnum)
        self.bug = vuln_names[vnum][0]
        self.package = package
        self.binary_packages = None
        self.unstable_version = unstable_version
        self.other_versions = other_versions.split(' ')
        if self.other_versions == ['']:
            self.other_versions = []
        self.description = vuln_names[vnum][1]
        self.binary_package = flags[0] == 'B'
        self.urgency = self.urgency_conversion[flags[1]]
        self.remote = {'?' : None,
                       'R' : True,
                       ' ' : False}[flags[2]]
        self.fix_available = flags[3] == 'F'
        
    

    def is_vulnerable(self, bp, sp):
        """Returns true if the specified binary package is subject to
        this vulnerability."""
        bin_pkg = bp[0]
        bin_ver = bp[1]
        src_pkg = sp[0]
        src_ver = sp[1]
        self._parse()
        #~ if self.binary_package and bin_pkg == self.package:
            #~ if self.unstable_version:
                #~ return bin_ver.compare(self.unstable_version) < 0
            #~ else:
                #~ return True
        if src_pkg == self.package:
            if self.unstable_version:
                return src_ver.compare(self.unstable_version) < 0 \
                       and str(src_ver) not in self.other_versions
            else:
                return str(src_ver) not in self.other_versions
        else:
            return False

    def obsolete(self, bin_name=None):
        if self.binary_packages is None:
            return
        if bin_name is None:
            bin_name = self.installed_package
        return bin_name not in self.binary_packages

    def installed(self, src_name, bin_name):
        """Returns a new vulnerability object for the installed package."""
        v = copy.copy(self)
        v.installed_package = bin_name
        return v

    def _parse(self):
        """Further parses the object."""
        if type(self.unstable_version) == bytes:
            if self.unstable_version:
                self.unstable_version = Version(self.unstable_version)
            else:
                self.unstable_version = None
            self.other_versions = list(map(Version, self.other_versions))
            
    def __str__(self):
        
        string = self.bug + " " + self.package + " " + str(self.unstable_version) + " "
        for other_version in self.other_versions:
            string += str(other_version) + " "
        return string


def display_vulnerability(v, bp, sp, suite):
    string = ""
    notes = []
    if v.fix_available:
        notes.append("fixed")
    if v.remote:
        notes.append("remotely exploitable")
    if v.urgency:
        notes.append(v.urgency + " urgency")
    notes = ', '.join(notes)
    if notes:
        sys.stdout.write("%s (%s)\n" % (v.bug, notes))
    else:
        sys.stdout.write("%s\n" % v.bug)
    sys.stdout.write("  %s\n" % v.description)
    sys.stdout.write("  installed: %s %s\n"
                      % (bp[0], bp[1]))
    if bp[0] != sp[0]:
        sys.stdout.write("             (built from %s %s)\n"
                      % (sp[0], sp[1]))
    if v.obsolete(bp[0]):
        sys.stdout.write("             package is obsolete\n")

    if v.binary_package:
        k = 'binary'
    else:
        k = 'source'
    if v.unstable_version:
        sys.stdout.write("  fixed in unstable: %s %s (%s package)\n"
                          % (v.package, v.unstable_version, k))
    for vb in v.other_versions:
        sys.stdout.write("  fixed on branch:   %s %s (%s package)\n"
                          % (v.package, vb, k))
    if v.fix_available:
        sys.stdout.write("  fix is available for the selected suite (%s)\n"
                          % suite)
    sys.stdout.write("\n")
# Fetch Data Function

def fetch_data(suite):
    """Returns a dictionary PACKAGE -> LIST-OF-VULNERABILITIES."""
    
    (url, backup_file_path) = conf.getDebsecanConf()
    
    if url is None:
        url = "https://security-tracker.debian.org/tracker/debsecan/release/1/"
    if url[-1] != "/":
        url += "/"
    if suite:
        url += suite
    else:
        url += 'GENERIC'
    r = urllib.request.Request(url)
    #~ sys.stdout.write(url + "\n")
    r.add_header('User-Agent', 'debsecan/' + VERSION)
    data = u = None
    try:
        u = urllib.request.urlopen(r)
        # In cron mode, we suppress almost all errors because we
        # assume that they are due to lack of Internet connectivity.
    except urllib.error.HTTPError as e:
        if e.code == 404:
            sys.stderr.write("error: while downloading %s:\n%s\n" % (url, e))
            if suite:
                sys.stderr.write("Are you sure %s is a Debian codename?\n" %
                                 repr(suite))
                logging.error('[%s] (%s)\tAre tou sure %s is a Debian codename?', datetime.datetime.today().isoformat(), __name__, suite)
            sys.exit(1)
        sys.stderr.write("error: while downloading %s:\n%s\n" % (url, e))
        logging.critical('[%s] (%s)\t%s', datetime.datetime.today().isoformat(), __name__, msg)
        logging.info('[%s] (%s)\tTrying to read backup debsecan file', datetime.datetime.today().isoformat(), __name__)
        try:
            data = StringIO(open(os.path.join(backup_file_path, suite), 'r').read())
            logging.info('[%s] (%s)\tBackup debsecan file successfully read.', datetime.datetime.today().isoformat(), __name__)
        except Exception as e:
            logging.error('[%s] (%s)\tFailed to read backup debsecan file %s.', datetime.datetime.today().isoformat(), __name__, e)
        #~ sys.exit(1)
  
    except urllib.error.URLError as e:
        # no e.code check here
        # Be conservative about the attributes offered by
        # URLError.  They are undocumented, and strerror is not
        # available even though it is documented for
        # EnvironmentError.
        msg = e.__dict__.get('reason', '')
        if msg:
            msg = "error: while downloading %s:\nerror: %s\n" % (url, msg)
        else:
            msg = "error: while downloading %s:\n" % url
        sys.stderr.write(msg)
        logging.critical('[%s] (%s)\t%s', datetime.datetime.today().isoformat(), __name__, msg)
        logging.info('[%s] (%s)\tTrying to read backup debsecan file', datetime.datetime.today().isoformat(), __name__)
        try:
            data = StringIO(open(os.path.join(backup_file_path, suite), 'r').read())
            logging.info('[%s] (%s)\tBackup debsecan file successfully read.', datetime.datetime.today().isoformat(), __name__)
        except Exception as e:
            logging.error('[%s] (%s)\tFailed to read backup debsecan file %s.', datetime.datetime.today().isoformat(), __name__, e)
        #~ sys.exit(1)
        

    if not data:
        data = []
        while 1:
            d = u.read(4096)
            if d:
                data.append(d)
            else:
                break
        data = StringIO(zlib.decompress(bytes.join(b'', data)).decode("utf-8"))

        # Backing up debsecanfile
        fw = open(os.path.join(backup_file_path, suite), 'w')
        fw.write(data.getvalue())
        fw.close()
    
    if data.readline() != "VERSION 1\n":
        sys.stderr.write("error: server sends data in unknown format\n")
        sys.exit(1)

    vuln_names = []
    for line in data:
        
        if line[-1:] == '\n':
            line = line[:-1]
        if line == '':
            break
        (name, flags, desc) = line.split(',', 2)
        vuln_names.append((name, desc))

    packages = {}
    for line in data:
        if line[-1:] == '\n':
            line = line[:-1]
        if line == '':
            break
        #~ print(line)
        v = Vulnerability(vuln_names, line)
        try:
            packages[v.package].append(v)
        except KeyError:
            packages[v.package] = [v]

    source_to_binary = {}
    for line in data:
        if line[-1:] == '\n':
            line = line[:-1]
        if line == '':
            break
        (sp, bps) = line.split(',')
        if bps:
            source_to_binary[sp] = bps.split(' ')
        else:
            source_to_binary[sp] = []

    for vs in list(packages.values()):
        for v in vs:
            if not v.binary_package:
                v.binary_packages = source_to_binary.get(v.package, None)

    return packages

    
def rate_system(host_packages, vulns, display=False):
    """Read /var/lib/dpkg/status and discover vulnerable packages.
    if display == True display vuln in stdout
    return the list of available vulnerabilities

    options: command line options
    vulns: list of vulnerabiltiies"""
    
    tmpvulns = []

    for host_package in host_packages:
        
        pkg_name = host_package['id']
        pkg_status = "install ok installed"
        pkg_version = host_package['version']
        pkg_arch = None
        pkg_source = host_package['src_id']
        pkg_source_version = host_package['src_version']
        suite = host_package['os_version']
        
        try:
            pkg_version = Version(pkg_version)
        except ValueError:
            sys.stderr.write("error: invalid version %s of package %s\n"
                         % (pkg_version, pkg_name))
            continue
        try:
            pkg_source_version = Version(pkg_source_version)
        except ValueError:
            sys.stderr.write("error: invalid source version %s of package %s\n"
                         % (pkg_version, pkg_name))
            continue

        try:
            vlist = vulns[pkg_source]
        except KeyError:
            try:
                vlist = vulns[pkg_name]
            except:
                continue
        
        for v in vlist:
            bp = (pkg_name, pkg_version)
            sp = (pkg_source, pkg_source_version)
            if v.is_vulnerable (bp, sp):
                
                    
                if v.fix_available == True and len(v.other_versions) > 0:
                    vuln_pkg_verss = []
                    vuln_pkg_verss.append(str(pkg_version))
                    vuln_pkg_src_verss = []
                    vuln_pkg_src_verss.append(str(pkg_source_version))
                    vuln_fixed_pkg_verss = []
                    vuln_fixed = []
                    vuln_fixed.append(f.suite2tag(suite))
                    for other_version in v.other_versions:
                        if pkg_source_version.compare(other_version) < 0:
                            vuln_fixed_pkg_verss.append(other_version)
                    tmpvulns.append(Vulns.Vuln(v.bug, "debsecan", pkg_name, vuln_pkg_verss, pkg_source, vuln_pkg_src_verss, [], vuln_fixed, vuln_fixed_pkg_verss, v.remote, v.urgency, "").to_json())
                else:
                    vuln_pkg_verss = []
                    vuln_pkg_verss.append(str(pkg_source_version))
                    vuln_pkg_src_verss = []
                    vuln_pkg_src_verss.append(str(pkg_source_version))
                    tmpvulns.append(Vulns.Vuln(v.bug, "debsecan", pkg_name, vuln_pkg_verss, pkg_source, vuln_pkg_src_verss, [], [], [], v.remote, v.urgency, "").to_json())
                if display == True:
                    display_vulnerability(v, bp, sp, suite)
                        
    return tmpvulns
                
