#!/bin/bash
# cPanel PHP одит 1.5.2. Изисква root и Python 2.6+ или Python 3.
# Бюлетин към: 2026-10-22. НЕ е общ скенер за уязвимости.
# Не инсталира пакети, не рестартира услуги и не променя сайтове.
# Метаданните на хранилищата се опресняват, освен ако не е подадено --offline.
#
# Употреба:
#   ./cpanel-php-audit.sh              кратък отчет на български
#   ./cpanel-php-audit.sh --details    пълен отчет (всички пакети, vhost-ове, бележки)
#   ./cpanel-php-audit.sh --json       машинно четим отчет (JSON)
#   ./cpanel-php-audit.sh --offline    без проверка на хранилищата
#   ./cpanel-php-audit.sh --no-color   без цветове
#   ./cpanel-php-audit.sh --output F   друг файл за отчета (по подразбиране php-audit.txt)
#   ./cpanel-php-audit.sh --no-scan    без бавните сканирания (логове, PHP файлове, .htaccess)
#   ./cpanel-php-audit.sh --since D    от коя дата да търси следи от атака (по подразбиране 2026-10-20)
#   ./cpanel-php-audit.sh --json-file F  записва и JSON (ползва се от php-audit-fleet.sh)
# Отчетът винаги се записва и във файл (без цветове), освен на екрана.
set -u
export PATH=/usr/local/cpanel/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
# Английски съобщения от системните команди, но UTF-8 за имената на файлове.
# С чист LC_ALL=C Python 3.6 (AlmaLinux/CloudLinux 8) не може да работи с имена на кирилица.
export LC_ALL=C
UTF8_LOCALE=$(locale -a 2>/dev/null | grep -ixE 'c\.utf-?8|en_us\.utf-?8' | head -1)
[ -n "$UTF8_LOCALE" ] && export LC_ALL="$UTF8_LOCALE"
export PYTHONIOENCODING=utf-8:replace
export COLUMNS=1000
for interpreter in python3 python2 python; do
    if command -v "$interpreter" >/dev/null 2>&1 && "$interpreter" -c 'import sys; sys.exit(0 if sys.version_info >= (2,6) else 1)' 2>/dev/null; then
        exec "$interpreter" - "$@" <<'PYTHON'
# -*- coding: utf-8 -*-
from __future__ import print_function, unicode_literals
import os, sys, re, io, pwd, stat as stat_mod, json, glob, subprocess, tempfile, time, signal, gzip
from optparse import OptionParser

VERSION = '1.5.2'
SOURCE = 'https://support.cpanel.net/hc/en-us/articles/44397389677335'
CVES = ['CVE-2026-109618', 'CVE-2026-109619']
SNAPSHOT = '2026-10-22'
# Поправени версии към 2026-10-22.
# cPanel EasyApache4 (vendor cPanel), източник: статията на cPanel по-горе.
# Без release (напр. '8.2.35') = всеки release на тази версия е поправен (upstream fix).
CPANEL_FIXED = {'74':'7.4.33-19', '80':'8.0.30-12', '81':'8.1.34-5',
                '82':'8.2.35', '83':'8.3.36', '84':'8.4.27', '85':'8.5.12'}
# cPanel ea-php 7.3 и по-стари нямат поправка от cPanel (само TuxCare ELS).
CPANEL_NO_FIX_BELOW = 74
# CloudLinux EasyApache4 (vendor CloudLinux), източник: blog.cloudlinux.com.
# Backport без смяна на PHP версията: важен е суфиксът .cloudlinux.N.
# 'elX' се заменя с dist тага на инсталирания пакет (el7/el8/el9/el10).
CL_FIXED = {'52':'5.2.17-27.elX.cloudlinux.67', '53':'5.3.29-29.elX.cloudlinux.69',
            '54':'5.4.45-82.elX.cloudlinux.9',  '55':'5.5.38-64.elX.cloudlinux.10',
            '56':'5.6.40-25.elX.cloudlinux.18', '70':'7.0.33-29.elX.cloudlinux.17',
            '71':'7.1.33-20.elX.cloudlinux.17', '72':'7.2.34-12.elX.cloudlinux.18',
            '73':'7.3.33-15.elX.cloudlinux.18', '74':'7.4.33-18.elX.cloudlinux.13',
            '80':'8.0.30-11.elX.cloudlinux.1',  '81':'8.1.34-4.elX.cloudlinux.1',
            '82':'8.2.34-2.elX.cloudlinux.1',   '83':'8.3.35-2.elX.cloudlinux.2',
            '84':'8.4.26-2.elX.cloudlinux.1',   '85':'8.5.11-2.elX.cloudlinux.1'}
# alt-php: CloudLinux не публикува номера на версии; поправката се проверява
# по CVE в changelog (alt-phpXX-common / -cli).
# cPanel & WHM: поправени билдове по тир. '138.1' = WP Squared.
# ВНИМАНИЕ: за тир 110 източниците се разминават (11.110.0.15 срещу 110.0.153);
# тук е по-строгата стойност 153.
CPANEL_WHM_FIXED = {'110':(11,110,0,153), '134':(11,134,0,66), '136':(11,136,0,50),
                    '138':(11,138,0,18), '138.1':(11,138,1,17)}
RULESET_REFERENCE = '8.85'

# ---------------------------------------------------------------------------
# Етикети за текстовия отчет. JSON изходът запазва английските кодове.
# ---------------------------------------------------------------------------
PATCH_LABEL = {'PATCHED':'ПОПРАВЕН', 'AFFECTED':'УЯЗВИМ', 'UNKNOWN':'НЕИЗВЕСТНО',
               'CHANGELOG_EVIDENCE':'ПОПРАВЕН (changelog)', 'NOT_CORE':'разширение',
               'UNCONFIRMED':'НЕПОТВЪРДЕН', 'NO_FIX_IN_CHANGELOG':'УЯЗВИМ (няма CVE)',
               'NO_VENDOR_FIX':'БЕЗ ПОПРАВКА (EOL)'}
PATCH_RANK = {'AFFECTED':7, 'NO_VENDOR_FIX':6, 'NO_FIX_IN_CHANGELOG':5, 'UNKNOWN':4,
              'UNCONFIRMED':3, 'CHANGELOG_EVIDENCE':2, 'PATCHED':1, 'NOT_CORE':0}
VULNERABLE = ('AFFECTED', 'NO_VENDOR_FIX', 'NO_FIX_IN_CHANGELOG')
PATCH_MARK = {'PATCHED':('✓','green'), 'CHANGELOG_EVIDENCE':('✓','green'),
              'AFFECTED':('✗','red'), 'NO_VENDOR_FIX':('✗','red'),
              'NO_FIX_IN_CHANGELOG':('✗','red'), 'UNKNOWN':('?','yellow'),
              'UNCONFIRMED':('?','yellow'), 'NOT_CORE':('·',None)}
UPDATE_LABEL = {'AVAILABLE':'има по-нова', 'NO_UPDATE_OFFERED':'✓ последна',
                'UNKNOWN':'? не е проверено'}
CPANEL_LABEL = {'PATCHED':'ПОПРАВЕН', 'AFFECTED':'УЯЗВИМ, нужен upcp',
                'UNSUPPORTED_TIER':'ТИРЪТ НЯМА ПОПРАВКА', 'UNKNOWN':'НЕИЗВЕСТНО'}
CHECK_MARK = {'PASS':('✓','green'), 'WARN':('!','yellow'), 'FAIL':('✗','red'),
              'UNKNOWN':('?','gray')}
CHECK_LABEL = {'agent':'Imunify360 агент', 'license':'Лиценз',
               'imunify360':'Услуга imunify360', 'imunify360-webshield':'Услуга WebShield',
               'ruleset_profile':'Профил на правилата', 'recommended_directives':'Препоръчани директиви',
               'domain_modsecurity':'ModSecurity по домейни', 'imunify_vendor':'Imunify vendor в cPanel',
               'vendor_auto_updates':'Автообновяване на vendor', 'ruleset_files':'Версия на правилата',
               'disabled_rules':'Изключени правила', 'apache_modsecurity_module':'ModSecurity модул в Apache',
               'apache_rule_exclusions':'Изключения в Apache', 'engine_directives':'SecRuleEngine',
               'imunify_apache_include':'Imunify include в Apache', 'webserver_engine':'Уеб сървър',
               'imunify_package_updates':'Обновления на Imunify пакети'}
REPO_LABEL = {'QUERIED_ENABLED_REPOSITORIES':'проверени', 'INCOMPLETE':'непълна проверка',
              'UNKNOWN':'не са проверени'}
WAF_LABEL = {'ATTENTION':'ВНИМАНИЕ', 'UNCONFIRMED':'НЕПОТВЪРДЕНО'}
COLORS = {'red':'31', 'green':'32', 'yellow':'33', 'gray':'90', 'bold':'1', 'cyan':'36'}
USE_COLOR = False

def paint(text, color):
    if not USE_COLOR or not color:
        return text
    return '\033[%sm%s\033[0m' % (COLORS[color], text)

def short(msg, limit=170):
    msg = re.sub(r'\s+', ' ', msg).strip()
    return msg if len(msg) <= limit else msg[:limit-3] + '...'

def family(name):
    m = re.match(r'^((?:ea|alt)-php\d+)', name)
    if m:
        return m.group(1)
    m = re.match(r'^(php[0-9.]*|libapache2-mod-php[0-9.]*)', name)
    return m.group(1) if m else name

def core_package(name):
    return bool(re.match(r'^(?:(?:ea-|alt-)php\d+-(?:php(?:-(?:cli|common|fpm|cgi|litespeed))?|cli|common|fpm|cgi)|php[0-9.]*(?:-(?:cli|common|fpm|cgi))?|libapache2-mod-php[0-9.]*)$', name))

def _segments(value):
    return re.findall(r'[0-9]+|[A-Za-z]+', value)

def vercmp(a, b):
    # Опростен rpmvercmp: числов сегмент > буквен, по-дълъг низ > по-къс.
    if a == b:
        return 0
    x, y = _segments(a), _segments(b)
    for p, q in zip(x, y):
        if p.isdigit() and q.isdigit():
            if int(p) != int(q):
                return 1 if int(p) > int(q) else -1
        elif p.isdigit():
            return 1
        elif q.isdigit():
            return -1
        elif p != q:
            return 1 if p > q else -1
    return (len(x) > len(y)) - (len(x) < len(y))

def split_evr(value):
    value = re.sub(r'^\d+:', '', value)
    if '-' in value:
        version, release = value.rsplit('-', 1)
        return version, release
    return value, None

def evr_cmp(installed, floor):
    # None = несравними (друг клон или липсващ release).
    iv, ir = split_evr(installed)
    fv, fr = split_evr(floor)
    if _segments(iv)[:2] != _segments(fv)[:2]:
        return None
    c = vercmp(iv, fv)
    if c or fr is None:
        return c
    if ir is None:
        return None
    return vercmp(ir, fr)

def dist_tag(version):
    m = re.search(r'\.(el\d+)', version)
    return m.group(1) if m else None

