diff --git a/app/main.py b/app/main.py index e9b06cf..c77ecb0 100644 --- a/app/main.py +++ b/app/main.py @@ -6,7 +6,7 @@ from starlette.middleware.base import BaseHTTPMiddleware from .config import APP_NAME, APP_VERSION from .dependencies import get_current_user, get_user_perms from .database import SessionLocal -from .routers import auth, dashboard, servers, settings, users, campaigns, planning, specifics, audit +from .routers import auth, dashboard, servers, settings, users, campaigns, planning, specifics, audit, contacts, qualys class PermissionsMiddleware(BaseHTTPMiddleware): @@ -39,6 +39,8 @@ app.include_router(campaigns.router) app.include_router(planning.router) app.include_router(specifics.router) app.include_router(audit.router) +app.include_router(contacts.router) +app.include_router(qualys.router) @app.get("/") diff --git a/app/routers/audit.py b/app/routers/audit.py index 0fd1461..b050d3d 100644 --- a/app/routers/audit.py +++ b/app/routers/audit.py @@ -1,9 +1,11 @@ -"""Router audit serveurs — resultats des scans d'audit""" -from fastapi import APIRouter, Request, Depends, Query -from fastapi.responses import HTMLResponse, RedirectResponse +"""Router audit serveurs — résultats des scans + audit temps réel""" +import csv, io +from fastapi import APIRouter, Request, Depends, Query, Form, UploadFile, File +from fastapi.responses import HTMLResponse, RedirectResponse, StreamingResponse from fastapi.templating import Jinja2Templates from sqlalchemy import text 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 router = APIRouter() @@ -49,7 +51,6 @@ async def audit_page(request: Request, db=Depends(get_db), LIMIT 500 """), params).fetchall() - # Stats stats = db.execute(text(""" SELECT COUNT(*) as total, @@ -63,22 +64,227 @@ async def audit_page(request: Request, db=Depends(get_db), FROM server_audit """)).fetchone() - return templates.TemplateResponse("audit.html", { - "request": request, "user": user, "app_name": APP_NAME, - "entries": entries, "stats": stats, "filter": filter, - "search": search, + # Dernier audit global + last_audit = db.execute(text( + "SELECT MAX(audit_date) as last_date, COUNT(*) as count FROM server_audit" + )).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) async def audit_detail(request: Request, audit_id: int, db=Depends(get_db)): user = get_current_user(request) if not user: - return HTMLResponse("

Non autorise

") + return HTMLResponse("

Non autorisé

") entry = db.execute(text("SELECT * FROM server_audit WHERE id = :id"), {"id": audit_id}).fetchone() if not entry: - return HTMLResponse("

Non trouve

") + return HTMLResponse("

Non trouvé

") return templates.TemplateResponse("partials/audit_detail.html", { "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"} + ) diff --git a/app/routers/campaigns.py b/app/routers/campaigns.py index d57e131..53c0ed5 100644 --- a/app/routers/campaigns.py +++ b/app/routers/campaigns.py @@ -99,6 +99,9 @@ async def campaign_create(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, "campaigns"): + return RedirectResponse(url="/campaigns", status_code=303) form = await request.form() year = int(form.get("year", datetime.now().year)) 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) if not user: return RedirectResponse(url="/login") + perms = get_user_perms(db, user) + if not can_edit(perms, "campaigns"): + return RedirectResponse(url="/campaigns", status_code=303) try: db.execute(text(""" 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) if not user: 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.commit() return RedirectResponse(url="/assignments?msg=deleted", status_code=303) diff --git a/app/routers/contacts.py b/app/routers/contacts.py new file mode 100644 index 0000000..6dff326 --- /dev/null +++ b/app/routers/contacts.py @@ -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("

Non autorisé

") + + contact = db.execute(text("SELECT * FROM contacts WHERE id = :id"), + {"id": contact_id}).fetchone() + if not contact: + return HTMLResponse("

Non trouvé

") + + 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) diff --git a/app/routers/qualys.py b/app/routers/qualys.py new file mode 100644 index 0000000..1388966 --- /dev/null +++ b/app/routers/qualys.py @@ -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'{result["msg"]}') + + +@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'{result["msg"]}') + + +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("

Non autorisé

") + + asset = db.execute(text("SELECT * FROM qualys_assets WHERE qualys_asset_id = :aid"), + {"aid": asset_id}).fetchone() + if not asset: + return HTMLResponse("

Asset non trouvé

") + + 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) diff --git a/app/routers/servers.py b/app/routers/servers.py index 05f10c4..e83ecc3 100644 --- a/app/routers/servers.py +++ b/app/routers/servers.py @@ -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) async def server_sync_qualys(request: Request, server_id: int, db=Depends(get_db)): user = get_current_user(request) diff --git a/app/routers/settings.py b/app/routers/settings.py index 602b880..d90a821 100644 --- a/app/routers/settings.py +++ b/app/routers/settings.py @@ -15,7 +15,8 @@ SECTIONS = { ("qualys_url", "URL API", False), ("qualys_user", "Utilisateur", False), ("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_url", "URL API", False), @@ -144,7 +145,11 @@ async def settings_save(request: Request, section: str, db=Depends(get_db)): val = form.get(key, "") if is_secret and val == "********": 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) ctx = _build_context(db, user, saved=section) diff --git a/app/services/qualys_service.py b/app/services/qualys_service.py index d58fdcf..f2bd53d 100644 --- a/app/services/qualys_service.py +++ b/app/services/qualys_service.py @@ -14,6 +14,9 @@ def _get_qualys_creds(db): user = get_secret(db, "qualys_user") or "" pwd = get_secret(db, "qualys_pass") 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 @@ -21,6 +24,218 @@ def parse_xml(txt, tag): return re.findall(f"<{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("")[1:]: + block = block.split("")[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("")[1:]: + block = block.split("")[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 "" 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 "" in block: + tag_block = block.split("")[1].split("")[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): """Sync les tags Qualys pour un serveur donne. Retourne un dict resultat.""" row = db.execute(text( diff --git a/app/services/realtime_audit_service.py b/app/services/realtime_audit_service.py new file mode 100644 index 0000000..ea149b2 --- /dev/null +++ b/app/services/realtime_audit_service.py @@ -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 diff --git a/app/services/server_service.py b/app/services/server_service.py index 32ef702..99bc063 100644 --- a/app/services/server_service.py +++ b/app/services/server_service.py @@ -115,7 +115,10 @@ def list_servers(db, filters, page=1, per_page=50, sort="hostname", sort_dir="as if filters.get("tier"): where.append("s.tier = :tier"); params["tier"] = filters["tier"] if filters.get("etat"): - where.append("s.etat = :etat"); params["etat"] = filters["etat"] + if filters["etat"] == "eol": + where.append("s.licence_support = 'eol'") + else: + where.append("s.etat = :etat"); params["etat"] = filters["etat"] if filters.get("search"): where.append("s.hostname ILIKE :search"); params["search"] = f"%{filters['search']}%" diff --git a/app/templates/audit.html b/app/templates/audit.html index 94ca19d..dcc5dd3 100644 --- a/app/templates/audit.html +++ b/app/templates/audit.html @@ -1,7 +1,77 @@ {% extends 'base.html' %} {% block title %}Audit Serveurs{% endblock %} {% block content %} -

Audit Serveurs ({{ stats.total }})

+
+
+

Audit Général Linux

+

+ {% 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 %} +

+
+ +
+ + +{% set p = perms if perms is defined else request.state.perms %} +{% if p.audit in ('edit', 'admin') %} +
+
+

Lancer un audit général

+ +
+
+
+

Tous les serveurs Linux en production seront audités (hors exclusions).

+
+
+ +
+ {% for d in domains %} + + {% endfor %} +
+
+
+ +
+ {% for z in zones %} + + {% endfor %} +
+
+
+ + +

EMV exclu par défaut (zone PCI-DSS)

+
+
+ +
+
+
+{% endif %} + +{% set msg = request.query_params.get('msg') %} +{% if msg %} +
+ {% 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 %} +
+{% endif %} + + +
@@ -84,6 +157,4 @@
- - {% endblock %} diff --git a/app/templates/audit_realtime.html b/app/templates/audit_realtime.html new file mode 100644 index 0000000..9a66991 --- /dev/null +++ b/app/templates/audit_realtime.html @@ -0,0 +1,24 @@ +{% extends 'base.html' %} +{% block title %}Audit temps réel{% endblock %} +{% block content %} +
+
+ ← Audit +

Audit temps réel

+
+
+ +
+ +
+ + +
+
+ + +
+ + +
+{% endblock %} diff --git a/app/templates/audit_realtime_results.html b/app/templates/audit_realtime_results.html new file mode 100644 index 0000000..677e54d --- /dev/null +++ b/app/templates/audit_realtime_results.html @@ -0,0 +1,120 @@ +{% extends 'base.html' %} +{% block title %}Résultats audit temps réel{% endblock %} +{% block content %} +
+
+ ← Retour audit +

Résultats audit temps réel

+
+
+
+ +
+
+
+ + +
+
+
{{ total }}
+
Total
+
+
+
{{ ok }}
+
Connectés
+
+
+
{{ failed }}
+
Échoués
+
+
+ + +
+ + + + + + + + + + + + + + + + + {% for r in results %} + + + + + + + + + + + + + + + {% endfor %} + +
HostnameStatutFQDN résoluOSKernelDisqueQualysS1ServicesSans autoBDDContainers
{{ r.hostname }}{{ r.status[:12] }}{{ r.resolved_fqdn or '-' }}{{ (r.os_release or '-')[:25] }}{{ (r.kernel or '-')[:20] }} + {% if r.disk_alert %}ALERTE + {% elif r.status == 'OK' %}OK + {% else %}-{% endif %} + {% if r.qualys_active %}OK{% elif r.status == 'OK' %}KO{% else %}-{% endif %}{% if r.sentinelone_active %}OK{% elif r.status == 'OK' %}KO{% else %}-{% endif %}{% if r.services_running %}{{ r.services_running.split('\n')|length }}{% else %}-{% endif %}{% if r.running_not_enabled and r.running_not_enabled != 'none' %}{{ r.running_not_enabled.split('\n')|length }}{% else %}-{% endif %}{{ (r.db_detect or '-')[:15] }}{{ (r.containers or '-')[:20] }}
+
+ + +{% for r in results %} +{% if r.status == 'OK' %} +
+ {{ r.hostname }} — {{ r.resolved_fqdn or '' }} +
+
+

Espace disque

+
{{ r.disk_space or 'N/A' }}
+
+
+

Applications

+
{{ r.apps_installed or 'N/A' }}
+
+
+

Services actifs

+
{{ r.services_running or 'N/A' }}
+
+
+

Sans auto-start

+
{{ r.running_not_enabled or 'Aucun' }}
+
+
+

Ports

+
{{ r.listening_ports or 'N/A' }}
+
+
+

Agents

+
{{ r.agents or 'N/A' }}
+
+ {% if r.containers and r.containers != 'none' %} +
+

Containers

+
{{ r.containers }}
+
+ {% endif %} + {% if r.failed_services and r.failed_services != 'none' %} +
+

Services en échec

+
{{ r.failed_services }}
+
+ {% endif %} +
+
+{% endif %} +{% endfor %} +{% endblock %} diff --git a/app/templates/audit_specific.html b/app/templates/audit_specific.html new file mode 100644 index 0000000..c11ddca --- /dev/null +++ b/app/templates/audit_specific.html @@ -0,0 +1,37 @@ +{% extends 'base.html' %} +{% block title %}Audit spécifique{% endblock %} +{% block content %} +
+
+ ← Audit +

Audit spécifique

+

Auditer un ou plusieurs serveurs à la demande

+
+
+ +
+
+
+ + +
+
+
+ + +
+
+ + +
+
+

Si plus de 10 serveurs, le parallélisme est recommandé.

+ +
+
+{% endblock %} diff --git a/app/templates/base.html b/app/templates/base.html index a63eb6e..e822704 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -56,11 +56,16 @@ {% set p = perms if perms is defined else request.state.perms %} Dashboard {% if p.servers %}Serveurs{% endif %} - {% if p.specifics %}Spécifiques{% endif %} + {% if p.specifics %}Specifiques{% endif %} {% if p.campaigns %}Campagnes{% endif %} {% if p.campaigns in ('edit', 'admin') %}Assignations{% endif %} + {% if p.qualys %}Qualys{% endif %} + {% if p.qualys %}Tags{% endif %} + {% if p.qualys %}Décodeur{% endif %} {% if p.planning %}Planning{% endif %} - {% if p.audit %}Audit{% endif %} + {% if p.audit %}Audit{% endif %} + {% if p.audit in ('edit', 'admin') %}Spécifique{% endif %} + {% if p.servers %}Contacts{% endif %} {% if p.users %}Utilisateurs{% endif %} {% if p.settings %}Settings{% endif %} @@ -71,7 +76,7 @@
{{ user.sub }} {{ user.role }} - Déconnexion + Deconnexion
diff --git a/app/templates/contacts.html b/app/templates/contacts.html new file mode 100644 index 0000000..d826069 --- /dev/null +++ b/app/templates/contacts.html @@ -0,0 +1,101 @@ +{% extends 'base.html' %} +{% block title %}Contacts{% endblock %} +{% block content %} +

Contacts & Responsables ({{ contacts|length }})

+ +{% if msg %} +
+ {% 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 %} +
+{% endif %} + + +
+ Tous + {% for r in roles_in_db %} + {{ r }} + {% endfor %} +
+ + + +
+
+ +{% if server %} +
+ {% if server_info %} +
+ Contacts pour + {{ server_info.hostname }} + {{ server_info.domain_name or '-' }} + {{ server_info.env_name or '-' }} + {% if server_info.app_type %}{{ server_info.app_type }}{% endif %} + {% if server_info.app_group %}groupe: {{ server_info.app_group }}{% endif %} +
+ {% else %} + Serveur "{{ server }}" non trouvé en base + {% endif %} +
+{% endif %} + + + + + +
+ + + + + + + + + + + {% for c in contacts %} + + + + + + + + + {% endfor %} + +
NomEmailRôleScopesActifActions
{{ c.name }}{{ c.email }}{{ c.role }}{{ (c.scopes_summary or '-')[:80] }}{{ 'Oui' if c.is_active else 'Non' }} +
+ +
+ +
+
+
+
+ + +
+

Ajouter un contact

+
+
+ + +
+
+ + +
+
+ + +
+ +
+
+{% endblock %} diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html index beb1a40..8d1281f 100644 --- a/app/templates/dashboard.html +++ b/app/templates/dashboard.html @@ -4,7 +4,7 @@

Dashboard

-
+
{{ stats.total_servers }}
Serveurs
@@ -21,6 +21,10 @@
{{ stats.qualys_tags }}
Tags Qualys
+
+
{{ stats.eol }}
+
EOL
+
diff --git a/app/templates/partials/contact_detail.html b/app/templates/partials/contact_detail.html new file mode 100644 index 0000000..901f694 --- /dev/null +++ b/app/templates/partials/contact_detail.html @@ -0,0 +1,85 @@ +
+
+

{{ c.name }}

+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + + +
+ + + +

Périmètre de responsabilité

+
+ {% for s in scopes %} +
+ {{ s.scope_type }} + {{ s.scope_value }} + {% if s.env_scope != 'all' %}{{ s.env_scope }}{% endif %} +
+ +
+
+ {% endfor %} + {% if not scopes %}

Aucun scope défini

{% endif %} +
+ + +
+
+ + +
+
+ + + + {% for d in domains %}{% endfor %} + {% for a in app_types %}{% endfor %} + +
+
+ + +
+ +
+ + + {% if servers %} +

Serveurs concernés ({{ servers|length }})

+
+ {% for s in servers %} +
+ {{ s.hostname }} + {{ s.domaine or '' }} + {{ (s.environnement or '')[:4] }} +
+ {% endfor %} +
+ {% endif %} +
diff --git a/app/templates/partials/qualys_asset_detail.html b/app/templates/partials/qualys_asset_detail.html new file mode 100644 index 0000000..d2158c0 --- /dev/null +++ b/app/templates/partials/qualys_asset_detail.html @@ -0,0 +1,106 @@ +
+
+

{{ a.name or a.hostname }}

+ +
+ +
+ +
+

Informations

+
+
Hostname: {{ a.hostname }}
+
IP: {{ a.ip_address or '-' }}
+
FQDN: {{ a.fqdn or '-' }}
+
OS: {{ a.os or '-' }}
+
Agent: {{ a.agent_status or '-' }}
+
Version: {{ a.agent_version or '-' }}
+
Dernier check-in: {{ a.last_checkin or '-' }}
+
Qualys ID: {{ a.qualys_asset_id }}
+
+
+ + +
+

Décodage nomenclature

+
+
Type: {{ decoded.type }}
+
Environnement: {{ decoded.env }}
+
Domaine: {{ decoded.domain }}
+
+

Tags suggérés

+
+ {% for t in decoded.tags %} + {% set found = false %} + {% for ct in tags %}{% if ct.name == t %}{% set found = true %}{% endif %}{% endfor %} + {{ t }} + {% endfor %} +
+
+
+ + +
+

Tags assignés ({{ tags|length }})

+
+ {% for t in tags %} +
+ {{ 'DYN' if t.is_dynamic else 'STAT' }} + {{ t.name }} + {% if not t.is_dynamic %} +
+ + +
+ {% endif %} +
+ {% endfor %} + {% if not tags %}Aucun tag{% endif %} +
+
+
+ + +
+

Ajouter un tag

+
+ + + +
+ +
+ +
diff --git a/app/templates/qualys_decoder.html b/app/templates/qualys_decoder.html new file mode 100644 index 0000000..8970ada --- /dev/null +++ b/app/templates/qualys_decoder.html @@ -0,0 +1,79 @@ +{% extends 'base.html' %} +{% block title %}Décodeur Qualys{% endblock %} +{% block content %} +

Décodeur nomenclature SANEF

+ + +
+
+ + +
+ +
+ +{% if result %} + +
+
+

{{ result.hostname }}

+ {{ result.type }} + {{ result.env }} + {{ result.domain }} +
+ +
+ +
+

Tags suggérés par la nomenclature

+
+ {% for tag in result.tags %} +
+ {{ tag }} + {% set is_auto = false %} + {% for prefix in auto_prefixes %} + {% if tag.startswith(prefix) %}{% set is_auto = true %}{% endif %} + {% endfor %} + {{ 'DYN' if is_auto else 'STAT' }} + {% set found = false %} + {% for ct in current_tags %} + {% if ct[0] == tag %}{% set found = true %}{% endif %} + {% endfor %} + {% if found %} + ✓ Assigné + {% else %} + ✗ Manquant + {% endif %} +
+ {% endfor %} +
+
+ + +
+

Tags actuels dans Qualys ({{ current_tags|length }})

+ {% if current_tags %} +
+ {% for name, is_dyn in current_tags %} + {{ name }} + {% endfor %} +
+ {% else %} +

{% if hostname %}Asset non trouvé dans Qualys ou aucun tag{% else %}Saisissez un hostname{% endif %}

+ {% endif %} +
+
+
+{% endif %} + + +
+

Légende

+
+
DYN Tag dynamique (géré automatiquement par Qualys via règle)
+
STAT Tag statique (assignation manuelle requise)
+
✓ Assigné Tag présent sur l'asset
+
✗ Manquant Tag suggéré mais non assigné
+
+
+{% endblock %} diff --git a/app/templates/qualys_search.html b/app/templates/qualys_search.html new file mode 100644 index 0000000..e6a469d --- /dev/null +++ b/app/templates/qualys_search.html @@ -0,0 +1,182 @@ +{% extends 'base.html' %} +{% block title %}Recherche Qualys{% endblock %} +{% block content %} +

Recherche Assets Qualys

+ + +
+
+ + +
+
+ + + +
+ + {% if search %}Export CSV{% endif %} +
+ +{% set msg = request.query_params.get('msg') %} +{% if msg %} +
+ {% 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 %} +
+{% endif %} +{% if search %} +

+ {% if api_msg %}{{ api_msg }}{% else %}{{ assets|length }} résultat(s){% endif %} +

+{% endif %} + + + + + +{% if assets %} + +{% if can_edit_qualys %} + + +{% endif %} + +
+ + + {% if can_edit_qualys %}{% endif %} + + + + + + + + + {% 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 %} + + {% if can_edit_qualys %}{% endif %} + + + + + + + + {% endfor %} + +
HostnameIPOSAgentTagsActions
{% if qid %}{% endif %}{{ hn or '-' }}{{ ip or '-' }}{{ (os or '-')[:30] }} + {% if agent %}{{ agent[:10] }} + {% else %}N/A{% endif %} + {{ (tl or '-')[:80] }} + {% if qid %} + + {% endif %} +
+
+{% endif %} +{% endblock %} diff --git a/app/templates/qualys_tags.html b/app/templates/qualys_tags.html new file mode 100644 index 0000000..62975b6 --- /dev/null +++ b/app/templates/qualys_tags.html @@ -0,0 +1,91 @@ +{% extends 'base.html' %} +{% block title %}Tags Qualys{% endblock %} +{% block content %} +
+

Tags Qualys ({{ stats.total }} — {{ stats.dyn }} DYN / {{ stats.stat }} STAT)

+
+ {% if can_edit_qualys %} +
+ +
+ {% endif %} + Export CSV +
+
+ +{% if msg %} +
+ {% 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 %} +
+{% endif %} + +{% if can_edit_qualys %} +
+
+
+ + +
+ +
+
+{% endif %} + + +
+ Tous ({{ stats.total }}) + DYN ({{ stats.dyn }}) + STAT ({{ stats.stat }}) +
+ {% if tag_type %}{% endif %} + + +
+
+ + +
+ + + + + + + + + {% if can_edit_qualys %}{% endif %} + + + {% for t in tags %} + + + + + + + + {% if can_edit_qualys %} + + {% endif %} + + {% endfor %} + +
Nom actuelTypeNom V3Type V3ID QualysAssetsAction
{{ t.name }}{{ 'DYN' if t.is_dynamic else 'STAT' }}{{ t.v3_name or '-' }}{% if t.v3_type %}{{ t.v3_type }}{% else %}-{% endif %}{{ t.qualys_tag_id }} + {% if t.asset_count > 0 %} + {{ t.asset_count }} + {% else %}0{% endif %} + + {% if not t.is_dynamic %} +
+ +
+ {% else %}Auto{% endif %} +
+
+ +
+ Légende V3 : Les tags surlignés ont un nom V3 différent du nom actuel (migration à prévoir). + Vert = nouveau nom V3 proposé. + Cliquez sur le nombre d'assets pour voir les serveurs associés. +
+{% endblock %} diff --git a/app/templates/servers.html b/app/templates/servers.html index f50056a..e7e70ab 100644 --- a/app/templates/servers.html +++ b/app/templates/servers.html @@ -36,12 +36,60 @@ {% for t in ['tier0','tier1','tier2','tier3'] %}{% endfor %} Reset + + + + +
@@ -62,7 +110,7 @@ {% for s in servers %} - + @@ -104,6 +152,7 @@ function closePanel() { } document.getElementById('check-all').addEventListener('change', function(e) { document.querySelectorAll('input[name=srv]').forEach(cb => cb.checked = e.target.checked); + updateBulk(); }); {% endblock %} diff --git a/app/templates/settings.html b/app/templates/settings.html index 94ebed6..35d817e 100644 --- a/app/templates/settings.html +++ b/app/templates/settings.html @@ -5,7 +5,7 @@ {% if saved %}
- Section "{{ saved }}" sauvegardée. + Section "{{ saved }}" sauvegardee.
{% endif %} @@ -42,9 +42,15 @@ -
- - +
+
+ + +
+
{% if editable.qualys %}{% endif %} @@ -93,7 +99,7 @@
-

Pour les environnements recette sans cle SSH. Chaque opérateur peut configurer son propre compte.

+

Pour les environnements recette sans cle SSH. Chaque operateur peut configurer son propre compte.

{% if editable.ssh_pwd %}{% endif %} @@ -134,7 +140,7 @@ -

Auth keyboard-interactive. Chaque opérateur configure son propre compte CyberArk. MDP saisi en session.

+

Auth keyboard-interactive. Chaque operateur configure son propre compte CyberArk. MDP saisi en session.

{% if editable.ssh_psmp %}{% endif %} @@ -228,7 +234,7 @@ @@ -298,7 +304,7 @@
- +

Envoie les evenements de patching vers Splunk via HEC.

{{ s.hostname }} {{ s.domaine or '-' }} {{ (s.environnement or '-')[:6] }} {% if vc.is_active %}
- +
{% endif %}