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:
parent
a8f7329a48
commit
87a4585cf1
@ -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
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
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
83
app/static/js/tailwind.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@ -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: {
|
||||
|
||||
@ -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">
|
||||
|
||||
Loading…
Reference in New Issue
Block a user