Qualys complet, contacts, audit refactoré, bulk serveurs

Qualys:
- Recherche API temps réel + cache 24h base locale
- Tags: liste DYN/STAT, mapping V3 (DOM-*, TYP-*, APP-*), nb assets cliquable
- CRUD tags: créer STAT, supprimer, resync API
- Détail asset: infos + décodage nomenclature V3 + tags assignés
- Ajout/retrait tag unitaire avec autocomplete filtrable
- Bulk add/remove tag en masse avec dropdown filtrable
- Tags retirer: charge dynamiquement les STAT assignés aux assets sélectionnés
- Resync assets sélectionnés + retour même recherche

Contacts:
- 50 contacts importés avec 93 scopes (domaine/app/serveur/zone par env)
- 13 rôles (responsable_domaine, ra_prod, ra_recette, referent_technique...)
- Recherche par nom/email/serveur (affiche contacts liés)
- CRUD complet: éditer, scopes, activer/désactiver, supprimer
- Serveurs liés calculés dynamiquement depuis les scopes

Audit:
- Restructuré: Audit général + sous-menu Spécifique
- Dernier audit global affiché avec date
- Lancer audit général avec exclusions (domaines/zones) et parallélisme
- KPIs Qualys KO et S1 KO cliquables
- Export CSV

Serveurs:
- Actions groupées bulk (domaine, env, tier, état, owner, licence)
- Dashboard: KPI EOL ajouté
- Filtre état: EOL + en décommissionnement ajoutés
- 138 serveurs EOL importés depuis Qualys (owner=na, hors périmètre)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Khalid MOUTAOUAKIL 2026-04-05 00:47:26 +02:00
parent e0105e7e00
commit 8e62b1fb11
24 changed files with 2480 additions and 40 deletions

View File

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

View File

