Sid Gifari From Gifari Industries - BD Cyber Security Team
Home
/
opt
/
cloudlinux
/
venv
/
lib
/
python3.11
/
site-packages
/
clwpos
/
✏️
Editing: clsop_repair.py
# Copyright © Cloud Linux GmbH & Cloud Linux Software, Inc 2010-2026 All Rights Reserved # # Licensed under CLOUD LINUX LICENSE AGREEMENT # http://cloudlinux.com/docs/LICENSE.TXT """ One-time repair of the clsop plugin copy inside each site (CLOS-7040). Module releases 1.1-9..1.1-45 (bar the 1.1-43.1 hotfix) unpacked a Cloudflare integration whose `substr( $key, ...)` fatals on newer WordPress. Updating the package replaces the zip the panel installs from, not the copy already inside a site, and a fataling site cannot update itself. Runs once per machine, off the requirements cron tick. Temporary: drop it once those releases are out of the field. """ import glob import hashlib import json import logging import os import re import stat import subprocess import syslog import tempfile import time import traceback from collections import Counter from collections.abc import Iterable, Iterator from contextlib import suppress from logging.handlers import RotatingFileHandler import sentry_sdk from clcommon.clpwd import drop_privileges from clcommon.clwpos_lib import find_wp_paths from clcommon.cpapi import cpusers, userdomains from clcommon.utils import get_rhn_systemid_value from clwpos.constants import CLWPOS_ADMIN_DIR, PKG_VERSION, SENTRY_DSN from clwpos.logsetup import get_package_version, setup_logging # The ticket suffix names which repair this records, so a later one gets a file of its own. MARKER_PATH = os.path.join(CLWPOS_ADMIN_DIR, 'clsop_repair_clos_7040.state') # Never rotated: see the handler below and logrotate.d/wpos. LOGFILE_PATH = '/var/log/clwpos/clsop_repair.log' SYSLOG_TAG = 'clsop-repair' # Matched as whole lines, so a copy under other whitespace is counted apart rather than edited. OLD_LINE = b'\t\t\tif ( substr( $key, - strlen( $method ) ) !== $method ) {' NEW_LINE = b'\t\t\tif ( substr( (string) $key, - strlen( $method ) ) !== $method ) {' PLUGIN_REL_PATH = 'wp-content/plugins/clsop/inc/ThirdParty/Plugins/CDN/Cloudflare.php' # The shipped file is ~15 KB. A read that hits the cap is not the whole file, so it is left alone. READ_MAX = 262144 # 7.3 is the floor those releases declared. Newer than 8.5 only if the machine has nothing else. PHP_MIN_VERSION_ID = 703 PHP_MAX_VERSION_ID = 805 PHP_CANDIDATE_GLOBS = ( '/usr/local/bin/php', '/usr/bin/php', '/opt/alt/php[0-9]*/usr/bin/php', '/opt/cpanel/ea-php[0-9]*/root/usr/bin/php', '/opt/plesk/php/[0-9]*/bin/php', '/usr/local/php[0-9]*/bin/php', ) # Checked between accounts: the cron holds its flock while this runs. PASS_LIMIT_SECONDS = 3600 # Attempts at a pass that could not start, roughly an hour at the cron's ten-minute tick. MAX_ATTEMPTS = 6 PHP_TIMEOUT_SECONDS = 30 PATCHED = 'patched' ALREADY_OK = 'already_ok' UNRECOGNISED = 'unrecognised' FAILED = 'failed' PARSE_OK = 'ok' PARSE_INVALID = 'invalid' PARSE_UNKNOWN = 'unknown' _CONTROL_CHARS_RE = re.compile(r'[\x00-\x1f\x7f-\x9f\u200b-\u200f\u2028-\u202e\u2060-\u2069]') _logger = setup_logging( caller_name='clsop_repair', file_level=logging.INFO, logfile_path=LOGFILE_PATH, ) # Per-copy lines are written while the effective uid belongs to an account, and a rollover into # root's log directory would fail and silently take the rest of the log with it. for _handler in _logger.handlers: if isinstance(_handler, RotatingFileHandler): _handler.maxBytes = 0 def _safe(path: str) -> str: """Site paths are named by the account, so strip control characters before logging one.""" return _CONTROL_CHARS_RE.sub('?', path) def _read_state() -> dict: try: with open(MARKER_PATH, encoding='utf-8') as marker: state = json.load(marker) except (OSError, ValueError): return {} return state if isinstance(state, dict) else {} def is_done() -> bool: """Has this machine had its pass, or spent every attempt at one?""" return bool(_read_state().get('finished')) def _attempts() -> int: """Attempts recorded so far. Anything the file does not state as a count reads as none.""" value = _read_state().get('attempts', 0) if isinstance(value, bool) or not isinstance(value, int) or value < 0: return 0 return value def _write_state(attempts: int, finished: bool, summary: str) -> bool: """True when the state reached disk.""" state = { 'recorded': time.strftime('%Y-%m-%dT%H:%M:%S%z'), 'summary': summary, 'attempts': attempts, 'finished': finished, } try: os.makedirs(os.path.dirname(MARKER_PATH), mode=0o700, exist_ok=True) with open(MARKER_PATH, 'w', encoding='utf-8') as marker: json.dump(state, marker, indent=2) return True except (OSError, ValueError): _logger.exception('Could not record the pass in %s', MARKER_PATH) return False def _php_version_id(php_bin: str) -> int | None: """MAJOR * 100 + MINOR, or None when it cannot be asked.""" try: probe = subprocess.run( [php_bin, '-d', 'display_errors=stderr', '-r', 'echo PHP_MAJOR_VERSION * 100 + PHP_MINOR_VERSION;'], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, timeout=PHP_TIMEOUT_SECONDS, check=False, ) except (OSError, subprocess.SubprocessError): return None try: return int(probe.stdout.decode('ascii', errors='ignore').strip()) except ValueError: return None def _account_credentials() -> dict: """ Ids for a child spawned under drop_privileges, which only lowers the effective uid. The real uid stays 0 there, so a child could call setuid(0) and be root again. Handing it the account's ids outright leaves it no way back. Empty when we are not inside such a block. """ if os.getuid() == 0 and os.geteuid() != 0: return {'user': os.geteuid(), 'group': os.getegid()} return {} def _parse_status(php_bin: str, path: str) -> str: """PARSE_OK, PARSE_INVALID, or PARSE_UNKNOWN when the interpreter could not be run at all.""" try: lint = subprocess.run( [php_bin, '-l', path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=PHP_TIMEOUT_SECONDS, check=False, **_account_credentials(), ) except (OSError, subprocess.SubprocessError): return PARSE_UNKNOWN if lint.returncode < 0: # Killed by a signal: the OOM killer, or an interpreter that crashed. That is a verdict on # the run, not on the file, and it must not be cached as one. return PARSE_UNKNOWN return PARSE_OK if lint.returncode == 0 else PARSE_INVALID def find_php_binary() -> str | None: """New enough for this file, and asked about a file known to parse first.""" try: os.makedirs(CLWPOS_ADMIN_DIR, mode=0o700, exist_ok=True) probe_fd, probe_path = tempfile.mkstemp(dir=CLWPOS_ADMIN_DIR, prefix='.clsop-repair-probe.') except OSError: # A directory only root can write, and a machine that cannot take this file is the # machine's problem, not a reason to raise through the pass. _logger.exception('Could not make a file to probe an interpreter with') return None newer: str | None = None try: with os.fdopen(probe_fd, 'wb') as probe: probe.write(b'<?php return 1;\n') for pattern in PHP_CANDIDATE_GLOBS: for candidate in sorted(glob.glob(pattern)): if not os.path.isfile(candidate) or not os.access(candidate, os.X_OK): continue version_id = _php_version_id(candidate) if version_id is None or version_id < PHP_MIN_VERSION_ID: continue if _parse_status(candidate, probe_path) != PARSE_OK: continue if version_id <= PHP_MAX_VERSION_ID: return candidate if newer is None: newer = candidate except OSError: _logger.exception('Could not probe the interpreters on this machine') return None finally: with suppress(OSError): os.unlink(probe_path) return newer class _Unreadable(Exception): """Could not be read: a failure, and worth reporting.""" class _NotRegular(Exception): """Not a regular file: refused by rule.""" def _read_capped(fd: int, limit: int) -> bytes: chunks = [] remaining = limit while remaining > 0: chunk = os.read(fd, remaining) if not chunk: break chunks.append(chunk) remaining -= len(chunk) return b''.join(chunks) def _read_candidate(path: str) -> tuple: """Content and mode from one open file. O_NOFOLLOW, so a path swapped for a symlink is refused.""" try: # O_NONBLOCK because an account can put a FIFO here, and opening one read-only never # returns: the pass would hang holding the cron's lock. O_NOFOLLOW refuses a symlink. fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK) except OSError as err: raise _Unreadable() from err try: file_stat = os.fstat(fd) if not stat.S_ISREG(file_stat.st_mode): raise _NotRegular() # One byte past the cap, to tell a file sitting on it from one cut short by it. content = _read_capped(fd, READ_MAX + 1) except OSError as err: raise _Unreadable() from err finally: os.close(fd) # Permission bits only: setuid/setgid on a PHP source file is never wanted. return content, stat.S_IMODE(file_stat.st_mode) & 0o777, file_stat.st_gid # Every site on a machine carries the same release of the file, so the bytes about to land are the # same everywhere; the verdict depends only on them and on the interpreter. Measured: one distinct # content per machine on all five test panels, so this turns one interpreter start per copy into one # per release. Filled and cleared inside a single pass. _PARSE_CACHE: dict = {} _PARSE_CACHE_MAX = 64 def _staged_status(php_bin: str, staged: str, content: bytes) -> str: """The syntax check for bytes we are about to write, asked once per distinct content.""" key = (php_bin, hashlib.sha256(content).digest()) cached = _PARSE_CACHE.get(key) if cached is not None: return cached status = _parse_status(php_bin, staged) # Not PARSE_UNKNOWN: failing to start the interpreter can be transient, and caching that would # write the whole machine off on one hiccup. if status != PARSE_UNKNOWN and len(_PARSE_CACHE) < _PARSE_CACHE_MAX: _PARSE_CACHE[key] = status return status def _write_repaired(path: str, content: bytes, mode: int, gid: int, php_bin: str) -> str: """Stage beside the copy, check, rename over it. Runs as the account, so ownership survives.""" directory = os.path.dirname(path) staged = None try: staged_fd, staged = tempfile.mkstemp(dir=directory, prefix='.clsop-repair.') with os.fdopen(staged_fd, 'wb') as staged_file: staged_file.write(content) os.chmod(staged, mode) # mkstemp gives the account's primary group; the copy may have had another. with suppress(OSError): os.chown(staged, -1, gid) # Starting PHP costs ~64 ms and is the whole cost of the pass, so the original is linted # only when this fails, and only to attribute the failure. staged_status = _staged_status(php_bin, staged, content) if staged_status == PARSE_UNKNOWN: _logger.warning('not repaired: could not run the syntax check %s', _safe(path)) return FAILED if staged_status == PARSE_INVALID: if _parse_status(php_bin, path) == PARSE_INVALID: _logger.info('not repaired: does not parse, left alone %s', _safe(path)) return UNRECOGNISED _logger.warning('not repaired: the edit does not parse %s', _safe(path)) return FAILED os.rename(staged, path) staged = None _logger.info('patched %s', _safe(path)) return PATCHED except OSError: _logger.exception('not repaired: could not put it in place %s', _safe(path)) return FAILED finally: if staged is not None: with suppress(OSError): os.unlink(staged) def repair_file(path: str, php_bin: str | None, dry_run: bool = False) -> str: """Judge one copy and repair it if it is the one this pass knows. Call as the account.""" if os.path.islink(path): _logger.info('not repaired: symlink %s', _safe(path)) return UNRECOGNISED try: content, mode, gid = _read_candidate(path) except _NotRegular: _logger.info('not repaired: not a regular file %s', _safe(path)) return UNRECOGNISED except _Unreadable: _logger.info('not repaired: could not read it %s', _safe(path)) return FAILED if len(content) > READ_MAX: _logger.info('not repaired: larger than this pass reads %s', _safe(path)) return UNRECOGNISED lines = content.split(b'\n') broken = lines.count(OLD_LINE) repaired = lines.count(NEW_LINE) # The edit hits every matching line, so a copy holding more than one it cannot put back whole. if broken > 1 or (broken == 1 and repaired): _logger.info('not repaired: not the one line this pass edits %s', _safe(path)) return UNRECOGNISED if not broken: if repaired: return ALREADY_OK _logger.info('not repaired: not recognised %s', _safe(path)) return UNRECOGNISED if php_bin is None: _logger.warning('not repaired: no PHP 7.3 or newer can check the result %s', _safe(path)) return UNRECOGNISED if dry_run: # Nothing is staged, so the copy as it stands is all there is to check. status = _parse_status(php_bin, path) if status == PARSE_INVALID: _logger.info('not repaired: does not parse, left alone %s', _safe(path)) return UNRECOGNISED if status == PARSE_UNKNOWN: _logger.warning('not repaired: could not run the syntax check %s', _safe(path)) return FAILED _logger.info('would patch %s', _safe(path)) return PATCHED new_content = b'\n'.join(NEW_LINE if line == OLD_LINE else line for line in lines) new_lines = new_content.split(b'\n') if new_lines.count(NEW_LINE) != 1 or OLD_LINE in new_lines: _logger.warning('not repaired: the edit did not give the line this pass expects %s', _safe(path)) return FAILED return _write_repaired(path, new_content, mode, gid, php_bin) def _candidate_files(docroots: Iterable[str]) -> Iterator[str]: """Plugin copies under one account's document roots. Call as that account.""" docroots = list(docroots) for docroot in docroots: try: # excludes keeps the search out of a document root nested under this one. wp_paths = list(find_wp_paths(docroot, excludes=docroots)) except OSError: _logger.exception('Could not look for sites under %s', _safe(docroot)) continue for wp_path in wp_paths: candidate = os.path.join(docroot, wp_path, PLUGIN_REL_PATH) if os.path.lexists(candidate): yield candidate def _account_docroots(username: str) -> list[str]: """The document roots the panel reports, each one once.""" seen = [] for _domain, docroot in userdomains(username) or []: if docroot and docroot not in seen: seen.append(docroot) return seen def repair_all(usernames: list[str] | None = None, dry_run: bool = False) -> tuple: """ Walk the accounts and repair what each carries. Sites come from the panel, not from a walk. Returns (counters, covered). `covered` is False only when the pass could not do its work at all: no usable interpreter, or no account list. """ counters: Counter = Counter() _PARSE_CACHE.clear() php_bin = find_php_binary() if php_bin is None: # Walked anyway: the counters are what tell an operator how many copies are waiting. _logger.error('No PHP 7.3 or newer on this machine can check an edit, nothing will be repaired') else: _logger.info('Checking edits with %s', php_bin) if usernames is None: try: usernames = sorted(cpusers()) except Exception: # pylint: disable=broad-except _logger.exception('Could not list the accounts of this machine') return counters, False deadline = time.monotonic() + PASS_LIMIT_SECONDS for username in usernames: if time.monotonic() > deadline: # Still counts as gone over. An account can make the pass arbitrarily long by creating # sites, so treating the limit as "try again" would hand it a permanent retry loop. _logger.warning('Time limit reached, %s and the accounts after it were not looked at', username) break try: docroots = _account_docroots(username) except Exception: # pylint: disable=broad-except _logger.exception('Could not resolve the document roots of %s', username) continue if not docroots: continue try: with drop_privileges(username): for candidate in _candidate_files(docroots): counters[repair_file(candidate, php_bin, dry_run)] += 1 except Exception: # pylint: disable=broad-except _logger.exception('Could not look at the sites of %s', username) return counters, php_bin is not None def format_summary(counters: Counter) -> str: """The line an operator greps for.""" return ( f'sweep done: found={sum(counters.values())} ' f'patched={counters[PATCHED]} ' f'already_ok={counters[ALREADY_OK]} ' f'unrecognised={counters[UNRECOGNISED]} ' f'failed={counters[FAILED]}' ) def _crash_details(err: BaseException) -> str: """Type, errno and the frames. Never str(err): its text carries the path that failed.""" errno = getattr(err, 'errno', None) named = type(err).__name__ if errno is None else f'{type(err).__name__} errno={errno}' # Named first: Sentry cuts an over-long value from the tail, and the default cap is 1 KB. return named + '\n' + ''.join(traceback.format_tb(err.__traceback__)) def _report(counters: Counter, covered: bool, details: str = '') -> None: """The one event a machine may send, and only when the pass is over and did not do its job.""" try: # Sentry groups messages by their text, so each of these is one issue. if details: outcome = 'the pass broke down' elif not covered: outcome = 'nothing could be checked' elif counters[FAILED]: outcome = 'copies left unrepaired' else: return release, is_development = get_package_version() sentry_sdk.init( dsn=SENTRY_DSN, # Stripped, as everything else this project sends: an unstripped one filters apart. release=release, environment='Development' if is_development else 'Release', # The defaults would hook an excepthook and a logging handler onto the rest of the cron. default_integrations=False, integrations=[], ) with suppress(Exception): # Decoration: a machine the lookup fails on must still get its event out. sentry_sdk.set_tag('system_id', get_rhn_systemid_value('system_id')) sentry_sdk.set_tag('full_version', PKG_VERSION) # Counters only: a copy's path names the account that owns it and the domain it serves. sentry_sdk.set_extra('summary', format_summary(counters)) if details: sentry_sdk.set_extra('details', details) sentry_sdk.capture_message(f'clsop repair: {outcome}', level='warning') # Nothing flushes at exit without the integrations, and the cron holds its lock meanwhile. sentry_sdk.flush(timeout=5) except Exception: # pylint: disable=broad-except _logger.exception('Could not report how the pass went') def run_once(dry_run: bool = False, force: bool = False) -> str: """The pass, guarded by the state file. Returns the summary line, empty when it declined.""" if not force and not dry_run and is_done(): return '' attempts = 0 if not dry_run: attempts = _attempts() + 1 # Counted before the work, so a pass that crashes has still spent one. A machine whose # state cannot be written does not run at all: nothing would bound the retries. if not _write_state(attempts, attempts >= MAX_ATTEMPTS, 'in progress'): _logger.error('The pass will not run while its state cannot be recorded') return '' _logger.info('Repair pass starting') counters: Counter = Counter() covered = False details = '' try: counters, covered = repair_all(dry_run=dry_run) except Exception as err: # pylint: disable=broad-except _logger.exception('The repair pass ended with an unexpected error') # Kept rather than reported here: one pass sends one event, and this is the one to send. details = _crash_details(err) summary = format_summary(counters) if not dry_run: finished = covered or attempts >= MAX_ATTEMPTS recorded = _write_state(attempts, finished, summary) if not finished: _logger.warning('The pass could not do its work, a later tick will try again') elif not force and recorded: # A retry would say it six times, a hand-run counts twice, and unwritten state bounds nothing. _report(counters, covered, details) _logger.info(summary) with suppress(OSError, ValueError): syslog.openlog(SYSLOG_TAG) syslog.syslog(syslog.LOG_INFO, summary) syslog.closelog() return summary def repair_clsop_copies_if_needed() -> None: """Cron entry point. Never raises: the scan runs after it.""" try: if is_done(): return run_once() except Exception: # pylint: disable=broad-except # Not reported: run_once reports what it catches, and a fault here would report every tick. _logger.exception('The repair pass ended with an unexpected error')
💾 Save
❌ Cancel