Audit complet: import JSON, carte flux, carte applicative

- server_audit_full_service.py: SSH PSMP/cle, parsing, stockage JSONB, flow map
- server_audit.sh: script bash avec sudo (compatible PSMP cybsecope)
- audit_full router: import JSON, liste, detail, carte flux
- Templates: liste audits, detail 8 onglets, carte flux + carte applicative
- Jointures: server_id via servers, dest_server via server_ips
- Sous-menu Audit > Complet dans la sidebar

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Khalid MOUTAOUAKIL 2026-04-06 16:06:55 +02:00
parent 833c4fc3d2
commit 20cd9c7d80
8 changed files with 1383 additions and 1 deletions

View File

@ -6,7 +6,7 @@ from starlette.middleware.base import BaseHTTPMiddleware
from .config import APP_NAME, APP_VERSION
from .dependencies import get_current_user, get_user_perms
from .database import SessionLocal
from .routers import auth, dashboard, servers, settings, users, campaigns, planning, specifics, audit, contacts, qualys, safe_patching
from .routers import auth, dashboard, servers, settings, users, campaigns, planning, specifics, audit, contacts, qualys, safe_patching, audit_full
class PermissionsMiddleware(BaseHTTPMiddleware):
@ -42,6 +42,7 @@ app.include_router(audit.router)
app.include_router(contacts.router)
app.include_router(qualys.router)
app.include_router(safe_patching.router)
app.include_router(audit_full.router)
@app.get("/")

104
app/routers/audit_full.py Normal file
View File

@ -0,0 +1,104 @@
"""Router Audit Complet — import JSON, liste, detail, carte flux, carte applicative"""
import json
from fastapi import APIRouter, Request, Depends, UploadFile, File
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy import text
from ..dependencies import get_db, get_current_user, get_user_perms, can_view, base_context
from ..services.server_audit_full_service import (
import_json_report, get_latest_audits, get_audit_detail,
get_flow_map, get_flow_map_for_server, get_app_map,
)
from ..config import APP_NAME
router = APIRouter()
templates = Jinja2Templates(directory="app/templates")
@router.get("/audit-full", response_class=HTMLResponse)
async def audit_full_list(request: Request, db=Depends(get_db)):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_view(perms, "audit"):
return RedirectResponse(url="/dashboard")
audits = get_latest_audits(db)
ctx = base_context(request, db, user)
ctx.update({
"app_name": APP_NAME, "audits": audits,
"msg": request.query_params.get("msg"),
})
return templates.TemplateResponse("audit_full_list.html", ctx)
@router.post("/audit-full/import")
async def audit_full_import(request: Request, db=Depends(get_db),
file: UploadFile = File(...)):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
try:
content = await file.read()
json_data = json.loads(content.decode("utf-8-sig"))
imported, errors = import_json_report(db, json_data)
return RedirectResponse(
url=f"/audit-full?msg=imported_{imported}_{errors}",
status_code=303,
)
except Exception as e:
return RedirectResponse(
url=f"/audit-full?msg=error_{str(e)[:50]}",
status_code=303,
)
@router.get("/audit-full/{audit_id}", response_class=HTMLResponse)
async def audit_full_detail(request: Request, audit_id: int, db=Depends(get_db)):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
audit = get_audit_detail(db, audit_id)
if not audit:
return RedirectResponse(url="/audit-full")
# Flux pour ce serveur
flows = get_flow_map_for_server(db, audit.hostname)
ctx = base_context(request, db, user)
ctx.update({
"app_name": APP_NAME, "a": audit, "flows": flows,
"services": audit.services if isinstance(audit.services, list) else json.loads(audit.services or "[]"),
"processes": audit.processes if isinstance(audit.processes, list) else json.loads(audit.processes or "[]"),
"listen_ports": audit.listen_ports if isinstance(audit.listen_ports, list) else json.loads(audit.listen_ports or "[]"),
"connections": audit.connections if isinstance(audit.connections, list) else json.loads(audit.connections or "[]"),
"flux_in": audit.flux_in if isinstance(audit.flux_in, list) else json.loads(audit.flux_in or "[]"),
"flux_out": audit.flux_out if isinstance(audit.flux_out, list) else json.loads(audit.flux_out or "[]"),
"disk_usage": audit.disk_usage if isinstance(audit.disk_usage, list) else json.loads(audit.disk_usage or "[]"),
"interfaces": audit.interfaces if isinstance(audit.interfaces, list) else json.loads(audit.interfaces or "[]"),
"correlation": audit.correlation_matrix if isinstance(audit.correlation_matrix, list) else json.loads(audit.correlation_matrix or "[]"),
"outbound": audit.outbound_only if isinstance(audit.outbound_only, list) else json.loads(audit.outbound_only or "[]"),
"firewall": audit.firewall if isinstance(audit.firewall, dict) else json.loads(audit.firewall or "{}"),
"conn_wait": audit.conn_wait if isinstance(audit.conn_wait, list) else json.loads(audit.conn_wait or "[]"),
"traffic": audit.traffic if isinstance(audit.traffic, list) else json.loads(audit.traffic or "[]"),
})
return templates.TemplateResponse("audit_full_detail.html", ctx)
@router.get("/audit-full/flow-map", response_class=HTMLResponse)
async def audit_full_flow_map(request: Request, db=Depends(get_db)):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
flows = get_flow_map(db)
app_map = get_app_map(db)
ctx = base_context(request, db, user)
ctx.update({
"app_name": APP_NAME, "flows": flows, "app_map": app_map,
})
return templates.TemplateResponse("audit_full_flowmap.html", ctx)

412
app/scripts/server_audit.sh Executable file
View File

