????

Your IP : 18.119.121.4


Current Path : /opt/imunify360/venv/lib/python3.11/site-packages/im360/subsys/panels/cpanel/
Upload File :
Current File : //opt/imunify360/venv/lib/python3.11/site-packages/im360/subsys/panels/cpanel/coraza_modsecurity.py

"""CoraZa ModSecurity interface for Imunify360"""

import json
import logging
import os
import shutil
import time
import zipfile
from itertools import chain
from pathlib import Path
from typing import Dict, List, Optional
from urllib.parse import urlparse

from defence360agent.subsys import svcctl
from defence360agent.subsys.panels.cpanel.panel import forbid_dns_only
from defence360agent.utils import (
    atomic_rewrite,
    async_lru_cache,
)
from im360.subsys.panels.base import (
    FilesVendor,
    FilesVendorList,
    ModSecurityInterface,
    OPENLITESPEED,
    MODSEC_NAME_TEMPLATE,
)
from im360.subsys.panels.cpanel.mod_security import (
    catch_exception,
)
from im360.subsys.shared_disabled_rules import (
    get_shared_disabled_modsec_rules_ids,
)
from im360.utils import is_apache2nginx_enabled

logger = logging.getLogger(__name__)

CORAZA_RULES_DIR = "/var/imunify360/modsec/coraza"


class CorazaModSecurity(ModSecurityInterface):
    """Coraza ModSecurity interface for Imunify360"""

    GLOBAL_DISABLED_RULES_CONFIG_FILENAME = "coraza.i360_disabled_rules.conf"
    DISABLED_RULES_CONFIG_DIR = "/etc/imunify360-wafd/modsecurity.d"
    WAFD_CORAZA_AUDIT_DIR = "/var/log/imunify360/modsec_audit"
    WAFD_CORAZA_AUDIT_LOG_FILE = "/var/log/imunify360/modsec_audit.log"

    @classmethod
    def get_audit_log_path(cls):
        return cls.WAFD_CORAZA_AUDIT_LOG_FILE

    @classmethod
    async def sync_global_disabled_rules(cls, rule_list):
        """
        :param list rule_list: rules to sync
        :return:
        """
        cls.write_global_disabled_rules(rule_list)
        await cls.reload_modsec()

    @classmethod
    def get_audit_logdir_path(cls):
        return cls.WAFD_CORAZA_AUDIT_DIR

    @classmethod
    def generate_disabled_rules_config(cls, rule_list):
        tpl = """SecRuleRemoveById {rules_list}"""
        content = ""
        rules_ids = {
            str(id_)
            for id_ in chain(
                rule_list,
                get_shared_disabled_modsec_rules_ids(),
            )
        }
        if rules_ids:
            content = tpl.format(rules_list=" ".join(sorted(rules_ids)))
        return content

    @classmethod
    def write_global_disabled_rules(cls, rule_list):
        """
        :param list rule_list: rules to sync
        :return:
        """
        os.makedirs(cls.DISABLED_RULES_CONFIG_DIR, exist_ok=True)
        atomic_rewrite(
            os.path.join(
                cls.DISABLED_RULES_CONFIG_DIR,
                cls.GLOBAL_DISABLED_RULES_CONFIG_FILENAME,
            ),
            cls.generate_disabled_rules_config(rule_list),
            backup=False,
        )

    @forbid_dns_only
    @catch_exception
    async def _install_settings(self):
        await CorazaFilesVendorList.apply()
        await self.reload_modsec()

    @classmethod
    async def installed_modsec(cls):
        # For Coraza scenario, installed modsec can be detected by presence of
        # enabled apache2nginx
        return is_apache2nginx_enabled()

    async def revert_settings(self):
        # Potentially revert to an empty state or remove Coraza rules if needed
        # Reverting means removing Coraza support:
        await CorazaFilesVendorList.revert()
        await self.reload_modsec()

    @classmethod
    async def modsec_vendor_list(cls) -> List[str]:
        """Return a list of installed ModSecurity vendors."""
        vendor_list = []
        vendor = await cls.get_modsec_vendor_from_release_file()
        if vendor:
            vendor_list.append(vendor)
        return vendor_list

    @classmethod
    @async_lru_cache(maxsize=1)
    async def _get_release_info_from_file(cls) -> Optional[dict]:
        modsec_release_file = await cls.build_vendor_file_path(
            vendor="Not used", filename="RELEASE"
        )
        try:
            with modsec_release_file.open() as release_f:
                json_data = json.load(release_f)
            return json_data
        except (OSError, json.JSONDecodeError):
            return None

    @classmethod
    async def enabled_modsec_vendor_list(cls) -> List[str]:
        """
        Return a list of enabled modsec vendors
        Checked by existence of CORAZA_RULES_DIR
        """
        return await cls.modsec_vendor_list()

    REBUILD_HTTPDCONF_CMD = None

    @classmethod
    def _get_conf_dir(cls):
        return cls.DISABLED_RULES_CONFIG_DIR

    @classmethod
    async def modsec_get_directive(cls, directive_name, default=None):
        """
        N/A for Coraza
        """
        raise NotImplementedError

    async def reset_modsec_directives(self):
        """
        Used for `imunify360-agent fix modsec directives` to
        reset ModSecurity settings to values chosen by Imunify360

        N/A for Coraza
        """
        raise NotImplementedError

    async def reset_modsec_rulesets(self):
        # Unused.
        raise NotImplementedError

    @classmethod
    async def reload_modsec(cls):
        """Reload the wafd service to apply updated rules"""
        unitctl = svcctl.imunify360_wafd_service()
        await unitctl.reload()

    @classmethod
    def detect_cwaf(cls):
        # Unused.
        return False

    @classmethod
    async def build_vendor_file_path(cls, vendor: str, filename: str) -> Path:
        return Path(CORAZA_RULES_DIR) / filename

    @classmethod
    async def _apply_modsec_files_update(cls):
        if await CorazaFilesVendorList.install_or_update():
            await cls.reload_modsec()

    @classmethod
    async def sync_disabled_rules_for_domains(
        cls, domain_rules_map: Dict[str, list]
    ):
        # Unused.
        pass


