#!/usr/bin/env python3 """Inventário de PHP de um servidor: versão, origem do pacote e o que fazer. Pergunta que ele responde em um minuto, sem instalar nada e sem ler código do cliente: "este servidor recebe o patch de segurança de hoje, e de onde?". Só biblioteca padrão. Não imprime variável de ambiente, hostname nem caminho de aplicação (só o do binário do PHP). """ from __future__ import annotations import argparse import datetime as dt import glob import json import os import re import shutil import subprocess import sys # php.net/supported-versions.php, conferido em 2026-10-21. Mudou a tabela? Mude aqui e no teste. FIM_SEGURANCA = { (8, 2): dt.date(2026, 12, 31), (8, 3): dt.date(2027, 12, 31), (8, 4): dt.date(2028, 12, 31), (8, 5): dt.date(2029, 12, 31), } # Ordem importa: o repositório de terceiros vence o rótulo da distro. ORIGENS = ( ("remirepo", re.compile(r"remi", re.IGNORECASE)), ("sury", re.compile(r"sury|ondrej", re.IGNORECASE)), ("distro", re.compile(r"red hat|centos|alma|rocky|oracle|fedora|debian|ubuntu|suse", re.IGNORECASE)), ) CAMINHOS_EXTRA = ("/opt/remi/php*/root/usr/bin/php", "/usr/local/bin/php", "/usr/bin/php*") def parse_versao(texto: str): m = re.search(r"PHP (\d+)\.(\d+)\.(\d+)", texto) return tuple(int(x) for x in m.groups()) if m else None def classificar_origem(vendor: str, caminho: str, em_container: bool) -> str: if em_container and caminho.startswith("/usr/local/bin"): return "docker-oficial" for nome, rx in ORIGENS: if rx.search(vendor) or (nome == "remirepo" and "/opt/remi/" in caminho): return nome return "desconhecida" def decidir(versao, origem: str, hoje: dt.date) -> dict: """Regra única: o que fazer com este PHP no dia do patch.""" ramo = versao[:2] if versao else None fim = FIM_SEGURANCA.get(ramo) if ramo is None: return {"classe": "ilegivel", "acao": "Conferir à mão: não consegui ler a versão."} if fim is None or fim < hoje: return {"classe": "eol", "acao": "Sem patch de segurança do PHP. Migrar de versão (prioridade máxima)."} if origem == "remirepo": return {"classe": "origem-sem-patch-hoje", "acao": "Versão suportada, mas o repositório remirepo avisou que não terá atualização hoje. " "Trocar a origem (pacote da distro ou build do fonte) para aplicar o patch no mesmo dia."} if origem == "desconhecida": return {"classe": "origem-a-confirmar", "acao": "Descobrir quem empacota este PHP e quando ele publica o patch."} return {"classe": "patch-no-dia", "acao": "Aplicar a atualização assim que a origem publicar; reiniciar PHP-FPM/Apache e conferir a versão."} def _rodar(args, tempo=10) -> str: try: r = subprocess.run(args, capture_output=True, text=True, timeout=tempo, check=False) except (OSError, subprocess.SubprocessError): return "" return (r.stdout or "") + (r.stderr or "") def _vendor_do_binario(caminho: str) -> str: if shutil.which("rpm"): saida = _rodar(["rpm", "-qf", "--qf", "%{VENDOR}|%{PACKAGER}|%{NAME}", caminho]) if saida and "not owned" not in saida and "não pertence" not in saida: return saida if shutil.which("dpkg"): pacote = _rodar(["dpkg", "-S", caminho]).split(":")[0].strip() if pacote: return _rodar(["apt-cache", "policy", pacote]) return "" def e_binario_php(caminho: str) -> bool: # "/usr/bin/php*" também casa phpize e php-config: não são o interpretador. return re.fullmatch(r"php(\d+(\.\d+)?)?", os.path.basename(caminho)) is not None def achar_binarios() -> list[str]: achados = [] for c in [shutil.which("php")] + [p for g in CAMINHOS_EXTRA for p in sorted(glob.glob(g))]: if c and e_binario_php(c) and os.path.isfile(c) and os.access(c, os.X_OK): real = os.path.realpath(c) if real not in achados: achados.append(real) return achados def auditar(hoje: dt.date | None = None) -> list[dict]: hoje = hoje or dt.datetime.now(dt.timezone.utc).date() em_container = os.path.exists("/.dockerenv") linhas = [] for caminho in achar_binarios(): versao = parse_versao(_rodar([caminho, "-v"])) origem = classificar_origem(_vendor_do_binario(caminho), caminho, em_container) linhas.append({"binario": caminho, "versao": ".".join(map(str, versao)) if versao else None, "origem": origem, **decidir(versao, origem, hoje)}) return linhas def relatorio_md(linhas: list[dict], rotulo: str, hoje: dt.date) -> str: topo = [f"# Diagnóstico PHP — {rotulo}", f"Data: {hoje.isoformat()}", ""] if not linhas: return "\n".join(topo + ["Nenhum PHP encontrado neste servidor (CLI). Rode também onde o PHP-FPM/Apache vive."]) topo += ["| binário | versão | origem | situação | o que fazer |", "|---|---|---|---|---|"] return "\n".join(topo + [f"| `{l['binario']}` | {l['versao']} | {l['origem']} | {l['classe']} | {l['acao']} |" for l in linhas]) def main(argv=None) -> int: p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) p.add_argument("--rotulo", default="servidor", help="nome do cliente/servidor no relatório (não é lido da máquina)") p.add_argument("--json", action="store_true") a = p.parse_args(argv) hoje = dt.datetime.now(dt.timezone.utc).date() linhas = auditar(hoje) print(json.dumps(linhas, ensure_ascii=False, indent=2) if a.json else relatorio_md(linhas, a.rotulo, hoje)) # Código de saída 1 = há algo a fazer; o relatório já diz o quê. return 0 if all(l["classe"] == "patch-no-dia" for l in linhas) and linhas else 1 if __name__ == "__main__": sys.exit(main())