# Copyright 2026 SigmaSRC, Inc. All Rights Reserved.

"""Fuel Runtime Library.

There will be at least two versions of this library with the same
interface; one running on the client, one running on the central
server.  I'm first writing the client version, because it's easier to
test (no need to connect to a database).

There may be two client-side versions: Dan would prefer the
client-side library to use the saved gather data rather than the live
system (for checking -- obviously enforcement *has* to use the live
system :-).  The advantage is consistency with the compliance checks
run on the central server.  The disadvantage is that we currently
don't have a way to query this information (it's in flat files using a
text encoding that's very slow for random access), and there may be
discrepancies between the live system and the gathered information.
For example, it would be bad to disable an account because it had no
password when the gathering process ran, when a password has already
been added by the time the enforcement code ran.  Using the live
system would prevent this mistake.

"""

RUNTIME_VERSION = "2.0" # Exported for the benefit of others

# Python imports
import os
import re
import hashlib
import sys
import stat
import time
import marshal
import logging

if os.name == "nt":
    from sigma.runtime.winrtlib import WinMixin
else:
    class WinMixin(object):
        def winregint(self, *args):
            return None
        def winregstr(self, *args):
            return None
        def winfileperms(self, *args):
            pass

# Imports that won't work everywhere
# (If these don't work, the Account and AccountGroup classes are inaccessible)
try:
    import pwd
except ImportError:
    pwd = None
try:
    import grp
except ImportError:
    grp = None

# Sigma imports
from sigma.runtime import pconf
from sigma.runtime import actions
from sigma.runtime import rtutils
from sigma.runtime import editfile
from sigma.runtime.fuelobj import FuelObject
from sigma.runtime.xmlout import ResultsOut
from sigma.runtime.pwall import PoliWall, IPAddr
from sigma.util import antglob
from sigma.util import console
from sigma.util.fuelset import FuelSet
from sigma.util.config import Options
from sigma.util import ostypes
from sigma.util.netstatinfo import NetstatInfo

# Global objects
logger = logging.getLogger("runtime.rtlib")

# Strings that indicate a definitely invalid shell
NON_SHELLS = ["/dev/null", "/bin/false", "/sbin/nologin"]

# Strings that indicate a definitely invalid home directory
NON_HOMES = ["/dev/null", "/bin/false"]

# Exception

class FuelExit(Exception):
    """Exception raised to stop execution."""

    def __init__(self, status):
        self.status = int(status)

    def __str__(self):
        return "FuelExit(%d)" % self.status

    __repr__ = __str__

def parse_version(s):
    """Parse a version string into a sequence of numbers.

    This understands the Python (and other) convention where 2.3a1
    combes before 2.3 but 2.3.1a1 comes after 2.3 (and before 2.3.1),
    and the Tcl convention that 7.4p1 comes after 7.4, and the (Java?)
    convention that 'X.YrcN' means release candidate N which again
    comes before X.Y.

    In general, if the string has a final suffix of the form

        <letter>+ <digit>+

    then this is ordered so that a < b < c < rc < 0 < p.

    """

    # 1) Strip off trailing <letter>+ <digit>+ if present

    m = re.match(r"\A(.*)([a-zA-Z]+)(\d+)\Z", s)
    if m:
        s, letters, digits = m.group(1, 2, 3)
    else:
        letters = digits = ""

    # 2) Break up in integer parts separated by dots; remove empty
    #    parts (this may fail if there are non-numbers)

    parts = [int(x.strip()) for x in s.split(".") if x.strip()]

    # 3) Append mapped letters and digits (this may fail for
    #    unrecognized words)

    if letters:

        lettermap = {
            "a": -4, "alpha": -4,
            "b": -3, "beta": -3,
            "c": -2, "gamma": -2,
            "rc": -1,
            "f": 0, "final": 0,
            "p": 1, "patch": 1,
            }
        parts.append(lettermap[letters.lower()])
        parts.append(int(digits))

    return parts

# XXX Should these classes be called Account and AccountGroup?  Or
# User and Group?  Or Account and Group?  Or what?

