Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 91 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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
22 changes: 20 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
|---|---|
| ![Salida de GhostCheck en la terminal: hallazgos por módulo con su severidad y recomendaciones](docs/terminal.png) | ![Informe HTML de GhostCheck con el nivel de riesgo y el detalle de cada módulo](docs/informe.png) |

<sub>Capturas de una auditoría sobre un servidor simulado (`python docs/demo/generar_capturas.py`): no se ejecuta nada en un sistema real.</sub>

### Requisitos

- Python **3.9 o superior** (el Python del sistema en RHEL 9 sirve tal cual).
Expand All @@ -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
```
Expand Down Expand Up @@ -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 |
|---|---|
| ![GhostCheck console output: findings per module with severity and recommendations](docs/terminal.png) | ![GhostCheck HTML report with the risk level and the details of each module](docs/informe.png) |

<sub>Screenshots of an audit of a simulated server (`python docs/demo/generar_capturas.py`): nothing runs on a real system.</sub>

### Requirements

- Python **3.9+** (the system Python on RHEL 9 works as is).
Expand All @@ -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
```
Expand Down
202 changes: 202 additions & 0 deletions docs/demo/generar_capturas.py
Original file line number Diff line number Diff line change
@@ -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("</span>" * abiertas)
abiertas = 0
continue
estilo = ";".join(ANSI[c] for c in codigos if c in ANSI)
partes.append(f'<span style="{estilo}">')
abiertas += 1
partes.append("</span>" * abiertas)
cuerpo = "".join(partes)
return ("<!doctype html><meta charset='utf-8'><style>body{margin:0;background:#1e2127}"
"pre{margin:0;padding:22px 26px;color:#d7dae0;font:15px/1.45 'DejaVu Sans Mono',"
"Consolas,monospace}</style><pre>" + cuerpo + "</pre>")


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()
Binary file added docs/informe.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/terminal.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading