ok
Direktori : /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/mod_security.py |
import logging import os from contextlib import suppress from functools import lru_cache from pathlib import Path from typing import Dict, List, Optional from urllib.parse import urlparse import yaml from defence360agent.contracts.config import ConfigFile from defence360agent.subsys.panels.base import ModsecVendorsError from defence360agent.subsys.panels.cpanel.panel import forbid_dns_only from defence360agent.subsys.panels.cpanel.whm import WHMAPIException, whmapi1 from defence360agent.utils import ( CheckRunError, async_lru_cache, atomic_rewrite, check_run, nice_iterator, ) from im360.contracts.config import ModSecurityDirectives from im360.subsys.panels.base import ( MODSEC_NAME_TEMPLATE, FilesVendor, FilesVendorList, ModSecSettingInterface, ModSecurityInterface, skip_if_not_installed_modsec, ) #: path to paths.conf file from the ea-apache24-config-runtime package EA4_PATHS_CONF_PATH = Path("/etc/cpanel/ea4/paths.conf") #: presence of the file indicates it is EasyApache 4 IS_EA4_PATH = Path("/etc/cpanel/ea4/is_ea4") #: path to modsec_vendor script MODSEC_VENDOR_BIN = "/usr/local/cpanel/scripts/modsec_vendor" NON_CONFLICTING_RULESETS = ( "comodo_apache", "comodo_litespeed", "imunify360_rules", "configserver", ) logger = logging.getLogger(__name__) def catch_exception(func): async def wrapper(*args, **kwargs): rv = None try: rv = await func(*args, **kwargs) except WHMAPIException as sww: # Do not mess the output with stacktrace, # more details can be found in sentry. logger.error(str(sww)) except: # noqa # do not left unreported logger.exception("Something went wrong") return rv return wrapper async def _modsec_vendor_cmd(cmd, param): """ :raise subprocess.CalledProcessError: """ await check_run([MODSEC_VENDOR_BIN, cmd, param], raise_exc=WHMAPIException) class ModSecSetting(ModSecSettingInterface): config_key = "prev_settings" directives_ids = { "SecAuditEngine": "0", "SecConnEngine": "1", "SecRuleEngine": "2", } OFF = "Off" @classmethod async def apply(cls): """ Reset ModSecurity settings to values chosen by Imunify360. :return str: previous settings """ # save previous settings previous_settings = await cPanelModSecurity.get_settings( *cls.directives_ids ) # set settings from the Imunify360 config module for directive_name, directive_id in cls.directives_ids.items(): await whmapi1( "modsec_set_setting", setting_id=cls.directives_ids[directive_name], state=getattr(ModSecurityDirectives, directive_name), ) await whmapi1("modsec_deploy_settings_changes") return ",".join( "{id}:{state}".format(id=cls.directives_ids[name], state=state) for name, state in sorted(previous_settings.items()) if state.strip() ) @classmethod async def revert(cls, prev_setting_value): """apply the setting previous value""" if prev_setting_value: for item in prev_setting_value.split(","): setting_id, state = item.split(":") if state.strip(): await whmapi1( "modsec_set_setting", setting_id=setting_id, state=state, ) await whmapi1("modsec_deploy_settings_changes") class cPanelModSecurity(ModSecurityInterface): CWAF_INSTALLATION_DIR = "/var/cpanel/cwaf" AUDIT_LOG_FILE = "/usr/local/apache/logs/modsec_audit.log" DISABLED_RULES_CONFIG_DIR = "/etc/apache2/conf.d/" # to load after modsec2.conf GLOBAL_DISABLED_RULES_CONFIG_FILENAME = "modsec2.i360_disabled_rules.conf" PER_DOMAIN_DISABLED_RULES_CONFIG_FILENAME = "i360_modsec_disable.conf" # it should be included before disabled rules config # to ensure that MyImunify rules can be disabled by a customer PER_DOMAIN_MYIMUNIFY_RULES_CONFIG_FILENAME = "i360_001_myimunify.conf" REBUILD_HTTPDCONF_CMD = ("/usr/local/cpanel/scripts/rebuildhttpdconf",) @classmethod def _get_conf_dir(cls) -> str: return cls.DISABLED_RULES_CONFIG_DIR @classmethod @lru_cache(maxsize=1) def _get_ea4_paths(cls) -> Optional[Dict[str, str]]: """Return a dict with ea4 paths or None for non-ea4 case.""" if IS_EA4_PATH.exists(): # easy apache 4 with EA4_PATHS_CONF_PATH.open() as f: paths_config = dict( map(str.strip, line.partition("=")[::2]) for line in f if "=" in line ) return paths_config @classmethod def _get_userdata_dir(cls): ea4_paths_config = cls._get_ea4_paths() if ea4_paths_config is not None: return ea4_paths_config.get( "dir_conf_userdata", "/etc/apache2/conf.d/userdata" ) else: # easy apache 3 return "/usr/local/apache/conf/userdata" @classmethod def _get_domain_confs_paths(cls, user, domain, *, conf_name): # use hardcode version according to # https://docs.cpanel.net/ea4/apache/modify-apache-virtual-hosts-with-include-files/ # now supported version apache >= 2.4 # EA3 is EOL confs = [] version_str = "2_4" for vhost_type in ("ssl", "std"): confs.append( Path(cls._get_userdata_dir()) / vhost_type / version_str / user / domain / conf_name ) return confs @classmethod def _delete_domain_conf(cls, user, domain, *, conf_name) -> bool: deleted = False for conf_path in cls._get_domain_confs_paths( user, domain, conf_name=conf_name ): with suppress(FileNotFoundError): conf_path.unlink(missing_ok=False) deleted = True return deleted @classmethod def _add_domain_conf(cls, user, domain, *, conf_name, conf_text) -> bool: updated = False for conf_path in cls._get_domain_confs_paths( user, domain, conf_name=conf_name ): logger.info("Adding domain configuration: %s", conf_path) conf_path.parent.mkdir(parents=True, exist_ok=True) updated |= atomic_rewrite(str(conf_path), conf_text, backup=False) return updated @classmethod async def sync_disabled_rules_for_domains( cls, domain_rules_map: Dict[str, list] ): logger.info("Sync disabled rules for [%s]", ",".join(domain_rules_map)) for domain, rule_list in domain_rules_map.items(): data = await whmapi1("domainuserdata", domain=domain) user = data["userdata"]["user"] cls._add_domain_conf( user, domain, conf_name=cls.PER_DOMAIN_DISABLED_RULES_CONFIG_FILENAME, conf_text=cls.generate_disabled_rules_config(rule_list), ) await check_run(cls.REBUILD_HTTPDCONF_CMD) @classmethod async def apply_myimunify_modsec_rules_for_domains( cls, *, enabled_users_domains: dict, disabled_users_domains: dict ) -> set: vendor = await cls.get_i360_vendor_name() myimunify_conf = await cls.build_vendor_file_path(vendor, "myimunify") conf_text = f"IncludeOptional {str(myimunify_conf)}\n" conf_name = cls.PER_DOMAIN_MYIMUNIFY_RULES_CONFIG_FILENAME updated_domains = set() def gen_user_domain(users_domains): for user, domains in users_domains.items(): for domain in domains: yield user, domain # add extended ruleset for enabled domains async for user, domain in nice_iterator( gen_user_domain(enabled_users_domains), chunk_size=1000 ): if cls._add_domain_conf( user, domain, conf_name=conf_name, conf_text=conf_text ): updated_domains.add(domain) # delete extended ruleset for disabled domains async for user, domain in nice_iterator( gen_user_domain(disabled_users_domains), chunk_size=1000 ): if cls._delete_domain_conf(user, domain, conf_name=conf_name): updated_domains.add(domain) if updated_domains: await check_run(cls.REBUILD_HTTPDCONF_CMD) return updated_domains @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, ) @classmethod async def sync_global_disabled_rules(cls, rule_list): """ :param list rule_list: rules to sync :raise OSError: if rebuildhttpdconf returned not zero exit code :return: """ cls.write_global_disabled_rules(rule_list) await check_run(cls.REBUILD_HTTPDCONF_CMD) @classmethod def _get_avalible_settings(cls): return [ModSecSetting, cPanelFilesVendorList] @classmethod def get_audit_log_path(cls): ea4_paths_config = cls._get_ea4_paths() if ea4_paths_config is not None: log_dir = ea4_paths_config.get("dir_logs", "/etc/apache2/logs") return os.path.join(log_dir, "modsec_audit.log") else: # easy apache 3 return cls.AUDIT_LOG_FILE @classmethod def get_audit_logdir_path(cls): return "/var/log/apache2/modsec_audit" @classmethod async def installed_modsec(cls): try: rc = (await whmapi1("modsec_is_installed"))["data"]["installed"] except (WHMAPIException, OSError): return False # can't get status, assume not installed else: return rc == 1 # rc==1 means modsec is installed @forbid_dns_only @catch_exception async def _install_settings(self): await self.reset_modsec_directives() await self.reset_modsec_rulesets() async def reset_modsec_directives(self): # implement abstractmethod ModSecurityInterface.reset_modsec_directives await self._reset_modsec_setting(ModSecSetting) async def reset_modsec_rulesets(self): # implement abstractmethod ModSecurityInterface.reset_modsec_rulesets await self._reset_modsec_setting(cPanelFilesVendorList) @forbid_dns_only async def _reset_modsec_setting(self, setting): config = ConfigFile() config.set("MOD_SEC", setting.config_key, await setting.apply()) @forbid_dns_only @skip_if_not_installed_modsec @catch_exception async def revert_settings(self): """Revert install_settings()""" config = ConfigFile() for setting in self._get_avalible_settings(): await setting.revert(config.get("MOD_SEC", setting.config_key)) config.set("MOD_SEC", setting.config_key, None) @classmethod def detect_cwaf(cls): """ Detects Comodo ModSecurity Rule Set :return: bool installed """ return os.path.exists(cls.CWAF_INSTALLATION_DIR) @classmethod @async_lru_cache(maxsize=1) async def installed_modsec_vendors_data(cls) -> List[Dict]: """Returns list of dicts that describes ModSecurity vendors.""" vendors_dict = (await whmapi1("modsec_get_vendors")).get("vendors", []) return vendors_dict @classmethod async def enabled_modsec_vendors_data(cls) -> List[Dict]: """Returns list of dicts that describes enabled ModSecurity vendors.""" vendors_dict = await cls.installed_modsec_vendors_data() return [vendor for vendor in vendors_dict if vendor["enabled"]] @classmethod async def invalidate_installed_vendors_cache(cls): cls.installed_modsec_vendors_data.cache_clear() # NOSONAR Pylint:E1101 @classmethod async def modsec_vendor_list(cls) -> List[str]: """Return a list of installed ModSecurity vendors.""" return [ v["vendor_id"] for v in await cls.installed_modsec_vendors_data() ] @classmethod async def enabled_modsec_vendor_list(cls) -> List[str]: return [ v["vendor_id"] for v in await cls.enabled_modsec_vendors_data() ] @classmethod async def modsec_get_directive(cls, directive_name, default=None): # implement abstractmethod ModSecurityInterface.modsec_get_directive try: return (await cls.get_settings(directive_name))[directive_name] except WHMAPIException: logger.exception("failed to get %s directive", directive_name) return default @classmethod async def get_settings(cls, *directive_names): """Return a mapping ModSecurity directive -> its state.""" settings = (await whmapi1("modsec_get_settings"))["settings"] try: return dict( (item["directive"], item["state"]) for item in settings if item["directive"] in directive_names ) except (KeyError, StopIteration): raise WHMAPIException("Could not parse whmapi1 output") @classmethod async def build_vendor_file_path(cls, vendor: str, filename: str) -> Path: vendors_data = await cls.installed_modsec_vendors_data() vendor_path = next( (v["path"] for v in vendors_data if v["vendor_id"] == vendor), None ) if vendor_path: return Path(vendor_path) / filename raise ModsecVendorsError( "Can't get vendor record for vendor {}." " Installed vendors: {}".format( vendor, [v["vendor_id"] for v in vendors_data] ) ) @classmethod def get_modsec_active_conf_files(cls) -> List[str]: return ModsecDatastore().get_as_list("active_configs") @classmethod def get_modsec_engine_mode(cls) -> str: return ModsecDatastore().get("settings").get("SecRuleEngine") @classmethod def get_modsec_vendor_updates(cls) -> List[str]: return ModsecDatastore().get_as_list("vendor_updates") @classmethod @skip_if_not_installed_modsec async def _apply_modsec_files_update(cls): await cls.invalidate_installed_vendors_cache() await cPanelFilesVendorList.apply() class ModsecDatastore: PATH = "/var/cpanel/modsec_cpanel_conf_datastore" def get(self, section): try: with open(self.PATH) as f: return yaml.safe_load(f).get(section) except (yaml.YAMLError, FileNotFoundError): logger.error("Modsec datastore is not found: %s", self.PATH) return {} def get_as_list(self, section): """Get values as a list from the following yaml structure: ``` section: value1: 1 value2: 1 value3: 0 ``` """ as_list = [] for value, enabled in self.get(section).items(): if enabled == 1: as_list.append(value) return as_list class cPanelFilesVendor(FilesVendor): modsec_interface = cPanelModSecurity async def apply(self): await self.modsec_interface.invalidate_installed_vendors_cache() await self._remove_obsoleted() await self._add_or_update_vendor() async def _remove_obsoleted(self): logger_ = logging.getLogger("%s.%s" % (__name__, "_remove_obsoleted")) installed_vendors = set( await self.modsec_interface.modsec_vendor_list() ) if installed_vendors: logger_.info( "Installed_vendors were detected: %r", installed_vendors ) else: logger_.info("No installed_vendors were detected.") return for to_be_removed in installed_vendors & set( self._item.get("obsoletes", []) ): logger_.info("Removing obsoleted vendor %r", to_be_removed) await self._remove_vendor(to_be_removed) installed_vendors.discard(to_be_removed) # Here we are removing vendors that are no more appropriate for # the current setup (obsoleted ones (DEF-4434)) or those are # not appropriate for active webserver (apache, litespeed)) for to_be_removed in installed_vendors: if ( to_be_removed.startswith("imunify360") and to_be_removed != self.vendor_id ): logger_.info( "Removing vendor %r which is inappropriate for this setup", to_be_removed, ) await self._remove_vendor(to_be_removed) async def _add_or_update_vendor(self): installed_vendors = await self.modsec_interface.modsec_vendor_list() if self.vendor_id in installed_vendors: enabled_vendors = ( await self.modsec_interface.enabled_modsec_vendor_list() ) if self.vendor_id in enabled_vendors: try: await check_run([MODSEC_VENDOR_BIN, "update", "--auto"]) except CheckRunError: logger.exception("%r failed with error", MODSEC_VENDOR_BIN) else: logger.info( "Successfully updated vendor %r.", self.vendor_id ) else: await self._add_vendor(url=self._item["url"], name=self.vendor_id) logger.info("Successfully installed vendor %r.", self.vendor_id) @classmethod async def _add_vendor(cls, url, name, **kwargs): await _modsec_vendor_cmd("add", url) @classmethod async def _remove_vendor(cls, vendor, **kwargs): return await _modsec_vendor_cmd("remove", vendor) def _vendor_id(self): basename = os.path.basename(urlparse(self._item["url"]).path) basename_no_yaml, _ = os.path.splitext(basename) if basename_no_yaml.startswith("meta_"): return basename_no_yaml[len("meta_") :] else: return None class cPanelFilesVendorList(FilesVendorList): files_vendor = cPanelFilesVendor modsec_interface = cPanelModSecurity _FULLY_COMPATIBLE_VENDORS = {"configserver"} @classmethod async def _get_compatible_name(cls, installed_vendors): web_server = await cls._get_web_server() if not web_server: raise cls.CompatiblityCheckFailed( "Web-server is not running, skipping " "imunify360 vendor installation", installed_vendors, ) return MODSEC_NAME_TEMPLATE.format( ruleset_suffix=cls.get_ruleset_suffix(), webserver=web_server, panel="cpanel", ) @classmethod def vendor_fit_panel(cls, item): return item["name"].endswith("cpanel")