def patch_assessment(pkg):
    if not core_package(pkg['name']):
        return 'NOT_CORE', 'Разширение или метапакет; няма отделна оценка'
    pkg['fixed_in'] = None
    name = pkg['name']
    # Debian/Ubuntu метапакети (php, php-cli с версия 2:93) не са реален PHP.
    major = re.match(r'^(?:\d+:)?(\d+)', pkg['version'])
    if re.match(r'^php(-[a-z]+)?$', name) and major and int(major.group(1)) not in (5, 7, 8):
        return 'NOT_CORE', 'Метапакет на дистрибуцията, не е PHP'
    vendor = pkg.get('vendor', '').lower()
    evidence = len(pkg.get('cve_evidence', [])) == 2
    match = re.match(r'^ea-php(\d+)-php(?:-|$)', name)
    floor = None
    # CloudLinux билдове на Ubuntu са с maintainer cPanel и без elX таг; познават се по '.cloudlinux' във версията.
    # Номерацията им е различна от el7-el10, затова при тях решава само changelog.
    cl_build = 'cloudlinux' in vendor or 'cloudlinux' in pkg['version'].lower()
    if match:
        br = match.group(1)
        if cl_build:
            tag = dist_tag(pkg['version'])
            if CL_FIXED.get(br) and tag:
                floor = CL_FIXED[br].replace('elX', tag)
        elif 'cpanel' in vendor:
            floor = CPANEL_FIXED.get(br)
            if not floor and int(br) < CPANEL_NO_FIX_BELOW and not evidence:
                pkg['fixed_in'] = 'няма (TuxCare ELS)'
                return 'NO_VENDOR_FIX', 'cPanel не поправя ea-php под 7.4; нужен е TuxCare ELS или по-нова PHP версия'
    if floor:
        pkg['fixed_in'] = floor
        c = evr_cmp(pkg['version'], floor)
        if c is not None:
            if c >= 0:
                return 'PATCHED', 'Версията е >= поправената %s' % floor
            if evidence:
                return 'UNKNOWN', 'Противоречие: версията е под %s, но changelog споменава двата CVE' % floor
            return 'AFFECTED', 'Версията е под поправената %s' % floor
    if evidence:
        pkg['fixed_in'] = pkg['fixed_in'] or 'по changelog'
        return 'CHANGELOG_EVIDENCE', 'Двата CVE са в changelog на пакета'
    # CloudLinux документира проверката през changelog: щом го има и CVE липсва,
    # пакетът не е обновен до поправената версия.
    # Нативният PHP от ОС (AlmaLinux/RHEL/Rocky/CloudLinux) също отбелязва CVE в changelog.
    if pkg.get('changelog_read') and (cl_build or name.startswith('alt-php') or re.search(r'cloudlinux|almalinux|red hat|rocky|ubuntu|debian', vendor)):
        pkg['fixed_in'] = pkg['fixed_in'] or 'по changelog'
        return 'NO_FIX_IN_CHANGELOG', 'Changelog е прочетен, но CVE-2026-109618/109619 липсват'
    return 'UNKNOWN', 'Няма поправена версия от доставчика и няма доказателство в changelog'

def cpanel_assessment(value):
    parts = version_tuple(value or '')
    if not parts or len(parts) < 4:
        return {'status':'UNKNOWN', 'version':value or '?', 'fixed_in':None}
    key = '%d.1' % parts[1] if parts[2] == 1 else '%d' % parts[1]
    floor = CPANEL_WHM_FIXED.get(key)
    if not floor:
        return {'status':'UNSUPPORTED_TIER', 'version':value, 'fixed_in':None}
    return {'status':'PATCHED' if parts >= floor else 'AFFECTED', 'version':value,
            'fixed_in':'.'.join(str(n) for n in floor)}

def parse_updates(output):
    updates, pending = {}, None
    for line in output.splitlines():
        fields = line.split()
        if not fields:
            continue
        if pending and len(fields) >= 2 and re.match(r'^\d', fields[0]):
            updates[pending] = fields[0]
            pending = None
            continue
        if pending:
            raise ValueError('Unparsed wrapped package row')
        pending = None
        if '.' in fields[0]:
            name, arch = fields[0].rsplit('.', 1)
            if audited_package(name) and arch in ('x86_64','i686','i386','noarch','aarch64','armv7hl'):
                if len(fields) >= 3 and re.match(r'^\d', fields[1]):
                    updates[(name, arch)] = fields[1]
                elif len(fields) == 1:
                    pending = (name, arch)
                else:
                    raise ValueError('Unparsed package update row')
    if pending:
        raise ValueError('Truncated package update row')
    return updates

def site_patch(version, packages):
    # Do not mix ea-PHP, alt-PHP or native PHP even when branch numbers match.
    if not re.match(r'^(ea|alt)-php\d+$', str(version)):
        return 'UNKNOWN'
    relevant = [p for p in packages if p['name'].startswith(version+'-') and core_package(p['name'])]
    states = [p['patch_status'] for p in relevant]
    if any(s in VULNERABLE for s in states):
        return 'AFFECTED'
    if not states or 'UNKNOWN' in states:
        return 'UNKNOWN'
    return 'PATCHED' if all(s == 'PATCHED' for s in states) else 'CHANGELOG_EVIDENCE'

def read(path):
    try:
        with open(path, 'rb') as f:
            return f.read().decode('utf-8', 'replace')
    except (IOError, OSError, UnicodeError, ValueError):
        return ''

def which(name):
    for directory in os.environ.get('PATH', '').split(':'):
        path = os.path.join(directory, name)
        if os.path.isfile(path) and os.access(path, os.X_OK):
            return path
    return None

PY3 = sys.version_info[0] >= 3
# Python 3 връща байтовете от невалиден UTF-8 непроменени; Python 2 няма surrogateescape.
DECODE_ERRORS = 'surrogateescape' if PY3 else 'replace'

def _arg(value):
    # Аргументите стават байтове, за да минават имена на кирилица и при ASCII локал.
    if isinstance(value, bytes):
        return value
    return value.encode('utf-8', 'surrogateescape')

def run(args, timeout=120):
    # Files prevent pipe-buffer deadlocks on large package inventories.
    args = [_arg(a) for a in args]
    with tempfile.TemporaryFile() as out:
        with tempfile.TemporaryFile() as err:
            try:
                p = subprocess.Popen(args, stdout=out, stderr=err, preexec_fn=os.setsid)
            except OSError as e:
                return 127, '', str(e)
            start = time.time()
            expired = False
            try:
                while p.poll() is None:
                    if time.time() - start > timeout:
                        expired = True
                        try:
                            os.killpg(p.pid, signal.SIGKILL)
                        except OSError:
                            pass
                        break
                    time.sleep(0.1)
            except BaseException:
                try:
                    os.killpg(p.pid, signal.SIGKILL)
                except OSError:
                    pass
                p.wait()
                raise
            p.wait()
            out.seek(0)
            err.seek(0)
            return (124 if expired else p.returncode,
                    out.read().decode('utf-8', DECODE_ERRORS),
                    err.read().decode('utf-8', 'replace') + (' TIMEOUT' if expired else ''))

def branch(value):
    value = str(value or '')
    m = re.search(r'(?:ea-|alt-)?php([0-9])([0-9])(?:\D|$)', value)
    if not m:
        m = re.search(r'(?:^|php|[^0-9A-Za-z])([0-9]+)\.([0-9]+)', value)
    return (int(m.group(1)), int(m.group(2))) if m else None

def php_package(name):
    # Avoid an optional group around a zero-or-more repetition: old SRE
    # compilers can reject it. The inner * already permits an empty suffix.
    return bool(re.match(r'^(?:(?:ea-|alt-)php[0-9]+(?:-|$)|php[0-9.]*(?:-|$)|libapache2-mod-php)', name))

def api(method):
    rc, out, err = run(['/usr/local/cpanel/bin/whmapi1', '--output=json', method])
    if rc:
        raise ValueError('%s: %s' % (method, err[-600:]))
    obj = json.loads(out)
    if str(obj.get('metadata', {}).get('result')) != '1':
        raise ValueError('%s: %s' % (method, obj.get('metadata')))
    return obj.get('data', {})

# Read-only Imunify360 checks. No agent update/fix/install/restart commands.
def security_package(name):
    return name.startswith(('imunify360', 'imunify-common', 'imunify-core')) or name in ('ea-apache24-mod_security2', 'ea-modsec30')

def audited_package(name):
    return php_package(name) or security_package(name)

def truth(value):
    if value is True or str(value).lower() in ('1','true','on','enabled'): return True
    if value is False or str(value).lower() in ('0','false','off','disabled'): return False
    return None

def objects(data):
    if isinstance(data, dict):
        yield data
        for value in data.values():
            for item in objects(value): yield item
    elif isinstance(data, list):
        for value in data:
            for item in objects(value): yield item

def json_command(args, timeout=40):
    rc, out, err = run(args, timeout)
    if rc: return None
    try: return json.loads(out)
    except ValueError: return None

def version_tuple(value):
    if not re.match(r'^\d+\.\d+(?:\.\d+)*$', value): return None
    parts = [int(n) for n in value.split('.')]
    return tuple(parts + [0] * max(0, 4-len(parts)))

def yes_no(value):
    return 'да' if value else 'не'

