356 lines
12 KiB
Python
Executable File
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---")
|