Qualys complet, contacts, audit refactoré, bulk serveurs
Qualys: - Recherche API temps réel + cache 24h base locale - Tags: liste DYN/STAT, mapping V3 (DOM-*, TYP-*, APP-*), nb assets cliquable - CRUD tags: créer STAT, supprimer, resync API - Détail asset: infos + décodage nomenclature V3 + tags assignés - Ajout/retrait tag unitaire avec autocomplete filtrable - Bulk add/remove tag en masse avec dropdown filtrable - Tags retirer: charge dynamiquement les STAT assignés aux assets sélectionnés - Resync assets sélectionnés + retour même recherche Contacts: - 50 contacts importés avec 93 scopes (domaine/app/serveur/zone par env) - 13 rôles (responsable_domaine, ra_prod, ra_recette, referent_technique...) - Recherche par nom/email/serveur (affiche contacts liés) - CRUD complet: éditer, scopes, activer/désactiver, supprimer - Serveurs liés calculés dynamiquement depuis les scopes Audit: - Restructuré: Audit général + sous-menu Spécifique - Dernier audit global affiché avec date - Lancer audit général avec exclusions (domaines/zones) et parallélisme - KPIs Qualys KO et S1 KO cliquables - Export CSV Serveurs: - Actions groupées bulk (domaine, env, tier, état, owner, licence) - Dashboard: KPI EOL ajouté - Filtre état: EOL + en décommissionnement ajoutés - 138 serveurs EOL importés depuis Qualys (owner=na, hors périmètre) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
e0105e7e00
commit
8e62b1fb11
@ -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("/")
|
||||
|
||||
@ -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("<p>Non autorise</p>")
|
||||
return HTMLResponse("<p>Non autorisé</p>")
|
||||
entry = db.execute(text("SELECT * FROM server_audit WHERE id = :id"),
|
||||
{"id": audit_id}).fetchone()
|
||||
if not entry:
|
||||
return HTMLResponse("<p>Non trouve</p>")
|
||||
return HTMLResponse("<p>Non trouvé</p>")
|
||||
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"}
|
||||
)
|
||||
|
||||
@ -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)
|
||||
|
||||
247
app/routers/contacts.py
Normal file
247
app/routers/contacts.py
Normal file
@ -0,0 +1,247 @@
|
||||
"""Router contacts — gestion des responsables applicatifs et scopes"""
|
||||
from fastapi import APIRouter, Request, Depends, Query, Form
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from sqlalchemy import text
|
||||
from ..dependencies import get_db, get_current_user, get_user_perms, can_view, can_edit, base_context
|
||||
from ..config import APP_NAME
|
||||
|
||||
router = APIRouter()
|
||||
templates = Jinja2Templates(directory="app/templates")
|
||||
|
||||
ROLES = [
|
||||
("responsable_domaine", "Responsable domaine"),
|
||||
("responsable_prod", "Responsable production"),
|
||||
("responsable_applicatif", "Responsable applicatif"),
|
||||
("referent_technique", "Référent technique"),
|
||||
("ra_prod", "RA Production"),
|
||||
("ra_recette", "RA Recette"),
|
||||
("ra_preprod", "RA Pré-prod"),
|
||||
("ra_test", "RA Test"),
|
||||
("ra_dev", "RA Développement"),
|
||||
("chef_projet", "Chef de projet"),
|
||||
("contact_technique", "Contact technique"),
|
||||
("editeur", "Éditeur"),
|
||||
("autre", "Autre"),
|
||||
]
|
||||
|
||||
SCOPE_TYPES = [
|
||||
("domain", "Domaine"),
|
||||
("application", "Application"),
|
||||
("app_group", "Groupe applicatif"),
|
||||
("server", "Serveur"),
|
||||
("zone", "Zone"),
|
||||
]
|
||||
|
||||
ENV_SCOPES = ["all", "prod", "recette", "preprod", "test", "dev"]
|
||||
|
||||
|
||||
@router.get("/contacts", response_class=HTMLResponse)
|
||||
async def contacts_page(request: Request, db=Depends(get_db),
|
||||
search: str = Query(None), role: str = Query(None),
|
||||
server: str = Query(None)):
|
||||
user = get_current_user(request)
|
||||
if not user:
|
||||
return RedirectResponse(url="/login")
|
||||
|
||||
where = ["1=1"]
|
||||
params = {}
|
||||
if search:
|
||||
where.append("(c.name ILIKE :q OR c.email ILIKE :q)")
|
||||
params["q"] = f"%{search}%"
|
||||
if role:
|
||||
where.append("c.role = :role")
|
||||
params["role"] = role
|
||||
|
||||
# Recherche par serveur : trouver les contacts liés à ce serveur
|
||||
server_info = None
|
||||
if server:
|
||||
server_info = db.execute(text("""
|
||||
SELECT s.id, s.hostname, d.code as domain_code, d.name as domain_name,
|
||||
e.name as env_name, s.app_group, z.name as zone_name,
|
||||
ss.app_type
|
||||
FROM servers s
|
||||
LEFT JOIN domain_environments de ON s.domain_env_id = de.id
|
||||
LEFT JOIN domains d ON de.domain_id = d.id
|
||||
LEFT JOIN environments e ON de.environment_id = e.id
|
||||
LEFT JOIN zones z ON s.zone_id = z.id
|
||||
LEFT JOIN server_specifics ss ON ss.server_id = s.id
|
||||
WHERE LOWER(s.hostname) = LOWER(:h)
|
||||
"""), {"h": server.strip()}).fetchone()
|
||||
|
||||
if server_info:
|
||||
where.append("""c.id IN (
|
||||
SELECT cs.contact_id FROM contact_scopes cs WHERE
|
||||
(cs.scope_type = 'server' AND LOWER(cs.scope_value) = LOWER(:srv_hn))
|
||||
OR (cs.scope_type = 'domain' AND cs.scope_value = :srv_dom)
|
||||
OR (cs.scope_type = 'app_group' AND cs.scope_value = :srv_ag)
|
||||
OR (cs.scope_type = 'application' AND UPPER(cs.scope_value) = UPPER(:srv_app))
|
||||
OR (cs.scope_type = 'zone' AND cs.scope_value = :srv_zone)
|
||||
)""")
|
||||
params["srv_hn"] = server.strip()
|
||||
params["srv_dom"] = server_info.domain_code or ""
|
||||
params["srv_ag"] = server_info.app_group or ""
|
||||
params["srv_app"] = server_info.app_type or ""
|
||||
params["srv_zone"] = server_info.zone_name or ""
|
||||
|
||||
wc = " AND ".join(where)
|
||||
|
||||
contacts = db.execute(text(f"""
|
||||
SELECT c.*,
|
||||
(SELECT string_agg(cs.scope_type || ':' || cs.scope_value ||
|
||||
CASE WHEN cs.env_scope != 'all' THEN '(' || cs.env_scope || ')' ELSE '' END, ', '
|
||||
ORDER BY cs.scope_type, cs.scope_value)
|
||||
FROM contact_scopes cs WHERE cs.contact_id = c.id) as scopes_summary
|
||||
FROM contacts c WHERE {wc} ORDER BY c.name
|
||||
"""), params).fetchall()
|
||||
|
||||
roles_in_db = db.execute(text(
|
||||
"SELECT DISTINCT role FROM contacts ORDER BY role"
|
||||
)).fetchall()
|
||||
|
||||
domains = db.execute(text("SELECT code, name FROM domains ORDER BY display_order")).fetchall()
|
||||
app_types = db.execute(text(
|
||||
"SELECT DISTINCT app_type FROM server_specifics WHERE app_type IS NOT NULL ORDER BY app_type"
|
||||
)).fetchall()
|
||||
|
||||
ctx = base_context(request, db, user)
|
||||
ctx.update({
|
||||
"app_name": APP_NAME, "contacts": contacts,
|
||||
"roles": ROLES, "roles_in_db": [r.role for r in roles_in_db],
|
||||
"scope_types": SCOPE_TYPES, "env_scopes": ENV_SCOPES,
|
||||
"domains": domains, "app_types": [r.app_type for r in app_types],
|
||||
"search": search, "role_filter": role, "server": server, "server_info": server_info,
|
||||
"msg": request.query_params.get("msg"),
|
||||
})
|
||||
return templates.TemplateResponse("contacts.html", ctx)
|
||||
|
||||
|
||||
@router.get("/contacts/{contact_id}", response_class=HTMLResponse)
|
||||
async def contact_detail(request: Request, contact_id: int, db=Depends(get_db)):
|
||||
user = get_current_user(request)
|
||||
if not user:
|
||||
return HTMLResponse("<p>Non autorisé</p>")
|
||||
|
||||
contact = db.execute(text("SELECT * FROM contacts WHERE id = :id"),
|
||||
{"id": contact_id}).fetchone()
|
||||
if not contact:
|
||||
return HTMLResponse("<p>Non trouvé</p>")
|
||||
|
||||
scopes = db.execute(text("""
|
||||
SELECT cs.* FROM contact_scopes cs WHERE cs.contact_id = :cid ORDER BY cs.scope_type, cs.scope_value
|
||||
"""), {"cid": contact_id}).fetchall()
|
||||
|
||||
# Serveurs liés via scopes
|
||||
servers = db.execute(text("""
|
||||
SELECT DISTINCT s.id, s.hostname, d.name as domaine, e.name as environnement
|
||||
FROM servers s
|
||||
LEFT JOIN domain_environments de ON s.domain_env_id = de.id
|
||||
LEFT JOIN domains d ON de.domain_id = d.id
|
||||
LEFT JOIN environments e ON de.environment_id = e.id
|
||||
LEFT JOIN server_specifics ss ON ss.server_id = s.id
|
||||
LEFT JOIN zones z ON s.zone_id = z.id
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM contact_scopes cs WHERE cs.contact_id = :cid AND (
|
||||
(cs.scope_type = 'domain' AND d.code = cs.scope_value)
|
||||
OR (cs.scope_type = 'application' AND UPPER(ss.app_type) = UPPER(cs.scope_value))
|
||||
OR (cs.scope_type = 'server' AND LOWER(s.hostname) = LOWER(cs.scope_value))
|
||||
OR (cs.scope_type = 'app_group' AND s.app_group = cs.scope_value)
|
||||
OR (cs.scope_type = 'zone' AND z.name = cs.scope_value)
|
||||
)
|
||||
)
|
||||
ORDER BY d.name, s.hostname LIMIT 100
|
||||
"""), {"cid": contact_id}).fetchall()
|
||||
|
||||
domains = db.execute(text("SELECT code, name FROM domains ORDER BY display_order")).fetchall()
|
||||
app_types = db.execute(text(
|
||||
"SELECT DISTINCT app_type FROM server_specifics WHERE app_type IS NOT NULL ORDER BY app_type"
|
||||
)).fetchall()
|
||||
|
||||
return templates.TemplateResponse("partials/contact_detail.html", {
|
||||
"request": request, "c": contact, "scopes": scopes, "servers": servers,
|
||||
"roles": ROLES, "scope_types": SCOPE_TYPES, "env_scopes": ENV_SCOPES,
|
||||
"domains": domains, "app_types": [r.app_type for r in app_types],
|
||||
})
|
||||
|
||||
|
||||
@router.post("/contacts/add")
|
||||
async def contact_add(request: Request, db=Depends(get_db),
|
||||
name: str = Form(...), email: str = Form(...), contact_role: str = Form("autre")):
|
||||
user = get_current_user(request)
|
||||
if not user:
|
||||
return RedirectResponse(url="/login")
|
||||
try:
|
||||
db.execute(text("""
|
||||
INSERT INTO contacts (name, email, role, is_active)
|
||||
VALUES (:n, :e, :r, true)
|
||||
"""), {"n": name.strip(), "e": email.strip().lower(), "r": contact_role})
|
||||
db.commit()
|
||||
return RedirectResponse(url="/contacts?msg=added", status_code=303)
|
||||
except Exception:
|
||||
db.rollback()
|
||||
return RedirectResponse(url="/contacts?msg=exists", status_code=303)
|
||||
|
||||
|
||||
@router.post("/contacts/{contact_id}/edit")
|
||||
async def contact_edit(request: Request, contact_id: int, db=Depends(get_db),
|
||||
name: str = Form(""), email: str = Form(""), contact_role: str = Form("")):
|
||||
user = get_current_user(request)
|
||||
if not user:
|
||||
return RedirectResponse(url="/login")
|
||||
updates = []; params = {"id": contact_id}
|
||||
if name: updates.append("name = :n"); params["n"] = name
|
||||
if email: updates.append("email = :e"); params["e"] = email.lower()
|
||||
if contact_role: updates.append("role = :r"); params["r"] = contact_role
|
||||
if updates:
|
||||
db.execute(text(f"UPDATE contacts SET {', '.join(updates)} WHERE id = :id"), params)
|
||||
db.commit()
|
||||
return RedirectResponse(url="/contacts?msg=edited", status_code=303)
|
||||
|
||||
|
||||
@router.post("/contacts/{contact_id}/toggle")
|
||||
async def contact_toggle(request: Request, contact_id: int, db=Depends(get_db)):
|
||||
user = get_current_user(request)
|
||||
if not user:
|
||||
return RedirectResponse(url="/login")
|
||||
db.execute(text("UPDATE contacts SET is_active = NOT is_active WHERE id = :id"), {"id": contact_id})
|
||||
db.commit()
|
||||
return RedirectResponse(url="/contacts?msg=toggled", status_code=303)
|
||||
|
||||
|
||||
@router.post("/contacts/{contact_id}/scope/add")
|
||||
async def scope_add(request: Request, contact_id: int, db=Depends(get_db),
|
||||
scope_type: str = Form(...), scope_value: str = Form(...),
|
||||
env_scope: str = Form("all")):
|
||||
user = get_current_user(request)
|
||||
if not user:
|
||||
return RedirectResponse(url="/login")
|
||||
try:
|
||||
db.execute(text("""
|
||||
INSERT INTO contact_scopes (contact_id, scope_type, scope_value, env_scope)
|
||||
VALUES (:cid, :st, :sv, :es)
|
||||
"""), {"cid": contact_id, "st": scope_type, "sv": scope_value.strip(), "es": env_scope})
|
||||
db.commit()
|
||||
except Exception:
|
||||
db.rollback()
|
||||
return RedirectResponse(url=f"/contacts?msg=scope_added", status_code=303)
|
||||
|
||||
|
||||
@router.post("/contacts/scope/{scope_id}/delete")
|
||||
async def scope_delete(request: Request, scope_id: int, db=Depends(get_db)):
|
||||
user = get_current_user(request)
|
||||
if not user:
|
||||
return RedirectResponse(url="/login")
|
||||
db.execute(text("DELETE FROM contact_scopes WHERE id = :id"), {"id": scope_id})
|
||||
db.commit()
|
||||
return RedirectResponse(url="/contacts?msg=scope_deleted", status_code=303)
|
||||
|
||||
|
||||
@router.post("/contacts/{contact_id}/delete")
|
||||
async def contact_delete(request: Request, contact_id: int, db=Depends(get_db)):
|
||||
user = get_current_user(request)
|
||||
if not user:
|
||||
return RedirectResponse(url="/login")
|
||||
db.execute(text("DELETE FROM contact_scopes WHERE contact_id = :cid"), {"cid": contact_id})
|
||||
db.execute(text("DELETE FROM contacts WHERE id = :cid"), {"cid": contact_id})
|
||||
db.commit()
|
||||
return RedirectResponse(url="/contacts?msg=deleted", status_code=303)
|
||||
518
app/routers/qualys.py
Normal file
518
app/routers/qualys.py
Normal file
@ -0,0 +1,518 @@
|
||||
"""Router Qualys — Tags, Recherche assets, Décodeur nomenclature"""
|
||||
from fastapi import APIRouter, Request, Depends, Query, Form
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse, StreamingResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from sqlalchemy import text
|
||||
import csv, io, re
|
||||
from ..dependencies import get_db, get_current_user, get_user_perms, can_view, can_edit, can_admin, base_context
|
||||
from ..services.qualys_service import (
|
||||
sync_server_qualys, search_assets_api, get_all_tags_api,
|
||||
create_tag_api, delete_tag_api, add_tag_to_asset_api,
|
||||
remove_tag_from_asset_api, resync_all_tags,
|
||||
)
|
||||
from ..config import APP_NAME
|
||||
|
||||
router = APIRouter()
|
||||
templates = Jinja2Templates(directory="app/templates")
|
||||
|
||||
# Nomenclature maps (from server_decoder.py)
|
||||
ENV_MAP = {'p': ('Production', 'ENV-PRD'), 'r': ('Recette', 'ENV-REC'), 'i': ('Pré-Prod', 'ENV-PPR'),
|
||||
'v': ('Test', 'ENV-TST'), 'd': ('Développement', 'ENV-DEV'), 't': ('Test', 'ENV-TST'),
|
||||
's': ('Production', 'ENV-PRD'), 'o': ('Pré-Prod', 'ENV-PPR')}
|
||||
DOMAIN_MAP = {
|
||||
'bot': ('Flux Libre', 'DOM-FL'), 'boo': ('Flux Libre', 'DOM-FL'), 'boc': ('Flux Libre', 'DOM-FL'),
|
||||
'afl': ('Flux Libre', 'DOM-FL'), 'sup': ('Flux Libre', 'DOM-FL'),
|
||||
'dsi': ('Infrastructure', 'DOM-INF'), 'cyb': ('Infrastructure', 'DOM-INF'),
|
||||
'vsa': ('Infrastructure', 'DOM-INF'), 'iad': ('Infrastructure', 'DOM-INF'),
|
||||
'bur': ('Infrastructure', 'DOM-INF'), 'aii': ('Infrastructure', 'DOM-INF'),
|
||||
'ecm': ('Infrastructure', 'DOM-INF'), 'log': ('Infrastructure', 'DOM-INF'),
|
||||
'bck': ('Infrastructure', 'DOM-INF'), 'gaw': ('Infrastructure', 'DOM-INF'),
|
||||
'vid': ('Infrastructure', 'DOM-INF'), 'sim': ('Infrastructure', 'DOM-INF'),
|
||||
'emv': ('EMV', 'TAG-EMV'), 'pci': ('EMV', 'TAG-EMV'),
|
||||
'pea': ('Péage', 'DOM-PEA'), 'osa': ('Péage', 'DOM-PEA'), 'svp': ('Péage', 'DOM-PEA'),
|
||||
'adv': ('Péage', 'DOM-PEA'), 'rpa': ('Péage', 'DOM-PEA'),
|
||||
'ame': ('Trafic', 'DOM-TRA'), 'tra': ('Trafic', 'DOM-TRA'), 'dai': ('Trafic', 'DOM-TRA'),
|
||||
'pat': ('Trafic', 'DOM-TRA'), 'dep': ('Trafic', 'DOM-TRA'), 'exp': ('Trafic', 'DOM-TRA'),
|
||||
'sig': ('Trafic', 'DOM-TRA'), 'rau': ('Trafic', 'DOM-TRA'),
|
||||
'dec': ('BI', 'DOM-BI'), 'sas': ('BI', 'DOM-BI'), 'bip': ('BI', 'DOM-BI'),
|
||||
'int': ('Gestion', 'DOM-GES'), 'agt': ('Gestion', 'DOM-GES'), 'pin': ('Gestion', 'DOM-GES'),
|
||||
'ech': ('Gestion', 'DOM-GES'),
|
||||
}
|
||||
AUTO_PREFIXES = ('ENV-', 'OS-', 'DOM-', 'TYP-', 'TAG-OBS', 'TAG-EMV')
|
||||
|
||||
|
||||
def _save_api_results_to_db(db, assets):
|
||||
"""Sauvegarde les résultats de recherche API dans la base locale"""
|
||||
for a in assets:
|
||||
if not a.get("qualys_asset_id"):
|
||||
continue
|
||||
hostname = (a.get("hostname") or a.get("name", "")).split(".")[0].lower()
|
||||
os_val = a.get("os", "")
|
||||
os_family = "linux" if any(k in os_val.lower() for k in ("linux", "red hat", "centos")) else "windows" if "windows" in os_val.lower() else None
|
||||
|
||||
srv = db.execute(text("SELECT id FROM servers WHERE LOWER(hostname) = LOWER(:h)"),
|
||||
{"h": hostname}).fetchone()
|
||||
server_id = srv.id if srv else None
|
||||
|
||||
db.execute(text("""
|
||||
INSERT INTO qualys_assets (qualys_asset_id, name, hostname, fqdn, ip_address, os, os_family,
|
||||
agent_status, agent_version, last_checkin, server_id)
|
||||
VALUES (:qid, :name, :hn, :fqdn, :ip, :os, :osf, :ast, :av, :lc, :sid)
|
||||
ON CONFLICT (qualys_asset_id) DO UPDATE SET
|
||||
name=EXCLUDED.name, fqdn=EXCLUDED.fqdn, ip_address=EXCLUDED.ip_address,
|
||||
os=EXCLUDED.os, os_family=EXCLUDED.os_family,
|
||||
agent_status=EXCLUDED.agent_status, agent_version=EXCLUDED.agent_version,
|
||||
last_checkin=EXCLUDED.last_checkin, server_id=COALESCE(EXCLUDED.server_id, qualys_assets.server_id),
|
||||
updated_at=now()
|
||||
"""), {
|
||||
"qid": a["qualys_asset_id"], "name": a.get("name"), "hn": hostname,
|
||||
"fqdn": a.get("fqdn") or None, "ip": a.get("ip_address") or None,
|
||||
"os": os_val, "osf": os_family,
|
||||
"ast": a.get("agent_status", ""), "av": a.get("agent_version", ""),
|
||||
"lc": a.get("last_checkin") or None, "sid": server_id,
|
||||
})
|
||||
|
||||
if a.get("tags"):
|
||||
db.execute(text("DELETE FROM qualys_asset_tags WHERE qualys_asset_id = :qid"),
|
||||
{"qid": a["qualys_asset_id"]})
|
||||
for tag_name in a["tags"]:
|
||||
tag_row = db.execute(text("SELECT qualys_tag_id FROM qualys_tags WHERE name = :n"),
|
||||
{"n": tag_name}).fetchone()
|
||||
if tag_row:
|
||||
db.execute(text("""
|
||||
INSERT INTO qualys_asset_tags (qualys_asset_id, qualys_tag_id)
|
||||
VALUES (:qid, :tid) ON CONFLICT DO NOTHING
|
||||
"""), {"qid": a["qualys_asset_id"], "tid": tag_row.qualys_tag_id})
|
||||
|
||||
db.commit()
|
||||
|
||||
|
||||
def _decode_hostname(hostname):
|
||||
"""Décode un hostname SANEF et retourne les tags suggérés"""
|
||||
hn = hostname.lower().strip()
|
||||
if hn.startswith('ls-'):
|
||||
return {'type': 'Physique', 'env': 'Production', 'domain': 'Péage', 'tags': ['ENV-PRD', 'DOM-PEA', 'TYP-SRV', 'OS-WIN']}
|
||||
|
||||
if len(hn) < 4:
|
||||
return {'type': '?', 'env': '?', 'domain': '?', 'tags': []}
|
||||
|
||||
# Type
|
||||
machine = 'VM' if hn[0] == 'v' else 'Physique'
|
||||
eqt = 'TYP-VIR' if hn[0] == 'v' else 'TYP-SRV'
|
||||
|
||||
# Env
|
||||
env_info = ENV_MAP.get(hn[1], ('?', None))
|
||||
env_name, env_tag = env_info
|
||||
|
||||
# Domain (try 4, 3, 2 char prefixes)
|
||||
rest = hn[2:]
|
||||
domain_name, domain_tag = '?', None
|
||||
for length in (4, 3, 2):
|
||||
prefix = rest[:length]
|
||||
if prefix in DOMAIN_MAP:
|
||||
domain_name, domain_tag = DOMAIN_MAP[prefix]
|
||||
break
|
||||
|
||||
tags = [t for t in [env_tag, domain_tag, eqt] if t]
|
||||
return {'type': machine, 'env': env_name, 'domain': domain_name, 'tags': tags}
|
||||
|
||||
|
||||
# === TAGS ===
|
||||
|
||||
@router.get("/qualys/tags", response_class=HTMLResponse)
|
||||
async def qualys_tags(request: Request, db=Depends(get_db),
|
||||
search: str = Query(None), tag_type: str = Query(None)):
|
||||
user = get_current_user(request)
|
||||
if not user:
|
||||
return RedirectResponse(url="/login")
|
||||
perms = get_user_perms(db, user)
|
||||
if not can_view(perms, "qualys"):
|
||||
return RedirectResponse(url="/dashboard")
|
||||
|
||||
where = ["1=1"]
|
||||
params = {}
|
||||
if search:
|
||||
where.append("qt.name ILIKE :q"); params["q"] = f"%{search}%"
|
||||
if tag_type == "dyn":
|
||||
where.append("qt.is_dynamic = true")
|
||||
elif tag_type == "stat":
|
||||
where.append("qt.is_dynamic = false")
|
||||
wc = " AND ".join(where)
|
||||
|
||||
tags = db.execute(text(f"""
|
||||
SELECT qt.*,
|
||||
(SELECT COUNT(*) FROM qualys_asset_tags qat WHERE qat.qualys_tag_id = qt.qualys_tag_id) as asset_count
|
||||
FROM qualys_tags qt WHERE {wc} ORDER BY qt.name
|
||||
"""), params).fetchall()
|
||||
|
||||
stats = db.execute(text("""
|
||||
SELECT COUNT(*) as total,
|
||||
COUNT(*) FILTER (WHERE is_dynamic) as dyn,
|
||||
COUNT(*) FILTER (WHERE NOT is_dynamic) as stat
|
||||
FROM qualys_tags
|
||||
""")).fetchone()
|
||||
|
||||
ctx = base_context(request, db, user)
|
||||
ctx.update({
|
||||
"app_name": APP_NAME, "tags": tags, "stats": stats,
|
||||
"search": search, "tag_type": tag_type,
|
||||
"can_edit_qualys": can_edit(perms, "qualys"),
|
||||
"msg": request.query_params.get("msg"),
|
||||
})
|
||||
return templates.TemplateResponse("qualys_tags.html", ctx)
|
||||
|
||||
|
||||
@router.post("/qualys/tags/resync")
|
||||
async def qualys_tags_resync(request: Request, db=Depends(get_db)):
|
||||
user = get_current_user(request)
|
||||
if not user:
|
||||
return RedirectResponse(url="/login")
|
||||
result = resync_all_tags(db)
|
||||
msg = "resync_ok" if result["ok"] else "resync_ko"
|
||||
return RedirectResponse(url=f"/qualys/tags?msg={msg}", status_code=303)
|
||||
|
||||
|
||||
@router.post("/qualys/tags/create")
|
||||
async def qualys_tag_create(request: Request, db=Depends(get_db),
|
||||
tag_name: str = Form(...)):
|
||||
user = get_current_user(request)
|
||||
if not user:
|
||||
return RedirectResponse(url="/login")
|
||||
result = create_tag_api(db, tag_name.strip())
|
||||
msg = "created" if result["ok"] else "create_error"
|
||||
return RedirectResponse(url=f"/qualys/tags?msg={msg}", status_code=303)
|
||||
|
||||
|
||||
@router.post("/qualys/tags/{tag_id}/delete")
|
||||
async def qualys_tag_delete(request: Request, tag_id: int, db=Depends(get_db)):
|
||||
user = get_current_user(request)
|
||||
if not user:
|
||||
return RedirectResponse(url="/login")
|
||||
result = delete_tag_api(db, tag_id)
|
||||
msg = "deleted" if result["ok"] else "delete_error"
|
||||
return RedirectResponse(url=f"/qualys/tags?msg={msg}", status_code=303)
|
||||
|
||||
|
||||
@router.post("/qualys/asset/{asset_id}/tag/add")
|
||||
async def qualys_asset_tag_add(request: Request, asset_id: int, db=Depends(get_db),
|
||||
tag_id: str = Form(...)):
|
||||
user = get_current_user(request)
|
||||
if not user:
|
||||
return RedirectResponse(url="/login")
|
||||
result = add_tag_to_asset_api(db, asset_id, int(tag_id))
|
||||
color = "text-cyber-green" if result["ok"] else "text-cyber-red"
|
||||
return HTMLResponse(f'<span class="text-xs {color}">{result["msg"]}</span>')
|
||||
|
||||
|
||||
@router.post("/qualys/asset/{asset_id}/tag/remove")
|
||||
async def qualys_asset_tag_remove(request: Request, asset_id: int, db=Depends(get_db),
|
||||
tag_id: str = Form(...)):
|
||||
user = get_current_user(request)
|
||||
if not user:
|
||||
return RedirectResponse(url="/login")
|
||||
result = remove_tag_from_asset_api(db, asset_id, int(tag_id))
|
||||
color = "text-cyber-green" if result["ok"] else "text-cyber-red"
|
||||
return HTMLResponse(f'<span class="text-xs {color}">{result["msg"]}</span>')
|
||||
|
||||
|
||||
def _bulk_return_url(form, msg):
|
||||
"""Construit l'URL de retour avec la recherche conservée"""
|
||||
s = form.get("return_search", "")
|
||||
f = form.get("return_field", "hostname")
|
||||
return f"/qualys/search?field={f}&search={s}&msg={msg}"
|
||||
|
||||
|
||||
@router.post("/qualys/bulk/add-tag")
|
||||
async def qualys_bulk_add_tag(request: Request, db=Depends(get_db)):
|
||||
user = get_current_user(request)
|
||||
if not user:
|
||||
return RedirectResponse(url="/login")
|
||||
form = await request.form()
|
||||
ids = [int(x) for x in form.get("asset_ids", "").split(",") if x.strip().isdigit()]
|
||||
tid = int(form.get("tag_id", "0") or "0")
|
||||
ok = 0; ko = 0
|
||||
for aid in ids:
|
||||
r = add_tag_to_asset_api(db, aid, tid)
|
||||
if r["ok"]: ok += 1
|
||||
else: ko += 1
|
||||
return RedirectResponse(url=_bulk_return_url(form, f"bulk_add_{ok}_{ko}"), status_code=303)
|
||||
|
||||
|
||||
@router.post("/qualys/bulk/remove-tag")
|
||||
async def qualys_bulk_remove_tag(request: Request, db=Depends(get_db)):
|
||||
user = get_current_user(request)
|
||||
if not user:
|
||||
return RedirectResponse(url="/login")
|
||||
form = await request.form()
|
||||
ids = [int(x) for x in form.get("asset_ids", "").split(",") if x.strip().isdigit()]
|
||||
tid = int(form.get("tag_id", "0") or "0")
|
||||
ok = 0; ko = 0
|
||||
for aid in ids:
|
||||
r = remove_tag_from_asset_api(db, aid, tid)
|
||||
if r["ok"]: ok += 1
|
||||
else: ko += 1
|
||||
return RedirectResponse(url=_bulk_return_url(form, f"bulk_rm_{ok}_{ko}"), status_code=303)
|
||||
|
||||
|
||||
@router.post("/qualys/resync-assets")
|
||||
async def qualys_resync_assets(request: Request, db=Depends(get_db)):
|
||||
user = get_current_user(request)
|
||||
if not user:
|
||||
return RedirectResponse(url="/login")
|
||||
form = await request.form()
|
||||
ids = [int(x) for x in form.get("asset_ids", "").split(",") if x.strip().isdigit()]
|
||||
ok = 0
|
||||
for aid in ids:
|
||||
result = search_assets_api(db, str(aid), field="id", operator="EQUALS")
|
||||
if result.get("ok") and result.get("assets"):
|
||||
_save_api_results_to_db(db, result["assets"])
|
||||
ok += 1
|
||||
return RedirectResponse(url=_bulk_return_url(form, f"resync_{ok}"), status_code=303)
|
||||
|
||||
|
||||
@router.get("/qualys/bulk/tags-for-assets")
|
||||
async def qualys_tags_for_assets(request: Request, db=Depends(get_db),
|
||||
asset_ids: str = Query("")):
|
||||
"""Retourne les tags STAT assignés aux assets sélectionnés (JSON)"""
|
||||
from fastapi.responses import JSONResponse
|
||||
user = get_current_user(request)
|
||||
if not user:
|
||||
return JSONResponse([])
|
||||
ids = [int(x) for x in asset_ids.split(",") if x.strip().isdigit()]
|
||||
if not ids:
|
||||
return JSONResponse([])
|
||||
|
||||
placeholders = ",".join([f":id{i}" for i in range(len(ids))])
|
||||
params = {f"id{i}": v for i, v in enumerate(ids)}
|
||||
|
||||
rows = db.execute(text(f"""
|
||||
SELECT qt.qualys_tag_id as id, qt.name, COUNT(DISTINCT qat.qualys_asset_id) as count
|
||||
FROM qualys_asset_tags qat
|
||||
JOIN qualys_tags qt ON qat.qualys_tag_id = qt.qualys_tag_id
|
||||
WHERE qat.qualys_asset_id IN ({placeholders}) AND qt.is_dynamic = false
|
||||
GROUP BY qt.qualys_tag_id, qt.name
|
||||
ORDER BY qt.name
|
||||
"""), params).fetchall()
|
||||
|
||||
return JSONResponse([{"id": r.id, "name": r.name, "count": r.count} for r in rows])
|
||||
|
||||
|
||||
@router.get("/qualys/tags/export")
|
||||
async def qualys_tags_export(request: Request, db=Depends(get_db)):
|
||||
user = get_current_user(request)
|
||||
if not user:
|
||||
return RedirectResponse(url="/login")
|
||||
tags = db.execute(text("SELECT * FROM qualys_tags ORDER BY name")).fetchall()
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output, delimiter=";")
|
||||
writer.writerow(["Nom", "ID Qualys", "Type", "Nb assets"])
|
||||
for t in tags:
|
||||
writer.writerow([t.name, t.qualys_tag_id, "DYN" if t.is_dynamic else "STAT", ""])
|
||||
output.seek(0)
|
||||
return StreamingResponse(iter(["\ufeff" + output.getvalue()]), media_type="text/csv",
|
||||
headers={"Content-Disposition": "attachment; filename=qualys_tags.csv"})
|
||||
|
||||
|
||||
# === RECHERCHE ASSETS ===
|
||||
|
||||
@router.get("/qualys/search", response_class=HTMLResponse)
|
||||
async def qualys_search(request: Request, db=Depends(get_db),
|
||||
search: str = Query(None), field: str = Query("hostname")):
|
||||
user = get_current_user(request)
|
||||
if not user:
|
||||
return RedirectResponse(url="/login")
|
||||
perms = get_user_perms(db, user)
|
||||
if not can_view(perms, "qualys"):
|
||||
return RedirectResponse(url="/dashboard")
|
||||
|
||||
assets = []
|
||||
api_msg = None
|
||||
source = None
|
||||
if search:
|
||||
# Vérifier si on a des données récentes (< 24h) en base
|
||||
from datetime import datetime, timedelta
|
||||
cutoff = datetime.now() - timedelta(hours=24)
|
||||
|
||||
if field == "hostname":
|
||||
fresh = db.execute(text("""
|
||||
SELECT COUNT(*) FROM qualys_assets
|
||||
WHERE (hostname ILIKE :q OR name ILIKE :q OR fqdn ILIKE :q)
|
||||
AND updated_at > :cutoff
|
||||
"""), {"q": f"%{search}%", "cutoff": cutoff}).scalar()
|
||||
elif field == "ip":
|
||||
fresh = db.execute(text("""
|
||||
SELECT COUNT(*) FROM qualys_assets
|
||||
WHERE CAST(ip_address AS TEXT) LIKE :q AND updated_at > :cutoff
|
||||
"""), {"q": f"{search}%", "cutoff": cutoff}).scalar()
|
||||
elif field == "tag":
|
||||
fresh = db.execute(text("""
|
||||
SELECT COUNT(*) FROM qualys_assets qa
|
||||
WHERE qa.qualys_asset_id IN (
|
||||
SELECT qat.qualys_asset_id FROM qualys_asset_tags qat
|
||||
JOIN qualys_tags qt ON qat.qualys_tag_id = qt.qualys_tag_id
|
||||
WHERE qt.name ILIKE :q
|
||||
) AND qa.updated_at > :cutoff
|
||||
"""), {"q": f"%{search}%", "cutoff": cutoff}).scalar()
|
||||
else:
|
||||
fresh = 0
|
||||
|
||||
if fresh > 0:
|
||||
# Données fraiches en base — pas d'appel API
|
||||
source = "base (< 24h)"
|
||||
else:
|
||||
# Appel API + stockage en base
|
||||
if field == "hostname":
|
||||
result = search_assets_api(db, search, field="name", operator="CONTAINS")
|
||||
elif field == "ip":
|
||||
result = search_assets_api(db, search, field="address", operator="CONTAINS")
|
||||
elif field == "tag":
|
||||
result = search_assets_api(db, search, field="tagName", operator="EQUALS")
|
||||
else:
|
||||
result = {"ok": False, "msg": "Champ inconnu", "assets": []}
|
||||
|
||||
if result.get("ok") and result.get("assets"):
|
||||
_save_api_results_to_db(db, result["assets"])
|
||||
source = f"API Qualys ({len(result['assets'])} résultats)"
|
||||
elif not result.get("ok"):
|
||||
source = f"Erreur API: {result.get('msg', '?')}"
|
||||
# Fallback sur la base meme si pas frais
|
||||
source += " — fallback base"
|
||||
else:
|
||||
source = "API: aucun résultat"
|
||||
|
||||
# Lire depuis la base dans tous les cas
|
||||
where_db = ["1=1"]; params_db = {}
|
||||
if field == "hostname":
|
||||
where_db.append("(qa.hostname ILIKE :q OR qa.name ILIKE :q OR qa.fqdn ILIKE :q)")
|
||||
params_db["q"] = f"%{search}%"
|
||||
elif field == "ip":
|
||||
where_db.append("CAST(qa.ip_address AS TEXT) LIKE :q")
|
||||
params_db["q"] = f"{search}%"
|
||||
elif field == "tag":
|
||||
where_db.append("""qa.qualys_asset_id IN (
|
||||
SELECT qat.qualys_asset_id FROM qualys_asset_tags qat
|
||||
JOIN qualys_tags qt ON qat.qualys_tag_id = qt.qualys_tag_id
|
||||
WHERE qt.name ILIKE :q)""")
|
||||
params_db["q"] = f"%{search}%"
|
||||
wc_db = " AND ".join(where_db)
|
||||
|
||||
assets = db.execute(text(f"""
|
||||
SELECT qa.*, s.hostname as srv_hostname, s.tier,
|
||||
(SELECT string_agg(qt.name, ', ' ORDER BY qt.name)
|
||||
FROM qualys_asset_tags qat JOIN qualys_tags qt ON qat.qualys_tag_id = qt.qualys_tag_id
|
||||
WHERE qat.qualys_asset_id = qa.qualys_asset_id) as tags_list
|
||||
FROM qualys_assets qa
|
||||
LEFT JOIN servers s ON qa.server_id = s.id
|
||||
WHERE {wc_db} ORDER BY qa.hostname LIMIT 200
|
||||
"""), params_db).fetchall()
|
||||
|
||||
api_msg = f"{len(assets)} résultat(s) — source: {source}"
|
||||
|
||||
all_tags = db.execute(text("SELECT qualys_tag_id, name FROM qualys_tags ORDER BY name")).fetchall()
|
||||
|
||||
ctx = base_context(request, db, user)
|
||||
ctx.update({
|
||||
"app_name": APP_NAME, "assets": assets, "search": search,
|
||||
"field": field, "api_msg": api_msg,
|
||||
"all_tags": all_tags,
|
||||
"can_edit_qualys": can_edit(perms, "qualys"),
|
||||
"msg": request.query_params.get("msg"),
|
||||
})
|
||||
return templates.TemplateResponse("qualys_search.html", ctx)
|
||||
|
||||
|
||||
@router.get("/qualys/asset/{asset_id}", response_class=HTMLResponse)
|
||||
async def qualys_asset_detail(request: Request, asset_id: int, db=Depends(get_db)):
|
||||
user = get_current_user(request)
|
||||
if not user:
|
||||
return HTMLResponse("<p>Non autorisé</p>")
|
||||
|
||||
asset = db.execute(text("SELECT * FROM qualys_assets WHERE qualys_asset_id = :aid"),
|
||||
{"aid": asset_id}).fetchone()
|
||||
if not asset:
|
||||
return HTMLResponse("<p>Asset non trouvé</p>")
|
||||
|
||||
tags = db.execute(text("""
|
||||
SELECT qt.name, qt.is_dynamic, qt.qualys_tag_id
|
||||
FROM qualys_asset_tags qat JOIN qualys_tags qt ON qat.qualys_tag_id = qt.qualys_tag_id
|
||||
WHERE qat.qualys_asset_id = :aid ORDER BY qt.name
|
||||
"""), {"aid": asset_id}).fetchall()
|
||||
|
||||
decoded = _decode_hostname(asset.hostname or asset.name or "")
|
||||
|
||||
all_tags = db.execute(text(
|
||||
"SELECT qualys_tag_id, name, is_dynamic FROM qualys_tags ORDER BY name"
|
||||
)).fetchall()
|
||||
|
||||
return templates.TemplateResponse("partials/qualys_asset_detail.html", {
|
||||
"request": request, "a": asset, "tags": tags, "decoded": decoded, "all_tags": all_tags,
|
||||
})
|
||||
|
||||
|
||||
@router.get("/qualys/search/export")
|
||||
async def qualys_search_export(request: Request, db=Depends(get_db),
|
||||
search: str = Query(""), field: str = Query("hostname")):
|
||||
user = get_current_user(request)
|
||||
if not user:
|
||||
return RedirectResponse(url="/login")
|
||||
# Re-execute la recherche
|
||||
where = ["1=1"]; params = {}
|
||||
if search:
|
||||
if field == "hostname":
|
||||
where.append("(qa.hostname ILIKE :q OR qa.name ILIKE :q OR qa.fqdn ILIKE :q)")
|
||||
params["q"] = f"%{search}%"
|
||||
elif field == "ip":
|
||||
where.append("CAST(qa.ip_address AS TEXT) LIKE :q"); params["q"] = f"{search}%"
|
||||
wc = " AND ".join(where)
|
||||
assets = db.execute(text(f"""
|
||||
SELECT qa.hostname, qa.ip_address, qa.fqdn, qa.os, qa.agent_status,
|
||||
qa.agent_version, qa.last_checkin, qa.qualys_asset_id
|
||||
FROM qualys_assets qa WHERE {wc} ORDER BY qa.hostname LIMIT 1000
|
||||
"""), params).fetchall()
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output, delimiter=";")
|
||||
writer.writerow(["Hostname", "IP", "FQDN", "OS", "Agent", "Version", "Dernier check-in", "Qualys ID"])
|
||||
for a in assets:
|
||||
writer.writerow([a.hostname, a.ip_address, a.fqdn, a.os, a.agent_status,
|
||||
a.agent_version, a.last_checkin, a.qualys_asset_id])
|
||||
output.seek(0)
|
||||
return StreamingResponse(iter(["\ufeff" + output.getvalue()]), media_type="text/csv",
|
||||
headers={"Content-Disposition": f"attachment; filename=qualys_assets_{search or 'all'}.csv"})
|
||||
|
||||
|
||||
# === DECODEUR ===
|
||||
|
||||
@router.get("/qualys/decoder", response_class=HTMLResponse)
|
||||
async def qualys_decoder(request: Request, db=Depends(get_db),
|
||||
hostname: str = Query(None)):
|
||||
user = get_current_user(request)
|
||||
if not user:
|
||||
return RedirectResponse(url="/login")
|
||||
perms = get_user_perms(db, user)
|
||||
if not can_view(perms, "qualys"):
|
||||
return RedirectResponse(url="/dashboard")
|
||||
|
||||
result = None
|
||||
current_tags = []
|
||||
if hostname:
|
||||
result = _decode_hostname(hostname)
|
||||
result["hostname"] = hostname
|
||||
|
||||
# Chercher les tags actuels dans Qualys
|
||||
asset = db.execute(text(
|
||||
"SELECT qualys_asset_id FROM qualys_assets WHERE LOWER(hostname) = LOWER(:h)"
|
||||
), {"h": hostname.strip().split(".")[0].lower()}).fetchone()
|
||||
if asset:
|
||||
rows = db.execute(text("""
|
||||
SELECT qt.name, qt.is_dynamic FROM qualys_asset_tags qat
|
||||
JOIN qualys_tags qt ON qat.qualys_tag_id = qt.qualys_tag_id
|
||||
WHERE qat.qualys_asset_id = :aid ORDER BY qt.name
|
||||
"""), {"aid": asset.qualys_asset_id}).fetchall()
|
||||
current_tags = [(r.name, r.is_dynamic) for r in rows]
|
||||
|
||||
ctx = base_context(request, db, user)
|
||||
ctx.update({
|
||||
"app_name": APP_NAME, "result": result, "hostname": hostname,
|
||||
"current_tags": current_tags, "auto_prefixes": AUTO_PREFIXES,
|
||||
})
|
||||
return templates.TemplateResponse("qualys_decoder.html", ctx)
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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("<Tag>")[1:]:
|
||||
block = block.split("</Tag>")[0]
|
||||
tid = (parse_xml(block, "id") or [""])[0]
|
||||
tname = (parse_xml(block, "name") or [""])[0]
|
||||
rule_type = (parse_xml(block, "ruleType") or [""])[0]
|
||||
if tid and tname:
|
||||
tags.append({"id": int(tid), "name": tname, "is_dynamic": bool(rule_type), "rule_type": rule_type})
|
||||
return {"ok": True, "msg": f"{len(tags)} tags", "tags": tags}
|
||||
|
||||
|
||||
def _parse_assets_full(text):
|
||||
"""Parse le XML Qualys en liste de dicts enrichis"""
|
||||
assets = []
|
||||
for block in text.split("<HostAsset>")[1:]:
|
||||
block = block.split("</HostAsset>")[0]
|
||||
aid = (parse_xml(block, "id") or [""])[0]
|
||||
name = (parse_xml(block, "name") or [""])[0]
|
||||
fqdn = (parse_xml(block, "fqdn") or [""])[0]
|
||||
address = (parse_xml(block, "address") or [""])[0]
|
||||
os_val = (parse_xml(block, "os") or [""])[0]
|
||||
|
||||
agent_status = ""
|
||||
agent_version = ""
|
||||
last_checkin = ""
|
||||
if "<agentInfo>" in block:
|
||||
agent_status = (parse_xml(block, "status") or [""])[0]
|
||||
agent_version = (parse_xml(block, "agentVersion") or [""])[0]
|
||||
last_checkin = (parse_xml(block, "lastCheckedIn") or [""])[0]
|
||||
|
||||
# Tags
|
||||
tags = []
|
||||
if "<tags>" in block:
|
||||
tag_block = block.split("<tags>")[1].split("</tags>")[0]
|
||||
tag_names = parse_xml(tag_block, "name")
|
||||
tags = tag_names
|
||||
|
||||
hostname = name.split(".")[0].lower() if name else ""
|
||||
assets.append({
|
||||
"qualys_asset_id": int(aid) if aid else None,
|
||||
"name": name, "hostname": hostname, "fqdn": fqdn,
|
||||
"ip_address": address, "os": os_val,
|
||||
"agent_status": agent_status, "agent_version": agent_version,
|
||||
"last_checkin": last_checkin, "tags": tags,
|
||||
"tags_list": ", ".join(tags),
|
||||
})
|
||||
return assets
|
||||
|
||||
|
||||
def create_tag_api(db, tag_name):
|
||||
"""Crée un tag statique dans Qualys via API"""
|
||||
qualys_url, qualys_user, qualys_pass, qualys_proxy = _get_qualys_creds(db)
|
||||
if not qualys_user:
|
||||
return {"ok": False, "msg": "Credentials non configurés"}
|
||||
proxies = {"https": qualys_proxy, "http": qualys_proxy} if qualys_proxy else None
|
||||
try:
|
||||
r = requests.post(
|
||||
f"{qualys_url}/qps/rest/2.0/create/am/tag",
|
||||
json={"ServiceRequest": {"data": {"Tag": {"name": tag_name}}}},
|
||||
auth=(qualys_user, qualys_pass), verify=False, timeout=30, proxies=proxies,
|
||||
headers={"Content-Type": "application/json"})
|
||||
if r.status_code == 200 and "SUCCESS" in r.text:
|
||||
tid = (parse_xml(r.text, "id") or [""])[0]
|
||||
if tid:
|
||||
db.execute(text("""
|
||||
INSERT INTO qualys_tags (qualys_tag_id, name, is_dynamic) VALUES (:tid, :n, false)
|
||||
ON CONFLICT (qualys_tag_id) DO UPDATE SET name = EXCLUDED.name
|
||||
"""), {"tid": int(tid), "n": tag_name})
|
||||
db.commit()
|
||||
return {"ok": True, "msg": f"Tag '{tag_name}' créé (ID: {tid})"}
|
||||
return {"ok": False, "msg": f"Erreur API: {r.text[:200]}"}
|
||||
except Exception as e:
|
||||
return {"ok": False, "msg": str(e)}
|
||||
|
||||
|
||||
def delete_tag_api(db, qualys_tag_id):
|
||||
"""Supprime un tag dans Qualys via API"""
|
||||
qualys_url, qualys_user, qualys_pass, qualys_proxy = _get_qualys_creds(db)
|
||||
if not qualys_user:
|
||||
return {"ok": False, "msg": "Credentials non configurés"}
|
||||
proxies = {"https": qualys_proxy, "http": qualys_proxy} if qualys_proxy else None
|
||||
try:
|
||||
r = requests.post(
|
||||
f"{qualys_url}/qps/rest/2.0/delete/am/tag/{qualys_tag_id}",
|
||||
auth=(qualys_user, qualys_pass), verify=False, timeout=30, proxies=proxies,
|
||||
headers={"Content-Type": "application/json"})
|
||||
if r.status_code == 200 and "SUCCESS" in r.text:
|
||||
db.execute(text("DELETE FROM qualys_asset_tags WHERE qualys_tag_id = :tid"), {"tid": qualys_tag_id})
|
||||
db.execute(text("DELETE FROM qualys_tags WHERE qualys_tag_id = :tid"), {"tid": qualys_tag_id})
|
||||
db.commit()
|
||||
return {"ok": True, "msg": "Tag supprimé"}
|
||||
return {"ok": False, "msg": f"Erreur API: {r.text[:200]}"}
|
||||
except Exception as e:
|
||||
return {"ok": False, "msg": str(e)}
|
||||
|
||||
|
||||
def add_tag_to_asset_api(db, asset_id, tag_id):
|
||||
"""Ajoute un tag à un asset via API Qualys"""
|
||||
qualys_url, qualys_user, qualys_pass, qualys_proxy = _get_qualys_creds(db)
|
||||
if not qualys_user:
|
||||
return {"ok": False, "msg": "Credentials non configurés"}
|
||||
proxies = {"https": qualys_proxy, "http": qualys_proxy} if qualys_proxy else None
|
||||
try:
|
||||
r = requests.post(
|
||||
f"{qualys_url}/qps/rest/2.0/update/am/hostasset/{asset_id}",
|
||||
json={"ServiceRequest": {"data": {"HostAsset": {"tags": {"add": {"TagSimple": {"id": tag_id}}}}}}},
|
||||
auth=(qualys_user, qualys_pass), verify=False, timeout=30, proxies=proxies,
|
||||
headers={"Content-Type": "application/json"})
|
||||
if r.status_code == 200 and "SUCCESS" in r.text:
|
||||
db.execute(text("""
|
||||
INSERT INTO qualys_asset_tags (qualys_asset_id, qualys_tag_id)
|
||||
VALUES (:aid, :tid) ON CONFLICT DO NOTHING
|
||||
"""), {"aid": asset_id, "tid": tag_id})
|
||||
db.commit()
|
||||
return {"ok": True, "msg": "Tag ajouté"}
|
||||
return {"ok": False, "msg": f"Erreur: {r.text[:200]}"}
|
||||
except Exception as e:
|
||||
return {"ok": False, "msg": str(e)}
|
||||
|
||||
|
||||
def remove_tag_from_asset_api(db, asset_id, tag_id):
|
||||
"""Retire un tag d'un asset via API Qualys"""
|
||||
qualys_url, qualys_user, qualys_pass, qualys_proxy = _get_qualys_creds(db)
|
||||
if not qualys_user:
|
||||
return {"ok": False, "msg": "Credentials non configurés"}
|
||||
proxies = {"https": qualys_proxy, "http": qualys_proxy} if qualys_proxy else None
|
||||
try:
|
||||
r = requests.post(
|
||||
f"{qualys_url}/qps/rest/2.0/update/am/hostasset/{asset_id}",
|
||||
json={"ServiceRequest": {"data": {"HostAsset": {"tags": {"remove": {"TagSimple": {"id": tag_id}}}}}}},
|
||||
auth=(qualys_user, qualys_pass), verify=False, timeout=30, proxies=proxies,
|
||||
headers={"Content-Type": "application/json"})
|
||||
if r.status_code == 200 and "SUCCESS" in r.text:
|
||||
db.execute(text("""
|
||||
DELETE FROM qualys_asset_tags WHERE qualys_asset_id = :aid AND qualys_tag_id = :tid
|
||||
"""), {"aid": asset_id, "tid": tag_id})
|
||||
db.commit()
|
||||
return {"ok": True, "msg": "Tag retiré"}
|
||||
return {"ok": False, "msg": f"Erreur: {r.text[:200]}"}
|
||||
except Exception as e:
|
||||
return {"ok": False, "msg": str(e)}
|
||||
|
||||
|
||||
def resync_all_tags(db):
|
||||
"""Resync tous les tags depuis l'API Qualys vers la base locale"""
|
||||
result = get_all_tags_api(db)
|
||||
if not result["ok"]:
|
||||
return result
|
||||
count = 0
|
||||
for t in result["tags"]:
|
||||
db.execute(text("""
|
||||
INSERT INTO qualys_tags (qualys_tag_id, name, is_dynamic, rule_type)
|
||||
VALUES (:tid, :n, :dyn, :rt)
|
||||
ON CONFLICT (qualys_tag_id) DO UPDATE SET name = EXCLUDED.name, is_dynamic = EXCLUDED.is_dynamic,
|
||||
rule_type = EXCLUDED.rule_type, updated_at = now()
|
||||
"""), {"tid": t["id"], "n": t["name"], "dyn": t["is_dynamic"], "rt": t.get("rule_type")})
|
||||
count += 1
|
||||
db.commit()
|
||||
return {"ok": True, "msg": f"{count} tags synchronisés"}
|
||||
|
||||
|
||||
def sync_server_qualys(db, server_id):
|
||||
"""Sync les tags Qualys pour un serveur donne. Retourne un dict resultat."""
|
||||
row = db.execute(text(
|
||||
|
||||
222
app/services/realtime_audit_service.py
Normal file
222
app/services/realtime_audit_service.py
Normal file
@ -0,0 +1,222 @@
|
||||
"""Service audit temps reel — lance des checks SSH et retourne les resultats"""
|
||||
import socket
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime
|
||||
from sqlalchemy import text
|
||||
|
||||
try:
|
||||
import paramiko
|
||||
PARAMIKO_OK = True
|
||||
except ImportError:
|
||||
PARAMIKO_OK = False
|
||||
|
||||
SSH_KEY = "/opt/patchcenter/keys/id_rsa_cybglobal.pem"
|
||||
SSH_USER = "cybsecope"
|
||||
SSH_TIMEOUT = 12
|
||||
DNS_SUFFIXES = ["", ".sanef.groupe", ".sanef-rec.fr", ".sanef.fr"]
|
||||
|
||||
# Commandes d'audit (simplifiees pour le temps reel)
|
||||
AUDIT_CMDS = {
|
||||
"os_release": "cat /etc/redhat-release 2>/dev/null || head -1 /etc/os-release 2>/dev/null",
|
||||
"kernel": "uname -r",
|
||||
"uptime": "uptime -p 2>/dev/null || uptime",
|
||||
"selinux": "getenforce 2>/dev/null || echo N/A",
|
||||
"disk_space": "df -h --output=target,size,avail,pcent 2>/dev/null | grep -vE '^(tmpfs|devtmpfs|Filesystem)' | sort",
|
||||
"apps_installed": "rpm -qa --qf '%{NAME} %{VERSION}\\n' 2>/dev/null | grep -iE 'tomcat|java|jdk|nginx|httpd|haproxy|docker|podman|postgresql|postgres|mysql|mariadb|mongodb|oracle|redis|elasticsearch|splunk|centreon|qualys' | sort -u",
|
||||
"services_running": "systemctl list-units --type=service --state=running --no-pager --no-legend 2>/dev/null | grep -vE '(auditd|chronyd|crond|dbus|firewalld|getty|irqbalance|kdump|lvm2|NetworkManager|polkit|postfix|rsyslog|sshd|sssd|systemd|tuned|user@)' | awk '{print $1}' | sed 's/.service//' | sort",
|
||||
"running_not_enabled": "comm -23 <(systemctl list-units --type=service --state=running --no-pager --no-legend 2>/dev/null | grep -vE '(auditd|chronyd|crond|dbus|firewalld|getty|irqbalance|kdump|lvm2|NetworkManager|polkit|postfix|rsyslog|sshd|sssd|systemd|tuned|user@)' | awk '{print $1}' | sed 's/.service//' | sort) <(systemctl list-unit-files --type=service --state=enabled --no-pager --no-legend 2>/dev/null | awk '{print $1}' | sed 's/.service//' | sort) 2>/dev/null || echo none",
|
||||
"listening_ports": "ss -tlnp 2>/dev/null | grep LISTEN | grep -vE ':22 |:111 |:323 ' | awk '{print $4, $6}' | sort",
|
||||
"db_detect": "for svc in postgresql mariadbd mysqld mongod redis-server; do state=$(systemctl is-active $svc 2>/dev/null); [ \"$state\" = \"active\" ] && echo \"$svc:active\"; done; pgrep -x ora_pmon >/dev/null 2>&1 && echo 'oracle:active' || true",
|
||||
"cluster_detect": "(which pcs 2>/dev/null && pcs status 2>/dev/null | head -3) || (test -f /etc/corosync/corosync.conf && echo 'corosync:present') || echo 'no_cluster'",
|
||||
"containers": "if which podman >/dev/null 2>&1; then USERS=$(ps aux 2>/dev/null | grep -E 'conmon|podman' | grep -v grep | awk '{print $1}' | sort -u); for U in $USERS; do echo \"=== podman@$U ===\"; su - $U -c 'podman ps -a --format \"table {{.Names}} {{.Status}}\"' 2>/dev/null; done; fi; if which docker >/dev/null 2>&1; then docker ps -a --format 'table {{.Names}} {{.Status}}' 2>/dev/null; fi",
|
||||
"agents": "for svc in qualys-cloud-agent sentinelone zabbix-agent; do state=$(systemctl is-active $svc 2>/dev/null); [ \"$state\" = \"active\" ] && echo \"$svc:$state\"; done",
|
||||
"failed_services": "systemctl list-units --type=service --state=failed --no-pager --no-legend 2>/dev/null | awk '{print $2}' | head -10 || echo none",
|
||||
"satellite": "subscription-manager identity 2>/dev/null | grep -i 'org\\|server' || echo 'not_registered'",
|
||||
}
|
||||
|
||||
BANNER_FILTERS = [
|
||||
"GROUPE SANEF", "propriété du Groupe", "accèderait", "emprisonnement",
|
||||
"Article 323", "code pénal", "Authorized uses only", "CyberArk",
|
||||
"This session", "session is being",
|
||||
]
|
||||
|
||||
|
||||
def _resolve(hostname):
|
||||
for suffix in DNS_SUFFIXES:
|
||||
target = hostname + suffix
|
||||
try:
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
sock.settimeout(5)
|
||||
r = sock.connect_ex((target, 22))
|
||||
sock.close()
|
||||
if r == 0:
|
||||
return target
|
||||
except Exception:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def _connect(target):
|
||||
if not PARAMIKO_OK:
|
||||
return None
|
||||
import os
|
||||
if not os.path.exists(SSH_KEY):
|
||||
return None
|
||||
for loader in [paramiko.RSAKey.from_private_key_file, paramiko.Ed25519Key.from_private_key_file]:
|
||||
try:
|
||||
key = loader(SSH_KEY)
|
||||
client = paramiko.SSHClient()
|
||||
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
client.connect(target, port=22, username=SSH_USER, pkey=key,
|
||||
timeout=SSH_TIMEOUT, look_for_keys=False, allow_agent=False)
|
||||
return client
|
||||
except Exception:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def _run(client, cmd):
|
||||
try:
|
||||
full = f"sudo bash -c '{cmd}'"
|
||||
_, stdout, stderr = client.exec_command(full, timeout=15)
|
||||
out = stdout.read().decode("utf-8", errors="replace").strip()
|
||||
lines = [l for l in out.splitlines() if not any(b in l for b in BANNER_FILTERS) and l.strip()]
|
||||
return "\n".join(lines).strip()
|
||||
except Exception as e:
|
||||
return f"ERROR: {e}"
|
||||
|
||||
|
||||
def audit_single_server(hostname):
|
||||
"""Audite un serveur et retourne un dict de resultats"""
|
||||
result = {
|
||||
"hostname": hostname,
|
||||
"audit_date": datetime.now().strftime("%Y-%m-%d %H:%M"),
|
||||
"status": "PENDING",
|
||||
}
|
||||
|
||||
target = _resolve(hostname)
|
||||
if not target:
|
||||
result["status"] = "CONNECTION_FAILED"
|
||||
result["connection_method"] = f"DNS: aucun suffixe résolu ({hostname})"
|
||||
result["resolved_fqdn"] = None
|
||||
return result
|
||||
|
||||
result["resolved_fqdn"] = target
|
||||
client = _connect(target)
|
||||
if not client:
|
||||
result["status"] = "CONNECTION_FAILED"
|
||||
result["connection_method"] = f"SSH: connexion refusée ({target})"
|
||||
return result
|
||||
|
||||
result["status"] = "OK"
|
||||
result["connection_method"] = f"ssh_key ({SSH_USER}@{target})"
|
||||
|
||||
for key, cmd in AUDIT_CMDS.items():
|
||||
result[key] = _run(client, cmd)
|
||||
|
||||
try:
|
||||
client.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Post-traitement
|
||||
agents = result.get("agents", "")
|
||||
result["qualys_active"] = "qualys" in agents and "active" in agents
|
||||
result["sentinelone_active"] = "sentinelone" in agents and "active" in agents
|
||||
result["disk_alert"] = False
|
||||
for line in (result.get("disk_space") or "").split("\n"):
|
||||
parts = line.split()
|
||||
pcts = [p for p in parts if "%" in p]
|
||||
if pcts:
|
||||
try:
|
||||
pct = int(pcts[0].replace("%", ""))
|
||||
if pct >= 90:
|
||||
result["disk_alert"] = True
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def audit_servers_list(hostnames):
|
||||
"""Audite une liste de serveurs"""
|
||||
results = []
|
||||
for hn in hostnames:
|
||||
r = audit_single_server(hn.strip())
|
||||
results.append(r)
|
||||
return results
|
||||
|
||||
|
||||
def save_audit_to_db(db, results):
|
||||
"""Sauvegarde/met a jour les resultats d'audit en base"""
|
||||
updated = 0
|
||||
inserted = 0
|
||||
for r in results:
|
||||
hostname = r.get("hostname", "")
|
||||
if not hostname:
|
||||
continue
|
||||
|
||||
# Trouver server_id
|
||||
srv = db.execute(text("SELECT id FROM servers WHERE LOWER(hostname) = LOWER(:h)"),
|
||||
{"h": hostname.split(".")[0]}).fetchone()
|
||||
server_id = srv.id if srv else None
|
||||
|
||||
audit_date = datetime.now()
|
||||
agents = r.get("agents", "")
|
||||
|
||||
# Upsert
|
||||
existing = db.execute(text(
|
||||
"SELECT id FROM server_audit WHERE server_id = :sid AND server_id IS NOT NULL"
|
||||
), {"sid": server_id}).fetchone() if server_id else None
|
||||
|
||||
if existing:
|
||||
db.execute(text("""
|
||||
UPDATE server_audit SET
|
||||
status = :st, connection_method = :cm, resolved_fqdn = :rf,
|
||||
os_release = :os, kernel = :k, uptime = :up, selinux = :se,
|
||||
disk_detail = :dd, disk_alert = :da,
|
||||
apps_installed = :ai, services_running = :sr,
|
||||
running_not_enabled = :rne, listening_ports = :lp,
|
||||
db_detected = :db, cluster_detected = :cl, containers = :co,
|
||||
agents = :ag, qualys_active = :qa, sentinelone_active = :s1,
|
||||
failed_services = :fs, audit_date = :ad
|
||||
WHERE id = :id
|
||||
"""), {
|
||||
"id": existing.id, "st": r.get("status"), "cm": r.get("connection_method"),
|
||||
"rf": r.get("resolved_fqdn"), "os": r.get("os_release"), "k": r.get("kernel"),
|
||||
"up": r.get("uptime"), "se": r.get("selinux"), "dd": r.get("disk_space"),
|
||||
"da": r.get("disk_alert", False), "ai": r.get("apps_installed"),
|
||||
"sr": r.get("services_running"), "rne": r.get("running_not_enabled"),
|
||||
"lp": r.get("listening_ports"), "db": r.get("db_detect"),
|
||||
"cl": r.get("cluster_detect"), "co": r.get("containers"),
|
||||
"ag": agents, "qa": r.get("qualys_active", False),
|
||||
"s1": r.get("sentinelone_active", False), "fs": r.get("failed_services"),
|
||||
"ad": audit_date,
|
||||
})
|
||||
updated += 1
|
||||
else:
|
||||
db.execute(text("""
|
||||
INSERT INTO server_audit (server_id, hostname, audit_date, status, connection_method,
|
||||
resolved_fqdn, os_release, kernel, uptime, selinux, disk_detail, disk_alert,
|
||||
apps_installed, services_running, running_not_enabled, listening_ports,
|
||||
db_detected, cluster_detected, containers, agents, qualys_active,
|
||||
sentinelone_active, failed_services)
|
||||
VALUES (:sid, :hn, :ad, :st, :cm, :rf, :os, :k, :up, :se, :dd, :da,
|
||||
:ai, :sr, :rne, :lp, :db, :cl, :co, :ag, :qa, :s1, :fs)
|
||||
"""), {
|
||||
"sid": server_id, "hn": hostname, "ad": audit_date,
|
||||
"st": r.get("status"), "cm": r.get("connection_method"),
|
||||
"rf": r.get("resolved_fqdn"), "os": r.get("os_release"), "k": r.get("kernel"),
|
||||
"up": r.get("uptime"), "se": r.get("selinux"), "dd": r.get("disk_space"),
|
||||
"da": r.get("disk_alert", False), "ai": r.get("apps_installed"),
|
||||
"sr": r.get("services_running"), "rne": r.get("running_not_enabled"),
|
||||
"lp": r.get("listening_ports"), "db": r.get("db_detect"),
|
||||
"cl": r.get("cluster_detect"), "co": r.get("containers"),
|
||||
"ag": agents, "qa": r.get("qualys_active", False),
|
||||
"s1": r.get("sentinelone_active", False), "fs": r.get("failed_services"),
|
||||
})
|
||||
inserted += 1
|
||||
|
||||
db.commit()
|
||||
return updated, inserted
|
||||
@ -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']}%"
|
||||
|
||||
|
||||
@ -1,7 +1,77 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Audit Serveurs{% endblock %}
|
||||
{% block content %}
|
||||
<h2 class="text-xl font-bold text-cyber-accent mb-4">Audit Serveurs <span class="text-sm text-gray-500">({{ stats.total }})</span></h2>
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<div>
|
||||
<h2 class="text-xl font-bold text-cyber-accent">Audit Général Linux</h2>
|
||||
<p class="text-xs text-gray-500 mt-1">
|
||||
{% if last_audit and last_audit.last_date %}
|
||||
Dernier audit : {{ last_audit.last_date.strftime('%d/%m/%Y à %H:%M') }} — {{ last_audit.count }} serveurs
|
||||
{% else %}Aucun audit réalisé{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<a href="/audit/export/csv{% if filter %}?filter={{ filter }}{% endif %}{% if search %}&search={{ search }}{% endif %}" class="btn-sm bg-cyber-green text-black">Export CSV</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Lancer audit global -->
|
||||
{% set p = perms if perms is defined else request.state.perms %}
|
||||
{% if p.audit in ('edit', 'admin') %}
|
||||
<div x-data="{ showGlobal: false }" class="card p-4 mb-4">
|
||||
<div class="flex justify-between items-center">
|
||||
<h3 class="text-sm font-bold text-cyber-accent">Lancer un audit général</h3>
|
||||
<button @click="showGlobal = !showGlobal" class="btn-sm bg-cyber-border text-cyber-accent" x-text="showGlobal ? 'Masquer' : 'Configurer'"></button>
|
||||
</div>
|
||||
<div x-show="showGlobal" class="mt-3">
|
||||
<form method="POST" action="/audit/global" class="space-y-3">
|
||||
<p class="text-xs text-gray-500">Tous les serveurs Linux en production seront audités (hors exclusions).</p>
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label class="text-xs text-gray-500">Domaines à exclure</label>
|
||||
<div class="space-y-1 mt-1">
|
||||
{% for d in domains %}
|
||||
<label class="flex items-center gap-2 text-xs text-gray-400">
|
||||
<input type="checkbox" name="exclude_domains" value="{{ d.code }}" {% if d.code == 'EMV' %}checked{% endif %}>
|
||||
{{ d.name }}
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-gray-500">Zones à exclure</label>
|
||||
<div class="space-y-1 mt-1">
|
||||
{% for z in zones %}
|
||||
<label class="flex items-center gap-2 text-xs text-gray-400">
|
||||
<input type="checkbox" name="exclude_zones" value="{{ z.name }}" {% if z.name == 'EMV' %}checked{% endif %}>
|
||||
{{ z.name }}
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-gray-500">Parallélisme</label>
|
||||
<select name="parallel" class="w-full text-xs py-1 px-2 mt-1">
|
||||
<option value="1">Séquentiel (1)</option>
|
||||
<option value="5" selected>5 en parallèle</option>
|
||||
<option value="10">10 en parallèle</option>
|
||||
<option value="20">20 en parallèle</option>
|
||||
</select>
|
||||
<p class="text-xs text-gray-600 mt-2">EMV exclu par défaut (zone PCI-DSS)</p>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn-primary px-4 py-2 text-sm" onclick="this.textContent='Audit global en cours...'; this.disabled=true; this.form.submit()">Lancer l'audit général</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% set msg = request.query_params.get('msg') %}
|
||||
{% if msg %}
|
||||
<div class="mb-3 p-2 rounded text-sm {% if msg == 'no_hosts' or msg == 'no_results' %}bg-red-900/30 text-cyber-red{% else %}bg-green-900/30 text-cyber-green{% endif %}">
|
||||
{% if msg == 'no_hosts' %}Aucun hostname saisi.{% elif msg == 'no_results' %}Pas de résultats à sauvegarder.{% elif msg.startswith('saved_') %}Base mise à jour : {{ msg.split('_')[1] }} modifié(s), {{ msg.split('_')[2] }} ajouté(s).{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- KPIs -->
|
||||
<div class="grid grid-cols-8 gap-2 mb-4">
|
||||
@ -11,7 +81,7 @@
|
||||
</a>
|
||||
<a href="/audit?filter=failed" class="card p-2 text-center hover:border-cyber-accent/50 {% if filter == 'failed' %}border-cyber-accent{% endif %}">
|
||||
<div class="text-lg font-bold text-cyber-red">{{ stats.failed }}</div>
|
||||
<div class="text-[10px] text-gray-500">Échoués</div>
|
||||
<div class="text-[10px] text-gray-500">Echoues</div>
|
||||
</a>
|
||||
<a href="/audit?filter=disk" class="card p-2 text-center hover:border-cyber-accent/50 {% if filter == 'disk' %}border-cyber-accent{% endif %}">
|
||||
<div class="text-lg font-bold text-cyber-yellow">{{ stats.disk_alerts }}</div>
|
||||
@ -25,14 +95,14 @@
|
||||
<div class="text-lg font-bold text-cyber-red">{{ stats.failed_svc }}</div>
|
||||
<div class="text-[10px] text-gray-500">Svc en echec</div>
|
||||
</a>
|
||||
<div class="card p-2 text-center">
|
||||
<div class="text-lg font-bold text-cyber-green">{{ stats.qualys_ok }}</div>
|
||||
<div class="text-[10px] text-gray-500">Qualys OK</div>
|
||||
</div>
|
||||
<div class="card p-2 text-center">
|
||||
<div class="text-lg font-bold text-cyber-green">{{ stats.s1_ok }}</div>
|
||||
<div class="text-[10px] text-gray-500">SentinelOne OK</div>
|
||||
</div>
|
||||
<a href="/audit?filter=no_qualys" class="card p-2 text-center hover:border-cyber-accent/50 {% if filter == 'no_qualys' %}border-cyber-accent{% endif %}">
|
||||
<div class="text-lg font-bold text-cyber-red">{{ stats.ok - stats.qualys_ok }}</div>
|
||||
<div class="text-[10px] text-gray-500">Qualys KO</div>
|
||||
</a>
|
||||
<a href="/audit?filter=no_s1" class="card p-2 text-center hover:border-cyber-accent/50 {% if filter == 'no_s1' %}border-cyber-accent{% endif %}">
|
||||
<div class="text-lg font-bold text-cyber-red">{{ stats.ok - stats.s1_ok }}</div>
|
||||
<div class="text-[10px] text-gray-500">S1 KO</div>
|
||||
</a>
|
||||
<div class="card p-2 text-center">
|
||||
<form method="GET" class="flex gap-1">
|
||||
<input type="text" name="search" value="{{ search or '' }}" placeholder="Hostname" class="text-xs py-1 px-2 w-full">
|
||||
@ -40,6 +110,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Panel détail -->
|
||||
<div id="audit-detail" class="card mb-4 p-5" style="display:none"></div>
|
||||
|
||||
<!-- Table -->
|
||||
<div class="card overflow-x-auto">
|
||||
<table class="w-full table-cyber">
|
||||
@ -84,6 +157,4 @@
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Panel detail -->
|
||||
<div id="audit-detail" class="card mt-4 p-5" style="display:none"></div>
|
||||
{% endblock %}
|
||||
|
||||
24
app/templates/audit_realtime.html
Normal file
24
app/templates/audit_realtime.html
Normal file
@ -0,0 +1,24 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Audit temps réel{% endblock %}
|
||||
{% block content %}
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<div>
|
||||
<a href="/audit" class="text-xs text-gray-500 hover:text-gray-300">← Audit</a>
|
||||
<h2 class="text-xl font-bold text-cyber-accent">Audit temps réel</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card p-5">
|
||||
<form method="POST" action="/audit/realtime" enctype="multipart/form-data" class="space-y-3">
|
||||
<div>
|
||||
<label class="text-xs text-gray-500">Serveurs (un par ligne ou séparés par virgule)</label>
|
||||
<textarea name="hostnames_text" rows="6" class="w-full font-mono text-xs" placeholder="vpinfaweb1 vrtrabkme1 lpemvaste1"></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-gray-500">Ou importer un fichier (.txt, un hostname par ligne)</label>
|
||||
<input type="file" name="hostnames_file" accept=".txt" class="text-xs text-gray-400">
|
||||
</div>
|
||||
<button type="submit" class="btn-primary px-4 py-2 text-sm" onclick="this.textContent='Audit en cours...'; this.disabled=true; this.form.submit()">Générer le rapport d'audit</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
120
app/templates/audit_realtime_results.html
Normal file
120
app/templates/audit_realtime_results.html
Normal file
@ -0,0 +1,120 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Résultats audit temps réel{% endblock %}
|
||||
{% block content %}
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<div>
|
||||
<a href="/audit" class="text-xs text-gray-500 hover:text-gray-300">← Retour audit</a>
|
||||
<h2 class="text-xl font-bold text-cyber-accent">Résultats audit temps réel</h2>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<form method="POST" action="/audit/realtime/save">
|
||||
<button class="btn-primary px-4 py-2 text-sm" onclick="return confirm('Mettre à jour la base avec ces résultats ?')">Mettre à jour la base</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="grid grid-cols-3 gap-3 mb-4">
|
||||
<div class="card p-3 text-center">
|
||||
<div class="text-2xl font-bold text-cyber-accent">{{ total }}</div>
|
||||
<div class="text-xs text-gray-500">Total</div>
|
||||
</div>
|
||||
<div class="card p-3 text-center">
|
||||
<div class="text-2xl font-bold text-cyber-green">{{ ok }}</div>
|
||||
<div class="text-xs text-gray-500">Connectés</div>
|
||||
</div>
|
||||
<div class="card p-3 text-center">
|
||||
<div class="text-2xl font-bold text-cyber-red">{{ failed }}</div>
|
||||
<div class="text-xs text-gray-500">Échoués</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Résultats -->
|
||||
<div class="card overflow-x-auto">
|
||||
<table class="w-full table-cyber text-xs">
|
||||
<thead><tr>
|
||||
<th class="text-left p-2">Hostname</th>
|
||||
<th class="p-2">Statut</th>
|
||||
<th class="p-2">FQDN résolu</th>
|
||||
<th class="p-2">OS</th>
|
||||
<th class="p-2">Kernel</th>
|
||||
<th class="p-2">Disque</th>
|
||||
<th class="p-2">Qualys</th>
|
||||
<th class="p-2">S1</th>
|
||||
<th class="p-2">Services</th>
|
||||
<th class="p-2">Sans auto</th>
|
||||
<th class="p-2">BDD</th>
|
||||
<th class="p-2">Containers</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{% for r in results %}
|
||||
<tr class="{% if r.status != 'OK' %}bg-red-900/10{% elif r.disk_alert %}bg-yellow-900/10{% endif %}">
|
||||
<td class="p-2 font-mono text-cyber-accent">{{ r.hostname }}</td>
|
||||
<td class="p-2 text-center"><span class="badge {% if r.status == 'OK' %}badge-green{% else %}badge-red{% endif %}">{{ r.status[:12] }}</span></td>
|
||||
<td class="p-2 text-center text-gray-400">{{ r.resolved_fqdn or '-' }}</td>
|
||||
<td class="p-2 text-center text-gray-400" title="{{ r.os_release or '' }}">{{ (r.os_release or '-')[:25] }}</td>
|
||||
<td class="p-2 text-center text-gray-400">{{ (r.kernel or '-')[:20] }}</td>
|
||||
<td class="p-2 text-center">
|
||||
{% if r.disk_alert %}<span class="badge badge-red">ALERTE</span>
|
||||
{% elif r.status == 'OK' %}<span class="text-cyber-green">OK</span>
|
||||
{% else %}-{% endif %}
|
||||
</td>
|
||||
<td class="p-2 text-center">{% if r.qualys_active %}<span class="text-cyber-green">OK</span>{% elif r.status == 'OK' %}<span class="text-cyber-red">KO</span>{% else %}-{% endif %}</td>
|
||||
<td class="p-2 text-center">{% if r.sentinelone_active %}<span class="text-cyber-green">OK</span>{% elif r.status == 'OK' %}<span class="text-cyber-red">KO</span>{% else %}-{% endif %}</td>
|
||||
<td class="p-2 text-center text-gray-400" title="{{ r.services_running or '' }}">{% if r.services_running %}{{ r.services_running.split('\n')|length }}{% else %}-{% endif %}</td>
|
||||
<td class="p-2 text-center">{% if r.running_not_enabled and r.running_not_enabled != 'none' %}<span class="text-cyber-yellow">{{ r.running_not_enabled.split('\n')|length }}</span>{% else %}-{% endif %}</td>
|
||||
<td class="p-2 text-center text-gray-400">{{ (r.db_detect or '-')[:15] }}</td>
|
||||
<td class="p-2 text-center text-gray-400">{{ (r.containers or '-')[:20] }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Détail par serveur -->
|
||||
{% for r in results %}
|
||||
{% if r.status == 'OK' %}
|
||||
<details class="card mt-2">
|
||||
<summary class="p-3 cursor-pointer hover:bg-cyber-border/20 font-mono text-sm text-cyber-accent">{{ r.hostname }} — {{ r.resolved_fqdn or '' }}</summary>
|
||||
<div class="p-4 grid grid-cols-2 gap-4 text-xs">
|
||||
<div>
|
||||
<h4 class="text-cyber-accent font-bold mb-1">Espace disque</h4>
|
||||
<pre class="bg-cyber-bg p-2 rounded text-gray-400 overflow-x-auto">{{ r.disk_space or 'N/A' }}</pre>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="text-cyber-accent font-bold mb-1">Applications</h4>
|
||||
<pre class="bg-cyber-bg p-2 rounded text-gray-400 overflow-x-auto">{{ r.apps_installed or 'N/A' }}</pre>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="text-cyber-accent font-bold mb-1">Services actifs</h4>
|
||||
<pre class="bg-cyber-bg p-2 rounded text-gray-400 overflow-x-auto" style="max-height:150px">{{ r.services_running or 'N/A' }}</pre>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="text-cyber-yellow font-bold mb-1">Sans auto-start</h4>
|
||||
<pre class="bg-cyber-bg p-2 rounded text-cyber-yellow overflow-x-auto">{{ r.running_not_enabled or 'Aucun' }}</pre>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="text-cyber-accent font-bold mb-1">Ports</h4>
|
||||
<pre class="bg-cyber-bg p-2 rounded text-gray-400 overflow-x-auto">{{ r.listening_ports or 'N/A' }}</pre>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="text-cyber-accent font-bold mb-1">Agents</h4>
|
||||
<pre class="bg-cyber-bg p-2 rounded text-gray-400">{{ r.agents or 'N/A' }}</pre>
|
||||
</div>
|
||||
{% if r.containers and r.containers != 'none' %}
|
||||
<div class="col-span-2">
|
||||
<h4 class="text-cyber-accent font-bold mb-1">Containers</h4>
|
||||
<pre class="bg-cyber-bg p-2 rounded text-gray-400">{{ r.containers }}</pre>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if r.failed_services and r.failed_services != 'none' %}
|
||||
<div class="col-span-2">
|
||||
<h4 class="text-cyber-red font-bold mb-1">Services en échec</h4>
|
||||
<pre class="bg-red-900/20 p-2 rounded text-cyber-red">{{ r.failed_services }}</pre>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</details>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endblock %}
|
||||
37
app/templates/audit_specific.html
Normal file
37
app/templates/audit_specific.html
Normal file
@ -0,0 +1,37 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Audit spécifique{% endblock %}
|
||||
{% block content %}
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<div>
|
||||
<a href="/audit" class="text-xs text-gray-500 hover:text-gray-300">← Audit</a>
|
||||
<h2 class="text-xl font-bold text-cyber-accent">Audit spécifique</h2>
|
||||
<p class="text-xs text-gray-500 mt-1">Auditer un ou plusieurs serveurs à la demande</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card p-5">
|
||||
<form method="POST" action="/audit/realtime" enctype="multipart/form-data" class="space-y-3">
|
||||
<div>
|
||||
<label class="text-xs text-gray-500">Serveurs (un par ligne ou séparés par virgule)</label>
|
||||
<textarea name="hostnames_text" rows="6" class="w-full font-mono text-xs" placeholder="vpinfaweb1 vrtrabkme1 lpemvaste1"></textarea>
|
||||
</div>
|
||||
<div class="flex gap-3 items-end">
|
||||
<div>
|
||||
<label class="text-xs text-gray-500">Ou importer un fichier (.txt)</label>
|
||||
<input type="file" name="hostnames_file" accept=".txt" class="text-xs text-gray-400">
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-gray-500">Parallélisme</label>
|
||||
<select name="parallel" class="text-xs py-1 px-2">
|
||||
<option value="1">Séquentiel (1)</option>
|
||||
<option value="5" selected>5 en parallèle</option>
|
||||
<option value="10">10 en parallèle</option>
|
||||
<option value="20">20 en parallèle</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-gray-600">Si plus de 10 serveurs, le parallélisme est recommandé.</p>
|
||||
<button type="submit" class="btn-primary px-4 py-2 text-sm" onclick="this.textContent='Audit en cours...'; this.disabled=true; this.form.submit()">Lancer l'audit</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@ -56,11 +56,16 @@
|
||||
{% set p = perms if perms is defined else request.state.perms %}
|
||||
<a href="/dashboard" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'dashboard' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %}">Dashboard</a>
|
||||
{% if p.servers %}<a href="/servers" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if request.url.path == '/servers' or '/servers/' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %}">Serveurs</a>{% endif %}
|
||||
{% if p.specifics %}<a href="/specifics" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'specifics' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6 text-xs">Spécifiques</a>{% endif %}
|
||||
{% if p.specifics %}<a href="/specifics" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'specifics' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6 text-xs">Specifiques</a>{% endif %}
|
||||
{% if p.campaigns %}<a href="/campaigns" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'campaigns' in request.url.path and 'assignments' not in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %}">Campagnes</a>{% endif %}
|
||||
{% if p.campaigns in ('edit', 'admin') %}<a href="/assignments" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'assignments' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6 text-xs">Assignations</a>{% endif %}
|
||||
{% if p.qualys %}<a href="/qualys/search" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if '/qualys/' in request.url.path and 'tags' not in request.url.path and 'decoder' not in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %}">Qualys</a>{% endif %}
|
||||
{% if p.qualys %}<a href="/qualys/tags" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if '/qualys/tags' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6 text-xs">Tags</a>{% endif %}
|
||||
{% if p.qualys %}<a href="/qualys/decoder" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'decoder' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6 text-xs">Décodeur</a>{% endif %}
|
||||
{% if p.planning %}<a href="/planning" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'planning' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %}">Planning</a>{% endif %}
|
||||
{% if p.audit %}<a href="/audit" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if request.url.path == '/audit' or '/audit/' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %}">Audit</a>{% endif %}
|
||||
{% if p.audit %}<a href="/audit" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if request.url.path == '/audit' %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %}">Audit</a>{% endif %}
|
||||
{% if p.audit in ('edit', 'admin') %}<a href="/audit/specific" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'specific' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6 text-xs">Spécifique</a>{% endif %}
|
||||
{% if p.servers %}<a href="/contacts" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'contacts' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %}">Contacts</a>{% endif %}
|
||||
{% if p.users %}<a href="/users" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'users' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %}">Utilisateurs</a>{% endif %}
|
||||
{% if p.settings %}<a href="/settings" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'settings' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %}">Settings</a>{% endif %}
|
||||
</nav>
|
||||
@ -71,7 +76,7 @@
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-sm text-gray-400">{{ user.sub }}</span>
|
||||
<span class="badge badge-blue">{{ user.role }}</span>
|
||||
<a href="/logout" class="btn-sm bg-cyber-border text-gray-300 hover:bg-red-900/40 hover:text-cyber-red transition-colors">Déconnexion</a>
|
||||
<a href="/logout" class="btn-sm bg-cyber-border text-gray-300 hover:bg-red-900/40 hover:text-cyber-red transition-colors">Deconnexion</a>
|
||||
</div>
|
||||
</header>
|
||||
<div class="flex flex-1 overflow-hidden">
|
||||
|
||||
101
app/templates/contacts.html
Normal file
101
app/templates/contacts.html
Normal file
@ -0,0 +1,101 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Contacts{% endblock %}
|
||||
{% block content %}
|
||||
<h2 class="text-xl font-bold text-cyber-accent mb-4">Contacts & Responsables <span class="text-sm text-gray-500">({{ contacts|length }})</span></h2>
|
||||
|
||||
{% if msg %}
|
||||
<div class="mb-3 p-2 rounded text-sm {% if msg == 'exists' %}bg-red-900/30 text-cyber-red{% else %}bg-green-900/30 text-cyber-green{% endif %}">
|
||||
{% if msg == 'added' %}Contact ajouté.{% elif msg == 'edited' %}Contact modifié.{% elif msg == 'toggled' %}Statut modifié.{% elif msg == 'deleted' %}Contact supprimé.{% elif msg == 'scope_added' %}Scope ajouté.{% elif msg == 'scope_deleted' %}Scope supprimé.{% elif msg == 'exists' %}Ce mail existe déjà.{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Filtres -->
|
||||
<div class="flex gap-2 mb-4 items-center flex-wrap">
|
||||
<a href="/contacts" class="btn-sm {% if not role_filter %}bg-cyber-accent text-black{% else %}bg-cyber-border text-gray-300{% endif %}">Tous</a>
|
||||
{% for r in roles_in_db %}
|
||||
<a href="/contacts?role={{ r }}" class="btn-sm {% if role_filter == r %}bg-cyber-accent text-black{% else %}bg-cyber-border text-gray-300{% endif %}">{{ r }}</a>
|
||||
{% endfor %}
|
||||
<form method="GET" action="/contacts" class="flex gap-1 ml-auto">
|
||||
<input type="text" name="search" value="{{ search or '' }}" placeholder="Nom ou email..." class="text-xs py-1 px-2 w-36">
|
||||
<input type="text" name="server" value="{{ server or '' }}" placeholder="Serveur..." class="text-xs py-1 px-2 w-36 font-mono">
|
||||
<button type="submit" class="btn-sm bg-cyber-border text-cyber-accent">Chercher</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{% if server %}
|
||||
<div class="mb-3 p-3 card">
|
||||
{% if server_info %}
|
||||
<div class="flex items-center gap-3 text-sm">
|
||||
<span class="text-gray-500">Contacts pour</span>
|
||||
<span class="font-mono text-cyber-accent font-bold">{{ server_info.hostname }}</span>
|
||||
<span class="badge badge-blue">{{ server_info.domain_name or '-' }}</span>
|
||||
<span class="badge {% if server_info.env_name == 'Production' %}badge-green{% else %}badge-yellow{% endif %}">{{ server_info.env_name or '-' }}</span>
|
||||
{% if server_info.app_type %}<span class="badge badge-gray">{{ server_info.app_type }}</span>{% endif %}
|
||||
{% if server_info.app_group %}<span class="text-xs text-gray-500">groupe: {{ server_info.app_group }}</span>{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<span class="text-cyber-red text-sm">Serveur "{{ server }}" non trouvé en base</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Panel détail -->
|
||||
<div id="contact-detail" class="card mb-4 p-5" style="display:none"></div>
|
||||
|
||||
<!-- Table -->
|
||||
<div class="card overflow-x-auto">
|
||||
<table class="w-full table-cyber text-sm">
|
||||
<thead><tr>
|
||||
<th class="text-left p-2">Nom</th>
|
||||
<th class="text-left p-2">Email</th>
|
||||
<th class="p-2">Rôle</th>
|
||||
<th class="text-left p-2">Scopes</th>
|
||||
<th class="p-2">Actif</th>
|
||||
<th class="p-2">Actions</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{% for c in contacts %}
|
||||
<tr class="{% if not c.is_active %}opacity-40{% endif %}">
|
||||
<td class="p-2 font-bold text-cyber-accent">{{ c.name }}</td>
|
||||
<td class="p-2 text-xs text-gray-400">{{ c.email }}</td>
|
||||
<td class="p-2 text-center"><span class="badge {% if 'responsable' in c.role %}badge-blue{% elif 'referent' in c.role %}badge-yellow{% elif 'ra_' in c.role %}badge-green{% else %}badge-gray{% endif %}">{{ c.role }}</span></td>
|
||||
<td class="p-2 text-xs text-gray-400" style="max-width:300px">{{ (c.scopes_summary or '-')[:80] }}</td>
|
||||
<td class="p-2 text-center"><span class="badge {% if c.is_active %}badge-green{% else %}badge-red{% endif %}">{{ 'Oui' if c.is_active else 'Non' }}</span></td>
|
||||
<td class="p-2 text-center">
|
||||
<div class="flex gap-1 justify-center">
|
||||
<button class="btn-sm bg-cyber-border text-cyber-accent"
|
||||
hx-get="/contacts/{{ c.id }}" hx-target="#contact-detail" hx-swap="innerHTML"
|
||||
onclick="document.getElementById('contact-detail').style.display='block'; window.scrollTo({top:0,behavior:'smooth'})">Éditer</button>
|
||||
<form method="POST" action="/contacts/{{ c.id }}/delete" style="display:inline">
|
||||
<button class="btn-sm bg-red-900/30 text-cyber-red" onclick="return confirm('Supprimer {{ c.name }} ?')">Suppr</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Ajouter -->
|
||||
<div class="card p-4 mt-4">
|
||||
<h4 class="text-sm font-bold text-cyber-accent mb-3">Ajouter un contact</h4>
|
||||
<form method="POST" action="/contacts/add" class="flex gap-3 items-end flex-wrap">
|
||||
<div>
|
||||
<label class="text-xs text-gray-500">Nom complet</label>
|
||||
<input type="text" name="name" required class="text-xs py-1 px-2 w-48">
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-gray-500">Email</label>
|
||||
<input type="email" name="email" required class="text-xs py-1 px-2 w-52">
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-gray-500">Rôle</label>
|
||||
<select name="contact_role" class="text-xs py-1 px-2">
|
||||
{% for code, label in roles %}<option value="{{ code }}">{{ label }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" class="btn-primary px-4 py-1 text-sm">Ajouter</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@ -4,7 +4,7 @@
|
||||
<h2 class="text-xl font-bold text-cyber-accent mb-4">Dashboard</h2>
|
||||
|
||||
<!-- KPIs -->
|
||||
<div class="grid grid-cols-4 gap-4 mb-6">
|
||||
<div class="grid grid-cols-5 gap-4 mb-6">
|
||||
<div class="card p-4 text-center">
|
||||
<div class="text-3xl font-bold text-cyber-accent">{{ stats.total_servers }}</div>
|
||||
<div class="text-xs text-gray-500">Serveurs</div>
|
||||
@ -21,6 +21,10 @@
|
||||
<div class="text-3xl font-bold text-cyber-yellow">{{ stats.qualys_tags }}</div>
|
||||
<div class="text-xs text-gray-500">Tags Qualys</div>
|
||||
</div>
|
||||
<div class="card p-4 text-center">
|
||||
<div class="text-3xl font-bold text-cyber-red">{{ stats.eol }}</div>
|
||||
<div class="text-xs text-gray-500">EOL</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Par domaine -->
|
||||
|
||||
85
app/templates/partials/contact_detail.html
Normal file
85
app/templates/partials/contact_detail.html
Normal file
@ -0,0 +1,85 @@
|
||||
<div>
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 class="text-lg font-bold text-cyber-accent">{{ c.name }}</h3>
|
||||
<button onclick="document.getElementById('contact-detail').style.display='none'" class="text-gray-500 hover:text-white text-xl">×</button>
|
||||
</div>
|
||||
|
||||
<!-- Édition -->
|
||||
<form method="POST" action="/contacts/{{ c.id }}/edit" class="flex gap-3 items-end mb-4">
|
||||
<div>
|
||||
<label class="text-xs text-gray-500">Nom</label>
|
||||
<input type="text" name="name" value="{{ c.name }}" class="text-xs py-1 px-2 w-44">
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-gray-500">Email</label>
|
||||
<input type="email" name="email" value="{{ c.email }}" class="text-xs py-1 px-2 w-52">
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-gray-500">Rôle</label>
|
||||
<select name="contact_role" class="text-xs py-1 px-2">
|
||||
{% for code, label in roles %}<option value="{{ code }}" {% if c.role == code %}selected{% endif %}>{{ label }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" class="btn-sm bg-cyber-accent text-black">Modifier</button>
|
||||
<form method="POST" action="/contacts/{{ c.id }}/toggle" style="display:inline">
|
||||
<button type="submit" class="btn-sm {% if c.is_active %}bg-red-900/30 text-cyber-red{% else %}bg-green-900/30 text-cyber-green{% endif %}">
|
||||
{{ 'Désactiver' if c.is_active else 'Activer' }}
|
||||
</button>
|
||||
</form>
|
||||
</form>
|
||||
|
||||
<!-- Scopes existants -->
|
||||
<h4 class="text-xs text-cyber-accent font-bold uppercase mb-2 border-b border-cyber-border pb-1">Périmètre de responsabilité</h4>
|
||||
<div class="space-y-1 mb-3">
|
||||
{% for s in scopes %}
|
||||
<div class="flex items-center gap-2 text-xs">
|
||||
<span class="badge {% if s.scope_type == 'domain' %}badge-blue{% elif s.scope_type == 'application' %}badge-yellow{% elif s.scope_type == 'server' %}badge-red{% else %}badge-gray{% endif %}">{{ s.scope_type }}</span>
|
||||
<span class="font-mono text-cyber-accent">{{ s.scope_value }}</span>
|
||||
{% if s.env_scope != 'all' %}<span class="badge badge-green">{{ s.env_scope }}</span>{% endif %}
|
||||
<form method="POST" action="/contacts/scope/{{ s.id }}/delete" style="display:inline">
|
||||
<button class="text-gray-600 hover:text-cyber-red text-xs" onclick="return confirm('Supprimer ?')">×</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% if not scopes %}<p class="text-xs text-gray-500">Aucun scope défini</p>{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Ajouter scope -->
|
||||
<form method="POST" action="/contacts/{{ c.id }}/scope/add" class="flex gap-2 items-end mb-4">
|
||||
<div>
|
||||
<label class="text-xs text-gray-500">Type</label>
|
||||
<select name="scope_type" class="text-xs py-1 px-2">
|
||||
{% for code, label in scope_types %}<option value="{{ code }}">{{ label }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-gray-500">Valeur</label>
|
||||
<input type="text" name="scope_value" class="text-xs py-1 px-2 w-36" required list="scope-values" placeholder="EMV, COMMVAULT...">
|
||||
<datalist id="scope-values">
|
||||
{% for d in domains %}<option value="{{ d.code }}">{{ d.name }}</option>{% endfor %}
|
||||
{% for a in app_types %}<option value="{{ a }}">{{ a }}</option>{% endfor %}
|
||||
</datalist>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-gray-500">Env</label>
|
||||
<select name="env_scope" class="text-xs py-1 px-2">
|
||||
{% for es in env_scopes %}<option value="{{ es }}">{{ es }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" class="btn-sm bg-cyber-accent text-black">Ajouter</button>
|
||||
</form>
|
||||
|
||||
<!-- Serveurs liés -->
|
||||
{% if servers %}
|
||||
<h4 class="text-xs text-cyber-accent font-bold uppercase mb-2 border-b border-cyber-border pb-1">Serveurs concernés ({{ servers|length }})</h4>
|
||||
<div class="grid grid-cols-3 gap-1 text-xs">
|
||||
{% for s in servers %}
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="font-mono text-cyber-accent">{{ s.hostname }}</span>
|
||||
<span class="text-gray-500">{{ s.domaine or '' }}</span>
|
||||
<span class="badge {% if s.environnement == 'Production' %}badge-green{% else %}badge-yellow{% endif %} text-[9px]">{{ (s.environnement or '')[:4] }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
106
app/templates/partials/qualys_asset_detail.html
Normal file
106
app/templates/partials/qualys_asset_detail.html
Normal file
@ -0,0 +1,106 @@
|
||||
<div>
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 class="text-lg font-bold text-cyber-accent">{{ a.name or a.hostname }}</h3>
|
||||
<button onclick="document.getElementById('qualys-detail').style.display='none'" class="text-gray-500 hover:text-white text-xl">×</button>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<!-- Infos -->
|
||||
<div>
|
||||
<h4 class="text-xs text-cyber-accent font-bold uppercase mb-2 border-b border-cyber-border pb-1">Informations</h4>
|
||||
<div class="space-y-1 text-xs">
|
||||
<div><span class="text-gray-500">Hostname:</span> <span class="font-mono">{{ a.hostname }}</span></div>
|
||||
<div><span class="text-gray-500">IP:</span> <span class="font-mono text-cyber-green">{{ a.ip_address or '-' }}</span></div>
|
||||
<div><span class="text-gray-500">FQDN:</span> <span class="font-mono">{{ a.fqdn or '-' }}</span></div>
|
||||
<div><span class="text-gray-500">OS:</span> {{ a.os or '-' }}</div>
|
||||
<div><span class="text-gray-500">Agent:</span> <span class="badge {% if a.agent_status and 'ACTIVE' in a.agent_status %}badge-green{% else %}badge-red{% endif %}">{{ a.agent_status or '-' }}</span></div>
|
||||
<div><span class="text-gray-500">Version:</span> {{ a.agent_version or '-' }}</div>
|
||||
<div><span class="text-gray-500">Dernier check-in:</span> {{ a.last_checkin or '-' }}</div>
|
||||
<div><span class="text-gray-500">Qualys ID:</span> {{ a.qualys_asset_id }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Décodage -->
|
||||
<div>
|
||||
<h4 class="text-xs text-cyber-accent font-bold uppercase mb-2 border-b border-cyber-border pb-1">Décodage nomenclature</h4>
|
||||
<div class="space-y-1 text-xs">
|
||||
<div><span class="text-gray-500">Type:</span> {{ decoded.type }}</div>
|
||||
<div><span class="text-gray-500">Environnement:</span> {{ decoded.env }}</div>
|
||||
<div><span class="text-gray-500">Domaine:</span> {{ decoded.domain }}</div>
|
||||
</div>
|
||||
<h4 class="text-xs text-cyber-accent font-bold uppercase mt-3 mb-1">Tags suggérés</h4>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{% for t in decoded.tags %}
|
||||
{% set found = false %}
|
||||
{% for ct in tags %}{% if ct.name == t %}{% set found = true %}{% endif %}{% endfor %}
|
||||
<span class="badge {% if found %}badge-green{% else %}badge-red{% endif %}" title="{% if found %}Assigné{% else %}Manquant{% endif %}">{{ t }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tags assignés -->
|
||||
<div class="mt-4">
|
||||
<h4 class="text-xs text-cyber-accent font-bold uppercase mb-2 border-b border-cyber-border pb-1">Tags assignés ({{ tags|length }})</h4>
|
||||
<div class="space-y-1">
|
||||
{% for t in tags %}
|
||||
<div class="flex items-center gap-2 text-xs">
|
||||
<span class="badge {% if t.is_dynamic %}badge-blue{% else %}badge-yellow{% endif %}">{{ 'DYN' if t.is_dynamic else 'STAT' }}</span>
|
||||
<span class="font-mono text-cyber-accent">{{ t.name }}</span>
|
||||
{% if not t.is_dynamic %}
|
||||
<form method="POST" action="/qualys/asset/{{ a.qualys_asset_id }}/tag/remove" style="display:inline"
|
||||
hx-post="/qualys/asset/{{ a.qualys_asset_id }}/tag/remove" hx-target="#tag-result" hx-swap="innerHTML">
|
||||
<input type="hidden" name="tag_id" value="{{ t.qualys_tag_id }}">
|
||||
<button class="text-gray-600 hover:text-cyber-red" title="Retirer ce tag">✕</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% if not tags %}<span class="text-xs text-gray-500">Aucun tag</span>{% endif %}
|
||||
</div>
|
||||
<div id="tag-result" class="mt-1"></div>
|
||||
</div>
|
||||
|
||||
<!-- Ajouter un tag -->
|
||||
<div class="mt-4">
|
||||
<h4 class="text-xs text-cyber-accent font-bold uppercase mb-2 border-b border-cyber-border pb-1">Ajouter un tag</h4>
|
||||
<form hx-post="/qualys/asset/{{ a.qualys_asset_id }}/tag/add" hx-target="#tag-result" hx-swap="innerHTML" class="flex gap-2 items-center">
|
||||
<input type="hidden" name="tag_id" id="add-tag-id-{{ a.qualys_asset_id }}">
|
||||
<input type="text" id="add-tag-input-{{ a.qualys_asset_id }}" class="text-xs py-1 px-2 flex-1 font-mono" placeholder="Filtrer et sélectionner un tag..." autocomplete="off"
|
||||
onkeyup="filterTags(this, '{{ a.qualys_asset_id }}')" onfocus="document.getElementById('tag-dropdown-{{ a.qualys_asset_id }}').style.display='block'">
|
||||
<button type="submit" class="btn-sm bg-cyber-accent text-black">Ajouter</button>
|
||||
</form>
|
||||
<div id="tag-dropdown-{{ a.qualys_asset_id }}" class="bg-cyber-card border border-cyber-border rounded mt-1 overflow-y-auto text-xs" style="display:none; max-height:150px">
|
||||
{% set assigned_ids = tags | map(attribute='qualys_tag_id') | list %}
|
||||
{% for t in all_tags %}
|
||||
{% if t.qualys_tag_id not in assigned_ids %}
|
||||
<div class="px-2 py-1 hover:bg-cyber-border/30 cursor-pointer tag-option" data-id="{{ t.qualys_tag_id }}" data-name="{{ t.name }}"
|
||||
onclick="selectTag('{{ a.qualys_asset_id }}', '{{ t.qualys_tag_id }}', '{{ t.name }}')">
|
||||
<span class="font-mono">{{ t.name }}</span>
|
||||
<span class="badge {% if t.is_dynamic %}badge-blue{% else %}badge-yellow{% endif %} text-[8px] ml-1">{{ 'DYN' if t.is_dynamic else 'STAT' }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
function filterTags(input, aid) {
|
||||
var q = input.value.toLowerCase();
|
||||
var dd = document.getElementById('tag-dropdown-' + aid);
|
||||
dd.style.display = 'block';
|
||||
dd.querySelectorAll('.tag-option').forEach(function(el) {
|
||||
el.style.display = el.dataset.name.toLowerCase().includes(q) ? '' : 'none';
|
||||
});
|
||||
}
|
||||
function selectTag(aid, tid, tname) {
|
||||
document.getElementById('add-tag-id-' + aid).value = tid;
|
||||
document.getElementById('add-tag-input-' + aid).value = tname;
|
||||
document.getElementById('tag-dropdown-' + aid).style.display = 'none';
|
||||
}
|
||||
document.addEventListener('click', function(e) {
|
||||
document.querySelectorAll('[id^=tag-dropdown-]').forEach(function(dd) {
|
||||
if (!dd.contains(e.target) && !e.target.id.startsWith('add-tag-input-')) dd.style.display = 'none';
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</div>
|
||||
79
app/templates/qualys_decoder.html
Normal file
79
app/templates/qualys_decoder.html
Normal file
@ -0,0 +1,79 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Décodeur Qualys{% endblock %}
|
||||
{% block content %}
|
||||
<h2 class="text-xl font-bold text-cyber-accent mb-4">Décodeur nomenclature SANEF</h2>
|
||||
|
||||
<!-- Saisie -->
|
||||
<form method="GET" class="card p-4 mb-4 flex gap-3 items-end">
|
||||
<div class="flex-1">
|
||||
<label class="text-xs text-gray-500">Hostname à décoder</label>
|
||||
<input type="text" name="hostname" value="{{ hostname or '' }}" placeholder="vpdsiasat1, vrtrabkme1, ls-amiens..." class="w-full font-mono">
|
||||
</div>
|
||||
<button type="submit" class="btn-primary px-4 py-2 text-sm">Décoder</button>
|
||||
</form>
|
||||
|
||||
{% if result %}
|
||||
<!-- Résultat décodage -->
|
||||
<div class="card p-5 mb-4">
|
||||
<div class="flex items-center gap-4 mb-4">
|
||||
<h3 class="text-xl font-bold font-mono text-cyber-accent">{{ result.hostname }}</h3>
|
||||
<span class="badge {% if result.type == 'VM' %}badge-blue{% else %}badge-gray{% endif %}">{{ result.type }}</span>
|
||||
<span class="badge {% if result.env == 'Production' %}badge-green{% elif result.env == 'Recette' %}badge-yellow{% else %}badge-gray{% endif %}">{{ result.env }}</span>
|
||||
<span class="badge badge-blue">{{ result.domain }}</span>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-6">
|
||||
<!-- Tags suggérés -->
|
||||
<div>
|
||||
<h4 class="text-xs text-cyber-accent font-bold uppercase mb-2 border-b border-cyber-border pb-1">Tags suggérés par la nomenclature</h4>
|
||||
<div class="space-y-1">
|
||||
{% for tag in result.tags %}
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<span class="font-mono text-cyber-yellow">{{ tag }}</span>
|
||||
{% set is_auto = false %}
|
||||
{% for prefix in auto_prefixes %}
|
||||
{% if tag.startswith(prefix) %}{% set is_auto = true %}{% endif %}
|
||||
{% endfor %}
|
||||
<span class="badge {% if is_auto %}badge-blue{% else %}badge-yellow{% endif %}">{{ 'DYN' if is_auto else 'STAT' }}</span>
|
||||
{% set found = false %}
|
||||
{% for ct in current_tags %}
|
||||
{% if ct[0] == tag %}{% set found = true %}{% endif %}
|
||||
{% endfor %}
|
||||
{% if found %}
|
||||
<span class="text-cyber-green text-xs">✓ Assigné</span>
|
||||
{% else %}
|
||||
<span class="text-cyber-red text-xs">✗ Manquant</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tags actuels Qualys -->
|
||||
<div>
|
||||
<h4 class="text-xs text-cyber-accent font-bold uppercase mb-2 border-b border-cyber-border pb-1">Tags actuels dans Qualys ({{ current_tags|length }})</h4>
|
||||
{% if current_tags %}
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{% for name, is_dyn in current_tags %}
|
||||
<span class="badge {% if is_dyn %}badge-blue{% else %}badge-yellow{% endif %}">{{ name }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-xs text-gray-500">{% if hostname %}Asset non trouvé dans Qualys ou aucun tag{% else %}Saisissez un hostname{% endif %}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Légende -->
|
||||
<div class="card p-4">
|
||||
<h4 class="text-xs text-cyber-accent font-bold uppercase mb-2">Légende</h4>
|
||||
<div class="grid grid-cols-2 gap-2 text-xs">
|
||||
<div class="flex items-center gap-2"><span class="badge badge-blue">DYN</span> Tag dynamique (géré automatiquement par Qualys via règle)</div>
|
||||
<div class="flex items-center gap-2"><span class="badge badge-yellow">STAT</span> Tag statique (assignation manuelle requise)</div>
|
||||
<div class="flex items-center gap-2"><span class="text-cyber-green">✓ Assigné</span> Tag présent sur l'asset</div>
|
||||
<div class="flex items-center gap-2"><span class="text-cyber-red">✗ Manquant</span> Tag suggéré mais non assigné</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
182
app/templates/qualys_search.html
Normal file
182
app/templates/qualys_search.html
Normal file
@ -0,0 +1,182 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Recherche Qualys{% endblock %}
|
||||
{% block content %}
|
||||
<h2 class="text-xl font-bold text-cyber-accent mb-4">Recherche Assets Qualys</h2>
|
||||
|
||||
<!-- Recherche -->
|
||||
<form method="GET" class="card p-3 mb-4 flex gap-3 items-end flex-wrap">
|
||||
<div>
|
||||
<label class="text-xs text-gray-500">Champ</label>
|
||||
<select name="field" class="text-xs py-1 px-2" id="search-field" onchange="
|
||||
var inp = document.getElementById('search-input');
|
||||
var sel = document.getElementById('tag-select');
|
||||
if (this.value === 'tag') { inp.style.display='none'; sel.style.display='block'; sel.name='search'; inp.name=''; }
|
||||
else { inp.style.display='block'; sel.style.display='none'; inp.name='search'; sel.name=''; }
|
||||
">
|
||||
<option value="hostname" {% if field == 'hostname' %}selected{% endif %}>Hostname</option>
|
||||
<option value="ip" {% if field == 'ip' %}selected{% endif %}>IP</option>
|
||||
<option value="tag" {% if field == 'tag' %}selected{% endif %}>Tag</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<label class="text-xs text-gray-500">Recherche</label>
|
||||
<input type="text" id="search-input" name="search" value="{% if field != 'tag' %}{{ search or '' }}{% endif %}" placeholder="Hostname ou IP..." class="w-full" {% if field == 'tag' %}style="display:none" name=""{% endif %}>
|
||||
<select id="tag-select" {% if field == 'tag' %}name="search"{% else %}name="" style="display:none"{% endif %} class="w-full" size="1" style="{% if field != 'tag' %}display:none;{% endif %} max-height:200px; overflow-y:auto;">
|
||||
<option value="">— Choisir un tag —</option>
|
||||
{% for t in all_tags %}<option value="{{ t.name }}" {% if field == 'tag' and search == t.name %}selected{% endif %}>{{ t.name }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" class="btn-primary px-4 py-1 text-sm">Rechercher</button>
|
||||
{% if search %}<a href="/qualys/search/export?search={{ search }}&field={{ field }}" class="btn-sm bg-cyber-green text-black">Export CSV</a>{% endif %}
|
||||
</form>
|
||||
|
||||
{% set msg = request.query_params.get('msg') %}
|
||||
{% if msg %}
|
||||
<div class="mb-3 p-2 rounded text-sm bg-green-900/30 text-cyber-green">
|
||||
{% if msg.startswith('resync_') %}{{ msg.split('_')[1] }} asset(s) resynchronisé(s).{% elif msg.startswith('bulk_add_') %}Tags ajoutés: {{ msg.split('_')[2] }} OK, {{ msg.split('_')[3] }} KO.{% elif msg.startswith('bulk_rm_') %}Tags retirés: {{ msg.split('_')[2] }} OK, {{ msg.split('_')[3] }} KO.{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if search %}
|
||||
<p class="text-xs text-gray-500 mb-2">
|
||||
{% if api_msg %}{{ api_msg }}{% else %}{{ assets|length }} résultat(s){% endif %}
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
<!-- Panel détail -->
|
||||
<div id="qualys-detail" class="card mb-4 p-5" style="display:none"></div>
|
||||
|
||||
<!-- Résultats -->
|
||||
{% if assets %}
|
||||
<!-- Bulk actions -->
|
||||
{% if can_edit_qualys %}
|
||||
<div id="bulk-tag-bar" class="card p-3 mb-2 flex gap-3 items-center" style="display:none">
|
||||
<span class="text-xs text-gray-400" id="bulk-tag-count">0 sélectionné(s)</span>
|
||||
<form method="POST" action="/qualys/bulk/add-tag" class="flex gap-2 items-center relative">
|
||||
<input type="hidden" name="asset_ids" id="bulk-tag-ids">
|
||||
<input type="hidden" name="return_search" value="{{ search or '' }}">
|
||||
<input type="hidden" name="return_field" value="{{ field or 'hostname' }}">
|
||||
<input type="hidden" name="tag_id" id="bulk-add-tag-id">
|
||||
<input type="text" id="bulk-add-input" class="text-xs py-1 px-2 w-40 font-mono" placeholder="Tag à ajouter..." autocomplete="off"
|
||||
onkeyup="filterBulkTags(this, 'bulk-add-dd')" onfocus="document.getElementById('bulk-add-dd').style.display='block'">
|
||||
<div id="bulk-add-dd" class="absolute top-8 left-0 bg-cyber-card border border-cyber-border rounded overflow-y-auto text-xs z-50" style="display:none; max-height:150px; width:250px">
|
||||
{% for t in all_tags %}<div class="px-2 py-1 hover:bg-cyber-border/30 cursor-pointer bulk-tag-opt" data-name="{{ t.name }}"
|
||||
onclick="document.getElementById('bulk-add-tag-id').value='{{ t.qualys_tag_id }}'; document.getElementById('bulk-add-input').value='{{ t.name }}'; document.getElementById('bulk-add-dd').style.display='none'">
|
||||
<span class="font-mono">{{ t.name }}</span></div>{% endfor %}
|
||||
</div>
|
||||
<button type="submit" class="btn-sm bg-cyber-accent text-black">+ Ajouter</button>
|
||||
</form>
|
||||
<form method="POST" action="/qualys/bulk/remove-tag" class="flex gap-2 items-center relative">
|
||||
<input type="hidden" name="asset_ids" id="bulk-tag-ids2">
|
||||
<input type="hidden" name="return_search" value="{{ search or '' }}">
|
||||
<input type="hidden" name="return_field" value="{{ field or 'hostname' }}">
|
||||
<input type="hidden" name="tag_id" id="bulk-rm-tag-id">
|
||||
<input type="text" id="bulk-rm-input" class="text-xs py-1 px-2 w-40 font-mono" placeholder="Tag à retirer..." autocomplete="off"
|
||||
onkeyup="filterBulkTags(this, 'bulk-rm-dd')" onfocus="loadRemoveTags(); document.getElementById('bulk-rm-dd').style.display='block'">
|
||||
<div id="bulk-rm-dd" class="absolute top-8 left-0 bg-cyber-card border border-cyber-border rounded overflow-y-auto text-xs z-50" style="display:none; max-height:150px; width:250px">
|
||||
<div class="px-2 py-1 text-gray-500">Chargement...</div>
|
||||
</div>
|
||||
<button type="submit" class="btn-sm bg-red-900/30 text-cyber-red">- Retirer</button>
|
||||
</form>
|
||||
<form method="POST" action="/qualys/resync-assets" class="flex gap-2 items-center">
|
||||
<input type="hidden" name="asset_ids" id="bulk-tag-ids3">
|
||||
<input type="hidden" name="return_search" value="{{ search or '' }}">
|
||||
<input type="hidden" name="return_field" value="{{ field or 'hostname' }}">
|
||||
<button type="submit" class="btn-sm bg-cyber-border text-cyber-accent">Resync Qualys</button>
|
||||
</form>
|
||||
</div>
|
||||
<script>
|
||||
function filterBulkTags(input, ddId) {
|
||||
var q = input.value.toLowerCase();
|
||||
var dd = document.getElementById(ddId);
|
||||
dd.style.display = 'block';
|
||||
dd.querySelectorAll('.bulk-tag-opt').forEach(function(el) {
|
||||
el.style.display = el.dataset.name.toLowerCase().includes(q) ? '' : 'none';
|
||||
});
|
||||
}
|
||||
document.addEventListener('click', function(e) {
|
||||
['bulk-add-dd', 'bulk-rm-dd'].forEach(function(id) {
|
||||
var dd = document.getElementById(id);
|
||||
if (dd && !dd.contains(e.target) && e.target.id !== 'bulk-add-input' && e.target.id !== 'bulk-rm-input') dd.style.display = 'none';
|
||||
});
|
||||
});
|
||||
function loadRemoveTags() {
|
||||
var ids = document.getElementById('bulk-tag-ids2').value;
|
||||
if (!ids) return;
|
||||
fetch('/qualys/bulk/tags-for-assets?asset_ids=' + ids)
|
||||
.then(r => r.json())
|
||||
.then(tags => {
|
||||
var dd = document.getElementById('bulk-rm-dd');
|
||||
dd.innerHTML = '';
|
||||
if (tags.length === 0) { dd.innerHTML = '<div class="px-2 py-1 text-gray-500">Aucun tag STAT</div>'; return; }
|
||||
tags.forEach(function(t) {
|
||||
var div = document.createElement('div');
|
||||
div.className = 'px-2 py-1 hover:bg-cyber-border/30 cursor-pointer bulk-tag-opt';
|
||||
div.dataset.name = t.name;
|
||||
div.innerHTML = '<span class="font-mono">' + t.name + '</span> <span class="text-gray-500 text-[9px]">(' + t.count + ')</span>';
|
||||
div.onclick = function() {
|
||||
document.getElementById('bulk-rm-tag-id').value = t.id;
|
||||
document.getElementById('bulk-rm-input').value = t.name;
|
||||
dd.style.display = 'none';
|
||||
};
|
||||
dd.appendChild(div);
|
||||
});
|
||||
});
|
||||
}
|
||||
function updateBulkTag() {
|
||||
var checks = document.querySelectorAll('input[name=asset_chk]:checked');
|
||||
var bar = document.getElementById('bulk-tag-bar');
|
||||
if (checks.length > 0) {
|
||||
bar.style.display = 'flex';
|
||||
document.getElementById('bulk-tag-count').textContent = checks.length + ' sélectionné(s)';
|
||||
var ids = Array.from(checks).map(c => c.value).join(',');
|
||||
document.getElementById('bulk-tag-ids').value = ids;
|
||||
document.getElementById('bulk-tag-ids2').value = ids;
|
||||
document.getElementById('bulk-tag-ids3').value = ids;
|
||||
} else { bar.style.display = 'none'; }
|
||||
}
|
||||
</script>
|
||||
{% endif %}
|
||||
|
||||
<div class="card overflow-x-auto">
|
||||
<table class="w-full table-cyber text-xs">
|
||||
<thead><tr>
|
||||
{% if can_edit_qualys %}<th class="p-2 w-6"><input type="checkbox" onchange="document.querySelectorAll('input[name=asset_chk]').forEach(c=>c.checked=this.checked); updateBulkTag()"></th>{% endif %}
|
||||
<th class="text-left p-2">Hostname</th>
|
||||
<th class="p-2">IP</th>
|
||||
<th class="p-2">OS</th>
|
||||
<th class="p-2">Agent</th>
|
||||
<th class="text-left p-2">Tags</th>
|
||||
<th class="p-2">Actions</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{% for a in assets %}
|
||||
{% set hn = a.hostname %}
|
||||
{% set ip = a.ip_address %}
|
||||
{% set os = a.os %}
|
||||
{% set agent = a.agent_status %}
|
||||
{% set tl = a.tags_list %}
|
||||
{% set qid = a.qualys_asset_id %}
|
||||
<tr>
|
||||
{% if can_edit_qualys %}<td class="p-2 text-center" onclick="event.stopPropagation()">{% if qid %}<input type="checkbox" name="asset_chk" value="{{ qid }}" onchange="updateBulkTag()">{% endif %}</td>{% endif %}
|
||||
<td class="p-2 font-mono text-cyber-accent">{{ hn or '-' }}</td>
|
||||
<td class="p-2 text-center text-gray-400">{{ ip or '-' }}</td>
|
||||
<td class="p-2 text-center text-gray-400" title="{{ os or '' }}">{{ (os or '-')[:30] }}</td>
|
||||
<td class="p-2 text-center">
|
||||
{% if agent %}<span class="badge {% if 'ACTIVE' in agent %}badge-green{% else %}badge-gray{% endif %}">{{ agent[:10] }}</span>
|
||||
{% else %}<span class="text-gray-600 text-xs">N/A</span>{% endif %}
|
||||
</td>
|
||||
<td class="p-2 text-gray-400" style="max-width:300px">{{ (tl or '-')[:80] }}</td>
|
||||
<td class="p-2 text-center">
|
||||
{% if qid %}
|
||||
<button class="btn-sm bg-cyber-border text-cyber-accent"
|
||||
hx-get="/qualys/asset/{{ qid }}" hx-target="#qualys-detail" hx-swap="innerHTML"
|
||||
onclick="document.getElementById('qualys-detail').style.display='block'; window.scrollTo({top:0,behavior:'smooth'})">Détail</button>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
91
app/templates/qualys_tags.html
Normal file
91
app/templates/qualys_tags.html
Normal file
@ -0,0 +1,91 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Tags Qualys{% endblock %}
|
||||
{% block content %}
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="text-xl font-bold text-cyber-accent">Tags Qualys <span class="text-sm text-gray-500">({{ stats.total }} — {{ stats.dyn }} DYN / {{ stats.stat }} STAT)</span></h2>
|
||||
<div class="flex gap-2">
|
||||
{% if can_edit_qualys %}
|
||||
<form method="POST" action="/qualys/tags/resync" style="display:inline">
|
||||
<button class="btn-sm bg-cyber-border text-cyber-accent">Resync API</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<a href="/qualys/tags/export" class="btn-sm bg-cyber-green text-black">Export CSV</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if msg %}
|
||||
<div class="mb-3 p-2 rounded text-sm {% if 'error' in msg or 'ko' in msg %}bg-red-900/30 text-cyber-red{% else %}bg-green-900/30 text-cyber-green{% endif %}">
|
||||
{% if msg == 'resync_ok' %}Tags resynchronisés depuis l'API Qualys.{% elif msg == 'resync_ko' %}Erreur de synchronisation.{% elif msg == 'created' %}Tag créé.{% elif msg == 'create_error' %}Erreur création.{% elif msg == 'deleted' %}Tag supprimé.{% elif msg == 'delete_error' %}Erreur suppression.{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if can_edit_qualys %}
|
||||
<div class="card p-3 mb-4">
|
||||
<form method="POST" action="/qualys/tags/create" class="flex gap-3 items-end">
|
||||
<div class="flex-1">
|
||||
<label class="text-xs text-gray-500">Créer un tag statique</label>
|
||||
<input type="text" name="tag_name" placeholder="ex: MID-TOMCAT, BDD-MONGO" class="w-full font-mono" required>
|
||||
</div>
|
||||
<button type="submit" class="btn-primary px-4 py-1 text-sm">Créer</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Filtres -->
|
||||
<div class="flex gap-2 mb-4 items-center">
|
||||
<a href="/qualys/tags" class="btn-sm {% if not tag_type %}bg-cyber-accent text-black{% else %}bg-cyber-border text-gray-300{% endif %}">Tous ({{ stats.total }})</a>
|
||||
<a href="/qualys/tags?tag_type=dyn" class="btn-sm {% if tag_type == 'dyn' %}bg-cyber-accent text-black{% else %}bg-cyber-border text-gray-300{% endif %}">DYN ({{ stats.dyn }})</a>
|
||||
<a href="/qualys/tags?tag_type=stat" class="btn-sm {% if tag_type == 'stat' %}bg-cyber-accent text-black{% else %}bg-cyber-border text-gray-300{% endif %}">STAT ({{ stats.stat }})</a>
|
||||
<form method="GET" class="flex gap-1 ml-auto">
|
||||
{% if tag_type %}<input type="hidden" name="tag_type" value="{{ tag_type }}">{% endif %}
|
||||
<input type="text" name="search" value="{{ search or '' }}" placeholder="Filtrer..." class="text-xs py-1 px-2 w-44">
|
||||
<button type="submit" class="btn-sm bg-cyber-border text-cyber-accent">Chercher</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<div class="card overflow-x-auto">
|
||||
<table class="w-full table-cyber text-sm">
|
||||
<thead><tr>
|
||||
<th class="text-left p-2">Nom actuel</th>
|
||||
<th class="p-2">Type</th>
|
||||
<th class="text-left p-2">Nom V3</th>
|
||||
<th class="p-2">Type V3</th>
|
||||
<th class="p-2">ID Qualys</th>
|
||||
<th class="p-2">Assets</th>
|
||||
{% if can_edit_qualys %}<th class="p-2">Action</th>{% endif %}
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{% for t in tags %}
|
||||
<tr class="{% if t.v3_name and t.v3_name != t.name %}bg-yellow-900/5{% endif %}">
|
||||
<td class="p-2 font-mono text-cyber-accent">{{ t.name }}</td>
|
||||
<td class="p-2 text-center"><span class="badge {% if t.is_dynamic %}badge-blue{% else %}badge-yellow{% endif %}">{{ 'DYN' if t.is_dynamic else 'STAT' }}</span></td>
|
||||
<td class="p-2 font-mono text-xs {% if t.v3_name and t.v3_name != t.name %}text-cyber-green{% else %}text-gray-500{% endif %}">{{ t.v3_name or '-' }}</td>
|
||||
<td class="p-2 text-center">{% if t.v3_type %}<span class="badge {% if t.v3_type == 'DYN' %}badge-blue{% else %}badge-yellow{% endif %} text-[9px]">{{ t.v3_type }}</span>{% else %}-{% endif %}</td>
|
||||
<td class="p-2 text-center text-xs text-gray-500">{{ t.qualys_tag_id }}</td>
|
||||
<td class="p-2 text-center">
|
||||
{% if t.asset_count > 0 %}
|
||||
<a href="/qualys/search?field=tag&search={{ t.name }}" class="text-cyber-accent hover:underline">{{ t.asset_count }}</a>
|
||||
{% else %}<span class="text-gray-600">0</span>{% endif %}
|
||||
</td>
|
||||
{% if can_edit_qualys %}
|
||||
<td class="p-2 text-center">
|
||||
{% if not t.is_dynamic %}
|
||||
<form method="POST" action="/qualys/tags/{{ t.qualys_tag_id }}/delete" style="display:inline">
|
||||
<button class="btn-sm bg-red-900/30 text-cyber-red" onclick="return confirm('Supprimer {{ t.name }} ?')">Suppr</button>
|
||||
</form>
|
||||
{% else %}<span class="text-xs text-gray-600">Auto</span>{% endif %}
|
||||
</td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="card p-3 mt-4 text-xs text-gray-500">
|
||||
<strong>Légende V3 :</strong> Les tags surlignés ont un nom V3 différent du nom actuel (migration à prévoir).
|
||||
<span class="text-cyber-green font-mono ml-2">Vert</span> = nouveau nom V3 proposé.
|
||||
Cliquez sur le nombre d'assets pour voir les serveurs associés.
|
||||
</div>
|
||||
{% endblock %}
|
||||
@ -36,12 +36,60 @@
|
||||
{% for t in ['tier0','tier1','tier2','tier3'] %}<option value="{{ t }}" {% if filters.tier == t %}selected{% endif %}>{{ t }}</option>{% endfor %}
|
||||
</select>
|
||||
<select name="etat" onchange="this.form.submit()"><option value="">Etat</option>
|
||||
{% for e in ['en_production','en_implementation','decommissionne'] %}<option value="{{ e }}" {% if filters.etat == e %}selected{% endif %}>{{ e }}</option>{% endfor %}
|
||||
{% for e in ['en_production','en_implementation','en_decommissionnement','decommissionne','eol'] %}<option value="{{ e }}" {% if filters.etat == e %}selected{% endif %}>{{ e }}</option>{% endfor %}
|
||||
</select>
|
||||
<button type="submit" class="btn-primary px-3 py-1 text-sm">Filtrer</button>
|
||||
<a href="/servers" class="text-xs text-gray-500 hover:text-gray-300">Reset</a>
|
||||
</form>
|
||||
|
||||
<!-- Actions groupées -->
|
||||
<div id="bulk-bar" class="card p-3 mb-2 flex gap-3 items-center flex-wrap" style="display:none">
|
||||
<span class="text-xs text-gray-400" id="bulk-count">0 sélectionné(s)</span>
|
||||
<form method="POST" action="/servers/bulk" id="bulk-form" class="flex gap-2 items-center flex-wrap">
|
||||
<input type="hidden" name="server_ids" id="bulk-ids">
|
||||
<select name="bulk_field" class="text-xs py-1 px-2" id="bulk-field">
|
||||
<option value="">— Action —</option>
|
||||
<option value="domain_code">Domaine</option>
|
||||
<option value="env_code">Environnement</option>
|
||||
<option value="tier">Tier</option>
|
||||
<option value="etat">État</option>
|
||||
<option value="patch_os_owner">Owner</option>
|
||||
<option value="licence_support">Licence</option>
|
||||
</select>
|
||||
<select name="bulk_value" class="text-xs py-1 px-2" id="bulk-value">
|
||||
<option value="">— Valeur —</option>
|
||||
</select>
|
||||
<button type="submit" class="btn-primary px-3 py-1 text-xs">Appliquer</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const bulkValues = {
|
||||
domain_code: [{% for d in domains_list %}{v:"{{ d.code }}", l:"{{ d.name }}"},{% endfor %}],
|
||||
env_code: [{% for e in envs_list %}{v:"{{ e.code }}", l:"{{ e.name }}"},{% endfor %}],
|
||||
tier: [{v:"tier0",l:"tier0"},{v:"tier1",l:"tier1"},{v:"tier2",l:"tier2"},{v:"tier3",l:"tier3"}],
|
||||
etat: [{v:"en_production",l:"en_production"},{v:"en_implementation",l:"en_implementation"},{v:"en_decommissionnement",l:"en_decommissionnement"},{v:"decommissionne",l:"décommissionné"}],
|
||||
patch_os_owner: [{v:"secops",l:"secops"},{v:"ipop",l:"ipop"},{v:"na",l:"na"}],
|
||||
licence_support: [{v:"active",l:"active"},{v:"eol",l:"eol"},{v:"els",l:"els"}],
|
||||
};
|
||||
document.getElementById('bulk-field').addEventListener('change', function() {
|
||||
const sel = document.getElementById('bulk-value');
|
||||
sel.innerHTML = '<option value="">— Valeur —</option>';
|
||||
(bulkValues[this.value] || []).forEach(o => { sel.innerHTML += '<option value="'+o.v+'">'+o.l+'</option>'; });
|
||||
});
|
||||
function updateBulk() {
|
||||
const checks = document.querySelectorAll('input[name=srv]:checked');
|
||||
const bar = document.getElementById('bulk-bar');
|
||||
const count = document.getElementById('bulk-count');
|
||||
const ids = document.getElementById('bulk-ids');
|
||||
if (checks.length > 0) {
|
||||
bar.style.display = 'flex';
|
||||
count.textContent = checks.length + ' sélectionné(s)';
|
||||
ids.value = Array.from(checks).map(c => c.value).join(',');
|
||||
} else { bar.style.display = 'none'; }
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Table -->
|
||||
<div id="server-table" class="card overflow-x-auto">
|
||||
<table class="w-full table-cyber">
|
||||
@ -62,7 +110,7 @@
|
||||
<tbody>
|
||||
{% for s in servers %}
|
||||
<tr id="row-{{ s.id }}" class="group" hx-get="/servers/{{ s.id }}/detail" hx-target="#detail-panel" hx-swap="innerHTML" onclick="openPanel()">
|
||||
<td class="p-2" onclick="event.stopPropagation()"><input type="checkbox" name="srv" value="{{ s.id }}"></td>
|
||||
<td class="p-2" onclick="event.stopPropagation()"><input type="checkbox" name="srv" value="{{ s.id }}" onchange="updateBulk()"></td>
|
||||
<td class="p-2 font-mono text-sm text-cyber-accent">{{ s.hostname }}</td>
|
||||
<td class="p-2 text-center text-xs">{{ s.domaine or '-' }}</td>
|
||||
<td class="p-2 text-center"><span class="badge {% if s.environnement == 'Production' %}badge-green{% elif s.environnement == 'Recette' %}badge-yellow{% else %}badge-gray{% endif %}">{{ (s.environnement or '-')[:6] }}</span></td>
|
||||
@ -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();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
|
||||
{% if saved %}
|
||||
<div class="mb-4 p-3 rounded bg-green-900/30 text-cyber-green text-sm">
|
||||
Section "{{ saved }}" sauvegardée.
|
||||
Section "{{ saved }}" sauvegardee.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@ -42,9 +42,15 @@
|
||||
<input type="password" name="qualys_pass" value="{{ vals.qualys_pass }}" class="w-full" {% if not editable.qualys %}disabled{% endif %}>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-gray-500">Proxy</label>
|
||||
<input type="text" name="qualys_proxy" value="{{ vals.qualys_proxy }}" placeholder="http://proxy.sanef.fr:8080" class="w-full font-mono text-xs" {% if not editable.qualys %}disabled{% endif %}>
|
||||
<div class="flex gap-3 items-end">
|
||||
<div class="flex-1">
|
||||
<label class="text-xs text-gray-500">Proxy</label>
|
||||
<input type="text" name="qualys_proxy" value="{{ vals.qualys_proxy }}" placeholder="http://proxy.sanef.fr:8080" class="w-full font-mono text-xs" {% if not editable.qualys %}disabled{% endif %}>
|
||||
</div>
|
||||
<label class="flex items-center gap-2 text-xs text-gray-400 pb-1">
|
||||
<input type="checkbox" name="qualys_bypass_proxy" value="true" {% if vals.qualys_bypass_proxy == 'true' %}checked{% endif %} {% if not editable.qualys %}disabled{% endif %}>
|
||||
Bypass proxy (accès direct)
|
||||
</label>
|
||||
</div>
|
||||
{% if editable.qualys %}<button type="submit" class="btn-primary px-4 py-2 text-sm">Sauvegarder</button>{% endif %}
|
||||
</form>
|
||||
@ -93,7 +99,7 @@
|
||||
<label class="text-xs text-gray-500">Password par defaut</label>
|
||||
<input type="password" name="ssh_pwd_default_pass" value="{{ vals.ssh_pwd_default_pass }}" class="w-full" {% if not editable.ssh_pwd %}disabled{% endif %}>
|
||||
</div>
|
||||
<p class="text-xs text-gray-600">Pour les environnements recette sans cle SSH. Chaque opérateur peut configurer son propre compte.</p>
|
||||
<p class="text-xs text-gray-600">Pour les environnements recette sans cle SSH. Chaque operateur peut configurer son propre compte.</p>
|
||||
{% if editable.ssh_pwd %}<button type="submit" class="btn-primary px-4 py-2 text-sm">Sauvegarder</button>{% endif %}
|
||||
</form>
|
||||
</div>
|
||||
@ -134,7 +140,7 @@
|
||||
<input type="text" name="psmp_default_safe" value="{{ vals.psmp_default_safe }}" class="w-full" {% if not editable.ssh_psmp %}disabled{% endif %}>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-gray-600">Auth keyboard-interactive. Chaque opérateur configure son propre compte CyberArk. MDP saisi en session.</p>
|
||||
<p class="text-xs text-gray-600">Auth keyboard-interactive. Chaque operateur configure son propre compte CyberArk. MDP saisi en session.</p>
|
||||
{% if editable.ssh_psmp %}<button type="submit" class="btn-primary px-4 py-2 text-sm">Sauvegarder</button>{% endif %}
|
||||
</form>
|
||||
</div>
|
||||
@ -228,7 +234,7 @@
|
||||
<td class="p-2 text-center">
|
||||
{% if vc.is_active %}
|
||||
<form method="POST" action="/settings/vcenter/{{ vc.id }}/delete" style="display:inline">
|
||||
<button type="submit" class="btn-sm bg-red-900/30 text-cyber-red" onclick="return confirm('Désactiver ce vCenter ?')">Désactiver</button>
|
||||
<button type="submit" class="btn-sm bg-red-900/30 text-cyber-red" onclick="return confirm('Desactiver ce vCenter ?')">Desactiver</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</td>
|
||||
@ -298,7 +304,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-gray-500">Vérifier SSL (true/false)</label>
|
||||
<label class="text-xs text-gray-500">Verifier SSL (true/false)</label>
|
||||
<input type="text" name="splunk_verify_ssl" value="{{ vals.splunk_verify_ssl }}" placeholder="true" class="w-full" {% if not editable.splunk %}disabled{% endif %}>
|
||||
</div>
|
||||
<p class="text-xs text-gray-600">Envoie les evenements de patching vers Splunk via HEC.</p>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user