def audit_waf(sites, packages, offline):
    result = {'status':'UNCONFIRMED', 'checks':[], 'packages':packages,
              'rulesets':[], 'domains':[], 'disabled_rules':[],
              'latest_ruleset':'UNKNOWN', 'reference_ruleset':'8.85',
              'reference_date':SNAPSHOT, 'runtime_probe':'NOT_PERFORMED',
              'limits':['Статичната конфигурация не доказва реално блокиране на заявки.',
                        'Правилата 8.85 са референция към дата, не последна версия онлайн.',
                        'Настройки по директории и състоянието след reload изискват отделна проверка.',
                        'WAF спира известни шаблони, но не поправя PHP CVE.']}
    def check(name, state, detail):
        result['checks'].append({'name':name,'status':state,'detail':detail})
    agent = which('imunify360-agent')
    if not agent:
        check('agent', 'FAIL', 'imunify360-agent не е намерен; няма потвърдена защита от Imunify360')
        result['status']='ATTENTION'
        return result
    check('agent','PASS','imunify360-agent е наличен')
    rc,out,err=run([agent,'version'],30)
    result['agent_version'] = out.strip()[:100] if rc==0 else 'UNKNOWN'
    license_data=json_command([agent,'rstatus','--json','-v'])
    if isinstance(license_data,dict):
        license_info=license_data.get('license',license_data)
        if not isinstance(license_info,dict): license_info={}
        valid=truth(license_info.get('status'))
        kind=license_info.get('license_type',license_data.get('license_type','UNKNOWN'))
        # Never include license identifiers, tokens or raw config in the report.
        result['license']={'valid':valid,'type':kind}
        check('license','PASS' if valid is True and kind=='imunify360' else ('FAIL' if valid is False or kind not in ('UNKNOWN','imunify360') else 'UNKNOWN'), 'валиден=%s; продукт=%s'%(valid,kind))
    else:
        check('license','UNKNOWN','Статусът на лиценза не може да бъде прочетен от тази версия на агента')
    for service in ('imunify360','imunify360-webshield'):
        if os.path.isdir('/run/systemd/system') and which('systemctl'):
            rc,out,err=run(['systemctl','is-active',service],20)
            state='PASS' if rc==0 and out.strip()=='active' else ('FAIL' if out.strip() in ('failed','inactive') else 'UNKNOWN')
        else:
            rc,out,err=run(['service',service,'status'],20)
            state='PASS' if rc==0 and re.search(r'\brunning\b',out,re.I) and not re.search(r'not running|stopped',out,re.I) else ('FAIL' if re.search(r'not running|stopped|dead',out,re.I) else 'UNKNOWN')
        check(service,state,'Статус на услугата; само WebShield не доказва WAF покритие')
    config=json_command([agent,'config','show','--json'])
    sections={}
    if config is not None:
        for obj in objects(config):
            for key in ('MOD_SEC','PROACTIVE_DEFENCE','WEBSHIELD'):
                if isinstance(obj.get(key),dict):
                    sections[key]=dict((k,v) for k,v in obj[key].items() if k in ('ruleset','app_specific_ruleset','enabled','enable','mode'))
    result['selected_config']=sections
    mode=sections.get('MOD_SEC',{}).get('ruleset','UNKNOWN')
    check('ruleset_profile','PASS' if mode=='FULL' else ('WARN' if mode=='MINIMAL' else 'UNKNOWN'),'%s; MINIMAL не означава изключена защита'%mode)
    rc,out,err=run([agent,'check','modsec','directives'],40)
    has_warn = re.search(r'warning|wrong value|error',out+err,re.I)
    check('recommended_directives','UNKNOWN' if rc else ('WARN' if has_warn else 'PASS'),
          'Диагностика на агента: '+('неподдържана/грешка' if rc else ('има предупреждения' if has_warn else 'без предупреждения')))
    # Authoritative per-account enable/disable state from cPanel UAPI.
    accounts=sorted(set(s.get('account','') for s in sites if re.match(r'^[A-Za-z0-9_]+$',s.get('account',''))))
    start=time.time()
    failed_accounts=[]
    for index,account in enumerate(accounts):
        if time.time()-start>300:
            failed_accounts.extend(accounts[index:]); break
        if index%25==0: print('WAF: проверка на домейните, акаунт %d/%d'%(index+1,len(accounts)),file=sys.stderr)
        data=json_command(['/usr/local/cpanel/bin/uapi','--output=json','--user='+account,'ModSecurity','list_domains'],30)
        api_result=data.get('result',{}) if isinstance(data,dict) else {}
        found=False
        if str(api_result.get('status'))=='1':
            for obj in objects(api_result.get('data')):
                if 'domain' in obj and 'enabled' in obj:
                    found=True
                    result['domains'].append({'account':account,'domain':obj['domain'],'enabled':truth(obj['enabled'])})
        if not found: failed_accounts.append(account)
    result['unverified_accounts']=failed_accounts
    disabled=[d for d in result['domains'] if d['enabled'] is False]
    expected=set(s.get('domain') for s in sites)
    known=set(d['domain'] for d in result['domains'] if d['enabled'] is not None)
    result['unverified_domains']=sorted(expected-known)
    check('domain_modsecurity','FAIL' if disabled else ('UNKNOWN' if failed_accounts or expected-known or not accounts else 'PASS'),
          '%d изключени; %d акаунта непроверени; %d домейна без данни'%(len(disabled),len(failed_accounts),len(expected-known)))
    # Vendor registration and update flags, if supported by the installed cPanel.
    try:
        vendors=api('modsec_get_vendors')
        records=[]
        for obj in objects(vendors):
            ident=str(obj.get('vendor_id',obj.get('vendor',''))) or str(obj.get('name',''))
            if 'imunify' in ident.lower():
                records.append(dict((k,obj[k]) for k in ('vendor_id','vendor','name','enabled','update','updates','inst_dist','dist') if k in obj))
        result['vendors']=records
        enabled=[r for r in records if truth(r.get('enabled')) is True]
        check('imunify_vendor','PASS' if enabled else ('FAIL' if records and all(truth(r.get('enabled')) is False for r in records) else 'UNKNOWN'),
              '%d записа за Imunify vendor; %d активни. Coraza може да не регистрира vendor в cPanel.'%(len(records),len(enabled)))
        update_flags=[truth(r.get('update',r.get('updates'))) for r in records]
        check('vendor_auto_updates','WARN' if False in update_flags else ('PASS' if update_flags and all(f is True for f in update_flags) else 'UNKNOWN'),
              'Само флагът в cPanel; агентът може да обновява правилата по друг механизъм')
    except (ValueError,TypeError,AttributeError):
        check('imunify_vendor','UNKNOWN','Vendor API не е достъпно')
    # Files on disk are inventory only, never proof of an active include.
    paths=glob.glob('/etc/apache2/conf.d/modsec_vendor_configs/imunify360-*/VERSION')
    paths+=glob.glob('/usr/local/apache/conf/modsec_vendor_configs/imunify360-*/VERSION')
    seen=set()
    for path in paths:
        real=os.path.realpath(path)
        if real in seen: continue
        seen.add(real)
        value=read(path).strip()
        ver=version_tuple(value)
        result['rulesets'].append({'path':path,'version':value[:60],
            'reference_status':'AT_LEAST_REFERENCE' if ver and ver>=version_tuple('8.85') else ('OLDER_THAN_REFERENCE' if ver else 'UNKNOWN'),
            'loaded':'UNCONFIRMED'})
    check('ruleset_files','WARN' if any(r['reference_status']=='OLDER_THAN_REFERENCE' for r in result['rulesets']) else ('PASS' if result['rulesets'] and all(r['reference_status']=='AT_LEAST_REFERENCE' for r in result['rulesets']) else 'UNKNOWN'),
          'Версии: %s (референция 8.85 към %s)'%(', '.join(r['version'] for r in result['rulesets']) or 'няма намерени', SNAPSHOT))
    rules=json_command([agent,'rules','list-disabled','--json'])
    if rules is None:
        check('disabled_rules','UNKNOWN','Списъкът с изключени правила не е достъпен')
    else:
        for obj in objects(rules):
            if obj.get('plugin')=='modsec' and 'id' in obj:
                result['disabled_rules'].append(dict((k,obj[k]) for k in ('id','domains','name') if k in obj))
        # Accept only known list/envelope shapes, never assume unrecognized output is empty.
        known_shape=isinstance(rules,list) or (isinstance(rules,dict) and any(isinstance(rules.get(k),list) for k in ('items','data','rules')))
        check('disabled_rules','WARN' if result['disabled_rules'] else ('PASS' if known_shape else 'UNKNOWN'),
              '%d изключени ModSecurity правила (обхват в --details)'%len(result['disabled_rules']))
    apache=which('apachectl') or which('httpd') or which('apache2ctl')
    if apache:
        rc,out,err=run([apache,'-M'],30)
        check('apache_modsecurity_module','UNKNOWN' if rc else ('PASS' if re.search(r'security[23]_module',out) else 'WARN'),
              'По конфигурацията на Apache; LiteSpeed/Coraza изискват отделна проверка')
        rc,out,err=run([apache,'-t','-D','DUMP_INCLUDES'],30)
        included=[]
        if rc==0:
            for line in (out+'\n'+err).splitlines():
                match=re.search(r'\)\s+(/[^\r\n]+)$',line)
                if match: included.append(match.group(1).strip())
        result['apache_include_count']=len(included)
        directives=[]
        for path in sorted(set(included))[:2000]:
            for number,line in enumerate(read(path).splitlines()):
                if re.match(r'^\s*(SecRuleEngine|SecRuleRemoveById|SecRuleRemoveByTag)\s+',line,re.I):
                    directives.append({'file':path,'line':number+1,'directive':line.strip()[:400]})
        result['engine_and_exclusion_directives']=directives
        exclusions=[d for d in directives if re.match(r'^SecRuleRemove',d['directive'],re.I)]
        check('apache_rule_exclusions','WARN' if exclusions else ('PASS' if included else 'UNKNOWN'), '%d директиви за премахване на правила'%len(exclusions))
        on=any(re.match(r'^SecRuleEngine\s+On\b',d['directive'],re.I) for d in directives)
        off=any(re.match(r'^SecRuleEngine\s+(Off|DetectionOnly)\b',d['directive'],re.I) for d in directives)
        check('engine_directives','WARN' if off else ('PASS' if on else 'UNKNOWN'),
              'Открити Off/DetectionOnly: %s (условният обхват не се анализира)'%yes_no(off))
        loaded=any('imunify360' in p for p in included)
        check('imunify_apache_include','PASS' if loaded else 'UNKNOWN','Път на Imunify360 в include-ите на Apache: %s'%yes_no(loaded))
    else: check('webserver_engine','UNKNOWN','Не е намерен apachectl/httpd; друг уеб сървър изисква ръчна проверка')
    offered=[p for p in packages if p['update']=='AVAILABLE']
    check('imunify_package_updates','WARN' if offered else ('UNKNOWN' if offline or not packages or any(p['update']=='UNKNOWN' for p in packages) else 'PASS'),
          '%d пакета за сигурност имат обновления; правилата се обновяват отделно'%len(offered))
    # Never label the server protected without a correlated HTTP/log runtime test.
    result['status']='ATTENTION' if any(c['status'] in ('FAIL','WARN') for c in result['checks']) else 'UNCONFIRMED'
    return result

# ---------------------------------------------------------------------------
# Допълнителни проверки. Само четене, нищо не се променя по системата.
# ---------------------------------------------------------------------------
# Признаци от VulnCheck / Prophet Security (21-22.10.2026).
IOC_IPS = ['85.11.167.243', '45.198.224.169', '109.71.252.97', '221.14.159.134', '91.90.123.25']
IOC_LOG_REGEX = ('^(' + '|'.join(ip.replace('.', '[.]') for ip in IOC_IPS) + ') '
                 '|php2shell|p2s9rdme2st6guc9g0rafic|xqsuvwz3n97wu8j3emz1axuo5y|PWNED::')
IOC_SESSION = 'sess_xqsuvwz3n97wu8j3emz1axuo5y'
WEBSHELL_REGEX = r'PWNED::|system\(\s*\$_(GET|POST|REQUEST)\['
CRASH_REGEX = r'exited on signal 11|exit signal Segmentation fault|Out of memory \(allocated'
WAF_HIT_REGEX = r'CVE-2026-10961[89]|php2shell'
WEB_PARENTS = ('php-fpm', 'php-cgi', 'lsphp', 'httpd', 'apache2', 'litespeed', 'lshttpd', 'frankenphp')
SUSPICIOUS_CHILD = ('sh', 'bash', 'dash', 'curl', 'wget', 'python', 'python2', 'python3',
                    'perl', 'nc', 'ncat', 'socat', 'busybox')
PACKAGE_MANAGERS = ('yum', 'dnf', 'rpm', 'apt-get', 'apt', 'dpkg', 'unattended-upgr')
# Собственият PHP на cPanel (/usr/local/cpanel/3rdparty). 81 = backport за тир 110.
CPANEL_INTERNAL_FIXED = {'81':'8.1.34-2', '82':'8.2.35', '83':'8.3.36', '84':'8.4.27', '85':'8.5.12'}

def epoch_of(day):
    return time.mktime(time.strptime(day, '%Y-%m-%d'))

def day_tokens(since):
    # Дати във форматите на Apache, PHP-FPM, LiteSpeed и access логовете.
    tokens = []
    t = epoch_of(since)
    while t <= time.time() + 86400:
        lt = time.localtime(t)
        for fmt in ('%d-%b-%Y', '%Y-%m-%d', '%b %d ', '%d/%b/%Y'):
            tokens.append(time.strftime(fmt, lt))
        t += 86400
    return tokens

def user_name(uid):
    try:
        return pwd.getpwuid(uid).pw_name
    except (KeyError, TypeError):
        return str(uid)