class CorazaFilesVendor(FilesVendor):
    modsec_interface = CorazaModSecurity

    async def _add_vendor(self, url, name, *args, **kwargs):
        """Only one vendor is supported"""
        await CorazaFilesVendorList.install_or_update()

    async def _remove_vendor(self, vendor, *args, **kwargs):
        """
        Clear vendor data
        """
        atomically_swap_folders()  # swap existing folder with empty one

    async def apply(self):
        """
        Atomically swap vendor folders. We can swap them atomically
        only by swapping symlinks to them
        """
        atomically_swap_folders(self._item["local_path"])

    def _vendor_id(self):
        basename = os.path.basename(urlparse(self._item["url"]).path)
        basename_no_zip, _ = os.path.splitext(basename)
        return basename_no_zip


class CorazaFilesVendorList(FilesVendorList):
    files_vendor = CorazaFilesVendor
    modsec_interface = CorazaModSecurity

    @classmethod
    async def _get_compatible_name(cls, installed_vendors):
        web_server = OPENLITESPEED

        return MODSEC_NAME_TEMPLATE.format(
            ruleset_suffix=cls.get_ruleset_suffix(),
            webserver=web_server,
            panel="plesk",
        )

    @classmethod
    def vendor_fit_panel(cls, item):
        # use plesk item from description.json cause plesk,
        # da and generic panel share the same zip
        return item["name"].endswith("plesk")

    @classmethod
    async def revert(cls, *_):
        if os.path.islink(CORAZA_RULES_DIR):
            dst_name = os.readlink(CORAZA_RULES_DIR)
            dst = os.path.join(os.path.dirname(CORAZA_RULES_DIR), dst_name)
            shutil.rmtree(dst, ignore_errors=True)
            os.remove(CORAZA_RULES_DIR)
        else:
            shutil.rmtree(CORAZA_RULES_DIR, ignore_errors=True)


def atomically_swap_folders(zip_path=None):
    """
    Tries to swap folders atomically. The idea is to atomically swap symlynks
    to the folders while folders themselves can be removed afterwards.
    If path to zip file is passed, current folder is replaced with contents of
    the archive. Otherwise it's replaced with empty folder
    """
    # FIXME: Wrap this sync code into async wrappers
    curr_time = time.strftime("%Y-%m-%dT%H%M%S")
    dest_dirpath = f"{CORAZA_RULES_DIR}_{curr_time}"
    dest_symlink = f"{CORAZA_RULES_DIR}_{curr_time}_sym"
    os.makedirs(dest_dirpath, mode=0o700)

    if zip_path:
        with zipfile.ZipFile(zip_path) as zf:
            for member in zf.namelist():
                filename = os.path.basename(member)
                if not filename:
                    continue
                target = os.path.join(dest_dirpath, filename)
                with zf.open(member) as src, open(target, "wb") as dst:
                    shutil.copyfileobj(src, dst)
    os.symlink(os.path.basename(dest_dirpath), dest_symlink)

    if os.path.islink(CORAZA_RULES_DIR):
        dst_name = os.readlink(CORAZA_RULES_DIR)
        dst = os.path.join(os.path.dirname(CORAZA_RULES_DIR), dst_name)
        os.rename(dest_symlink, CORAZA_RULES_DIR)
        shutil.rmtree(dst, ignore_errors=True)
    else:
        shutil.rmtree(CORAZA_RULES_DIR, ignore_errors=True)
        os.rename(dest_symlink, CORAZA_RULES_DIR)