#!/usr/bin/env python3
"""
Test automatisé de sécurité et UX — laurentkeller.org
Tourne en cron, archive les résultats, détecte les régressions.
"""
import json
import subprocess
import sys
import os
from datetime import datetime, timezone
from pathlib import Path
# Configuration
SITE = "https://laurentkeller.org"
REPORT_DIR = Path(os.path.expanduser("~/.hermes/cron/output/site-tests"))
REPORT_DIR.mkdir(parents=True, exist_ok=True)
HISTORY_FILE = REPORT_DIR / "history.json"
def curl(url, **kwargs):
"""Wrapper curl avec timeout."""
cmd = ["curl", "-s", "--connect-timeout", "5", "--max-time", "10"]
for k, v in kwargs.items():
if v is True:
cmd.append(f"--{k.replace('_', '-')}")
elif v:
cmd.append(f"--{k.replace('_', '-')}")
cmd.append(str(v))
cmd.append(url)
try:
r = subprocess.run(cmd, capture_output=True, text=True, timeout=15)
return {"exit": r.returncode, "stdout": r.stdout, "stderr": r.stderr}
except subprocess.TimeoutExpired:
return {"exit": -1, "stdout": "", "stderr": "timeout"}
def test_headers():
"""Teste les en-têtes de sécurité."""
r = curl(SITE, head=True)
headers_raw = r["stdout"].lower()
results = {
"strict-transport-security": "max-age=31536000; includesubdomains" in headers_raw,
"x-frame-options": "deny" in headers_raw,
"x-content-type-options": "nosniff" in headers_raw,
"content-security-policy": "default-src 'self'" in headers_raw,
"upgrade-insecure-requests": "upgrade-insecure-requests" in headers_raw,
"permissions-policy": "camera=(), microphone=(), geolocation=()" in headers_raw,
"referrer-policy": "strict-origin-when-cross-origin" in headers_raw,
"x-powered-by": "next.js" in headers_raw,
}
# Extraire le CSP complet
csp_line = [l for l in r["stdout"].split("\n") if "content-security-policy:" in l.lower()]
results["csp_img_src"] = "s3.keller-laurent.org" in (csp_line[0] if csp_line else "")
return results
def test_http_redirect():
"""Teste la redirection HTTP → HTTPS."""
r = curl("http://laurentkeller.org/", head=True, location=False)
has_redirect = "301" in r["stderr"] or "location: https://" in r["stdout"].lower()
return has_redirect
def test_tls():
"""Teste la version TLS."""
r = subprocess.run(
["openssl", "s_client", "-servername", "laurentkeller.org",
"-connect", "laurentkeller.org:443", "-tlsextdebug"],
capture_output=True, text=True, input="", timeout=10
)
tls_version = "unknown"
for line in r.stdout.split("\n"):
if "Protocol" in line:
tls_version = line.strip()
break
return tls_version
def test_sensitive_files():
"""Teste l'accès aux fichiers sensibles."""
paths = [
"/.env", "/.env.local", "/package.json", "/.git/config",
"/.git/HEAD", "/admin", "/api/auth-debug", "/config.json",
"/backup", "/wp-admin",
]
results = {}
for path in paths:
r = curl(f"{SITE}{path}", head=True)
code = r["stdout"].split("\n")[0] if r["stdout"] else "000"
# Extraire le code HTTP
if "HTTP/" in code:
code = code.split()[1]
elif r["exit"] != 0:
code = "000"
else:
code = "200" # fallback
results[path] = code
return results
def test_methods():
"""Teste les méthodes HTTP non autorisées."""
methods = ["PUT", "DELETE", "PATCH", "OPTIONS", "TRACE"]
results = {}
for method in methods:
r = subprocess.run(
["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}",
"-X", method, "--connect-timeout", "5", "--max-time", "10",
f"{SITE}/galerie"],
capture_output=True, text=True, timeout=12
)
results[method] = r.stdout.strip()
return results
def test_xss():
"""Teste XSS basique."""
r = curl(f"{SITE}/galerie?q=")
blocked = "