@ -0,0 +1,412 @@
#!/bin/bash
# Audit complet serveur — applicatif + reseau + correlation
# Compatible RHEL 7/8/9
# Usage : bash server_audit.sh
HOSTNAME=$(hostname -s 2>/dev/null || hostname)
DATE=$(date '+%Y-%m-%d %H:%M:%S')
EXCLUDE_PROC="kworker|ksoftirq|migration|watchdog|kthread|rcu_|irq/|scsi|ata_|writeback|sshd:.*notty|bash /tmp|server_audit|capture_state|PM2.*God Daemon"
EXCLUDE_SVC="auditd|chronyd|crond|dbus|firewalld|getty|irqbalance|kdump|lvm2|lvmetad|NetworkManager|polkit|rsyslog|sshd|sssd|systemd|tuned|qualys|sentinelone|zabbix|commvault|veeam|tina|login|agetty|mingetty|logind|accounts-daemon|udisksd"
echo "########################################################"
echo "# AUDIT COMPLET — $HOSTNAME"
echo "# $DATE"
echo "# OS: $(cat /etc/redhat-release 2>/dev/null || head -1 /etc/os-release 2>/dev/null)"
echo "# Kernel: $(uname -r)"
echo "# Uptime: $(uptime -p 2>/dev/null || uptime)"
echo "########################################################"
##############################################################
# PARTIE 1 — APPLICATIF
##############################################################
echo ""
echo "========================================================"
echo " PARTIE 1 — ANALYSE APPLICATIVE"
echo "========================================================"
# ── 1.1 Services applicatifs running ──
echo ""
echo "=== 1.1 SERVICES APPLICATIFS RUNNING ==="
echo "SERVICE|ENABLED|MAIN_PID|USER|EXEC_START"
for svc in $(sudo systemctl list-units --type=service --state=running --no-pager --no-legend | awk '{print $1}'); do
svc_short=$(echo "$svc" | sed 's/.service//')
echo "$svc_short" | grep -qiE "$EXCLUDE_SVC" && continue
enabled=$(sudo systemctl is-enabled "$svc" 2>/dev/null)
pid=$(sudo systemctl show -p MainPID "$svc" 2>/dev/null | awk -F= '{print $2}')
user=$(sudo systemctl show -p User "$svc" 2>/dev/null | awk -F= '{print $2}')
execstart=""
[ -n "$pid" ] && [ "$pid" != "0" ] && [ -f "/proc/$pid/cmdline" ] && \
execstart=$(tr '\0' ' ' < "/proc/$pid/cmdline" 2>/dev/null | cut -c1-150)
if [ -z "$execstart" ]; then
execstart=$(sudo systemctl show -p ExecStart "$svc" 2>/dev/null | grep -oP '(?<=path=)\S+' | head -1)
fi
echo "$svc_short|$enabled|$pid|${user:-root}|$execstart"
done
# ── 1.2 Processus applicatifs ──
echo ""
echo "=== 1.2 PROCESSUS APPLICATIFS ==="
echo "PID|PPID|USER|EXE|CWD|CMDLINE|RESTART_HINT"
for pid in $(sudo ls -d /proc/[0-9]* 2>/dev/null | sed 's|/proc/||' | sort -n); do
[ ! -f /proc/$pid/cmdline ] && continue
cmd=$(tr '\0' ' ' < /proc/$pid/cmdline 2>/dev/null)
[ -z "$cmd" ] && continue
echo "$cmd" | grep -qE '^\[' && continue
echo "$cmd" | grep -qiE "$EXCLUDE_PROC" && continue
user=$(sudo stat -c %U /proc/$pid 2>/dev/null)
ppid=$(sudo awk '/PPid/{print $2}' /proc/$pid/status 2>/dev/null)
exe=$(sudo readlink /proc/$pid/exe 2>/dev/null)
cwd=$(sudo readlink /proc/$pid/cwd 2>/dev/null)
is_interesting=0
[ "$ppid" = "1" ] && is_interesting=1
echo "$cwd" | grep -qi "/applis" && is_interesting=1
echo "$exe" | grep -qiE "node|java|python|ruby|perl|php|tomcat" && is_interesting=1
echo "$cmd" | grep -qiE "/applis|\.jar|\.js |\.py |\.rb " && is_interesting=1
sudo ss -tlnp 2>/dev/null | grep "pid=$pid," >/dev/null && is_interesting=1
[ "$is_interesting" = "0" ] && continue
echo "$cmd" | grep -qiE "$EXCLUDE_SVC" && continue
# Exclure workers enfants (meme exe que le parent)
if [ "$ppid" != "1" ] && [ -n "$exe" ]; then
parent_exe=$(sudo readlink /proc/$ppid/exe 2>/dev/null)
[ "$exe" = "$parent_exe" ] && continue
# Exclure enfants dont le grandparent est PM2 God Daemon
grandppid=$(sudo awk '/PPid/{print $2}' /proc/$ppid/status 2>/dev/null)
if [ -n "$grandppid" ]; then
grandp_cmd=$(tr '\0' ' ' < /proc/$grandppid/cmdline 2>/dev/null)
echo "$grandp_cmd" | grep -qiE "PM2.*God Daemon" && continue
fi
fi
# Construire hint de redemarrage
hint=""
# Priorite 1: systemd
svc_match=$(sudo systemctl status $pid 2>/dev/null | head -1 | grep -oP '\S+\.service' | head -1)
if [ -z "$svc_match" ]; then
exe_name=$(basename "$exe" 2>/dev/null)
[ -n "$exe_name" ] && svc_match=$(sudo systemctl list-units --type=service --state=running --no-pager --no-legend 2>/dev/null | grep -i "$exe_name" | awk '{print $1}' | head -1)
fi
if [ -n "$svc_match" ]; then
hint="sudo systemctl restart $svc_match"
# Priorite 2: PM2
elif command -v pm2 &>/dev/null; then
pm2_name=""
pm2_json=""
for pm2_user in "$user" root $(sudo ps aux 2>/dev/null | grep -i 'PM2\|pm2' | grep -v grep | awk '{print $1}' | sort -u); do
pm2_json=$(su - "$pm2_user" -c "pm2 jlist 2>/dev/null" 2>/dev/null)
[ -z "$pm2_json" ] && continue
pm2_name=$(echo "$pm2_json" | grep -oP '"pid"\s*:\s*'$pid'\b[^}]*"name"\s*:\s*"\K[^"]+' 2>/dev/null | head -1)
[ -n "$pm2_name" ] && break
pm2_name=$(echo "$pm2_json" | grep -oP '"name"\s*:\s*"\K[^"]+(?=[^}]*"pid"\s*:\s*'$pid'\b)' 2>/dev/null | head -1)
[ -n "$pm2_name" ] && break
done
if [ -n "$pm2_name" ]; then
pm2_bin=$(which pm2 2>/dev/null || echo "/usr/local/bin/pm2")
pm2_exec=$(echo "$pm2_json" | grep -oP '"pm_exec_path"\s*:\s*"\K[^"]+' 2>/dev/null | head -1)
pm2_args=$(echo "$pm2_json" | grep -oP '"args"\s*:\s*\[\K[^\]]+' 2>/dev/null | head -1 | sed 's/"//g; s/,/ /g')
if [ -n "$pm2_exec" ] && [ -n "$pm2_args" ]; then
hint="su - $pm2_user -c '$pm2_bin start $pm2_exec --name $pm2_name -- $pm2_args'"
else
hint="su - $pm2_user -c '$pm2_bin restart $pm2_name'"
fi
fi
fi
# Priorite 3: start script dans cwd
if [ -z "$hint" ] && [ -n "$cwd" ] && [ -d "$cwd" ]; then
start_script=$(find "$cwd" -maxdepth 1 \( -name "start*" -o -name "run*" -o -name "*.sh" \) -executable 2>/dev/null | head -1)
if [ -n "$start_script" ]; then
hint="su - $user -c 'cd $cwd && $start_script'"
else
hint="su - $user -c 'cd $cwd && $(echo $cmd | cut -c1-120)'"
fi
fi
# Priorite 4: commande directe
if [ -z "$hint" ]; then
hint="su - $user -c '$(echo $cmd | cut -c1-120)'"
fi
echo "$pid|$ppid|$user|$exe|$cwd|$(echo $cmd | cut -c1-150)|$hint"
done
# ── 1.3 Services failed ──
echo ""
echo "=== 1.3 SERVICES EN ECHEC ==="
failed=$(sudo systemctl list-units --type=service --state=failed --no-pager --no-legend 2>/dev/null)
if [ -n "$failed" ]; then
echo "$failed"
else
echo "Aucun service en echec"
fi
# ── 1.4 Needs-restarting ──
echo ""
echo "=== 1.4 NEEDS-RESTARTING ==="
if ! command -v needs-restarting &>/dev/null; then
major=$(rpm -E %{rhel} 2>/dev/null || cat /etc/redhat-release 2>/dev/null | grep -oP '\d+' | head -1)
if [ "$major" -ge 9 ] 2>/dev/null; then
dnf install -y dnf-utils 2>&1 | tail -1
elif [ "$major" -ge 8 ] 2>/dev/null; then
dnf install -y yum-utils 2>&1 | tail -1
else
yum install -y yum-utils 2>&1 | tail -1
fi
fi
if command -v needs-restarting &>/dev/null; then
sudo needs-restarting -r 2>/dev/null
echo "EXIT_CODE=$?"
echo "--- Services a redemarrer ---"
sudo needs-restarting -s 2>/dev/null
else
echo "sudo needs-restarting non disponible"
fi
# ── 1.5 Espace disque ──
echo ""
echo "=== 1.5 ESPACE DISQUE ==="
df -h --output=target,size,used,avail,pcent 2>/dev/null | grep -vE '^(tmpfs|devtmpfs)' || df -h 2>/dev/null
##############################################################
# PARTIE 2 — RESEAU
##############################################################
echo ""
echo "========================================================"
echo " PARTIE 2 — ANALYSE RESEAU"
echo "========================================================"
# ── 2.1 Interfaces et IPs ──
echo ""
echo "=== 2.1 INTERFACES RESEAU ==="
echo "INTERFACE|IP|MASK|STATE|MAC"
ip -4 -o addr show 2>/dev/null | while read idx iface scope ip_mask rest; do
ip=$(echo "$ip_mask" | cut -d/ -f1)
mask=$(echo "$ip_mask" | cut -d/ -f2)
state=$(ip link show "$iface" 2>/dev/null | grep -oP '(?<=state )\S+' | head -1)
mac=$(ip link show "$iface" 2>/dev/null | grep -oP 'link/ether \K\S+' | head -1)
echo "$iface|$ip|/$mask|${state:-UP}|${mac:--}"
done
# ── 2.2 Routes ──
echo ""
echo "=== 2.2 TABLE DE ROUTAGE ==="
echo "DESTINATION|GATEWAY|INTERFACE|METRIC"
ip route show 2>/dev/null | while read line; do
dest=$(echo "$line" | awk '{print $1}')
gw=$(echo "$line" | grep -oP '(?<=via )\S+')
iface=$(echo "$line" | grep -oP '(?<=dev )\S+')
metric=$(echo "$line" | grep -oP '(?<=metric )\S+')
echo "$dest|${gw:-direct}|${iface:--}|${metric:--}"
done
# ── 2.3 Ports en ecoute ──
echo ""
echo "=== 2.3 PORTS EN ECOUTE ==="
echo "PROTO|ADDR:PORT|PID|PROCESS|USER|SERVICE"
sudo ss -tlnp 2>/dev/null | grep LISTEN | while read state recvq sendq local peer info; do
pid=$(echo "$info" | grep -oP 'pid=\K[0-9]+' | head -1)
proc=$(echo "$info" | grep -oP '"\K[^"]+' | head -1)
port=$(echo "$local" | grep -oP ':\K[0-9]+$')
user=""
[ -n "$pid" ] && user=$(sudo stat -c %U /proc/$pid 2>/dev/null)
svc=$(getent services "$port/tcp" 2>/dev/null | awk '{print $1}')
echo "TCP|$local|${pid:--}|${proc:--}|${user:--}|${svc:--}"
done
sudo ss -ulnp 2>/dev/null | grep -v 'State' | while read state recvq sendq local peer info; do
[ -z "$local" ] && continue
pid=$(echo "$info" | grep -oP 'pid=\K[0-9]+' | head -1)
proc=$(echo "$info" | grep -oP '"\K[^"]+' | head -1)
port=$(echo "$local" | grep -oP ':\K[0-9]+$')
user=""
[ -n "$pid" ] && user=$(sudo stat -c %U /proc/$pid 2>/dev/null)
svc=$(getent services "$port/udp" 2>/dev/null | awk '{print $1}')
echo "UDP|$local|${pid:--}|${proc:--}|${user:--}|${svc:--}"
done
# ── 2.4 Connexions etablies ──
echo ""
echo "=== 2.4 CONNEXIONS ETABLIES ==="
echo "DIRECTION|PROTO|LOCAL|REMOTE|PID|PROCESS|USER|STATE"
listen_ports=$(sudo ss -tlnp 2>/dev/null | grep LISTEN | grep -oP '\S+:(\d+)\s' | grep -oP ':\K\d+' | sort -u)
sudo ss -tnp 2>/dev/null | grep -v 'State' | while read state recvq sendq local remote info; do
[ "$state" = "State" ] && continue
[ -z "$local" ] && continue
pid=$(echo "$info" | grep -oP 'pid=\K[0-9]+' | head -1)
proc=$(echo "$info" | grep -oP '"\K[^"]+' | head -1)
user=""
[ -n "$pid" ] && user=$(sudo stat -c %U /proc/$pid 2>/dev/null)
local_port=$(echo "$local" | grep -oP ':\K[0-9]+$')
direction="OUT"
echo "$listen_ports" | grep -qx "$local_port" && direction="IN"
echo "$direction|TCP|$local|$remote|${pid:--}|${proc:--}|${user:--}|$state"
done
# ── 2.5 Resume flux entrants ──
echo ""
echo "=== 2.5 RESUME FLUX ENTRANTS (par port) ==="
echo "PORT|SERVICE|PROCESS|NB_CONNEXIONS|SOURCES"
for port in $(sudo ss -tnp 2>/dev/null | grep ESTAB | awk '{print $4}' | grep -oP ':\K\d+$' | sort -n | uniq); do
echo "$listen_ports" | grep -qx "$port" || continue
svc=$(getent services "$port/tcp" 2>/dev/null | awk '{print $1}')
proc=$(sudo ss -tlnp 2>/dev/null | grep ":$port " | grep -oP '"\K[^"]+' | head -1)
count=$(sudo ss -tnp 2>/dev/null | grep ESTAB | awk '{print $4}' | grep -P ":${port}$" | wc -l)
sources=$(sudo ss -tnp 2>/dev/null | grep ESTAB | awk -v p=":${port}$" '$4 ~ p {print $5}' | grep -oP '^[^:]+' | sort -u | head -10 | tr '\n' ',' | sed 's/,$//')
echo "$port|${svc:--}|${proc:--}|$count|${sources:--}"
done
# ── 2.6 Resume flux sortants ──
echo ""
echo "=== 2.6 RESUME FLUX SORTANTS (par destination:port) ==="
echo "DEST_IP|DEST_PORT|SERVICE|PROCESS|NB_CONNEXIONS"
sudo ss -tnp 2>/dev/null | grep ESTAB | while read state recvq sendq local remote info; do
local_port=$(echo "$local" | grep -oP ':\K[0-9]+$')
echo "$listen_ports" | grep -qx "$local_port" && continue
echo "$remote|$(echo "$info" | grep -oP '"\K[^"]+' | head -1)"
done | sort | uniq -c | sort -rn | head -50 | while read count dest_info; do
dest_ip=$(echo "$dest_info" | grep -oP '^\S+(?=:\d+)')
dest_port=$(echo "$dest_info" | grep -oP ':\K\d+(?=\|)')
proc=$(echo "$dest_info" | awk -F'|' '{print $2}')
svc=$(getent services "$dest_port/tcp" 2>/dev/null | awk '{print $1}')
dns=$(timeout 2 host "$dest_ip" 2>/dev/null | grep -oP 'pointer \K\S+' | sed 's/\.$//' | head -1)
if [ -n "$dns" ]; then
echo "$dest_ip ($dns)|$dest_port|${svc:--}|${proc:--}|$count"
else
echo "$dest_ip|$dest_port|${svc:--}|${proc:--}|$count"
fi
done
# ── 2.7 Connexions en attente ──
echo ""
echo "=== 2.7 CONNEXIONS EN ATTENTE ==="
echo "STATE|COUNT"
for st in TIME-WAIT CLOSE-WAIT FIN-WAIT-1 FIN-WAIT-2 SYN-SENT SYN-RECV LAST-ACK CLOSING; do
cnt=$(sudo ss -tn state "$st" 2>/dev/null | grep -c -v 'State')
[ "$cnt" -gt 0 ] && echo "$st|$cnt"
done
# ── 2.8 Stats reseau ──
echo ""
echo "=== 2.8 STATISTIQUES RESEAU ==="
echo "METRIC|VALUE"
echo "TCP ESTABLISHED|$(sudo ss -tn state established 2>/dev/null | grep -c -v 'State')"
echo "TCP LISTEN|$(sudo ss -tln 2>/dev/null | grep -c LISTEN)"
echo "UDP LISTEN|$(sudo ss -uln 2>/dev/null | grep -c -v 'State')"
# ── 2.9 Trafic par interface ──
echo ""
echo "=== 2.9 TRAFIC PAR INTERFACE ==="
echo "INTERFACE|RX_BYTES|RX_PACKETS|RX_ERRORS|TX_BYTES|TX_PACKETS|TX_ERRORS"
sudo cat /proc/net/dev 2>/dev/null | tail -n+3 | while read line; do
iface=$(echo "$line" | awk -F: '{print $1}' | tr -d ' ')
stats=$(echo "$line" | awk -F: '{print $2}')
rx_bytes=$(echo "$stats" | awk '{print $1}')
rx_pkt=$(echo "$stats" | awk '{print $2}')
rx_err=$(echo "$stats" | awk '{print $3}')
tx_bytes=$(echo "$stats" | awk '{print $9}')
tx_pkt=$(echo "$stats" | awk '{print $10}')
tx_err=$(echo "$stats" | awk '{print $11}')
[ "$iface" = "lo" ] && continue
rx_h=$(numfmt --to=iec "$rx_bytes" 2>/dev/null || echo "${rx_bytes}B")
tx_h=$(numfmt --to=iec "$tx_bytes" 2>/dev/null || echo "${tx_bytes}B")
echo "$iface|$rx_h|$rx_pkt|$rx_err|$tx_h|$tx_pkt|$tx_err"
done
# ── 2.10 Firewall ──
echo ""
echo "=== 2.10 FIREWALL ==="
if command -v iptables &>/dev/null; then
echo "--- POLICY ---"
for chain in INPUT OUTPUT FORWARD; do
policy=$(iptables -L "$chain" -n 2>/dev/null | head -1 | grep -oP 'policy \K\w+')
echo "$chain|$policy"
done
echo "--- INPUT ---"
echo "NUM|TARGET|PROTO|SOURCE|DEST|PORT|INFO"
iptables -L INPUT -n -v --line-numbers 2>/dev/null | tail -n+3 | head -30 | while read num pkts bytes target prot opt in out source dest rest; do
port=$(echo "$rest" | grep -oP '(dpt|dpts):\K\S+' | head -1)
echo "$num|$target|$prot|$source|$dest|${port:--}|$rest"
done
echo "--- OUTPUT ---"
echo "NUM|TARGET|PROTO|SOURCE|DEST|PORT|INFO"
iptables -L OUTPUT -n -v --line-numbers 2>/dev/null | tail -n+3 | head -30 | while read num pkts bytes target prot opt in out source dest rest; do
port=$(echo "$rest" | grep -oP '(dpt|dpts):\K\S+' | head -1)
echo "$num|$target|$prot|$source|$dest|${port:--}|$rest"
done
else
echo "iptables non disponible"
fi
if sudo systemctl is-active firewalld &>/dev/null 2>&1; then
echo "--- FIREWALLD ---"
echo "ZONE|SERVICES|PORTS"
for zone in $(firewall-cmd --get-active-zones 2>/dev/null | grep -v '^\s' | grep -v '^$'); do
svcs=$(firewall-cmd --zone="$zone" --list-services 2>/dev/null | tr ' ' ',')
ports=$(firewall-cmd --zone="$zone" --list-ports 2>/dev/null | tr ' ' ',')
echo "$zone|${svcs:--}|${ports:--}"
done
fi
##############################################################
# PARTIE 3 — CORRELATION
##############################################################
echo ""
echo "========================================================"
echo " PARTIE 3 — CORRELATION PROCESS ↔ PORTS ↔ FLUX"
echo "========================================================"
echo ""
echo "=== 3.1 MATRICE PROCESS → PORTS → CONNEXIONS ==="
echo "PROCESS|USER|PID|LISTEN_PORTS|CONN_IN|CONN_OUT|REMOTE_DESTINATIONS"
# Pour chaque process qui ecoute sur un port
sudo ss -tlnp 2>/dev/null | grep LISTEN | while read state recvq sendq local peer info; do
pid=$(echo "$info" | grep -oP 'pid=\K[0-9]+' | head -1)
proc=$(echo "$info" | grep -oP '"\K[^"]+' | head -1)
[ -z "$pid" ] && continue
port=$(echo "$local" | grep -oP ':\K[0-9]+$')
echo "$pid|$proc|$port"
done | sort -t'|' -k1,1n -u | while IFS='|' read pid proc port; do
user=$(sudo stat -c %U /proc/$pid 2>/dev/null)
# Tous les ports en ecoute pour ce PID
ports=$(sudo ss -tlnp 2>/dev/null | grep "pid=$pid," | grep -oP '\S+:\K\d+(?=\s)' | sort -un | tr '\n' ',' | sed 's/,$//')
# Connexions entrantes sur ces ports
conn_in=0
for p in $(echo "$ports" | tr ',' ' '); do
c=$(sudo ss -tnp 2>/dev/null | grep ESTAB | awk '{print $4}' | grep -c ":${p}$")
conn_in=$((conn_in + c))
done
# Connexions sortantes de ce PID
conn_out=$(sudo ss -tnp 2>/dev/null | grep ESTAB | grep "pid=$pid," | while read st rq sq lc rm inf; do
lp=$(echo "$lc" | grep -oP ':\K\d+$')
echo "$listen_ports" | grep -qx "$lp" || echo OUT
done | wc -l)
# Destinations sortantes
dests=$(sudo ss -tnp 2>/dev/null | grep ESTAB | grep "pid=$pid," | awk '{print $5}' | sort -u | head -5 | tr '\n' ',' | sed 's/,$//')
echo "$proc|${user:--}|$pid|$ports|$conn_in|$conn_out|${dests:--}"
done
# Processus avec connexions sortantes mais sans port en ecoute
echo ""
echo "=== 3.2 PROCESS SORTANTS UNIQUEMENT (pas de port en ecoute) ==="
echo "PROCESS|USER|PID|DESTINATIONS"
sudo ss -tnp 2>/dev/null | grep ESTAB | while read state recvq sendq local remote info; do
pid=$(echo "$info" | grep -oP 'pid=\K[0-9]+' | head -1)
proc=$(echo "$info" | grep -oP '"\K[^"]+' | head -1)
[ -z "$pid" ] && continue
local_port=$(echo "$local" | grep -oP ':\K[0-9]+$')
# Verifier si ce process n'ecoute PAS
sudo ss -tlnp 2>/dev/null | grep "pid=$pid," >/dev/null && continue
echo "$pid|$proc|$remote"
done | sort -t'|' -k1,1n -k3 | awk -F'|' '{
if ($1 != prev_pid) {
if (prev_pid != "") print prev_pid"|"prev_proc"|"dests
prev_pid=$1; prev_proc=$2; dests=$3
} else {
dests=dests","$3
}
} END { if (prev_pid != "") print prev_pid"|"prev_proc"|"dests }' | while IFS='|' read pid proc dests; do
user=$(sudo stat -c %U /proc/$pid 2>/dev/null)
echo "$proc|${user:--}|$pid|$dests"
done
echo ""
echo "########################################################"
echo "# FIN AUDIT — $HOSTNAME$(date '+%H:%M:%S')"
echo "########################################################"

