Patching: filtre SecOps/Hors SecOps, KPIs par perimetre, detail partial "pas encore audite"

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Khalid MOUTAOUAKIL 2026-04-06 22:11:35 +02:00
parent 390a162cf4
commit cb8ade24e4
3 changed files with 98 additions and 21 deletions

View File

@ -233,6 +233,7 @@ async def audit_full_patching(request: Request, db=Depends(get_db)):
year = int(request.query_params.get("year", "2026"))
search = request.query_params.get("q", "").strip()
domain = request.query_params.get("domain", "")
scope = request.query_params.get("scope", "") # secops, other, ou vide=tout
page = int(request.query_params.get("page", "1"))
sort = request.query_params.get("sort", "hostname")
sort_dir = request.query_params.get("dir", "asc")
@ -241,6 +242,8 @@ async def audit_full_patching(request: Request, db=Depends(get_db)):
yr_count = "patch_count_2026" if year == 2026 else "patch_count_2025"
yr_weeks = "patch_weeks_2026" if year == 2026 else "patch_weeks_2025"
# KPIs globaux + secops/autre
_latest = "id IN (SELECT DISTINCT ON (hostname) id FROM server_audit_full WHERE status IN ('ok','partial') ORDER BY hostname, audit_date DESC)"
kpis = db.execute(text(
f"SELECT COUNT(*) as total,"
f" COUNT(*) FILTER (WHERE {yr_count} >= 1) as patched,"
@ -248,8 +251,25 @@ async def audit_full_patching(request: Request, db=Depends(get_db)):
f" COUNT(*) FILTER (WHERE {yr_count} >= 2) as twice,"
f" COUNT(*) FILTER (WHERE {yr_count} >= 3) as thrice,"
f" COUNT(*) FILTER (WHERE {yr_count} = 0 OR {yr_count} IS NULL) as never"
f" FROM server_audit_full WHERE status IN ('ok','partial')"
f" AND id IN (SELECT DISTINCT ON (hostname) id FROM server_audit_full WHERE status IN ('ok','partial') ORDER BY hostname, audit_date DESC)"
f" FROM server_audit_full WHERE status IN ('ok','partial') AND {_latest}"
)).fetchone()
kpis_secops = db.execute(text(
f"SELECT COUNT(*) as total,"
f" COUNT(*) FILTER (WHERE saf.{yr_count} >= 1) as patched,"
f" COUNT(*) FILTER (WHERE saf.{yr_count} = 0 OR saf.{yr_count} IS NULL) as never"
f" FROM server_audit_full saf JOIN servers s ON saf.server_id = s.id"
f" WHERE saf.status IN ('ok','partial') AND s.patch_os_owner = 'secops'"
f" AND saf.{_latest}"
)).fetchone()
kpis_other = db.execute(text(
f"SELECT COUNT(*) as total,"
f" COUNT(*) FILTER (WHERE saf.{yr_count} >= 1) as patched,"
f" COUNT(*) FILTER (WHERE saf.{yr_count} = 0 OR saf.{yr_count} IS NULL) as never"
f" FROM server_audit_full saf JOIN servers s ON saf.server_id = s.id"
f" WHERE saf.status IN ('ok','partial') AND (s.patch_os_owner != 'secops' OR s.patch_os_owner IS NULL)"
f" AND saf.{_latest}"
)).fetchone()
patch_by_domain = db.execute(text(
@ -307,6 +327,18 @@ async def audit_full_patching(request: Request, db=Depends(get_db)):
if search:
servers = [s for s in servers if search.lower() in s.hostname.lower()]
# Filtre scope secops / autre
if scope == "secops":
secops_hosts = {r.hostname for r in db.execute(text(
"SELECT hostname FROM servers WHERE patch_os_owner = 'secops'"
)).fetchall()}
servers = [s for s in servers if s.hostname in secops_hosts]
elif scope == "other":
secops_hosts = {r.hostname for r in db.execute(text(
"SELECT hostname FROM servers WHERE patch_os_owner = 'secops'"
)).fetchall()}
servers = [s for s in servers if s.hostname not in secops_hosts]
if sort == "hostname":
servers.sort(key=lambda s: s.hostname.lower(), reverse=(sort_dir == "desc"))
elif sort == "count":
@ -322,9 +354,11 @@ async def audit_full_patching(request: Request, db=Depends(get_db)):
ctx = base_context(request, db, user)
ctx.update({
"app_name": APP_NAME, "year": year, "kpis": kpis,
"kpis_secops": kpis_secops, "kpis_other": kpis_other,
"patch_by_domain": patch_by_domain, "patch_weekly": patch_weekly,
"servers": servers_page, "all_domains": all_domains, "all_zones": all_zones,
"search": search, "domain": domain, "sort": sort, "sort_dir": sort_dir,
"search": search, "domain": domain, "scope": scope,
"sort": sort, "sort_dir": sort_dir,
"page": page, "total_pages": total_pages, "total_filtered": total_filtered,
})
return templates.TemplateResponse("audit_full_patching.html", ctx)
@ -504,24 +538,33 @@ async def audit_full_detail(request: Request, audit_id: int, db=Depends(get_db))
if not audit:
return RedirectResponse(url="/audit-full")
# Flux pour ce serveur
flows = get_flow_map_for_server(db, audit.hostname)
def _j(val, default):
if val is None: return default
if isinstance(val, (list, dict)): return val
try: return json.loads(val)
except: return default
# Serveur partial (pas encore audite via SSH)
is_partial = (audit.status == "partial")
flows = [] if is_partial else 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 "[]"),
"is_partial": is_partial,
"services": _j(audit.services, []),
"processes": _j(audit.processes, []),
"listen_ports": _j(audit.listen_ports, []),
"connections": _j(audit.connections, []),
"flux_in": _j(audit.flux_in, []),
"flux_out": _j(audit.flux_out, []),
"disk_usage": _j(audit.disk_usage, []),
"interfaces": _j(audit.interfaces, []),
"correlation": _j(audit.correlation_matrix, []),
"outbound": _j(audit.outbound_only, []),
"firewall": _j(audit.firewall, {}),
"conn_wait": _j(audit.conn_wait, []),
"traffic": _j(audit.traffic, []),
})
return templates.TemplateResponse("audit_full_detail.html", ctx)

