Sécurité: nginx CSP, ACL réseau, SSH hardening, PostgreSQL, assets locaux

- Nginx: headers HSTS/X-Frame/nosniff/CSP, rate limit login 5r/m
- CSP: self only, unsafe-inline (Tailwind JIT), object-src none, pas de CDN externe
- Assets locaux: Tailwind/HTMX/Alpine.js téléchargés dans /static/js/
- ACL réseau: table allowed_networks administrable depuis Settings
- Fichier /etc/nginx/patchcenter_acl.conf régénéré auto depuis la base
- PostgreSQL: logs connexion/déconnexion, requêtes lentes >1s, max 50 conn
- REVOKE CREATE pour user patchcenter, role readonly créé
- SSH: clé only, 3 tentatives, pas de TCP forwarding
- Backup toutes les 30min, rétention 3 jours
- Application 100% hors ligne (aucune dépendance internet côté navigateur)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Khalid MOUTAOUAKIL 2026-04-05 01:39:19 +02:00
parent a8f7329a48
commit 87a4585cf1
6 changed files with 232 additions and 3 deletions

View File

@ -55,6 +55,10 @@ SECTIONS = {
("vsphere_user", "Utilisateur vCenter", False),
("vsphere_pass", "Mot de passe vCenter", True),
],
"security": [
("security_session_timeout", "Timeout session (minutes)", False),
("security_max_login_attempts", "Max tentatives login", False),
],
"splunk": [
("splunk_hec_url", "URL HEC", False),
("splunk_hec_token", "Token HEC", True),
@ -98,6 +102,7 @@ SECTION_ACCESS = {
"splunk": {"visible": ["admin", "coordinator"], "editable": ["admin", "coordinator"]},
"teams": {"visible": ["admin", "coordinator"], "editable": ["admin", "coordinator"]},
"itop": {"visible": ["admin"], "editable": ["admin"]},
"security": {"visible": ["admin"], "editable": ["admin"]},
}
@ -108,6 +113,7 @@ def _build_context(db, user, saved=None):
q_assets = db.execute(text("SELECT COUNT(*) FROM qualys_assets")).scalar()
q_linked = db.execute(text("SELECT COUNT(*) FROM servers WHERE qualys_asset_id IS NOT NULL")).scalar()
vcenters = db.execute(text("SELECT * FROM vcenters ORDER BY name")).fetchall()
allowed_nets = db.execute(text("SELECT * FROM allowed_networks ORDER BY cidr")).fetchall()
# Filtrer les sections visibles selon le role
visible = {s: s in SECTION_ACCESS and role in SECTION_ACCESS[s]["visible"] for s in SECTIONS}
@ -116,6 +122,7 @@ def _build_context(db, user, saved=None):
return {
"user": user, "app_name": APP_NAME, "role": role,
"sections": SECTIONS, "vals": _load_section_values(db),
"allowed_nets": allowed_nets,
"q_tags": q_tags, "q_assets": q_assets, "q_linked": q_linked,
"vcenters": vcenters, "saved": saved,
"visible": visible, "editable": editable,
@ -205,3 +212,64 @@ async def secret_update(request: Request, db=Depends(get_db),
ctx = _build_context(db, user, saved="secret")
ctx["request"] = request
return templates.TemplateResponse("settings.html", ctx)
# --- Réseaux autorisés ---
def _regen_nginx_acl(db):
"""Régénère le fichier ACL nginx depuis la base"""
nets = db.execute(text("SELECT cidr FROM allowed_networks WHERE is_active = true ORDER BY cidr")).fetchall()
lines = ["# Généré par PatchCenter — ne pas éditer manuellement"]
for n in nets:
lines.append(f"allow {n.cidr};")
lines.append("deny all;")
try:
with open("/etc/nginx/patchcenter_acl.conf", "w") as f:
f.write("\n".join(lines) + "\n")
import subprocess
subprocess.run(["nginx", "-t"], capture_output=True, check=True)
subprocess.run(["systemctl", "reload", "nginx"], capture_output=True)
return True
except Exception:
return False
@router.post("/settings/network/add")
async def network_add(request: Request, db=Depends(get_db),
cidr: str = Form(...), description: str = Form("")):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
db.execute(text("INSERT INTO allowed_networks (cidr, description) VALUES (:c, :d)"),
{"c": cidr.strip(), "d": description or None})
db.commit()
_regen_nginx_acl(db)
ctx = _build_context(db, user, saved="security")
ctx["request"] = request
return templates.TemplateResponse("settings.html", ctx)
@router.post("/settings/network/{net_id}/delete")
async def network_delete(request: Request, net_id: int, db=Depends(get_db)):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
db.execute(text("DELETE FROM allowed_networks WHERE id = :id"), {"id": net_id})
db.commit()
_regen_nginx_acl(db)
ctx = _build_context(db, user, saved="security")
ctx["request"] = request
return templates.TemplateResponse("settings.html", ctx)
@router.post("/settings/network/{net_id}/toggle")
async def network_toggle(request: Request, net_id: int, db=Depends(get_db)):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
db.execute(text("UPDATE allowed_networks SET is_active = NOT is_active WHERE id = :id"), {"id": net_id})
db.commit()
_regen_nginx_acl(db)
ctx = _build_context(db, user, saved="security")
ctx["request"] = request
return templates.TemplateResponse("settings.html", ctx)

5
app/static/js/alpine.min.js vendored Normal file

File diff suppressed because one or more lines are too long

1
app/static/js/htmx.min.js vendored Normal file

File diff suppressed because one or more lines are too long

83
app/static/js/tailwind.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@ -4,9 +4,9 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ app_name }} - {% block title %}{% endblock %}</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/htmx.org@1.9.12"></script>
<script src="https://unpkg.com/alpinejs@3.14.3/dist/cdn.min.js" defer></script>
<script src="/static/js/tailwind.min.js"></script>
<script src="/static/js/htmx.min.js"></script>
<script src="/static/js/alpine.min.js" defer></script>
<script>
tailwind.config = {
theme: { extend: { colors: {

View File

@ -279,6 +279,78 @@
</div>
{% endif %}
<!-- Sécurité -->
{% if visible.security %}
<div class="card overflow-hidden">
{{ section_header("security", "Sécurité", "Réseau", "badge-red") }}
<div x-show="open === 'security'" class="border-t border-cyber-border p-4 space-y-4">
<!-- Réseaux autorisés -->
<h4 class="text-xs text-cyber-accent font-bold uppercase">Réseaux autorisés (nginx ACL)</h4>
<table class="w-full table-cyber text-sm">
<thead><tr>
<th class="text-left p-2">CIDR</th>
<th class="text-left p-2">Description</th>
<th class="p-2">Actif</th>
{% if editable.security %}<th class="p-2">Actions</th>{% endif %}
</tr></thead>
<tbody>
{% for n in allowed_nets %}
<tr class="{% if not n.is_active %}opacity-40{% endif %}">
<td class="p-2 font-mono text-cyber-accent">{{ n.cidr }}</td>
<td class="p-2 text-xs text-gray-400">{{ n.description or '-' }}</td>
<td class="p-2 text-center"><span class="badge {% if n.is_active %}badge-green{% else %}badge-red{% endif %}">{{ 'Oui' if n.is_active else 'Non' }}</span></td>
{% if editable.security %}
<td class="p-2 text-center">
<div class="flex gap-1 justify-center">
<form method="POST" action="/settings/network/{{ n.id }}/toggle" style="display:inline">
<button class="btn-sm bg-cyber-border text-gray-400">{{ 'Désactiver' if n.is_active else 'Activer' }}</button>
</form>
<form method="POST" action="/settings/network/{{ n.id }}/delete" style="display:inline">
<button class="btn-sm bg-red-900/30 text-cyber-red" onclick="return confirm('Supprimer ce réseau ?')">Suppr</button>
</form>
</div>
</td>
{% endif %}
</tr>
{% endfor %}
</tbody>
</table>
{% if editable.security %}
<form method="POST" action="/settings/network/add" class="flex gap-3 items-end mt-2">
<div>
<label class="text-xs text-gray-500">CIDR</label>
<input type="text" name="cidr" placeholder="10.0.0.0/24" class="text-xs py-1 px-2 w-40 font-mono" required>
</div>
<div class="flex-1">
<label class="text-xs text-gray-500">Description</label>
<input type="text" name="description" placeholder="VPN nomade" class="text-xs py-1 px-2 w-full">
</div>
<button type="submit" class="btn-primary px-3 py-1 text-sm">Ajouter</button>
</form>
{% endif %}
<p class="text-xs text-gray-600 mt-2">Le fichier <code>/etc/nginx/patchcenter_acl.conf</code> est régénéré automatiquement à chaque modification. Nginx est rechargé.</p>
<!-- Paramètres sécurité -->
<h4 class="text-xs text-cyber-accent font-bold uppercase mt-4">Paramètres</h4>
<form method="POST" action="/settings/security" class="space-y-3">
<div class="grid grid-cols-2 gap-3">
<div>
<label class="text-xs text-gray-500">Timeout session (minutes)</label>
<input type="number" name="security_session_timeout" value="{{ vals.security_session_timeout or '60' }}" class="w-full" {% if not editable.security %}disabled{% endif %}>
</div>
<div>
<label class="text-xs text-gray-500">Max tentatives login (rate limit/min)</label>
<input type="number" name="security_max_login_attempts" value="{{ vals.security_max_login_attempts or '5' }}" class="w-full" {% if not editable.security %}disabled{% endif %}>
</div>
</div>
{% if editable.security %}<button type="submit" class="btn-primary px-4 py-2 text-sm">Sauvegarder</button>{% endif %}
</form>
</div>
</div>
{% endif %}
<!-- Splunk Remote Log -->
{% if visible.splunk %}
<div class="card overflow-hidden">