hardened_php.py
| 26.4 KB | Satir:
0
| py
Geri
import asyncio import base64 import configparser import json import logging import os import re import shlex import subprocess import distro from abc import abstractmethod from typing import List, Optional from defence360agent.contracts.config import Core, Packaging from defence360agent.contracts.license import LicenseCLN from defence360agent.subsys.features.abstract_feature import ( AbstractFeature, FeatureError, FeatureStatus, ea4_only, ) from defence360agent.subsys.panels.cpanel import cPanel from defence360agent.utils import ( OsReleaseInfo, check_run, check_run_outside_sandbox, run, run_cmd_and_log_outside_sandbox, _wrap_outside_sandbox_shell, os_version, ) logger = logging.getLogger(__name__) _ELS_SETUP_TMPL = ( "export PATH=/opt/imunify360/venv/bin:$PATH;" " _els_tmp=$(mktemp)" ' && curl -sf -o "$_els_tmp" {url}' ' && sh "$_els_tmp" -i;' " _els_rc=$?;" ' rm -f "$_els_tmp";' " exit $_els_rc" ) _ELS_RPM_SETUP = _ELS_SETUP_TMPL.format( url=( "https://repo.alt.tuxcare.com/alt-php-els/" "install-els-alt-php-rpm-repo.sh" ) ) _ELS_DEB_SETUP = _ELS_SETUP_TMPL.format( url=( "https://repo.alt.tuxcare.com/alt-php-els/" "install-els-alt-php-deb-repo.sh" ) ) class SimpleInstallerMixIn: """This is a mixin class implementing common case installation scenario. Installation is supposed to be through a single command cls.INSTALL_CMD. Removal is done through interpolating a space separated list of package names to remove into cls.REMOVE_CMD_TMPL. List of packages to remove is obtained by collecting all installed alt-php* packages except those we want to keep (as returned by required_packages()). """ INSTALL_CMD = "/bin/false" REMOVE_CMD_TMPL = "/bin/false" @abstractmethod def generate_repo(self, enabled: Optional[bool] = None): return @abstractmethod async def pre_install_cmd(self, enabled: bool): return @abstractmethod def remove_repo(self): return @staticmethod @abstractmethod async def _list_alt_php_packages() -> set: """Set of installed package names matching alt-php*""" return set() @classmethod def _keep_installed(cls, pkg): # this packages should not be managed by this class, as required by # Imunify360 to work. Should be updated every time major php version # used by ai-bolit is updated return ( pkg.startswith("alt-php-internal") or pkg == "alt-php-config" or pkg == "alt-php-hyperscan" ) @classmethod async def _feature_packages(cls) -> set: """Set of installed alt-php packages except those we keep installed""" all_alt_php = await cls._list_alt_php_packages() return set(pkg for pkg in all_alt_php if not cls._keep_installed(pkg)) @AbstractFeature.raise_if_shouldnt_install_now async def install(self): # generate_repo can block on subprocess (rpm -q, dpkg-query, # dpkg --remove, curl|sh) for several seconds; run it off the # event loop thread. await asyncio.to_thread(self.generate_repo, enabled=True) await self.pre_install_cmd(enabled=True) # DEF-41613: yum groupinstall runs RPM %prein/%post scriptlets # whose LSM transitions on exec are refused under NNP. return await run_cmd_and_log_outside_sandbox( self.INSTALL_CMD, self.INSTALL_LOG_FILE_MASK ) @AbstractFeature.raise_if_shouldnt_remove_now async def remove(self): await asyncio.to_thread(self.remove_repo) cmd = self.REMOVE_CMD_TMPL.format( " ".join(map(shlex.quote, await self._feature_packages())) ) await self.pre_install_cmd(enabled=False) return await run_cmd_and_log_outside_sandbox( cmd, self.REMOVE_LOG_FILE_MASK ) async def _check_installed_impl(self) -> bool: return bool(await self._feature_packages()) class HardenedPHPCentos(SimpleInstallerMixIn, AbstractFeature): LEGACY_REPO_FILE = "/etc/yum.repos.d/imunify360-alt-php.repo" ELS_REPO_FILE = "/etc/yum.repos.d/php-els.repo" TOKEN_FILE = "/etc/yum/vars/phpelstoken" NAME = "Hardened-PHP" LOG_DIR = "/var/log/%s" % Core.PRODUCT INSTALL_LOG_FILE_MASK = "%s/install-hardenedphp.log.*" % LOG_DIR REMOVE_LOG_FILE_MASK = "%s/remove-hardenedphp.log.*" % LOG_DIR INSTALL_CMD = "yum group mark remove alt-php; yum -y groupinstall alt-php" REMOVE_CMD_TMPL = "yum group mark install alt-php; yum -y remove {}" ENABLE_CRB_CMD = "dnf config-manager --enable crb" DISABLE_CRB_CMD = "dnf config-manager --disable crb" _CMD_LIST = [INSTALL_CMD, REMOVE_CMD_TMPL.format("")] def _remove_legacy_repo(self): try: os.remove(self.LEGACY_REPO_FILE) except FileNotFoundError: pass except OSError: logger.error("Can't delete %s", self.LEGACY_REPO_FILE) @classmethod def _els_php_release_installed(cls): if ( subprocess.call( ["rpm", "-q", "els-php-release"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) != 0 ): return False return os.path.exists(cls.ELS_REPO_FILE) def _install_els_repo(self): cmd = _wrap_outside_sandbox_shell(_ELS_RPM_SETUP) result = subprocess.run(cmd, shell=True, stderr=subprocess.PIPE) if result.returncode != 0: logger.error( "ELS RPM repo setup failed (rc=%d): %s", result.returncode, result.stderr.decode(errors="replace").strip(), ) def _write_token(self): if not self._els_php_release_installed(): return server_id = LicenseCLN.get_server_id() if not server_id: return try: os.makedirs(os.path.dirname(self.TOKEN_FILE), exist_ok=True) fd = os.open( self.TOKEN_FILE, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o640, ) with os.fdopen(fd, "w") as f: f.write(server_id) os.chmod(self.TOKEN_FILE, 0o640) except OSError: logger.error("Can't write %s", self.TOKEN_FILE) def generate_repo(self, enabled: Optional[bool] = None): if os.path.exists(self.LEGACY_REPO_FILE): self._remove_legacy_repo() if not self._els_php_release_installed(): self._install_els_repo() self._write_token() async def pre_install_cmd(self, enabled: bool): if not os_version().startswith("9"): return elif enabled: await check_run(self.ENABLE_CRB_CMD.split()) else: await check_run(self.DISABLE_CRB_CMD.split()) def remove_repo(self): self._remove_legacy_repo() @staticmethod async def _list_alt_php_packages() -> set: raw_output = await check_run( ["rpm", "-qa", "--queryformat", "%{NAME}\n", "alt-php*"] ) return set(raw_output.decode().split()) class HardenedPHPUbuntu(SimpleInstallerMixIn, AbstractFeature): ELS_REPO_FILE = "/etc/apt/sources.list.d/php-els.list" AUTH_CONF_FILE = "/etc/apt/auth.conf.d/php-els.conf" NAME = "Hardened-PHP" LOG_DIR = "/var/log/%s" % Core.PRODUCT INSTALL_LOG_FILE_MASK = "%s/install-hardenedphp.log.*" % LOG_DIR REMOVE_LOG_FILE_MASK = "%s/remove-hardenedphp.log.*" % LOG_DIR INSTALL_CMD = "apt-get install -y alt-php" REMOVE_CMD_TMPL = "apt-get purge -y {}" _CMD_LIST = [INSTALL_CMD, REMOVE_CMD_TMPL.format("")] @classmethod def _els_php_release_installed(cls): result = subprocess.run( [ "dpkg-query", "-W", "-f=${db:Status-Status}", "els-php-release", ], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, ) if not ( result.returncode == 0 and result.stdout.strip() == b"installed" ): return False return os.path.exists(cls.ELS_REPO_FILE) def _install_els_repo(self): self._clear_reinstreq_if_stuck() cmd = _wrap_outside_sandbox_shell(_ELS_DEB_SETUP) result = subprocess.run(cmd, shell=True, stderr=subprocess.PIPE) if result.returncode != 0: logger.error( "ELS DEB repo setup failed (rc=%d): %s", result.returncode, result.stderr.decode(errors="replace").strip(), ) def _write_token(self): if not self._els_php_release_installed(): return server_id = LicenseCLN.get_server_id() if not server_id: return try: os.makedirs(os.path.dirname(self.AUTH_CONF_FILE), exist_ok=True) fd = os.open( self.AUTH_CONF_FILE, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o640, ) with os.fdopen(fd, "w") as f: f.write( "machine repo.alt.tuxcare.com/alt-php-els/\n" "login %s\npassword\n" % server_id ) os.chmod(self.AUTH_CONF_FILE, 0o640) except OSError: logger.error("Can't write %s", self.AUTH_CONF_FILE) def generate_repo(self, enabled: Optional[bool] = None): if not self._els_php_release_installed(): self._install_els_repo() self._write_token() async def pre_install_cmd(self, enabled: bool): return @staticmethod def _els_php_release_reinstreq() -> bool: result = subprocess.run( [ "dpkg-query", "-W", "-f=${db:Status-Eflag}", "els-php-release", ], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, ) return result.returncode == 0 and result.stdout.strip() == b"reinstreq" @classmethod def _clear_reinstreq_if_stuck(cls) -> bool: if not cls._els_php_release_reinstreq(): return False logger.error( "els-php-release is wedged at reinstreq from a prior" " interrupted install; force-removing to unblock apt" ) subprocess.run( [ "dpkg", "--remove", "--force-remove-reinstreq", "els-php-release", ], check=False, ) return True def remove_repo(self): self._clear_reinstreq_if_stuck() @staticmethod async def _list_alt_php_packages() -> set: pkgs_in_dpkg_db = ( ( await check_run( [ "dpkg-query", "-W", "-f", "${Package} ${db:Status-Status}\n", "alt-php*", ] ) ) .decode() .strip() .split("\n") ) return set( pkg for line in pkgs_in_dpkg_db for pkg, status in [line.split()] if status == "installed" ) class HardenedPHPCloudLinux(AbstractFeature): MSG = "HardenedPHP is managed by lvemanager in CloudLinuxOS" INSTALL_LOG_FILE_MASK = "empty" REMOVE_LOG_FILE_MASK = "empty" async def init(self): return self async def status(self): rc, _, _ = await run(["rpm", "-q", "lvemanager"]) return { "items": { "status": FeatureStatus.MANAGED_BY_LVE, "lve_installed": rc == 0, "message": self.MSG, } } async def install(self): raise FeatureError(self.MSG) async def remove(self): raise FeatureError(self.MSG) async def _check_installed_impl(self) -> bool: # does not matter return True class HardenedPHPCloudLinuxSolo(HardenedPHPCloudLinux): MSG = "HardenedPHP is not supported in CloudLinuxOS Solo" async def status(self): return { "items": { "status": FeatureStatus.NOT_SUPPORTED_BY_CL_SOLO, "message": self.MSG, } } class EaPHPCentos(HardenedPHPCentos): REPO_FILE = "/etc/yum.repos.d/imunify360-ea-php-hardened.repo" LOG_DIR = "/var/log/%s" % Core.PRODUCT INSTALL_LOG_FILE_MASK = "%s/install-ea_php.log.*" % LOG_DIR REMOVE_LOG_FILE_MASK = "%s/remove-ea_php.log.*" % LOG_DIR INSTALL_CMD = "yum -y groupremove ea-php; yum -y groupinstall ea-php" REMOVE_SCRIPT = "/opt/imunify360/venv/share/imunify360/scripts/remove_hardened_php.py" # noqa: E501 REPO_NAME = "imunify360-ea-php-hardened" _CMD_LIST = [INSTALL_CMD, REMOVE_SCRIPT] @classmethod def _repo_tmpl_filepath(cls): return os.path.join(Packaging.DATADIR, os.path.basename(cls.REPO_FILE)) @classmethod def _prepare_token(cls, token): try: sep = ":" fields = "".join( str(token[k]) + sep for k in LicenseCLN.VERIFY_FIELDS_V1 ) except KeyError as e: raise FeatureError( f"License token can not be created by error {e}" ) sign_bytes = base64.b64decode(token["sign"]) data = fields.encode() + sign_bytes return base64.urlsafe_b64encode(data).decode() @classmethod def _prepare_repo_conf(cls, token, enabled: bool): enabled_flag = "1" if enabled else "0" try: token = cls._prepare_token(token) except FeatureError as e: if not enabled: token_placeholder = "unregister-token-placeholder" token = base64.urlsafe_b64encode( token_placeholder.encode() ).decode() else: raise e with open(cls._repo_tmpl_filepath(), "r") as repo_template: template = repo_template.read() return template.format(token=token, enabled=enabled_flag) def generate_repo(self, enabled: Optional[bool] = None): if enabled is None: # called on CLN license update repo = configparser.ConfigParser() try: repo.read(self.REPO_FILE) enabled = repo[self.REPO_NAME]["enabled"] == "1" except Exception: enabled = True token = LicenseCLN.get_token() server_id = LicenseCLN.get_server_id() if not server_id: if enabled: raise FeatureError( "tried to enable repo but server_id is empty (not" " registered?)" ) logger.warning( "server_id is empty (not registered?) ignoring due to removal" " of repo" ) with open(self.REPO_FILE, "w") as repo_file: repo_file.write(self._prepare_repo_conf(token, enabled)) os.chmod(self.REPO_FILE, os.stat(self._repo_tmpl_filepath()).st_mode) def remove_repo(self): self.generate_repo(enabled=False) @staticmethod async def _query_eaphp_versions() -> List[dict]: raw_output = await check_run( 'rpm -qa --queryformat "%{NAME} %{RELEASE}\n" "ea-php*"', shell=True, ) words = raw_output.decode().split() return [ {"name": words[i], "release": words[i + 1]} for i in range(0, len(words), 2) ] async def _check_installed_impl(self) -> bool: versioned_re = re.compile(r"ea-php\d+") for pkg in await self._query_eaphp_versions(): if ( versioned_re.search(pkg["name"]) is not None and "cloudlinux" in pkg["release"] ): return True return False @ea4_only async def status(self): return await super().status() @ea4_only @AbstractFeature.raise_if_shouldnt_install_now async def install(self): self.generate_repo(enabled=True) return await run_cmd_and_log_outside_sandbox( self.INSTALL_CMD, self.INSTALL_LOG_FILE_MASK ) @ea4_only @AbstractFeature.raise_if_shouldnt_remove_now async def remove(self): self.generate_repo(enabled=False) return await run_cmd_and_log_outside_sandbox( self.REMOVE_SCRIPT, self.REMOVE_LOG_FILE_MASK ) _CPANEL_HARDENED_PHP_MSG = ( "For EL9/Ubuntu20 cpanel servers use cPanel Profile to configure harden" " php.\nMore info:\n\t" " https://docs.cpanel.net/ea4/basics/the-ea-cpanel-tools-package-scripts/\n\t" # noqa: E501 " https://docs.cpanel.net/whm/software/easyapache-4-interface/" ) def _parse_whmapi1_output(raw: bytes, command: str) -> dict: # whmapi1 always exits 0; failures live in metadata.result/reason. output = json.loads(raw) if not output.get("metadata", {}).get("result"): reason = output.get("metadata", {}).get("reason", "unknown") raise FeatureError("whmapi1 {} failed: {}".format(command, reason)) # `data` may legitimately be missing or null when a command returns # no payload; normalise to dict so callers can probe keys safely. data = output.get("data") return data if isinstance(data, dict) else {} # Mirrors the mutual Conflicts: declared by the ea-apache24 MPM/cgi # packages themselves (one MPM per server; cgi pairs with prefork, cgid # with the threaded MPMs). _EA4_MUTUAL_CONFLICTS = { "ea-apache24-mod_mpm_prefork": frozenset( ( "ea-apache24-mod_mpm_worker", "ea-apache24-mod_mpm_event", "ea-apache24-mod_cgid", "ea-apache24-mod_http2", ) ), "ea-apache24-mod_mpm_worker": frozenset( ( "ea-apache24-mod_mpm_prefork", "ea-apache24-mod_mpm_event", "ea-apache24-mod_cgi", ) ), "ea-apache24-mod_mpm_event": frozenset( ( "ea-apache24-mod_mpm_prefork", "ea-apache24-mod_mpm_worker", "ea-apache24-mod_cgi", ) ), "ea-apache24-mod_cgi": frozenset( ( "ea-apache24-mod_cgid", "ea-apache24-mod_mpm_worker", "ea-apache24-mod_mpm_event", ) ), "ea-apache24-mod_cgid": frozenset( ( "ea-apache24-mod_cgi", "ea-apache24-mod_mpm_prefork", ) ), } class EaPHPCentosEL9(EaPHPCentos): MSG = _CPANEL_HARDENED_PHP_MSG IMUNIFY_PROFILE_FILENAME = "imunify_hardened_php.json" IMUNIFY_PROFILE_PATH = ( "/etc/cpanel/ea4/profiles/custom/" + IMUNIFY_PROFILE_FILENAME ) BASELINE_PROFILE_PATH = ( "/etc/cpanel/ea4/profiles/vendor/cloudlinux/allphp.json" ) # On failure ea_install_profile exits non-zero with the resolver # error only in cPanel's packman log, so pull it into the install # log the agent hands to the user. INSTALL_PROFILE_CMD = ( "/usr/local/bin/ea_install_profile --install " + IMUNIFY_PROFILE_PATH + "; _rc=$?;" ' if [ "$_rc" -ne 0 ]; then' ' echo "ea_install_profile failed with exit code $_rc";' ' tail -n 20 "$(ls -t /usr/local/cpanel/logs/packman/errors/*' ' 2>/dev/null | head -1)" 2>/dev/null;' " fi; exit $_rc" ) @ea4_only @AbstractFeature.raise_if_shouldnt_install_now async def install(self): # Union of installed EA4 + cloudlinux baseline = superset shape; # ea_install_profile --install never removes customer packages. self.generate_repo(enabled=True) # generate_repo only configures the ea-php hardened repo; the # allphp baseline also lists alt-php interpreters, which are # served by the separate els-alt-php repo. Without it those # entries silently resolve to "package does not exist" and only # ea-php ends up hardened. await asyncio.to_thread(self._setup_alt_php_repo) # yum RPM %post/%prein scriptlets need LSM domain transitions that # the agent's NoNewPrivileges sandbox blocks with EPERM on SELinux # hosts (EL9+, exactly this code path's target). await check_run_outside_sandbox( ["yum", "-y", "install", "ea-profiles-cloudlinux"] ) await self._save_imunify_profile() return await run_cmd_and_log_outside_sandbox( self.INSTALL_PROFILE_CMD, self.INSTALL_LOG_FILE_MASK ) def _setup_alt_php_repo(self): if os.path.exists(self.LEGACY_REPO_FILE): self._remove_legacy_repo() if not self._els_php_release_installed(): self._install_els_repo() self._write_token() @ea4_only @AbstractFeature.raise_if_shouldnt_remove_now async def remove(self): self.generate_repo(enabled=False) try: os.remove(self.IMUNIFY_PROFILE_PATH) except FileNotFoundError: pass return "Repo imunify360-ea-php-hardened removed.\n" + self.MSG async def _check_installed_impl(self) -> bool: # Two independent signals, both required: # - IMUNIFY_PROFILE_PATH: imunify's intent to manage this host # (created by install(), deleted by remove()). # - parent's rpm -qa ea-php* "cloudlinux" check: at least one # hardened ea-php interpreter is actually present. # Intent alone would lie if ea_install_profile failed in the # background; rpm check alone would lie after remove() (which # intentionally leaves packages in place so a customer's running # sites are not broken). if not os.path.exists(self.IMUNIFY_PROFILE_PATH): return False return await super()._check_installed_impl() async def _save_imunify_profile(self): installed = await self._get_installed_ea4_packages() # 30 KB JSON read off the event loop, matching the asyncio.to_thread # pattern SimpleInstallerMixIn.install() uses for generate_repo. baseline = await asyncio.to_thread(self._load_baseline_packages) pkgs = self._merge_profile_packages(installed, baseline) args = [ "/usr/sbin/whmapi1", "--output=json", "ea4_save_profile", "filename=" + self.IMUNIFY_PROFILE_FILENAME, "name=Imunify Hardened PHP", "desc=Imunify-managed Hardened PHP profile.", "overwrite=1", ] + ["pkg=" + p for p in pkgs] raw = await check_run(args) _parse_whmapi1_output(raw, "ea4_save_profile") @staticmethod def _merge_profile_packages( installed: List[str], baseline: List[str] ) -> List[str]: # The baseline assumes the threaded stack (mpm_worker + cgid); # the host's installed MPM/cgi choice must win, otherwise the # profile demands both alternatives and PackMan's resolver dies. installed_set = set(installed) blocked = set() for pkg in installed_set: blocked |= _EA4_MUTUAL_CONFLICTS.get(pkg, frozenset()) return sorted(installed_set | (set(baseline) - blocked)) @staticmethod async def _get_installed_ea4_packages() -> List[str]: raw = await check_run( [ "/usr/sbin/whmapi1", "--output=json", "ea4_get_currently_installed_packages", ] ) data = _parse_whmapi1_output( raw, "ea4_get_currently_installed_packages" ) if "packages" not in data: raise FeatureError( "whmapi1 ea4_get_currently_installed_packages returned " "no 'packages' field: " + repr(data) ) return data["packages"] @classmethod def _load_baseline_packages(cls) -> List[str]: # Missing/corrupt baseline must surface — the caller's flow runs # yum -y install ea-profiles-cloudlinux beforehand, so the file # must exist; silently degrading to [] would make install() a # no-op while still returning success. with open(cls.BASELINE_PROFILE_PATH) as f: return json.load(f).get("pkgs") or [] class EaPHPUbuntuCpanel(AbstractFeature): REPO_FILE = "/etc/apt/sources.list.d/cloudlinux-ea4.list" REPO_URL = ( "https://repo.cloudlinux.com/cloudlinux-ubuntu/cloudlinux-ea4/stable/" ) REPO_NAME = "cloudlinux-ea4" MSG = _CPANEL_HARDENED_PHP_MSG INSTALL_LOG_FILE_MASK = "empty" REMOVE_LOG_FILE_MASK = "empty" def _generate_repo(self): with open(self.REPO_FILE, "w") as f: f.write(f"deb {self.REPO_URL} ./\n") def _remove_repo(self): try: os.remove(self.REPO_FILE) except FileNotFoundError: pass except OSError: logger.error("Can't delete %s", self.REPO_FILE) @ea4_only @AbstractFeature.raise_if_shouldnt_install_now async def install(self): self._generate_repo() return f"Repo {self.REPO_NAME} activated.\n" + self.MSG @ea4_only @AbstractFeature.raise_if_shouldnt_remove_now async def remove(self): self._remove_repo() return f"Repo {self.REPO_NAME} removed.\n" + self.MSG @ea4_only async def status(self): return await super().status() async def _check_installed_impl(self) -> bool: return os.path.isfile(self.REPO_FILE) def _is_cloudlinux_release_installed() -> bool: return os.path.exists("/etc/cloudlinux-release") def get_hardened_php_feature() -> Optional[AbstractFeature]: """ :return: AbstractFeature subclass: feature that implements Hardened PHP installation for current environment. """ has_cpanel = cPanel.is_installed() if ( OsReleaseInfo.is_centos() or OsReleaseInfo.is_rhel() or OsReleaseInfo.is_oracle_linux() or OsReleaseInfo.is_almalinux() or OsReleaseInfo.is_rockylinux() ): if has_cpanel and int(distro.major_version()) >= 9: return EaPHPCentosEL9 elif has_cpanel: return EaPHPCentos else: return HardenedPHPCentos if OsReleaseInfo.is_cloudlinux(): if OsReleaseInfo.is_cloudlinux_solo(): return HardenedPHPCloudLinuxSolo # CL regular return HardenedPHPCloudLinux if OsReleaseInfo.is_ubuntu() or OsReleaseInfo.is_debian(): if has_cpanel: if _is_cloudlinux_release_installed(): return HardenedPHPCloudLinux return EaPHPUbuntuCpanel return HardenedPHPUbuntu return None
Kaydet
Ctrl+S ile kaydet