????
Current Path : /opt/imunify360/venv/lib/python3.11/site-packages/im360/subsys/panels/cpanel/ |
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)