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---")
|
||||
Executable
+124
@@ -0,0 +1,124 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Suivi quotidien du projet Mon-site-web-photos v4.
|
||||
|
||||
Utilise gh CLI pour interroger le board GitHub et produit un résumé.
|
||||
À exécuter via cronjob avec no_agent=True.
|
||||
"""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
|
||||
|
||||
def gh_run(*args: str) -> dict:
|
||||
"""Run gh CLI and return parsed JSON."""
|
||||
result = subprocess.run(
|
||||
["gh", *args],
|
||||
capture_output=True, text=True, timeout=30,
|
||||
env={**__import__("os").environ, "GH_PROMPT_DISABLED": "1"},
|
||||
)
|
||||
if result.returncode != 0:
|
||||
print(f"ERREUR gh {' '.join(args)}: {result.stderr.strip()}", file=sys.stderr)
|
||||
return {}
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def get_issue_state(number: int) -> dict:
|
||||
"""Get issue state and labels."""
|
||||
return gh_run("issue", "view", str(number), "--json", "number,title,state,labels,updatedAt")
|
||||
|
||||
|
||||
def format_daily_report() -> str:
|
||||
"""Generate the daily status report."""
|
||||
board = gh_run("project", "item-list", "2", "--owner", "lolo1580", "--limit", "50", "--format", "json")
|
||||
items = board.get("items", [])
|
||||
if not items:
|
||||
return "⚠️ Impossible de récupérer les données du board."
|
||||
|
||||
# Collect issue numbers from board items
|
||||
issue_numbers = set()
|
||||
for item in items:
|
||||
content = item.get("content", {})
|
||||
num = content.get("number")
|
||||
if num:
|
||||
issue_numbers.add(num)
|
||||
|
||||
# Get details for each issue
|
||||
issues = []
|
||||
for num in sorted(issue_numbers):
|
||||
detail = get_issue_state(num)
|
||||
if detail and detail.get("number"):
|
||||
# Merge board-level labels (more reliable)
|
||||
board_labels = []
|
||||
for item in items:
|
||||
if item.get("content", {}).get("number") == num:
|
||||
board_labels = item.get("labels", [])
|
||||
break
|
||||
detail["_board_labels"] = board_labels
|
||||
issues.append(detail)
|
||||
|
||||
# Categorize
|
||||
bugs = [i for i in issues if "bug" in i.get("_board_labels", [])]
|
||||
features = [i for i in issues if "feature" in i.get("_board_labels", [])]
|
||||
critical = [i for i in issues if "critical" in i.get("_board_labels", [])]
|
||||
high = [i for i in issues if "high" in i.get("_board_labels", [])]
|
||||
|
||||
open_issues = [i for i in issues if i.get("state") == "OPEN"]
|
||||
closed_issues = [i for i in issues if i.get("state") == "CLOSED"]
|
||||
|
||||
now = datetime.now(timezone.utc).strftime("%d/%m/%Y %H:%M UTC")
|
||||
|
||||
lines = [
|
||||
f"📋 **Suivi quotidien — Mon-site-web-photos v4**",
|
||||
f"_{now}_",
|
||||
"",
|
||||
"---",
|
||||
"",
|
||||
f"### 📊 Résumé",
|
||||
"",
|
||||
f"| Indicateur | Valeur |",
|
||||
f"|------------|--------|",
|
||||
f"| Issues ouvertes | {len(open_issues)} |",
|
||||
f"| Issues fermées | {len(closed_issues)} |",
|
||||
f"| Bugs | {len(bugs)} |",
|
||||
f"| Features | {len(features)} |",
|
||||
f"| 🔴 Critiques | {len(critical)} |",
|
||||
f"| 🟡 Hautes | {len(high)} |",
|
||||
"",
|
||||
]
|
||||
|
||||
if critical:
|
||||
lines.append("### 🔴 Bugs critiques")
|
||||
for i in critical:
|
||||
lines.append(f"- **#{i['number']}** {i['title']}")
|
||||
lines.append("")
|
||||
|
||||
if open_issues:
|
||||
lines.append("### 📌 Issues ouvertes")
|
||||
lines.append("")
|
||||
lines.append("| # | Titre | Type | Priorité |")
|
||||
lines.append("|---|------|------|----------|")
|
||||
for i in open_issues:
|
||||
labels = i.get("_board_labels", [])
|
||||
i_type = "Bug" if "bug" in labels else "Feature" if "feature" in labels else "Autre"
|
||||
priority = "🔴" if "critical" in labels else "🟡" if "high" in labels else "🟢" if "medium" in labels else "⚪"
|
||||
lines.append(f"| #{i['number']} | {i['title'][:55]} | {i_type} | {priority} |")
|
||||
lines.append("")
|
||||
|
||||
if closed_issues:
|
||||
lines.append("### ✅ Récemment fermés")
|
||||
for i in closed_issues:
|
||||
lines.append(f"- **#{i['number']}** {i['title']}")
|
||||
lines.append("")
|
||||
|
||||
lines.append("---")
|
||||
lines.append(f"🔗 **Board** : https://github.com/lolo1580/Mon-site-web-photos/projects/2")
|
||||
lines.append(f"📁 **Repo** : https://github.com/lolo1580/Mon-site-web-photos")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
report = format_daily_report()
|
||||
print(report)
|
||||
Reference in New Issue
Block a user