class Account(FuelObject):

    def __init__(self, host,
                 username, passwd, uid, gid, realname, home, shell):
        self.host = host
        self.username = username
        self.passwd = passwd
        self.uid = uid
        self.gid = gid
        self.realname = realname
        self.home = home
        self.shell = shell

    def delete(self):
        if self.host.enforce:
            # XXX Should we use popen2 and do something to log stderr?
            # Then argument quoting becomes a problem.
            before = ":".join((self.username, self.passwd,
                               str(self.uid), str(self.gid),
                               self.realname, self.home, self.shell))
            # Special case: after is the shadow file info, if known
            shadow = self.host.get_etc_shadow_fields(self.username)
            if shadow is None:
                after = None
            else:
                after = ":".join(shadow).rstrip("\n")
            status = os.spawnlp(os.P_WAIT, "userdel", "userdel",
                                self.username)
            if status != 0:
                # XXX What if it failed after a partial action???
                raise IOError("Couldn't delete user %s" % self.username)
            self.host.logaction("userdel", self.username, before, after,
                                "deleted user account")

    def set_shell(self, shell):
        if self.host.enforce:
            before = self.shell
            f = open("/etc/passwd", "r")
            try:
                lines = f.readlines()
            finally:
                f.close()
            # Make a backup
            f = open("/etc/passwd.bak", "w")
            try:
                f.writelines(lines)
            finally:
                f.close()
            # Edit the lines
            for i, line in enumerate(lines):
                line = line.rstrip("\n")
                words = line.split(":")
                if words[0] == self.username and len(words) >= 7:
                    words[6] = shell
                    lines[i] = ":".join(words) + "\n"
                    break # Only edit the first occurrence
            f = open("/etc/passwd", "w")
            try:
                f.writelines(lines)
            finally:
                f.close()
            self.shell = shell
            after = self.shell
            self.host.logaction("chsh", self.username, before, after,
                                "set user's shell to %s" % self.shell)

    def get_shadow_field(self, index):
        fields = self.host.get_etc_shadow_fields(self.username)
        if not fields:
            return None
        else:
            return fields[index]

    def get_shadow_int_field(self, index):
        x = self.get_shadow_field(index)
        if x is None:
            return None
        else:
            try:
                return int(x)
            except ValueError:
                return None

    def get_shadow_username(self):
        # This is useful for checking that the shadow entry exists at all
        return self.get_shadow_field(0)

    def get_shadow_password(self):
        return self.get_shadow_field(1)

    def get_shadow_lastchg(self):
        return self.get_shadow_int_field(2)

    def get_shadow_min(self):
        return self.get_shadow_int_field(3)

    def get_shadow_max(self):
        return self.get_shadow_int_field(4)

    def get_shadow_warn(self):
        return self.get_shadow_int_field(5)

    def get_shadow_inactive(self):
        return self.get_shadow_int_field(6)

    def get_shadow_expire(self):
        return self.get_shadow_int_field(7)

    def get_shadow_flag(self):
        return self.get_shadow_int_field(8)

    shadow_username = property(get_shadow_username)
    shadow_password = property(get_shadow_password)
    shadow_lastchg = property(get_shadow_lastchg)
    shadow_min = property(get_shadow_min)
    shadow_max = property(get_shadow_max)
    shadow_warn = property(get_shadow_warn)
    shadow_inactive = property(get_shadow_inactive)
    shadow_expire = property(get_shadow_expire)
    shadow_flag = property(get_shadow_flag)

class AccountGroup(FuelObject):

    def __init__(self, host, groupname, passwd, gid, members):
        members = tuple(members)
        for x in members:
            assert isinstance(x, str), (x, members)
        self.host = host
        self.groupname = groupname
        self.passwd = passwd
        self.gid = gid
        self.members = members # tuple of strings

    def get_explicit_members(self):
        """Return the explicit members as a set of Accounts.

        This returns only the members explicitly listed in the group
        file.  See get_implicit_members().

        """
        result = FuelSet()
        for username in self.members:
            result.add(self.host.account(username))
        return result

    def get_implicit_members(self):
        """Return the implicit members as a set of Accounts.

        This returns only the users whose gid in the passwd file
        equals this group's gid.  See get_explicit_members().

        """
        result = FuelSet()
        for pwent in pwd.getpwall():
            if pwent.pw_gid == self.gid:
                result.add(self.host.account(pwent.pw_name))
        return result

    def get_all_members(self):
        """Return all (implicit or explicit) members of the group."""
        return self.get_implicit_members() | self.get_explicit_members()

class Permission(object):
    """A permission object symbolically represents file permissions.

    For now, the argument is simply the Unix permission bits
    (including setuid etc. but excluding the file type information);
    in the future, we'll add an argument for the Windows equivalent
    too, once we know the structure of that data.

    """
    def __init__(self, unixbits):
        self.unixbits = unixbits


class SharedFile(FuelObject):
    # This is used from File in dbrtlib.py.

    def __init__(self, host, filename):
        self.host = host
        self.filename = filename

