From 728226040cf3aad366b572537b5d4baa26f28272 Mon Sep 17 00:00:00 2001 From: jaimefgdev <186261259+jaimefgdev@users.noreply.github.com> Date: Wed, 30 Sep 2026 12:34:18 +0200 Subject: [PATCH] =?UTF-8?q?Capturas=20en=20el=20README=20y=20release=20aut?= =?UTF-8?q?om=C3=A1tica=20con=20el=20RPM?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - docs/demo/generar_capturas.py: ejecuta ghostcheck.main() contra un servidor simulado (ficheros de /etc de prueba y comandos con respuesta fija, filtrados por la lista blanca) y captura la terminal y el informe HTML con Chrome headless. - README (ES/EN): capturas al principio e instalación desde el RPM de la release. - release.yml: con un tag v* comprueba la versión, construye el RPM en Rocky Linux 9, lo instala y lo ejecuta, y publica la release con el RPM, ghostcheck.py y SHA256SUMS. En PR solo construye y prueba. --- .github/workflows/release.yml | 91 +++++++++++++++ README.md | 22 +++- docs/demo/generar_capturas.py | 202 ++++++++++++++++++++++++++++++++++ docs/informe.png | Bin 0 -> 489230 bytes docs/terminal.png | Bin 0 -> 427423 bytes 5 files changed, 313 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 docs/demo/generar_capturas.py create mode 100644 docs/informe.png create mode 100644 docs/terminal.png diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..dc926c9 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,91 @@ +name: Release + +# Al publicar un tag v* (p. ej. v1.0.0): comprueba la versión, construye el RPM en +# Rocky Linux 9, lo instala y lo ejecuta, y publica una GitHub Release con el RPM, +# ghostcheck.py y sus SHA-256. En un PR que toque el empaquetado, o lanzado a mano, +# solo construye y prueba: no publica nada. +on: + push: + tags: ["v*"] + workflow_dispatch: + pull_request: + paths: [".github/workflows/release.yml", "packaging/**", "ghostcheck.py"] + +permissions: + contents: read + +jobs: + version: + name: Comprobar versión + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - name: El tag, ghostcheck.py y el .spec dicen la misma versión + run: | + py=$(sed -n 's/^__version__ = "\(.*\)"/\1/p' ghostcheck.py) + spec=$(sed -n 's/^Version:[[:space:]]*//p' packaging/ghostcheck.spec) + echo "ghostcheck.py=$py spec=$spec ref=$GITHUB_REF_NAME" + [ "$py" = "$spec" ] || { echo "::error::ghostcheck.py ($py) y el .spec ($spec) no coinciden"; exit 1; } + if [ "$GITHUB_REF_TYPE" = "tag" ] && [ "$GITHUB_REF_NAME" != "v$py" ]; then + echo "::error::El tag $GITHUB_REF_NAME no coincide con la versión $py"; exit 1 + fi + + rpm: + name: Construir y probar el RPM (Rocky Linux 9) + needs: version + runs-on: ubuntu-latest + container: rockylinux:9 + steps: + - uses: actions/checkout@v7 + - name: Construir el RPM + run: | + dnf -q -y install rpm-build python3 + rpmbuild -bb --define "_sourcedir $PWD" --define "_rpmdir $PWD/dist" packaging/ghostcheck.spec + ls -l dist/noarch + - name: Instalarlo y ejecutarlo (solo --version y --help) + run: | + rpm -ivh dist/noarch/ghostcheck-*.noarch.rpm + ghostcheck --version + ghostcheck --help > /dev/null + - name: Preparar los ficheros de la release + run: | + mkdir -p release + cp dist/noarch/ghostcheck-*.noarch.rpm ghostcheck.py release/ + cd release && sha256sum * > SHA256SUMS && cat SHA256SUMS + - uses: actions/upload-artifact@v7 + with: + name: release + path: release/ + if-no-files-found: error + + publicar: + name: Publicar la GitHub Release + if: github.ref_type == 'tag' + needs: rpm + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/download-artifact@v8 + with: + name: release + path: release + - name: Crear la release con el RPM, el script y los SHA-256 + env: + GH_TOKEN: ${{ github.token }} + run: | + { + echo "## Descarga" + echo + echo "- **RPM** (RHEL / Rocky / AlmaLinux 9): \`sudo dnf install ./$(ls release/*.rpm | xargs -n1 basename)\`" + echo "- **Un solo fichero**: \`sudo python3 ghostcheck.py\`" + echo + echo "## SHA-256" + echo + echo '```' + cat release/SHA256SUMS + echo '```' + } > notas.md + gh release create "$GITHUB_REF_NAME" release/* \ + --repo "$GITHUB_REPOSITORY" --title "GhostCheck $GITHUB_REF_NAME" \ + --notes-file notas.md --generate-notes diff --git a/README.md b/README.md index 89fc4e7..e518b9f 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,12 @@ GhostCheck es una auditoría de seguridad para servidores **RHEL / Rocky / AlmaL Funciona en **modo DRY-RUN**: solo lee el sistema, nunca lo modifica (ver [Garantías DRY-RUN](#garantías-dry-run)). +| Terminal | Informe HTML | +|---|---| +|  |  | + +Capturas de una auditoría sobre un servidor simulado (`python docs/demo/generar_capturas.py`): no se ejecuta nada en un sistema real. + ### Requisitos - Python **3.9 o superior** (el Python del sistema en RHEL 9 sirve tal cual). @@ -35,7 +41,10 @@ sudo ./ghostcheck.py # 3) Como paquete Python (instala el comando `ghostcheck`) pip install git+https://github.com/jaimefgdev/ghostcheck.git -# 4) Como RPM (desde la raíz del repositorio, requiere rpm-build) +# 4) RPM ya construido: descárgalo de https://github.com/jaimefgdev/GhostCheck/releases/latest +sudo dnf install ./ghostcheck-*.noarch.rpm + +# 5) Construyendo el RPM (desde la raíz del repositorio, requiere rpm-build) rpmbuild -bb --define "_sourcedir $PWD" packaging/ghostcheck.spec sudo dnf install ~/rpmbuild/RPMS/noarch/ghostcheck-*.noarch.rpm ``` @@ -178,6 +187,12 @@ GhostCheck is a security audit for **RHEL / Rocky / AlmaLinux / CentOS Stream / It runs in **DRY-RUN mode**: it only reads the system and never changes it (see [DRY-RUN guarantees](#dry-run-guarantees)). Console output and reports are in Spanish. +| Terminal | HTML report | +|---|---| +|  |  | + +Screenshots of an audit of a simulated server (`python docs/demo/generar_capturas.py`): nothing runs on a real system. + ### Requirements - Python **3.9+** (the system Python on RHEL 9 works as is). @@ -197,7 +212,10 @@ git clone https://github.com/jaimefgdev/ghostcheck.git && cd ghostcheck && sudo # 3) As a Python package (installs the `ghostcheck` command) pip install git+https://github.com/jaimefgdev/ghostcheck.git -# 4) As an RPM (from the repository root, needs rpm-build) +# 4) Prebuilt RPM: download it from https://github.com/jaimefgdev/GhostCheck/releases/latest +sudo dnf install ./ghostcheck-*.noarch.rpm + +# 5) As an RPM (from the repository root, needs rpm-build) rpmbuild -bb --define "_sourcedir $PWD" packaging/ghostcheck.spec sudo dnf install ~/rpmbuild/RPMS/noarch/ghostcheck-*.noarch.rpm ``` diff --git a/docs/demo/generar_capturas.py b/docs/demo/generar_capturas.py new file mode 100644 index 0000000..c0bd4df --- /dev/null +++ b/docs/demo/generar_capturas.py @@ -0,0 +1,202 @@ +""" +Genera las capturas del README (docs/terminal.png y docs/informe.png). + + python docs/demo/generar_capturas.py + +Ejecuta ghostcheck.main() contra un servidor SIMULADO: los ficheros de /etc +viven en un directorio temporal y cada comando del sistema devuelve una +respuesta fija de esta lista. Cualquier comando que no esté aquí o que no sea +de solo lectura para ghostcheck detiene el script. No se ejecuta nada real. + +Las capturas se hacen con Chrome/Chromium en modo headless (variable CHROME +para indicar la ruta si no se encuentra solo). +""" + +from __future__ import annotations + +import html +import io +import os +import re +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + +RAIZ = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(RAIZ)) + +import ghostcheck # noqa: E402 + +HOST = "srv-web01.example.com" + +PASSWD = """\ +root:x:0:0:root:/root:/bin/bash +bin:x:1:1:bin:/bin:/sbin/nologin +jaime:x:1000:1000:Jaime:/home/jaime:/bin/bash +backup:x:1001:1001:Copias:/home/backup:/bin/bash +""" +SHADOW = """\ +root:$6$abc$hash:19800:0:99999:7::: +bin:*:19800:0:99999:7::: +jaime:$6$def$hash:19800:0:99999:7::: +backup::19800:0:99999:7::: +""" +SSHD_CONFIG = "Include /etc/ssh/sshd_config.d/*.conf\nPermitRootLogin no\nX11Forwarding yes\n" +SSHD_T = ("port 22\npermitrootlogin no\npasswordauthentication yes\n" + "permitemptypasswords no\nx11forwarding yes\n") +DNF = """\ +RHSA-2024:3306 Moderate/Sec. kernel-5.14.0-427.18.1.el9_4.x86_64 +RHSA-2024:4000 Important/Sec. openssl-libs-1:3.0.7-27.el9.x86_64 +RHSA-2024:4001 Critical/Sec. kernel-core-5.14.0-427.18.1.el9_4.x86_64 +""" + + +def respuestas(sshd_config: str) -> dict[tuple[str, ...], tuple[int, str]]: + r: dict[tuple[str, ...], tuple[int, str]] = { + ("getenforce",): (0, "Enforcing"), + ("sestatus",): (0, "SELinux status: enabled\n" + "Loaded policy name: targeted\n" + "Current mode: enforcing"), + ("systemctl", "is-active", "firewalld"): (0, "active"), + ("firewall-cmd", "--get-default-zone"): (0, "public"), + ("firewall-cmd", "--get-active-zones"): (0, "public\n interfaces: eth0"), + ("firewall-cmd", "--list-ports", "--zone=public"): (0, "8080/tcp"), + ("firewall-cmd", "--list-services", "--zone=public"): (0, "cockpit dhcpv6-client ssh"), + ("firewall-cmd", "--list-rich-rules", "--zone=public"): (0, ""), + ("firewall-cmd", "--list-forward-ports", "--zone=public"): (0, ""), + ("firewall-cmd", "--info-service=cockpit"): (0, "cockpit\n ports: 9090/tcp"), + ("firewall-cmd", "--info-service=dhcpv6-client"): (0, "dhcpv6-client\n ports: 546/udp"), + ("firewall-cmd", "--info-service=ssh"): (0, "ssh\n ports: 22/tcp"), + ("sshd", "-T", "-f", sshd_config): (0, SSHD_T), + ("dnf", "-q", "-C", "updateinfo", "list", "--security"): (0, DNF), + ("hostname", "-f"): (0, HOST), + } + return r + + +class SistemaSimulado: + def __init__(self, tabla: dict[tuple[str, ...], tuple[int, str]]) -> None: + self.tabla = tabla + self.binarios = {c[0] for c in tabla} + + def which(self, nombre: str, *_, **__) -> str | None: + return f"/usr/bin/{nombre}" if nombre in self.binarios else None + + def run(self, comando, *_, **__): + comando = tuple(comando) + if not ghostcheck.comando_permitido(list(comando)): + raise SystemExit(f"Comando no permitido en la demo: {comando}") + if comando not in self.tabla: + raise FileNotFoundError(2, "No such file or directory", comando[0]) + codigo, salida = self.tabla[comando] + return subprocess.CompletedProcess(list(comando), codigo, salida, "") + + +class TerminalSimulada(io.StringIO): + def isatty(self) -> bool: # colores ANSI, como en una terminal real + return True + + +ANSI = {"1": "font-weight:700", "31": "color:#ff5f5f", "91": "color:#ff5f5f", + "32": "color:#5fd75f", "92": "color:#5fd75f", "33": "color:#ffd75f", + "93": "color:#ffd75f", "35": "color:#d787ff", "95": "color:#d787ff", + "36": "color:#5fd7ff", "96": "color:#5fd7ff"} + + +def ansi_a_html(texto: str) -> str: + partes, abiertas = [], 0 + for trozo in re.split(r"(\x1b\[[0-9;]*m)", texto): + m = re.fullmatch(r"\x1b\[([0-9;]*)m", trozo) + if not m: + partes.append(html.escape(trozo)) + continue + codigos = [c for c in m.group(1).split(";") if c] + if not codigos or codigos == ["0"]: + partes.append("" * abiertas) + abiertas = 0 + continue + estilo = ";".join(ANSI[c] for c in codigos if c in ANSI) + partes.append(f'') + abiertas += 1 + partes.append("" * abiertas) + cuerpo = "".join(partes) + return ("
" + cuerpo + "") + + +def buscar_chrome() -> str: + candidatos = [os.environ.get("CHROME", ""), "chromium", "chromium-browser", "google-chrome", + r"C:\Program Files\Google\Chrome\Application\chrome.exe", + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"] + for c in candidatos: + if c and (shutil.which(c) or Path(c).exists()): + return shutil.which(c) or c + raise SystemExit("No encuentro Chrome/Chromium: indica la ruta con la variable CHROME.") + + +def capturar(chrome: str, pagina: Path, destino: Path, alto: int, ancho: int = 1100) -> None: + subprocess.run([chrome, "--headless=new", "--disable-gpu", "--hide-scrollbars", + f"--screenshot={destino}", f"--window-size={ancho},{alto}", + "--force-device-scale-factor=2", pagina.resolve().as_uri()], + check=True, capture_output=True, timeout=60) + + +def main() -> None: + reales = (subprocess.run, shutil.which, os.path.exists) + with tempfile.TemporaryDirectory() as tmp: + tmp_p = Path(tmp) + etc = tmp_p / "etc" + (etc / "ssh").mkdir(parents=True) + (etc / "selinux").mkdir() + (etc / "passwd").write_text(PASSWD, encoding="utf-8") + (etc / "shadow").write_text(SHADOW, encoding="utf-8") + (etc / "ssh" / "sshd_config").write_text(SSHD_CONFIG, encoding="utf-8") + (etc / "selinux" / "config").write_text("SELINUX=enforcing\n", encoding="utf-8") + # La demo usa las rutas de un servidor real; solo la LECTURA de esos + # ficheros se redirige a las copias de prueba del directorio temporal. + mapa = {"/etc/passwd": etc / "passwd", "/etc/shadow": etc / "shadow", + "/etc/ssh/sshd_config": etc / "ssh" / "sshd_config", + "/etc/selinux/config": etc / "selinux" / "config"} + rutas = ghostcheck.Rutas(passwd="/etc/passwd", shadow="/etc/shadow", + sshd_config="/etc/ssh/sshd_config", + selinux_config="/etc/selinux/config") + sistema = SistemaSimulado(respuestas(rutas.sshd_config)) + leer_real, existe_real, spinner_real = ghostcheck._leer_lineas, os.path.exists, ghostcheck.Spinner + + ghostcheck.RUTAS = rutas + ghostcheck._leer_lineas = lambda ruta: leer_real(str(mapa.get(ruta, ruta))) + ghostcheck.subprocess.run = sistema.run + ghostcheck.shutil.which = sistema.which + ghostcheck.os.path.exists = lambda ruta: existe_real(str(mapa.get(ruta, ruta))) + ghostcheck.socket.gethostname = lambda: HOST + ghostcheck.es_root = lambda: True + # Sin animación de carga: en una captura fija solo dejaría restos. + ghostcheck.Spinner = lambda mensaje, salida, activo=True: spinner_real(mensaje, salida, activo=False) + salida_dir = tmp_p / "reportes" + salida_dir.mkdir() + terminal = TerminalSimulada() + salida_real, sys.stdout = sys.stdout, terminal + try: + codigo = ghostcheck.main(["-o", str(salida_dir), "-f", "html", "--offline"]) + finally: + sys.stdout = salida_real + subprocess.run, shutil.which, os.path.exists = reales # la captura usa lo real + ghostcheck._leer_lineas, ghostcheck.Spinner = leer_real, spinner_real + + # Las rutas del directorio temporal se muestran como en el servidor. + texto = terminal.getvalue().replace(str(salida_dir) + os.sep, "/root/") + informe = next(salida_dir.glob("*.html")) + (tmp_p / "terminal.html").write_text(ansi_a_html(texto), encoding="utf-8") + + chrome = buscar_chrome() + docs = RAIZ / "docs" + capturar(chrome, tmp_p / "terminal.html", docs / "terminal.png", 1560, ancho=1500) + capturar(chrome, informe, docs / "informe.png", 1500) + print(f"Nivel de riesgo de la demo: código {codigo}. Capturas en docs/terminal.png y docs/informe.png") + + +if __name__ == "__main__": + main() diff --git a/docs/informe.png b/docs/informe.png new file mode 100644 index 0000000000000000000000000000000000000000..a1723d1cdb381c9bb014da0b64735aa0e7d0f931 GIT binary patch literal 489230 zcmeFYcT`hPzyAxOf`DKI1f)dZQL1#M1tLgMKzdbrM>?Umpdclb=ojftKzi>%AV>`z zk=}*STj(t$-1waH{LVS|_^eaz{p+qf|LnE0_UxI=o;~xP*XR9-)KF8pO@5!8h=}O+ z%NO!mL_}msL_`z_vTKB2xVlNNh=}eGy_A>L@k!fmHY^A
3k!Y78
z2J25P8=|pKw_UM5HqIaWl(3O-sDjkKP?1de!Eh9d%s^>=%1pP2dbQx%(*vfRdJf-s
z2WQ-q0M%=5EFOK2f#|D P_-CG*+MP9f{ah;f&TeH@10^xQD2K {`x3hREO{*uZJ^lJ6^(Lj@tB 5}c$!rvW!|3i*<& n}VP|x70ezeY7?onj}es9=#~!`yYZiL|w@<+7%6rD< WpEYsNXfr99%l9Bd@gi2Yr5DHX;L*Tl4x?ID_@2u*u
zX+`B8Zly=%No2A7+z3O9jpQ%a=+D3)n1;U02hug>ysPcjC9gFOo0<&ookn*m@7?7O
zJyoGfpd+d{q+1A;v6SlXZY#{|El$}aHl1FrkSO&A;U78QAkE$B<;uF|-wSBt*}r}J
z=5N#nTVt!bcbpNqTK%;zYB?RgP!0F_I7Su&gdOFZ&vFB8El63_WmJ6^j^E3%WCSNZ
ziuWa2*tT(%@mXysQ}P||MQ(FLHR4n8FkAPqou$LcY^6usMroa?^6>YraE9Emi8*3~+ErVr@)9ZyNt;I`&fFBKNXmG3LVV}a!0{jfFkrvyUuM)$ctxcv(
zbA!Vk0U0~@amxfraPHQeujNdGoBLFYD$l95-SZx>iyE
01%U(XQU((~BcR3Q0H+ZZ+O(u2_q4 >&
zEirTX!VDkhyxo~6m&8CqEsFfLpb_c~KRNx`>~PBTb9+phxq@^zIvh||iw4t}o8tX>
z{)L_C)wkMLuvZftmhOuUtd;zuB?QsqS7!+!08B?*R8y_}PzJi4vcA8G71T09S^rba
z*Qu~=J{7cWvJmr1!N9k}@y!?%BdPf5^>58~Qy?DRhU5Nji3XTE(7SsQ8fH+baj3Tx
zaQq}lw=>$cP3zlgw&TOPMGA$I#bLR^j+t7y#ZIq!_2T^-bjp|94YwE1V$(|cEz}~n
zyeMQ_7gg4_p+dXr?5sEti=T5H?PLtvTSeD2q38BFwp70LIU}LSKWg9o9-q}j>b_T$
z3N%6p(Fz3oW&3W$Ded$Pe85
$d1Aa>xfUHCNS;|;qg_S_KTG_t4XCzF%
zFO?i(-dbWNjSyak(%0H*<0B7Z?2^;6)sxL`w#j0zS~5~O!dZgeks3o;PvwPt-)`d+
z1?89`(1J`*gB#GnJH#m?%4IUij4In`NCpE56-w(+6-?2;r^TB&mF9k~t=tFvSu4jZcfg6jo-_myk-;H!A-
zL-Nzc0IJ&aYENYX@^*G|mX#SYL^e-1d2uVe;CHlAD*d63?W;CUixSY(W3X>4QZM47
z{F^G^XT#qLP#5|8Enc*!cEM9a?(b5&sA
5Oh^`Zr%A#e2^ia^c#-cOy}}f-XJ3ezku4(0Tebatv~^I=g&xK=DaEsPZ+EOmW6J
z88&=Tsrx;=Kxed9y5~uJt3+8InO=Nryajn-;
yF`lK_KrPVg;^Hr
zv-tH<#gs(n(