metis-desk/scripts/visual-audit.py

333 lines
18 KiB
Python
Raw Normal View History

#!/usr/bin/env python3
"""Prepara datos desechables y verifica un acceso real mediante Playwright MCP."""
import argparse
import base64
import contextlib
import hashlib
import http.cookiejar
import json
import os
from pathlib import Path
import re
import runpy
import secrets
import shutil
import signal
import socket
import subprocess
import sys
import tempfile
import time
import tomllib
import urllib.error
import urllib.request
import uuid
ROOT = Path(__file__).resolve().parent.parent
ARTIFACTS = ROOT / ".artifacts/playwright"
# Índice oficial contrastado con el registro; no utiliza el volumen de compose del proyecto.
DATABASE_IMAGE = "mariadb:11.8@sha256:6422478cb8e159f080fb1d8ccf65101e26fe51385787fde7d16c3b165a331f15"
def private_write(path, text):
descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
with os.fdopen(descriptor, "w", encoding="utf-8") as stream:
stream.write(text)
def command(args, **options):
return subprocess.run(args, check=True, timeout=120, capture_output=True, text=True, **options)
def available_port():
with socket.socket() as listener:
listener.bind(("127.0.0.1", 0))
return listener.getsockname()[1]
@contextlib.contextmanager
def child_process(args, environment, log):
with log.open("w", encoding="utf-8") as stream:
process = subprocess.Popen(args, cwd=ROOT, env=environment, stdout=stream,
stderr=stream, start_new_session=True)
try:
yield process
finally:
if process.poll() is None:
os.killpg(process.pid, signal.SIGTERM)
try:
process.wait(timeout=10)
except subprocess.TimeoutExpired:
os.killpg(process.pid, signal.SIGKILL)
process.wait(timeout=10)
def wait_for(check, process=None):
deadline = time.monotonic() + 120
while time.monotonic() < deadline:
if process is not None and process.poll() is not None:
raise RuntimeError("Un servidor temporal terminó antes de estar disponible; revisa sus logs locales.")
if check():
return
# Reintento condicionado a disponibilidad, con plazo; no sustituye una espera de UI.
time.sleep(0.25)
raise RuntimeError("Tiempo agotado esperando un servicio temporal.")
class TestApi:
"""Cliente del HTTP público; nunca fabrica cookies, sesiones ni tokens CSRF."""
def __init__(self, base, origin):
self.base = base
self.origin = origin
self.opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(http.cookiejar.CookieJar()))
def request(self, path, body=None, expected=200):
headers = {}
if body is not None:
token = self.request("/auth/session")["csrfToken"]
headers = {"content-type": "application/json", "origin": self.origin, "x-csrf-token": token}
request = urllib.request.Request(self.base + "/api" + path,
data=None if body is None else json.dumps(body).encode(),
headers=headers)
try:
with self.opener.open(request, timeout=20) as response:
if response.status != expected:
raise RuntimeError("Respuesta HTTP inesperada en " + path)
return json.load(response)
except urllib.error.HTTPError as error:
if error.code == expected:
return None
# Los cuerpos pueden contener datos de sesión; no se vuelcan en el diagnóstico.
raise RuntimeError(f"HTTP {error.code} en {path}") from None
def ready(self):
try:
self.request("/auth/session")
return True
except (OSError, RuntimeError):
return False
def prepare_fixture(api):
if not api.request("/auth/session")["setupRequired"]:
raise RuntimeError("La instancia desechable no está vacía; se rechaza preparar datos.")
password = secrets.token_urlsafe(32)
api.request("/auth/bootstrap", {"name": "Preparación temporal", "email": "setup@audit.example.test",
"password": secrets.token_urlsafe(32)}, expected=201)
user = api.request("/users", {"name": "Auditoría visual", "email": "visual@audit.example.test",
"password": password}, expected=201)["user"]
if user["isInitialAdmin"]:
raise RuntimeError("La cuenta de auditoría debe ser ordinaria.")
company = api.request("/companies", {"name": "Auditoría visual — Datos sintéticos", "active": True}, expected=201)["company"]
company_id = str(uuid.UUID(company["id"]))
catalog = api.request("/asset-catalog/" + company_id)["items"]
template = next((item for item in catalog if item["values"]["kind"] == "template"
and item["values"]["name"] == "Portátil"), None)
if template is None:
raise RuntimeError("El catálogo de serie no incluye la plantilla Portátil.")
asset = api.request("/assets/" + company_id, {"values": {
"name": "AUDIT-PORT-01", "templateId": template["id"], "siteId": None,
"registeredOn": "2026-01-15", "status": "operational", "notes": "Activo sintético para auditorías visuales.",
"characteristics": {},
}}, expected=201)["asset"]
api.request("/auth/logout", {})
return {"company": company_id, "asset": str(uuid.UUID(asset["id"])), "user": user["id"],
"email": "visual@audit.example.test", "password": password}
def tool(client, name, arguments):
try:
return client.tool(name, arguments)
except (RuntimeError, OSError) as error:
# Las respuestas de rellenado pueden repetir credenciales. No imprimirlas ni guardarlas.
if name == "browser_run_code_unsafe":
# Solo categorías de error conocidas; nunca contenido, argumentos ni snapshots.
category = "timeout" if "Timeout" in str(error) else "ejecución de código"
raise RuntimeError("Falló " + name + " (" + category + ").") from None
raise RuntimeError("Falló " + name + "; no se registra su contenido para proteger la sesión.") from None
def result_json(result):
for part in result.get("content", []):
if part.get("type") == "text" and "### Result\n" in part["text"]:
return json.JSONDecoder().raw_decode(part["text"].split("### Result\n", 1)[1].lstrip())[0]
raise RuntimeError("El MCP no devolvió el resultado JSON esperado.")
@contextlib.contextmanager
def browser(config, state=None):
name = "metis-visual-browser-" + uuid.uuid4().hex[:12]
args = ["docker", "run", "--name=" + name, *config["args"][1:]]
if state is not None:
image_position = next(index for index, arg in enumerate(args) if arg.startswith("mcr.microsoft.com/playwright/mcp:"))
args[image_position:image_position] = ["--user=" + str(os.getuid()) + ":" + str(os.getgid()),
"--mount=type=bind,source=" + str(state) + ",target=/tmp/metis-audit-state.json,readonly"]
args.append("--storage-state=/tmp/metis-audit-state.json")
# Reutiliza el cliente de diagnóstico existente, sin añadir un SDK ni duplicar JSON-RPC.
client_type = runpy.run_path(str(ROOT / "scripts/check-playwright-mcp.py"))["StdioClient"]
client = client_type(args, 120)
try:
client.request("initialize", {"protocolVersion": "2025-06-18", "capabilities": {},
"clientInfo": {"name": "metis-authenticated-audit", "version": "1"}})
client.send({"method": "notifications/initialized"})
yield client
finally:
try:
client.close()
finally:
subprocess.run(["docker", "rm", "-f", name], capture_output=True, timeout=15, check=False)
def capture(client, directory, name):
result = tool(client, "browser_take_screenshot", {"type": "png", "fullPage": False, "scale": "css"})
part = next(item for item in result["content"] if item.get("type") == "image" and item.get("mimeType") == "image/png")
data = base64.b64decode(part["data"], validate=True)
if not data.startswith(b"\x89PNG\r\n\x1a\n"):
raise RuntimeError("Captura PNG inválida.")
destination = directory / (name + ".png")
destination.write_bytes(data)
return {"file": destination.name, "sha256": hashlib.sha256(data).hexdigest()}
def verify_browser(config, origin, fixture, directory, state, logout):
code_tool = "browser_run_code_unsafe"
evidence = []
assets_path = "/companies/" + fixture["company"] + "/assets"
with browser(config) as client:
print("Verificando el formulario de acceso real…", flush=True)
tool(client, "browser_resize", {"width": 1280, "height": 900})
tool(client, "browser_navigate", {"url": origin + "/login"})
tool(client, code_tool, {"code": "async (page) => { await page.getByRole('button', {name:'Entrar', exact:true}).waitFor(); }"})
evidence.append(capture(client, directory, "00-login"))
tool(client, "browser_fill_form", {"fields": [
{"name": "Correo electrónico", "type": "textbox", "target": "input[type=email]", "value": fixture["email"]},
{"name": "Contraseña", "type": "textbox", "target": "input[type=password]", "value": fixture["password"]},
]})
tool(client, "browser_click", {"target": "button[type=submit]"})
tool(client, code_tool, {"code": "async (page) => { await page.locator('[data-slot=workspace]').waitFor({timeout:30000}); }"})
identity = result_json(tool(client, "browser_evaluate", {"function": """async () => {
const response = await fetch('/api/auth/session');
const {user} = await response.json();
return {id:user?.id, admin:user?.isInitialAdmin, usersStatus:(await fetch('/api/users')).status};
}"""}))
if identity != {"id": fixture["user"], "admin": False, "usersStatus": 403}:
raise RuntimeError("La sesión no corresponde al usuario ordinario esperado.")
evidence.append(capture(client, directory, "01-authenticated"))
print("Usuario ordinario autenticado; verificando listado, ficha y diálogo…", flush=True)
tool(client, "browser_navigate", {"url": origin + assets_path})
tool(client, code_tool, {"code": "async (page) => { await page.getByRole('link', {name:'AUDIT-PORT-01', exact:true}).waitFor(); }"})
evidence.append(capture(client, directory, "02-assets"))
tool(client, code_tool, {"code": "async (page) => { await page.getByRole('link', {name:'AUDIT-PORT-01', exact:true}).click(); await page.getByRole('heading', {name:'AUDIT-PORT-01', exact:true}).waitFor(); }"})
evidence.append(capture(client, directory, "03-detail"))
tool(client, code_tool, {"code": "async (page) => { await page.getByRole('button', {name:'Editar activo', exact:true}).click(); await page.getByRole('dialog', {name:'Editar activo', exact:true}).waitFor(); }"})
evidence.append(capture(client, directory, "04-dialog"))
tool(client, code_tool, {"code": "async (page) => { await page.getByRole('dialog').getByRole('button', {name:'Cancelar', exact:true}).click(); await page.getByRole('dialog').waitFor({state:'hidden'}); }"})
storage = result_json(tool(client, code_tool, {"code": "async (page) => await page.context().storageState()"}))
private_write(state, json.dumps(storage))
# Otro contenedor comprueba la reutilización por el mismo mecanismo que recibirá Codex.
with browser(config, state) as client:
tool(client, "browser_navigate", {"url": origin + assets_path})
tool(client, code_tool, {"code": "async (page) => { await page.getByRole('link', {name:'AUDIT-PORT-01', exact:true}).waitFor(); }"})
if logout:
result = result_json(tool(client, "browser_evaluate", {"function": """async () => {
const {csrfToken} = await (await fetch('/api/auth/session')).json();
const response = await fetch('/api/auth/logout', {
method:'POST', body:'{}', headers:{'content-type':'application/json', 'x-csrf-token':csrfToken}
});
return {logout:response.status, protected:(await fetch('/api/companies')).status};
}"""}))
if result != {"logout": 200, "protected": 401}:
raise RuntimeError("El cierre de sesión no revocó el acceso esperado.")
return evidence
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--check", action="store_true", help="Verifica y retira el entorno; sin esta opción espera hasta Ctrl+C")
options = parser.parse_args()
for executable in ("docker", "node", "pnpm"):
if shutil.which(executable) is None:
parser.error("Falta " + executable + "; consulta docs/playwright-mcp.md")
if not command(["node", "--version"]).stdout.startswith("v24."):
parser.error("Se necesita Node.js 24.x.")
with (ROOT / ".codex/config.toml").open("rb") as source:
config = tomllib.load(source)["mcp_servers"]["playwright"]
images = [arg for arg in config["args"] if re.fullmatch(r"mcr\.microsoft\.com/playwright/mcp:v[\d.]+@sha256:[a-f0-9]{64}", arg)]
if config["command"] != "docker" or len(images) != 1 or "--network=host" not in config["args"]:
parser.error("Se requiere la configuración Docker fijada con red host.")
ARTIFACTS.mkdir(parents=True, exist_ok=True)
directory = Path(tempfile.mkdtemp(prefix="authenticated-", dir=ARTIFACTS))
container = "metis-visual-db-" + uuid.uuid4().hex[:12]
container_created = False
database_password = secrets.token_urlsafe(32)
api_port, web_port = available_port(), available_port()
while api_port == web_port:
web_port = available_port()
origin = "http://localhost:" + str(web_port)
api = TestApi("http://127.0.0.1:" + str(api_port), origin)
state = directory / "session.json"
env_file = directory / "database.env"
try:
private_write(env_file, "MARIADB_DATABASE=metis_desk_test\nMARIADB_USER=visual_audit\nMARIADB_PASSWORD=" + database_password + "\nMARIADB_ROOT_PASSWORD=" + secrets.token_urlsafe(32) + "\n")
print("Preparando MariaDB desechable y servicios en puertos libres…", flush=True)
command(["docker", "run", "--detach", "--rm", "--name=" + container,
"--label=metis.visual-audit=true", "--publish=127.0.0.1::3306",
"--env-file=" + str(env_file), DATABASE_IMAGE])
container_created = True
env_file.unlink()
wait_for(lambda: subprocess.run(["docker", "exec", container, "healthcheck.sh", "--connect", "--innodb_initialized"],
capture_output=True, timeout=15, check=False).returncode == 0)
bindings = json.loads(command(["docker", "inspect", "--format={{json .NetworkSettings.Ports}}", container]).stdout)
database_port = bindings["3306/tcp"][0]["HostPort"]
# No carga .env: las únicas credenciales apuntan al contenedor recién creado.
environment = {**os.environ, "DB_HOST": "127.0.0.1", "DB_PORT": database_port,
"DB_NAME": "metis_desk_test", "DB_USER": "visual_audit", "DB_PASSWORD": database_password,
"SESSION_SECRET": secrets.token_urlsafe(48), "NODE_ENV": "test", "APP_ORIGIN": origin,
"PORT": str(api_port), "API_PROXY_TARGET": api.base}
with child_process(["node", "--import", "tsx", "apps/server/src/index.ts"], environment, directory / "api.log") as backend:
wait_for(api.ready, backend)
fixture = prepare_fixture(api)
TestApi(api.base, origin).request("/companies", expected=401)
with child_process(["pnpm", "--config.verify-deps-before-run=false", "--filter", "@metis/web", "exec", "vite",
"--host", "127.0.0.1", "--port", str(web_port), "--strictPort"], environment, directory / "vite.log") as frontend:
web = TestApi(origin, origin)
wait_for(web.ready, frontend)
evidence = verify_browser(config, origin, fixture, directory, state, options.check)
report = {"origin": origin, "companyId": fixture["company"], "assetId": fixture["asset"],
"ordinaryUser": True, "unauthenticatedStatus": 401, "userManagementStatus": 403,
"reusedSession": True, "logoutChecked": options.check,
"databaseImage": DATABASE_IMAGE, "mcpImage": images[0], "screenshots": evidence}
(directory / "report.json").write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8")
print("Verificado: login real, usuario ordinario, listado, ficha, diálogo cancelado y sesión en otro contenedor.", flush=True)
print("Evidencia local: " + str(directory.relative_to(ROOT)), flush=True)
if not options.check:
print("Mantén este proceso abierto. En otra terminal WSL:", flush=True)
print("METIS_PLAYWRIGHT_STORAGE_STATE=" + str(state.relative_to(ROOT)) + " bash scripts/codex-playwright-wsl.sh", flush=True)
print("URL de auditoría: " + origin + "/companies/" + fixture["company"] + "/assets", flush=True)
print("Ctrl+C retira exclusivamente este entorno y su sesión.", flush=True)
signal.pause()
finally:
env_file.unlink(missing_ok=True)
state.unlink(missing_ok=True)
if container_created:
removed = subprocess.run(["docker", "rm", "-f", "-v", container], capture_output=True, timeout=30, check=False)
if removed.returncode != 0 and "No such container" not in removed.stderr.decode(errors="replace"):
raise RuntimeError("No se pudo confirmar la retirada del contenedor temporal; consulta la limpieza manual documentada.")
print("Entorno temporal retirado; las capturas se conservan sin sesión ni credenciales.", flush=True)
if __name__ == "__main__":
signal.signal(signal.SIGTERM, lambda *_: sys.exit(130))
try:
main()
except KeyboardInterrupt:
print("Auditoría finalizada.")
except (OSError, RuntimeError, ValueError, KeyError, StopIteration, subprocess.SubprocessError) as error:
# No volcar excepciones de HTTP/MCP: podrían incluir contraseñas o cookies.
print("La comprobación falló. Revisa los requisitos Docker/WSL y los logs de servicios locales; no se ha validado la integración.", file=sys.stderr)
if isinstance(error, RuntimeError):
print(str(error), file=sys.stderr)
sys.exit(1)