class File(SharedFile):

    def _get_stats(self):
        return os.stat(self.filename)

    def _get_exists(self):
        try:
            self._get_stats()
            return True
        except os.error:
            return False

    exists = property(_get_exists)

    def _get_ftype(self):
        try:
            st = self._get_stats()
        except os.error:
            return "nonexistent"
        mode = st.st_mode
        if stat.S_ISREG(mode):
            return "file"
        if stat.S_ISDIR(mode):
            # XXX Do we want to distinguish mount points?
            return "directory"
        if stat.S_ISLNK(mode):
            return "symlink"
        if stat.S_ISSOCK(mode):
            return "socket"
        if stat.S_ISFIFO(mode):
            return "fifo"
        return "special" # Don't distinguish between CHR and BLK

    ftype = property(_get_ftype)

    def _get_perms(self):
        return stat.S_IMODE(self._get_stats().st_mode)

    perms = property(_get_perms)

    def _get_user(self):
        return self.host.account(self._get_stats().st_uid)

    user = property(_get_user)

    def _get_group(self):
        return self.host.group(self._get_stats().st_gid)

    group = property(_get_group)

    def _get_uid(self):
        return self._get_stats().st_uid

    uid = property(_get_uid)

    def _get_gid(self):
        return self._get_stats().st_gid

    gid = property(_get_gid)

    def _get_size(self):
        return self._get_stats().st_size

    size = property(_get_size)

    def _get_atime(self):
        return self._get_stats().st_atime

    atime = property(_get_atime)

    def _get_mtime(self):
        return self._get_stats().st_mtime

    mtime = property(_get_mtime)

    def _get_ctime(self):
        return self._get_stats().st_ctime

    ctime = property(_get_ctime)

    def _get_dev(self):
        return self._get_stats().st_dev

    dev = property(_get_dev)

    def _get_ino(self):
        return self._get_stats().st_ino

    ino = property(_get_ino)

    def _get_linkto(self):
        try:
            return os.readlink(self.filename)
        except os.error:
            return ""

    linkto = property(_get_linkto)

    def chmod(self, perm):
        """Change mode.

        Argument is a Permission object.

        This is a no-op if the file already has the requested
        permissions.

        """
        if not self.host.enforce:
            return
        if perm.unixbits == self.perms:
            return
        before = oct(self.perms)
        os.chmod(self.filename, perm.unixbits)
        after = oct(self.perms)
        self.host.logaction("chmod", self.filename, before, after,
                            "%s -> %s" % (before, after))

    def chown(self, user, group=None):
        """Change user and group.

        Arguments are an Account instance and an optional Group
        instance.  If both are given, user.uid and group.gid are used.
        If group is None, user.gid is used instead of group.gid.  If
        user is None, group may not be None, and the uid is not
        changed.

        This is a no-op if the file already has the requested owner
        and group.

        """
        if not self.host.enforce:
            return
        ubefore = self.user
        gbefore = self.group
        if user is None:
            uid = ubefore.uid
        else:
            uid = user.uid
        if group is None:
            if user is None:
                raise TypeError("either user or group (or both) must be given")
            gid = user.gid
        else:
            gid = group.gid
        if uid == ubefore.uid and gid == gbefore.gid:
            return
        os.chown(self.filename, uid, gid)
        uafter = self.user
        gafter = self.group
        before = "%d,%d" % (ubefore.uid, gbefore.gid)
        after = "%d,%d" % (uafter.uid, gafter.gid)
        self.host.logaction("chown", self.filename, before, after,
                            "%s -> %s (%s,%s -> %s,%s)" %
                            (before, after,
                             ubefore.username, gbefore.groupname,
                             uafter.username, gafter.groupname))

    def open(self, mode="r"):
        """Open the file for reading."""
        if mode not in ("r", "rb", "U"):
            raise ValueError("mode must be 'r', 'rb', or 'U'")
        return open(self.filename, mode)

    def save(self, data, message, mode="w", perm=None, user=None, group=None):
        """Write new contents to the file.

        This takes care of calling actionlog().

        """
        if mode not in ("w", "wb"):
            raise ValueError("mode must be 'w' or 'wb'")
        if not self.exists:
            action = "create"
            before = None
        else:
            action = "overwrite"
            f = open(self.filename, "rb")
            try:
                before = f.read()
            finally:
                f.close()
            # XXX Make a backup copy
        if mode == "w" and os.linesep != "\n":
            after = data.replace("\n", os.linesep)
        else:
            after = data
        # XXX Use dance.rename()?  (Which has problems too.)
        f = open(self.filename, mode)
        try:
            f.write(data)
        finally:
            f.close()
        self.host.logaction(action, self.filename, before, after, message)
        if user or group:
            self.chown(user, group)
        if perm:
            self.chmod(perm)

    def get_md5(self):
        """Calculate the file's MD5.  Return it hexlified.

        Return None if the file can't be opened or is not a regular file.

        """
        try:
            st = self._get_stats()
        except os.error:
            return None
        if not stat.S_ISREG(st.st_mode):
            return ""
        try:
            f = open(self.filename, "rb")
        except IOError:
            return ""
        try:
            BUFSIZE = 8*1024
            summer = hashlib.md5()
            while True:
                block = f.read(BUFSIZE)
                if not block:
                    break
                summer.update(block)
            return summer.hexdigest()
        finally:
            f.close()

    def delete(self):
        """Delete a file or directory.

        We don't really delete it, we rename it with a special suffix:
        '.YYYY-MM-DD.sigma' where YYYY-MM-DD is today's date
        (local time).  If that file already exists, we use
        .YYY-MM-DD.N.sigma where N is a sequence number (starting
        with 1, up to 99).

        """
        if self.host.enforce:
            try:
                f = self.open("rb")
            except os.error as err:
                before = None # Must be a directory
            else:
                try:
                    before = f.read()
                finally:
                    f.close()
            # Special case: after is the file's stat() info
            after = ":".join(map(str, self._get_stats()))
            ymd = time.localtime()[:3]
            target = self.filename + ".%04d-%02d-%02d.sigma" % ymd
            if os.path.exists(target):
                fmt = self.filename + ".%04d-%02d-%02d.%d.sigma"
                y, m, d = ymd
                n = 1
                while n < 100:
                    target = fmt % (y, m, d, n)
                    if not os.path.exists(target):
                        break
                    n += 1
                else:
                    # Too many tries; just delete and overwrite it
                    try:
                        os.remove(target)
                    except os.error:
                        pass
            os.rename(self.filename, target)
            try:
                os.chmod(target, 0)
            except os.error:
                s = ""
            else:
                s = " with mode 0"
            self.host.report("renamed %r to %r%s", self.filename, target, s)
            self.host.logaction("delete", self.filename, before, after,
                                "renamed to %r%s" % (target, s))

class FileStat(FuelObject):
    """A record containing some info about a file.

    This is specifically not a full-fledged File object, as it is used
    to return info from the RPM database.

    """

    def __init__(self, filename, size, mode, user, group, md5):
        self.filename = filename
        self.size = size
        self.mode = mode
        self.user = user
        self.group = group
        self.md5 = md5


