%PDF-
%PDF-
Mini Shell
Mini Shell
"""
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 3 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, see <https://www.gnu.org/licenses/>.
Copyright © 2019 Cloud Linux Software Inc.
This software is also available under ImunifyAV commercial license,
see <https://www.imunify360.com/legal/eula>
"""
import asyncio
import inspect
import logging
import subprocess
from abc import ABC, ABCMeta, abstractmethod
from contextlib import suppress
from functools import lru_cache, wraps
from defence360agent.contracts.messages import Message, MessageType
from defence360agent.utils import Scope
logger = logging.getLogger(__name__)
class BasePlugin(object):
SCOPE = Scope.AV_IM360
_subclasses = []
def __init_subclass__(cls, **kwargs):
super().__init_subclass__(**kwargs)
cls._subclasses.append(cls)
@classmethod
def get_active_plugins(cls):
# consider all non-abstract subclasses are active
return [
plugin
for plugin in cls._subclasses
if not inspect.isabstract(plugin)
]
async def shutdown(self):
"""Shutdown plugin's subsystems, cancel running tasks,
clean iptables (if plugin is protector).
It should be safe to assume that it is called after
corresponding create_source if applicable.
It is called only from the shutdown task that runs at most once,
meaning shutdown() is never called twice.
"""
pass
def __repr__(self):
return "%s.%s" % (self.__class__.__module__, self.__class__.__name__)
class MessageSource(BasePlugin, ABC):
@abstractmethod
def create_source(self, loop, sink):
"""This method is a coroutine."""
class Sensor(MessageSource, ABC):
"""
Sensor is alias to MessageSource.
"""
def create_source(self, loop, sink):
"""This method is a coroutine."""
return self.create_sensor(loop, sink)
@abstractmethod
def create_sensor(self, loop, sink):
"""This method is a coroutine."""
class LogStreamReader(Sensor, metaclass=ABCMeta):
source_file = None
# Limit of bytes consumed from stream
# while trying to read one line (128 kB)
_LIMIT = 2 ** 17
_cmd = None
async def create_sensor(self, loop, sink):
self._loop = loop
self._sink = sink
self._cmd = None
if not self.source_file:
return
self._cmd = ('/usr/bin/tail',
# follow beyond the end of the file
'--follow=name',
'-n0',
# keep trying to open a file if it is inaccessible
'--retry',
self.source_file)
self._child_process = await asyncio.create_subprocess_exec(
*self._cmd,
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
bufsize=0,
limit=self._LIMIT
)
loop.create_task(self._infinite_read_and_proceed(
self._child_process.stdout))
async def shutdown(self):
if self._cmd is not None:
cmd, self._cmd = self._cmd, None
logger.debug("Terminating child process [%s]", cmd)
# child process dies from the same signal when agent
# is run from console (not as --daemon)
with suppress(ProcessLookupError):
self._child_process.kill()
rc = await self._child_process.wait()
logger.debug("Terminated child process [%s] with code [%d]",
cmd,
rc)
@abstractmethod
async def _infinite_read_and_proceed(self, stream_reader):
raise NotImplementedError
class BaseMessageProcessor:
@lru_cache(maxsize=1)
def _message_processors(self):
rv = []
for attr_str in dir(self):
if attr_str.startswith("_"):
continue # skip non-public attributes
func = getattr(self, attr_str)
if callable(func) and hasattr(func,
'_decorated_for_process_message'):
rv.append(func)
return rv
async def process_message(self, message):
logger.debug("Dispatching %r through %r...", message, self)
for coro in self._message_processors():
result = await coro(message)
if isinstance(result, Message):
return result
class MessageSink(BasePlugin, BaseMessageProcessor, ABC):
class ProcessingOrder:
# e.g. check is valid ipv4
PRE_PROCESS_MESSAGE = 10
# lfd plugin should process lfd alerts before other ignore plugins
LFD = 18
# e.g. for ignore_alert_with_whitelisted_ip
IGNORE_MESSAGE = 20
# Should be before check ip in graylist
UNBLOCK_FROM_SUBNET = 30
# Check ip in the graylist already
CHECK_IP_IN_GRAYLIST = 40
# Append ttl to alert
GRAYLIST_TIMEOUT = 50
# Store graylist to db, fixup ServerPush:method=BLOCK graylist
# expiration if it is lower then doslist expiration
GRAYLIST_DB_FIXUP = 55
# this should run before IPSET_PROTECTOR
IMPORT_EXPORT_WBLIST = 60
# make ml prediction before lazy_init
ML_PREDICTION = 70
# the default
DEFAULT = 80
IPSET_PROTECTOR = DEFAULT
WEBSHIELD_PROTECTOR = 81
# should be run after ManageGrayList(DEFAULT)
WHITELIST_UNBLOCKED = 90
# Synclist timestamp update
SYNCLIST_UPDATE = 100
# post action
POST_ACTION = 120
# event hook processing
EVENT_HOOK = 150
# iContact
ICONTACT_SENT = 200
# e.g. Accumulate
POST_PROCESS_MESSAGE = 999
# alias for DEFAULT
PROCESSING_ORDER = ProcessingOrder.DEFAULT
@abstractmethod
async def create_sink(self, loop):
pass
def expect(*message_type, async_lock=None, **expect_fields):
"""
@expect decorator for MessageSink.dosmth(message) async methods.
MessageSink method will be called by MessageSink.process_message()
if message_type and expect_fields match the message ones.
@expect's can be stacked together and decision whether to call decorated
coro is made by evaluating stacked @expect's with logical OR:
@expect(SensorAlert) # -- OR --
@expect(ServerPush, method='BLOCK')
def protect(message): ...
"""
def decorate(coro):
if getattr(coro, '__name__', '').startswith('_'):
raise TypeError('{coro} is not public'.format(coro=coro))
@wraps(coro)
async def decorated(self, message):
def match():
return (isinstance(message, message_type) and
all(message.get(k) == v
for k, v in expect_fields.items()))
def is_stacked(coro):
return hasattr(coro, '_decorated_for_process_message')
def terminal(coro):
if is_stacked(coro):
return terminal(coro._decorated_for_process_message)
return coro
# process stacked decorators with logical OR
if match():
if async_lock is True:
await message.acquire()
try:
result = await terminal(coro)(self, message)
except Exception as exc:
if isinstance(
message, MessageType.Lockable
) and message.locked():
message.release()
raise exc
else:
if (
async_lock is False
and isinstance(message, MessageType.Lockable)
and message.locked()
):
message.release()
return result
if is_stacked(coro):
# Give next decorator a chance: logical OR
return await coro(self, message)
return None
decorated._decorated_for_process_message = coro
return decorated
return decorate
_plugin_registry = set()
def thisguy(plugincls):
"""Register class as a plugin.
>>> @thisguy
>>> class ConcreteSink (MessageSink):
>>> ...
"""
_plugin_registry.add(plugincls)
return plugincls
def theseguys():
"""Enumerate classobj for registered plugins."""
return _plugin_registry
Zerion Mini Shell 1.0