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}>([^<]*){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("+ {% 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 %} +
+| Hostname | +Statut | +FQDN résolu | +OS | +Kernel | +Disque | +Qualys | +S1 | +Services | +Sans auto | +BDD | +Containers | +
|---|---|---|---|---|---|---|---|---|---|---|---|
| {{ 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] }} | +
{{ r.disk_space or 'N/A' }}
+ {{ r.apps_installed or 'N/A' }}
+ {{ r.services_running or 'N/A' }}
+ {{ r.running_not_enabled or 'Aucun' }}
+ {{ r.listening_ports or 'N/A' }}
+ {{ r.agents or 'N/A' }}
+ {{ r.containers }}
+ {{ r.failed_services }}
+ | Nom | +Rôle | +Scopes | +Actif | +Actions | +|
|---|---|---|---|---|---|
| {{ c.name }} | +{{ c.email }} | +{{ c.role }} | +{{ (c.scopes_summary or '-')[:80] }} | +{{ 'Oui' if c.is_active else 'Non' }} | +
+
+
+
+
+ |
+
Aucun scope défini
{% endif %} +{% if hostname %}Asset non trouvé dans Qualys ou aucun tag{% else %}Saisissez un hostname{% endif %}
+ {% endif %} ++ {% if api_msg %}{{ api_msg }}{% else %}{{ assets|length }} résultat(s){% endif %} +
+{% endif %} + + + + + +{% if assets %} + +{% if can_edit_qualys %} + + +{% endif %} + +| {% endif %} + | Hostname | +IP | +OS | +Agent | +Tags | +Actions | +
|---|---|---|---|---|---|---|
| {% if qid %}{% endif %} | {% endif %} +{{ hn or '-' }} | +{{ ip or '-' }} | +{{ (os or '-')[:30] }} | ++ {% if agent %}{{ agent[:10] }} + {% else %}N/A{% endif %} + | +{{ (tl or '-')[:80] }} | ++ {% if qid %} + + {% endif %} + | +
| Nom actuel | +Type | +Nom V3 | +Type V3 | +ID Qualys | +Assets | + {% if can_edit_qualys %}Action | {% endif %} +
|---|---|---|---|---|---|---|
| {{ 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 can_edit_qualys %} ++ {% if not t.is_dynamic %} + + {% else %}Auto{% endif %} + | + {% endif %} +
| + | {{ s.hostname }} | {{ s.domaine or '-' }} | {{ (s.environnement or '-')[:6] }} | @@ -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 %}{% if vc.is_active %} {% endif %} | @@ -298,7 +304,7 @@