class AttributeCollector(FuelObject):

    def __init__(self, host):
        self.host = host
        self.attrs = []
        self.attrID = 1
        self._maxlength = 4000
        self.options = Options()

    def add(self, name, value, when=None, who=None):
        return self.addchild(0, name, value, when, who)

    def addchild(self, parentID, name, value, when=None, who=None):
        aID = self.attrID
        if parentID >= aID:
            raise ValueError("parentID must be less than " + str(aID))
        if who == None:
            # We may not have an AgentID yet so use localhost
            #  and let the server translate agentIDs from the gather header.
            who = "127.0.0.1"
        if when == None:
            when = str(time.time())
        if len(value) > self._maxlength:
            _truncatestring = "\n(Output truncated to " + str(self._maxlength) + " characters)"
            value = value[:self._maxlength - len (_truncatestring)] + _truncatestring
        self.attrs.append([aID, parentID, who, when, name, value])
        self.attrID += 1
        return aID

    def save(self):
        """Collect and spool new attributes.

        """
        from sigma.gather import gathering
        c = gathering.Collector(self.options)
        try:
            t = c.table("attributes", ["id", "pid", "who", "when", "name", "value"])
            attrs = self.attrs
            self.attrs = []
            for attr in attrs:
                t.add(attr)
            c.flush()
            c.send_full()
            c.shuffle_save_files()
        finally:
            c.cleanup()

class SimpleInteger(FuelObject):

    def __init__(self, host):
        self.host = host
        self.value = 0

    def setvalue(self, value):
        self.value = value

    def getvalue(self):
        return self.value


class Package(FuelObject):
    """A Package instance represents an installed application.

    For now, we only support applications installed via RPM; in the
    future, we'll support the Solaris and Windows ways as well
    (probably by platform-specific refactorings).

    """

    def __init__(self, host, pkgname, version, release):
        self.host = host
        self.pkgname = pkgname
        self.version = version
        self.release = release # Vendor/build-specific version info
        try:
            self.parsed_version = parse_version(self.version)
        except:
            self.parsed_version = None
        try:
            self.parsed_release = parse_version(self.release)
        except:
            self.parsed_release = None


    def get_files(self):
        """Return a set of FileStat instances.

        This specifically should *not* return a list of File objects,
        as this is data we should *compare* to the actual file objects.

        """
        fields = """FILENAMES FILESIZES FILEMODES FILEUSERNAME
                    FILEGROUPNAME FILEMD5S""".split()
        fields = ["%%{%s}" % f for f in fields]
        delimiter = "\x01"
        fields = delimiter.join(fields)
        command = r"rpm -q --qf '[%s\n]' %s" % (fields, self.pkgname)
        pipe = os.popen(command, "r")
        files = FuelSet()
        for line in pipe:
            record = line.strip().split(delimiter)
            if len(record) == 6:
                filename, ssize, smode, user, group, md5 = record
                size = int(ssize)
                mode = int(smode)
                md5 = md5 or ""
                files.add(FileStat(filename, size, mode,
                                   self.host.account(user),
                                   self.host.group(group), md5))
        pipe.close()
        return files

class Process(FuelObject):

    def __init__(self, host, pid, ppid, command_line, login):
        """Constructor."""
        self.host = host
        self.pid = pid                  # process id
        self.ppid = ppid                # parent process id
        self.command_line = command_line # command name
        self.login = login              # login name
        # Compute the command from the command line
        words = command_line.split()
        command = words and words[0] or ""
        if command[:1] + command[-1:] == "[]":
            command = command[1:-1]
        else:
            command = os.path.basename(command)
            if command[:1] == "-":
                command = command[1:]
        self.command = command

_ifname_prefix = "\\Device\\NPF_" # Stuff to strip off the interface name

class InterfaceInfo(FuelObject):
    """See getinterfaceinfo() below."""
    def __init__(self, ip, iftype, network, name):
        self.ip = ip
        self.ipaddr = IPAddr(ip)
        self.iftype = iftype
        self.network = network
        if name.startswith(_ifname_prefix):
            # Strip stuff added by poliwall to GUID on Windows systems
            name = name[len(_ifname_prefix):]
        self.name = name

