# Copyright 2026 SigmaSRC, Inc. All Rights Reserved.

"""Code relating to logging enforcement actions.

Each action is logged in two places: a one-line record is appended to
the regular agent log; and an XML record with enough information to
allow rolling back the action is logged to a separate file,
$AVDATA/log/action.YYYY-MM-DD.xml.  (This file is not technically
valid XML; however it contains 1 or more valid XML elements.)

"""

import os
import re
import sys
import time
import logging
import datetime
from io import StringIO

if __name__ == "__main__":
    sys.path.append("../..")

from sigma.util import config, avalog, xmlparse
from sigma.protocol import xmlproto

options = config.Options()
logger = logging.getLogger("runtime.actions")

# XML elements

class Before(xmlparse.ElementClass):
    __element__ = "before"
    __attributes__ = {"missing": xmlparse.Boolean}
    __characters__ = "text"

class After(xmlparse.ElementClass):
    __element__ = "after"
    __attributes__ = {"missing": xmlparse.Boolean}
    __characters__ = "text"

class Context(xmlparse.ElementClass):
    __element__ = "context"
    __attributes__ = {}
    __children__ = {xmlproto.Directive: "directive"} # with exactly one rule

class Action(xmlparse.ElementClass):
    __element__ = "action"
    __attributes__ = {
        "type": xmlparse.String,
        "timestamp": xmlparse.Timestamp,
        "object": xmlparse.String,
        "message": xmlparse.String,
        }
    __children__ = {
        Context: "context",
        Before: "before",
        After: "after",
        }

def logaction(host, type, object, before, after, message):
    """Log an action.

    Arguments (all except host are strings):

    host    -- an rtlib.Host object (used to get the action's context)
    type    -- the action type, e.g. 'chmod' or 'write'
    object  -- the object of the action (e.g. a filename or username)
    before  -- the object's data before the action (relevant for the action)
    after   -- the object's data after the action
    message -- additional message to be displayed to the user

    The before and after arguments may also be None; this means that
    the object was created or deleted, respectively.  For operations
    that delete an object, the after argument may be reintepreted as
    metadata; this is up to the operation.  Similar for the before
    argument for creation operations.

    This is not supposed to be fast, but robust!

    """
    logger.info("%s - %s - %s", type, object, message)
    now = time.time()
    # Set filename to $AVDATA/log/action.YYYY-MM-DD.xml
    ymd = time.localtime(now)[:3]
    basename = options.action_prefix + ".%04d-%02d-%02d.xml" % ymd
    filename = os.path.join(options.agentlogs, basename)
    if before is None:
        before = Before(missing=True)
    else:
        before = Before(text=quote(before))
    if after is None:
        after = After(missing=True)
    else:
        after = After(text=quote(after))
    context = None
    if isinstance(host.context, xmlproto.Directive):
        context = Context(directive=host.context)
    element = Action(
        type=type,
        timestamp=datetime.datetime.fromtimestamp(now, datetime.timezone.utc).replace(tzinfo=None),
        object=object,
        message=message,
        before=before,
        after=after,
        context=context,
        )
    data = str(element)
    f = open(filename, "a")
    try:
        print(data, file=f)
    finally:
        f.close()

def readactions(arg, filename=None):
    """Generator yielding successive actions read from an action log file.

    The argument may either be a file name or a stream.

    """
    if isinstance(arg, str):
        f = open(arg, "U")
    else:
        f = arg
    if filename is None:
        filename = getattr(f, "filename", "<file>")
    parser = xmlparse.GenericParser([Action])
    buffer = []
    for line in f:
        buffer.append(line)
        if line.startswith("</action>"):
            text = "".join(buffer)
            buffer = []
            try:
                action = parser.parseText(text)
            except xmlparse.ParseError as err:
                logger.error("%s: %s", filename, err)
            else:
                yield action
    if f is not arg:
        f.close()
    

# Data for quote()
printable = [chr(i) for i in range(32, 127)]
printable.remove("%")
printable.append("\n")
##printable.append("\r")
quotemap = dict(zip(printable, printable))
for i in range(256):
    c = chr(i)
    if c not in quotemap:
        quotemap[c] = c1 = "%%%02x" % i # i.e., %xx where xx ~~ hex(i)

def quote(s):
    "Helper to %-encode a string."
    map = quotemap
    buffer = StringIO()
    put = buffer.write
    for c in s:
        put(map[c])
    return buffer.getvalue()

# Data for unquote()
invquotemap = {}
for i in range(256):
    c = chr(i)
    x = "%02x" % i
    x0 = x[0]
    x1 = x[1]
    for y0 in x0, x0.upper():
        for y1 in x1, x1.upper():
            invquotemap["%" + y0 + y1] = c

finder = re.compile(r"(%[\da-fA-F][\da-fA-F]|[^%]+|%+)")

def unquote(s):
    "Helper to %-decode a string."
    mapget = invquotemap.get
    buffer = StringIO()
    put = buffer.write
    for part in finder.findall(s):
        put(mapget(part, part))
    return buffer.getvalue()

def test():
    "Simple self-test.  Produces no output and no errors if all is well."
    for s in "".join([chr(i) for i in range(256)]), open("actions.py").read():
        sq = quote(s)
        squ = unquote(sq)
        assert squ == s

if __name__ == "__main__":
    test()
