Files

356 lines
12 KiB
Python
Executable File

#!/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=<script>alert(1)</script>")
blocked = "<script>alert" not in r["stdout"]
return blocked
def test_path_traversal():
"""Teste path traversal."""
r = curl(f"{SITE}/../../../etc/passwd", head=True)
code = r["stdout"].split("\n")[0] if r["stdout"] else "000"
if "HTTP/" in code:
code = code.split()[1]
return code
def test_cors():
"""Teste CORS."""
r = subprocess.run(
["curl", "-sI", "-H", "Origin: https://evil.com",
"--connect-timeout", "5", "--max-time", "10", SITE],
capture_output=True, text=True, timeout=12
)
has_cors = "access-control-allow-origin" in r.stdout.lower()
return not has_cors # True = pas de CORS ouvert = OK
def test_pages():
"""Teste l'accessibilité des pages."""
pages = [
"/", "/galerie", "/albums", "/agenda", "/wallpapers",
"/contact", "/mentions-legales", "/confidentialite",
]
results = {}
for page in pages:
r = curl(f"{SITE}{page}")
code = r["stdout"].split("\n")[0] if r["stdout"] else "000"
if "HTTP/" in code:
code = code.split()[1]
elif r["exit"] != 0:
code = "000"
else:
code = "200"
# Vérifier le contenu
content_ok = True
if page == "/albums" and "Aucun album" in r["stdout"]:
content_ok = False
if page == "/agenda" and "Aucun evenement" in r["stdout"]:
content_ok = False
if page == "/wallpapers" and "Aucun wallpaper" in r["stdout"]:
content_ok = False
if page == "/mentions-legales" and "A definir" in r["stdout"]:
content_ok = False
results[page] = {"code": code, "content_ok": content_ok}
return results
def test_contact_form():
"""Teste le formulaire contact."""
r = subprocess.run(
["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}",
"-X", "POST", "-H", "Content-Type: application/x-www-form-urlencoded",
"-d", "name=Test&email=test@test.com&message=Test automatique",
"--connect-timeout", "5", "--max-time", "10",
f"{SITE}/contact"],
capture_output=True, text=True, timeout=12
)
return r.stdout.strip()
def test_robots_sitemap():
"""Teste robots.txt et sitemap.xml."""
r_robots = curl(f"{SITE}/robots.txt")
r_sitemap = curl(f"{SITE}/sitemap.xml")
return {
"robots_ok": "Sitemap:" in r_robots["stdout"],
"sitemap_ok": "<urlset" in r_sitemap["stdout"],
}
def load_history():
"""Charge l'historique des tests."""
if HISTORY_FILE.exists():
with open(HISTORY_FILE) as f:
return json.load(f)
return {"tests": []}
def save_history(history):
"""Sauvegarde l'historique."""
with open(HISTORY_FILE, "w") as f:
json.dump(history, f, indent=2, default=str)
def compute_score(results):
"""Calcule un score de sécurité sur 100."""
score = 100
deductions = {
"headers": {
"strict-transport-security": 10,
"x-frame-options": 5,
"x-content-type-options": 5,
"content-security-policy": 15,
"upgrade-insecure-requests": 5,
"permissions-policy": 5,
"referrer-policy": 5,
},
"http_redirect": 10,
"xss_blocked": 10,
"cors_closed": 5,
"path_traversal": 5,
}
for h, ok in results.get("headers", {}).items():
if h in deductions["headers"] and not ok:
score -= deductions["headers"][h]
if not results.get("http_redirect"):
score -= deductions["http_redirect"]
if not results.get("xss_blocked"):
score -= deductions["xss_blocked"]
if not results.get("cors_closed"):
score -= deductions["cors_closed"]
if results.get("path_traversal") not in ("404", "405", "000"):
score -= deductions["path_traversal"]
return max(0, score)
def main():
print("🔍 Lancement des tests...")
results = {
"timestamp": datetime.now(timezone.utc).isoformat(),
"url": SITE,
}
# Tests
print(" Headers...", end=" ")
results["headers"] = test_headers()
print("OK" if all(results["headers"].values()) else "PROBLEMES")
print(" HTTP→HTTPS...", end=" ")
results["http_redirect"] = test_http_redirect()
print("OK" if results["http_redirect"] else "FAIL")
print(" TLS...", end=" ")
results["tls"] = test_tls()
print(results["tls"])
print(" Fichiers sensibles...", end=" ")
results["sensitive_files"] = test_sensitive_files()
safe = all(v in ("404", "405", "000", "302") for v in results["sensitive_files"].values())
print("OK" if safe else "PROBLEMES")
print(" Méthodes HTTP...", end=" ")
results["methods"] = test_methods()
methods_ok = all(v == "405" for k, v in results["methods"].items() if k in ("PUT", "DELETE", "PATCH", "TRACE"))
print("OK" if methods_ok else "PROBLEMES")
print(" XSS...", end=" ")
results["xss_blocked"] = test_xss()
print("OK" if results["xss_blocked"] else "FAIL")
print(" Path traversal...", end=" ")
results["path_traversal"] = test_path_traversal()
print(results["path_traversal"])
print(" CORS...", end=" ")
results["cors_closed"] = test_cors()
print("OK" if results["cors_closed"] else "OUVERT!")
print(" Pages...", end=" ")
results["pages"] = test_pages()
pages_ok = all(v["code"] == "200" for v in results["pages"].values())
print("OK" if pages_ok else "PROBLEMES")
print(" Formulaire contact...", end=" ")
results["contact_form"] = test_contact_form()
print(results["contact_form"])
print(" Robots/Sitemap...", end=" ")
results["seo"] = test_robots_sitemap()
print("OK" if all(results["seo"].values()) else "PROBLEMES")
# Score
results["score"] = compute_score(results)
# Pages vides / problèmes UX
empty_pages = [p for p, v in results["pages"].items() if not v["content_ok"]]
results["empty_pages"] = empty_pages
results["methods_anomalies"] = {k: v for k, v in results["methods"].items() if v not in ("405", "400")}
# Historique
history = load_history()
history["tests"].append(results)
# Garder max 52 entrées (1 an si hebdo)
if len(history["tests"]) > 52:
history["tests"] = history["tests"][-52:]
save_history(history)
# Rapport
print(f"\n{'='*50}")
print(f"📊 SCORE DE SÉCURITÉ : {results['score']}/100")
print(f"{'='*50}")
if results["score"] < 80:
print("⚠️ Score bas — actions correctives nécessaires")
elif results["score"] >= 95:
print("✅ Excellent")
else:
print("👍 Correct")
# Régression ?
if len(history["tests"]) >= 2:
prev = history["tests"][-2]
diff = results["score"] - prev["score"]
if diff < 0:
print(f"📉 Régression de {abs(diff)} points depuis le dernier test")
elif diff > 0:
print(f"📈 Amélioration de {diff} points depuis le dernier test")
# Problèmes détectés
issues = []
if not all(results["headers"].values()):
issues.append("Headers de sécurité incomplets")
if not results["http_redirect"]:
issues.append("Pas de redirection HTTP→HTTPS")
if not results["xss_blocked"]:
issues.append("Vulnérabilité XSS détectée")
if not results["cors_closed"]:
issues.append("CORS ouvert")
if results["methods_anomalies"]:
issues.append(f"Méthodes HTTP anormales : {results['methods_anomalies']}")
if empty_pages:
issues.append(f"Pages vides : {empty_pages}")
if results["contact_form"] != "200":
issues.append(f"Formulaire contact anormal : {results['contact_form']}")
if issues:
print(f"\n🔴 Problèmes détectés ({len(issues)}) :")
for i, issue in enumerate(issues, 1):
print(f" {i}. {issue}")
else:
print("\n✅ Aucun problème détecté")
# Sauvegarde du rapport individuel
report_file = REPORT_DIR / f"rapport-{datetime.now().strftime('%Y-%m-%d_%H-%M')}.json"
with open(report_file, "w") as f:
json.dump(results, f, indent=2, default=str)
print(f"\n📁 Rapport sauvegardé : {report_file}")
return results
if __name__ == "__main__":
results = main()
# Sortie JSON pour le cron
print(f"\n---JSON---\n{json.dumps({'score': results['score'], 'issues': len([k for k,v in results.get('headers',{}).items() if not v]) + (0 if results.get('http_redirect') else 1) + (0 if results.get('xss_blocked') else 1)}, indent=2)}\n---ENDJSON---")