def age_text(seconds):
    days, rest = divmod(int(seconds), 86400)
    return ('%dд %dч' % (days, rest // 3600)) if days else ('%dч %dмин' % (rest // 3600, (rest % 3600) // 60))

def account_of(path):
    m = re.match(r'^/home\d*/([^/]+)/', path)
    return m.group(1) if m else '?'

def proc_table():
    procs = {}
    try:
        clk = float(os.sysconf(str('SC_CLK_TCK')))
    except (ValueError, OSError):
        clk = 100.0
    try:
        uptime = float(read('/proc/uptime').split()[0])
    except (IndexError, ValueError):
        uptime = 0.0
    for d in glob.glob('/proc/[0-9]*'):
        stat = read(d + '/stat')
        m = re.match(r'^(\d+) \((.*)\) ', stat)
        if not m:
            continue
        rest = stat[stat.rfind(')') + 2:].split()
        try:
            ppid = int(rest[1])
            start = int(rest[19]) / clk
            uid = os.stat(d).st_uid
        except (IndexError, ValueError, OSError):
            continue
        procs[int(m.group(1))] = {'pid':int(m.group(1)), 'comm':m.group(2), 'ppid':ppid, 'uid':uid,
                                  'age':max(0.0, uptime - start),
                                  'cmd':read(d + '/cmdline').replace('\x00', ' ').strip()[:300]}
    return procs

def recent_files(patterns, since_epoch, exclude=None):
    files = set()
    for pattern in patterns:
        for path in glob.glob(pattern):
            if exclude and re.search(exclude, path):
                continue
            try:
                st = os.stat(path)
            except (OSError, UnicodeError, ValueError):
                continue
            if stat_mod.S_ISREG(st.st_mode) and st.st_mtime >= since_epoch:
                files.add(os.path.realpath(path))
    return sorted(files)

def grep_files(regex, files, timeout=300, count=True):
    # Връща ({файл: брой редове}, пълно ли е сканирането).
    found, start = {}, time.time()
    for i in range(0, len(files), 200):
        left = timeout - (time.time() - start)
        if left <= 5:
            return found, False
        args = ['grep', '-E', '-a', '-s'] + (['-c', '-H'] if count else ['-l']) + ['-e', regex, '--'] + files[i:i+200]
        rc, out, err = run(args, int(left))
        if rc == 124:
            return found, False
        for line in out.splitlines():
            if count:
                path, _, n = line.rpartition(':')
                if n.isdigit() and int(n) > 0:
                    found[path] = int(n)
            elif line:
                found[line] = 1
    return found, True

def grep_lines_since(regex, files, tokens, timeout=120):
    lines, complete = [], True
    for path in files:
        rc, out, err = run(['grep', '-E', '-a', '-s', '-m', '200000', '-e', regex, '--', path], timeout)
        if rc == 124:
            complete = False
        for line in out.splitlines():
            if any(t in line for t in tokens):
                lines.append((path, line[:300]))
    return lines, complete

def web_roots(sites):
    roots = set()
    for s in sites:
        d = s.get('documentroot') or ''
        try:
            if d.startswith('/') and os.path.isdir(d):
                roots.add(os.path.realpath(d))
        except (UnicodeError, ValueError):
            continue
    if not roots:
        roots.update(os.path.realpath(p) for p in glob.glob('/home*/*/public_html') if os.path.isdir(p))
    result = []
    for r in sorted(roots):
        if not any(r.startswith(o.rstrip('/') + '/') for o in result):
            result.append(r)
    return result

def family_states(packages):
    fams = {}
    for p in packages:
        if core_package(p['name']):
            fams.setdefault(family(p['name']), []).append(p)
    states = {}
    for fam, pk in fams.items():
        worst = max([p['patch_status'] for p in pk], key=lambda st: PATCH_RANK.get(st, 4))
        if any(p['update'] == 'AVAILABLE' for p in pk):
            upd = 'OUTDATED'
        elif all(p['update'] == 'NO_UPDATE_OFFERED' for p in pk):
            upd = 'LATEST'
        else:
            upd = 'UNKNOWN'
        states[fam] = {'patch_status':worst, 'update_state':upd}
    return states

def audit_cpanel_php(backend):
    result = {'packages':[], 'binary':None}
    if backend == 'rpm':
        rc, out, err = run(['rpm', '-qa', '--qf', '%{NAME}\t%{VERSION}-%{RELEASE}\n', 'cpanel-php*'])
    elif backend == 'deb':
        rc, out, err = run(['dpkg-query', '-W', '-f=${Package}\t${Version}\n', 'cpanel-php*'])
    else:
        rc, out = 1, ''
    fams = {}
    for line in out.splitlines() if rc == 0 else []:
        fields = line.split('\t')
        m = re.match(r'^cpanel-php(\d+)', fields[0])
        if m and len(fields) == 2 and fields[1]:
            fams.setdefault(m.group(1), []).append(fields)
    for br in sorted(fams):
        name, version = sorted(fams[br], key=lambda f: len(f[0]))[0]
        log = ''
        if backend == 'rpm':
            rc, log, err = run(['rpm', '-q', '--changelog', name])
        evidence = all(cve in log for cve in CVES)
        floor = CPANEL_INTERNAL_FIXED.get(br)
        c = evr_cmp(version, floor) if floor else None
        if (c is not None and c >= 0) or evidence:
            status = 'PATCHED'
        elif c is not None:
            status = 'AFFECTED'
        else:
            status = 'UNKNOWN'
        result['packages'].append({'family':'cpanel-php' + br, 'package':name, 'version':version,
                                   'fixed_in':floor, 'status':status})
    rc, out, err = run(['/usr/local/cpanel/3rdparty/bin/php', '-v'], 20)
    if rc == 0 and out.strip():
        result['binary'] = out.strip().splitlines()[0][:80]
    return result

def audit_blockers(backend, procs):
    items = []
    def add(kind, detail):
        items.append({'kind':kind, 'detail':detail[:300]})
    if backend == 'rpm':
        for manager in (which('dnf'), which('yum')):
            if not manager:
                continue
            rc, out, err = run([manager, '-q', 'versionlock', 'list'], 90)
            if rc == 0:
                for line in out.splitlines():
                    if re.search(r'php|imunify', line, re.I):
                        add('versionlock', line.strip())
                break
        for path in ['/etc/yum.conf', '/etc/dnf/dnf.conf'] + sorted(glob.glob('/etc/yum.repos.d/*.repo')):
            section = ''
            for line in read(path).splitlines():
                s = re.match(r'^\s*\[([^\]]+)\]', line)
                if s:
                    section = s.group(1)
                    continue
                m = re.match(r'^\s*exclude(?:pkgs)?\s*=\s*(.+)$', line)
                if m and (re.search(r'php|imunify', m.group(1), re.I) or re.search(r'(^|[\s,])\*($|[\s,])', m.group(1))):
                    add('exclude', '%s [%s]: exclude=%s' % (path, section or 'main', m.group(1).strip()))
                e = re.match(r'^\s*enabled\s*=\s*0\s*$', line)
                if e and re.search(r'ea4|alt-php|imunify', section, re.I):
                    add('repo_disabled', '%s: хранилището [%s] е изключено (enabled=0)' % (path, section))
    elif backend == 'deb':
        rc, out, err = run(['apt-mark', 'showhold'], 60)
        for line in out.splitlines() if rc == 0 else []:
            if re.search(r'php|imunify', line, re.I):
                add('hold', 'apt-mark hold: ' + line.strip())
        for path in glob.glob('/etc/apt/preferences.d/*') + ['/etc/apt/preferences']:
            text = read(path)
            if re.search(r'php', text, re.I) and re.search(r'Pin-Priority:\s*-\d', text):
                add('pin', '%s: отрицателен Pin-Priority за PHP пакет' % path)
    conf = read('/etc/cpupdate.conf')
    for key, meaning in (('RPMUP', 'upcp не обновява системните пакети'), ('UPDATES', 'cPanel не се обновява автоматично')):
        m = re.search(r'^%s=(\S+)' % key, conf, re.M)
        if m and m.group(1).lower() in ('never', 'manual'):
            add('cpupdate', '/etc/cpupdate.conf: %s=%s (%s)' % (key, m.group(1), meaning))
    for p in sorted(procs.values(), key=lambda p: -p['age']):
        if p['comm'] in PACKAGE_MANAGERS and p['age'] > 3600:
            add('stale_process', 'PID %d (%s) работи от %s: %s' % (p['pid'], p['comm'], age_text(p['age']), p['cmd'][:150]))
    return {'items':items}

def audit_selector(fam_patch):
    sel = which('selectorctl')
    if not sel:
        return None
    rc, out, err = run([sel, '--list'], 30)
    versions = []
    for line in out.splitlines() if rc == 0 else []:
        m = re.match(r'^\s*(\d+\.\d+)\b', line)
        if m and m.group(1) not in versions:
            versions.append(m.group(1))
    rows = []
    for v in versions:
        fam = 'alt-php' + v.replace('.', '')
        rc, out, err = run([sel, '--list-users', '--version=' + v], 90)
        users = None
        if rc == 0:
            users = sorted(set(u for u in re.split(r'[\s,]+', out.strip()) if re.match(r'^[A-Za-z0-9_.-]+$', u)))
        rows.append({'version':v, 'family':fam, 'status':fam_patch.get(fam, 'UNKNOWN'), 'accounts':users})
    return rows

def upload_progress_values(fam_patch):
    values = {}
    for fam in sorted(fam_patch):
        m = re.match(r'^(ea|alt)-php(\d+)$', fam)
        if not m:
            continue
        binary = ('/opt/cpanel/%s/root/usr/bin/php' % fam) if m.group(1) == 'ea' else ('/opt/alt/php%s/usr/bin/php' % m.group(2))
        if not os.access(binary, os.X_OK):
            continue
        rc, out, err = run([binary, '-r', 'echo ini_get("session.upload_progress.enabled") ? "1" : "0";'], 20)
        values[fam] = out.strip()[-1:] if rc == 0 and out.strip()[-1:] in ('0', '1') else '?'
    return values

def custom_php_binaries():
    found = []
    candidates = glob.glob('/home*/*/bin/php*') + glob.glob('/usr/local/bin/php*') + glob.glob('/opt/*/bin/php*') + glob.glob('/opt/*/*/bin/php*')
    for path in sorted(set(candidates)):
        real = os.path.realpath(path)
        if real.startswith(('/opt/cpanel/', '/opt/alt/', '/usr/local/cpanel/', '/usr/bin/')) or not os.path.isfile(real):
            continue
        if not os.access(real, os.X_OK) or not re.match(r'^php[0-9.]*(-cgi|-fpm)?$', os.path.basename(path)):
            continue
        rc, out, err = run([real, '-v'], 15)
        found.append({'path':path, 'version':(out.strip().splitlines() or ['?'])[0][:60] if rc == 0 else '?'})
    return found

def scan_htaccess(roots, timeout=180):
    result = {'families':{}, 'upload_progress_overrides':[], 'files':0, 'complete':True}
    if not roots:
        return result
    rc, out, err = run(['find'] + roots + ['-xdev', '-type', 'f', '-name', '.htaccess', '-size', '-256k', '-print'], timeout)
    result['complete'] = rc != 124
    files = out.splitlines()[:50000]
    result['files'] = len(files)
    for path in files:
        text = read(path)
        for fam in set(re.findall(r'x-httpd-((?:ea|alt)-php\d+)', text)):
            result['families'][fam] = result['families'].get(fam, 0) + 1
        if re.search(r'php_(?:admin_)?(?:value|flag)\s+session\.upload_progress', text):
            result['upload_progress_overrides'].append(path)
    return result

def audit_runtime(report, fam_patch, procs, no_scan):
    rt = {'selector':audit_selector(fam_patch), 'stale':[], 'custom_php':custom_php_binaries(),
          'upload_progress':upload_progress_values(fam_patch), 'htaccess':None}
    groups = {}
    for hint in report['restart_hints']:
        p = procs.get(int(hint['pid']), {})
        g = groups.setdefault(hint['executable'], {'executable':hint['executable'], 'pids':0, 'users':set()})
        g['pids'] += 1
        g['users'].add(user_name(p.get('uid')))
    rt['stale'] = [{'executable':g['executable'], 'pids':g['pids'], 'users':sorted(g['users'])}
                   for g in sorted(groups.values(), key=lambda g: g['executable'])]
    vulnerable = set()
    for row in rt['selector'] or []:
        if row['status'] in VULNERABLE and row['accounts']:
            vulnerable.update(row['accounts'])
    rt['selector_vulnerable_accounts'] = sorted(vulnerable)
    if not no_scan:
        print('Сканиране на .htaccess файловете...', file=sys.stderr)
        rt['htaccess'] = scan_htaccess(web_roots(report['sites']))
    return rt

def imunify_malware_since(since_epoch):
    agent = which('imunify360-agent')
    if not agent:
        return None
    data = json_command([agent, 'malware', 'malicious', 'list', '--json'], 180)
    if data is None:
        return None
    hits = []
    for obj in objects(data):
        path = obj.get('file') or obj.get('path')
        if not hasattr(path, 'startswith'):
            continue
        stamps = [obj.get(k) for k in ('created', 'detected', 'scan_date', 'ctime', 'timestamp')]
        stamps = [float(s) for s in stamps if isinstance(s, (int, float)) or (hasattr(s, 'isdigit') and s.isdigit())]
        if stamps and max(stamps) >= since_epoch:
            hits.append(path)
    return sorted(set(hits))

def audit_exposure(report, procs, since, no_scan):
    since_epoch = epoch_of(since)
    tokens = day_tokens(since)
    ex = {'since':since, 'complete':True, 'scanned':not no_scan}
    # Признаци на компрометиране
    ex['processes'] = []
    for p in procs.values():
        parent = procs.get(p['ppid'])
        if not parent or p['uid'] == 0 or p['comm'] not in SUSPICIOUS_CHILD:
            continue
        if not parent['comm'].startswith(WEB_PARENTS):
            continue
        if re.search(r'sendmail|exim|splitlogs|rotatelogs|/usr/local/cpanel/', p['cmd']):
            continue
        ex['processes'].append({'pid':p['pid'], 'user':user_name(p['uid']), 'parent':parent['comm'],
                                'age':age_text(p['age']), 'cmd':p['cmd'][:200]})
    session_globs = ['/var/cpanel/php/sessions/*/', '/tmp/', '/home*/*/tmp/', '/var/lib/php/session/', '/var/lib/php/sessions/']
    ex['sessions'] = sorted(set(sum([glob.glob(g + IOC_SESSION) for g in session_globs], [])))
    ex['malware'] = imunify_malware_since(since_epoch)
    # За преглед
    ex['cron_changed'] = [os.path.basename(p) for p in recent_files(['/var/spool/cron/*', '/var/spool/cron/crontabs/*'], since_epoch)]
    ex['ssh_keys_changed'] = recent_files(['/home*/*/.ssh/authorized_keys*'], since_epoch)
    error_logs = recent_files(['/etc/apache2/logs/error_log', '/usr/local/apache/logs/error_log',
                               '/usr/local/lsws/logs/error.log', '/opt/cpanel/ea-php*/root/usr/var/log/php-fpm/error.log',
                               '/opt/alt/php*/var/log/php-fpm.log'], since_epoch)
    crashes, ok1 = grep_lines_since(CRASH_REGEX, error_logs, tokens)
    ex['crashes'] = len(crashes)
    ex['crash_samples'] = [line for path, line in crashes[-5:]]
    waf_logs = error_logs + recent_files(['/etc/apache2/logs/modsec_audit.log', '/var/log/apache2/modsec_audit.log',
                                          '/usr/local/apache/logs/modsec_audit.log'], since_epoch)
    waf, ok2 = grep_lines_since(WAF_HIT_REGEX, waf_logs, tokens)
    ex['waf_hits'] = len(waf)
    ex['complete'] = ok1 and ok2
    ex['access_log_hits'] = ex['webshells'] = ex['recent_php'] = None
    if no_scan:
        return ex
    print('Сканиране на access логовете за признаци на атака...', file=sys.stderr)
    domlogs = recent_files(['/var/log/apache2/domlogs/*', '/var/log/apache2/domlogs/*/*',
                            '/usr/local/apache/domlogs/*', '/usr/local/apache/domlogs/*/*'], since_epoch,
                           exclude=r'(-bytes_log|\.offset|\.bkup|ftpxferlog|\.gz)$')
    hits, ok3 = grep_files(IOC_LOG_REGEX, domlogs, 600, True)
    ex['access_logs_scanned'] = len(domlogs)
    ex['access_log_hits'] = sorted([{'file':f, 'lines':n} for f, n in hits.items()], key=lambda h: -h['lines'])
    print('Търсене на нови/променени PHP файлове и уеб шелове...', file=sys.stderr)
    roots = web_roots(report['sites'])
    found, ok4 = [], True
    if roots:
        rc, out, err = run(['find'] + roots + ['-xdev', '-type', 'f', '(', '-iname', '*.php', '-o', '-iname', '*.phtml',
                            '-o', '-iname', '*.phar', '-o', '-name', 'p2s.txt', '-o', '-name', 'php2shell-probe-*', ')',
                            '-newermt', since, '-print'], 600)
        ok4 = rc != 124
        found = sorted(set(out.splitlines()))
    per_account = {}
    for path in found:
        per_account[account_of(path)] = per_account.get(account_of(path), 0) + 1
    ex['recent_php'] = {'total':len(found),
                        'per_account':sorted(per_account.items(), key=lambda kv: -kv[1])[:20]}
    php_files = [f for f in found if re.search(r'\.(php|phtml|phar)$', f, re.I)]
    shells, ok5 = grep_files(WEBSHELL_REGEX, php_files, 600, count=False)
    named = [f for f in found if os.path.basename(f) == 'p2s.txt' or os.path.basename(f).startswith('php2shell-probe-')]
    ex['webshells'] = sorted(set(list(shells.keys()) + named))
    ex['complete'] = ex['complete'] and ok3 and ok4 and ok5
    return ex

def exposure_counts(ex):
    compromise = len(ex.get('processes') or []) + len(ex.get('sessions') or []) + \
                 len(ex.get('webshells') or []) + len(ex.get('malware') or [])
    attempts = len(ex.get('access_log_hits') or []) + (1 if ex.get('waf_hits') else 0)
    return compromise, attempts

# ---------------------------------------------------------------------------
# Текстов отчет
# ---------------------------------------------------------------------------
def recommendations(report):
    acts = []
    pk = report['packages']
    s = report['summary']
    cp = report.get('cpanel_status', {})
    if cp.get('status') == 'AFFECTED':
        acts.append('cPanel:   /scripts/upcp --force  (нужен е поне %s)' % cp.get('fixed_in'))
    elif cp.get('status') == 'UNSUPPORTED_TIER':
        acts.append('cPanel:   тирът няма поправка; премини на 110, 134, 136 или 138 и пусни /scripts/upcp')
    need = [p for p in pk if core_package(p['name']) and (p['patch_status'] in VULNERABLE or p['update'] == 'AVAILABLE')]
    if need:
        globs = []
        for p in need:
            m = re.match(r'^(ea-php|alt-php)', p['name'])
            g = (m.group(1) if m else 'php') + '*'
            if g not in globs:
                globs.append(g)
        if report.get('package_backend') == 'rpm':
            mgr = 'dnf' if which('dnf') else 'yum'
            acts.append('Метаданни: %s clean all && %s makecache' % (mgr, mgr))
            acts.append('Обнови:   /scripts/update-packages  (или %s update %s; без --security, там ги няма)' %
                        (mgr, ' '.join("'%s'" % g for g in sorted(globs))))
        else:
            acts.append('Обнови:   apt clean && apt update && /scripts/update-packages')
        acts.append('Рестарт:  /scripts/restartsrv_apache_php_fpm && /scripts/restartsrv_httpd')
        acts.append('Провери:  пусни одита отново след обновяването')
    elif s['restart_hints']:
        acts.append('Рестарт:  /scripts/restartsrv_apache_php_fpm && /scripts/restartsrv_httpd')
    if any(p['patch_status'] == 'NO_VENDOR_FIX' for p in pk):
        acts.append('EOL PHP:  ea-php под 7.4 няма поправка от cPanel; TuxCare ELS или миграция на сайтовете')
    if any(c['name'] == 'ruleset_files' and c['status'] == 'WARN' for c in report['imunify360']['checks']):
        acts.append('Правила:  imunify360-agent update --force modsec-rules  (нужни са >= %s)' % RULESET_REFERENCE)
    if s['unknown_core_packages']:
        acts.append('Ръчно:    сравни пакетите с "?" с бюлетина ' + SOURCE)
    if any(c['status'] == 'FAIL' for c in report['imunify360']['checks']):
        acts.append('WAF:      провери редовете с ✗ в секция IMUNIFY360 / WAF')
    if s['sites_below_74'] or s['sites_on_upstream_eol']:
        acts.append('План:     миграция на сайтовете от EOL PHP версии')
    # Блокирани обновления: без това обновяването няма да мине.
    kinds = set(i['kind'] for i in report.get('blockers', {}).get('items', []))
    fixes = []
    if 'stale_process' in kinds:
        fixes.append('спри висящия yum/dnf/apt процес (виж БЛОКИРАНИ ОБНОВЛЕНИЯ)')
    if 'versionlock' in kinds:
        fixes.append('%s versionlock delete за PHP пакетите' % ('dnf' if which('dnf') else 'yum'))
    if kinds & set(['exclude', 'repo_disabled', 'hold', 'pin']):
        fixes.append('махни exclude/hold/pin или включи хранилището')
    if 'cpupdate' in kinds:
        fixes.append('провери /etc/cpupdate.conf (RPMUP/UPDATES)')
    if fixes:
        acts.insert(0, 'Първо:    ' + '; '.join(fixes))
    if s.get('cpanel_php_vulnerable') and not any(a.startswith('cPanel:') for a in acts):
        acts.append('cPanel:   собственият PHP на cPanel е уязвим: /scripts/upcp --force')
    rt = report.get('runtime', {})
    if rt.get('selector_vulnerable_accounts'):
        acts.append('Selector: %d акаунта ползват уязвим alt-php; обнови alt-php (yum update \'alt-php*\')' % len(rt['selector_vulnerable_accounts']))
    vuln_fams = [f for f, v in report.get('php_families', {}).items() if v['patch_status'] in VULNERABLE]
    open_fams = [f for f in vuln_fams if rt.get('upload_progress', {}).get(f) != '0']
    if open_fams:
        acts.append('Временно: докато обновиш, session.upload_progress.enabled = Off за %s (MultiPHP INI Editor), после рестарт на PHP-FPM' % ', '.join(sorted(open_fams)))
    if rt.get('stale') and not any(a.startswith('Рестарт:') for a in acts):
        acts.append('Рестарт:  /scripts/restartsrv_apache_php_fpm && /scripts/restartsrv_httpd')
    compromise, attempts = exposure_counts(report.get('exposure', {}))
    if compromise:
        acts.insert(0, 'АТАКА:    запази логовете, провери файловете от СЛЕДИ ОТ АТАКА, смени паролите и DB паролите на засегнатите акаунти')
    elif attempts:
        acts.append('Опити:    има сканиране/опити за атака; обнови и провери засегнатите домейни от СЛЕДИ ОТ АТАКА')
    return acts

def more_line(out, total, shown):
    if total > shown:
        out.append(paint('    ... и още %d (пълен списък с --details)' % (total - shown), 'gray'))

def render_blockers(out, report):
    items = report.get('blockers', {}).get('items', [])
    out.append('')
    out.append(paint('БЛОКИРАНИ ОБНОВЛЕНИЯ', 'bold'))
    if not items:
        out.append('  %s Няма versionlock, exclude, изключени хранилища или висящи yum/dnf/apt процеси' % paint('✓', 'green'))
    for item in items:
        out.append('  %s %s' % (paint('✗', 'red'), item['detail']))

def render_runtime(out, report, details):
    rt = report.get('runtime') or {}
    fams = report.get('php_families', {})
    limit = None if details else 8
    out.append('')
    out.append(paint('РЕАЛНО ИЗПОЛЗВАН PHP', 'bold'))
    sel = rt.get('selector')
    if sel is None:
        out.append('  CloudLinux PHP Selector: няма (не е CloudLinux)')
    else:
        used = [r for r in sel if r['accounts']]
        out.append('  CloudLinux PHP Selector (важи за домейни на системната PHP версия):')
        if not used:
            out.append('    няма акаунти с избрана alt-php версия')
        for r in used:
            mark, color = PATCH_MARK.get(r['status'], ('?', 'yellow'))
            names = r['accounts'] if details else r['accounts'][:6]
            out.append('    %s %-10s %4d акаунта  %s  %s%s' % (paint(mark, color), r['family'], len(r['accounts']),
                       paint('%-20s' % PATCH_LABEL.get(r['status'], r['status']), color), ', '.join(names),
                       ' ...' if len(names) < len(r['accounts']) else ''))
        for r in sel:
            if r['accounts'] is None:
                out.append(paint('    ? %s: списъкът с акаунти не може да бъде прочетен' % r['family'], 'gray'))
    ht = rt.get('htaccess')
    if ht is None:
        out.append(paint('  .htaccess: не е сканирано (--no-scan)', 'gray'))
    else:
        out.append('  PHP handler в .htaccess (%d файла сканирани):' % ht['files'])
        if not ht['families']:
            out.append('    няма зададени ea-php/alt-php handler-и')
        for fam in sorted(ht['families']):
            status = fams.get(fam, {}).get('patch_status', 'NOT_INSTALLED')
            mark, color = PATCH_MARK.get(status, ('?', 'yellow'))
            label = PATCH_LABEL.get(status, 'НЕ Е ИНСТАЛИРАН' if status == 'NOT_INSTALLED' else status)
            out.append('    %s %-10s %5d файла  %s' % (paint(mark, color), fam, ht['families'][fam], paint(label, color)))
        if ht['upload_progress_overrides']:
            out.append(paint('    ! %d .htaccess задават session.upload_progress (засяга CVE-2026-109619):' % len(ht['upload_progress_overrides']), 'yellow'))
            shown = ht['upload_progress_overrides'][:limit] if limit else ht['upload_progress_overrides']
            for path in shown:
                out.append('      %s' % path)
            more_line(out, len(ht['upload_progress_overrides']), len(shown))
    stale = rt.get('stale') or []
    if stale:
        out.append(paint('  Процеси със стар (изтрит) PHP/Apache файл, нужен е рестарт:', 'yellow'))
        for g in stale:
            users = g['users'] if details else g['users'][:6]
            out.append('    %s %s | %d процеса | %s%s' % (paint('!', 'yellow'), g['executable'].replace(' (deleted)', ''), g['pids'],
                       ', '.join(users), ' ...' if len(users) < len(g['users']) else ''))
    else:
        out.append('  %s Няма работещи PHP/Apache процеси със стари файлове' % paint('✓', 'green'))
    vulnerable = [f for f, v in fams.items() if v['patch_status'] in VULNERABLE]
    up = rt.get('upload_progress') or {}
    if vulnerable:
        parts = []
        for f in sorted(vulnerable):
            value = up.get(f, '?')
            parts.append('%s=%s' % (f, {'1':paint('On', 'yellow'), '0':paint('Off', 'green')}.get(value, '?')))
        out.append('  session.upload_progress.enabled на уязвимите версии (CLI php.ini): ' + ', '.join(parts))
    custom = rt.get('custom_php') or []
    if custom:
        out.append(paint('  Собствени PHP билдове извън ea-php/alt-php (провери ръчно):', 'yellow'))
        for c in custom:
            out.append('    ? %s  %s' % (c['path'], c['version']))

def render_exposure(out, report, details):
    ex = report.get('exposure') or {}
    limit = None if details else 8
    out.append('')
    out.append(paint('СЛЕДИ ОТ АТАКА (от %s)' % ex.get('since', '?'), 'bold'))
    def listing(title, items, color, fmt):
        out.append('  %s %s: %d' % (paint('✗' if color == 'red' else '!', color), title, len(items)))
        shown = items if limit is None else items[:limit]
        for item in shown:
            out.append('    ' + fmt(item))
        more_line(out, len(items), len(shown))
    compromise, attempts = exposure_counts(ex)
    out.append(paint('  Признаци на компрометиране:', 'bold'))
    if not compromise:
        out.append('    %s няма открити (уеб шелове, IoC сесии, shell процеси от PHP, нов malware)' % paint('✓', 'green'))
    if ex.get('webshells'):
        listing('Уеб шелове / файлове от exploit-а', ex['webshells'], 'red', lambda p: p)
    if ex.get('sessions'):
        listing('Сесия от exploit-а (%s)' % IOC_SESSION, ex['sessions'], 'red', lambda p: p)
    if ex.get('processes'):
        listing('Shell/мрежови процеси, стартирани от PHP/уеб сървъра', ex['processes'], 'red',
                lambda p: 'PID %d %s (родител %s, %s): %s' % (p['pid'], p['user'], p['parent'], p['age'], p['cmd']))
    if ex.get('malware'):
        listing('Нов malware от Imunify360', ex['malware'], 'red', lambda p: p)
    out.append(paint('  Опити за атака:', 'bold'))
    hits = ex.get('access_log_hits')
    if hits is None:
        out.append(paint('    access логовете не са сканирани (--no-scan)', 'gray'))
    elif hits:
        listing('Домейни със заявки от скенери/exploit (IP, User-Agent, boundary)', hits, 'yellow',
                lambda h: '%-60s %d реда' % (os.path.basename(h['file']), h['lines']))
    else:
        out.append('    %s няма заявки с известните признаци в %d access лога' % (paint('✓', 'green'), ex.get('access_logs_scanned', 0)))
    if ex.get('waf_hits'):
        out.append('    %s WAF записи за CVE-2026-109618/109619 в логовете: %d' % (paint('!', 'yellow'), ex['waf_hits']))
    out.append(paint('  За преглед (може да е нормална дейност):', 'bold'))
    rp = ex.get('recent_php')
    if rp is None:
        out.append(paint('    PHP файловете не са сканирани (--no-scan)', 'gray'))
    else:
        top = ', '.join('%s (%d)' % kv for kv in rp['per_account'][:(20 if details else 5)])
        out.append('    Нови/променени PHP файлове: %d%s' % (rp['total'], (' | най-много: ' + top) if top else ''))
    if ex.get('crashes'):
        out.append('    Сривове/OOM на PHP в логовете: %d' % ex['crashes'])
        for line in ex.get('crash_samples', [])[:3]:
            out.append(paint('      ' + line[:160], 'gray'))
    if ex.get('cron_changed'):
        out.append('    Променени crontab-ове: %s' % ', '.join(ex['cron_changed'][:(None if details else 10)]))
    if ex.get('ssh_keys_changed'):
        out.append('    Променени authorized_keys: %s' % ', '.join(account_of(p) for p in ex['ssh_keys_changed']))

def render_text(report, details):
    packages, sites = report['packages'], report['sites']
    waf = report.get('imunify360', {'checks':[]})
    summary = report['summary']
    warnings, errors = report['warnings'], report['errors']
    site_key = lambda s: (s.get('account', ''), s.get('domain', ''))
    out = []
    bar = '═' * 78
    out.append(paint(bar, 'cyan'))
    out.append(paint(' cPanel PHP одит %s | %s | %s' % (VERSION, report['hostname'], report['utc']), 'bold'))
    out.append(' ОС: %s' % report['os'].get('PRETTY_NAME', '?'))
    out.append(' Бюлетин: %s (към %s) | Хранилища: %s' % (', '.join(CVES), SNAPSHOT,
               REPO_LABEL.get(report['repository_status'], report['repository_status'])))
    out.append(paint(bar, 'cyan'))

    # --- PHP версии, групирани по семейство ---
    families, extensions = {}, 0
    for p in packages:
        if core_package(p['name']):
            families.setdefault(family(p['name']), []).append(p)
        else:
            extensions += 1
    site_count = {}
    for s in sites:
        site_count[s['php']] = site_count.get(s['php'], 0) + 1
    cp = report.get('cpanel_status', {'status':'UNKNOWN', 'version':report['cpanel'], 'fixed_in':None})
    cp_mark, cp_color = PATCH_MARK.get(cp['status'], ('✗', 'red')) if cp['status'] != 'UNSUPPORTED_TIER' else ('✗', 'red')
    out.append('')
    out.append(paint('cPANEL & WHM', 'bold'))
    out.append('  %s %-27s поправено от: %-18s %s' % (paint(cp_mark, cp_color), cp['version'],
               cp['fixed_in'] or '-', paint(CPANEL_LABEL.get(cp['status'], cp['status']), cp_color)))
    cpp = report.get('cpanel_php', {})
    for r in cpp.get('packages', []):
        mark, color = PATCH_MARK.get(r['status'], ('?', 'yellow'))
        out.append('  %s %-27s поправено от: %-18s %s' % (paint(mark, color), '%s %s' % (r['family'], r['version']),
                   r['fixed_in'] or '-', paint(PATCH_LABEL.get(r['status'], r['status']), color)))
    if cpp.get('binary'):
        out.append(paint('    PHP на WHM/cPanel интерфейса: %s' % cpp['binary'], 'gray'))
    render_blockers(out, report)
    out.append('')
    out.append(paint('PHP ВЕРСИИ (основни пакети)', 'bold'))
    if not families:
        out.append('  Няма открити основни PHP пакети.')
    else:
        out.append(paint('    %-11s %-27s %-27s %-20s %-29s %s' % ('Версия', 'Инсталирана', 'Поправено от', 'CVE статус', 'Последна версия?', 'Сайтове'), 'gray'))
    fam_state = {}
    for fam in sorted(families):
        pk = families[fam]
        worst = max([p['patch_status'] for p in pk], key=lambda st: PATCH_RANK.get(st, 4))
        pref = [fam + '-php-cli', fam + '-php-common', fam + '-cli', fam + '-common', fam]
        main_pkg = sorted(pk, key=lambda p: pref.index(p['name']) if p['name'] in pref else 99)[0]
        offered = [p for p in pk if p['update'] == 'AVAILABLE']
        if offered:
            target = main_pkg if main_pkg['update'] == 'AVAILABLE' else offered[0]
            update, ucolor = '→ %s' % target['candidate'], 'yellow'
            fam_state[fam] = 'OUTDATED'
        elif all(p['update'] == 'NO_UPDATE_OFFERED' for p in pk):
            update, ucolor = UPDATE_LABEL['NO_UPDATE_OFFERED'], 'green'
            fam_state[fam] = 'LATEST'
        else:
            update, ucolor = UPDATE_LABEL['UNKNOWN'], 'gray'
            fam_state[fam] = 'UNKNOWN'
        fixed = main_pkg.get('fixed_in') or next((p.get('fixed_in') for p in pk if p.get('fixed_in')), None) or '-'
        ver = branch(fam)
        count = site_count.get(fam, 0)
        sites_txt = ('%d' % count if count else '-') + (' (EOL)' if ver and ver <= (8, 1) else '')
        mark, color = PATCH_MARK.get(worst, ('?', 'yellow'))
        out.append('  %s %-11s %-27s %-27s %s %s %s' % (paint(mark, color), fam, main_pkg['version'][:27], fixed[:27],
                   paint('%-20s' % PATCH_LABEL.get(worst, worst), color), paint('%-29s' % update[:29], ucolor), sites_txt))
    if any(p['patch_status'] == 'NO_FIX_IN_CHANGELOG' for p in packages):
        out.append(paint('  "УЯЗВИМ (няма CVE)": CloudLinux не сменя PHP версията при backport; поправката се вижда само в changelog.', 'gray'))
    if extensions and not details:
        out.append(paint('  (+%d пакета разширения не са показани; пълен списък с --details)' % extensions, 'gray'))
    if details and packages:
        out.append('')
        out.append(paint('ВСИЧКИ PHP ПАКЕТИ', 'bold'))
        for p in sorted(packages, key=lambda p: p['name']):
            mark, color = PATCH_MARK.get(p['patch_status'], ('?', 'yellow'))
            upd = ('→ %s' % p['candidate']) if p['update'] == 'AVAILABLE' else UPDATE_LABEL.get(p['update'], p['update'])
            out.append('  %s %-42s %-27s %-31s %s' % (paint(mark, color), '%s.%s' % (p['name'], p['arch']),
                       p['version'], upd, PATCH_LABEL.get(p['patch_status'], p['patch_status'])))

    # --- Сайтове ---
    affected = sorted([s for s in sites if s['patch_status'] == 'AFFECTED'], key=site_key)
    below = sorted([s for s in sites if s['status'] == 'BELOW_7_4'], key=site_key)
    unresolved = sorted([s for s in sites if s['status'] == 'UNKNOWN'], key=site_key)
    out.append('')
    out.append(paint('САЙТОВЕ (vhost-ове от MultiPHP)', 'bold'))
    out.append('  Общо %d | на уязвим PHP: %d | под 7.4: %d | на EOL PHP (<= 8.1): %d | неустановен PHP: %d' % (
               len(sites), len(affected), len(below), summary['sites_on_upstream_eol'], len(unresolved)))
    for title, group, color in (('На уязвим PHP', affected, 'red'), ('Под PHP 7.4', below, 'yellow'),
                                ('С неустановена PHP версия', unresolved, 'gray')):
        if not group:
            continue
        shown = group if details else group[:8]
        out.append('  %s:' % title)
        for s in shown:
            out.append('    %s %-40s %s / %s' % (paint('•', color), s['domain'], s['account'], s['php']))
        if len(group) > len(shown):
            out.append(paint('    ... и още %d (пълен списък с --details)' % (len(group) - len(shown)), 'gray'))
    if any(s.get('runtime_php_status') for s in sites):
        out.append(paint('  ! CloudLinux PHP Selector може да замества MultiPHP: реалният PHP по акаунт не е потвърден.', 'yellow'))
    if details and sites:
        out.append('  Всички vhost-ове:')
        for s in sorted(sites, key=site_key):
            mark, color = PATCH_MARK.get(s['patch_status'], ('?', 'yellow'))
            out.append('    %s %-40s %-14s %-12s %s%s' % (paint(mark, color), s['domain'], s['account'], s['php'],
                       PATCH_LABEL.get(s['patch_status'], s['patch_status']), ' (EOL)' if s.get('upstream_eol') else ''))

    render_runtime(out, report, details)

    # --- Imunify360 / WAF ---
    checks = waf.get('checks', [])
    passed = sum(c['status'] == 'PASS' for c in checks)
    agent_lines = (waf.get('agent_version') or '').splitlines()
    out.append('')
    out.append(paint('IMUNIFY360 / WAF', 'bold'))
    out.append('  %d от %d проверки са ОК%s' % (passed, len(checks),
               (' | агент: %s' % agent_lines[0][:40]) if agent_lines and agent_lines[0] != 'UNKNOWN' else ''))
    for c in checks:
        if c['status'] == 'PASS' and not details:
            continue
        mark, color = CHECK_MARK.get(c['status'], ('?', 'gray'))
        out.append('  %s %-30s %s' % (paint(mark, color), CHECK_LABEL.get(c['name'], c['name']), short(c['detail'], 110)))
    off_domains = sorted([d for d in waf.get('domains', []) if d['enabled'] is False], key=site_key)
    if off_domains:
        shown = off_domains if details else off_domains[:8]
        out.append('  ModSecurity е изключен за:')
        for d in shown:
            out.append('    %s %s (%s)' % (paint('•', 'red'), d['domain'], d['account']))
        if len(off_domains) > len(shown):
            out.append(paint('    ... и още %d (пълен списък с --details)' % (len(off_domains) - len(shown)), 'gray'))
    if details:
        for r in waf.get('disabled_rules', []):
            out.append('  Изключено правило: %s' % json.dumps(r, sort_keys=True))
        for d in waf.get('engine_and_exclusion_directives', []):
            out.append('  %s:%d  %s' % (d['file'], d['line'], d['directive']))
        for item in waf.get('limits', []):
            out.append(paint('  Ограничение: ' + item, 'gray'))

    render_exposure(out, report, details)

    # --- Рестарт, бележки, непотвърдени точки ---
    if details and report['restart_hints']:
        out.append('')
        out.append(paint('ПРОЦЕСИ СЪС СТАР ИЗПЪЛНИМ ФАЙЛ (нужен рестарт)', 'bold'))
        for hint in report['restart_hints']:
            out.append('  PID %s  %s' % (hint['pid'], hint['executable']))
    if details and warnings:
        out.append('')
        out.append(paint('БЕЛЕЖКИ', 'bold'))
        for w in warnings:
            out.append('  • ' + w)
    if errors:
        out.append('')
        out.append(paint('НЕПОТВЪРДЕНИ ТОЧКИ', 'bold'))
        for e in errors:
            out.append('  ? ' + (e if details else short(e)))

    # --- Обобщение ---
    affected_fams = sorted(set(family(p['name']) for p in packages if p['patch_status'] in VULNERABLE))
    latest = sorted(f for f in fam_state if fam_state[f] == 'LATEST')
    outdated = sorted(f for f in fam_state if fam_state[f] == 'OUTDATED')
    unchecked = sorted(f for f in fam_state if fam_state[f] == 'UNKNOWN')
    if outdated:
        latest_txt = paint('%d от %d PHP версии НЕ са на последна версия: %s' % (len(outdated), len(fam_state), ', '.join(outdated)), 'yellow')
    elif unchecked:
        latest_txt = paint('%d от %d PHP версии не са проверени (%s)' % (len(unchecked), len(fam_state), ', '.join(unchecked)), 'yellow')
    elif fam_state:
        latest_txt = paint('всички %d PHP версии са на последна версия' % len(fam_state), 'green')
    else:
        latest_txt = '-'
    unknown_fams = sorted(set(family(p['name']) for p in packages if p['patch_status'] == 'UNKNOWN'))
    wc = {}
    for c in checks:
        wc[c['status']] = wc.get(c['status'], 0) + 1
    compromise, attempts = exposure_counts(report.get('exposure', {}))
    if compromise:
        verdict = ('✗ ИМА ПРИЗНАЦИ НА КОМПРОМЕТИРАНЕ, ПРОВЕРИ ВЕДНАГА', 'red')
    elif (summary['affected_core_packages'] or summary['configured_sites_with_affected_packages']
          or summary['cpanel_not_patched'] or summary.get('cpanel_php_vulnerable')):
        verdict = ('✗ ИМА УЯЗВИМИ ПАКЕТИ, НУЖНО Е ОБНОВЯВАНЕ', 'red')
    elif summary['unknown_core_packages']:
        verdict = ('? СТАТУСЪТ НЕ Е ПОТВЪРДЕН ЗА ЧАСТ ОТ ПАКЕТИТЕ', 'yellow')
    elif report['exit_code'] == 2:
        verdict = ('? НЯМА ОТКРИТИ УЯЗВИМИ, НО ПРОВЕРКАТА Е НЕПЪЛНА', 'yellow')
    elif report['exit_code'] == 1:
        verdict = ('! НЯМА УЯЗВИМИ ПАКЕТИ, ИМА ЗАБЕЛЕЖКИ', 'yellow')
    else:
        verdict = ('✓ НЕ СА ОТКРИТИ ПРОБЛЕМИ', 'green')
    repo_ok = report['repository_status'] == 'QUERIED_ENABLED_REPOSITORIES'
    rows = [
        ('Резултат', paint(verdict[0], verdict[1])),
        ('cPanel/WHM', paint('%s: %s' % (cp['version'], CPANEL_LABEL.get(cp['status'], cp['status'])), cp_color)),
        ('Актуалност', latest_txt),
        ('Уязвими', paint('%d PHP пакета%s | %d от %d сайта' % (summary['affected_core_packages'],
            (' (' + ', '.join(affected_fams) + ')') if affected_fams else '',
            summary['configured_sites_with_affected_packages'], len(sites)),
            'red' if summary['affected_core_packages'] else 'green')),
        ('Неизвестни', paint('%d пакета%s' % (summary['unknown_core_packages'],
            (' (' + ', '.join(unknown_fams) + ')') if unknown_fams else ''),
            'yellow' if summary['unknown_core_packages'] else None)),
        ('Обновления', ('%d PHP пакета (вкл. разширения) имат по-нова версия' % summary['packages_with_updates']) if repo_ok
            else paint('хранилищата не са проверени напълно', 'yellow')),
        ('Стар PHP', '%d сайта под 7.4 | %d на EOL версия (<= 8.1)' % (summary['sites_below_74'], summary['sites_on_upstream_eol'])),
        ('Рестарт', ('%d процеса работят със стар PHP/Apache файл' % summary['restart_hints']) if summary['restart_hints']
            else 'не са открити процеси със стари файлове'),
        ('WAF', '%s | %d проблема, %d предупреждения, %d неизвестни' % (
            paint(WAF_LABEL.get(waf.get('status'), waf.get('status', '?')), 'yellow' if waf.get('status') == 'ATTENTION' else None),
            wc.get('FAIL', 0), wc.get('WARN', 0), wc.get('UNKNOWN', 0))),
        ('Следи', paint('%d признака на компрометиране' % compromise, 'red') if compromise
            else (paint('няма компрометиране, но има опити за атака (%d)' % attempts, 'yellow') if attempts
                  else paint('няма открити', 'green'))),
        ('Блокирания', paint('%d пречки за обновяване' % summary.get('update_blockers', 0), 'red')
            if summary.get('update_blockers') else paint('няма', 'green')),
        ('Selector', ('%d акаунта на уязвим alt-php' % summary.get('selector_vulnerable_accounts', 0))
            if summary.get('selector_vulnerable_accounts') else 'няма акаунти на уязвим alt-php'),
        ('Пълнота', 'пълна проверка' if not errors else paint('%d непотвърдени точки (виж по-горе)' % len(errors), 'yellow')),
    ]
    out.append('')
    out.append(paint(bar, 'cyan'))
    out.append(paint(' ОБОБЩЕНИЕ', 'bold'))
    out.append(paint(bar, 'cyan'))
    for label, value in rows:
        out.append(' %-12s %s' % (label + ':', value))
    acts = recommendations(report)
    if acts:
        out.append('')
        out.append(paint(' Какво да направиш:', 'bold'))
        for a in acts:
            out.append('  ' + paint('→', 'cyan') + ' ' + a)
    out.append('')
    out.append(' Код на изход: %d (0 = няма забележки, 1 = има забележки, 2 = непълна проверка)' % report['exit_code'])
    if not details:
        out.append(paint(' Пълен отчет: --details | JSON: --json', 'gray'))
    out.append(paint(bar, 'cyan'))
    return '\n'.join(out)


def main():
    global USE_COLOR
    parser = OptionParser(usage='%prog [--offline] [--details] [--json] [--no-color]',
        description='Одит на PHP пакетите и обновленията в cPanel. Опреснява само метаданните на хранилищата. '
        'Изход 0 = няма забележки, 1 = има забележки, 2 = непълна проверка. Не е гаранция за сигурност на сървъра.')
    parser.add_option('--offline', action='store_true', default=False,
                      help='Без проверка на хранилищата; статусът на обновленията остава неизвестен')
    parser.add_option('--json', action='store_true', default=False, dest='as_json',
                      help='Машинно четим отчет (JSON)')
    parser.add_option('--details', action='store_true', default=False,
                      help='Пълен отчет: всички пакети, vhost-ове, бележки и WAF детайли')
    parser.add_option('--no-color', action='store_true', default=False, dest='no_color',
                      help='Без цветове в изхода')
    parser.add_option('--output', default='php-audit.txt', metavar='ФАЙЛ',
                      help='Файл за отчета (по подразбиране php-audit.txt в текущата директория)')
    parser.add_option('--json-file', default=None, dest='json_file', metavar='ФАЙЛ',
                      help='Запиши допълнително JSON отчет в този файл')
    parser.add_option('--no-scan', action='store_true', default=False, dest='no_scan',
                      help='Без бавните сканирания (access логове, PHP файлове, .htaccess)')
    parser.add_option('--since', default='2026-10-20', metavar='ГГГГ-ММ-ДД',
                      help='От коя дата да се търсят следи от атака (по подразбиране 2026-10-20, изтичането на детайлите)')
    opts, args = parser.parse_args()
    if args:
        parser.error('Неочаквани аргументи')
    try:
        time.strptime(opts.since, '%Y-%m-%d')
    except ValueError:
        parser.error('--since трябва да е дата във формат ГГГГ-ММ-ДД')
    if os.geteuid() != 0:
        parser.error('Стартирай като root, за да се прочете конфигурацията на всички cPanel акаунти')
    USE_COLOR = (not opts.no_color and not opts.as_json and sys.stdout.isatty()
                 and os.environ.get('TERM', '') not in ('', 'dumb'))
    report = {'audit_version':VERSION, 'advisory_snapshot':SNAPSHOT, 'utc':time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()),
              'hostname':os.uname()[1], 'os':{}, 'cpanel':read('/usr/local/cpanel/version').strip(),
              'packages':[], 'sites':[], 'warnings':[], 'errors':[], 'restart_hints':[],
              'repository_status':'UNKNOWN', 'advisory':SOURCE,
              'scope':'Configured MultiPHP vhosts and package metadata; not an HTTP runtime test.'}
    warnings, errors = report['warnings'], report['errors']
    for line in read('/etc/os-release').splitlines():
        if '=' in line:
            key, val = line.split('=', 1)
            if key in ['ID', 'VERSION_ID', 'PRETTY_NAME']:
                report['os'][key] = val.strip().strip('"').strip("'")
    if not report['os']:
        report['os']['PRETTY_NAME'] = read('/etc/redhat-release').strip() or os.uname()[0]
    oslabel = str(report['os'])
    if re.search(r'CentOS.*(?:release )?6\.', oslabel, re.I):
        warnings.append('CENTOS_6_EOL: неподдържана ОС; старият EasyApache може да няма MultiPHP.')
    if not report['cpanel']:
        errors.append('Не е открита инсталация на cPanel.')
        # Never refresh repositories on an accidentally selected non-cPanel host.
        report['exit_code'] = 2
        print(json.dumps(report, indent=2) if opts.as_json else errors[-1])
        return 2
    report['cpanel_status'] = cpanel_assessment(report['cpanel'])
    if time.strftime('%Y-%m-%d', time.gmtime()) > SNAPSHOT:
        warnings.append('Правилата за CVE са към ' + SNAPSHOT + '; хранилищата се проверяват сега, но правилата не се обновяват автоматично.')

    # Enumerate full package versions, including the vendor release revision.
    backend = 'deb' if which('dpkg-query') and not which('rpm') else ('rpm' if which('rpm') else None)
    if report['os'].get('ID') == 'ubuntu':
        backend = 'deb'
    report['package_backend'] = backend
    if backend == 'rpm':
        rc, out, err = run(['rpm', '-qa', '--qf', '%{NAME}\t%{VERSION}-%{RELEASE}\t%{ARCH}\t%{VENDOR}\n'])
    elif backend == 'deb':
        rc, out, err = run(['dpkg-query', '-W', '-f=${Package}\t${Version}\t${Architecture}\t${Status}\t${Maintainer}\n'])
    else:
        rc, out, err = 1, '', 'No RPM/dpkg package manager detected'
    if rc:
        errors.append('Неуспешен списък на пакетите: ' + err[-1200:])
    else:
        for line in out.splitlines():
            fields = line.split('\t')
            if len(fields) < 4 or not audited_package(fields[0]):
                continue
            if backend == 'deb' and fields[3] != 'install ok installed':
                continue
            report['packages'].append({'name':fields[0], 'version':fields[1], 'arch':fields[2],
                'vendor':fields[3] if backend == 'rpm' else (fields[4] if len(fields)>4 else ''), 'update':'UNKNOWN',
                'candidate':None, 'cve_evidence':[], 'patch_status':'UNCONFIRMED'})
    packages = report['packages']
    if not packages:
        errors.append('Не са открити управлявани PHP пакети; потребителски/EasyApache 3 PHP изисква ръчна проверка.')

    # Changelogs are evidence only. Absence of a CVE is NOT proof of vulnerability.
    for pkg in packages:
        name = pkg['name']
        if not core_package(name):
            pkg['patch_status'], pkg['patch_reason'] = patch_assessment(pkg)
            continue
        if backend == 'rpm':
            rc, log, err = run(['rpm', '-q', '--changelog', name + '.' + pkg['arch']])
            if rc:
                log = ''
        else:
            log = ''
            for path in glob.glob('/usr/share/doc/' + name + '/changelog*'):
                try:
                    f = gzip.open(path, 'rb') if path.endswith('.gz') else open(path, 'rb')
                    log += f.read(4 * 1024 * 1024).decode('utf-8', 'replace')
                    f.close()
                except (IOError, OSError, EOFError):
                    pass
        pkg['cve_evidence'] = [cve for cve in CVES if cve in log]
        # Само changelog с реални записи се приема за прочетен.
        pkg['changelog_read'] = bool(re.search(r'^\*', log, re.M) or re.search(r'^\S.*\) \S+; urgency=', log, re.M))
        pkg['patch_status'], pkg['patch_reason'] = patch_assessment(pkg)

    if not opts.offline and packages:
        print('Проверка на активните хранилища (само метаданни, без обновяване на пакети)...', file=sys.stderr)
        if backend == 'rpm':
            manager = which('dnf') or which('yum')
            if not manager:
                errors.append('Няма dnf или yum.')
            else:
                rc, out, err = run([manager, '-q', '--setopt=metadata_expire=0',
                    '--setopt=skip_if_unavailable=False', 'check-update', '*php*', '*imunify*', 'ea-apache24-mod_security2', 'ea-modsec30'], 600)
                failed = re.search(r'error|failed|cannot|ignoring repositories|no enabled repo|skipping repository', out+'\n'+err, re.I)
                if rc not in (0, 100) or failed:
                    errors.append('Непълна проверка на хранилищата: ' + (err or out)[-1600:])
                else:
                    try:
                        updates = parse_updates(out)
                    except ValueError:
                        updates = None
                    if updates is None or (rc == 100 and not updates):
                        errors.append('Има обновления, но изходът не може да бъде разчетен.')
                    else:
                        report['repository_status'] = 'QUERIED_ENABLED_REPOSITORIES'
                        for pkg in packages:
                            pkg['candidate'] = updates.get((pkg['name'], pkg['arch']))
                            pkg['update'] = 'AVAILABLE' if pkg['candidate'] else 'NO_UPDATE_OFFERED'
        elif backend == 'deb':
            rc, out, err = run(['apt-get', '-o', 'Acquire::Retries=0', '-o',
                               'Acquire::http::Timeout=30', '-o', 'Acquire::https::Timeout=30', 'update'], 600)
            apt_lines = (out + '\n' + err).splitlines()
            # W: (напр. ключ в стария trusted.gpg) не пречи; E:/Err: и неуспешно сваляне пречат.
            apt_errors = [l for l in apt_lines if re.match(r'^(E:|Err:)', l) or re.search(r'Failed to fetch|Some index files failed', l)]
            for l in [l for l in apt_lines if l.startswith('W:')][:5]:
                warnings.append('APT предупреждение: ' + l[:300])
            if rc or apt_errors:
                last = [l for l in apt_lines if l.strip()][-1:] or ['код %d' % rc]
                errors.append('Непълно опресняване на APT: ' + ' | '.join(apt_errors[-5:] or last)[:1600])
            else:
                all_ok = True
                for pkg in packages:
                    rc, out, err = run(['apt-cache', 'policy', pkg['name'] + ':' + pkg['arch']])
                    match = re.search(r'^\s*Candidate:\s*(\S+)', out, re.M)
                    if rc or not match or match.group(1) == '(none)':
                        all_ok = False
                        continue
                    pkg['candidate'] = match.group(1)
                    rc, out, err = run(['dpkg', '--compare-versions', pkg['candidate'], 'gt', pkg['version']])
                    if rc not in (0, 1):
                        all_ok = False
                        continue
                    pkg['update'] = 'AVAILABLE' if rc == 0 else 'NO_UPDATE_OFFERED'
                report['repository_status'] = 'QUERIED_ENABLED_REPOSITORIES' if all_ok else 'INCOMPLETE'
                if not all_ok:
                    errors.append('Не е установена налична версия (APT) за всички PHP пакети.')
    else:
        errors.append('Наличните обновления не са проверени (--offline или няма пакети).')

    security_packages = [p for p in packages if security_package(p['name'])]
    packages[:] = [p for p in packages if php_package(p['name'])]

    # MultiPHP returns resolved configured vhost versions on EasyApache 4.
    try:
        data = api('php_get_vhost_versions')
        rows = data.get('versions')
        if not isinstance(rows, list):
            raise ValueError('Unexpected vhost API response; data.versions is not a list')
        default_version = None
        try:
            default_version = api('php_get_system_default_version').get('version')
        except (ValueError, TypeError, AttributeError):
            pass
        report['system_default_php'] = default_version
        for row in rows:
            version = row.get('version', '')
            if version == 'inherit':
                sources = row.get('phpversion_source') or []
                # A domain can inherit from another vhost, not just the system default.
                if isinstance(sources, list) and any(isinstance(s,dict) and str(s.get('system_default'))=='1' for s in sources):
                    version = default_version or 'UNKNOWN'
            ver = branch(version)
            report['sites'].append({'account':row.get('account', '?'),
                'domain':row.get('vhost', '?'), 'documentroot':row.get('documentroot', ''),
                'php':version, 'status':('BELOW_7_4' if ver < (7,4) else 'AT_LEAST_7_4') if ver else 'UNKNOWN',
                'fpm':row.get('php_fpm'), 'source':'WHM_MultiPHP',
                'patch_status':site_patch(version, packages),
                'upstream_eol':bool(ver and ver <= (8,1))})
        if any(site['status'] == 'UNKNOWN' for site in report['sites']):
            errors.append('Някои vhost-ове са с неустановена PHP версия (вкл. възможно наследяване).')
    except (ValueError, TypeError, AttributeError) as e:
        errors.append('Няма достъп до списъка на PHP по домейни от MultiPHP: ' + str(e))
        # Configuration hints for legacy systems: explicitly not effective runtime results.
        for path in sorted(glob.glob('/var/cpanel/userdata/*/*')):
            base = os.path.basename(path)
            if not os.path.isfile(path) or base.endswith(('.cache', '.json', '.yaml', '_SSL')) or base in ('main', 'cache'):
                continue
            content = read(path)
            host = re.search(r'^servername:\s*[\'"]?([^\s\'"#]+)', content, re.M)
            if not host:
                continue
            ver = re.search(r'^phpversion:\s*[\'"]?([^\s\'"#]+)', content, re.M)
            report['sites'].append({'account':os.path.basename(os.path.dirname(path)),
                'domain':host.group(1), 'php':ver.group(1) if ver else 'UNKNOWN',
                'status':'UNKNOWN', 'patch_status':'UNKNOWN', 'source':'USERDATA_HINT_ONLY'})
    if which('selectorctl') or os.path.isdir('/opt/alt'):
        errors.append('CloudLinux/alt-PHP: PHP Selector може да замества MultiPHP. '
                      'Нужна е проверка по акаунт на реално използвания PHP.')
        for site in report['sites']:
            site['multiphp_patch_status'] = site['patch_status']
            site['runtime_php_status'] = 'UNKNOWN_SELECTOR_OVERRIDE'
    # Evidence of old executables still in use. Absence is not proof of a restart.
    for path in glob.glob('/proc/[0-9]*/exe'):
        try:
            target = os.readlink(path)
            if target.endswith(' (deleted)') and re.search(r'php|httpd|apache2|lshttpd|litespeed', target, re.I):
                report['restart_hints'].append({'pid':path.split('/')[2], 'executable':target})
        except OSError:
            pass
    warnings.append('"Няма по-нова" означава само, че активните хранилища (с текущите excludes/pins/locks) '
                    'не предлагат обновление; това не доказва, че CVE е поправен.')
    warnings.append('Контейнери (Docker и др.) не се проверяват. Изтритите изпълними файлове са само подсказка за рестарт. '
                    'Не се прави HTTP проба и не се създава phpinfo файл.')
    report['php_families'] = family_states(packages)
    fam_patch = dict((f, v['patch_status']) for f, v in report['php_families'].items())
    print('Проверка на cPanel PHP, блокирани обновления и реалния PHP по акаунт...', file=sys.stderr)
    procs = proc_table()
    report['cpanel_php'] = audit_cpanel_php(backend)
    report['blockers'] = audit_blockers(backend, procs)
    report['runtime'] = audit_runtime(report, fam_patch, procs, opts.no_scan)
    report['exposure'] = audit_exposure(report, procs, opts.since, opts.no_scan)
    if opts.no_scan:
        warnings.append('--no-scan: access логовете, PHP файловете и .htaccess не са сканирани.')
    elif not report['exposure']['complete'] or (report['runtime']['htaccess'] and not report['runtime']['htaccess']['complete']):
        errors.append('Сканирането за следи от атака не е завършило (таймаут); резултатите са частични.')
    if report['exposure']['malware'] is None and which('imunify360-agent'):
        warnings.append('Списъкът с malware от Imunify360 не можа да бъде прочетен.')
    compromise, attempts = exposure_counts(report['exposure'])
    warnings.append('PHP 7.x не се поддържа от upstream. Поправките на доставчика (backport) не променят PHP_VERSION. '
                    'Прагът 7.4 е само за инвентаризация, не е оценка на сигурността.')
    unconfirmed = [p for p in packages if p['patch_status'] == 'UNKNOWN']
    if unconfirmed:
        errors.append('Няма данни за поправка на CVE за %d основни PHP пакета; провери бюлетините на доставчика.' % len(unconfirmed))
    if any(s['patch_status'] == 'UNKNOWN' for s in report['sites']):
        errors.append('PHP версията на някои vhost-ове не може да се свърже с проверени пакети.')
    report['summary'] = {
        'packages_with_updates':sum(p['update']=='AVAILABLE' for p in packages),
        'affected_core_packages':sum(p['patch_status'] in VULNERABLE for p in packages),
        'cpanel_not_patched':int(report['cpanel_status']['status'] in ('AFFECTED', 'UNSUPPORTED_TIER')),
        'unknown_core_packages':len(unconfirmed),
        'sites_below_74':sum(s['status']=='BELOW_7_4' for s in report['sites']),
        'configured_sites_with_affected_packages':sum(s['patch_status']=='AFFECTED' for s in report['sites']),
        'sites_on_upstream_eol':sum(bool(s.get('upstream_eol')) for s in report['sites']),
        'restart_hints':len(report['restart_hints']),
        'cpanel_php_vulnerable':sum(r['status'] == 'AFFECTED' for r in report['cpanel_php']['packages']),
        'update_blockers':len(report['blockers']['items']),
        'selector_vulnerable_accounts':len(report['runtime']['selector_vulnerable_accounts']),
        'outdated_php_families':sum(v['update_state'] == 'OUTDATED' for v in report['php_families'].values()),
        'compromise_indicators':compromise,
        'attack_attempts':attempts}
    print('Проверка на Imunify360 и WAF...', file=sys.stderr)
    report['imunify360'] = audit_waf(report['sites'], security_packages, opts.offline)
    if any(c['status'] in ('UNKNOWN','FAIL') for c in report['imunify360']['checks']):
        errors.append('Проверките на Imunify360/WAF са непълни или неуспешни; виж секция IMUNIFY360 / WAF.')
    warnings.append('Проверките на WAF са по статична конфигурация и не гарантират защита. '
                    'Последната версия на правилата и реалното блокиране не са потвърдени.')
    findings = any(report['summary'].values()) or report['imunify360']['status']=='ATTENTION' or any('CENTOS_6_EOL' in w for w in warnings)
    report['exit_code'] = 2 if errors else (1 if findings else 0)
    if opts.as_json:
        text = json.dumps(report, indent=2, sort_keys=True, ensure_ascii=True)
        print(text)
    else:
        print(render_text(report, opts.details))
        # Файлът е без цветове (ANSI кодове), за да се чете нормално.
        screen_color = USE_COLOR
        USE_COLOR = False
        text = render_text(report, opts.details)
        USE_COLOR = screen_color
    try:
        f = io.open(opts.output, 'w', encoding='utf-8', errors='replace')
        f.write(text + '\n')
        f.close()
        print(paint('Отчетът е записан в: %s' % os.path.abspath(opts.output), 'cyan'), file=sys.stderr)
    except (IOError, OSError) as e:
        print('ГРЕШКА: отчетът не е записан в %s: %s' % (opts.output, e), file=sys.stderr)
    if opts.json_file:
        try:
            f = io.open(opts.json_file, 'w', encoding='utf-8', errors='replace')
            f.write(json.dumps(report, indent=2, sort_keys=True, ensure_ascii=True) + '\n')
            f.close()
        except (IOError, OSError) as e:
            print('ГРЕШКА: JSON не е записан в %s: %s' % (opts.json_file, e), file=sys.stderr)
    return report['exit_code']

if __name__ == '__main__':
    sys.exit(main())
PYTHON
    fi
done
echo 'ГРЕШКА: Нужен е Python 2.6+ или Python 3. Нищо не е променено.' >&2
exit 2