View File

@ -0,0 +1,496 @@
"""Service audit complet serveur — applicatif + reseau + correlation + carte flux
Adapte du standalone SANEF corrige pour PatchCenter (FastAPI/PostgreSQL)
"""
import json
import re
import os
import socket
import logging
from datetime import datetime
from sqlalchemy import text
logging.getLogger("paramiko").setLevel(logging.CRITICAL)
logging.getLogger("paramiko.transport").setLevel(logging.CRITICAL)
try:
import paramiko
PARAMIKO_OK = True
except ImportError:
PARAMIKO_OK = False
SSH_KEY_FILE = "/opt/patchcenter/keys/id_rsa_cybglobal.pem"
PSMP_HOST = "psmp.sanef.fr"
CYBR_USER = "CYBP01336"
TARGET_USER = "cybsecope"
SSH_TIMEOUT = 20
ENV_DOMAINS = {
"prod": ".sanef.groupe",
"preprod": ".sanef.groupe",
"recette": ".sanef-rec.fr",
"test": ".sanef-rec.fr",
"dev": ".sanef-rec.fr",
}
BANNER_FILTERS = [
"GROUPE SANEF", "propriete du Groupe", "accederait", "emprisonnement",
"Article 323", "code penal", "Authorized uses only", "CyberArk",
"This session", "session is being",
]
SCRIPT_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "scripts", "server_audit.sh")
def _load_script():
with open(SCRIPT_PATH, "r", encoding="utf-8") as f:
return f.read()
def _get_psmp_password(db=None):
if not db:
return None
try:
from .secrets_service import get_secret
return get_secret(db, "ssh_pwd_default_pass")
except Exception:
return None
# ── DETECTION ENV + SSH (pattern SANEF corrige) ──
def detect_env(hostname):
h = hostname.lower()
c = h[1] if len(h) > 1 else ""
if c == "p": return "prod"
elif c == "i": return "preprod"
elif c == "r": return "recette"
elif c == "v": return "test"
elif c == "d": return "dev"
return "recette"
def _load_key():
if not os.path.exists(SSH_KEY_FILE):
return None
for cls in [paramiko.RSAKey, paramiko.Ed25519Key, paramiko.ECDSAKey]:
try:
return cls.from_private_key_file(SSH_KEY_FILE)
except Exception:
continue
return None
def _build_fqdn_candidates(hostname):
if "." in hostname:
return [hostname]
c = hostname[1] if len(hostname) > 1 else ""
if c in ("p", "i"):
return [f"{hostname}.sanef.groupe", f"{hostname}.sanef-rec.fr", hostname]
else:
return [f"{hostname}.sanef-rec.fr", f"{hostname}.sanef.groupe", hostname]
def _try_psmp(fqdn, password):
if not password:
return None
try:
username = f"{CYBR_USER}@{TARGET_USER}@{fqdn}"
transport = paramiko.Transport((PSMP_HOST, 22))
transport.connect()
def handler(title, instructions, prompt_list):
return [password] * len(prompt_list)
transport.auth_interactive(username, handler)
client = paramiko.SSHClient()
client._transport = transport
return client
except Exception:
return None
def _try_key(fqdn, key):
if not key:
return None
try:
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
client.connect(fqdn, port=22, username=TARGET_USER, pkey=key,
timeout=SSH_TIMEOUT, look_for_keys=False, allow_agent=False)
return client
except Exception:
return None
def ssh_connect(hostname, password=None):
fqdn_candidates = _build_fqdn_candidates(hostname)
key = _load_key()
for fqdn in fqdn_candidates:
if password:
client = _try_psmp(fqdn, password)
if client:
return client, None
if key:
client = _try_key(fqdn, key)
if client:
return client, None
return None, f"Connexion impossible sur {fqdn_candidates}"
def ssh_run_script(client, script_content, timeout=300):
try:
chan = client._transport.open_session()
chan.settimeout(timeout)
chan.exec_command("bash -s")
chan.sendall(script_content.encode("utf-8"))
chan.shutdown_write()
out = b""
while True:
try:
chunk = chan.recv(8192)
if not chunk:
break
out += chunk
except Exception:
break
chan.close()
out_str = out.decode("utf-8", errors="replace")
if not out_str.strip():
return "", "Sortie vide"
lines = [l for l in out_str.splitlines() if not any(b in l for b in BANNER_FILTERS)]
return "\n".join(lines), None
except Exception as e:
return "", str(e)
# ── PARSING ──
def parse_audit_output(raw):
result = {
"hostname": "", "os_release": "", "kernel": "", "uptime": "",
"services": [], "processes": [], "services_failed": "",
"needs_restarting": "", "reboot_required": False, "disk_usage": [],
"interfaces": [], "routes": [], "listen_ports": [],
"connections": [], "flux_in": [], "flux_out": [],
"conn_wait": [], "net_stats": {}, "traffic": [],
"firewall": {"policy": {}, "input": [], "output": [], "firewalld": []},
"correlation_matrix": [], "outbound_only": [],
}
section = None
firewall_sub = None
for line in raw.splitlines():
ls = line.strip()
m = re.match(r"^# AUDIT COMPLET .+ (.+)$", ls)
if m: result["hostname"] = m.group(1); continue
m = re.match(r"^# OS: (.+)$", ls)
if m: result["os_release"] = m.group(1); continue
m = re.match(r"^# Kernel: (.+)$", ls)
if m: result["kernel"] = m.group(1); continue
m = re.match(r"^# Uptime: (.+)$", ls)
if m: result["uptime"] = m.group(1); continue
if "1.1 SERVICES APPLICATIFS" in ls: section = "services"; continue
elif "1.2 PROCESSUS APPLICATIFS" in ls: section = "processes"; continue
elif "1.3 SERVICES EN ECHEC" in ls: section = "services_failed"; continue
elif "1.4 NEEDS-RESTARTING" in ls: section = "needs_restarting"; continue
elif "1.5 ESPACE DISQUE" in ls: section = "disk"; continue
elif "2.1 INTERFACES" in ls: section = "interfaces"; continue
elif "2.2 TABLE DE ROUTAGE" in ls: section = "routes"; continue
elif "2.3 PORTS EN ECOUTE" in ls: section = "listen_ports"; continue
elif "2.4 CONNEXIONS ETABLIES" in ls: section = "connections"; continue
elif "2.5 RESUME FLUX ENTRANTS" in ls: section = "flux_in"; continue
elif "2.6 RESUME FLUX SORTANTS" in ls: section = "flux_out"; continue
elif "2.7 CONNEXIONS EN ATTENTE" in ls: section = "conn_wait"; continue
elif "2.8 STATISTIQUES" in ls: section = "net_stats"; continue
elif "2.9 TRAFIC" in ls: section = "traffic"; continue
elif "2.10 FIREWALL" in ls: section = "firewall"; firewall_sub = None; continue
elif "3.1 MATRICE" in ls: section = "correlation"; continue
elif "3.2 PROCESS SORTANTS" in ls: section = "outbound"; continue
elif ls.startswith("===") or ls.startswith("###"): section = None; continue
if not ls: continue
headers = ["SERVICE|","PID|PPID","PROTO|","DIRECTION|","PORT|","DEST_IP|",
"METRIC|","INTERFACE|","DESTINATION|","NUM|","PROCESS|USER",
"ZONE|","Mont","STATE|COUNT"]
if any(ls.startswith(h) for h in headers): continue
parts = ls.split("|")
if section == "services" and len(parts) >= 2:
result["services"].append({"name":parts[0],"enabled":parts[1],"pid":parts[2] if len(parts)>2 else "","user":parts[3] if len(parts)>3 else "","exec":parts[4] if len(parts)>4 else ""})
elif section == "processes" and len(parts) >= 6:
result["processes"].append({"pid":parts[0],"ppid":parts[1],"user":parts[2],"exe":parts[3],"cwd":parts[4],"cmdline":parts[5],"restart_hint":parts[6] if len(parts)>6 else ""})
elif section == "services_failed":
if ls != "Aucun service en echec": result["services_failed"] += ls + "\n"
elif section == "needs_restarting":
result["needs_restarting"] += ls + "\n"
if "EXIT_CODE=1" in ls: result["reboot_required"] = True
elif section == "disk":
p = ls.split()
if len(p) >= 5 and "%" in p[-1]:
try: result["disk_usage"].append({"mount":p[0],"size":p[1],"used":p[2],"avail":p[3],"pct":int(p[4].replace("%",""))})
except: pass
elif section == "interfaces" and len(parts) >= 3:
result["interfaces"].append({"iface":parts[0],"ip":parts[1],"mask":parts[2],"state":parts[3] if len(parts)>3 else "","mac":parts[4] if len(parts)>4 else ""})
elif section == "routes" and len(parts) >= 3:
result["routes"].append({"dest":parts[0],"gw":parts[1],"iface":parts[2],"metric":parts[3] if len(parts)>3 else ""})
elif section == "listen_ports" and len(parts) >= 3:
result["listen_ports"].append({"proto":parts[0],"addr_port":parts[1],"pid":parts[2],"process":parts[3] if len(parts)>3 else "","user":parts[4] if len(parts)>4 else "","service":parts[5] if len(parts)>5 else ""})
elif section == "connections" and len(parts) >= 5:
result["connections"].append({"direction":parts[0],"proto":parts[1],"local":parts[2],"remote":parts[3],"pid":parts[4],"process":parts[5] if len(parts)>5 else "","user":parts[6] if len(parts)>6 else "","state":parts[7] if len(parts)>7 else ""})
elif section == "flux_in" and len(parts) >= 3:
result["flux_in"].append({"port":parts[0],"service":parts[1],"process":parts[2],"count":parts[3] if len(parts)>3 else "0","sources":parts[4] if len(parts)>4 else ""})
elif section == "flux_out" and len(parts) >= 3:
result["flux_out"].append({"dest_ip":parts[0],"dest_port":parts[1],"service":parts[2],"process":parts[3] if len(parts)>3 else "","count":parts[4] if len(parts)>4 else "1"})
elif section == "conn_wait" and len(parts) == 2:
result["conn_wait"].append({"state":parts[0],"count":parts[1]})
elif section == "net_stats" and len(parts) == 2:
result["net_stats"][parts[0].strip()] = parts[1].strip()
elif section == "traffic" and len(parts) >= 5:
result["traffic"].append({"iface":parts[0],"rx_bytes":parts[1],"rx_pkt":parts[2],"rx_err":parts[3],"tx_bytes":parts[4],"tx_pkt":parts[5] if len(parts)>5 else "","tx_err":parts[6] if len(parts)>6 else ""})
elif section == "firewall":
if "POLICY" in ls: firewall_sub = "policy"; continue
elif "INPUT" in ls and "---" in ls: firewall_sub = "input"; continue
elif "OUTPUT" in ls and "---" in ls: firewall_sub = "output"; continue
elif "FIREWALLD" in ls: firewall_sub = "firewalld"; continue
if firewall_sub == "policy" and len(parts) == 2: result["firewall"]["policy"][parts[0]] = parts[1]
elif firewall_sub == "input" and len(parts) >= 3: result["firewall"]["input"].append(ls)
elif firewall_sub == "output" and len(parts) >= 3: result["firewall"]["output"].append(ls)
elif firewall_sub == "firewalld" and len(parts) >= 2: result["firewall"]["firewalld"].append({"zone":parts[0],"services":parts[1],"ports":parts[2] if len(parts)>2 else ""})
elif section == "correlation" and len(parts) >= 4:
result["correlation_matrix"].append({"process":parts[0],"user":parts[1],"pid":parts[2],"listen_ports":parts[3],"conn_in":parts[4] if len(parts)>4 else "0","conn_out":parts[5] if len(parts)>5 else "0","remote_dests":parts[6] if len(parts)>6 else ""})
elif section == "outbound" and len(parts) >= 3:
result["outbound_only"].append({"process":parts[0],"user":parts[1],"pid":parts[2],"dests":parts[3] if len(parts)>3 else ""})
result["services_failed"] = result["services_failed"].strip()
result["needs_restarting"] = result["needs_restarting"].strip()
return result
# ── STOCKAGE DB ──
def _resolve_server_id(db, hostname):
srv = db.execute(text(
"SELECT id FROM servers WHERE LOWER(hostname) = LOWER(:h)"
), {"h": hostname.split(".")[0]}).fetchone()
return srv.id if srv else None
def _resolve_dest_server(db, dest_ip):
row = db.execute(text("""
SELECT s.id, s.hostname FROM servers s
JOIN server_ips si ON s.id = si.server_id
WHERE si.ip_address = :ip::inet
LIMIT 1
"""), {"ip": dest_ip}).fetchone()
return (row.id, row.hostname) if row else (None, None)
def save_audit_to_db(db, parsed, raw_output="", status="ok", error_msg=None):
hostname = parsed.get("hostname", "")
if not hostname:
return None
server_id = _resolve_server_id(db, hostname)
row = db.execute(text("""
INSERT INTO server_audit_full (
server_id, hostname, audit_date, os_release, kernel, uptime,
services, processes, services_failed, needs_restarting, reboot_required,
disk_usage, interfaces, routes, listen_ports, connections,
flux_in, flux_out, conn_wait, net_stats, traffic, firewall,
correlation_matrix, outbound_only, raw_output, status, error_msg
) VALUES (
:sid, :hn, NOW(), :os, :k, :up,
:svc, :proc, :sf, :nr, :rr,
:du, :iface, :rt, :lp, :conn,
:fi, :fo, :cw, :ns, :tr, :fw,
:cm, :ob, :raw, :st, :err
) RETURNING id
"""), {
"sid": server_id, "hn": hostname,
"os": parsed.get("os_release", ""), "k": parsed.get("kernel", ""),
"up": parsed.get("uptime", ""),
"svc": json.dumps(parsed.get("services", [])),
"proc": json.dumps(parsed.get("processes", [])),
"sf": parsed.get("services_failed", ""),
"nr": parsed.get("needs_restarting", ""),
"rr": parsed.get("reboot_required", False),
"du": json.dumps(parsed.get("disk_usage", [])),
"iface": json.dumps(parsed.get("interfaces", [])),
"rt": json.dumps(parsed.get("routes", [])),
"lp": json.dumps(parsed.get("listen_ports", [])),
"conn": json.dumps(parsed.get("connections", [])),
"fi": json.dumps(parsed.get("flux_in", [])),
"fo": json.dumps(parsed.get("flux_out", [])),
"cw": json.dumps(parsed.get("conn_wait", [])),
"ns": json.dumps(parsed.get("net_stats", {})),
"tr": json.dumps(parsed.get("traffic", [])),
"fw": json.dumps(parsed.get("firewall", {})),
"cm": json.dumps(parsed.get("correlation_matrix", [])),
"ob": json.dumps(parsed.get("outbound_only", [])),
"raw": raw_output, "st": status, "err": error_msg,
}).fetchone()
audit_id = row.id
_build_flow_map(db, audit_id, hostname, server_id, parsed)
return audit_id
def _build_flow_map(db, audit_id, hostname, server_id, parsed):
local_ips = [i["ip"] for i in parsed.get("interfaces", []) if i["ip"] != "127.0.0.1"]
source_ip = local_ips[0] if local_ips else ""
for conn in parsed.get("connections", []):
remote = conn.get("remote", "")
m = re.match(r'^(.+):(\d+)$', remote)
if not m:
continue
dest_ip = m.group(1)
dest_port = int(m.group(2))
if dest_ip.startswith("127.") or dest_ip == "::1":
continue
dest_server_id, dest_hostname = _resolve_dest_server(db, dest_ip)
db.execute(text("""
INSERT INTO network_flow_map (
audit_id, source_server_id, source_hostname, source_ip,
dest_ip, dest_port, dest_hostname, dest_server_id,
process_name, process_user, direction,
connection_count, state, audit_date
) VALUES (
:aid, :ssid, :shn, :sip,
:dip, :dp, :dhn, :dsid,
:pn, :pu, :dir, 1, :st, NOW()
)
"""), {
"aid": audit_id, "ssid": server_id, "shn": hostname, "sip": source_ip,
"dip": dest_ip, "dp": dest_port, "dhn": dest_hostname, "dsid": dest_server_id,
"pn": conn.get("process", ""), "pu": conn.get("user", ""),
"dir": conn.get("direction", ""), "st": conn.get("state", ""),
})
# ── IMPORT JSON (depuis standalone) ──
def import_json_report(db, json_data):
servers = json_data.get("servers", [])
imported = 0
errors = 0
for srv in servers:
if srv.get("status") == "error":
errors += 1
continue
hostname = srv.get("hostname", "")
if not hostname:
continue
parsed = {k: srv.get(k, v) for k, v in {
"hostname": "", "os_release": "", "kernel": "", "uptime": "",
"services": [], "processes": [], "services_failed": "",
"needs_restarting": "", "reboot_required": False, "disk_usage": [],
"interfaces": [], "routes": [], "listen_ports": [],
"connections": [], "flux_in": [], "flux_out": [],
"conn_wait": [], "net_stats": {}, "traffic": [],
"firewall": {}, "correlation_matrix": [], "outbound_only": [],
}.items()}
save_audit_to_db(db, parsed)
imported += 1
db.commit()
return imported, errors
# ── REQUETES ──
def get_latest_audits(db, limit=100):
return db.execute(text("""
SELECT DISTINCT ON (hostname) id, server_id, hostname, audit_date,
os_release, kernel, uptime, status, reboot_required,
jsonb_array_length(COALESCE(services, '[]')) as svc_count,
jsonb_array_length(COALESCE(listen_ports, '[]')) as port_count,
jsonb_array_length(COALESCE(connections, '[]')) as conn_count,
jsonb_array_length(COALESCE(processes, '[]')) as proc_count
FROM server_audit_full
WHERE status = 'ok'
ORDER BY hostname, audit_date DESC
LIMIT :lim
"""), {"lim": limit}).fetchall()
def get_audit_detail(db, audit_id):
return db.execute(text(
"SELECT * FROM server_audit_full WHERE id = :id"
), {"id": audit_id}).fetchone()
def get_flow_map(db):
return db.execute(text("""
SELECT source_hostname, source_ip, dest_ip, dest_port,
dest_hostname, process_name, direction, state,
COUNT(*) as cnt
FROM network_flow_map nfm
JOIN server_audit_full saf ON nfm.audit_id = saf.id
WHERE saf.id IN (
SELECT DISTINCT ON (hostname) id FROM server_audit_full
WHERE status = 'ok' ORDER BY hostname, audit_date DESC
)
GROUP BY source_hostname, source_ip, dest_ip, dest_port,
dest_hostname, process_name, direction, state
ORDER BY source_hostname
""")).fetchall()
def get_flow_map_for_server(db, hostname):
return db.execute(text("""
SELECT source_hostname, source_ip, dest_ip, dest_port,
dest_hostname, process_name, direction, state
FROM network_flow_map
WHERE audit_id = (
SELECT id FROM server_audit_full WHERE hostname = :h
ORDER BY audit_date DESC LIMIT 1
)
ORDER BY direction DESC, dest_ip
"""), {"h": hostname}).fetchall()
def get_flow_map_for_domain(db, domain_code):
return db.execute(text("""
SELECT nfm.source_hostname, nfm.source_ip, nfm.dest_ip, nfm.dest_port,
nfm.dest_hostname, nfm.process_name, nfm.direction, nfm.state
FROM network_flow_map nfm
JOIN server_audit_full saf ON nfm.audit_id = saf.id
JOIN servers s ON saf.server_id = s.id
JOIN domain_environments de ON s.domain_env_id = de.id
JOIN domains d ON de.domain_id = d.id
WHERE d.code = :dc
AND saf.id IN (
SELECT DISTINCT ON (hostname) id FROM server_audit_full
WHERE status = 'ok' ORDER BY hostname, audit_date DESC
)
ORDER BY nfm.source_hostname
"""), {"dc": domain_code}).fetchall()
def get_app_map(db):
audits = db.execute(text("""
SELECT DISTINCT ON (hostname) hostname, server_id, processes, listen_ports
FROM server_audit_full WHERE status = 'ok'
ORDER BY hostname, audit_date DESC
""")).fetchall()
app_groups = {}
for audit in audits:
processes = audit.processes if isinstance(audit.processes, list) else json.loads(audit.processes or "[]")
for proc in processes:
cwd = proc.get("cwd", "")
m = re.search(r'/applis/([^/]+)', cwd)
if not m:
continue
app_name = m.group(1)
if app_name not in app_groups:
app_groups[app_name] = {"servers": [], "ports": set()}
if audit.hostname not in [s["hostname"] for s in app_groups[app_name]["servers"]]:
app_groups[app_name]["servers"].append({
"hostname": audit.hostname,
"server_id": audit.server_id,
"user": proc.get("user", ""),
"cmdline": proc.get("cmdline", "")[:100],
"restart_hint": proc.get("restart_hint", "")[:100],
})
listen = audit.listen_ports if isinstance(audit.listen_ports, list) else json.loads(audit.listen_ports or "[]")
pid = proc.get("pid", "")
for lp in listen:
if lp.get("pid") == pid:
app_groups[app_name]["ports"].add(lp.get("addr_port", ""))
for k in app_groups:
app_groups[k]["ports"] = list(app_groups[k]["ports"])
return app_groups

