ÿØÿàJFIFÿþ ÿÛC       ÿÛC ÿÀÿÄÿÄ"#QrÿÄÿÄ&1!A"2qQaáÿÚ ?Øy,æ/3JæÝ¹È߲؋5êXw²±ÉyˆR”¾I0ó2—PI¾IÌÚiMö¯–þrìN&"KgX:Šíµ•nTJnLK„…@!‰-ý ùúmë;ºgµŒ&ó±hw’¯Õ@”Ü— 9ñ-ë.²1<yà‚¹ïQÐU„ہ?.’¦èûbß±©Ö«Âw*VŒ) `$‰bØÔŸ’ëXÖ-ËTÜíGÚ3ð«g Ÿ§¯—Jx„–’U/ÂÅv_s(Hÿ@TñJÑãõçn­‚!ÈgfbÓc­:él[ðQe 9ÀPLbÃãCµm[5¿ç'ªjglå‡Ûí_§Úõl-;"PkÞÞÁQâ¼_Ñ^¢SŸx?"¸¦ùY騐ÒOÈ q’`~~ÚtËU¹CڒêV  I1Áß_ÿÙ""" 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 . Copyright © 2019 Cloud Linux Software Inc. This software is also available under ImunifyAV commercial license, see """ import asyncio import logging import pwd from pathlib import Path from typing import Coroutine from defence360agent.contracts.config import ANTIVIRUS_MODE, SystemConfig from defence360agent.contracts.hook_events import HookEvent from defence360agent.contracts.messages import MessageType from defence360agent.contracts.plugins import ( MessageSink, MessageSource, expect, ) from defence360agent.subsys.panels import hosting_panel from defence360agent.subsys.persistent_state import ( load_state, register_lock_file, save_state, ) from defence360agent.utils import Scope, recurring_check from defence360agent.utils.check_lock import check_lock from defence360agent.utils.common import DAY from imav.contracts.config import Wordpress from imav.malwarelib.model import MalwareHit from imav.model.wordpress import WPSite from imav.wordpress import plugin from imav.wordpress.proxy_auth import is_secret_expired, rotate_secret from imav.wordpress.site_repository import get_sites_by_path logger = logging.getLogger(__name__) LOCK_FILE = register_lock_file("wp-gen-auth", Scope.AV_IM360) CONFIG_DIR = Path("/etc/sysconfig/imunify360/imunify360.config.d") FIRST_INSTALL_CONFIG_FILE = Path( "/opt/imunify360/venv/share/imunify360/11_on_first_install_wp_av.config" ) FIRST_INSTALL_CONFIG_PATH = CONFIG_DIR / "11_on_first_install_wp_av.config" FIRST_INSTALL_FLAG = CONFIG_DIR / ".11_on_first_install_wp_av.flag" class ImunifySecurityPlugin(MessageSink, MessageSource): SCOPE = Scope.AV_IM360 def __init__(self): self._loop = None self._sink = None self.installation_completed = load_state("ImunifySecurityPlugin").get( "installed" ) self.last_config_value = ( load_state("ImunifySecurityPlugin").get("enabled") or Wordpress.SECURITY_PLUGIN_ENABLED ) self.installation_task: asyncio.Task | None = None self.deleting_task: asyncio.Task | None = None self.install_and_update_task: asyncio.Task | None = None self.freshly_installed_sites: set[WPSite] = set() async def create_sink(self, loop): pass async def create_source(self, loop, sink): self._loop = loop self._sink = sink self._update_auth_task = self._loop.create_task( self.refresh_auth_files() ) if ANTIVIRUS_MODE: await self._apply_first_install_config() else: FIRST_INSTALL_FLAG.unlink(missing_ok=True) async def _apply_first_install_config(self): if not FIRST_INSTALL_FLAG.exists(): return if await hosting_panel.HostingPanel().users_count() == 1: _ = FIRST_INSTALL_CONFIG_PATH.write_text( FIRST_INSTALL_CONFIG_FILE.read_text() ) FIRST_INSTALL_CONFIG_PATH.chmod(0o600) FIRST_INSTALL_FLAG.unlink() async def shutdown(self): self._update_auth_task.cancel() # CancelledError is handled by @recurring_check(): await self._update_auth_task def _task_in_progress(self, task_attr_name): if not hasattr(self, task_attr_name): logger.error("Unknown task '%s'", task_attr_name) return False task = getattr(self, task_attr_name) return task is not None and not task.done() and not task.cancelled() async def process_installation(self, coro: Coroutine, for_new_sites=False): if self._task_in_progress("deleting_task"): if for_new_sites: coro.close() return if self.deleting_task: self.deleting_task.cancel() await self.deleting_task if self._task_in_progress("installation_task"): logger.warning("Installation is already running") coro.close() return self.installation_task = asyncio.create_task(coro) async def process_deleting(self, coro): if self._task_in_progress("installation_task"): if self.installation_task: self.installation_task.cancel() await self.installation_task if self._task_in_progress("deleting_task"): logger.warning("Deleting is already running") return self.deleting_task = asyncio.create_task(coro) @recurring_check( check_lock, check_period_first=True, check_lock_period=DAY, lock_file=LOCK_FILE, ) async def refresh_auth_files(self): if is_secret_expired(): await rotate_secret() await plugin.update_auth_everywhere() async def _install_on_new_sites(self): """Install plugin on new WordPress sites.""" # Clear any previously tracked sites self.freshly_installed_sites.clear() async def install_and_track(): installed_sites = await plugin.install_everywhere(sink=self._sink) if installed_sites: self.freshly_installed_sites.update(installed_sites) return installed_sites await self.process_installation( install_and_track(), for_new_sites=True, ) async def _tidy_up(self): """Tidy up sites from which the WordPress plugin was deleted manually.""" await plugin.tidy_up_manually_deleted( sink=self._sink, freshly_installed_sites=self.freshly_installed_sites, ) await plugin.fix_data_file_permissions_everywhere(sink=self._sink) if not Wordpress.SECURITY_PLUGIN_ENABLED: # If the feature is disabled, remove the WordPress plugin from all sites. await self.process_deleting( plugin.remove_all_installed(sink=self._sink) ) self.installation_completed = False async def _update_existing(self): """Update plugin on all sites where it is installed.""" await plugin.update_everywhere(sink=self._sink) async def _run_install_and_update(self): """ Combined operation: install on new sites, tidy up, and update existing plugins. This runs all three operations sequentially to avoid race conditions. """ # Install plugin on new sites. await self._install_on_new_sites() # Wait for installation to complete before proceeding. if self.installation_task: await self.installation_task # Tidy up and update. await self._tidy_up() await self._update_existing() @expect(MessageType.WordpressPluginAction) async def manage_plugin_action(self, message): logger.info( "ImunifySecurityPlugin received message action: %s method: %s", message.action, message.method, ) # Check if install_and_update is running - it blocks all other actions if self._task_in_progress("install_and_update_task"): logger.warning( "Install-and-update is still running, skipping action %s", message.action, ) return if message.action == "install_on_new_sites": if not self.installation_completed: # The installation is not completed yet. We cannot know reliably which sites are new. return await self._install_on_new_sites() return if self._task_in_progress("installation_task"): logger.warning( "Installation is still running, skipping action %s", message.action, ) return if self._task_in_progress("deleting_task"): logger.warning( "Uninstallation is already running, skipping action %s", message.action, ) return if message.action == "update_existing": await self._update_existing() return if message.action == "tidy_up": await self._tidy_up() return if message.action == "install_and_update": if not self.installation_completed: # The installation is not completed yet. We cannot know reliably which sites are new. logger.warning( "Installation is not completed yet, skipping" " install_and_update" ) return # Run install_and_update as a background task to prevent blocking. # Note: No need to check if already running - the check at the top of this function # (line 182) already handles that case. self.install_and_update_task = asyncio.create_task( self._run_install_and_update() ) @expect(MessageType.ConfigUpdate) async def manage_plugin_installation(self, message): if not isinstance(message["conf"], SystemConfig): return current_config_value = Wordpress.SECURITY_PLUGIN_ENABLED if current_config_value == self.last_config_value: return # Update last config value immediately to prevent multiple installations self.last_config_value = current_config_value if current_config_value and not self.installation_completed: # Feature was enabled in config, install the WordPress plugin on all sites. await self.process_installation( plugin.install_everywhere(sink=self._sink) ) self.installation_completed = True elif not current_config_value and self.installation_completed: # Feature was disabled in config, remove the WordPress plugin from all sites. await self.process_deleting( plugin.remove_all_installed(sink=self._sink) ) self.installation_completed = False save_state( "ImunifySecurityPlugin", { "installed": self.installation_completed, "enabled": current_config_value, }, ) @expect(HookEvent.MalwareCleanupFinished) async def handle_malware_cleanup_finished(self, message): """ INFO [2025-02-24 12:00:20,384] imav.plugins.wordpress: Malware cleanup finished: HookEvent.MalwareCleanupFinished( { 'cleanup_id': 'fa4fe7e48dbf45588f53b24366cd8893', 'started': 1740398411.786418, 'error': None, 'total_files': 3, 'total_cleaned': 3, 'status': 'ok' } ) """ # Skip if plugin is disabled if not self.last_config_value: return # Leave early if status is not ok or the started time is missing. if message.get("status") != "ok" or not message.get("started"): return # load all malware hits cleaned since the cleanup started hits = MalwareHit.cleaned_since(message["started"]) site_paths = set() # Collect all site paths that need to be updated. for hit in hits: if hit.resource_type == "file": try: user_info = pwd.getpwnam(hit.user) user_sites = plugin.get_sites_for_user(user_info) uid = ( # In None cases there also no user_sites, so it wouldn't be used user_info.pw_uid if user_info else None ) for site_path in user_sites: if hit.orig_file.startswith(site_path): site_paths.add((site_path, uid)) break except KeyError: pass if not site_paths: logger.debug("Cleanup finished => no sites found for cleaned hits") return logger.info( "Cleanup finished => %s site(s) need to be updated", len(site_paths), ) # Convert paths to WPSite objects with empty domain and update data on the sites that need to be updated. # We need to work with paths here because sometimes the domain is not set, see https://cloudlinux.atlassian.net/browse/DEF-32238. wordpress_sites = [ WPSite(docroot=site_path, domain="", uid=uid) for site_path, uid in site_paths ] await plugin.update_data_on_sites(self._sink, wordpress_sites) logger.info("%s site(s) updated after a cleanup", len(wordpress_sites)) @expect(HookEvent.MalwareScanningFinished) async def handle_malware_scan_finished(self, message): """ INFO [2025-02-24 11:57:17,968] imav.plugins.wordpress: Malware scan finished: HookEvent.MalwareScanningFinished( { 'scan_id': 'b9bd136aff0a4d87a248c859cfe41c47', 'scan_type': 'user', 'path': '/home/user1' } ) INFO [2025-02-24 12:00:10,740] imav.plugins.wordpress: Malware scan finished: HookEvent.MalwareScanningFinished( { 'scan_id': 'a74271d2cdd04e0c9bd49ef6de23e0d8', 'scan_type': 'user', 'path': '/home/user4', 'started': 1740398383, 'total_files': 39229, 'total_malicious': 3, 'error': None, 'status': 'ok', 'scan_params': {'intensity_cpu': 2, 'intensity_io': 2, 'intensity_ram': 2048, 'initiator': None, 'file_patterns': None, 'exclude_patterns': None, 'follow_symlinks': False, 'detect_elf': True}, 'stats': {'scan_time': 27, 'mem_peak': 28217344, 'smart_time_hs': 0.004, 'scan_time_hs': 1.1751, 'smart_time_preg': 0, 'scan_time_preg': 2.7391, 'finder_time': 13.5896, 'cas_time': 0.7562, 'deobfuscate_time': 0.8998, 'total_files': 39229} } ) """ # Skip if plugin is disabled if not self.last_config_value: return # Leave early if status is not ok or path or stats are missing. if ( message.get("status") != "ok" or not message.get("path") or not message.get("stats") ): return # Malware scan is finished, figure out what sites need to be updated based on the path. path = message["path"] sites = get_sites_by_path(path) if not sites: logger.debug("Scan finished => no sites found for path=%s", path) return # Update data on the sites that need to be updated. logger.info( "Scan finished => %s site(s) need to be updated", len(sites) ) await plugin.update_data_on_sites(self._sink, sites) logger.info("%s site(s) updated after a scan", len(sites))