class HostBase(FuelObject):
    """Base class for Host class.

    This is what's shared between rtlib and dbrtlib.

    """

    enforce = False
    pwall_enforce = False
    pwall_mode = None # "allow" or "deny", meaning global allow or deny mode
    directiveid = None
    directivever = None
    ruleid = None
    tmplid = None
    priority = 0
    context = None # Directive with one Rule giving exact execution context

    def __init__(self, hostname):
        """Constructor, sets the host name."""
        self.hostname = hostname
        self.xmlout = None

    def close(self):
        if self.xmlout is not None:
            self.xmlout.close()
            self.xmlout = None

    def logaction(self, type, object, before, after, message):
        """Wrapper for actions.logaction() that also calls enforcement()."""
        actions.logaction(self, type, object, before, after, message)
        self.enforcement(0, "%s - %s - %s", type, object, message)

    def openxml(self):
        """Ensure self.xmlout is defined and <results> element started."""
        if self.xmlout is None:
            self.xmlout = ResultsOut()
            self.xmlout.results()
        return self.xmlout

    def __repr__(self):
        return "%s(%r)" % (self.__class__.__name__, self.hostname)

    def settmplid(self, id):
        """Set the template ID."""
        self.tmplid = id

    def setruleid(self, id, priority=0):
        """Start new <result> output element.

        This is typically used only by code generated by shipfuel.py.

        The arguments are the rule id (a string) and optionally a
        priority (a number, defaulting to zero; higher numbers mean
        lower priorities).

        As a side effect, self.ruleid and self.priority are set.

        """
        self.ruleid = id
        self.priority = priority
        self.openxml().result(id)

    def setdirectiveid(self, id, ver):
        """Set the directive ID.  """
        self.directiveid = id
        self.directivever = ver

    def report(self, msg, *args):
        """Override this to report in a different way.

        Host methods should not use print statements.  Compliance
        violations should be logged using the violation() method,
        which write XML to stdout; other output should be written
        using report(), which logs the message at INFO level.

        This method supports three invocation styles:

        a) Message without arguments: self.report('Foo bar')
        b) Format string plus arguments: self.report('Foo %s', 'bar')
        c) Non-format string plus values: self.report('Foo', 'bar')

        All three examples above produce the same output.  Non-format
        strings should not contain any % characters (not even doubled).

        """
        msg = rtutils.format(msg, *args)
        for line in msg.splitlines():
            logger.info(line)

    def compliance(self, msgid, msg, *args):
        """Report compliance.

        For now, the message is ignored; in the future, it may be
        passed on to the end user in the UI (just like violations).

        """
        msg = rtutils.format(msg, *args)
        logger.info("Rule %s: compliance %s", self.ruleid, msg)
        self.openxml().compliance(msg)

    def error(self, msgid, msg, *args):
        """Report a serious error in XML to stdout.

        Arguments as for report() and format().

        """
        self.report("error:" + msg, *args)
        self.openxml().error(rtutils.format(msg, *args))
        raise FuelExit(1)

    def leave(self, msgid, msg, *args):
        """Report a low-grade error in XML to stdout (rule N/A).

        Arguments as for report() and format().

        """
        self.report("leave:" + msg, *args)
        self.openxml().leave(rtutils.format(msg, *args))
        raise FuelExit(0)

    def violation(self, msgid, msg, *args):
        """Report a violation in XML to stdout.

        Arguments as for report() and format().

        """
        self.openxml().violation(rtutils.format(msg, *args))

    def enforcement(self, msgid, msg, *args):
        """Report a enforcement in XML to stdout.

        Arguments as for report() and format().

        """
        self.openxml().enforcement(rtutils.format(msg, *args))

    def detail(self, key, value):
        """Report a violation detail in XML to stdout.

        Arguments are a detail key (e.g. 'filename' or 'mode') and a
        detail value (e.g. the name or mode of the file in violation).

        Call this zero or more times after calling violation().

        XXX This is obsolete, but there's still Fuel that uses it.

        """
        self.openxml().detail(key, value)

    def require(self, test, msgid, msg, expr=None, symbols=None):
        """Intelligent violation support.

        If the test argument is false, a violation is reported using
        the msgid and msgid arguments; detail is added based on the
        expr and symbols arguments.  The detail is filled in by Fuel
        compiler support: expression is the source text of the
        expression whose value is the test argument, and symbols is a
        dictionary giving the values of various symbols and
        subexpressions.

        XXX I'm tempted to deprecate this, it didn't work out very
        well (especially since the detail text is never used in the
        server).

        """
        xmlout = self.openxml()
        if not test:
            xmlout.violation(msg)
            if expr:
                xmlout.detail("expression", expr)
            if symbols:
                keys = sorted(symbols.keys())
                for key in keys:
                    xmlout.detail(key, repr(symbols[key]))

    perms_cache = {}

    def permission(self, mode):
        """Return a Permission object for the given mode.

        This uses a cache.

        XXX Perhaps we should get rid of Permission objects?

        """
        perm = self.perms_cache.get(mode)
        if perm is None:
            perm = self.perms_cache[mode] = Permission(mode)
        return perm

    # Some sample permissions (I'm struggling a bit with the names)
    PERM_WORLD_WRITABLE = Permission(0o666)
    PERM_GROUP_WRITABLE = Permission(0o664)
    PERM_WORLD_READABLE = Permission(0o644)
    PERM_READONLY = Permission(0o444)
    PERM_PRIVATE = Permission(0o600)
    PERM_EXTREME = Permission(0o400)
    PERM_NONE = Permission(0o000)
    PERM_EXECUTABLE = Permission(0o755)
    PERM_SETUID = Permission(0o4711)
    PERM_SETUID_R = Permission(0o4755)
    PERM_SETGID = Permission(0o2711)
    PERM_SETGID_R = Permission(0o2755)

    # Higher-level utilities

    def file_exists(self, filename):
        """Given a file name, return True if it exists, else false."""
        return self.file(filename).exists

    def configfile(self, filename, subsystem, perms=None,
                   user=None, group=None):
        """Return a ConfigFile object."""
        cf = pconf.ParseConfigFile(self, filename, subsystem, perms,
                                   user, group)
        errors = cf.geterrors()
        if errors:
            self.report("warning: errors from configfile(%r, %r):",
                        filename, subsystem)
            for line in errors:
                self.report("%s", line)
        return cf

    def editfile(self, filename, perms=None, user=None, group=None):
        """Return an EditFile object."""
        return editfile.EditFile(self, filename, perms, user, group)

    def editbuffer(self, initialtext=None):
        """Return an EditBuffer object."""
        # It's really an EditFile object without a file
        result = editfile.EditFile(self)
        if initialtext is not None:
            result.append(initialtext)
        return result

    def perms(self, enforce, files, perm, user, group=None):
        """Permission checking helper.

        This actually checks owner and permission together, since
        these are really closely related (the permission bits mean
        nothing without a specific user/group).

        If the group is omitted, it is *not* enforced.

        If the user is explicitly set to None, it is not enforced.

        Having fewer permission bits than requested is okay.

        The 'enforce' argument is a flag determining whether
        non-compliance should only be reported or also be enforced.
        If self.enforce is False, enforce is forced to False.

        """
        enforce = self.enforce and enforce
        if user is None:
            uid = None
        else:
            uid = user.uid
        if group is None:
            gid = None
        else:
            gid = group.gid
        xmlout = self.openxml()
        for file in files:
            if not file.exists:
                continue
            if file.perms & ~perm.unixbits:
                msg = "file %s has wrong permissions (%s, should be %s)"
                msg %= (file.filename, oct(file.perms), oct(perm.unixbits))
                xmlout.violation(msg)
                if enforce:
                    file.chmod(perm)
            chown = False
            if uid is not None and file.uid != uid:
                msg = "file %s has wrong owner (uid=%s, should be uid=%s)"
                msg %= (file.filename, file.uid, uid)
                xmlout.violation(msg)
                chown = True
            if gid is not None and  file.gid != gid:
                msg = "file %s has wrong group (gid=%s, should be gid=%s)"
                msg %= (file.filename, file.gid, gid)
                xmlout.violation(msg)
                chown = True
            if chown and enforce:
                file.chown(user, group)

