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}>(.*?)", 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

+
+ Analyse gap vs nomenclature V3 + Tags (vue legacy) +
+
+ +{% if error %} +
+ Erreur Qualys : {{ error }} +
+{% endif %} + +
+
{{ stats.total }}
Total Qualys
+
{{ stats.dyn }}
Dynamiques
+
{{ stats.stat }}
Statiques
+
+ +
+ + + + + + + + + {% for t in tags %} + + + + + + + {% endfor %} + +
NomTypeRègle (QQL)ID
{{ t.name }}{{ t.type }}{{ t.rule_text or '-' }}{{ t.id }}
+
+{% 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 %} +
+

Gap nomenclature V3 ↔ Qualys

+ ← Vue Qualys +
+ +{% 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.

+ + + + + + + + + {% for t in console_steps %} + + + + + + + {% endfor %} + +
TagCatégorieQQL à saisirCouleur
{{ t.name }}{{ t.category }}{{ t.qql }} {{ t.color }}
+
+{% endif %} + + +{% if gap.missing_stat %} +
+
+

🟡 Tags STAT manquants ({{ gap.missing_stat|length }}) — créables via API

+ {% if can_modify %} +
+ +
+ {% endif %} +
+ + + + + + + {% if can_modify %}{% endif %} + + + {% for t in gap.missing_stat %} + + + + + + {% if can_modify %} + + {% endif %} + + {% endfor %} + +
TagCatégorieDescriptionCouleurAction
{{ t.name }}{{ t.category }}{{ t.description or '-' }} +
+ + + + +
+
+
+{% endif %} + + +{% if gap.mismatch %} +
+

⚠ Type divergent ({{ gap.mismatch|length }})

+

Le type (DYN/STAT) dans Qualys ne correspond pas au catalogue V3.

+ + + + {% for m in gap.mismatch %} + + {% endfor %} + +
TagV3Qualys
{{ m.catalog.name }}{{ m.catalog.type }}{{ m.qualys.type }}
+
+{% endif %} + + +
+ ✓ Tags V3 présents ({{ gap.present|length }}) + + + + {% for p in gap.present %} + + {% endfor %} + +
TagType
{{ p.catalog.name }}{{ p.catalog.type }}
+
+ + +{% 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