Initial import: monitoring, power-management, maintenance, web scripts
This commit is contained in:
Executable
+355
@@ -0,0 +1,355 @@
|
||||
#!/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---")
|
||||
Reference in New Issue
Block a user