class Host(HostBase, WinMixin):
    """The Host class represents a host.

    All operations that (explicitly or implicitly) use a host as
    context should be methods of this class or

    In local mode, there can be only one Host object and it represents
    the current host.  In the central server version of this library,
    there can be many Host objects, each corresponding to a different
    row in the host table.

    """

    def __init__(self, hostname):
        HostBase.__init__(self, hostname)
        self.users_cache = {}
        self.groups_cache = {}
        self.options = Options()

    # Utilities for creating Account and AccountGroup instances

    # XXX When should two Accounts be the same object?  Only when all
    # fields are identical?  Are Account mutable?  What to return when
    # a uid doesn't exist?  Same questions for AccountGroups.

    def account(self, uid):
        """Return an Account instance for a given user name or uid."""
        # Raises KeyError if the user doesn't exist.
        user = self.users_cache.get(uid)
        if user is not None:
            return user
        try:
            if isinstance(uid, str):
                pwent = pwd.getpwnam(uid)
            else:
                pwent = pwd.getpwuid(uid)
        except KeyError:
            return None
        user = Account(self, *pwent)
        self.users_cache[user.username] = user
        self.users_cache[user.uid] = user
        return user

    shadow_filename = "/etc/shadow"
    shadow_cache = {}
    shadow_mtime = None

    def check_etc_shadow_file(self):
        try:
            mtime = os.path.getmtime(self.shadow_filename)
        except os.error:
            mtime = None
        if self.shadow_mtime != mtime:
            self.shadow_mtime = mtime
            self.shadow_cache = {}
            try:
                f = open(self.shadow_filename)
            except IOError as err:
                logger.debug("Can't open %s: %s", self.shadow_filename, err)
                return
            try:
                for line in f:
                    parts = line.split(":")
                    if len(parts) != 9:
                        logger.warning("Bogus line in /etc/passwd: %r", line)
                        continue
                    username = parts[0]
                    if username in self.shadow_cache:
                        logger.warning("Duplicate line in /etc/shadow for %s",
                                    username)
                        continue
                    self.shadow_cache[username] = tuple(parts)
            finally:
                f.close()

    def get_etc_shadow_fields(self, username):
        self.check_etc_shadow_file()
        return self.shadow_cache.get(username)

    def list_all_accounts(self):
        """Return a Set of all Accounts found in the password database.

        This uses getpwall() and includes remote accounts (e.g. NIS).

        """
        result = FuelSet()
        for pwent in pwd.getpwall():
            user = self.users_cache.get(pwent.pw_name)
            if user is None:
                user = Account(self, *pwent)
                self.users_cache[user.username] = user
                self.users_cache[user.uid] = user
            result.add(user)
        return result

    def list_local_accounts(self):
        """Return a Set of all Accounts found in the password database.

        Unlike list_all_accounts(), this parses /etc/passwd and only
        returns local accounts.

        """
        result = FuelSet()
        try:
            f = open("/etc/passwd")
        except IOError:
            return result
        try:
            for line in f:
                if line[0] not in ('#', '+'):
                    stuff = line.rstrip("\n").split(":")
                    if len(stuff) == 7:
                        user = self.users_cache.get(stuff[0])
                        if user is None:
                            try:
                                stuff[2:4] = list(map(int, stuff[2:4]))
                            except ValueError:
                                # Invalid user entry -- skip it
                                continue
                            user = Account(self, *stuff)
                            self.users_cache[user.username] = user
                            self.users_cache[user.uid] = user
                        result.add(user)
        finally:
            f.close()
        return result

    def group(self, gid):
        """Return an AccountGroup instance for a given group name or gid."""
        group = self.groups_cache.get(gid)
        if group is None:
            try:
                if isinstance(gid, str):
                    stuff = grp.getgrnam(gid)
                else:
                    stuff = grp.getgrgid(gid)
            except KeyError:
                return None
            group = AccountGroup(self, *stuff)
            self.groups_cache[group.groupname] = group
            self.groups_cache[group.gid] = group
        return group

    def list_all_groups(self):
        """Return a Set of all AccountGroups found in the groups database.

        This uses getgrall() and includes remote groups (e.g. NIS).

        """
        result = FuelSet()
        for grent in grp.getgrall():
            group = self.groups_cache.get(grent.gr_name)
            if group is None:
                group = AccountGroup(self, *grent)
                self.groups_cache[group.groupname] = group
                self.groups_cache[group.gid] = group
            result.add(group)
        return result

    def list_local_groups(self):
        """Return a Set of all AccountGroups found in the group database.

        Unlike list_all_groups(), this parses /etc/group and only
        returns local groups.

        """
        result = FuelSet()
        try:
            f = open("/etc/group")
        except IOError:
            return result
        try:
            for line in f:
                if line[0] not in ('#', '+'):
                    stuff = line.rstrip("\n").split(":")
                    if len(stuff) == 4:
                        group = self.groups_cache.get(stuff[0])
                        if group is None:
                            stuff[2] = int(stuff[2])
                            group = AccountGroup(self, *stuff)
                            self.groups_cache[group.groupname] = group
                            self.groups_cache[group.gid] = group
                        result.add(group)
        finally:
            f.close()
        return result

    def _tuplify_group_stuff(self, stuff):
        """Internal helper to tuplify group stuff."""
        stuff = list(stuff)
        stuff[3] = tuple(stuff[3])# Tuplify the list of members
        return tuple(stuff) # Tuplify the whole thing

    # Utilities for creating File instances

    # XXX Should we have a cache for File objects like for Accounts?  But
    # what if a file is deleted?  Should the File object delete itself???
    # And there may be a lot of File objects; the cache shouldn't keep
    # them all alive (not such a big deal for Accounts).

    def file(self, filename):
        """Return a File object."""
        if not os.path.isabs(filename):
            self.error(0, "File name is not absolute: %s", filename)
        return File(self, filename)

    def glob(self, includes, excludes=[], xdev=False):
        """Return a set of File objects matching the pattern."""
        matches = []
        for pattern in includes:
            a = antglob.AntPattern(pattern)
            matches += a.glob(xdev)
        if excludes:
            excludes = list(map(antglob.AntPattern, excludes))
            results = []
            for match in matches:
                for a in excludes:
                    if a.match(match):
                        break
                else:
                    results.append(match)
        else:
            results = matches
        return FuelSet(map(self.file, results))

    # Package utilities

    def get_version_info(self, pkgname):
        """Return a tuple (version, release) if pkgname is installed.

        If pkgname is not installed return None.

        Both return values are strings and may contain digits and dots
        (and the version may probably contain letters, too).

        """
        command = "rpm -q --qf '%{VERSION}|%{RELEASE}\n' " + pkgname
        pipe = os.popen(command, "r")
        text = pipe.read().strip()
        sts = pipe.close()
        if sts or not text:
            return None
        [version, release] = text.split("|")
        return (version, release)

    def package(self, pkgname):
        """Return a Package object corresponding to the package name.

        Return None if no such package is installed.

        """
        vr = self.get_version_info(pkgname)
        if vr is None:
            return None
        version, release = vr
        return Package(self, pkgname, version, release)

    # Process utilities

    def list_all_processes(self):
        """Return a Set of all known Process instances."""
        processes = FuelSet()
        from sigma.gather.ps import ps
        it = ps()
        for record in it:
            # Check and skip header line
            assert record[:-1] == "UID PID PPID C STIME TTY TIME".split()
            assert record[-1] in ("CMD", "COMMAND")
            break
        for uid, pid, ppid, c, stime, tty, time, cmd in it:
            p = Process(self, int(pid), int(ppid), cmd, uid)
            processes.add(p)
        return processes

    # Poliwall

    pw_update_commands = False # If True, pw.save() should update pw_commands
    pw_commands = None # If not None, cached copy of poliwall commands
    pw_ifnameskips = None # If not None, cached copy of poliwall commands

    def poliwall(self):
        """Fuel API: PoliWall factory method."""
        return PoliWall(self, self.ruleid, self.priority)

    # Fuel API (used to be in PoliWall)
    MY_LAN = FuelSet([IPAddr("127.69.83.255")]) # 'E', 'S', 0xff

    # Matrix (used to be in PoliWall)

    pw_matrix = None # If not None, cached copy of matrix

    def gethostgroup(self, name):
        """Fuel API: get a host group by name from the Matrix."""
        return self.getmatrix().gethostgroup(name)

    def gethostgroupnames(self):
        """Fuel API: get the names of all host groups in the Matrix."""
        return self.getmatrix().gethostgroupnames()

    def getmatrixid(self):
        """Fuel API: get the Matrix id."""
        return self.getmatrix().getid()

    def getmatrix(self):
        """Internal: load the Matrix if necessary."""
        if self.pw_matrix is None:
            # Delayed this import to avoid circularity problems
            from sigma.runtime import matrix
            self.pw_matrix = matrix.loadmatrix(self.options)
        return self.pw_matrix

    # Get InterfaceInfo (used to be in net_const.fuel)

    pwifaces_cache = None

    def getinterfaceinfo(self):
        "Fuel API: parse $AVDATA/interfaces.txt; returns set of InterfaceInfo."
        if self.pwifaces_cache is None:
            self.pwifaces_cache = FuelSet()
            fn = self.options.datapath("interfaces.txt")
            try:
                f = open(fn)
            except EnvironmentError as err:
                logger.warning("Can't open %s: %s", fn, err)
            else:
                try:
                    for line in f:
                        m = re.match(r"\s*(\S+)\s+(\S+)\s+(\S+)\s+(\S+)", line)
                        if m:
                            info = InterfaceInfo(*m.groups())
                            self.pwifaces_cache.add(info)
                finally:
                    f.close()
        return self.pwifaces_cache

    def getinterfaces(self):
        """Fuel API: parse $AVDATA/interfaces.txt; returns set of IPAddr."""
        return FuelSet([x.ipaddr for x in self.getinterfaceinfo()])

    # XXX Should we rename this?  It's not just for scripts any more...
    def runscript(self, *args):
        script = " ".join(args)
        # console.popen(), not os.popen(): on Windows the child writes the
        # OEM code page, which strict UTF-8 decoding rejects -- see
        # sigma.util.console.
        pipe = console.popen(script, "r")
        text = pipe.read()
        sts = pipe.close()
        if sts:
            if text:
                if not text.endswith("\n"):
                    text += "\n"
            text += "Error status: (%d, %d)" % (sts>>8, sts&0xff)
        elif text and text.endswith("\n"):
            text = text[:-1]
        return text

    # Gather control

    def gather(self, name):
        from sigma.gather import gathering
        g = gathering.Gatherer(self.options)
        g.full_gather(name)

    def attrcollector(self):
        """Return a AttributeCollector object."""
        return AttributeCollector(self)

    def integer(self):
        """Return a SimpleInteger object."""
        return SimpleInteger(self)

    # Configuration options

    def getconfig(self, name, default=None, raw=False):
        name = name.replace("-", "_")
        s = getattr(self.options, name, default)
        if not raw and s is not None:
            # Special processing for the benefit of multi-line banners
            s = s.strip("\n")
            s = s.replace("\\t", "\t")
            s = s.replace("\\n", "\n")
        return s

    def getostype(self):
        return ostypes.guess_ostype()
    
    def getportbinding(self):
        ostype = ostypes.guess_ostype()
        if "windows" in ostypes.allancestors(ostype):
            return self._wingetportbinding()
        elif ostype == "redhat":
            portInfo = FuelSet()
            _ipre = r"\d+\.\d+\.\d+\.\d+"
            _optport = r"(\d+|\*)"
            linux_linere_tcp = re.compile(
                r"(?P<proto>(tcp))\s*\d+\s*\d+\s*"
                + r"(?P<localip>" + _ipre + r"):(?P<localport>\d+)\s+"
                + r"(?P<foreignip>" + _ipre + r"):(?P<foreignport>"+_optport+r")\s+"
                + r"(?P<state>[^\s]*)\s+"
                + r"(?P<processinfo>[^\n]+)")
            linux_linere_udp = re.compile(
                r"(?P<proto>(udp))\s*\d+\s*\d+\s*"
                + r"(?P<localip>" + _ipre + r"):(?P<localport>\d+)\s+"
                + r"(?P<foreignip>" + _ipre + r"):(?P<foreignport>"+_optport+r")\s+"
                + r"(?P<processinfo>[^\n]+)")
            file = os.popen("/bin/netstat --inet -nap", "r")
            try:
                for line in file:
                    match = linux_linere_tcp.match(line)
                    if match:
                        proto, localip, localport, foreignip, foreignport, state, processinfo = \
                             match.group("proto", "localip", "localport",
                                         "foreignip", "foreignport", "state", "processinfo")
                        if "/" in processinfo:
                            pid, processName = processinfo.split("/", 1)
                            portInfo.add(NetstatInfo(proto, localip, localport, foreignip, foreignport, state, pid, processName))          
                        else:
                            portInfo.add(NetstatInfo(proto, localip, localport, foreignip, foreignport, state, processinfo, processinfo))          
                    else:
                        match = linux_linere_udp.match(line)
                        if match:
                            proto, localip, localport, foreignip, foreignport, processinfo = \
                                 match.group("proto", "localip", "localport",
                                         "foreignip", "foreignport", "processinfo")
                            if "/" in processinfo:
                                pid, processName = processinfo.split("/", 1)
                                portInfo.add(NetstatInfo(proto, localip, localport, foreignip, foreignport, "", pid, processName))          
                            else:
                                portInfo.add(NetstatInfo(proto, localip, localport, foreignip, foreignport, "", processinfo, processinfo))          
            finally:
                file.close()
            return portInfo
        else:
            self.leave(0, "Not valid on Agent's OS")
            
    def gettimeofday(self, year=None, month=None, day=None, hour=None, 
        min=None, sec=None):
        # year is not specified, return current time
        if year == None or year < 0:
            return int(time.time())
        if month == None: 
            month = -1
        if day == None:
            day = -1
        if hour == None:
            hour = -1
        if min == None:
            min = -1
        if sec == None:
            sec = -1
        # assume it's local time
        t = (year, month, day, hour, min, sec, -1, -1, -1) 
        return int(time.mktime(t))
        
################
# Main function for Fuel files that define proc main(h: Host)

def fuel_main(main_proc, args=None):
    import sys
    import optparse
    parser = optparse.OptionParser()
    parser.add_option("-s", "--server", dest="hostname",
              help="run the script against the server database for HOSTNAME")
    parser.add_option("-f", "--enforce", dest="enforce", action="store_true",
                      help="enable enforcement actions (default off)")
    opts, args = parser.parse_args(args)
    if opts.hostname:
        if opts.enforce:
            parser.error("you can't use -s/--server and -f/--enforce together")
        from sigma.serverfuel.dbrtlib import Host as RemoteHost
        host = RemoteHost(opts.hostname)
    else:
        host = Host("localhost")
        if opts.enforce:
            host.enforce = host.pwall_enforce = True
    try:
        main_proc(host)
    finally:
        host.close()
