diff --git a/app/main.py b/app/main.py
index 0cfd835..f4da7a5 100644
--- a/app/main.py
+++ b/app/main.py
@@ -6,7 +6,7 @@ from starlette.middleware.base import BaseHTTPMiddleware
from .config import APP_NAME, APP_VERSION
from .dependencies import get_current_user, get_user_perms
from .database import SessionLocal, SessionLocalDemo
-from .routers import auth, dashboard, servers, settings, users, campaigns, planning, specifics, audit, contacts, qualys, quickwin, referentiel, patching, applications
+from .routers import auth, dashboard, servers, settings, users, campaigns, planning, specifics, audit, contacts, qualys, qualys_tags, quickwin, referentiel, patching, applications
class PermissionsMiddleware(BaseHTTPMiddleware):
@@ -60,6 +60,7 @@ app.include_router(specifics.router)
app.include_router(audit.router)
app.include_router(contacts.router)
app.include_router(qualys.router)
+app.include_router(qualys_tags.router)
app.include_router(quickwin.router)
app.include_router(referentiel.router)
app.include_router(patching.router)
diff --git a/app/routers/qualys_tags.py b/app/routers/qualys_tags.py
new file mode 100644
index 0000000..9e546c2
--- /dev/null
+++ b/app/routers/qualys_tags.py
@@ -0,0 +1,105 @@
+"""Router Qualys Tags V3 — liste, gap analysis, creation"""
+from fastapi import APIRouter, Request, Depends, Form
+from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
+from fastapi.templating import Jinja2Templates
+from ..dependencies import get_db, get_current_user, get_user_perms, can_view, can_edit
+from ..services.qualys_tags_service import (
+ list_qualys_tags, analyze_gap, create_static_tag,
+ delete_tag, generate_console_steps, load_catalog,
+)
+from ..config import APP_NAME
+
+router = APIRouter()
+templates = Jinja2Templates(directory="app/templates")
+
+
+@router.get("/qualys/tagsv3", response_class=HTMLResponse)
+def tags_list_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_view(perms, "qualys"):
+ return RedirectResponse(url="/dashboard")
+
+ result = list_qualys_tags(db)
+ tags = result.get("tags", [])
+ tags.sort(key=lambda t: (t["type"], t["name"]))
+ stats = {
+ "total": len(tags),
+ "dyn": sum(1 for t in tags if t["type"] == "DYN"),
+ "stat": sum(1 for t in tags if t["type"] == "STAT"),
+ }
+ return templates.TemplateResponse("qualys_tagsv3.html", {
+ "request": request, "user": user, "app_name": APP_NAME,
+ "tags": tags, "stats": stats,
+ "error": result.get("msg") if not result.get("ok") else None,
+ })
+
+
+@router.get("/qualys/tagsv3/gap", response_class=HTMLResponse)
+def tags_gap_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_view(perms, "qualys"):
+ return RedirectResponse(url="/dashboard")
+
+ gap = analyze_gap(db)
+ console_steps = generate_console_steps(gap.get("missing_dyn", [])) if gap.get("ok") else []
+ can_modify = can_edit(perms, "qualys")
+ return templates.TemplateResponse("qualys_tagsv3_gap.html", {
+ "request": request, "user": user, "app_name": APP_NAME,
+ "gap": gap, "console_steps": console_steps, "can_modify": can_modify,
+ })
+
+
+@router.post("/qualys/tagsv3/create-static")
+def tags_create_static(request: Request, db=Depends(get_db),
+ name: str = Form(...), color: str = Form(""),
+ description: str = Form("")):
+ user = get_current_user(request)
+ if not user:
+ return RedirectResponse(url="/login")
+ perms = get_user_perms(db, user)
+ if not can_edit(perms, "qualys"):
+ return RedirectResponse(url="/qualys/tagsv3/gap?msg=denied")
+ r = create_static_tag(db, name, color, description)
+ msg = "created" if r.get("ok") else f"error_{r.get('msg', 'unknown')[:30]}"
+ return RedirectResponse(url=f"/qualys/tagsv3/gap?msg={msg}", status_code=303)
+
+
+@router.post("/qualys/tagsv3/create-all-static")
+def tags_create_all_static(request: Request, db=Depends(get_db)):
+ """Cree tous les tags STAT manquants en un coup."""
+ user = get_current_user(request)
+ if not user:
+ return RedirectResponse(url="/login")
+ perms = get_user_perms(db, user)
+ if not can_edit(perms, "qualys"):
+ return RedirectResponse(url="/qualys/tagsv3/gap?msg=denied")
+
+ gap = analyze_gap(db)
+ if not gap.get("ok"):
+ return RedirectResponse(url="/qualys/tagsv3/gap?msg=qualys_err", status_code=303)
+
+ created = failed = 0
+ for t in gap.get("missing_stat", []):
+ r = create_static_tag(db, t["name"], t["color"], t.get("description", ""))
+ if r.get("ok"):
+ created += 1
+ else:
+ failed += 1
+ return RedirectResponse(
+ url=f"/qualys/tagsv3/gap?msg=bulk_created_{created}_failed_{failed}",
+ status_code=303,
+ )
+
+
+@router.get("/qualys/tagsv3/catalog", response_class=JSONResponse)
+def tags_catalog_json(request: Request, db=Depends(get_db)):
+ user = get_current_user(request)
+ if not user:
+ return JSONResponse({"error": "Non autorise"}, status_code=403)
+ return load_catalog()
diff --git a/app/services/qualys_tags_service.py b/app/services/qualys_tags_service.py
new file mode 100644
index 0000000..f292fd4
--- /dev/null
+++ b/app/services/qualys_tags_service.py
@@ -0,0 +1,192 @@
+"""Service Qualys Tags V3 — CRUD + analyse gap vs catalogue.
+
+Limitations API Qualys (confirmées par la V3) :
+- Tag dynamique : creation impossible via API -> UI console uniquement
+- Tag statique : creation + assignment OK via API
+- Lecture tous tags (avec ruleType DYNAMIC/STATIC) OK
+"""
+import os
+import requests
+from pathlib import Path
+from sqlalchemy import text
+
+# Path vers le catalogue V3
+CATALOG_PATH = Path(__file__).parent.parent.parent / "deploy" / "qualys_tags_v3.yaml"
+
+
+def _get_creds(db):
+ """Retourne (url, user, pass, proxy) depuis app_secrets."""
+ from .secrets_service import get_secret
+ return (
+ get_secret(db, "qualys_url"),
+ get_secret(db, "qualys_user"),
+ get_secret(db, "qualys_pass"),
+ get_secret(db, "qualys_proxy"),
+ )
+
+
+def _qualys_post(db, endpoint, payload, timeout=60):
+ """POST sur Qualys QPS REST 2.0."""
+ url, user, pwd, proxy = _get_creds(db)
+ if not user:
+ return {"ok": False, "msg": "Credentials Qualys non configures"}
+ proxies = {"https": proxy, "http": proxy} if proxy else None
+ try:
+ r = requests.post(
+ f"{url}{endpoint}",
+ json=payload, auth=(user, pwd),
+ verify=False, timeout=timeout, proxies=proxies,
+ headers={"X-Requested-With": "PatchCenter", "Content-Type": "application/json"},
+ )
+ return {"ok": r.status_code == 200, "status": r.status_code, "text": r.text}
+ except Exception as e:
+ return {"ok": False, "msg": str(e)[:200]}
+
+
+def _parse_xml_text(text_block, tag):
+ """Extrait valeur (premier match)."""
+ import re
+ m = re.search(f"<{tag}>(.*?){tag}>", text_block, re.DOTALL)
+ return m.group(1).strip() if m else ""
+
+
+def list_qualys_tags(db):
+ """Liste tous les tags Qualys (nom, id, type DYN/STAT, ruleText)."""
+ # search all tags
+ payload = {"ServiceRequest": {"preferences": {"limitResults": 1000}}}
+ r = _qualys_post(db, "/qps/rest/2.0/search/am/tag", payload)
+ if not r.get("ok"):
+ return {"ok": False, "msg": r.get("msg") or f"HTTP {r.get('status')}", "tags": []}
+ tags = []
+ for block in r["text"].split("")[1:]:
+ block = block.split("")[0]
+ tid = _parse_xml_text(block, "id")
+ name = _parse_xml_text(block, "name")
+ rule_type = _parse_xml_text(block, "ruleType")
+ rule_text = _parse_xml_text(block, "ruleText")
+ color = _parse_xml_text(block, "color")
+ if tid and name:
+ tags.append({
+ "id": int(tid),
+ "name": name,
+ "type": "DYN" if rule_type else "STAT",
+ "rule_type": rule_type,
+ "rule_text": rule_text,
+ "color": color,
+ })
+ return {"ok": True, "tags": tags}
+
+
+def load_catalog():
+ """Charge le catalogue YAML V3."""
+ try:
+ import yaml
+ except ImportError:
+ return {"error": "pyyaml non installe (pip install pyyaml)"}
+ if not CATALOG_PATH.exists():
+ return {"error": f"Catalogue introuvable: {CATALOG_PATH}"}
+ with open(CATALOG_PATH, "r", encoding="utf-8") as f:
+ return yaml.safe_load(f)
+
+
+def analyze_gap(db):
+ """Compare catalogue V3 vs tags Qualys. Retourne missing/extra/mismatch."""
+ catalog = load_catalog()
+ if "error" in catalog:
+ return {"ok": False, "msg": catalog["error"]}
+
+ ql = list_qualys_tags(db)
+ if not ql.get("ok"):
+ return ql
+
+ qualys_by_name = {t["name"]: t for t in ql["tags"]}
+ catalog_tags = []
+ for cat_name, cat in catalog.get("categories", {}).items():
+ for t in cat.get("tags", []) or []:
+ catalog_tags.append({
+ "category": cat_name,
+ "name": t["name"],
+ "type": t["type"],
+ "auto": t.get("auto", False),
+ "qql": t.get("qql", ""),
+ "color": t.get("color", ""),
+ "description": t.get("description", ""),
+ })
+
+ missing_dyn = [] # Dans catalogue, pas dans Qualys, type DYN -> creer en console
+ missing_stat = [] # Dans catalogue, pas dans Qualys, type STAT -> creable via API
+ mismatch = [] # Present mais type different (DYN vs STAT)
+ present = [] # OK
+
+ for c in catalog_tags:
+ q = qualys_by_name.get(c["name"])
+ if not q:
+ if c["type"] == "DYN":
+ missing_dyn.append(c)
+ else:
+ missing_stat.append(c)
+ elif c["type"] != q["type"]:
+ mismatch.append({"catalog": c, "qualys": q})
+ else:
+ present.append({"catalog": c, "qualys": q})
+
+ # Tags Qualys pas dans le catalogue (pour info)
+ catalog_names = {c["name"] for c in catalog_tags}
+ extra = [t for t in ql["tags"] if t["name"] not in catalog_names]
+
+ return {
+ "ok": True,
+ "present": present,
+ "missing_dyn": missing_dyn,
+ "missing_stat": missing_stat,
+ "mismatch": mismatch,
+ "extra": extra,
+ "total_catalog": len(catalog_tags),
+ "total_qualys": len(ql["tags"]),
+ }
+
+
+def create_static_tag(db, name, color="", description=""):
+ """Cree un tag statique Qualys via API."""
+ payload = {
+ "ServiceRequest": {
+ "data": {
+ "Tag": {
+ "name": name,
+ "color": color or "#607D8B",
+ "description": description,
+ }
+ }
+ }
+ }
+ r = _qualys_post(db, "/qps/rest/2.0/create/am/tag", payload)
+ if r.get("ok"):
+ return {"ok": True, "msg": f"Tag '{name}' cree"}
+ return {"ok": False, "msg": r.get("msg") or f"HTTP {r.get('status')}: {r.get('text', '')[:200]}"}
+
+
+def delete_tag(db, tag_id):
+ """Supprime un tag Qualys par id."""
+ r = _qualys_post(db, f"/qps/rest/2.0/delete/am/tag/{tag_id}", {})
+ return {"ok": r.get("ok"), "msg": r.get("msg") or f"HTTP {r.get('status')}"}
+
+
+def generate_console_steps(missing_dyn):
+ """Retourne un texte avec les etapes console Qualys pour chaque tag DYN manquant."""
+ steps = []
+ for t in missing_dyn:
+ steps.append({
+ "name": t["name"],
+ "category": t["category"],
+ "qql": t["qql"],
+ "color": t["color"],
+ "steps": [
+ "VMDR > Assets > Tags > New Tag",
+ f"Name: {t['name']}",
+ f"Color: {t['color']}",
+ "Cocher 'Create as Dynamic Tag'",
+ f"Asset Criteria: {t['qql']}",
+ "Sauvegarder",
+ ],
+ })
+ return steps
diff --git a/app/templates/qualys_tagsv3.html b/app/templates/qualys_tagsv3.html
new file mode 100644
index 0000000..18330db
--- /dev/null
+++ b/app/templates/qualys_tagsv3.html
@@ -0,0 +1,44 @@
+{% extends 'base.html' %}
+{% block title %}Tags V3 - Vue Qualys{% endblock %}
+{% block content %}
+
+
Qualys Tags V3 — Vue actuelle
+
+
+
+{% if error %}
+
+ Erreur Qualys : {{ error }}
+
+{% endif %}
+
+
+
{{ stats.total }}
Total Qualys
+
{{ stats.dyn }}
Dynamiques
+
{{ stats.stat }}
Statiques
+
+
+
+
+
+ | Nom |
+ Type |
+ Règle (QQL) |
+ ID |
+
+
+ {% for t in tags %}
+
+ | {{ t.name }} |
+ {{ t.type }} |
+ {{ t.rule_text or '-' }} |
+ {{ t.id }} |
+
+ {% endfor %}
+
+
+
+{% endblock %}
diff --git a/app/templates/qualys_tagsv3_gap.html b/app/templates/qualys_tagsv3_gap.html
new file mode 100644
index 0000000..4e332ae
--- /dev/null
+++ b/app/templates/qualys_tagsv3_gap.html
@@ -0,0 +1,135 @@
+{% extends 'base.html' %}
+{% block title %}Tags V3 - Gap analysis{% endblock %}
+{% block content %}
+
+
+{% if not gap.ok %}
+
+ Erreur : {{ gap.msg }}
+
+{% else %}
+
+
+
+
{{ gap.present|length }}
Présents OK
+
{{ gap.missing_dyn|length }}
DYN manquants (console)
+
{{ gap.missing_stat|length }}
STAT manquants (API)
+
{{ gap.mismatch|length }}
Type divergent
+
{{ gap.extra|length }}
Extra (hors V3)
+
+
+
+{% if gap.missing_dyn %}
+
+
+
🔴 Tags DYN manquants ({{ gap.missing_dyn|length }}) — à créer MANUELLEMENT dans console Qualys
+
+
L'API Qualys ne permet pas de créer des tags dynamiques. Copie les étapes ci-dessous dans VMDR > Assets > Tags > New Tag.
+
+
+ | Tag |
+ Catégorie |
+ QQL à saisir |
+ Couleur |
+
+
+ {% for t in console_steps %}
+
+ | {{ t.name }} |
+ {{ t.category }} |
+ {{ t.qql }} |
+ {{ t.color }} |
+
+ {% endfor %}
+
+
+
+{% endif %}
+
+
+{% if gap.missing_stat %}
+
+
+
🟡 Tags STAT manquants ({{ gap.missing_stat|length }}) — créables via API
+ {% if can_modify %}
+
+ {% endif %}
+
+
+
+ | Tag |
+ Catégorie |
+ Description |
+ Couleur |
+ {% if can_modify %}Action | {% endif %}
+
+
+ {% for t in gap.missing_stat %}
+
+ | {{ t.name }} |
+ {{ t.category }} |
+ {{ t.description or '-' }} |
+ |
+ {% if can_modify %}
+
+
+ |
+ {% endif %}
+
+ {% endfor %}
+
+
+
+{% endif %}
+
+
+{% if gap.mismatch %}
+
+
⚠ Type divergent ({{ gap.mismatch|length }})
+
Le type (DYN/STAT) dans Qualys ne correspond pas au catalogue V3.
+
+ | Tag | V3 | Qualys |
+
+ {% for m in gap.mismatch %}
+ | {{ m.catalog.name }} | {{ m.catalog.type }} | {{ m.qualys.type }} |
+ {% endfor %}
+
+
+
+{% endif %}
+
+
+
+ ✓ Tags V3 présents ({{ gap.present|length }})
+
+ | Tag | Type |
+
+ {% for p in gap.present %}
+ | {{ p.catalog.name }} | {{ p.catalog.type }} |
+ {% endfor %}
+
+
+
+
+
+{% if gap.extra %}
+
+ Extras Qualys (non-V3) ({{ gap.extra|length }})
+
+ {% for t in gap.extra %}{{ t.name }}{% endfor %}
+
+
+{% endif %}
+
+{% endif %}
+{% endblock %}
diff --git a/deploy/qualys_tags_v3.yaml b/deploy/qualys_tags_v3.yaml
new file mode 100644
index 0000000..b3aedd9
--- /dev/null
+++ b/deploy/qualys_tags_v3.yaml
@@ -0,0 +1,179 @@
+# Catalogue des tags Qualys V3 SANEF
+# Source : SANEF DSI / Sécurité Opérationnelle — Plan d'action Qualys V3 (Mars 2026)
+#
+# type: DYN (dynamic — création console web Qualys UNIQUEMENT)
+# STAT (static — création + assignation via API OK)
+# auto: True = entierement automatisable (Tag Rule ou script)
+# False = necessite decision humaine
+
+categories:
+ OS:
+ description: "Système d'exploitation — dynamique sur operatingSystem"
+ tags:
+ - name: OS-LIN
+ type: DYN
+ auto: true
+ qql: 'operatingSystem.category1: "Linux"'
+ color: "#4CAF50"
+ - name: OS-WIN
+ type: DYN
+ auto: true
+ qql: 'operatingSystem.category1: "Windows"'
+ color: "#2196F3"
+ - name: OS-WIN-SRV
+ type: DYN
+ auto: true
+ qql: 'operatingSystem: "Windows Server"'
+ color: "#1976D2"
+ - name: OS-ESX
+ type: DYN
+ auto: true
+ qql: 'operatingSystem: "ESXi"'
+ color: "#9C27B0"
+
+ ENV:
+ description: "Environnement — dynamique sur hostname position 2"
+ tags:
+ - name: ENV-PRD
+ type: DYN
+ auto: true
+ qql: 'name: "vp" OR name: "sp" OR name: "lp" OR name: "ls-"'
+ color: "#F44336"
+ - name: ENV-REC
+ type: DYN
+ auto: true
+ qql: 'name: "vr" OR name: "sr" OR name: "lr"'
+ color: "#FF9800"
+ - name: ENV-PPR
+ type: DYN
+ auto: true
+ qql: 'name: "vi" OR name: "si" OR name: "vo"'
+ color: "#FFC107"
+ - name: ENV-TST
+ type: DYN
+ auto: true
+ qql: 'name: "vv" OR name: "vt"'
+ color: "#CDDC39"
+ - name: ENV-DEV
+ type: DYN
+ auto: true
+ qql: 'name: "vd" OR name: "sd"'
+ color: "#8BC34A"
+
+ POS:
+ description: "Périmètre / Domaine — dynamique sur hostname positions 2-N"
+ tags:
+ - name: POS-FL
+ type: DYN
+ auto: true
+ qql: 'name: "*bot" OR name: "*boo" OR name: "*boc" OR name: "*afl" OR name: "*sup"'
+ color: "#009688"
+ - name: POS-INF
+ type: DYN
+ auto: true
+ qql: 'name: "*dsi" OR name: "*cyb" OR name: "*vsa" OR name: "*iad" OR name: "*bur" OR name: "*aii" OR name: "*ecm" OR name: "*log" OR name: "*vid" OR name: "*gaw" OR name: "*bck" OR name: "*ngw" OR name: "*pct" OR name: "*pix" OR name: "*sim" OR name: "*nms" OR name: "*ges" OR name: "*mon"'
+ color: "#3F51B5"
+ - name: POS-PEA
+ type: DYN
+ auto: true
+ qql: 'name: "*pea" OR name: "*osa" OR name: "*svp" OR name: "*adv" OR name: "*rpa" OR name: "*rpn" OR name: "ls-"'
+ color: "#673AB7"
+ - name: POS-TRA
+ type: DYN
+ auto: true
+ qql: 'name: "*ame" OR name: "*tra" OR name: "*dai" OR name: "*pat" OR name: "*rau" OR name: "*dep" OR name: "*exp" OR name: "*sig" OR name: "*air"'
+ color: "#E91E63"
+ - name: POS-BI
+ type: DYN
+ auto: true
+ qql: 'name: "*dec" OR name: "*sas" OR name: "*bip" OR name: "*apt" OR name: "*pbi" OR name: "*rep"'
+ color: "#FF5722"
+ - name: POS-GES
+ type: DYN
+ auto: true
+ qql: 'name: "*int" OR name: "*agt" OR name: "*pin" OR name: "*ech"'
+ color: "#795548"
+ - name: POS-DMZ
+ type: DYN
+ auto: true
+ qql: 'name: "*ssi"'
+ color: "#607D8B"
+
+ EQT:
+ description: "Type equipement — position 1"
+ tags:
+ - name: EQT-VIR
+ type: DYN
+ auto: true
+ qql: 'name: "v"'
+ color: "#00BCD4"
+ - name: EQT-SRV
+ type: DYN
+ auto: true
+ qql: 'name: "l" OR name: "s"'
+ color: "#03A9F4"
+ - name: EQT-SWI
+ type: DYN
+ auto: true
+ qql: 'name: "n"'
+ color: "#4DD0E1"
+
+ SPEC_AUTO:
+ description: "Tags spécifiques automatisables"
+ tags:
+ - name: TAG-OBS
+ type: DYN
+ auto: true
+ qql: 'operatingSystem: "Windows Server 2008" OR operatingSystem: "Windows Server 2012" OR operatingSystem: "CentOS release 6" OR operatingSystem: "Red Hat Enterprise Linux Server release 6"'
+ color: "#B71C1C"
+ - name: TAG-EMV
+ type: DYN
+ auto: true
+ qql: 'name: "*emv" OR name: "*pci"'
+ color: "#D500F9"
+
+ SPEC_MANUAL:
+ description: "Tags spécifiques non automatisables (decision humaine)"
+ tags:
+ - name: TAG-SED
+ type: STAT
+ auto: false
+ description: "Securite Exposition Directe — IP publique / NAT direct"
+ color: "#C62828"
+ - name: TAG-SEI
+ type: STAT
+ auto: false
+ description: "Securite Exposition Indirecte — derriere frontal"
+ color: "#EF6C00"
+ - name: TAG-DEC
+ type: STAT
+ auto: false
+ description: "Decommissionnement en cours"
+ color: "#6D4C41"
+ - name: TAG-INT
+ type: STAT
+ auto: false
+ description: "Integration / Implémentation en cours"
+ color: "#FDD835"
+ - name: TAG-SIC
+ type: STAT
+ auto: false
+ description: "Zone SIC — Systeme Information Classifie"
+ color: "#1A237E"
+ - name: TAG-SIA
+ type: STAT
+ auto: false
+ description: "Zone SIA — Systeme Information Administration"
+ color: "#283593"
+
+ PREFIXES_MANUAL:
+ description: "Prefixes statiques pour tags nominatifs (crees a la demande via API)"
+ prefixes:
+ - prefix: APP-
+ description: "Application hebergee — APP-SAT, APP-JIRA, APP-GLPI..."
+ - prefix: BDD-
+ description: "Type de base de donnees — BDD-ORA, BDD-PG, BDD-SQL..."
+ - prefix: VRF-
+ description: "VRF reseau — VRF-TRAFIC, VRF-EMV..."
+ - prefix: MID-
+ description: "Middleware — MID-TOMCAT, MID-HAPROXY..."
diff --git a/requirements.txt b/requirements.txt
index 085ae6d..faa763d 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -34,6 +34,7 @@ PyNaCl==1.6.2
python-jose==3.5.0
python-multipart==0.0.26
python-pptx==1.0.2
+PyYAML==6.0.2
reportlab==4.4.10
requests==2.33.1
rsa==4.9.1