View File

@ -14,6 +14,14 @@
</div>
</div>
{% if is_partial %}
<div class="card p-8 text-center mb-4" style="background:#111827;">
<div class="text-3xl font-bold text-gray-500 mb-2">Pas encore audite</div>
<p class="text-sm text-gray-600">Ce serveur n'a pas encore ete audite via SSH (Windows, EMV...)</p>
<p class="text-xs text-gray-600 mt-2">{{ a.hostname }} — {{ a.os_release or 'OS inconnu' }}</p>
{% if a.last_patch_week %}<p class="text-xs text-cyber-green mt-2">Dernier patch : {{ a.last_patch_week }} {{ a.last_patch_year }}</p>{% endif %}
</div>
{% else %}
<!-- KPI -->
<div style="display:flex;flex-wrap:nowrap;gap:8px;margin-bottom:16px;">
<div class="card p-2 text-center" style="flex:1;min-width:0"><div class="text-base font-bold text-cyber-accent">{{ services|length }}</div><div class="text-xs text-gray-500">Services</div></div>
@ -229,4 +237,5 @@
{% endif %}
</div>
</div>
{% endif %}{# end is_partial else #}
{% endblock %}

View File

@ -9,10 +9,34 @@
<div class="flex gap-2 items-center">
<a href="/audit-full/patching?year=2025" class="btn-sm {% if year == 2025 %}bg-cyber-accent text-black{% else %}bg-cyber-border text-gray-400{% endif %} px-3 py-1">2025</a>
<a href="/audit-full/patching?year=2026" class="btn-sm {% if year == 2026 %}bg-cyber-accent text-black{% else %}bg-cyber-border text-gray-400{% endif %} px-3 py-1">2026</a>
<span class="text-gray-600 mx-1">|</span>
<a href="/audit-full/patching?year={{ year }}" class="btn-sm {% if not scope %}bg-cyber-accent text-black{% else %}bg-cyber-border text-gray-400{% endif %} px-3 py-1">Tous</a>
<a href="/audit-full/patching?year={{ year }}&scope=secops" class="btn-sm {% if scope == 'secops' %}bg-cyber-green text-black{% else %}bg-cyber-border text-gray-400{% endif %} px-3 py-1">SecOps</a>
<a href="/audit-full/patching?year={{ year }}&scope=other" class="btn-sm {% if scope == 'other' %}bg-cyber-yellow text-black{% else %}bg-cyber-border text-gray-400{% endif %} px-3 py-1">Hors SecOps</a>
</div>
</div>
<!-- KPIs -->
<!-- KPIs par perimetre -->
{% if kpis_secops and kpis_other %}
<div style="display:flex;flex-wrap:nowrap;gap:6px;margin-bottom:8px;">
<a href="/audit-full/patching?year={{ year }}&scope=secops" class="card p-2 text-center hover:bg-cyber-hover {% if scope == 'secops' %}ring-1 ring-cyber-green{% endif %}" style="flex:1;min-width:0;background:#111827;">
<div class="text-xs text-gray-500 mb-1">SecOps</div>
<div class="text-lg font-bold text-cyber-green">{{ kpis_secops.patched }}<span class="text-gray-500 text-xs">/{{ kpis_secops.total }}</span></div>
{% set pct_s = (kpis_secops.patched / kpis_secops.total * 100)|int if kpis_secops.total > 0 else 0 %}
<div style="height:4px;background:#1f2937;border-radius:2px;margin-top:4px;"><div style="height:100%;width:{{ pct_s }}%;background:#22c55e;border-radius:2px;"></div></div>
<div style="font-size:10px;" class="{% if pct_s >= 80 %}text-cyber-green{% else %}text-cyber-yellow{% endif %} mt-1">{{ pct_s }}%</div>
</a>
<a href="/audit-full/patching?year={{ year }}&scope=other" class="card p-2 text-center hover:bg-cyber-hover {% if scope == 'other' %}ring-1 ring-cyber-yellow{% endif %}" style="flex:1;min-width:0;background:#111827;">
<div class="text-xs text-gray-500 mb-1">Hors SecOps</div>
<div class="text-lg font-bold text-cyber-yellow">{{ kpis_other.patched }}<span class="text-gray-500 text-xs">/{{ kpis_other.total }}</span></div>
{% set pct_o = (kpis_other.patched / kpis_other.total * 100)|int if kpis_other.total > 0 else 0 %}
<div style="height:4px;background:#1f2937;border-radius:2px;margin-top:4px;"><div style="height:100%;width:{{ pct_o }}%;background:#eab308;border-radius:2px;"></div></div>
<div style="font-size:10px;" class="{% if pct_o >= 80 %}text-cyber-green{% else %}text-cyber-yellow{% endif %} mt-1">{{ pct_o }}%</div>
</a>
</div>
{% endif %}
<!-- KPIs globaux -->
{% if kpis %}
{% set pct = (kpis.patched / kpis.total * 100)|int if kpis.total > 0 else 0 %}
<div style="display:flex;flex-wrap:nowrap;gap:6px;margin-bottom:12px;">
@ -79,6 +103,7 @@
<div class="card p-3 mb-4 flex gap-3 items-center flex-wrap">
<form method="GET" action="/audit-full/patching" class="flex gap-2 items-center flex-1">
<input type="hidden" name="year" value="{{ year }}">
{% if scope %}<input type="hidden" name="scope" value="{{ scope }}">{% endif %}
<input type="text" name="q" value="{{ search }}" placeholder="Rechercher..." class="text-xs py-1 px-2 flex-1 font-mono">
<select name="domain" class="text-xs py-1 px-2" onchange="this.form.submit()">
<option value="">Tous</option>
@ -92,7 +117,7 @@
</div>
<!-- Tableau serveurs -->
{% set qs %}{% if search %}&q={{ search }}{% endif %}{% if domain %}&domain={{ domain }}{% endif %}{% endset %}
{% set qs %}{% if search %}&q={{ search }}{% endif %}{% if domain %}&domain={{ domain }}{% endif %}{% if scope %}&scope={{ scope }}{% endif %}{% endset %}
<div class="card overflow-x-auto">
<table class="w-full table-cyber text-xs">
<thead><tr>
@ -120,7 +145,7 @@
</table>
{% if total_pages > 1 %}
{% set pqs %}&year={{ year }}{% if search %}&q={{ search }}{% endif %}{% if domain %}&domain={{ domain }}{% endif %}{% if sort %}&sort={{ sort }}&dir={{ sort_dir }}{% endif %}{% endset %}
{% set pqs %}&year={{ year }}{% if search %}&q={{ search }}{% endif %}{% if domain %}&domain={{ domain }}{% endif %}{% if scope %}&scope={{ scope }}{% endif %}{% if sort %}&sort={{ sort }}&dir={{ sort_dir }}{% endif %}{% endset %}
<div class="flex justify-between items-center p-3 border-t border-cyber-border">
<span class="text-xs text-gray-500">Page {{ page }}/{{ total_pages }}</span>
<div class="flex gap-1">