@ -1,9 +1,11 @@
"""Router audit serveurs — resultats des scans d'audit""" """Router audit serveurs — résultats des scans + audit temps réel"""
from fastapi import APIRouter, Request, Depends, Query import csv, io
from fastapi.responses import HTMLResponse, RedirectResponse from fastapi import APIRouter, Request, Depends, Query, Form, UploadFile, File
from fastapi.responses import HTMLResponse, RedirectResponse, StreamingResponse
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from sqlalchemy import text from sqlalchemy import text
from ..dependencies import get_db, get_current_user, get_user_perms, can_view, can_edit, can_admin, base_context from ..dependencies import get_db, get_current_user, get_user_perms, can_view, can_edit, can_admin, base_context
from ..services.realtime_audit_service import audit_servers_list, save_audit_to_db
from ..config import APP_NAME from ..config import APP_NAME
router = APIRouter() router = APIRouter()
@ -49,7 +51,6 @@ async def audit_page(request: Request, db=Depends(get_db),
LIMIT 500 LIMIT 500
"""), params).fetchall() """), params).fetchall()
# Stats
stats = db.execute(text(""" stats = db.execute(text("""
SELECT SELECT
COUNT(*) as total, COUNT(*) as total,
@ -63,22 +64,227 @@ async def audit_page(request: Request, db=Depends(get_db),
FROM server_audit FROM server_audit
""")).fetchone() """)).fetchone()
return templates.TemplateResponse("audit.html", { # Dernier audit global
"request": request, "user": user, "app_name": APP_NAME, last_audit = db.execute(text(
"entries": entries, "stats": stats, "filter": filter, "SELECT MAX(audit_date) as last_date, COUNT(*) as count FROM server_audit"
"search": search, )).fetchone()
# Domaines et zones pour exclusions
domains = db.execute(text("SELECT code, name FROM domains ORDER BY display_order")).fetchall()
zones = db.execute(text("SELECT DISTINCT name FROM zones ORDER BY name")).fetchall()
ctx = base_context(request, db, user)
ctx.update({
"app_name": APP_NAME, "entries": entries, "stats": stats,
"filter": filter, "search": search,
"last_audit": last_audit, "domains": domains, "zones": zones,
}) })
return templates.TemplateResponse("audit.html", ctx)
@router.get("/audit/specific", response_class=HTMLResponse)
async def audit_specific_page(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_edit(perms, "audit"):
return RedirectResponse(url="/audit")
ctx = base_context(request, db, user)
ctx["app_name"] = APP_NAME
return templates.TemplateResponse("audit_specific.html", ctx)
@router.get("/audit/{audit_id}", response_class=HTMLResponse) @router.get("/audit/{audit_id}", response_class=HTMLResponse)
async def audit_detail(request: Request, audit_id: int, db=Depends(get_db)): async def audit_detail(request: Request, audit_id: int, db=Depends(get_db)):
user = get_current_user(request) user = get_current_user(request)
if not user: if not user:
return HTMLResponse("<p>Non autorise</p>") return HTMLResponse("<p>Non autorisé</p>")
entry = db.execute(text("SELECT * FROM server_audit WHERE id = :id"), entry = db.execute(text("SELECT * FROM server_audit WHERE id = :id"),
{"id": audit_id}).fetchone() {"id": audit_id}).fetchone()
if not entry: if not entry:
return HTMLResponse("<p>Non trouve</p>") return HTMLResponse("<p>Non trouvé</p>")
return templates.TemplateResponse("partials/audit_detail.html", { return templates.TemplateResponse("partials/audit_detail.html", {
"request": request, "e": entry, "request": request, "e": entry,
}) })
# --- Audit temps réel ---
@router.post("/audit/global", response_class=HTMLResponse)
async def audit_global(request: Request, db=Depends(get_db)):
"""Lance un audit global sur tous les serveurs Linux hors exclusions"""
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_edit(perms, "audit"):
return RedirectResponse(url="/audit")
form = await request.form()
exclude_domains = form.getlist("exclude_domains")
exclude_zones = form.getlist("exclude_zones")
parallel = int(form.get("parallel", "5"))
# Construire la requete
where = ["s.os_family = 'linux'", "s.etat = 'en_production'"]
params = {}
if exclude_domains:
where.append("d.code NOT IN :ed")
params["ed"] = tuple(exclude_domains)
if exclude_zones:
where.append("z.name NOT IN :ez")
params["ez"] = tuple(exclude_zones)
wc = " AND ".join(where)
# Utiliser du SQL direct avec parametres corrects
query = f"""
SELECT s.hostname FROM servers s
LEFT JOIN domain_environments de ON s.domain_env_id = de.id
LEFT JOIN domains d ON de.domain_id = d.id
LEFT JOIN zones z ON s.zone_id = z.id
WHERE {wc} ORDER BY s.hostname
"""
# Gerer les tuples pour IN
if exclude_domains:
placeholders = ",".join([f":ed{i}" for i in range(len(exclude_domains))])
query = query.replace("d.code NOT IN :ed", f"d.code NOT IN ({placeholders})")
for i, v in enumerate(exclude_domains):
params[f"ed{i}"] = v
del params["ed"]
if exclude_zones:
placeholders = ",".join([f":ez{i}" for i in range(len(exclude_zones))])
query = query.replace("z.name NOT IN :ez", f"z.name NOT IN ({placeholders})")
for i, v in enumerate(exclude_zones):
params[f"ez{i}"] = v
del params["ez"]
rows = db.execute(text(query), params).fetchall()
hostnames = [r.hostname for r in rows]
if not hostnames:
return RedirectResponse(url="/audit?msg=no_hosts", status_code=303)
# Lancer l'audit
results = audit_servers_list(hostnames)
request.app.state.last_audit_results = results
ctx = base_context(request, db, user)
ctx.update({
"app_name": APP_NAME, "results": results,
"total": len(results),
"ok": sum(1 for r in results if r.get("status") == "OK"),
"failed": sum(1 for r in results if r.get("status") != "OK"),
})
return templates.TemplateResponse("audit_realtime_results.html", ctx)
@router.post("/audit/realtime", response_class=HTMLResponse)
async def audit_realtime(request: Request, db=Depends(get_db),
hostnames_text: str = Form(""),
hostnames_file: UploadFile = File(None)):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_edit(perms, "audit"):
return RedirectResponse(url="/audit")
# Collecter les hostnames
hostnames = []
if hostnames_text.strip():
hostnames = [h.strip() for h in hostnames_text.strip().replace(",", "\n").split("\n") if h.strip()]
if hostnames_file and hostnames_file.filename:
content = (await hostnames_file.read()).decode("utf-8", errors="replace")
hostnames += [h.strip() for h in content.split("\n") if h.strip() and not h.startswith("#")]
if not hostnames:
return RedirectResponse(url="/audit?msg=no_hosts", status_code=303)
# Lancer l'audit
results = audit_servers_list(hostnames)
# Stocker en session (request.state) pour export/save
request.app.state.last_audit_results = results
ctx = base_context(request, db, user)
ctx.update({
"app_name": APP_NAME, "results": results,
"total": len(results),
"ok": sum(1 for r in results if r.get("status") == "OK"),
"failed": sum(1 for r in results if r.get("status") != "OK"),
})
return templates.TemplateResponse("audit_realtime_results.html", ctx)
@router.post("/audit/realtime/save")
async def audit_realtime_save(request: Request, db=Depends(get_db)):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
results = getattr(request.app.state, "last_audit_results", None)
if not results:
return RedirectResponse(url="/audit?msg=no_results", status_code=303)
updated, inserted = save_audit_to_db(db, results)
return RedirectResponse(url=f"/audit?msg=saved_{updated}_{inserted}", status_code=303)
@router.get("/audit/export/csv")
async def audit_export_csv(request: Request, db=Depends(get_db),
filter: str = Query(None), search: str = Query(None)):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
where = ["1=1"]
params = {}
if filter == "failed":
where.append("sa.status = 'CONNECTION_FAILED'")
elif filter == "no_qualys":
where.append("sa.qualys_active = false AND sa.status = 'OK'")
elif filter == "no_s1":
where.append("sa.sentinelone_active = false AND sa.status = 'OK'")
if search:
where.append("sa.hostname ILIKE :q"); params["q"] = f"%{search}%"
wc = " AND ".join(where)
rows = db.execute(text(f"""
SELECT sa.hostname, sa.status, sa.connection_method, sa.resolved_fqdn,
sa.os_release, sa.kernel, sa.uptime, sa.selinux,
sa.disk_detail, sa.disk_alert, sa.apps_installed,
sa.services_running, sa.running_not_enabled, sa.listening_ports,
sa.db_detected, sa.cluster_detected, sa.containers,
sa.agents, sa.qualys_active, sa.sentinelone_active,
sa.failed_services, sa.audit_date
FROM server_audit sa WHERE {wc} ORDER BY sa.hostname
"""), params).fetchall()
output = io.StringIO()
writer = csv.writer(output, delimiter=";")
headers = ["Hostname", "Statut", "Connexion", "FQDN", "OS", "Kernel", "Uptime", "SELinux",
"Disque", "Alerte disque", "Applications", "Services running",
"Sans auto-start", "Ports", "BDD", "Cluster", "Containers",
"Agents", "Qualys", "SentinelOne", "Services KO", "Date audit"]
writer.writerow(headers)
for r in rows:
writer.writerow([
r.hostname, r.status, r.connection_method, r.resolved_fqdn,
(r.os_release or "")[:80], r.kernel, r.uptime, r.selinux,
(r.disk_detail or "")[:100], "OUI" if r.disk_alert else "NON",
(r.apps_installed or "")[:100], (r.services_running or "")[:100],
(r.running_not_enabled or "")[:100], (r.listening_ports or "")[:100],
r.db_detected, r.cluster_detected, (r.containers or "")[:80],
r.agents, "OUI" if r.qualys_active else "NON",
"OUI" if r.sentinelone_active else "NON", r.failed_services,
r.audit_date.strftime("%Y-%m-%d %H:%M") if r.audit_date else "",
])
output.seek(0)
return StreamingResponse(
iter([output.getvalue()]),
media_type="text/csv",
headers={"Content-Disposition": f"attachment; filename=audit_export_{filter or 'all'}.csv"}
)

View File

@ -99,6 +99,9 @@ async def campaign_create(request: Request, db=Depends(get_db)):
user = get_current_user(request) user = get_current_user(request)
if not user: if not user:
return RedirectResponse(url="/login") return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_edit(perms, "campaigns"):
return RedirectResponse(url="/campaigns", status_code=303)
form = await request.form() form = await request.form()
year = int(form.get("year", datetime.now().year)) year = int(form.get("year", datetime.now().year))
week = int(form.get("week_number", 0)) week = int(form.get("week_number", 0))
@ -375,6 +378,9 @@ async def assignment_add(request: Request, db=Depends(get_db),
user = get_current_user(request) user = get_current_user(request)
if not user: if not user:
return RedirectResponse(url="/login") return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_edit(perms, "campaigns"):
return RedirectResponse(url="/campaigns", status_code=303)
try: try:
db.execute(text(""" db.execute(text("""
INSERT INTO default_assignments (rule_type, rule_value, user_id, priority, note) INSERT INTO default_assignments (rule_type, rule_value, user_id, priority, note)
@ -393,6 +399,9 @@ async def assignment_delete(request: Request, rule_id: int, db=Depends(get_db)):
user = get_current_user(request) user = get_current_user(request)
if not user: if not user:
return RedirectResponse(url="/login") return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_edit(perms, "campaigns"):
return RedirectResponse(url="/campaigns", status_code=303)
db.execute(text("DELETE FROM default_assignments WHERE id = :id"), {"id": rule_id}) db.execute(text("DELETE FROM default_assignments WHERE id = :id"), {"id": rule_id})
db.commit() db.commit()
return RedirectResponse(url="/assignments?msg=deleted", status_code=303) return RedirectResponse(url="/assignments?msg=deleted", status_code=303)

247
app/routers/contacts.py Normal file
View File

@ -0,0 +1,247 @@
"""Router contacts — gestion des responsables applicatifs et scopes"""
from fastapi import APIRouter, Request, Depends, Query, Form
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, can_edit, base_context
from ..config import APP_NAME
router = APIRouter()
templates = Jinja2Templates(directory="app/templates")
ROLES = [
("responsable_domaine", "Responsable domaine"),
("responsable_prod", "Responsable production"),
("responsable_applicatif", "Responsable applicatif"),
("referent_technique", "Référent technique"),
("ra_prod", "RA Production"),
("ra_recette", "RA Recette"),
("ra_preprod", "RA Pré-prod"),
("ra_test", "RA Test"),
("ra_dev", "RA Développement"),
("chef_projet", "Chef de projet"),
("contact_technique", "Contact technique"),
("editeur", "Éditeur"),
("autre", "Autre"),
]
SCOPE_TYPES = [
("domain", "Domaine"),
("application", "Application"),
("app_group", "Groupe applicatif"),
("server", "Serveur"),
("zone", "Zone"),
]
ENV_SCOPES = ["all", "prod", "recette", "preprod", "test", "dev"]
@router.get("/contacts", response_class=HTMLResponse)
async def contacts_page(request: Request, db=Depends(get_db),
search: str = Query(None), role: str = Query(None),
server: str = Query(None)):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
where = ["1=1"]
params = {}
if search:
where.append("(c.name ILIKE :q OR c.email ILIKE :q)")
params["q"] = f"%{search}%"
if role:
where.append("c.role = :role")
params["role"] = role
# Recherche par serveur : trouver les contacts liés à ce serveur
server_info = None
if server:
server_info = db.execute(text("""
SELECT s.id, s.hostname, d.code as domain_code, d.name as domain_name,
e.name as env_name, s.app_group, z.name as zone_name,
ss.app_type
FROM servers s
LEFT JOIN domain_environments de ON s.domain_env_id = de.id
LEFT JOIN domains d ON de.domain_id = d.id
LEFT JOIN environments e ON de.environment_id = e.id
LEFT JOIN zones z ON s.zone_id = z.id
LEFT JOIN server_specifics ss ON ss.server_id = s.id
WHERE LOWER(s.hostname) = LOWER(:h)
"""), {"h": server.strip()}).fetchone()
if server_info:
where.append("""c.id IN (
SELECT cs.contact_id FROM contact_scopes cs WHERE
(cs.scope_type = 'server' AND LOWER(cs.scope_value) = LOWER(:srv_hn))
OR (cs.scope_type = 'domain' AND cs.scope_value = :srv_dom)
OR (cs.scope_type = 'app_group' AND cs.scope_value = :srv_ag)
OR (cs.scope_type = 'application' AND UPPER(cs.scope_value) = UPPER(:srv_app))
OR (cs.scope_type = 'zone' AND cs.scope_value = :srv_zone)
)""")
params["srv_hn"] = server.strip()
params["srv_dom"] = server_info.domain_code or ""
params["srv_ag"] = server_info.app_group or ""
params["srv_app"] = server_info.app_type or ""
params["srv_zone"] = server_info.zone_name or ""
wc = " AND ".join(where)
contacts = db.execute(text(f"""
SELECT c.*,
(SELECT string_agg(cs.scope_type || ':' || cs.scope_value ||
CASE WHEN cs.env_scope != 'all' THEN '(' || cs.env_scope || ')' ELSE '' END, ', '
ORDER BY cs.scope_type, cs.scope_value)
FROM contact_scopes cs WHERE cs.contact_id = c.id) as scopes_summary
FROM contacts c WHERE {wc} ORDER BY c.name
"""), params).fetchall()
roles_in_db = db.execute(text(
"SELECT DISTINCT role FROM contacts ORDER BY role"
)).fetchall()
domains = db.execute(text("SELECT code, name FROM domains ORDER BY display_order")).fetchall()
app_types = db.execute(text(
"SELECT DISTINCT app_type FROM server_specifics WHERE app_type IS NOT NULL ORDER BY app_type"
)).fetchall()
ctx = base_context(request, db, user)
ctx.update({
"app_name": APP_NAME, "contacts": contacts,
"roles": ROLES, "roles_in_db": [r.role for r in roles_in_db],
"scope_types": SCOPE_TYPES, "env_scopes": ENV_SCOPES,
"domains": domains, "app_types": [r.app_type for r in app_types],
"search": search, "role_filter": role, "server": server, "server_info": server_info,
"msg": request.query_params.get("msg"),
})
return templates.TemplateResponse("contacts.html", ctx)
@router.get("/contacts/{contact_id}", response_class=HTMLResponse)
async def contact_detail(request: Request, contact_id: int, db=Depends(get_db)):
user = get_current_user(request)
if not user:
return HTMLResponse("<p>Non autorisé</p>")
contact = db.execute(text("SELECT * FROM contacts WHERE id = :id"),
{"id": contact_id}).fetchone()
if not contact:
return HTMLResponse("<p>Non trouvé</p>")
scopes = db.execute(text("""
SELECT cs.* FROM contact_scopes cs WHERE cs.contact_id = :cid ORDER BY cs.scope_type, cs.scope_value
"""), {"cid": contact_id}).fetchall()
# Serveurs liés via scopes
servers = db.execute(text("""
SELECT DISTINCT s.id, s.hostname, d.name as domaine, e.name as environnement
FROM servers s
LEFT JOIN domain_environments de ON s.domain_env_id = de.id
LEFT JOIN domains d ON de.domain_id = d.id
LEFT JOIN environments e ON de.environment_id = e.id
LEFT JOIN server_specifics ss ON ss.server_id = s.id
LEFT JOIN zones z ON s.zone_id = z.id
WHERE EXISTS (
SELECT 1 FROM contact_scopes cs WHERE cs.contact_id = :cid AND (
(cs.scope_type = 'domain' AND d.code = cs.scope_value)
OR (cs.scope_type = 'application' AND UPPER(ss.app_type) = UPPER(cs.scope_value))
OR (cs.scope_type = 'server' AND LOWER(s.hostname) = LOWER(cs.scope_value))
OR (cs.scope_type = 'app_group' AND s.app_group = cs.scope_value)
OR (cs.scope_type = 'zone' AND z.name = cs.scope_value)
)
)
ORDER BY d.name, s.hostname LIMIT 100
"""), {"cid": contact_id}).fetchall()
domains = db.execute(text("SELECT code, name FROM domains ORDER BY display_order")).fetchall()
app_types = db.execute(text(
"SELECT DISTINCT app_type FROM server_specifics WHERE app_type IS NOT NULL ORDER BY app_type"
)).fetchall()
return templates.TemplateResponse("partials/contact_detail.html", {
"request": request, "c": contact, "scopes": scopes, "servers": servers,
"roles": ROLES, "scope_types": SCOPE_TYPES, "env_scopes": ENV_SCOPES,
"domains": domains, "app_types": [r.app_type for r in app_types],
})
@router.post("/contacts/add")
async def contact_add(request: Request, db=Depends(get_db),
name: str = Form(...), email: str = Form(...), contact_role: str = Form("autre")):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
try:
db.execute(text("""
INSERT INTO contacts (name, email, role, is_active)
VALUES (:n, :e, :r, true)
"""), {"n": name.strip(), "e": email.strip().lower(), "r": contact_role})
db.commit()
return RedirectResponse(url="/contacts?msg=added", status_code=303)
except Exception:
db.rollback()
return RedirectResponse(url="/contacts?msg=exists", status_code=303)
@router.post("/contacts/{contact_id}/edit")
async def contact_edit(request: Request, contact_id: int, db=Depends(get_db),
name: str = Form(""), email: str = Form(""), contact_role: str = Form("")):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
updates = []; params = {"id": contact_id}
if name: updates.append("name = :n"); params["n"] = name
if email: updates.append("email = :e"); params["e"] = email.lower()
if contact_role: updates.append("role = :r"); params["r"] = contact_role
if updates:
db.execute(text(f"UPDATE contacts SET {', '.join(updates)} WHERE id = :id"), params)
db.commit()
return RedirectResponse(url="/contacts?msg=edited", status_code=303)
@router.post("/contacts/{contact_id}/toggle")
async def contact_toggle(request: Request, contact_id: int, db=Depends(get_db)):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
db.execute(text("UPDATE contacts SET is_active = NOT is_active WHERE id = :id"), {"id": contact_id})
db.commit()
return RedirectResponse(url="/contacts?msg=toggled", status_code=303)
@router.post("/contacts/{contact_id}/scope/add")
async def scope_add(request: Request, contact_id: int, db=Depends(get_db),
scope_type: str = Form(...), scope_value: str = Form(...),
env_scope: str = Form("all")):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
try:
db.execute(text("""
INSERT INTO contact_scopes (contact_id, scope_type, scope_value, env_scope)
VALUES (:cid, :st, :sv, :es)
"""), {"cid": contact_id, "st": scope_type, "sv": scope_value.strip(), "es": env_scope})
db.commit()
except Exception:
db.rollback()
return RedirectResponse(url=f"/contacts?msg=scope_added", status_code=303)
@router.post("/contacts/scope/{scope_id}/delete")
async def scope_delete(request: Request, scope_id: int, db=Depends(get_db)):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
db.execute(text("DELETE FROM contact_scopes WHERE id = :id"), {"id": scope_id})
db.commit()
return RedirectResponse(url="/contacts?msg=scope_deleted", status_code=303)
@router.post("/contacts/{contact_id}/delete")
async def contact_delete(request: Request, contact_id: int, db=Depends(get_db)):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
db.execute(text("DELETE FROM contact_scopes WHERE contact_id = :cid"), {"cid": contact_id})
db.execute(text("DELETE FROM contacts WHERE id = :cid"), {"cid": contact_id})
db.commit()
return RedirectResponse(url="/contacts?msg=deleted", status_code=303)

518
app/routers/qualys.py Normal file
View File

@ -0,0 +1,518 @@
"""Router Qualys — Tags, Recherche assets, Décodeur nomenclature"""
from fastapi import APIRouter, Request, Depends, Query, Form
from fastapi.responses import HTMLResponse, RedirectResponse, StreamingResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy import text
import csv, io, re
from ..dependencies import get_db, get_current_user, get_user_perms, can_view, can_edit, can_admin, base_context
from ..services.qualys_service import (
sync_server_qualys, search_assets_api, get_all_tags_api,
create_tag_api, delete_tag_api, add_tag_to_asset_api,
remove_tag_from_asset_api, resync_all_tags,
)
from ..config import APP_NAME
router = APIRouter()
templates = Jinja2Templates(directory="app/templates")
# Nomenclature maps (from server_decoder.py)
ENV_MAP = {'p': ('Production', 'ENV-PRD'), 'r': ('Recette', 'ENV-REC'), 'i': ('Pré-Prod', 'ENV-PPR'),
'v': ('Test', 'ENV-TST'), 'd': ('Développement', 'ENV-DEV'), 't': ('Test', 'ENV-TST'),
's': ('Production', 'ENV-PRD'), 'o': ('Pré-Prod', 'ENV-PPR')}
DOMAIN_MAP = {
'bot': ('Flux Libre', 'DOM-FL'), 'boo': ('Flux Libre', 'DOM-FL'), 'boc': ('Flux Libre', 'DOM-FL'),
'afl': ('Flux Libre', 'DOM-FL'), 'sup': ('Flux Libre', 'DOM-FL'),
'dsi': ('Infrastructure', 'DOM-INF'), 'cyb': ('Infrastructure', 'DOM-INF'),
'vsa': ('Infrastructure', 'DOM-INF'), 'iad': ('Infrastructure', 'DOM-INF'),
'bur': ('Infrastructure', 'DOM-INF'), 'aii': ('Infrastructure', 'DOM-INF'),
'ecm': ('Infrastructure', 'DOM-INF'), 'log': ('Infrastructure', 'DOM-INF'),
'bck': ('Infrastructure', 'DOM-INF'), 'gaw': ('Infrastructure', 'DOM-INF'),
'vid': ('Infrastructure', 'DOM-INF'), 'sim': ('Infrastructure', 'DOM-INF'),
'emv': ('EMV', 'TAG-EMV'), 'pci': ('EMV', 'TAG-EMV'),
'pea': ('Péage', 'DOM-PEA'), 'osa': ('Péage', 'DOM-PEA'), 'svp': ('Péage', 'DOM-PEA'),
'adv': ('Péage', 'DOM-PEA'), 'rpa': ('Péage', 'DOM-PEA'),
'ame': ('Trafic', 'DOM-TRA'), 'tra': ('Trafic', 'DOM-TRA'), 'dai': ('Trafic', 'DOM-TRA'),
'pat': ('Trafic', 'DOM-TRA'), 'dep': ('Trafic', 'DOM-TRA'), 'exp': ('Trafic', 'DOM-TRA'),
'sig': ('Trafic', 'DOM-TRA'), 'rau': ('Trafic', 'DOM-TRA'),
'dec': ('BI', 'DOM-BI'), 'sas': ('BI', 'DOM-BI'), 'bip': ('BI', 'DOM-BI'),
'int': ('Gestion', 'DOM-GES'), 'agt': ('Gestion', 'DOM-GES'), 'pin': ('Gestion', 'DOM-GES'),
'ech': ('Gestion', 'DOM-GES'),
}
AUTO_PREFIXES = ('ENV-', 'OS-', 'DOM-', 'TYP-', 'TAG-OBS', 'TAG-EMV')
def _save_api_results_to_db(db, assets):
"""Sauvegarde les résultats de recherche API dans la base locale"""
for a in assets:
if not a.get("qualys_asset_id"):
continue
hostname = (a.get("hostname") or a.get("name", "")).split(".")[0].lower()
os_val = a.get("os", "")
os_family = "linux" if any(k in os_val.lower() for k in ("linux", "red hat", "centos")) else "windows" if "windows" in os_val.lower() else None
srv = db.execute(text("SELECT id FROM servers WHERE LOWER(hostname) = LOWER(:h)"),
{"h": hostname}).fetchone()
server_id = srv.id if srv else None
db.execute(text("""
INSERT INTO qualys_assets (qualys_asset_id, name, hostname, fqdn, ip_address, os, os_family,
agent_status, agent_version, last_checkin, server_id)
VALUES (:qid, :name, :hn, :fqdn, :ip, :os, :osf, :ast, :av, :lc, :sid)
ON CONFLICT (qualys_asset_id) DO UPDATE SET
name=EXCLUDED.name, fqdn=EXCLUDED.fqdn, ip_address=EXCLUDED.ip_address,
os=EXCLUDED.os, os_family=EXCLUDED.os_family,
agent_status=EXCLUDED.agent_status, agent_version=EXCLUDED.agent_version,
last_checkin=EXCLUDED.last_checkin, server_id=COALESCE(EXCLUDED.server_id, qualys_assets.server_id),
updated_at=now()
"""), {
"qid": a["qualys_asset_id"], "name": a.get("name"), "hn": hostname,
"fqdn": a.get("fqdn") or None, "ip": a.get("ip_address") or None,
"os": os_val, "osf": os_family,
"ast": a.get("agent_status", ""), "av": a.get("agent_version", ""),
"lc": a.get("last_checkin") or None, "sid": server_id,
})
if a.get("tags"):
db.execute(text("DELETE FROM qualys_asset_tags WHERE qualys_asset_id = :qid"),
{"qid": a["qualys_asset_id"]})
for tag_name in a["tags"]:
tag_row = db.execute(text("SELECT qualys_tag_id FROM qualys_tags WHERE name = :n"),
{"n": tag_name}).fetchone()
if tag_row:
db.execute(text("""
INSERT INTO qualys_asset_tags (qualys_asset_id, qualys_tag_id)
VALUES (:qid, :tid) ON CONFLICT DO NOTHING
"""), {"qid": a["qualys_asset_id"], "tid": tag_row.qualys_tag_id})
db.commit()
def _decode_hostname(hostname):
"""Décode un hostname SANEF et retourne les tags suggérés"""
hn = hostname.lower().strip()
if hn.startswith('ls-'):
return {'type': 'Physique', 'env': 'Production', 'domain': 'Péage', 'tags': ['ENV-PRD', 'DOM-PEA', 'TYP-SRV', 'OS-WIN']}
if len(hn) < 4:
return {'type': '?', 'env': '?', 'domain': '?', 'tags': []}
# Type
machine = 'VM' if hn[0] == 'v' else 'Physique'
eqt = 'TYP-VIR' if hn[0] == 'v' else 'TYP-SRV'
# Env
env_info = ENV_MAP.get(hn[1], ('?', None))
env_name, env_tag = env_info
# Domain (try 4, 3, 2 char prefixes)
rest = hn[2:]
domain_name, domain_tag = '?', None
for length in (4, 3, 2):
prefix = rest[:length]
if prefix in DOMAIN_MAP:
domain_name, domain_tag = DOMAIN_MAP[prefix]
break
tags = [t for t in [env_tag, domain_tag, eqt] if t]
return {'type': machine, 'env': env_name, 'domain': domain_name, 'tags': tags}
# === TAGS ===
@router.get("/qualys/tags", response_class=HTMLResponse)
async def qualys_tags(request: Request, db=Depends(get_db),
search: str = Query(None), tag_type: str = Query(None)):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_view(perms, "qualys"):
return RedirectResponse(url="/dashboard")
where = ["1=1"]
params = {}
if search:
where.append("qt.name ILIKE :q"); params["q"] = f"%{search}%"
if tag_type == "dyn":
where.append("qt.is_dynamic = true")
elif tag_type == "stat":
where.append("qt.is_dynamic = false")
wc = " AND ".join(where)
tags = db.execute(text(f"""
SELECT qt.*,
(SELECT COUNT(*) FROM qualys_asset_tags qat WHERE qat.qualys_tag_id = qt.qualys_tag_id) as asset_count
FROM qualys_tags qt WHERE {wc} ORDER BY qt.name
"""), params).fetchall()
stats = db.execute(text("""
SELECT COUNT(*) as total,
COUNT(*) FILTER (WHERE is_dynamic) as dyn,
COUNT(*) FILTER (WHERE NOT is_dynamic) as stat
FROM qualys_tags
""")).fetchone()
ctx = base_context(request, db, user)
ctx.update({
"app_name": APP_NAME, "tags": tags, "stats": stats,
"search": search, "tag_type": tag_type,
"can_edit_qualys": can_edit(perms, "qualys"),
"msg": request.query_params.get("msg"),
})
return templates.TemplateResponse("qualys_tags.html", ctx)
@router.post("/qualys/tags/resync")
async def qualys_tags_resync(request: Request, db=Depends(get_db)):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
result = resync_all_tags(db)
msg = "resync_ok" if result["ok"] else "resync_ko"
return RedirectResponse(url=f"/qualys/tags?msg={msg}", status_code=303)
@router.post("/qualys/tags/create")
async def qualys_tag_create(request: Request, db=Depends(get_db),
tag_name: str = Form(...)):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
result = create_tag_api(db, tag_name.strip())
msg = "created" if result["ok"] else "create_error"
return RedirectResponse(url=f"/qualys/tags?msg={msg}", status_code=303)
@router.post("/qualys/tags/{tag_id}/delete")
async def qualys_tag_delete(request: Request, tag_id: int, db=Depends(get_db)):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
result = delete_tag_api(db, tag_id)
msg = "deleted" if result["ok"] else "delete_error"
return RedirectResponse(url=f"/qualys/tags?msg={msg}", status_code=303)
@router.post("/qualys/asset/{asset_id}/tag/add")
async def qualys_asset_tag_add(request: Request, asset_id: int, db=Depends(get_db),
tag_id: str = Form(...)):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
result = add_tag_to_asset_api(db, asset_id, int(tag_id))
color = "text-cyber-green" if result["ok"] else "text-cyber-red"
return HTMLResponse(f'<span class="text-xs {color}">{result["msg"]}</span>')
@router.post("/qualys/asset/{asset_id}/tag/remove")
async def qualys_asset_tag_remove(request: Request, asset_id: int, db=Depends(get_db),
tag_id: str = Form(...)):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
result = remove_tag_from_asset_api(db, asset_id, int(tag_id))
color = "text-cyber-green" if result["ok"] else "text-cyber-red"
return HTMLResponse(f'<span class="text-xs {color}">{result["msg"]}</span>')
def _bulk_return_url(form, msg):
"""Construit l'URL de retour avec la recherche conservée"""
s = form.get("return_search", "")
f = form.get("return_field", "hostname")
return f"/qualys/search?field={f}&search={s}&msg={msg}"
@router.post("/qualys/bulk/add-tag")
async def qualys_bulk_add_tag(request: Request, db=Depends(get_db)):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
form = await request.form()
ids = [int(x) for x in form.get("asset_ids", "").split(",") if x.strip().isdigit()]
tid = int(form.get("tag_id", "0") or "0")
ok = 0; ko = 0
for aid in ids:
r = add_tag_to_asset_api(db, aid, tid)
if r["ok"]: ok += 1
else: ko += 1
return RedirectResponse(url=_bulk_return_url(form, f"bulk_add_{ok}_{ko}"), status_code=303)
@router.post("/qualys/bulk/remove-tag")
async def qualys_bulk_remove_tag(request: Request, db=Depends(get_db)):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
form = await request.form()
ids = [int(x) for x in form.get("asset_ids", "").split(",") if x.strip().isdigit()]
tid = int(form.get("tag_id", "0") or "0")
ok = 0; ko = 0
for aid in ids:
r = remove_tag_from_asset_api(db, aid, tid)
if r["ok"]: ok += 1
else: ko += 1
return RedirectResponse(url=_bulk_return_url(form, f"bulk_rm_{ok}_{ko}"), status_code=303)
@router.post("/qualys/resync-assets")
async def qualys_resync_assets(request: Request, db=Depends(get_db)):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
form = await request.form()
ids = [int(x) for x in form.get("asset_ids", "").split(",") if x.strip().isdigit()]
ok = 0
for aid in ids:
result = search_assets_api(db, str(aid), field="id", operator="EQUALS")
if result.get("ok") and result.get("assets"):
_save_api_results_to_db(db, result["assets"])
ok += 1
return RedirectResponse(url=_bulk_return_url(form, f"resync_{ok}"), status_code=303)
@router.get("/qualys/bulk/tags-for-assets")
async def qualys_tags_for_assets(request: Request, db=Depends(get_db),
asset_ids: str = Query("")):
"""Retourne les tags STAT assignés aux assets sélectionnés (JSON)"""
from fastapi.responses import JSONResponse
user = get_current_user(request)
if not user:
return JSONResponse([])
ids = [int(x) for x in asset_ids.split(",") if x.strip().isdigit()]
if not ids:
return JSONResponse([])
placeholders = ",".join([f":id{i}" for i in range(len(ids))])
params = {f"id{i}": v for i, v in enumerate(ids)}
rows = db.execute(text(f"""
SELECT qt.qualys_tag_id as id, qt.name, COUNT(DISTINCT qat.qualys_asset_id) as count
FROM qualys_asset_tags qat
JOIN qualys_tags qt ON qat.qualys_tag_id = qt.qualys_tag_id
WHERE qat.qualys_asset_id IN ({placeholders}) AND qt.is_dynamic = false
GROUP BY qt.qualys_tag_id, qt.name
ORDER BY qt.name
"""), params).fetchall()
return JSONResponse([{"id": r.id, "name": r.name, "count": r.count} for r in rows])
@router.get("/qualys/tags/export")
async def qualys_tags_export(request: Request, db=Depends(get_db)):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
tags = db.execute(text("SELECT * FROM qualys_tags ORDER BY name")).fetchall()
output = io.StringIO()
writer = csv.writer(output, delimiter=";")
writer.writerow(["Nom", "ID Qualys", "Type", "Nb assets"])
for t in tags:
writer.writerow([t.name, t.qualys_tag_id, "DYN" if t.is_dynamic else "STAT", ""])
output.seek(0)
return StreamingResponse(iter(["\ufeff" + output.getvalue()]), media_type="text/csv",
headers={"Content-Disposition": "attachment; filename=qualys_tags.csv"})
# === RECHERCHE ASSETS ===
@router.get("/qualys/search", response_class=HTMLResponse)
async def qualys_search(request: Request, db=Depends(get_db),
search: str = Query(None), field: str = Query("hostname")):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_view(perms, "qualys"):
return RedirectResponse(url="/dashboard")
assets = []
api_msg = None
source = None
if search:
# Vérifier si on a des données récentes (< 24h) en base
from datetime import datetime, timedelta
cutoff = datetime.now() - timedelta(hours=24)
if field == "hostname":
fresh = db.execute(text("""
SELECT COUNT(*) FROM qualys_assets
WHERE (hostname ILIKE :q OR name ILIKE :q OR fqdn ILIKE :q)
AND updated_at > :cutoff
"""), {"q": f"%{search}%", "cutoff": cutoff}).scalar()
elif field == "ip":
fresh = db.execute(text("""
SELECT COUNT(*) FROM qualys_assets
WHERE CAST(ip_address AS TEXT) LIKE :q AND updated_at > :cutoff
"""), {"q": f"{search}%", "cutoff": cutoff}).scalar()
elif field == "tag":
fresh = db.execute(text("""
SELECT COUNT(*) FROM qualys_assets qa
WHERE qa.qualys_asset_id IN (
SELECT qat.qualys_asset_id FROM qualys_asset_tags qat
JOIN qualys_tags qt ON qat.qualys_tag_id = qt.qualys_tag_id
WHERE qt.name ILIKE :q
) AND qa.updated_at > :cutoff
"""), {"q": f"%{search}%", "cutoff": cutoff}).scalar()
else:
fresh = 0
if fresh > 0:
# Données fraiches en base — pas d'appel API
source = "base (< 24h)"
else:
# Appel API + stockage en base
if field == "hostname":
result = search_assets_api(db, search, field="name", operator="CONTAINS")
elif field == "ip":
result = search_assets_api(db, search, field="address", operator="CONTAINS")
elif field == "tag":
result = search_assets_api(db, search, field="tagName", operator="EQUALS")
else:
result = {"ok": False, "msg": "Champ inconnu", "assets": []}
if result.get("ok") and result.get("assets"):
_save_api_results_to_db(db, result["assets"])
source = f"API Qualys ({len(result['assets'])} résultats)"
elif not result.get("ok"):
source = f"Erreur API: {result.get('msg', '?')}"
# Fallback sur la base meme si pas frais
source += " — fallback base"
else:
source = "API: aucun résultat"
# Lire depuis la base dans tous les cas
where_db = ["1=1"]; params_db = {}
if field == "hostname":
where_db.append("(qa.hostname ILIKE :q OR qa.name ILIKE :q OR qa.fqdn ILIKE :q)")
params_db["q"] = f"%{search}%"
elif field == "ip":
where_db.append("CAST(qa.ip_address AS TEXT) LIKE :q")
params_db["q"] = f"{search}%"
elif field == "tag":
where_db.append("""qa.qualys_asset_id IN (
SELECT qat.qualys_asset_id FROM qualys_asset_tags qat
JOIN qualys_tags qt ON qat.qualys_tag_id = qt.qualys_tag_id
WHERE qt.name ILIKE :q)""")
params_db["q"] = f"%{search}%"
wc_db = " AND ".join(where_db)
assets = db.execute(text(f"""
SELECT qa.*, s.hostname as srv_hostname, s.tier,
(SELECT string_agg(qt.name, ', ' ORDER BY qt.name)
FROM qualys_asset_tags qat JOIN qualys_tags qt ON qat.qualys_tag_id = qt.qualys_tag_id
WHERE qat.qualys_asset_id = qa.qualys_asset_id) as tags_list
FROM qualys_assets qa
LEFT JOIN servers s ON qa.server_id = s.id
WHERE {wc_db} ORDER BY qa.hostname LIMIT 200
"""), params_db).fetchall()
api_msg = f"{len(assets)} résultat(s) — source: {source}"
all_tags = db.execute(text("SELECT qualys_tag_id, name FROM qualys_tags ORDER BY name")).fetchall()
ctx = base_context(request, db, user)
ctx.update({
"app_name": APP_NAME, "assets": assets, "search": search,
"field": field, "api_msg": api_msg,
"all_tags": all_tags,
"can_edit_qualys": can_edit(perms, "qualys"),
"msg": request.query_params.get("msg"),
})
return templates.TemplateResponse("qualys_search.html", ctx)
@router.get("/qualys/asset/{asset_id}", response_class=HTMLResponse)
async def qualys_asset_detail(request: Request, asset_id: int, db=Depends(get_db)):
user = get_current_user(request)
if not user:
return HTMLResponse("<p>Non autorisé</p>")
asset = db.execute(text("SELECT * FROM qualys_assets WHERE qualys_asset_id = :aid"),
{"aid": asset_id}).fetchone()
if not asset:
return HTMLResponse("<p>Asset non trouvé</p>")
tags = db.execute(text("""
SELECT qt.name, qt.is_dynamic, qt.qualys_tag_id
FROM qualys_asset_tags qat JOIN qualys_tags qt ON qat.qualys_tag_id = qt.qualys_tag_id
WHERE qat.qualys_asset_id = :aid ORDER BY qt.name
"""), {"aid": asset_id}).fetchall()
decoded = _decode_hostname(asset.hostname or asset.name or "")
all_tags = db.execute(text(
"SELECT qualys_tag_id, name, is_dynamic FROM qualys_tags ORDER BY name"
)).fetchall()
return templates.TemplateResponse("partials/qualys_asset_detail.html", {
"request": request, "a": asset, "tags": tags, "decoded": decoded, "all_tags": all_tags,
})
@router.get("/qualys/search/export")
async def qualys_search_export(request: Request, db=Depends(get_db),
search: str = Query(""), field: str = Query("hostname")):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
# Re-execute la recherche
where = ["1=1"]; params = {}
if search:
if field == "hostname":
where.append("(qa.hostname ILIKE :q OR qa.name ILIKE :q OR qa.fqdn ILIKE :q)")
params["q"] = f"%{search}%"
elif field == "ip":
where.append("CAST(qa.ip_address AS TEXT) LIKE :q"); params["q"] = f"{search}%"
wc = " AND ".join(where)
assets = db.execute(text(f"""
SELECT qa.hostname, qa.ip_address, qa.fqdn, qa.os, qa.agent_status,
qa.agent_version, qa.last_checkin, qa.qualys_asset_id
FROM qualys_assets qa WHERE {wc} ORDER BY qa.hostname LIMIT 1000
"""), params).fetchall()
output = io.StringIO()
writer = csv.writer(output, delimiter=";")
writer.writerow(["Hostname", "IP", "FQDN", "OS", "Agent", "Version", "Dernier check-in", "Qualys ID"])
for a in assets:
writer.writerow([a.hostname, a.ip_address, a.fqdn, a.os, a.agent_status,
a.agent_version, a.last_checkin, a.qualys_asset_id])
output.seek(0)
return StreamingResponse(iter(["\ufeff" + output.getvalue()]), media_type="text/csv",
headers={"Content-Disposition": f"attachment; filename=qualys_assets_{search or 'all'}.csv"})
# === DECODEUR ===
@router.get("/qualys/decoder", response_class=HTMLResponse)
async def qualys_decoder(request: Request, db=Depends(get_db),
hostname: str = Query(None)):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_view(perms, "qualys"):
return RedirectResponse(url="/dashboard")
result = None
current_tags = []
if hostname:
result = _decode_hostname(hostname)
result["hostname"] = hostname
# Chercher les tags actuels dans Qualys
asset = db.execute(text(
"SELECT qualys_asset_id FROM qualys_assets WHERE LOWER(hostname) = LOWER(:h)"
), {"h": hostname.strip().split(".")[0].lower()}).fetchone()
if asset:
rows = db.execute(text("""
SELECT qt.name, qt.is_dynamic FROM qualys_asset_tags qat
JOIN qualys_tags qt ON qat.qualys_tag_id = qt.qualys_tag_id
WHERE qat.qualys_asset_id = :aid ORDER BY qt.name
"""), {"aid": asset.qualys_asset_id}).fetchall()
current_tags = [(r.name, r.is_dynamic) for r in rows]
ctx = base_context(request, db, user)
ctx.update({
"app_name": APP_NAME, "result": result, "hostname": hostname,
"current_tags": current_tags, "auto_prefixes": AUTO_PREFIXES,
})
return templates.TemplateResponse("qualys_decoder.html", ctx)

View File

@ -98,6 +98,59 @@ async def server_update(request: Request, server_id: int, db=Depends(get_db),
}) })
@router.post("/servers/bulk")
async def servers_bulk(request: Request, db=Depends(get_db),
server_ids: str = Form(""), bulk_field: str = Form(""),
bulk_value: str = Form("")):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
if not server_ids or not bulk_field or not bulk_value:
return RedirectResponse(url="/servers", status_code=303)
ids = [int(x) for x in server_ids.split(",") if x.strip().isdigit()]
if not ids:
return RedirectResponse(url="/servers", status_code=303)
from sqlalchemy import text as sqlt
if bulk_field in ("tier", "etat", "patch_os_owner", "licence_support"):
db.execute(sqlt(f"UPDATE servers SET {bulk_field} = :val WHERE id = ANY(:ids)"),
{"val": bulk_value, "ids": ids})
elif bulk_field == "domain_code":
# Trouver le domain_env_id correspondant (prod par defaut)
row = db.execute(sqlt("""
SELECT de.id FROM domain_environments de
JOIN domains d ON de.domain_id = d.id
JOIN environments e ON de.environment_id = e.id
WHERE d.code = :dc ORDER BY e.display_order LIMIT 1
"""), {"dc": bulk_value}).fetchone()
if row:
db.execute(sqlt("UPDATE servers SET domain_env_id = :deid WHERE id = ANY(:ids)"),
{"deid": row.id, "ids": ids})
elif bulk_field == "env_code":
# Pour chaque serveur, garder son domaine mais changer l'env
for sid in ids:
srv = db.execute(sqlt("""
SELECT d.id as did FROM servers s
JOIN domain_environments de ON s.domain_env_id = de.id
JOIN domains d ON de.domain_id = d.id
WHERE s.id = :sid
"""), {"sid": sid}).fetchone()
if srv:
de = db.execute(sqlt("""
SELECT de.id FROM domain_environments de
JOIN environments e ON de.environment_id = e.id
WHERE de.domain_id = :did AND e.code = :ec
"""), {"did": srv.did, "ec": bulk_value}).fetchone()
if de:
db.execute(sqlt("UPDATE servers SET domain_env_id = :deid WHERE id = :sid"),
{"deid": de.id, "sid": sid})
db.commit()
return RedirectResponse(url=f"/servers?msg=bulk_{len(ids)}", status_code=303)
@router.post("/servers/{server_id}/sync-qualys", response_class=HTMLResponse) @router.post("/servers/{server_id}/sync-qualys", response_class=HTMLResponse)
async def server_sync_qualys(request: Request, server_id: int, db=Depends(get_db)): async def server_sync_qualys(request: Request, server_id: int, db=Depends(get_db)):
user = get_current_user(request) user = get_current_user(request)

View File

@ -15,7 +15,8 @@ SECTIONS = {
("qualys_url", "URL API", False), ("qualys_url", "URL API", False),
("qualys_user", "Utilisateur", False), ("qualys_user", "Utilisateur", False),
("qualys_pass", "Mot de passe", True), ("qualys_pass", "Mot de passe", True),
("qualys_proxy", "Proxy (ex: http://proxy:3128)", False), ("qualys_proxy", "Proxy", False),
("qualys_bypass_proxy", "Bypass proxy (true/false)", False),
], ],
"itop": [ "itop": [
("itop_url", "URL API", False), ("itop_url", "URL API", False),
@ -144,7 +145,11 @@ async def settings_save(request: Request, section: str, db=Depends(get_db)):
val = form.get(key, "") val = form.get(key, "")
if is_secret and val == "********": if is_secret and val == "********":
continue continue
if val: # Checkbox: si absent du form = "false"
if key.endswith("_bypass_proxy") or key.endswith("_verify_ssl"):
val = "true" if val else "false"
set_secret(db, key, val, label)
elif val:
set_secret(db, key, val, label) set_secret(db, key, val, label)
ctx = _build_context(db, user, saved=section) ctx = _build_context(db, user, saved=section)

View File

@ -14,6 +14,9 @@ def _get_qualys_creds(db):
user = get_secret(db, "qualys_user") or "" user = get_secret(db, "qualys_user") or ""
pwd = get_secret(db, "qualys_pass") or "" pwd = get_secret(db, "qualys_pass") or ""
proxy = get_secret(db, "qualys_proxy") or "" proxy = get_secret(db, "qualys_proxy") or ""
bypass = (get_secret(db, "qualys_bypass_proxy") or "").lower() == "true"
if bypass:
proxy = ""
return url, user, pwd, proxy return url, user, pwd, proxy
@ -21,6 +24,218 @@ def parse_xml(txt, tag):
return re.findall(f"<{tag}>([^<]*)</{tag}>", txt) return re.findall(f"<{tag}>([^<]*)</{tag}>", txt)
def search_assets_api(db, query, field="name", operator="CONTAINS"):
"""Recherche des assets via l'API Qualys en temps réel"""
qualys_url, qualys_user, qualys_pass, qualys_proxy = _get_qualys_creds(db)
if not qualys_user:
return {"ok": False, "msg": "Credentials Qualys non configurés", "assets": []}
proxies = {"https": qualys_proxy, "http": qualys_proxy} if qualys_proxy else None
try:
r = requests.post(
f"{qualys_url}/qps/rest/2.0/search/am/hostasset",
json={"ServiceRequest": {
"preferences": {"limitResults": 200},
"filters": {"Criteria": [
{"field": field, "operator": operator, "value": query}
]}
}},
auth=(qualys_user, qualys_pass),
verify=False, timeout=60, proxies=proxies,
headers={"Content-Type": "application/json"}
)
except Exception as e:
return {"ok": False, "msg": f"Erreur API: {e}", "assets": []}
if r.status_code != 200 or "SUCCESS" not in r.text:
return {"ok": False, "msg": f"API HTTP {r.status_code}", "assets": []}
assets = _parse_assets_full(r.text)
return {"ok": True, "msg": f"{len(assets)} résultat(s)", "assets": assets}
def get_all_tags_api(db):
"""Récupère tous les tags depuis l'API Qualys"""
qualys_url, qualys_user, qualys_pass, qualys_proxy = _get_qualys_creds(db)
if not qualys_user:
return {"ok": False, "msg": "Credentials non configurés", "tags": []}
proxies = {"https": qualys_proxy, "http": qualys_proxy} if qualys_proxy else None
try:
r = requests.post(
f"{qualys_url}/qps/rest/2.0/search/am/tag",
json={"ServiceRequest": {"preferences": {"limitResults": 1000}}},
auth=(qualys_user, qualys_pass),
verify=False, timeout=60, proxies=proxies,
headers={"Content-Type": "application/json"}
)
except Exception as e:
return {"ok": False, "msg": str(e), "tags": []}
if r.status_code != 200 or "SUCCESS" not in r.text:
return {"ok": False, "msg": f"HTTP {r.status_code}", "tags": []}
tags = []
for block in r.text.split("<Tag>")[1:]:
block = block.split("</Tag>")[0]
tid = (parse_xml(block, "id") or [""])[0]
tname = (parse_xml(block, "name") or [""])[0]
rule_type = (parse_xml(block, "ruleType") or [""])[0]
if tid and tname:
tags.append({"id": int(tid), "name": tname, "is_dynamic": bool(rule_type), "rule_type": rule_type})
return {"ok": True, "msg": f"{len(tags)} tags", "tags": tags}
def _parse_assets_full(text):
"""Parse le XML Qualys en liste de dicts enrichis"""
assets = []
for block in text.split("<HostAsset>")[1:]:
block = block.split("</HostAsset>")[0]
aid = (parse_xml(block, "id") or [""])[0]
name = (parse_xml(block, "name") or [""])[0]
fqdn = (parse_xml(block, "fqdn") or [""])[0]
address = (parse_xml(block, "address") or [""])[0]
os_val = (parse_xml(block, "os") or [""])[0]
agent_status = ""
agent_version = ""
last_checkin = ""
if "<agentInfo>" in block:
agent_status = (parse_xml(block, "status") or [""])[0]
agent_version = (parse_xml(block, "agentVersion") or [""])[0]
last_checkin = (parse_xml(block, "lastCheckedIn") or [""])[0]
# Tags
tags = []
if "<tags>" in block:
tag_block = block.split("<tags>")[1].split("</tags>")[0]
tag_names = parse_xml(tag_block, "name")
tags = tag_names
hostname = name.split(".")[0].lower() if name else ""
assets.append({
"qualys_asset_id": int(aid) if aid else None,
"name": name, "hostname": hostname, "fqdn": fqdn,
"ip_address": address, "os": os_val,
"agent_status": agent_status, "agent_version": agent_version,
"last_checkin": last_checkin, "tags": tags,
"tags_list": ", ".join(tags),
})
return assets
def create_tag_api(db, tag_name):
"""Crée un tag statique dans Qualys via API"""
qualys_url, qualys_user, qualys_pass, qualys_proxy = _get_qualys_creds(db)
if not qualys_user:
return {"ok": False, "msg": "Credentials non configurés"}
proxies = {"https": qualys_proxy, "http": qualys_proxy} if qualys_proxy else None
try:
r = requests.post(
f"{qualys_url}/qps/rest/2.0/create/am/tag",
json={"ServiceRequest": {"data": {"Tag": {"name": tag_name}}}},
auth=(qualys_user, qualys_pass), verify=False, timeout=30, proxies=proxies,
headers={"Content-Type": "application/json"})
if r.status_code == 200 and "SUCCESS" in r.text:
tid = (parse_xml(r.text, "id") or [""])[0]
if tid:
db.execute(text("""
INSERT INTO qualys_tags (qualys_tag_id, name, is_dynamic) VALUES (:tid, :n, false)
ON CONFLICT (qualys_tag_id) DO UPDATE SET name = EXCLUDED.name
"""), {"tid": int(tid), "n": tag_name})
db.commit()
return {"ok": True, "msg": f"Tag '{tag_name}' créé (ID: {tid})"}
return {"ok": False, "msg": f"Erreur API: {r.text[:200]}"}
except Exception as e:
return {"ok": False, "msg": str(e)}
def delete_tag_api(db, qualys_tag_id):
"""Supprime un tag dans Qualys via API"""
qualys_url, qualys_user, qualys_pass, qualys_proxy = _get_qualys_creds(db)
if not qualys_user:
return {"ok": False, "msg": "Credentials non configurés"}
proxies = {"https": qualys_proxy, "http": qualys_proxy} if qualys_proxy else None
try:
r = requests.post(
f"{qualys_url}/qps/rest/2.0/delete/am/tag/{qualys_tag_id}",
auth=(qualys_user, qualys_pass), verify=False, timeout=30, proxies=proxies,
headers={"Content-Type": "application/json"})
if r.status_code == 200 and "SUCCESS" in r.text:
db.execute(text("DELETE FROM qualys_asset_tags WHERE qualys_tag_id = :tid"), {"tid": qualys_tag_id})
db.execute(text("DELETE FROM qualys_tags WHERE qualys_tag_id = :tid"), {"tid": qualys_tag_id})
db.commit()
return {"ok": True, "msg": "Tag supprimé"}
return {"ok": False, "msg": f"Erreur API: {r.text[:200]}"}
except Exception as e:
return {"ok": False, "msg": str(e)}
def add_tag_to_asset_api(db, asset_id, tag_id):
"""Ajoute un tag à un asset via API Qualys"""
qualys_url, qualys_user, qualys_pass, qualys_proxy = _get_qualys_creds(db)
if not qualys_user:
return {"ok": False, "msg": "Credentials non configurés"}
proxies = {"https": qualys_proxy, "http": qualys_proxy} if qualys_proxy else None
try:
r = requests.post(
f"{qualys_url}/qps/rest/2.0/update/am/hostasset/{asset_id}",
json={"ServiceRequest": {"data": {"HostAsset": {"tags": {"add": {"TagSimple": {"id": tag_id}}}}}}},
auth=(qualys_user, qualys_pass), verify=False, timeout=30, proxies=proxies,
headers={"Content-Type": "application/json"})
if r.status_code == 200 and "SUCCESS" in r.text:
db.execute(text("""
INSERT INTO qualys_asset_tags (qualys_asset_id, qualys_tag_id)
VALUES (:aid, :tid) ON CONFLICT DO NOTHING
"""), {"aid": asset_id, "tid": tag_id})
db.commit()
return {"ok": True, "msg": "Tag ajouté"}
return {"ok": False, "msg": f"Erreur: {r.text[:200]}"}
except Exception as e:
return {"ok": False, "msg": str(e)}
def remove_tag_from_asset_api(db, asset_id, tag_id):
"""Retire un tag d'un asset via API Qualys"""
qualys_url, qualys_user, qualys_pass, qualys_proxy = _get_qualys_creds(db)
if not qualys_user:
return {"ok": False, "msg": "Credentials non configurés"}
proxies = {"https": qualys_proxy, "http": qualys_proxy} if qualys_proxy else None
try:
r = requests.post(
f"{qualys_url}/qps/rest/2.0/update/am/hostasset/{asset_id}",
json={"ServiceRequest": {"data": {"HostAsset": {"tags": {"remove": {"TagSimple": {"id": tag_id}}}}}}},
auth=(qualys_user, qualys_pass), verify=False, timeout=30, proxies=proxies,
headers={"Content-Type": "application/json"})
if r.status_code == 200 and "SUCCESS" in r.text:
db.execute(text("""
DELETE FROM qualys_asset_tags WHERE qualys_asset_id = :aid AND qualys_tag_id = :tid
"""), {"aid": asset_id, "tid": tag_id})
db.commit()
return {"ok": True, "msg": "Tag retiré"}
return {"ok": False, "msg": f"Erreur: {r.text[:200]}"}
except Exception as e:
return {"ok": False, "msg": str(e)}
def resync_all_tags(db):
"""Resync tous les tags depuis l'API Qualys vers la base locale"""
result = get_all_tags_api(db)
if not result["ok"]:
return result
count = 0
for t in result["tags"]:
db.execute(text("""
INSERT INTO qualys_tags (qualys_tag_id, name, is_dynamic, rule_type)
VALUES (:tid, :n, :dyn, :rt)
ON CONFLICT (qualys_tag_id) DO UPDATE SET name = EXCLUDED.name, is_dynamic = EXCLUDED.is_dynamic,
rule_type = EXCLUDED.rule_type, updated_at = now()
"""), {"tid": t["id"], "n": t["name"], "dyn": t["is_dynamic"], "rt": t.get("rule_type")})
count += 1
db.commit()
return {"ok": True, "msg": f"{count} tags synchronisés"}
def sync_server_qualys(db, server_id): def sync_server_qualys(db, server_id):
"""Sync les tags Qualys pour un serveur donne. Retourne un dict resultat.""" """Sync les tags Qualys pour un serveur donne. Retourne un dict resultat."""
row = db.execute(text( row = db.execute(text(

View File

@ -0,0 +1,222 @@
"""Service audit temps reel — lance des checks SSH et retourne les resultats"""
import socket
import json
import re
from datetime import datetime
from sqlalchemy import text
try:
import paramiko
PARAMIKO_OK = True
except ImportError:
PARAMIKO_OK = False
SSH_KEY = "/opt/patchcenter/keys/id_rsa_cybglobal.pem"
SSH_USER = "cybsecope"
SSH_TIMEOUT = 12
DNS_SUFFIXES = ["", ".sanef.groupe", ".sanef-rec.fr", ".sanef.fr"]
# Commandes d'audit (simplifiees pour le temps reel)
AUDIT_CMDS = {
"os_release": "cat /etc/redhat-release 2>/dev/null || head -1 /etc/os-release 2>/dev/null",
"kernel": "uname -r",
"uptime": "uptime -p 2>/dev/null || uptime",
"selinux": "getenforce 2>/dev/null || echo N/A",
"disk_space": "df -h --output=target,size,avail,pcent 2>/dev/null | grep -vE '^(tmpfs|devtmpfs|Filesystem)' | sort",
"apps_installed": "rpm -qa --qf '%{NAME} %{VERSION}\\n' 2>/dev/null | grep -iE 'tomcat|java|jdk|nginx|httpd|haproxy|docker|podman|postgresql|postgres|mysql|mariadb|mongodb|oracle|redis|elasticsearch|splunk|centreon|qualys' | sort -u",
"services_running": "systemctl list-units --type=service --state=running --no-pager --no-legend 2>/dev/null | grep -vE '(auditd|chronyd|crond|dbus|firewalld|getty|irqbalance|kdump|lvm2|NetworkManager|polkit|postfix|rsyslog|sshd|sssd|systemd|tuned|user@)' | awk '{print $1}' | sed 's/.service//' | sort",
"running_not_enabled": "comm -23 <(systemctl list-units --type=service --state=running --no-pager --no-legend 2>/dev/null | grep -vE '(auditd|chronyd|crond|dbus|firewalld|getty|irqbalance|kdump|lvm2|NetworkManager|polkit|postfix|rsyslog|sshd|sssd|systemd|tuned|user@)' | awk '{print $1}' | sed 's/.service//' | sort) <(systemctl list-unit-files --type=service --state=enabled --no-pager --no-legend 2>/dev/null | awk '{print $1}' | sed 's/.service//' | sort) 2>/dev/null || echo none",
"listening_ports": "ss -tlnp 2>/dev/null | grep LISTEN | grep -vE ':22 |:111 |:323 ' | awk '{print $4, $6}' | sort",
"db_detect": "for svc in postgresql mariadbd mysqld mongod redis-server; do state=$(systemctl is-active $svc 2>/dev/null); [ \"$state\" = \"active\" ] && echo \"$svc:active\"; done; pgrep -x ora_pmon >/dev/null 2>&1 && echo 'oracle:active' || true",
"cluster_detect": "(which pcs 2>/dev/null && pcs status 2>/dev/null | head -3) || (test -f /etc/corosync/corosync.conf && echo 'corosync:present') || echo 'no_cluster'",
"containers": "if which podman >/dev/null 2>&1; then USERS=$(ps aux 2>/dev/null | grep -E 'conmon|podman' | grep -v grep | awk '{print $1}' | sort -u); for U in $USERS; do echo \"=== podman@$U ===\"; su - $U -c 'podman ps -a --format \"table {{.Names}} {{.Status}}\"' 2>/dev/null; done; fi; if which docker >/dev/null 2>&1; then docker ps -a --format 'table {{.Names}} {{.Status}}' 2>/dev/null; fi",
"agents": "for svc in qualys-cloud-agent sentinelone zabbix-agent; do state=$(systemctl is-active $svc 2>/dev/null); [ \"$state\" = \"active\" ] && echo \"$svc:$state\"; done",
"failed_services": "systemctl list-units --type=service --state=failed --no-pager --no-legend 2>/dev/null | awk '{print $2}' | head -10 || echo none",
"satellite": "subscription-manager identity 2>/dev/null | grep -i 'org\\|server' || echo 'not_registered'",
}
BANNER_FILTERS = [
"GROUPE SANEF", "propriété du Groupe", "accèderait", "emprisonnement",
"Article 323", "code pénal", "Authorized uses only", "CyberArk",
"This session", "session is being",
]
def _resolve(hostname):
for suffix in DNS_SUFFIXES:
target = hostname + suffix
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(5)
r = sock.connect_ex((target, 22))
sock.close()
if r == 0:
return target
except Exception:
continue
return None
def _connect(target):
if not PARAMIKO_OK:
return None
import os
if not os.path.exists(SSH_KEY):
return None
for loader in [paramiko.RSAKey.from_private_key_file, paramiko.Ed25519Key.from_private_key_file]:
try:
key = loader(SSH_KEY)
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
client.connect(target, port=22, username=SSH_USER, pkey=key,
timeout=SSH_TIMEOUT, look_for_keys=False, allow_agent=False)
return client
except Exception:
continue
return None
def _run(client, cmd):
try:
full = f"sudo bash -c '{cmd}'"
_, stdout, stderr = client.exec_command(full, timeout=15)
out = stdout.read().decode("utf-8", errors="replace").strip()
lines = [l for l in out.splitlines() if not any(b in l for b in BANNER_FILTERS) and l.strip()]
return "\n".join(lines).strip()
except Exception as e:
return f"ERROR: {e}"
def audit_single_server(hostname):
"""Audite un serveur et retourne un dict de resultats"""
result = {
"hostname": hostname,
"audit_date": datetime.now().strftime("%Y-%m-%d %H:%M"),
"status": "PENDING",
}
target = _resolve(hostname)
if not target:
result["status"] = "CONNECTION_FAILED"
result["connection_method"] = f"DNS: aucun suffixe résolu ({hostname})"
result["resolved_fqdn"] = None
return result
result["resolved_fqdn"] = target
client = _connect(target)
if not client:
result["status"] = "CONNECTION_FAILED"
result["connection_method"] = f"SSH: connexion refusée ({target})"
return result
result["status"] = "OK"
result["connection_method"] = f"ssh_key ({SSH_USER}@{target})"
for key, cmd in AUDIT_CMDS.items():
result[key] = _run(client, cmd)
try:
client.close()
except Exception:
pass
# Post-traitement
agents = result.get("agents", "")
result["qualys_active"] = "qualys" in agents and "active" in agents
result["sentinelone_active"] = "sentinelone" in agents and "active" in agents
result["disk_alert"] = False
for line in (result.get("disk_space") or "").split("\n"):
parts = line.split()
pcts = [p for p in parts if "%" in p]
if pcts:
try:
pct = int(pcts[0].replace("%", ""))
if pct >= 90:
result["disk_alert"] = True
except ValueError:
pass
return result
def audit_servers_list(hostnames):
"""Audite une liste de serveurs"""
results = []
for hn in hostnames:
r = audit_single_server(hn.strip())
results.append(r)
return results
def save_audit_to_db(db, results):
"""Sauvegarde/met a jour les resultats d'audit en base"""
updated = 0
inserted = 0
for r in results:
hostname = r.get("hostname", "")
if not hostname:
continue
# Trouver server_id
srv = db.execute(text("SELECT id FROM servers WHERE LOWER(hostname) = LOWER(:h)"),
{"h": hostname.split(".")[0]}).fetchone()
server_id = srv.id if srv else None
audit_date = datetime.now()
agents = r.get("agents", "")
# Upsert
existing = db.execute(text(
"SELECT id FROM server_audit WHERE server_id = :sid AND server_id IS NOT NULL"
), {"sid": server_id}).fetchone() if server_id else None
if existing:
db.execute(text("""
UPDATE server_audit SET
status = :st, connection_method = :cm, resolved_fqdn = :rf,
os_release = :os, kernel = :k, uptime = :up, selinux = :se,
disk_detail = :dd, disk_alert = :da,
apps_installed = :ai, services_running = :sr,
running_not_enabled = :rne, listening_ports = :lp,
db_detected = :db, cluster_detected = :cl, containers = :co,
agents = :ag, qualys_active = :qa, sentinelone_active = :s1,
failed_services = :fs, audit_date = :ad
WHERE id = :id
"""), {
"id": existing.id, "st": r.get("status"), "cm": r.get("connection_method"),
"rf": r.get("resolved_fqdn"), "os": r.get("os_release"), "k": r.get("kernel"),
"up": r.get("uptime"), "se": r.get("selinux"), "dd": r.get("disk_space"),
"da": r.get("disk_alert", False), "ai": r.get("apps_installed"),
"sr": r.get("services_running"), "rne": r.get("running_not_enabled"),
"lp": r.get("listening_ports"), "db": r.get("db_detect"),
"cl": r.get("cluster_detect"), "co": r.get("containers"),
"ag": agents, "qa": r.get("qualys_active", False),
"s1": r.get("sentinelone_active", False), "fs": r.get("failed_services"),
"ad": audit_date,
})
updated += 1
else:
db.execute(text("""
INSERT INTO server_audit (server_id, hostname, audit_date, status, connection_method,
resolved_fqdn, os_release, kernel, uptime, selinux, disk_detail, disk_alert,
apps_installed, services_running, running_not_enabled, listening_ports,
db_detected, cluster_detected, containers, agents, qualys_active,
sentinelone_active, failed_services)
VALUES (:sid, :hn, :ad, :st, :cm, :rf, :os, :k, :up, :se, :dd, :da,
:ai, :sr, :rne, :lp, :db, :cl, :co, :ag, :qa, :s1, :fs)
"""), {
"sid": server_id, "hn": hostname, "ad": audit_date,
"st": r.get("status"), "cm": r.get("connection_method"),
"rf": r.get("resolved_fqdn"), "os": r.get("os_release"), "k": r.get("kernel"),
"up": r.get("uptime"), "se": r.get("selinux"), "dd": r.get("disk_space"),
"da": r.get("disk_alert", False), "ai": r.get("apps_installed"),
"sr": r.get("services_running"), "rne": r.get("running_not_enabled"),
"lp": r.get("listening_ports"), "db": r.get("db_detect"),
"cl": r.get("cluster_detect"), "co": r.get("containers"),
"ag": agents, "qa": r.get("qualys_active", False),
"s1": r.get("sentinelone_active", False), "fs": r.get("failed_services"),
})
inserted += 1
db.commit()
return updated, inserted

View File

@ -115,6 +115,9 @@ def list_servers(db, filters, page=1, per_page=50, sort="hostname", sort_dir="as
if filters.get("tier"): if filters.get("tier"):
where.append("s.tier = :tier"); params["tier"] = filters["tier"] where.append("s.tier = :tier"); params["tier"] = filters["tier"]
if filters.get("etat"): if filters.get("etat"):
if filters["etat"] == "eol":
where.append("s.licence_support = 'eol'")
else:
where.append("s.etat = :etat"); params["etat"] = filters["etat"] where.append("s.etat = :etat"); params["etat"] = filters["etat"]
if filters.get("search"): if filters.get("search"):
where.append("s.hostname ILIKE :search"); params["search"] = f"%{filters['search']}%" where.append("s.hostname ILIKE :search"); params["search"] = f"%{filters['search']}%"

View File

@ -1,7 +1,77 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% block title %}Audit Serveurs{% endblock %} {% block title %}Audit Serveurs{% endblock %}
{% block content %} {% block content %}
<h2 class="text-xl font-bold text-cyber-accent mb-4">Audit Serveurs <span class="text-sm text-gray-500">({{ stats.total }})</span></h2> <div class="flex justify-between items-center mb-4">
<div>
<h2 class="text-xl font-bold text-cyber-accent">Audit Général Linux</h2>
<p class="text-xs text-gray-500 mt-1">
{% if last_audit and last_audit.last_date %}
Dernier audit : {{ last_audit.last_date.strftime('%d/%m/%Y à %H:%M') }} — {{ last_audit.count }} serveurs
{% else %}Aucun audit réalisé{% endif %}
</p>
</div>
<div class="flex gap-2">
<a href="/audit/export/csv{% if filter %}?filter={{ filter }}{% endif %}{% if search %}&search={{ search }}{% endif %}" class="btn-sm bg-cyber-green text-black">Export CSV</a>
</div>
</div>
<!-- Lancer audit global -->
{% set p = perms if perms is defined else request.state.perms %}
{% if p.audit in ('edit', 'admin') %}
<div x-data="{ showGlobal: false }" class="card p-4 mb-4">
<div class="flex justify-between items-center">
<h3 class="text-sm font-bold text-cyber-accent">Lancer un audit général</h3>
<button @click="showGlobal = !showGlobal" class="btn-sm bg-cyber-border text-cyber-accent" x-text="showGlobal ? 'Masquer' : 'Configurer'"></button>
</div>
<div x-show="showGlobal" class="mt-3">
<form method="POST" action="/audit/global" class="space-y-3">
<p class="text-xs text-gray-500">Tous les serveurs Linux en production seront audités (hors exclusions).</p>
<div class="grid grid-cols-3 gap-3">
<div>
<label class="text-xs text-gray-500">Domaines à exclure</label>
<div class="space-y-1 mt-1">
{% for d in domains %}
<label class="flex items-center gap-2 text-xs text-gray-400">
<input type="checkbox" name="exclude_domains" value="{{ d.code }}" {% if d.code == 'EMV' %}checked{% endif %}>
{{ d.name }}
</label>
{% endfor %}
</div>
</div>
<div>
<label class="text-xs text-gray-500">Zones à exclure</label>
<div class="space-y-1 mt-1">
{% for z in zones %}
<label class="flex items-center gap-2 text-xs text-gray-400">
<input type="checkbox" name="exclude_zones" value="{{ z.name }}" {% if z.name == 'EMV' %}checked{% endif %}>
{{ z.name }}
</label>
{% endfor %}
</div>
</div>
<div>
<label class="text-xs text-gray-500">Parallélisme</label>
<select name="parallel" class="w-full text-xs py-1 px-2 mt-1">
<option value="1">Séquentiel (1)</option>
<option value="5" selected>5 en parallèle</option>
<option value="10">10 en parallèle</option>
<option value="20">20 en parallèle</option>
</select>
<p class="text-xs text-gray-600 mt-2">EMV exclu par défaut (zone PCI-DSS)</p>
</div>
</div>
<button type="submit" class="btn-primary px-4 py-2 text-sm" onclick="this.textContent='Audit global en cours...'; this.disabled=true; this.form.submit()">Lancer l'audit général</button>
</form>
</div>
</div>
{% endif %}
{% set msg = request.query_params.get('msg') %}
{% if msg %}
<div class="mb-3 p-2 rounded text-sm {% if msg == 'no_hosts' or msg == 'no_results' %}bg-red-900/30 text-cyber-red{% else %}bg-green-900/30 text-cyber-green{% endif %}">
{% if msg == 'no_hosts' %}Aucun hostname saisi.{% elif msg == 'no_results' %}Pas de résultats à sauvegarder.{% elif msg.startswith('saved_') %}Base mise à jour : {{ msg.split('_')[1] }} modifié(s), {{ msg.split('_')[2] }} ajouté(s).{% endif %}
</div>
{% endif %}
<!-- KPIs --> <!-- KPIs -->
<div class="grid grid-cols-8 gap-2 mb-4"> <div class="grid grid-cols-8 gap-2 mb-4">
@ -11,7 +81,7 @@
</a> </a>
<a href="/audit?filter=failed" class="card p-2 text-center hover:border-cyber-accent/50 {% if filter == 'failed' %}border-cyber-accent{% endif %}"> <a href="/audit?filter=failed" class="card p-2 text-center hover:border-cyber-accent/50 {% if filter == 'failed' %}border-cyber-accent{% endif %}">
<div class="text-lg font-bold text-cyber-red">{{ stats.failed }}</div> <div class="text-lg font-bold text-cyber-red">{{ stats.failed }}</div>
<div class="text-[10px] text-gray-500">Échoués</div> <div class="text-[10px] text-gray-500">Echoues</div>
</a> </a>
<a href="/audit?filter=disk" class="card p-2 text-center hover:border-cyber-accent/50 {% if filter == 'disk' %}border-cyber-accent{% endif %}"> <a href="/audit?filter=disk" class="card p-2 text-center hover:border-cyber-accent/50 {% if filter == 'disk' %}border-cyber-accent{% endif %}">
<div class="text-lg font-bold text-cyber-yellow">{{ stats.disk_alerts }}</div> <div class="text-lg font-bold text-cyber-yellow">{{ stats.disk_alerts }}</div>
@ -25,14 +95,14 @@
<div class="text-lg font-bold text-cyber-red">{{ stats.failed_svc }}</div> <div class="text-lg font-bold text-cyber-red">{{ stats.failed_svc }}</div>
<div class="text-[10px] text-gray-500">Svc en echec</div> <div class="text-[10px] text-gray-500">Svc en echec</div>
</a> </a>
<div class="card p-2 text-center"> <a href="/audit?filter=no_qualys" class="card p-2 text-center hover:border-cyber-accent/50 {% if filter == 'no_qualys' %}border-cyber-accent{% endif %}">
<div class="text-lg font-bold text-cyber-green">{{ stats.qualys_ok }}</div> <div class="text-lg font-bold text-cyber-red">{{ stats.ok - stats.qualys_ok }}</div>
<div class="text-[10px] text-gray-500">Qualys OK</div> <div class="text-[10px] text-gray-500">Qualys KO</div>
</div> </a>
<div class="card p-2 text-center"> <a href="/audit?filter=no_s1" class="card p-2 text-center hover:border-cyber-accent/50 {% if filter == 'no_s1' %}border-cyber-accent{% endif %}">
<div class="text-lg font-bold text-cyber-green">{{ stats.s1_ok }}</div> <div class="text-lg font-bold text-cyber-red">{{ stats.ok - stats.s1_ok }}</div>
<div class="text-[10px] text-gray-500">SentinelOne OK</div> <div class="text-[10px] text-gray-500">S1 KO</div>
</div> </a>
<div class="card p-2 text-center"> <div class="card p-2 text-center">
<form method="GET" class="flex gap-1"> <form method="GET" class="flex gap-1">
<input type="text" name="search" value="{{ search or '' }}" placeholder="Hostname" class="text-xs py-1 px-2 w-full"> <input type="text" name="search" value="{{ search or '' }}" placeholder="Hostname" class="text-xs py-1 px-2 w-full">
@ -40,6 +110,9 @@
</div> </div>
</div> </div>
<!-- Panel détail -->
<div id="audit-detail" class="card mb-4 p-5" style="display:none"></div>
<!-- Table --> <!-- Table -->
<div class="card overflow-x-auto"> <div class="card overflow-x-auto">
<table class="w-full table-cyber"> <table class="w-full table-cyber">
@ -84,6 +157,4 @@
</table> </table>
</div> </div>
<!-- Panel detail -->
<div id="audit-detail" class="card mt-4 p-5" style="display:none"></div>
{% endblock %} {% endblock %}

View File

@ -0,0 +1,24 @@
{% extends 'base.html' %}
{% block title %}Audit temps réel{% endblock %}
{% block content %}
<div class="flex justify-between items-center mb-4">
<div>
<a href="/audit" class="text-xs text-gray-500 hover:text-gray-300">← Audit</a>
<h2 class="text-xl font-bold text-cyber-accent">Audit temps réel</h2>
</div>
</div>
<div class="card p-5">
<form method="POST" action="/audit/realtime" enctype="multipart/form-data" class="space-y-3">
<div>
<label class="text-xs text-gray-500">Serveurs (un par ligne ou séparés par virgule)</label>
<textarea name="hostnames_text" rows="6" class="w-full font-mono text-xs" placeholder="vpinfaweb1&#10;vrtrabkme1&#10;lpemvaste1"></textarea>
</div>
<div>
<label class="text-xs text-gray-500">Ou importer un fichier (.txt, un hostname par ligne)</label>
<input type="file" name="hostnames_file" accept=".txt" class="text-xs text-gray-400">
</div>
<button type="submit" class="btn-primary px-4 py-2 text-sm" onclick="this.textContent='Audit en cours...'; this.disabled=true; this.form.submit()">Générer le rapport d'audit</button>
</form>
</div>
{% endblock %}

View File

@ -0,0 +1,120 @@
{% extends 'base.html' %}
{% block title %}Résultats audit temps réel{% endblock %}
{% block content %}
<div class="flex justify-between items-center mb-4">
<div>
<a href="/audit" class="text-xs text-gray-500 hover:text-gray-300">← Retour audit</a>
<h2 class="text-xl font-bold text-cyber-accent">Résultats audit temps réel</h2>
</div>
<div class="flex gap-2">
<form method="POST" action="/audit/realtime/save">
<button class="btn-primary px-4 py-2 text-sm" onclick="return confirm('Mettre à jour la base avec ces résultats ?')">Mettre à jour la base</button>
</form>
</div>
</div>
<!-- Stats -->
<div class="grid grid-cols-3 gap-3 mb-4">
<div class="card p-3 text-center">
<div class="text-2xl font-bold text-cyber-accent">{{ total }}</div>
<div class="text-xs text-gray-500">Total</div>
</div>
<div class="card p-3 text-center">
<div class="text-2xl font-bold text-cyber-green">{{ ok }}</div>
<div class="text-xs text-gray-500">Connectés</div>
</div>
<div class="card p-3 text-center">
<div class="text-2xl font-bold text-cyber-red">{{ failed }}</div>
<div class="text-xs text-gray-500">Échoués</div>
</div>
</div>
<!-- Résultats -->
<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">Statut</th>
<th class="p-2">FQDN résolu</th>
<th class="p-2">OS</th>
<th class="p-2">Kernel</th>
<th class="p-2">Disque</th>
<th class="p-2">Qualys</th>
<th class="p-2">S1</th>
<th class="p-2">Services</th>
<th class="p-2">Sans auto</th>
<th class="p-2">BDD</th>
<th class="p-2">Containers</th>
</tr></thead>
<tbody>
{% for r in results %}
<tr class="{% if r.status != 'OK' %}bg-red-900/10{% elif r.disk_alert %}bg-yellow-900/10{% endif %}">
<td class="p-2 font-mono text-cyber-accent">{{ r.hostname }}</td>
<td class="p-2 text-center"><span class="badge {% if r.status == 'OK' %}badge-green{% else %}badge-red{% endif %}">{{ r.status[:12] }}</span></td>
<td class="p-2 text-center text-gray-400">{{ r.resolved_fqdn or '-' }}</td>
<td class="p-2 text-center text-gray-400" title="{{ r.os_release or '' }}">{{ (r.os_release or '-')[:25] }}</td>
<td class="p-2 text-center text-gray-400">{{ (r.kernel or '-')[:20] }}</td>
<td class="p-2 text-center">
{% if r.disk_alert %}<span class="badge badge-red">ALERTE</span>
{% elif r.status == 'OK' %}<span class="text-cyber-green">OK</span>
{% else %}-{% endif %}
</td>
<td class="p-2 text-center">{% if r.qualys_active %}<span class="text-cyber-green">OK</span>{% elif r.status == 'OK' %}<span class="text-cyber-red">KO</span>{% else %}-{% endif %}</td>
<td class="p-2 text-center">{% if r.sentinelone_active %}<span class="text-cyber-green">OK</span>{% elif r.status == 'OK' %}<span class="text-cyber-red">KO</span>{% else %}-{% endif %}</td>
<td class="p-2 text-center text-gray-400" title="{{ r.services_running or '' }}">{% if r.services_running %}{{ r.services_running.split('\n')|length }}{% else %}-{% endif %}</td>
<td class="p-2 text-center">{% if r.running_not_enabled and r.running_not_enabled != 'none' %}<span class="text-cyber-yellow">{{ r.running_not_enabled.split('\n')|length }}</span>{% else %}-{% endif %}</td>
<td class="p-2 text-center text-gray-400">{{ (r.db_detect or '-')[:15] }}</td>
<td class="p-2 text-center text-gray-400">{{ (r.containers or '-')[:20] }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Détail par serveur -->
{% for r in results %}
{% if r.status == 'OK' %}
<details class="card mt-2">
<summary class="p-3 cursor-pointer hover:bg-cyber-border/20 font-mono text-sm text-cyber-accent">{{ r.hostname }} — {{ r.resolved_fqdn or '' }}</summary>
<div class="p-4 grid grid-cols-2 gap-4 text-xs">
<div>
<h4 class="text-cyber-accent font-bold mb-1">Espace disque</h4>
<pre class="bg-cyber-bg p-2 rounded text-gray-400 overflow-x-auto">{{ r.disk_space or 'N/A' }}</pre>
</div>
<div>
<h4 class="text-cyber-accent font-bold mb-1">Applications</h4>
<pre class="bg-cyber-bg p-2 rounded text-gray-400 overflow-x-auto">{{ r.apps_installed or 'N/A' }}</pre>
</div>
<div>
<h4 class="text-cyber-accent font-bold mb-1">Services actifs</h4>
<pre class="bg-cyber-bg p-2 rounded text-gray-400 overflow-x-auto" style="max-height:150px">{{ r.services_running or 'N/A' }}</pre>
</div>
<div>
<h4 class="text-cyber-yellow font-bold mb-1">Sans auto-start</h4>
<pre class="bg-cyber-bg p-2 rounded text-cyber-yellow overflow-x-auto">{{ r.running_not_enabled or 'Aucun' }}</pre>
</div>
<div>
<h4 class="text-cyber-accent font-bold mb-1">Ports</h4>
<pre class="bg-cyber-bg p-2 rounded text-gray-400 overflow-x-auto">{{ r.listening_ports or 'N/A' }}</pre>
</div>
<div>
<h4 class="text-cyber-accent font-bold mb-1">Agents</h4>
<pre class="bg-cyber-bg p-2 rounded text-gray-400">{{ r.agents or 'N/A' }}</pre>
</div>
{% if r.containers and r.containers != 'none' %}
<div class="col-span-2">
<h4 class="text-cyber-accent font-bold mb-1">Containers</h4>
<pre class="bg-cyber-bg p-2 rounded text-gray-400">{{ r.containers }}</pre>
</div>
{% endif %}
{% if r.failed_services and r.failed_services != 'none' %}
<div class="col-span-2">
<h4 class="text-cyber-red font-bold mb-1">Services en échec</h4>
<pre class="bg-red-900/20 p-2 rounded text-cyber-red">{{ r.failed_services }}</pre>
</div>
{% endif %}
</div>
</details>
{% endif %}
{% endfor %}
{% endblock %}

View File

@ -0,0 +1,37 @@
{% extends 'base.html' %}
{% block title %}Audit spécifique{% endblock %}
{% block content %}
<div class="flex justify-between items-center mb-4">
<div>
<a href="/audit" class="text-xs text-gray-500 hover:text-gray-300">← Audit</a>
<h2 class="text-xl font-bold text-cyber-accent">Audit spécifique</h2>
<p class="text-xs text-gray-500 mt-1">Auditer un ou plusieurs serveurs à la demande</p>
</div>
</div>
<div class="card p-5">
<form method="POST" action="/audit/realtime" enctype="multipart/form-data" class="space-y-3">
<div>
<label class="text-xs text-gray-500">Serveurs (un par ligne ou séparés par virgule)</label>
<textarea name="hostnames_text" rows="6" class="w-full font-mono text-xs" placeholder="vpinfaweb1&#10;vrtrabkme1&#10;lpemvaste1"></textarea>
</div>
<div class="flex gap-3 items-end">
<div>
<label class="text-xs text-gray-500">Ou importer un fichier (.txt)</label>
<input type="file" name="hostnames_file" accept=".txt" class="text-xs text-gray-400">
</div>
<div>
<label class="text-xs text-gray-500">Parallélisme</label>
<select name="parallel" class="text-xs py-1 px-2">
<option value="1">Séquentiel (1)</option>
<option value="5" selected>5 en parallèle</option>
<option value="10">10 en parallèle</option>
<option value="20">20 en parallèle</option>
</select>
</div>
</div>
<p class="text-xs text-gray-600">Si plus de 10 serveurs, le parallélisme est recommandé.</p>
<button type="submit" class="btn-primary px-4 py-2 text-sm" onclick="this.textContent='Audit en cours...'; this.disabled=true; this.form.submit()">Lancer l'audit</button>
</form>
</div>
{% endblock %}

View File

@ -56,11 +56,16 @@
{% set p = perms if perms is defined else request.state.perms %} {% set p = perms if perms is defined else request.state.perms %}
<a href="/dashboard" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'dashboard' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %}">Dashboard</a> <a href="/dashboard" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'dashboard' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %}">Dashboard</a>
{% if p.servers %}<a href="/servers" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if request.url.path == '/servers' or '/servers/' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %}">Serveurs</a>{% endif %} {% if p.servers %}<a href="/servers" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if request.url.path == '/servers' or '/servers/' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %}">Serveurs</a>{% endif %}
{% if p.specifics %}<a href="/specifics" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'specifics' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6 text-xs">Spécifiques</a>{% endif %} {% if p.specifics %}<a href="/specifics" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'specifics' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6 text-xs">Specifiques</a>{% endif %}
{% if p.campaigns %}<a href="/campaigns" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'campaigns' in request.url.path and 'assignments' not in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %}">Campagnes</a>{% endif %} {% if p.campaigns %}<a href="/campaigns" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'campaigns' in request.url.path and 'assignments' not in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %}">Campagnes</a>{% endif %}
{% if p.campaigns in ('edit', 'admin') %}<a href="/assignments" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'assignments' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6 text-xs">Assignations</a>{% endif %} {% if p.campaigns in ('edit', 'admin') %}<a href="/assignments" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'assignments' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6 text-xs">Assignations</a>{% endif %}
{% if p.qualys %}<a href="/qualys/search" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if '/qualys/' in request.url.path and 'tags' not in request.url.path and 'decoder' not in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %}">Qualys</a>{% endif %}
{% if p.qualys %}<a href="/qualys/tags" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if '/qualys/tags' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6 text-xs">Tags</a>{% endif %}
{% if p.qualys %}<a href="/qualys/decoder" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'decoder' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6 text-xs">Décodeur</a>{% endif %}
{% 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.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' or '/audit/' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %}">Audit</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.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.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 %} {% 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 %}
</nav> </nav>
@ -71,7 +76,7 @@
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<span class="text-sm text-gray-400">{{ user.sub }}</span> <span class="text-sm text-gray-400">{{ user.sub }}</span>
<span class="badge badge-blue">{{ user.role }}</span> <span class="badge badge-blue">{{ user.role }}</span>
<a href="/logout" class="btn-sm bg-cyber-border text-gray-300 hover:bg-red-900/40 hover:text-cyber-red transition-colors">Déconnexion</a> <a href="/logout" class="btn-sm bg-cyber-border text-gray-300 hover:bg-red-900/40 hover:text-cyber-red transition-colors">Deconnexion</a>
</div> </div>
</header> </header>
<div class="flex flex-1 overflow-hidden"> <div class="flex flex-1 overflow-hidden">

101
app/templates/contacts.html Normal file
View File

@ -0,0 +1,101 @@
{% extends 'base.html' %}
{% block title %}Contacts{% endblock %}
{% block content %}
<h2 class="text-xl font-bold text-cyber-accent mb-4">Contacts & Responsables <span class="text-sm text-gray-500">({{ contacts|length }})</span></h2>
{% if msg %}
<div class="mb-3 p-2 rounded text-sm {% if msg == 'exists' %}bg-red-900/30 text-cyber-red{% else %}bg-green-900/30 text-cyber-green{% endif %}">
{% if msg == 'added' %}Contact ajouté.{% elif msg == 'edited' %}Contact modifié.{% elif msg == 'toggled' %}Statut modifié.{% elif msg == 'deleted' %}Contact supprimé.{% elif msg == 'scope_added' %}Scope ajouté.{% elif msg == 'scope_deleted' %}Scope supprimé.{% elif msg == 'exists' %}Ce mail existe déjà.{% endif %}
</div>
{% endif %}
<!-- Filtres -->
<div class="flex gap-2 mb-4 items-center flex-wrap">
<a href="/contacts" class="btn-sm {% if not role_filter %}bg-cyber-accent text-black{% else %}bg-cyber-border text-gray-300{% endif %}">Tous</a>
{% for r in roles_in_db %}
<a href="/contacts?role={{ r }}" class="btn-sm {% if role_filter == r %}bg-cyber-accent text-black{% else %}bg-cyber-border text-gray-300{% endif %}">{{ r }}</a>
{% endfor %}
<form method="GET" action="/contacts" class="flex gap-1 ml-auto">
<input type="text" name="search" value="{{ search or '' }}" placeholder="Nom ou email..." class="text-xs py-1 px-2 w-36">
<input type="text" name="server" value="{{ server or '' }}" placeholder="Serveur..." class="text-xs py-1 px-2 w-36 font-mono">
<button type="submit" class="btn-sm bg-cyber-border text-cyber-accent">Chercher</button>
</form>
</div>
{% if server %}
<div class="mb-3 p-3 card">
{% if server_info %}
<div class="flex items-center gap-3 text-sm">
<span class="text-gray-500">Contacts pour</span>
<span class="font-mono text-cyber-accent font-bold">{{ server_info.hostname }}</span>
<span class="badge badge-blue">{{ server_info.domain_name or '-' }}</span>
<span class="badge {% if server_info.env_name == 'Production' %}badge-green{% else %}badge-yellow{% endif %}">{{ server_info.env_name or '-' }}</span>
{% if server_info.app_type %}<span class="badge badge-gray">{{ server_info.app_type }}</span>{% endif %}
{% if server_info.app_group %}<span class="text-xs text-gray-500">groupe: {{ server_info.app_group }}</span>{% endif %}
</div>
{% else %}
<span class="text-cyber-red text-sm">Serveur "{{ server }}" non trouvé en base</span>
{% endif %}
</div>
{% endif %}
<!-- Panel détail -->
<div id="contact-detail" class="card mb-4 p-5" style="display:none"></div>
<!-- Table -->
<div class="card overflow-x-auto">
<table class="w-full table-cyber text-sm">
<thead><tr>
<th class="text-left p-2">Nom</th>
<th class="text-left p-2">Email</th>
<th class="p-2">Rôle</th>
<th class="text-left p-2">Scopes</th>
<th class="p-2">Actif</th>
<th class="p-2">Actions</th>
</tr></thead>
<tbody>
{% for c in contacts %}
<tr class="{% if not c.is_active %}opacity-40{% endif %}">
<td class="p-2 font-bold text-cyber-accent">{{ c.name }}</td>
<td class="p-2 text-xs text-gray-400">{{ c.email }}</td>
<td class="p-2 text-center"><span class="badge {% if 'responsable' in c.role %}badge-blue{% elif 'referent' in c.role %}badge-yellow{% elif 'ra_' in c.role %}badge-green{% else %}badge-gray{% endif %}">{{ c.role }}</span></td>
<td class="p-2 text-xs text-gray-400" style="max-width:300px">{{ (c.scopes_summary or '-')[:80] }}</td>
<td class="p-2 text-center"><span class="badge {% if c.is_active %}badge-green{% else %}badge-red{% endif %}">{{ 'Oui' if c.is_active else 'Non' }}</span></td>
<td class="p-2 text-center">
<div class="flex gap-1 justify-center">
<button class="btn-sm bg-cyber-border text-cyber-accent"
hx-get="/contacts/{{ c.id }}" hx-target="#contact-detail" hx-swap="innerHTML"
onclick="document.getElementById('contact-detail').style.display='block'; window.scrollTo({top:0,behavior:'smooth'})">Éditer</button>
<form method="POST" action="/contacts/{{ c.id }}/delete" style="display:inline">
<button class="btn-sm bg-red-900/30 text-cyber-red" onclick="return confirm('Supprimer {{ c.name }} ?')">Suppr</button>
</form>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Ajouter -->
<div class="card p-4 mt-4">
<h4 class="text-sm font-bold text-cyber-accent mb-3">Ajouter un contact</h4>
<form method="POST" action="/contacts/add" class="flex gap-3 items-end flex-wrap">
<div>
<label class="text-xs text-gray-500">Nom complet</label>
<input type="text" name="name" required class="text-xs py-1 px-2 w-48">
</div>
<div>
<label class="text-xs text-gray-500">Email</label>
<input type="email" name="email" required class="text-xs py-1 px-2 w-52">
</div>
<div>
<label class="text-xs text-gray-500">Rôle</label>
<select name="contact_role" class="text-xs py-1 px-2">
{% for code, label in roles %}<option value="{{ code }}">{{ label }}</option>{% endfor %}
</select>
</div>
<button type="submit" class="btn-primary px-4 py-1 text-sm">Ajouter</button>
</form>
</div>
{% endblock %}

View File

@ -4,7 +4,7 @@
<h2 class="text-xl font-bold text-cyber-accent mb-4">Dashboard</h2> <h2 class="text-xl font-bold text-cyber-accent mb-4">Dashboard</h2>
<!-- KPIs --> <!-- KPIs -->
<div class="grid grid-cols-4 gap-4 mb-6"> <div class="grid grid-cols-5 gap-4 mb-6">
<div class="card p-4 text-center"> <div class="card p-4 text-center">
<div class="text-3xl font-bold text-cyber-accent">{{ stats.total_servers }}</div> <div class="text-3xl font-bold text-cyber-accent">{{ stats.total_servers }}</div>
<div class="text-xs text-gray-500">Serveurs</div> <div class="text-xs text-gray-500">Serveurs</div>
@ -21,6 +21,10 @@
<div class="text-3xl font-bold text-cyber-yellow">{{ stats.qualys_tags }}</div> <div class="text-3xl font-bold text-cyber-yellow">{{ stats.qualys_tags }}</div>
<div class="text-xs text-gray-500">Tags Qualys</div> <div class="text-xs text-gray-500">Tags Qualys</div>
</div> </div>
<div class="card p-4 text-center">
<div class="text-3xl font-bold text-cyber-red">{{ stats.eol }}</div>
<div class="text-xs text-gray-500">EOL</div>
</div>
</div> </div>
<!-- Par domaine --> <!-- Par domaine -->

View File

@ -0,0 +1,85 @@
<div>
<div class="flex justify-between items-center mb-4">
<h3 class="text-lg font-bold text-cyber-accent">{{ c.name }}</h3>
<button onclick="document.getElementById('contact-detail').style.display='none'" class="text-gray-500 hover:text-white text-xl">&times;</button>
</div>
<!-- Édition -->
<form method="POST" action="/contacts/{{ c.id }}/edit" class="flex gap-3 items-end mb-4">
<div>
<label class="text-xs text-gray-500">Nom</label>
<input type="text" name="name" value="{{ c.name }}" class="text-xs py-1 px-2 w-44">
</div>
<div>
<label class="text-xs text-gray-500">Email</label>
<input type="email" name="email" value="{{ c.email }}" class="text-xs py-1 px-2 w-52">
</div>
<div>
<label class="text-xs text-gray-500">Rôle</label>
<select name="contact_role" class="text-xs py-1 px-2">
{% for code, label in roles %}<option value="{{ code }}" {% if c.role == code %}selected{% endif %}>{{ label }}</option>{% endfor %}
</select>
</div>
<button type="submit" class="btn-sm bg-cyber-accent text-black">Modifier</button>
<form method="POST" action="/contacts/{{ c.id }}/toggle" style="display:inline">
<button type="submit" class="btn-sm {% if c.is_active %}bg-red-900/30 text-cyber-red{% else %}bg-green-900/30 text-cyber-green{% endif %}">
{{ 'Désactiver' if c.is_active else 'Activer' }}
</button>
</form>
</form>
<!-- Scopes existants -->
<h4 class="text-xs text-cyber-accent font-bold uppercase mb-2 border-b border-cyber-border pb-1">Périmètre de responsabilité</h4>
<div class="space-y-1 mb-3">
{% for s in scopes %}
<div class="flex items-center gap-2 text-xs">
<span class="badge {% if s.scope_type == 'domain' %}badge-blue{% elif s.scope_type == 'application' %}badge-yellow{% elif s.scope_type == 'server' %}badge-red{% else %}badge-gray{% endif %}">{{ s.scope_type }}</span>
<span class="font-mono text-cyber-accent">{{ s.scope_value }}</span>
{% if s.env_scope != 'all' %}<span class="badge badge-green">{{ s.env_scope }}</span>{% endif %}
<form method="POST" action="/contacts/scope/{{ s.id }}/delete" style="display:inline">
<button class="text-gray-600 hover:text-cyber-red text-xs" onclick="return confirm('Supprimer ?')">×</button>
</form>
</div>
{% endfor %}
{% if not scopes %}<p class="text-xs text-gray-500">Aucun scope défini</p>{% endif %}
</div>
<!-- Ajouter scope -->
<form method="POST" action="/contacts/{{ c.id }}/scope/add" class="flex gap-2 items-end mb-4">
<div>
<label class="text-xs text-gray-500">Type</label>
<select name="scope_type" class="text-xs py-1 px-2">
{% for code, label in scope_types %}<option value="{{ code }}">{{ label }}</option>{% endfor %}
</select>
</div>
<div>
<label class="text-xs text-gray-500">Valeur</label>
<input type="text" name="scope_value" class="text-xs py-1 px-2 w-36" required list="scope-values" placeholder="EMV, COMMVAULT...">
<datalist id="scope-values">
{% for d in domains %}<option value="{{ d.code }}">{{ d.name }}</option>{% endfor %}
{% for a in app_types %}<option value="{{ a }}">{{ a }}</option>{% endfor %}
</datalist>
</div>
<div>
<label class="text-xs text-gray-500">Env</label>
<select name="env_scope" class="text-xs py-1 px-2">
{% for es in env_scopes %}<option value="{{ es }}">{{ es }}</option>{% endfor %}
</select>
</div>
<button type="submit" class="btn-sm bg-cyber-accent text-black">Ajouter</button>
</form>
<!-- Serveurs liés -->
{% if servers %}
<h4 class="text-xs text-cyber-accent font-bold uppercase mb-2 border-b border-cyber-border pb-1">Serveurs concernés ({{ servers|length }})</h4>
<div class="grid grid-cols-3 gap-1 text-xs">
{% for s in servers %}
<div class="flex items-center gap-1">
<span class="font-mono text-cyber-accent">{{ s.hostname }}</span>
<span class="text-gray-500">{{ s.domaine or '' }}</span>
<span class="badge {% if s.environnement == 'Production' %}badge-green{% else %}badge-yellow{% endif %} text-[9px]">{{ (s.environnement or '')[:4] }}</span>
</div>
{% endfor %}
</div>
{% endif %}
</div>

View File

@ -0,0 +1,106 @@
<div>
<div class="flex justify-between items-center mb-4">
<h3 class="text-lg font-bold text-cyber-accent">{{ a.name or a.hostname }}</h3>
<button onclick="document.getElementById('qualys-detail').style.display='none'" class="text-gray-500 hover:text-white text-xl">&times;</button>
</div>
<div class="grid grid-cols-2 gap-4">
<!-- Infos -->
<div>
<h4 class="text-xs text-cyber-accent font-bold uppercase mb-2 border-b border-cyber-border pb-1">Informations</h4>
<div class="space-y-1 text-xs">
<div><span class="text-gray-500">Hostname:</span> <span class="font-mono">{{ a.hostname }}</span></div>
<div><span class="text-gray-500">IP:</span> <span class="font-mono text-cyber-green">{{ a.ip_address or '-' }}</span></div>
<div><span class="text-gray-500">FQDN:</span> <span class="font-mono">{{ a.fqdn or '-' }}</span></div>
<div><span class="text-gray-500">OS:</span> {{ a.os or '-' }}</div>
<div><span class="text-gray-500">Agent:</span> <span class="badge {% if a.agent_status and 'ACTIVE' in a.agent_status %}badge-green{% else %}badge-red{% endif %}">{{ a.agent_status or '-' }}</span></div>
<div><span class="text-gray-500">Version:</span> {{ a.agent_version or '-' }}</div>
<div><span class="text-gray-500">Dernier check-in:</span> {{ a.last_checkin or '-' }}</div>
<div><span class="text-gray-500">Qualys ID:</span> {{ a.qualys_asset_id }}</div>
</div>
</div>
<!-- Décodage -->
<div>
<h4 class="text-xs text-cyber-accent font-bold uppercase mb-2 border-b border-cyber-border pb-1">Décodage nomenclature</h4>
<div class="space-y-1 text-xs">
<div><span class="text-gray-500">Type:</span> {{ decoded.type }}</div>
<div><span class="text-gray-500">Environnement:</span> {{ decoded.env }}</div>
<div><span class="text-gray-500">Domaine:</span> {{ decoded.domain }}</div>
</div>
<h4 class="text-xs text-cyber-accent font-bold uppercase mt-3 mb-1">Tags suggérés</h4>
<div class="flex flex-wrap gap-1">
{% for t in decoded.tags %}
{% set found = false %}
{% for ct in tags %}{% if ct.name == t %}{% set found = true %}{% endif %}{% endfor %}
<span class="badge {% if found %}badge-green{% else %}badge-red{% endif %}" title="{% if found %}Assigné{% else %}Manquant{% endif %}">{{ t }}</span>
{% endfor %}
</div>
</div>
</div>
<!-- Tags assignés -->
<div class="mt-4">
<h4 class="text-xs text-cyber-accent font-bold uppercase mb-2 border-b border-cyber-border pb-1">Tags assignés ({{ tags|length }})</h4>
<div class="space-y-1">
{% for t in tags %}
<div class="flex items-center gap-2 text-xs">
<span class="badge {% if t.is_dynamic %}badge-blue{% else %}badge-yellow{% endif %}">{{ 'DYN' if t.is_dynamic else 'STAT' }}</span>
<span class="font-mono text-cyber-accent">{{ t.name }}</span>
{% if not t.is_dynamic %}
<form method="POST" action="/qualys/asset/{{ a.qualys_asset_id }}/tag/remove" style="display:inline"
hx-post="/qualys/asset/{{ a.qualys_asset_id }}/tag/remove" hx-target="#tag-result" hx-swap="innerHTML">
<input type="hidden" name="tag_id" value="{{ t.qualys_tag_id }}">
<button class="text-gray-600 hover:text-cyber-red" title="Retirer ce tag"></button>
</form>
{% endif %}
</div>
{% endfor %}
{% if not tags %}<span class="text-xs text-gray-500">Aucun tag</span>{% endif %}
</div>
<div id="tag-result" class="mt-1"></div>
</div>
<!-- Ajouter un tag -->
<div class="mt-4">
<h4 class="text-xs text-cyber-accent font-bold uppercase mb-2 border-b border-cyber-border pb-1">Ajouter un tag</h4>
<form hx-post="/qualys/asset/{{ a.qualys_asset_id }}/tag/add" hx-target="#tag-result" hx-swap="innerHTML" class="flex gap-2 items-center">
<input type="hidden" name="tag_id" id="add-tag-id-{{ a.qualys_asset_id }}">
<input type="text" id="add-tag-input-{{ a.qualys_asset_id }}" class="text-xs py-1 px-2 flex-1 font-mono" placeholder="Filtrer et sélectionner un tag..." autocomplete="off"
onkeyup="filterTags(this, '{{ a.qualys_asset_id }}')" onfocus="document.getElementById('tag-dropdown-{{ a.qualys_asset_id }}').style.display='block'">
<button type="submit" class="btn-sm bg-cyber-accent text-black">Ajouter</button>
</form>
<div id="tag-dropdown-{{ a.qualys_asset_id }}" class="bg-cyber-card border border-cyber-border rounded mt-1 overflow-y-auto text-xs" style="display:none; max-height:150px">
{% set assigned_ids = tags | map(attribute='qualys_tag_id') | list %}
{% for t in all_tags %}
{% if t.qualys_tag_id not in assigned_ids %}
<div class="px-2 py-1 hover:bg-cyber-border/30 cursor-pointer tag-option" data-id="{{ t.qualys_tag_id }}" data-name="{{ t.name }}"
onclick="selectTag('{{ a.qualys_asset_id }}', '{{ t.qualys_tag_id }}', '{{ t.name }}')">
<span class="font-mono">{{ t.name }}</span>
<span class="badge {% if t.is_dynamic %}badge-blue{% else %}badge-yellow{% endif %} text-[8px] ml-1">{{ 'DYN' if t.is_dynamic else 'STAT' }}</span>
</div>
{% endif %}
{% endfor %}
</div>
</div>
<script>
function filterTags(input, aid) {
var q = input.value.toLowerCase();
var dd = document.getElementById('tag-dropdown-' + aid);
dd.style.display = 'block';
dd.querySelectorAll('.tag-option').forEach(function(el) {
el.style.display = el.dataset.name.toLowerCase().includes(q) ? '' : 'none';
});
}
function selectTag(aid, tid, tname) {
document.getElementById('add-tag-id-' + aid).value = tid;
document.getElementById('add-tag-input-' + aid).value = tname;
document.getElementById('tag-dropdown-' + aid).style.display = 'none';
}
document.addEventListener('click', function(e) {
document.querySelectorAll('[id^=tag-dropdown-]').forEach(function(dd) {
if (!dd.contains(e.target) && !e.target.id.startsWith('add-tag-input-')) dd.style.display = 'none';
});
});
</script>
</div>

View File

@ -0,0 +1,79 @@
{% extends 'base.html' %}
{% block title %}Décodeur Qualys{% endblock %}
{% block content %}
<h2 class="text-xl font-bold text-cyber-accent mb-4">Décodeur nomenclature SANEF</h2>
<!-- Saisie -->
<form method="GET" class="card p-4 mb-4 flex gap-3 items-end">
<div class="flex-1">
<label class="text-xs text-gray-500">Hostname à décoder</label>
<input type="text" name="hostname" value="{{ hostname or '' }}" placeholder="vpdsiasat1, vrtrabkme1, ls-amiens..." class="w-full font-mono">
</div>
<button type="submit" class="btn-primary px-4 py-2 text-sm">Décoder</button>
</form>
{% if result %}
<!-- Résultat décodage -->
<div class="card p-5 mb-4">
<div class="flex items-center gap-4 mb-4">
<h3 class="text-xl font-bold font-mono text-cyber-accent">{{ result.hostname }}</h3>
<span class="badge {% if result.type == 'VM' %}badge-blue{% else %}badge-gray{% endif %}">{{ result.type }}</span>
<span class="badge {% if result.env == 'Production' %}badge-green{% elif result.env == 'Recette' %}badge-yellow{% else %}badge-gray{% endif %}">{{ result.env }}</span>
<span class="badge badge-blue">{{ result.domain }}</span>
</div>
<div class="grid grid-cols-2 gap-6">
<!-- Tags suggérés -->
<div>
<h4 class="text-xs text-cyber-accent font-bold uppercase mb-2 border-b border-cyber-border pb-1">Tags suggérés par la nomenclature</h4>
<div class="space-y-1">
{% for tag in result.tags %}
<div class="flex items-center gap-2 text-sm">
<span class="font-mono text-cyber-yellow">{{ tag }}</span>
{% set is_auto = false %}
{% for prefix in auto_prefixes %}
{% if tag.startswith(prefix) %}{% set is_auto = true %}{% endif %}
{% endfor %}
<span class="badge {% if is_auto %}badge-blue{% else %}badge-yellow{% endif %}">{{ 'DYN' if is_auto else 'STAT' }}</span>
{% set found = false %}
{% for ct in current_tags %}
{% if ct[0] == tag %}{% set found = true %}{% endif %}
{% endfor %}
{% if found %}
<span class="text-cyber-green text-xs">✓ Assigné</span>
{% else %}
<span class="text-cyber-red text-xs">✗ Manquant</span>
{% endif %}
</div>
{% endfor %}
</div>
</div>
<!-- Tags actuels Qualys -->
<div>
<h4 class="text-xs text-cyber-accent font-bold uppercase mb-2 border-b border-cyber-border pb-1">Tags actuels dans Qualys ({{ current_tags|length }})</h4>
{% if current_tags %}
<div class="flex flex-wrap gap-1">
{% for name, is_dyn in current_tags %}
<span class="badge {% if is_dyn %}badge-blue{% else %}badge-yellow{% endif %}">{{ name }}</span>
{% endfor %}
</div>
{% else %}
<p class="text-xs text-gray-500">{% if hostname %}Asset non trouvé dans Qualys ou aucun tag{% else %}Saisissez un hostname{% endif %}</p>
{% endif %}
</div>
</div>
</div>
{% endif %}
<!-- Légende -->
<div class="card p-4">
<h4 class="text-xs text-cyber-accent font-bold uppercase mb-2">Légende</h4>
<div class="grid grid-cols-2 gap-2 text-xs">
<div class="flex items-center gap-2"><span class="badge badge-blue">DYN</span> Tag dynamique (géré automatiquement par Qualys via règle)</div>
<div class="flex items-center gap-2"><span class="badge badge-yellow">STAT</span> Tag statique (assignation manuelle requise)</div>
<div class="flex items-center gap-2"><span class="text-cyber-green">✓ Assigné</span> Tag présent sur l'asset</div>
<div class="flex items-center gap-2"><span class="text-cyber-red">✗ Manquant</span> Tag suggéré mais non assigné</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,182 @@
{% extends 'base.html' %}
{% block title %}Recherche Qualys{% endblock %}
{% block content %}
<h2 class="text-xl font-bold text-cyber-accent mb-4">Recherche Assets Qualys</h2>
<!-- Recherche -->
<form method="GET" class="card p-3 mb-4 flex gap-3 items-end flex-wrap">
<div>
<label class="text-xs text-gray-500">Champ</label>
<select name="field" class="text-xs py-1 px-2" id="search-field" onchange="
var inp = document.getElementById('search-input');
var sel = document.getElementById('tag-select');
if (this.value === 'tag') { inp.style.display='none'; sel.style.display='block'; sel.name='search'; inp.name=''; }
else { inp.style.display='block'; sel.style.display='none'; inp.name='search'; sel.name=''; }
">
<option value="hostname" {% if field == 'hostname' %}selected{% endif %}>Hostname</option>
<option value="ip" {% if field == 'ip' %}selected{% endif %}>IP</option>
<option value="tag" {% if field == 'tag' %}selected{% endif %}>Tag</option>
</select>
</div>
<div class="flex-1">
<label class="text-xs text-gray-500">Recherche</label>
<input type="text" id="search-input" name="search" value="{% if field != 'tag' %}{{ search or '' }}{% endif %}" placeholder="Hostname ou IP..." class="w-full" {% if field == 'tag' %}style="display:none" name=""{% endif %}>
<select id="tag-select" {% if field == 'tag' %}name="search"{% else %}name="" style="display:none"{% endif %} class="w-full" size="1" style="{% if field != 'tag' %}display:none;{% endif %} max-height:200px; overflow-y:auto;">
<option value="">— Choisir un tag —</option>
{% for t in all_tags %}<option value="{{ t.name }}" {% if field == 'tag' and search == t.name %}selected{% endif %}>{{ t.name }}</option>{% endfor %}
</select>
</div>
<button type="submit" class="btn-primary px-4 py-1 text-sm">Rechercher</button>
{% if search %}<a href="/qualys/search/export?search={{ search }}&field={{ field }}" class="btn-sm bg-cyber-green text-black">Export CSV</a>{% endif %}
</form>
{% set msg = request.query_params.get('msg') %}
{% if msg %}
<div class="mb-3 p-2 rounded text-sm bg-green-900/30 text-cyber-green">
{% if msg.startswith('resync_') %}{{ msg.split('_')[1] }} asset(s) resynchronisé(s).{% elif msg.startswith('bulk_add_') %}Tags ajoutés: {{ msg.split('_')[2] }} OK, {{ msg.split('_')[3] }} KO.{% elif msg.startswith('bulk_rm_') %}Tags retirés: {{ msg.split('_')[2] }} OK, {{ msg.split('_')[3] }} KO.{% endif %}
</div>
{% endif %}
{% if search %}
<p class="text-xs text-gray-500 mb-2">
{% if api_msg %}{{ api_msg }}{% else %}{{ assets|length }} résultat(s){% endif %}
</p>
{% endif %}
<!-- Panel détail -->
<div id="qualys-detail" class="card mb-4 p-5" style="display:none"></div>
<!-- Résultats -->
{% if assets %}
<!-- Bulk actions -->
{% if can_edit_qualys %}
<div id="bulk-tag-bar" class="card p-3 mb-2 flex gap-3 items-center" style="display:none">
<span class="text-xs text-gray-400" id="bulk-tag-count">0 sélectionné(s)</span>
<form method="POST" action="/qualys/bulk/add-tag" class="flex gap-2 items-center relative">
<input type="hidden" name="asset_ids" id="bulk-tag-ids">
<input type="hidden" name="return_search" value="{{ search or '' }}">
<input type="hidden" name="return_field" value="{{ field or 'hostname' }}">
<input type="hidden" name="tag_id" id="bulk-add-tag-id">
<input type="text" id="bulk-add-input" class="text-xs py-1 px-2 w-40 font-mono" placeholder="Tag à ajouter..." autocomplete="off"
onkeyup="filterBulkTags(this, 'bulk-add-dd')" onfocus="document.getElementById('bulk-add-dd').style.display='block'">
<div id="bulk-add-dd" class="absolute top-8 left-0 bg-cyber-card border border-cyber-border rounded overflow-y-auto text-xs z-50" style="display:none; max-height:150px; width:250px">
{% for t in all_tags %}<div class="px-2 py-1 hover:bg-cyber-border/30 cursor-pointer bulk-tag-opt" data-name="{{ t.name }}"
onclick="document.getElementById('bulk-add-tag-id').value='{{ t.qualys_tag_id }}'; document.getElementById('bulk-add-input').value='{{ t.name }}'; document.getElementById('bulk-add-dd').style.display='none'">
<span class="font-mono">{{ t.name }}</span></div>{% endfor %}
</div>
<button type="submit" class="btn-sm bg-cyber-accent text-black">+ Ajouter</button>
</form>
<form method="POST" action="/qualys/bulk/remove-tag" class="flex gap-2 items-center relative">
<input type="hidden" name="asset_ids" id="bulk-tag-ids2">
<input type="hidden" name="return_search" value="{{ search or '' }}">
<input type="hidden" name="return_field" value="{{ field or 'hostname' }}">
<input type="hidden" name="tag_id" id="bulk-rm-tag-id">
<input type="text" id="bulk-rm-input" class="text-xs py-1 px-2 w-40 font-mono" placeholder="Tag à retirer..." autocomplete="off"
onkeyup="filterBulkTags(this, 'bulk-rm-dd')" onfocus="loadRemoveTags(); document.getElementById('bulk-rm-dd').style.display='block'">
<div id="bulk-rm-dd" class="absolute top-8 left-0 bg-cyber-card border border-cyber-border rounded overflow-y-auto text-xs z-50" style="display:none; max-height:150px; width:250px">
<div class="px-2 py-1 text-gray-500">Chargement...</div>
</div>
<button type="submit" class="btn-sm bg-red-900/30 text-cyber-red">- Retirer</button>
</form>
<form method="POST" action="/qualys/resync-assets" class="flex gap-2 items-center">
<input type="hidden" name="asset_ids" id="bulk-tag-ids3">
<input type="hidden" name="return_search" value="{{ search or '' }}">
<input type="hidden" name="return_field" value="{{ field or 'hostname' }}">
<button type="submit" class="btn-sm bg-cyber-border text-cyber-accent">Resync Qualys</button>
</form>
</div>
<script>
function filterBulkTags(input, ddId) {
var q = input.value.toLowerCase();
var dd = document.getElementById(ddId);
dd.style.display = 'block';
dd.querySelectorAll('.bulk-tag-opt').forEach(function(el) {
el.style.display = el.dataset.name.toLowerCase().includes(q) ? '' : 'none';
});
}
document.addEventListener('click', function(e) {
['bulk-add-dd', 'bulk-rm-dd'].forEach(function(id) {
var dd = document.getElementById(id);
if (dd && !dd.contains(e.target) && e.target.id !== 'bulk-add-input' && e.target.id !== 'bulk-rm-input') dd.style.display = 'none';
});
});
function loadRemoveTags() {
var ids = document.getElementById('bulk-tag-ids2').value;
if (!ids) return;
fetch('/qualys/bulk/tags-for-assets?asset_ids=' + ids)
.then(r => r.json())
.then(tags => {
var dd = document.getElementById('bulk-rm-dd');
dd.innerHTML = '';
if (tags.length === 0) { dd.innerHTML = '<div class="px-2 py-1 text-gray-500">Aucun tag STAT</div>'; return; }
tags.forEach(function(t) {
var div = document.createElement('div');
div.className = 'px-2 py-1 hover:bg-cyber-border/30 cursor-pointer bulk-tag-opt';
div.dataset.name = t.name;
div.innerHTML = '<span class="font-mono">' + t.name + '</span> <span class="text-gray-500 text-[9px]">(' + t.count + ')</span>';
div.onclick = function() {
document.getElementById('bulk-rm-tag-id').value = t.id;
document.getElementById('bulk-rm-input').value = t.name;
dd.style.display = 'none';
};
dd.appendChild(div);
});
});
}
function updateBulkTag() {
var checks = document.querySelectorAll('input[name=asset_chk]:checked');
var bar = document.getElementById('bulk-tag-bar');
if (checks.length > 0) {
bar.style.display = 'flex';
document.getElementById('bulk-tag-count').textContent = checks.length + ' sélectionné(s)';
var ids = Array.from(checks).map(c => c.value).join(',');
document.getElementById('bulk-tag-ids').value = ids;
document.getElementById('bulk-tag-ids2').value = ids;
document.getElementById('bulk-tag-ids3').value = ids;
} else { bar.style.display = 'none'; }
}
</script>
{% endif %}
<div class="card overflow-x-auto">
<table class="w-full table-cyber text-xs">
<thead><tr>
{% if can_edit_qualys %}<th class="p-2 w-6"><input type="checkbox" onchange="document.querySelectorAll('input[name=asset_chk]').forEach(c=>c.checked=this.checked); updateBulkTag()"></th>{% endif %}
<th class="text-left p-2">Hostname</th>
<th class="p-2">IP</th>
<th class="p-2">OS</th>
<th class="p-2">Agent</th>
<th class="text-left p-2">Tags</th>
<th class="p-2">Actions</th>
</tr></thead>
<tbody>
{% for a in assets %}
{% set hn = a.hostname %}
{% set ip = a.ip_address %}
{% set os = a.os %}
{% set agent = a.agent_status %}
{% set tl = a.tags_list %}
{% set qid = a.qualys_asset_id %}
<tr>
{% if can_edit_qualys %}<td class="p-2 text-center" onclick="event.stopPropagation()">{% if qid %}<input type="checkbox" name="asset_chk" value="{{ qid }}" onchange="updateBulkTag()">{% endif %}</td>{% endif %}
<td class="p-2 font-mono text-cyber-accent">{{ hn or '-' }}</td>
<td class="p-2 text-center text-gray-400">{{ ip or '-' }}</td>
<td class="p-2 text-center text-gray-400" title="{{ os or '' }}">{{ (os or '-')[:30] }}</td>
<td class="p-2 text-center">
{% if agent %}<span class="badge {% if 'ACTIVE' in agent %}badge-green{% else %}badge-gray{% endif %}">{{ agent[:10] }}</span>
{% else %}<span class="text-gray-600 text-xs">N/A</span>{% endif %}
</td>
<td class="p-2 text-gray-400" style="max-width:300px">{{ (tl or '-')[:80] }}</td>
<td class="p-2 text-center">
{% if qid %}
<button class="btn-sm bg-cyber-border text-cyber-accent"
hx-get="/qualys/asset/{{ qid }}" hx-target="#qualys-detail" hx-swap="innerHTML"
onclick="document.getElementById('qualys-detail').style.display='block'; window.scrollTo({top:0,behavior:'smooth'})">Détail</button>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
{% endblock %}

View File

@ -0,0 +1,91 @@
{% extends 'base.html' %}
{% block title %}Tags Qualys{% endblock %}
{% block content %}
<div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-bold text-cyber-accent">Tags Qualys <span class="text-sm text-gray-500">({{ stats.total }} — {{ stats.dyn }} DYN / {{ stats.stat }} STAT)</span></h2>
<div class="flex gap-2">
{% if can_edit_qualys %}
<form method="POST" action="/qualys/tags/resync" style="display:inline">
<button class="btn-sm bg-cyber-border text-cyber-accent">Resync API</button>
</form>
{% endif %}
<a href="/qualys/tags/export" class="btn-sm bg-cyber-green text-black">Export CSV</a>
</div>
</div>
{% if msg %}
<div class="mb-3 p-2 rounded text-sm {% if 'error' in msg or 'ko' in msg %}bg-red-900/30 text-cyber-red{% else %}bg-green-900/30 text-cyber-green{% endif %}">
{% if msg == 'resync_ok' %}Tags resynchronisés depuis l'API Qualys.{% elif msg == 'resync_ko' %}Erreur de synchronisation.{% elif msg == 'created' %}Tag créé.{% elif msg == 'create_error' %}Erreur création.{% elif msg == 'deleted' %}Tag supprimé.{% elif msg == 'delete_error' %}Erreur suppression.{% endif %}
</div>
{% endif %}
{% if can_edit_qualys %}
<div class="card p-3 mb-4">
<form method="POST" action="/qualys/tags/create" class="flex gap-3 items-end">
<div class="flex-1">
<label class="text-xs text-gray-500">Créer un tag statique</label>
<input type="text" name="tag_name" placeholder="ex: MID-TOMCAT, BDD-MONGO" class="w-full font-mono" required>
</div>
<button type="submit" class="btn-primary px-4 py-1 text-sm">Créer</button>
</form>
</div>
{% endif %}
<!-- Filtres -->
<div class="flex gap-2 mb-4 items-center">
<a href="/qualys/tags" class="btn-sm {% if not tag_type %}bg-cyber-accent text-black{% else %}bg-cyber-border text-gray-300{% endif %}">Tous ({{ stats.total }})</a>
<a href="/qualys/tags?tag_type=dyn" class="btn-sm {% if tag_type == 'dyn' %}bg-cyber-accent text-black{% else %}bg-cyber-border text-gray-300{% endif %}">DYN ({{ stats.dyn }})</a>
<a href="/qualys/tags?tag_type=stat" class="btn-sm {% if tag_type == 'stat' %}bg-cyber-accent text-black{% else %}bg-cyber-border text-gray-300{% endif %}">STAT ({{ stats.stat }})</a>
<form method="GET" class="flex gap-1 ml-auto">
{% if tag_type %}<input type="hidden" name="tag_type" value="{{ tag_type }}">{% endif %}
<input type="text" name="search" value="{{ search or '' }}" placeholder="Filtrer..." class="text-xs py-1 px-2 w-44">
<button type="submit" class="btn-sm bg-cyber-border text-cyber-accent">Chercher</button>
</form>
</div>
<!-- Table -->
<div class="card overflow-x-auto">
<table class="w-full table-cyber text-sm">
<thead><tr>
<th class="text-left p-2">Nom actuel</th>
<th class="p-2">Type</th>
<th class="text-left p-2">Nom V3</th>
<th class="p-2">Type V3</th>
<th class="p-2">ID Qualys</th>
<th class="p-2">Assets</th>
{% if can_edit_qualys %}<th class="p-2">Action</th>{% endif %}
</tr></thead>
<tbody>
{% for t in tags %}
<tr class="{% if t.v3_name and t.v3_name != t.name %}bg-yellow-900/5{% endif %}">
<td class="p-2 font-mono text-cyber-accent">{{ t.name }}</td>
<td class="p-2 text-center"><span class="badge {% if t.is_dynamic %}badge-blue{% else %}badge-yellow{% endif %}">{{ 'DYN' if t.is_dynamic else 'STAT' }}</span></td>
<td class="p-2 font-mono text-xs {% if t.v3_name and t.v3_name != t.name %}text-cyber-green{% else %}text-gray-500{% endif %}">{{ t.v3_name or '-' }}</td>
<td class="p-2 text-center">{% if t.v3_type %}<span class="badge {% if t.v3_type == 'DYN' %}badge-blue{% else %}badge-yellow{% endif %} text-[9px]">{{ t.v3_type }}</span>{% else %}-{% endif %}</td>
<td class="p-2 text-center text-xs text-gray-500">{{ t.qualys_tag_id }}</td>
<td class="p-2 text-center">
{% if t.asset_count > 0 %}
<a href="/qualys/search?field=tag&search={{ t.name }}" class="text-cyber-accent hover:underline">{{ t.asset_count }}</a>
{% else %}<span class="text-gray-600">0</span>{% endif %}
</td>
{% if can_edit_qualys %}
<td class="p-2 text-center">
{% if not t.is_dynamic %}
<form method="POST" action="/qualys/tags/{{ t.qualys_tag_id }}/delete" style="display:inline">
<button class="btn-sm bg-red-900/30 text-cyber-red" onclick="return confirm('Supprimer {{ t.name }} ?')">Suppr</button>
</form>
{% else %}<span class="text-xs text-gray-600">Auto</span>{% endif %}
</td>
{% endif %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="card p-3 mt-4 text-xs text-gray-500">
<strong>Légende V3 :</strong> Les tags surlignés ont un nom V3 différent du nom actuel (migration à prévoir).
<span class="text-cyber-green font-mono ml-2">Vert</span> = nouveau nom V3 proposé.
Cliquez sur le nombre d'assets pour voir les serveurs associés.
</div>
{% endblock %}

View File

@ -36,12 +36,60 @@
{% for t in ['tier0','tier1','tier2','tier3'] %}<option value="{{ t }}" {% if filters.tier == t %}selected{% endif %}>{{ t }}</option>{% endfor %} {% for t in ['tier0','tier1','tier2','tier3'] %}<option value="{{ t }}" {% if filters.tier == t %}selected{% endif %}>{{ t }}</option>{% endfor %}
</select> </select>
<select name="etat" onchange="this.form.submit()"><option value="">Etat</option> <select name="etat" onchange="this.form.submit()"><option value="">Etat</option>
{% for e in ['en_production','en_implementation','decommissionne'] %}<option value="{{ e }}" {% if filters.etat == e %}selected{% endif %}>{{ e }}</option>{% endfor %} {% for e in ['en_production','en_implementation','en_decommissionnement','decommissionne','eol'] %}<option value="{{ e }}" {% if filters.etat == e %}selected{% endif %}>{{ e }}</option>{% endfor %}
</select> </select>
<button type="submit" class="btn-primary px-3 py-1 text-sm">Filtrer</button> <button type="submit" class="btn-primary px-3 py-1 text-sm">Filtrer</button>
<a href="/servers" class="text-xs text-gray-500 hover:text-gray-300">Reset</a> <a href="/servers" class="text-xs text-gray-500 hover:text-gray-300">Reset</a>
</form> </form>
<!-- Actions groupées -->
<div id="bulk-bar" class="card p-3 mb-2 flex gap-3 items-center flex-wrap" style="display:none">
<span class="text-xs text-gray-400" id="bulk-count">0 sélectionné(s)</span>
<form method="POST" action="/servers/bulk" id="bulk-form" class="flex gap-2 items-center flex-wrap">
<input type="hidden" name="server_ids" id="bulk-ids">
<select name="bulk_field" class="text-xs py-1 px-2" id="bulk-field">
<option value="">— Action —</option>
<option value="domain_code">Domaine</option>
<option value="env_code">Environnement</option>
<option value="tier">Tier</option>
<option value="etat">État</option>
<option value="patch_os_owner">Owner</option>
<option value="licence_support">Licence</option>
</select>
<select name="bulk_value" class="text-xs py-1 px-2" id="bulk-value">
<option value="">— Valeur —</option>
</select>
<button type="submit" class="btn-primary px-3 py-1 text-xs">Appliquer</button>
</form>
</div>
<script>
const bulkValues = {
domain_code: [{% for d in domains_list %}{v:"{{ d.code }}", l:"{{ d.name }}"},{% endfor %}],
env_code: [{% for e in envs_list %}{v:"{{ e.code }}", l:"{{ e.name }}"},{% endfor %}],
tier: [{v:"tier0",l:"tier0"},{v:"tier1",l:"tier1"},{v:"tier2",l:"tier2"},{v:"tier3",l:"tier3"}],
etat: [{v:"en_production",l:"en_production"},{v:"en_implementation",l:"en_implementation"},{v:"en_decommissionnement",l:"en_decommissionnement"},{v:"decommissionne",l:"décommissionné"}],
patch_os_owner: [{v:"secops",l:"secops"},{v:"ipop",l:"ipop"},{v:"na",l:"na"}],
licence_support: [{v:"active",l:"active"},{v:"eol",l:"eol"},{v:"els",l:"els"}],
};
document.getElementById('bulk-field').addEventListener('change', function() {
const sel = document.getElementById('bulk-value');
sel.innerHTML = '<option value="">— Valeur —</option>';
(bulkValues[this.value] || []).forEach(o => { sel.innerHTML += '<option value="'+o.v+'">'+o.l+'</option>'; });
});
function updateBulk() {
const checks = document.querySelectorAll('input[name=srv]:checked');
const bar = document.getElementById('bulk-bar');
const count = document.getElementById('bulk-count');
const ids = document.getElementById('bulk-ids');
if (checks.length > 0) {
bar.style.display = 'flex';
count.textContent = checks.length + ' sélectionné(s)';
ids.value = Array.from(checks).map(c => c.value).join(',');
} else { bar.style.display = 'none'; }
}
</script>
<!-- Table --> <!-- Table -->
<div id="server-table" class="card overflow-x-auto"> <div id="server-table" class="card overflow-x-auto">
<table class="w-full table-cyber"> <table class="w-full table-cyber">
@ -62,7 +110,7 @@
<tbody> <tbody>
{% for s in servers %} {% for s in servers %}
<tr id="row-{{ s.id }}" class="group" hx-get="/servers/{{ s.id }}/detail" hx-target="#detail-panel" hx-swap="innerHTML" onclick="openPanel()"> <tr id="row-{{ s.id }}" class="group" hx-get="/servers/{{ s.id }}/detail" hx-target="#detail-panel" hx-swap="innerHTML" onclick="openPanel()">
<td class="p-2" onclick="event.stopPropagation()"><input type="checkbox" name="srv" value="{{ s.id }}"></td> <td class="p-2" onclick="event.stopPropagation()"><input type="checkbox" name="srv" value="{{ s.id }}" onchange="updateBulk()"></td>
<td class="p-2 font-mono text-sm text-cyber-accent">{{ s.hostname }}</td> <td class="p-2 font-mono text-sm text-cyber-accent">{{ s.hostname }}</td>
<td class="p-2 text-center text-xs">{{ s.domaine or '-' }}</td> <td class="p-2 text-center text-xs">{{ s.domaine or '-' }}</td>
<td class="p-2 text-center"><span class="badge {% if s.environnement == 'Production' %}badge-green{% elif s.environnement == 'Recette' %}badge-yellow{% else %}badge-gray{% endif %}">{{ (s.environnement or '-')[:6] }}</span></td> <td class="p-2 text-center"><span class="badge {% if s.environnement == 'Production' %}badge-green{% elif s.environnement == 'Recette' %}badge-yellow{% else %}badge-gray{% endif %}">{{ (s.environnement or '-')[:6] }}</span></td>
@ -104,6 +152,7 @@ function closePanel() {
} }
document.getElementById('check-all').addEventListener('change', function(e) { document.getElementById('check-all').addEventListener('change', function(e) {
document.querySelectorAll('input[name=srv]').forEach(cb => cb.checked = e.target.checked); document.querySelectorAll('input[name=srv]').forEach(cb => cb.checked = e.target.checked);
updateBulk();
}); });
</script> </script>
{% endblock %} {% endblock %}

View File

@ -5,7 +5,7 @@
{% if saved %} {% if saved %}
<div class="mb-4 p-3 rounded bg-green-900/30 text-cyber-green text-sm"> <div class="mb-4 p-3 rounded bg-green-900/30 text-cyber-green text-sm">
Section "{{ saved }}" sauvegardée. Section "{{ saved }}" sauvegardee.
</div> </div>
{% endif %} {% endif %}
@ -42,10 +42,16 @@
<input type="password" name="qualys_pass" value="{{ vals.qualys_pass }}" class="w-full" {% if not editable.qualys %}disabled{% endif %}> <input type="password" name="qualys_pass" value="{{ vals.qualys_pass }}" class="w-full" {% if not editable.qualys %}disabled{% endif %}>
</div> </div>
</div> </div>
<div> <div class="flex gap-3 items-end">
<div class="flex-1">
<label class="text-xs text-gray-500">Proxy</label> <label class="text-xs text-gray-500">Proxy</label>
<input type="text" name="qualys_proxy" value="{{ vals.qualys_proxy }}" placeholder="http://proxy.sanef.fr:8080" class="w-full font-mono text-xs" {% if not editable.qualys %}disabled{% endif %}> <input type="text" name="qualys_proxy" value="{{ vals.qualys_proxy }}" placeholder="http://proxy.sanef.fr:8080" class="w-full font-mono text-xs" {% if not editable.qualys %}disabled{% endif %}>
</div> </div>
<label class="flex items-center gap-2 text-xs text-gray-400 pb-1">
<input type="checkbox" name="qualys_bypass_proxy" value="true" {% if vals.qualys_bypass_proxy == 'true' %}checked{% endif %} {% if not editable.qualys %}disabled{% endif %}>
Bypass proxy (accès direct)
</label>
</div>
{% if editable.qualys %}<button type="submit" class="btn-primary px-4 py-2 text-sm">Sauvegarder</button>{% endif %} {% if editable.qualys %}<button type="submit" class="btn-primary px-4 py-2 text-sm">Sauvegarder</button>{% endif %}
</form> </form>
</div> </div>
@ -93,7 +99,7 @@
<label class="text-xs text-gray-500">Password par defaut</label> <label class="text-xs text-gray-500">Password par defaut</label>
<input type="password" name="ssh_pwd_default_pass" value="{{ vals.ssh_pwd_default_pass }}" class="w-full" {% if not editable.ssh_pwd %}disabled{% endif %}> <input type="password" name="ssh_pwd_default_pass" value="{{ vals.ssh_pwd_default_pass }}" class="w-full" {% if not editable.ssh_pwd %}disabled{% endif %}>
</div> </div>
<p class="text-xs text-gray-600">Pour les environnements recette sans cle SSH. Chaque opérateur peut configurer son propre compte.</p> <p class="text-xs text-gray-600">Pour les environnements recette sans cle SSH. Chaque operateur peut configurer son propre compte.</p>
{% if editable.ssh_pwd %}<button type="submit" class="btn-primary px-4 py-2 text-sm">Sauvegarder</button>{% endif %} {% if editable.ssh_pwd %}<button type="submit" class="btn-primary px-4 py-2 text-sm">Sauvegarder</button>{% endif %}
</form> </form>
</div> </div>
@ -134,7 +140,7 @@
<input type="text" name="psmp_default_safe" value="{{ vals.psmp_default_safe }}" class="w-full" {% if not editable.ssh_psmp %}disabled{% endif %}> <input type="text" name="psmp_default_safe" value="{{ vals.psmp_default_safe }}" class="w-full" {% if not editable.ssh_psmp %}disabled{% endif %}>
</div> </div>
</div> </div>
<p class="text-xs text-gray-600">Auth keyboard-interactive. Chaque opérateur configure son propre compte CyberArk. MDP saisi en session.</p> <p class="text-xs text-gray-600">Auth keyboard-interactive. Chaque operateur configure son propre compte CyberArk. MDP saisi en session.</p>
{% if editable.ssh_psmp %}<button type="submit" class="btn-primary px-4 py-2 text-sm">Sauvegarder</button>{% endif %} {% if editable.ssh_psmp %}<button type="submit" class="btn-primary px-4 py-2 text-sm">Sauvegarder</button>{% endif %}
</form> </form>
</div> </div>
@ -228,7 +234,7 @@
<td class="p-2 text-center"> <td class="p-2 text-center">
{% if vc.is_active %} {% if vc.is_active %}
<form method="POST" action="/settings/vcenter/{{ vc.id }}/delete" style="display:inline"> <form method="POST" action="/settings/vcenter/{{ vc.id }}/delete" style="display:inline">
<button type="submit" class="btn-sm bg-red-900/30 text-cyber-red" onclick="return confirm('Désactiver ce vCenter ?')">sactiver</button> <button type="submit" class="btn-sm bg-red-900/30 text-cyber-red" onclick="return confirm('Desactiver ce vCenter ?')">Desactiver</button>
</form> </form>
{% endif %} {% endif %}
</td> </td>
@ -298,7 +304,7 @@
</div> </div>
</div> </div>
<div> <div>
<label class="text-xs text-gray-500">Vérifier SSL (true/false)</label> <label class="text-xs text-gray-500">Verifier SSL (true/false)</label>
<input type="text" name="splunk_verify_ssl" value="{{ vals.splunk_verify_ssl }}" placeholder="true" class="w-full" {% if not editable.splunk %}disabled{% endif %}> <input type="text" name="splunk_verify_ssl" value="{{ vals.splunk_verify_ssl }}" placeholder="true" class="w-full" {% if not editable.splunk %}disabled{% endif %}>
</div> </div>
<p class="text-xs text-gray-600">Envoie les evenements de patching vers Splunk via HEC.</p> <p class="text-xs text-gray-600">Envoie les evenements de patching vers Splunk via HEC.</p>