View File

@ -0,0 +1,232 @@
{% extends 'base.html' %}
{% block title %}{{ a.hostname }}{% endblock %}
{% block content %}
<a href="/audit-full" class="text-xs text-gray-500 hover:text-gray-300">< Retour</a>
<div class="flex justify-between items-center mb-4">
<div>
<h2 class="text-xl font-bold text-cyber-accent">{{ a.hostname }}</h2>
<p class="text-xs text-gray-500">{{ a.os_release }} | {{ a.kernel }} | {{ a.uptime }}</p>
</div>
<div class="flex gap-2 text-xs">
{% for iface in interfaces %}{% if iface.ip != '127.0.0.1' %}
<span class="badge badge-blue">{{ iface.ip }}{{ iface.mask }} ({{ iface.iface }})</span>
{% endif %}{% endfor %}
</div>
</div>
<!-- KPI -->
<div class="grid grid-cols-6 gap-3 mb-4">
<div class="card p-3 text-center"><div class="text-lg font-bold text-cyber-accent">{{ services|length }}</div><div class="text-xs text-gray-500">Services</div></div>
<div class="card p-3 text-center"><div class="text-lg font-bold text-cyber-accent">{{ processes|length }}</div><div class="text-xs text-gray-500">Process</div></div>
<div class="card p-3 text-center"><div class="text-lg font-bold text-cyber-accent">{{ listen_ports|length }}</div><div class="text-xs text-gray-500">Ports</div></div>
<div class="card p-3 text-center"><div class="text-lg font-bold text-cyber-accent">{{ connections|length }}</div><div class="text-xs text-gray-500">Connexions</div></div>
<div class="card p-3 text-center"><div class="text-lg font-bold {% if a.reboot_required %}text-cyber-red{% else %}text-cyber-green{% endif %}">{% if a.reboot_required %}Oui{% else %}Non{% endif %}</div><div class="text-xs text-gray-500">Reboot</div></div>
<div class="card p-3 text-center"><div class="text-lg font-bold {% if a.services_failed %}text-cyber-red{% else %}text-cyber-green{% endif %}">{% if a.services_failed %}KO{% else %}OK{% endif %}</div><div class="text-xs text-gray-500">Failed svc</div></div>
</div>
<!-- Onglets -->
<div x-data="{ tab: 'services' }">
<div class="flex gap-1 mb-3 flex-wrap">
{% for t, label in [('services','Services'),('processes','Processus'),('ports','Ports'),('connections','Connexions'),('flux','Flux'),('disk','Disque'),('firewall','Firewall'),('correlation','Correlation')] %}
<button @click="tab='{{ t }}'" class="px-3 py-1 text-xs rounded" :class="tab==='{{ t }}' ? 'bg-cyber-accent text-black font-bold' : 'bg-cyber-border text-gray-400'">{{ label }}</button>
{% endfor %}
</div>
<!-- Services -->
<div x-show="tab==='services'" class="card overflow-x-auto">
<table class="w-full table-cyber text-xs"><thead><tr>
<th class="text-left p-2">Service</th><th class="p-2">Enabled</th><th class="p-2">PID</th><th class="p-2">User</th><th class="text-left p-2">Exec</th>
</tr></thead><tbody>
{% for s in services %}<tr>
<td class="p-2 font-mono text-cyber-accent">{{ s.name }}</td>
<td class="p-2 text-center"><span class="badge {% if s.enabled == 'enabled' %}badge-green{% else %}badge-gray{% endif %}">{{ s.enabled }}</span></td>
<td class="p-2 text-center font-mono">{{ s.pid }}</td>
<td class="p-2 text-center">{{ s.user }}</td>
<td class="p-2 text-gray-400">{{ s.exec[:80] }}</td>
</tr>{% endfor %}
</tbody></table>
</div>
<!-- Processus -->
<div x-show="tab==='processes'" class="card overflow-x-auto">
<table class="w-full table-cyber text-xs"><thead><tr>
<th class="p-2">PID</th><th class="p-2">User</th><th class="text-left p-2">Exe</th><th class="text-left p-2">CWD</th><th class="text-left p-2">Cmdline</th><th class="text-left p-2">Restart</th>
</tr></thead><tbody>
{% for p in processes %}<tr class="{% if '/applis' in (p.cwd or '') %}bg-green-900/10{% endif %}">
<td class="p-2 font-mono text-center">{{ p.pid }}</td>
<td class="p-2 text-center">{{ p.user }}</td>
<td class="p-2 font-mono text-gray-400">{{ (p.exe or '')[:40] }}</td>
<td class="p-2 font-mono text-gray-500">{{ (p.cwd or '')[:30] }}</td>
<td class="p-2 font-mono text-cyber-accent">{{ (p.cmdline or '')[:60] }}</td>
<td class="p-2 text-gray-400">{{ (p.restart_hint or '')[:60] }}</td>
</tr>{% endfor %}
</tbody></table>
</div>
<!-- Ports -->
<div x-show="tab==='ports'" class="card overflow-x-auto">
<table class="w-full table-cyber text-xs"><thead><tr>
<th class="p-2">Proto</th><th class="p-2">Addr:Port</th><th class="p-2">PID</th><th class="p-2">Process</th><th class="p-2">User</th><th class="p-2">Service</th>
</tr></thead><tbody>
{% for lp in listen_ports %}<tr>
<td class="p-2 text-center">{{ lp.proto }}</td>
<td class="p-2 font-mono text-cyber-accent">{{ lp.addr_port }}</td>
<td class="p-2 text-center font-mono">{{ lp.pid }}</td>
<td class="p-2 text-center">{{ lp.process }}</td>
<td class="p-2 text-center">{{ lp.user }}</td>
<td class="p-2 text-center text-gray-400">{{ lp.service }}</td>
</tr>{% endfor %}
</tbody></table>
</div>
<!-- Connexions -->
<div x-show="tab==='connections'" class="card overflow-x-auto">
<table class="w-full table-cyber text-xs"><thead><tr>
<th class="p-2">Dir</th><th class="p-2">Local</th><th class="p-2">Remote</th><th class="p-2">PID</th><th class="p-2">Process</th><th class="p-2">User</th><th class="p-2">State</th>
</tr></thead><tbody>
{% for c in connections %}<tr class="{% if c.state == 'CLOSE-WAIT' %}bg-red-900/10{% endif %}">
<td class="p-2 text-center"><span class="badge {% if c.direction == 'IN' %}badge-green{% else %}badge-yellow{% endif %}">{{ c.direction }}</span></td>
<td class="p-2 font-mono">{{ c.local }}</td>
<td class="p-2 font-mono text-cyber-accent">{{ c.remote }}</td>
<td class="p-2 text-center font-mono">{{ c.pid }}</td>
<td class="p-2 text-center">{{ c.process }}</td>
<td class="p-2 text-center">{{ c.user }}</td>
<td class="p-2 text-center"><span class="badge {% if c.state == 'ESTAB' %}badge-green{% elif c.state == 'CLOSE-WAIT' %}badge-red{% else %}badge-gray{% endif %}">{{ c.state }}</span></td>
</tr>{% endfor %}
</tbody></table>
</div>
<!-- Flux -->
<div x-show="tab==='flux'" class="space-y-3">
{% if flux_in %}
<div class="card overflow-x-auto">
<div class="p-2 border-b border-cyber-border"><span class="text-xs font-bold text-cyber-green">Flux entrants</span></div>
<table class="w-full table-cyber text-xs"><thead><tr>
<th class="p-2">Port</th><th class="p-2">Service</th><th class="p-2">Process</th><th class="p-2">Nb</th><th class="text-left p-2">Sources</th>
</tr></thead><tbody>
{% for f in flux_in %}<tr>
<td class="p-2 text-center font-mono text-cyber-accent">{{ f.port }}</td>
<td class="p-2 text-center">{{ f.service }}</td>
<td class="p-2 text-center">{{ f.process }}</td>
<td class="p-2 text-center font-bold">{{ f.count }}</td>
<td class="p-2 font-mono text-gray-400">{{ f.sources }}</td>
</tr>{% endfor %}
</tbody></table>
</div>
{% endif %}
{% if flux_out %}
<div class="card overflow-x-auto">
<div class="p-2 border-b border-cyber-border"><span class="text-xs font-bold text-cyber-yellow">Flux sortants</span></div>
<table class="w-full table-cyber text-xs"><thead><tr>
<th class="text-left p-2">Destination</th><th class="p-2">Port</th><th class="p-2">Service</th><th class="p-2">Process</th><th class="p-2">Nb</th>
</tr></thead><tbody>
{% for f in flux_out %}<tr>
<td class="p-2 font-mono text-cyber-accent">{{ f.dest_ip }}</td>
<td class="p-2 text-center">{{ f.dest_port }}</td>
<td class="p-2 text-center">{{ f.service }}</td>
<td class="p-2 text-center">{{ f.process }}</td>
<td class="p-2 text-center font-bold">{{ f.count }}</td>
</tr>{% endfor %}
</tbody></table>
</div>
{% endif %}
{% if flows %}
<div class="card overflow-x-auto">
<div class="p-2 border-b border-cyber-border"><span class="text-xs font-bold text-cyber-accent">Carte flux (resolu)</span></div>
<table class="w-full table-cyber text-xs"><thead><tr>
<th class="p-2">Dir</th><th class="p-2">IP dest</th><th class="p-2">Port</th><th class="p-2">Serveur dest</th><th class="p-2">Process</th><th class="p-2">State</th>
</tr></thead><tbody>
{% for f in flows %}<tr>
<td class="p-2 text-center"><span class="badge {% if f.direction == 'IN' %}badge-green{% else %}badge-yellow{% endif %}">{{ f.direction }}</span></td>
<td class="p-2 font-mono">{{ f.dest_ip }}</td>
<td class="p-2 text-center">{{ f.dest_port }}</td>
<td class="p-2 font-mono text-cyber-accent">{{ f.dest_hostname or '-' }}</td>
<td class="p-2 text-center">{{ f.process_name }}</td>
<td class="p-2 text-center"><span class="badge {% if f.state == 'ESTAB' %}badge-green{% elif f.state == 'CLOSE-WAIT' %}badge-red{% else %}badge-gray{% endif %}">{{ f.state }}</span></td>
</tr>{% endfor %}
</tbody></table>
</div>
{% endif %}
</div>
<!-- Disque -->
<div x-show="tab==='disk'" class="card overflow-x-auto">
<table class="w-full table-cyber text-xs"><thead><tr>
<th class="text-left p-2">Mount</th><th class="p-2">Taille</th><th class="p-2">Utilise</th><th class="p-2">Dispo</th><th class="p-2">%</th>
</tr></thead><tbody>
{% for d in disk_usage %}<tr class="{% if d.pct >= 90 %}bg-red-900/20{% elif d.pct >= 80 %}bg-yellow-900/10{% endif %}">
<td class="p-2 font-mono">{{ d.mount }}</td>
<td class="p-2 text-center">{{ d.size }}</td>
<td class="p-2 text-center">{{ d.used }}</td>
<td class="p-2 text-center">{{ d.avail }}</td>
<td class="p-2 text-center font-bold {% if d.pct >= 90 %}text-cyber-red{% elif d.pct >= 80 %}text-cyber-yellow{% else %}text-cyber-green{% endif %}">{{ d.pct }}%</td>
</tr>{% endfor %}
</tbody></table>
</div>
<!-- Firewall -->
<div x-show="tab==='firewall'" class="space-y-3">
{% if firewall.policy %}
<div class="card p-3">
<span class="text-xs font-bold text-cyber-accent">Policy iptables :</span>
{% for chain, pol in firewall.policy.items() %}
<span class="badge {% if pol == 'DROP' %}badge-red{% elif pol == 'ACCEPT' %}badge-green{% else %}badge-gray{% endif %} ml-2">{{ chain }}={{ pol or '?' }}</span>
{% endfor %}
</div>
{% endif %}
{% if firewall.firewalld %}
<div class="card p-3">
<span class="text-xs font-bold text-cyber-accent">Firewalld :</span>
{% for z in firewall.firewalld %}
<div class="mt-1 text-xs">Zone <span class="text-cyber-yellow">{{ z.zone }}</span> : services={{ z.services }} ports={{ z.ports }}</div>
{% endfor %}
</div>
{% endif %}
{% if conn_wait %}
<div class="card p-3">
<span class="text-xs font-bold text-cyber-accent">Connexions en attente :</span>
{% for cw in conn_wait %}
<span class="badge {% if cw.state == 'CLOSE-WAIT' %}badge-red{% else %}badge-gray{% endif %} ml-2">{{ cw.state }}={{ cw.count }}</span>
{% endfor %}
</div>
{% endif %}
</div>
<!-- Correlation -->
<div x-show="tab==='correlation'" class="space-y-3">
{% if correlation %}
<div class="card overflow-x-auto">
<div class="p-2 border-b border-cyber-border"><span class="text-xs font-bold text-cyber-accent">Matrice process / ports / flux</span></div>
<table class="w-full table-cyber text-xs"><thead><tr>
<th class="text-left p-2">Process</th><th class="p-2">User</th><th class="p-2">PID</th><th class="p-2">Ports</th><th class="p-2">IN</th><th class="p-2">OUT</th><th class="text-left p-2">Destinations</th>
</tr></thead><tbody>
{% for c in correlation %}<tr>
<td class="p-2 font-mono text-cyber-accent">{{ c.process }}</td>
<td class="p-2 text-center">{{ c.user }}</td>
<td class="p-2 text-center font-mono">{{ c.pid }}</td>
<td class="p-2 text-center font-mono text-cyber-yellow">{{ c.listen_ports }}</td>
<td class="p-2 text-center font-bold text-cyber-green">{{ c.conn_in }}</td>
<td class="p-2 text-center font-bold text-cyber-yellow">{{ c.conn_out }}</td>
<td class="p-2 font-mono text-gray-400">{{ c.remote_dests }}</td>
</tr>{% endfor %}
</tbody></table>
</div>
{% endif %}
{% if outbound %}
<div class="card overflow-x-auto">
<div class="p-2 border-b border-cyber-border"><span class="text-xs font-bold text-cyber-yellow">Process sortants uniquement</span></div>
<table class="w-full table-cyber text-xs"><thead><tr>
<th class="text-left p-2">Process</th><th class="p-2">User</th><th class="p-2">PID</th><th class="text-left p-2">Destinations</th>
</tr></thead><tbody>
{% for o in outbound %}<tr>
<td class="p-2 font-mono text-cyber-accent">{{ o.process }}</td>
<td class="p-2 text-center">{{ o.user }}</td>
<td class="p-2 text-center font-mono">{{ o.pid }}</td>
<td class="p-2 font-mono text-gray-400">{{ o.dests }}</td>
</tr>{% endfor %}
</tbody></table>
</div>
{% endif %}
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,70 @@
{% extends 'base.html' %}
{% block title %}Carte flux{% endblock %}
{% block content %}
<a href="/audit-full" class="text-xs text-gray-500 hover:text-gray-300">< Retour</a>
<h2 class="text-xl font-bold text-cyber-accent mb-4">Carte des flux reseau</h2>
{% if flows %}
<div class="card overflow-x-auto mb-4">
<div class="p-2 border-b border-cyber-border">
<span class="text-xs font-bold text-cyber-accent">{{ flows|length }} flux inter-serveurs</span>
</div>
<table class="w-full table-cyber text-xs">
<thead><tr>
<th class="p-2">Dir</th>
<th class="text-left p-2">Source</th>
<th class="p-2">IP source</th>
<th class="text-left p-2">Destination</th>
<th class="p-2">IP dest</th>
<th class="p-2">Port</th>
<th class="p-2">Process</th>
<th class="p-2">State</th>
<th class="p-2">Nb</th>
</tr></thead>
<tbody>
{% for f in flows %}
<tr class="{% if f.state == 'CLOSE-WAIT' %}bg-red-900/10{% endif %}">
<td class="p-2 text-center"><span class="badge {% if f.direction == 'IN' %}badge-green{% else %}badge-yellow{% endif %}">{{ f.direction }}</span></td>
<td class="p-2 font-mono text-cyber-accent">{{ f.source_hostname }}</td>
<td class="p-2 font-mono text-gray-500 text-center">{{ f.source_ip }}</td>
<td class="p-2 font-mono {% if f.dest_hostname %}text-cyber-accent{% else %}text-gray-400{% endif %}">{{ f.dest_hostname or '-' }}</td>
<td class="p-2 font-mono text-gray-500 text-center">{{ f.dest_ip }}</td>
<td class="p-2 text-center font-bold">{{ f.dest_port }}</td>
<td class="p-2 text-center">{{ f.process_name }}</td>
<td class="p-2 text-center"><span class="badge {% if f.state == 'ESTAB' %}badge-green{% elif f.state == 'CLOSE-WAIT' %}badge-red{% else %}badge-gray{% endif %}">{{ f.state }}</span></td>
<td class="p-2 text-center">{{ f.cnt }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="card p-8 text-center text-gray-500">Aucun flux. Importez des rapports JSON d'abord.</div>
{% endif %}
{% if app_map %}
<h3 class="text-lg font-bold text-cyber-accent mb-3">Carte applicative</h3>
<div class="grid grid-cols-2 gap-3">
{% for app_name, app in app_map.items() %}
<div class="card p-3">
<div class="flex justify-between items-center mb-2">
<span class="text-sm font-bold text-cyber-yellow">{{ app_name }}</span>
<span class="badge badge-blue">{{ app.servers|length }} serveur(s)</span>
</div>
{% if app.ports %}
<div class="text-xs text-gray-500 mb-2">Ports: {% for p in app.ports %}<span class="font-mono text-cyber-accent">{{ p }}</span>{% if not loop.last %}, {% endif %}{% endfor %}</div>
{% endif %}
<div class="space-y-1">
{% for s in app.servers %}
<div class="text-xs font-mono">
<span class="text-cyber-accent">{{ s.hostname }}</span>
<span class="text-gray-500">user={{ s.user }}</span>
<span class="text-gray-600">{{ s.cmdline[:50] }}</span>
</div>
{% endfor %}
</div>
</div>
{% endfor %}
</div>
{% endif %}
{% endblock %}

View File

@ -0,0 +1,66 @@
{% extends 'base.html' %}
{% block title %}Audit complet{% endblock %}
{% block content %}
<div class="flex justify-between items-center mb-4">
<div>
<h2 class="text-xl font-bold text-cyber-accent">Audit complet serveurs</h2>
<p class="text-xs text-gray-500 mt-1">Applicatif + reseau + correlation — import JSON depuis le standalone</p>
</div>
<div class="flex gap-2 items-center">
<form method="POST" action="/audit-full/import" enctype="multipart/form-data" class="flex gap-2 items-center">
<input type="file" name="file" accept=".json" class="text-xs" required>
<button type="submit" class="btn-primary px-4 py-2 text-sm" data-loading="Import en cours...|Insertion des donnees">Importer JSON</button>
</form>
<a href="/audit-full/flow-map" class="btn-sm bg-cyber-border text-cyber-accent px-4 py-2">Carte flux</a>
</div>
</div>
{% if msg %}
<div class="mb-3 p-2 rounded text-sm {% if 'error' in msg %}bg-red-900/30 text-cyber-red{% else %}bg-green-900/30 text-cyber-green{% endif %}">
{% if msg.startswith('imported_') %}
{% set parts = msg.split('_') %}
{{ parts[1] }} serveur(s) importe(s){% if parts[2]|int > 0 %}, {{ parts[2] }} erreur(s){% endif %}.
{% elif msg.startswith('error_') %}Erreur: {{ msg[6:] }}{% endif %}
</div>
{% endif %}
{% if audits %}
<div class="card overflow-x-auto">
<table class="w-full table-cyber text-xs">
<thead><tr>
<th class="text-left p-2">Hostname</th>
<th class="p-2">OS</th>
<th class="p-2">Kernel</th>
<th class="p-2">Uptime</th>
<th class="p-2">Services</th>
<th class="p-2">Process</th>
<th class="p-2">Ports</th>
<th class="p-2">Conn</th>
<th class="p-2">Reboot</th>
<th class="p-2">Date</th>
</tr></thead>
<tbody>
{% for a in audits %}
<tr class="hover:bg-cyber-hover cursor-pointer" onclick="location.href='/audit-full/{{ a.id }}'">
<td class="p-2 font-mono text-cyber-accent font-bold">{{ a.hostname }}</td>
<td class="p-2 text-center text-gray-400">{{ (a.os_release or '')[:30] }}</td>
<td class="p-2 text-center font-mono text-gray-500">{{ (a.kernel or '')[:25] }}</td>
<td class="p-2 text-center text-gray-400">{{ (a.uptime or '')[:20] }}</td>
<td class="p-2 text-center">{{ a.svc_count }}</td>
<td class="p-2 text-center">{{ a.proc_count }}</td>
<td class="p-2 text-center">{{ a.port_count }}</td>
<td class="p-2 text-center">{{ a.conn_count }}</td>
<td class="p-2 text-center">{% if a.reboot_required %}<span class="text-cyber-red">Oui</span>{% else %}<span class="text-cyber-green">Non</span>{% endif %}</td>
<td class="p-2 text-center text-gray-500">{{ a.audit_date.strftime('%d/%m %H:%M') if a.audit_date else '-' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="card p-8 text-center text-gray-500">
<p class="text-sm">Aucun audit importe.</p>
<p class="text-xs mt-2">Lancez le standalone sur vos serveurs puis importez le JSON ici.</p>
</div>
{% endif %}
{% endblock %}

View File

@ -63,6 +63,7 @@
{% if p.planning %}<a href="/planning" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'planning' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %}">Planning</a>{% endif %}
{% if p.audit %}<a href="/audit" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if request.url.path == '/audit' %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %}">Audit</a>{% endif %}
{% if p.audit in ('edit', 'admin') %}<a href="/audit/specific" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'specific' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6 text-xs">Spécifique</a>{% endif %}
{% if p.audit %}<a href="/audit-full" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'audit-full' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6 text-xs">Complet</a>{% endif %}
{% if p.servers %}<a href="/contacts" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'contacts' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %}">Contacts</a>{% endif %}
{% if p.users %}<a href="/users" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'users' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %}">Utilisateurs</a>{% endif %}
{% if p.settings %}<a href="/settings" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'settings' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %}">Settings</a>{% endif %}