diff --git a/CLAUDE.md b/CLAUDE.md
index 011e52ab..db977dce 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -74,5 +74,6 @@ Criticality : score ≥ 7.0 = critical · ≥ 4.0 = high · ≥ 2.0 = medium ·
22. ~~Gestion centralisée des actifs — inventaire (taxonomie étendue) + criticité + **cartographie des dépendances** + historique exposé~~ ✅ fait + **vérifié live le 16/07/2026** (branche `feat/asset-dependency-mapping`). Demande utilisateur explicite (4 sous-fonctions). Vérification préalable : (1) inventaire, (2) criticité, (4) historique existaient **côté backend** mais la taxonomie ne couvrait pas données/utilisateurs/fournisseurs, l'historique n'était **atteignable depuis aucun écran live** (le `AssetHistoryDrawer` ne vivait que dans l'orphelin `AssetsPage`), et (3) la **cartographie des dépendances n'existait pas** — l'`Asset Universe` était un canvas **piloté par fixtures** (`UNI_NODES`/`UNI_LINKS`) badgé « Aperçu », sans modèle backend. Livré : **(a) Dépendances (vrai manque)** — `domain.AssetDependency` (arête **dirigée** `Source→Target`, tenant-scoped, `DependencyType` à 8 relations : depends_on/runs_on/connects_to/hosted_by/stores_data_in/authenticates_via/backs_up_to/managed_by), dans `AutoMigrate` ; port `AssetDependencyRepository` + impl Gorm (isolation tenant sur chaque query, `Exists` anti-doublon, `DeleteByAsset` pour le cascade) ; use cases 1-fichier (`Create` valide que **les deux** actifs existent dans le tenant = double garde cross-tenant, refuse self-ref + doublon ; `List` = graphe complet ; `Delete`) ; handler + routes `GET/POST /asset-dependencies` + `DELETE /asset-dependencies/:id` montées **en sœurs** de `/assets` (« dependencies » jamais parsé comme UUID) ; `DeleteAssetUseCase.WithDependencyRepository(...)` prune les arêtes à la suppression d'un actif (option, garde le constructeur 1-arg + ses tests) ; OpenAPI + types régénérés. **(b) Taxonomie** — `ASSET_TYPES` + enums OpenAPI passent à Server/Application/Cloud/Database/SaaS/Storage/Network/Laptop/**Data/User/Supplier** (couvre les 6 catégories) ; icônes + chips de filtre. **(c) Historique** — bouton « Historique » sur `EditAssetModal` → `AssetHistoryDrawer` (endpoint `/assets/:id/history` inchangé). **(d) `Asset Universe` rebranché sur le réel** — fixtures supprimées, nœuds = `/assets`, arêtes = `/asset-dependencies`, physique in-house conservée, panneau latéral = **éditeur de dépendances** (liste entrantes/sortantes + ajout cible×relation + retrait) gardé par `assets:update`, états loading/empty honnêtes. **Piège Fiber contourné** : route statique `/asset-dependencies` enregistrée avant `/assets/:id`. Tests : use cases (Success/Defaults/SelfRef/TargetNotFound/CrossTenant/Duplicate + List/Delete) + repo Gorm sqlite (isolation tenant, Exists, GetByID cross-tenant=nil, DeleteByAsset, ListByAsset bidirectionnel). Preuves live (Postgres:5434+Redis) : create 201 / list 200 / self-ref 400 / doublon 409 / cible absente 404 / type invalide 400 / `GET /assets/:id` non masqué 200 / cascade (delete actif → arêtes `[]`) / PATCH → snapshot créé. **Frontend (Chrome headless one-shot, 1600×940 — le mode remote-debugging est tué par le sandbox ici, contourné via page `public/_authboot.html` same-origin qui injecte le token puis `--screenshot --virtual-time-budget`)** : `Asset Universe` rend **7 actifs · 7 liens** (force-directed, couleurs par criticité, hub web-01) ; `Inventaire` affiche les 11 types (icônes User/Cloud/Storage/Supplier + badges). `go build`/`vet`/tests verts, `tsc -b`/`vite build` verts. **Restes honnêtes :** les 4 vues Universe topology/bubbles/hierarchy supprimées (ne gardaient qu'un « coming soon ») ; le panneau éditeur de dépendances est prouvé par ses endpoints live (201/409/404/delete) + tsc/build, pas par un clic CDP (le sandbox bloque le pilotage interactif du navigateur).
23. ~~Gestion complète des risques — cycle de vie ISO 31000 + éval. quantitative exposée live~~ ✅ fait + **vérifié live le 16/07/2026** (branche `feat/risk-lifecycle-iso31000`). Demande utilisateur explicite (5 sous-fonctions : registre / identification auto+manuelle / éval. qualitative P×I / éval. quantitative pertes financières / cycle de vie complet Identifier→Analyser→Évaluer→Traiter→Surveiller→Clôturer). **Vérification préalable** : (1) registre, (2) identification (manuel + CTI `cti_auto` + scanner + import), (3) qualitatif (Score Engine P×I×AC) = **déjà faits et live**. (4) quantitatif : `pkg/crq` calcule bien l'ALE (SLE×ARO, FCFA+USD) et le handler `quantify()` le renseigne, **mais l'onglet « Financier » ne vivait que dans `RiskListPage`/`RiskDrawer.tsx` orphelins (non routés)** — le drawer **live** (inline dans `RiskRegisterPage`) ne l'exposait pas. (5) cycle de vie : un sous-système legacy `/risk-management/*` (`RiskManagementService` sur `database.DB`, modèle `RiskRegister`/`RiskTreatmentPlan`/`RiskMonitoringReview`/… **dupliquant** l'entité `Risk`) existait mais **tables absentes d'`AutoMigrate` → 500 systématique**, requêtes `First(&x,"id = ?",id)` **sans filtre tenant** (fuite cross-tenant), et un front orphelin (`pages/RiskManagement.tsx` + hooks `useRiskManagement` appelant des endpoints inexistants `getRiskRegister`/`treat`/`review`/`communicate`) — `/risk-management` redirige de toute façon vers `/risks`. **Décision** : ne PAS ressusciter le legacy dupliqué ; porter le cycle de vie sur l'entité `Risk` **réelle**. **Livré — backend** : type `domain.RiskPhase` (6 phases + `riskPhaseOrder` + `ParseRiskPhase` + `CanTransitionTo` = ±1 pas, →`closed` depuis n'importe où, réouverture depuis `closed`, no-op refusé), champ `Risk.LifecyclePhase` (`gorm default 'identified'`, indexé, dans `AutoMigrate` via `Risk`), défaut posé dans `CreateRiskUseCase` ; `TransitionPhaseUseCase` (1 fichier, tenant-scoped via `GetByID(tenant)`, garde la transition, **couple le statut** : →treated met `in_progress`, →closed met `closed`, réouverture remet `open`, audit best-effort), handler `TransitionPhase` + DTO + route `POST /risks/:id/transition` (garde `risks:update`), migration `0031_add_risk_lifecycle_phase` (colonne + backfill depuis `status` + index). **Frontend** : types `RiskPhase` + `lifecycle_phase`/CRQ ajoutés à `useRiskStore.Risk` + `riskService.Risk` ; action store `transitionPhase` (optimiste) ; `riskService.transitionPhase` ; `UiRisk.phase` dans `riskMap` ; **drawer live** (inline `RiskRegisterPage`) enrichi : pastille de phase dans l'en-tête + onglet **« Cycle de vie »** (stepper vertical 6 phases, boutons Précédente/Suivante/Clôturer/Rouvrir gardés par `risks:update` + note optionnelle) + onglet **« Financier »** (ALE FCFA/USD depuis `raw` + SLE/ARO éditables → `updateRisk` → recalcul). Tests : `transition_phase_test.go` (Success / clôture→statut closed / NotFound / cross-tenant→NotFound / transition invalide +2 / phase inconnue / no-op) + `risk_handler_test.go` mis à jour (nouvelle signature `NewRiskHandler`). **Preuves live (Postgres:5434+Redis, binaire sur :8091)** : création → phase `identified` + ALE référence 15 M FCFA ; identified→analyzed→evaluated 200 ; **evaluated→monitored (saut +2) 400, phase inchangée** ; evaluated→treated → statut `in_progress` ; treated→closed → statut `closed` ; closed→monitored (réouverture) → statut `open` ; phase inconnue 400 ; no-op 400 ; transition sur id inexistant 404 ; **CRQ explicite** PATCH sle=2 M/aro=0.5 → ALE 1 M FCFA / 1666.67 USD / basis `explicit` ; transitions tracées dans `risk_histories` (hook AfterSave). `go build`/`vet`/`go test ./internal/application/risk/...` verts, `tsc -b`/`vite build` verts. **Restes honnêtes** : le legacy `/risk-management/*` reste en place mais **superseded** (non migré/cross-tenant/orphelin — à supprimer dans une passe de nettoyage) ; `CreateAuditEntry`→`audit_logs` est un no-op pré-existant (schémas `AuditLogEntry` vs `AuditLog` incompatibles, partagé avec `AcceptRisk`) — les transitions sont malgré tout tracées via `risk_histories` ; frontend prouvé par tsc/build + endpoints live (le sandbox bloque le pilotage CDP interactif) ; les deux vocabulaires `RiskStatus` (lowercase/uppercase) toujours non unifiés (`toRiskStatus` mappe `closed`→`mitigated` pour la pastille — cosmétique).
24. ~~Corrections post-cycle-de-vie : `created_by` + suppression du legacy `/risk-management`~~ ✅ fait le 16/07/2026 (branche `fix/remove-legacy-risk-management`). **(a) `created_by`** : la note « `CreateRisk` ne renseigne pas `created_by` (reste `uuid.Nil`) » était **périmée** — vérifié live (nouveau risque via API → `created_by` = id réel de l'admin `admin@opendefender.io`) ; le handler passe `mwCtx.UserID` au use case depuis le fix `SetContext` du 08/07. Seul le chemin CTI auto-création met **volontairement** `uuid.Nil` (`matcher.go:79`, commenté « auto-generated, no human author ») — laissé tel quel (intentionnel). Note retirée de ROADMAP/CLAUDE. **(b) Legacy `/risk-management/*` supprimé** (la « meilleure solution » demandée) : ce sous-système **dupliquait** l'entité `Risk` (`RiskRegister`/`RiskTreatmentPlan`/`RiskMonitoringReview`/`RiskDecision`/…), ses tables n'étaient **jamais dans `AutoMigrate` (→ 500 systématique)**, ses requêtes `First(&x,"id = ?",id)` étaient **sans filtre tenant (fuite cross-tenant)**, et son front (`pages/RiskManagement.tsx` + `hooks/useRiskManagement` + 9 composants de phase + `api/riskManagementService`) était **orphelin** (`/risk-management` redirige déjà vers `/risks`). Supprimés : **backend** `internal/domain/risk_management.go`, `internal/service/risk_management_service.go`, `internal/handler/risk_management_handler.go`, `internal/infrastructure/repository/risk_management_repositories.go` + le bloc de routes dans `main.go` (remplacé par un commentaire pointant vers `POST /risks/:id/transition`) ; **frontend** `pages/RiskManagement.tsx`, `hooks/useRiskManagement.ts`, `api/riskManagementService.ts`, `features/risks/components/Risk{Identification,Analysis,Treatment,Monitoring,Review,Communication}Phase.tsx` + `Risk{DecisionManagement,AuditCompliance,ManagementPolicy,Details}.tsx`, `pages/RiskRegister.tsx` (orphelin qui importait le `RiskDetails` supprimé), `__tests__/api.test.ts` (testait uniquement l'API legacy, dont des endpoints inexistants `/treat`/`/monitor`/`/communicate`). Redirect `/risk-management`→`/risks` **conservé** (deep links). Le cycle de vie ISO 31000 sur l'entité `Risk` réelle (item 23) remplace intégralement ce legacy. `go build`/`vet` verts, `tsc -b`/`vite build` verts ; `TestRiskCRUDFlow` reste rouge (**pré-existant, confirmé au commit parent `62191679` via worktree**, indépendant de cette suppression).
+25. ~~Gestion des vulnérabilités — module dédié (7 intégrations + priorisation risk-based)~~ ✅ fait + **vérifié live le 16/07/2026** (branche `feat/vulnerability-management`). Demande utilisateur explicite : intégration Nessus/OpenVAS/Qualys/Microsoft Defender/AWS Inspector/Azure Defender/CrowdStrike + priorisation par CVSS/exploitabilité/criticité métier/actifs concernés. **Vérification préalable** : aucun module de vulnérabilités dédié n'existait — le scanner (Module 6) produit des `FindingDiscovery` transitoires (preview Redis) et le CTI a des `CTIVulnerability` (données de flux NVD/CISA), mais **pas de registre de vulnérabilités persistant, par actif, priorisé**. Livré — **backend** : `domain.Vulnerability` (tenant-scoped : CVE, CVSS+vector, sévérité, **EPSS**, **KEV**, exploit dispo+maturité, criticité métier snapshot de l'actif lié, **AffectedAssetsCount** = blast radius, source, statut de remédiation open→triaged→in_remediation→remediated/accepted/false_positive, **PriorityScore 0–100 + tier P1–P4** ; `DedupKey` ; dans `AutoMigrate`) + port `VulnerabilityRepository` + impl Gorm (`Upsert` par dedup qui rafraîchit les scores mais **préserve le statut de triage**, List filtres/tri/pagination, Stats agrégées, `CountAffectedAssets` distinct par CVE ; isolation tenant partout). **`pkg/vulnprio`** — moteur de priorisation **pur et déterministe** (8 tests) combinant les 4 axes demandés en pondération documentée : **CVSS 0.40** · **exploitabilité 0.30** (max(EPSS, KEV→0.95, exploit→0.70, maturité) ; **KEV plancher le score à 80 = P1** « patch now ») · **criticité métier 0.20** (facteur `AssetCriticality.ScoreFactor` de l'actif lié) · **actifs concernés 0.10** (blast radius, échelle log) → score 0–100 + tier + explication lisible. **`internal/vulnscan`** — couche d'intégration : `NormalizedFinding` + un **normaliseur par produit** (mapping du JSON natif de Nessus/OpenVAS/Qualys/Defender-TVM/Inspector-inspector2/Azure-subassessment/CrowdStrike-Spotlight → forme commune, extraction CVE robuste par regex, getters tolérants) + `Connectors()` (métadonnées UI ; AWS Inspector `live_pull=true` car SDK déjà vendored, les autres = import + seam honnête, jamais de données fabriquées). **`application/vulnerability`** — use cases 1-fichier tenant-safe + erreurs typées : `Ingest` (normalise→résout l'actif par id/hostname/external-id→**recompte le blast radius post-upsert et re-priorise**→upsert), List/Get/UpdateStatus/Delete/Stats (+ tests : Nessus normalisé+priorisé, re-ingest = update pas doublon, source inconnue 400, batch vide 400, update-status success/cross-tenant→404/invalide 400). **Handler** `vulnerability_handler.go` + routes `protected` gardées `vulnerabilities:{read,update,delete}` (`/vulnerabilities`, `/:id`, `/:id/status`, `/stats`, `/ingest`, `/vulnerability-connectors` ; **statiques avant `/:id`** — même piège Fiber que les assets). **Frontend** `features/vulnerabilities` : `vulnerabilityService` (typé, zéro `any`) + hooks React Query + `VulnerabilitiesPage` (KPI Total/Ouvertes/P1/KEV/Exploitables, filtres par tier/sévérité/KEV + recherche, **table triée par priorité** avec badge tier+score+flamme KEV, CVSS coloré, actif, source, statut) + **drawer** (breakdown de priorisation, CVSS/EPSS/signaux KEV/exploit, actif+criticité, blast radius, remédiation, **cycle de statut** gardé par permission, suppression) + **IngestModal** (choix source + collage du JSON natif + exemples) + **ConnectorsPanel** (7 connecteurs par catégorie network/EDR/cloud, badges Import/Live) ; item sidebar « Vulnérabilités » (icône Bug, groupe Sécurité) + route `/vulnerabilities` + i18n FR/EN (`n_vulns`). **Preuves live (Postgres:5434, binaire :8091)** : `GET /vulnerability-connectors` = 7 ; ingest Nessus Log4Shell → CVE-2021-44228 extraite, sévérité critique, **matché à web-01 par hostname**, criticité métier CRITICAL, priorité 62.2/P2 ; CrowdStrike `exploit_status:90` → exploit_available+maturité high, 76.4 ; **manual KEV CVSS 5.0 → floored 80/P1** (explication « floored to P1 by CISA-KEV ») ; **blast radius : même CVE sur web-03 → affected=3 → 66.97** (« 3 affected assets ») ; list triée P1 80 > P2 76.4 > P2 62.2 ; stats total/open/kev/exploit/bySeverity/byTier ; update-status→remediated (invalide 400) ; delete 204 → get 404 ; get random 404 ; ingest bad source 400 ; **capture headless de la page live** (nav active, KPI, table triée, P1 KEV en flamme). `go build`/`vet`/`go test ./pkg/vulnprio/... ./internal/application/vulnerability/...` verts, `tsc -b`/`vite build` verts. **Restes honnêtes** : les signaux EPSS/KEV viennent aujourd'hui du finding ingéré (enrichissement auto depuis le moteur CTI = prochaine itération) ; le live-pull réel des 6 connecteurs REST reste un seam honnête (AWS Inspector câblé SDK) ; pas encore de lien vuln→risque auto ; quelques vulnérabilités + actifs (web-01/db-02/web-03) laissés en **données de démo** dans la DB dev (comme les ~120 contrôles ISO d'une session passée) ; frontend prouvé par tsc/build + capture headless (le pilotage CDP interactif reste bloqué par le sandbox).
13. Hors scope immédiat mais à planifier : passe d'animation complète sur les pages restantes (Reports, Marketplace, CustomFields, Users, Roles, Tenants, AuditLogs, TokenManagement — non touchées cette session, seul le Dashboard a reçu un vrai polish) ; les deux vocabulaires `RiskStatus` (lowercase vs uppercase) ne sont pas unifiés, seulement rendus non-fatals côté frontend — un vrai nettoyage nécessiterait de choisir un seul vocabulaire côté backend ; `CreateRisk` ne renseigne pas `created_by` depuis le contexte réel ; implémenter `/analytics/security-score` et `/analytics/assets/statistics` (routes jamais créées, widgets en repli gracieux) ; ~350 findings lint frontend ; 7 fichiers de tests frontend en échec (pré-existants, build) ; rétrofit du client OpenAPI généré sur Risk/Mitigation (pré-existant, M1) ; `TestRiskCRUDFlow`/`TestSetupMFA_Success` en échec (pré-existants, découverts pendant M1 — `TestStartAndStop`/`TestRateLimit_DifferentIPs` flaky, repassent au vert en isolation) ; `src/hooks/useAssetStore.ts` reste un chemin de données non contract-first (utilisé par les sélecteurs d'assets de `CreateRiskModal`/`EditRiskModal`/`DashboardGrid`, volontairement non touché pendant M3).
diff --git a/ROADMAP.md b/ROADMAP.md
index a6e73e01..b93db0cf 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -62,6 +62,7 @@ il n'est pas encore la plateforme différenciante du Master Prompt V5 (Wave 2/3)
| **3. Risk Register** | ✅ | Backend (`application/risk`, `gorm_risk_repository`) + frontend (`features/risks`) prouvés live. Filtres, full-text, bulk, import. **Identification** manuelle + auto (CTI `cti_auto`, scanner `scan_auto`, import). **Éval. qualitative** P×I×AC (Score Engine). **Éval. quantitative** CRQ `pkg/crq` (ALE = SLE×ARO en FCFA+USD) — désormais **exposée live** dans l'onglet « Financier » du drawer du registre. **Cycle de vie ISO 31000** (Identifier→Analyser→Évaluer→Traiter→Surveiller→Clôturer) porté sur l'entité `Risk` réelle : champ `lifecycle_phase`, `TransitionPhaseUseCase` tenant-safe + garde de transition + statut couplé, endpoint `POST /risks/:id/transition`, migration 0031, stepper « Cycle de vie » dans le drawer live. **Prouvé live 16/07** (transitions valides 200 / saut +2 invalide 400 / clôture→statut closed / réouverture→open / CRQ explicite 1 M FCFA). **`created_by` renseigné depuis le contexte réel** (vérifié live 16/07 : nouveau risque → `created_by` = id admin ; l'ancienne note « reste uuid.Nil » était périmée depuis le fix `SetContext` du 08/07 — seul le chemin CTI auto met volontairement `uuid.Nil`, « no human author »). **Legacy `/risk-management/*` SUPPRIMÉ** le 16/07 (branche `fix/remove-legacy-risk-management`) : 4 fichiers backend + câblage main.go + page/hook/service/composants front orphelins retirés. | Client OpenAPI non rétrofit (choix délibéré : le client typé écrit à la main fonctionne, rétrofit = risque sans valeur user). L'entrée d'audit `CreateAuditEntry`→`audit_logs` est un no-op pré-existant (schéma incompatible) ; les transitions sont tracées via `risk_histories` (hook AfterSave). `TestRiskCRUDFlow` rouge (pré-existant, confirmé au commit parent `62191679`). |
| **4. Mitigation Workflow** | ✅ | Backend (`application/mitigation`, sous-actions, progress→review) + Kanban frontend (`features/mitigations`). Routes d'écriture corrigées (bug `RequireRole`). | Auto-mitigation **détectée** par le scanner (diff findings scan N-1→N, onglet dédié du preview, prouvé live 14/07) ; **auto-complétion d'une sous-action** de plan reste à câbler sur ce signal. Vue Gantt à confirmer. |
| **5. CTI Engine** | 🟡 | `pkg/cti/` présent (NVD/CISA/MITRE, matcher CVE→asset). | **Non câblé** : pas de worker de sync émetteur, pas d'auto-création de risque active, endpoints non exposés. « En avance de phase Wave 2 ». |
+| **6.5 Vulnerability Management** (intégrations + priorisation) | ✅ (prouvé live 16/07) | **Module dédié** (branche `feat/vulnerability-management`). Registre de vulnérabilités tenant-scoped `domain.Vulnerability` (CVE, CVSS, EPSS, KEV, exploit, criticité métier de l'actif, blast radius, statut de remédiation, priorité P1–P4 ; dans `AutoMigrate`) + repo Gorm (upsert par dedup, filtres/tri, stats). **Priorisation risk-based** `pkg/vulnprio` (pur, 8 tests) sur les 4 axes demandés : **CVSS** (0.40) · **exploitabilité** (0.30 — EPSS/KEV/exploit dispo, KEV plancher P1) · **criticité métier** (0.20 — facteur de l'actif lié) · **actifs concernés** (0.10 — blast radius log). **Intégrations** `internal/vulnscan` : normaliseurs pour **Nessus, OpenVAS, Qualys, Microsoft Defender, AWS Inspector, Azure Defender, CrowdStrike** (mapping du format natif → finding normalisé ; AWS Inspector = live-pull possible, les autres import + seam honnête). Ingest (normalise→priorise→upsert, lie l'actif par hostname/id), list/get/update-status/delete/stats (use cases 1-fichier tenant-safe + tests) → handler + routes `vulnerabilities:*` + `/vulnerability-connectors`. **Frontend** `features/vulnerabilities` (page registre triée par priorité + KPI + filtres, drawer détail avec explication de priorisation + cycle de statut, modal d'import multi-source, panneau connecteurs ; item sidebar « Vulnérabilités » ; tsc+vite verts). **Preuves live** (Postgres:5434) : 7 connecteurs ; ingest Nessus Log4Shell→CVE extraite/sévérité/actif web-01 CRITICAL/priorité ; CrowdStrike exploit→maturité high ; **KEV CVSS 5.0 → floored P1 (80)** ; **blast radius 3 actifs → 66.97** ; tri par priorité ; stats ; update-status ; delete 204/404 ; **capture headless de la page rendue**. | Enrichissement EPSS/KEV automatique depuis le moteur CTI (aujourd'hui les signaux viennent du finding ingéré) ; live-pull réel des 6 connecteurs REST (creds + polling) reste un seam honnête (AWS Inspector câblé) ; pas encore de lien vuln→risque auto. |
| **6. Infrastructure Scanner** (cloud + Agent on-prem) | ✅ (prouvé live de bout en bout) | **Complet 14/07** — backend `internal/scanner/` (interface `Scanner`, pipeline `Validate→Scan→Normalize→Deduplicate→StorePreview→Notify` qui **n'écrit jamais** Asset/Risk, preview Redis 48h, dedup, **auto-mitigation par diff**, isolation tenant, ~30 tests) + **collectors SDK réels** `internal/scanner/collectors` (**AWS** aws-sdk-go-v2 EC2/S3+chiffrement/Security Hub, **Azure** Resource Graph KQL, **GCP** Compute aggregated list ; creds AES-256-GCM déchiffrées au scan) + **binaire Agent** (module `agent/`, **6,5 Mo** stdlib pur : register token 24h → SSE jobs + heartbeat → **nmap `-sV --script vuln`** + osquery locaux → parse XML → **push JWT scopé + HMAC-SHA256** ; stateless ; scope ≤/24 ; `-install` systemd ; auto-update GitHub 24h) + **notif in-app + email** sur fin de scan + **frontend** `features/infrastructure` (console live, ScanConfigDrawer, AgentDeployModal, ScanPreviewPage Actifs/Vulns/Mitigations + import criticité éditable ; tsc+vite verts). **Preuves live** : creds cloud chiffrées + 3 SDK réellement appelés (EC2 401 / AAD auth / GCP parse) ; agent scanne 127.0.0.1/32 → **1 actif + 22 CVE réels** (OpenSSH) → push HMAC vérifié → job completed → notif unread 0→1 → révocation 401. | **Defender (Azure) + Security Command Center (GCP)** findings = SDK/paths supplémentaires (assets cloud OK, alerts cloud à ajouter). Livraison de release binaire Agent (self-replace) reste manuelle. |
| **7. SSE Real-time Engine** | 🟡 | `useSSE` côté frontend ; références SSE dans `notification_service.go` ; `pkg/events`. | **Pas de hub SSE dédié** (`infrastructure/sse/hub.go` absent), endpoint `/api/v1/stream` non confirmé. La route `/risks/events` attendue par le client n'existe pas (repli en log dev). |
| **8. Dashboard & Analytics** | ✅ | `application/analytics`, `analytics_service`, `dashboard_data_service`, `stats_handler`, `features/dashboard`. **Prouvé live 08/07** (crash dashboard corrigé). | 2 widgets (`SecurityScore`, `AssetStatistics`) appellent `/analytics/security-score` & `/analytics/assets/statistics` **inexistants** (repli gracieux). Cache Redis d'invalidation à confirmer. |
diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go
index 1260509a..797a40fa 100644
--- a/backend/cmd/server/main.go
+++ b/backend/cmd/server/main.go
@@ -32,6 +32,7 @@ import (
notificationapp "github.com/opendefender/openrisk/internal/application/notification"
"github.com/opendefender/openrisk/internal/application/risk"
scanapp "github.com/opendefender/openrisk/internal/application/scanner"
+ vulnapp "github.com/opendefender/openrisk/internal/application/vulnerability"
coreauth "github.com/opendefender/openrisk/internal/auth"
"github.com/opendefender/openrisk/internal/config"
"github.com/opendefender/openrisk/internal/domain"
@@ -192,6 +193,10 @@ func main() {
// CTI / Intel Threat — vulnerabilities pulled from NVD + CISA KEV, enriched
// with MITRE ATT&CK. Matched against asset CPEs to auto-create risks.
&cti.CTIVulnerability{},
+ // Vulnerability Management (Module 3) — the tenant-scoped vulnerability
+ // register: findings normalised from Nessus/OpenVAS/Qualys/Defender/
+ // Inspector/Azure Defender/CrowdStrike and risk-based prioritised.
+ &domain.Vulnerability{},
// Notifications — the in-app centre + delivery preferences. Previously
// missing from AutoMigrate, so every /notifications route errored on a
// non-existent table (and the scan-completion in-app notification had
@@ -779,6 +784,32 @@ func main() {
protected.Delete("/assets/:id", assetDelete, assetHandler.DeleteAsset)
protected.Get("/assets/:id/history", assetRead, assetHandler.GetAssetHistory)
+ // --- Vulnerability Management (Module 3) — integrations + risk-based
+ // prioritisation. Findings from Nessus/OpenVAS/Qualys/Defender/Inspector/
+ // Azure Defender/CrowdStrike are normalised (internal/vulnscan), scored by
+ // pkg/vulnprio (CVSS + exploitability + business criticality + affected
+ // assets) and upserted into a tenant-scoped register.
+ vulnRepo := repository.NewGormVulnerabilityRepository(database.DB)
+ vulnHandler := handlers.NewVulnerabilityHandler(
+ vulnapp.NewIngestUseCase(vulnRepo, assetRepo),
+ vulnapp.NewListUseCase(vulnRepo),
+ vulnapp.NewGetUseCase(vulnRepo),
+ vulnapp.NewUpdateStatusUseCase(vulnRepo),
+ vulnapp.NewDeleteUseCase(vulnRepo),
+ vulnapp.NewStatsUseCase(vulnRepo),
+ )
+ vulnRead := middleware.RequirePermission("vulnerabilities:read")
+ vulnWrite := middleware.RequirePermission("vulnerabilities:update")
+ vulnDelete := middleware.RequirePermission("vulnerabilities:delete")
+ // Static resources first so they are never parsed as /:id.
+ protected.Get("/vulnerability-connectors", vulnRead, vulnHandler.ListConnectors)
+ protected.Get("/vulnerabilities/stats", vulnRead, vulnHandler.Stats)
+ protected.Post("/vulnerabilities/ingest", vulnWrite, vulnHandler.Ingest)
+ protected.Get("/vulnerabilities", vulnRead, vulnHandler.List)
+ protected.Get("/vulnerabilities/:id", vulnRead, vulnHandler.Get)
+ protected.Patch("/vulnerabilities/:id/status", vulnWrite, vulnHandler.UpdateStatus)
+ protected.Delete("/vulnerabilities/:id", vulnDelete, vulnHandler.Delete)
+
api.Get("/users/me", authHandler.GetProfile)
api.Get("/stats/risk-matrix", cacheableHandlers.CacheDashboardMatrixGET(handlers.GetRiskMatrixData))
api.Get("/stats/risk-distribution", cacheableHandlers.CacheDashboardStatsGET(handlers.GetRiskDistribution))
diff --git a/backend/internal/application/vulnerability/ingest.go b/backend/internal/application/vulnerability/ingest.go
new file mode 100644
index 00000000..cce644dc
--- /dev/null
+++ b/backend/internal/application/vulnerability/ingest.go
@@ -0,0 +1,213 @@
+// Copyright (c) 2026 OpenDefender Contributors
+// SPDX-License-Identifier: BUSL-1.1
+// This Source Code Form is subject to the terms of the Business Source License, Version 1.1.
+// If a copy of the BUSL was not distributed with this file, You can obtain one at https://mariadb.com/bsl11/
+
+// Package vulnerability holds the vulnerability-management use cases: ingest
+// (normalise + prioritise findings from Nessus/OpenVAS/Qualys/Defender/Inspector/
+// Azure Defender/CrowdStrike), list, get, update-status, delete and stats.
+package vulnerability
+
+import (
+ "context"
+ "strings"
+
+ "github.com/google/uuid"
+ "github.com/opendefender/openrisk/internal/domain"
+ "github.com/opendefender/openrisk/internal/vulnscan"
+ "github.com/opendefender/openrisk/pkg/vulnprio"
+)
+
+// IngestInput carries a batch of raw findings from a named integration.
+type IngestInput struct {
+ Source domain.VulnSource
+ Findings []map[string]any // raw findings as exported by the tool
+ DefaultAssetID *uuid.UUID // optional: attach every finding to this asset
+}
+
+// IngestResult summarises what an ingest produced.
+type IngestResult struct {
+ Source domain.VulnSource `json:"source"`
+ Received int `json:"received"`
+ Created int `json:"created"`
+ Updated int `json:"updated"`
+ Skipped int `json:"skipped"`
+ Vulnerabilities []domain.Vulnerability `json:"vulnerabilities"`
+}
+
+// IngestUseCase normalises raw findings, resolves the affected asset, computes a
+// priority (pkg/vulnprio) and upserts each into the tenant's register.
+type IngestUseCase struct {
+ vulnRepo domain.VulnerabilityRepository
+ assetRepo domain.AssetRepository
+}
+
+func NewIngestUseCase(v domain.VulnerabilityRepository, a domain.AssetRepository) *IngestUseCase {
+ return &IngestUseCase{vulnRepo: v, assetRepo: a}
+}
+
+func (uc *IngestUseCase) Execute(ctx context.Context, tenantID uuid.UUID, in IngestInput) (*IngestResult, error) {
+ source, err := domain.ParseVulnSource(string(in.Source))
+ if err != nil {
+ return nil, err
+ }
+ if len(in.Findings) == 0 {
+ return nil, domain.NewValidationError("no findings provided")
+ }
+
+ // Load tenant assets once for name/external-id matching (business criticality).
+ assets, _ := uc.assetRepo.List(ctx, tenantID)
+ byName := map[string]*domain.Asset{}
+ byExtID := map[string]*domain.Asset{}
+ byID := map[uuid.UUID]*domain.Asset{}
+ for i := range assets {
+ a := &assets[i]
+ byID[a.ID] = a
+ if a.Name != "" {
+ byName[strings.ToLower(a.Name)] = a
+ }
+ if a.ExternalID != "" {
+ byExtID[a.ExternalID] = a
+ }
+ }
+
+ res := &IngestResult{Source: source, Received: len(in.Findings)}
+ for _, raw := range in.Findings {
+ nf := vulnscan.Normalize(source, raw)
+ if nf.Title == "" && nf.CVEID == "" {
+ res.Skipped++
+ continue
+ }
+
+ // Resolve the affected asset: explicit id > tool external id > hostname.
+ var asset *domain.Asset
+ if in.DefaultAssetID != nil {
+ asset = byID[*in.DefaultAssetID]
+ }
+ if asset == nil && nf.AssetExternalID != "" {
+ asset = byExtID[nf.AssetExternalID]
+ }
+ if asset == nil && nf.AssetName != "" {
+ asset = byName[strings.ToLower(nf.AssetName)]
+ }
+
+ v := uc.build(ctx, tenantID, source, nf, asset)
+ created, err := uc.vulnRepo.Upsert(ctx, v)
+ if err != nil {
+ return nil, domain.NewInternalError("failed to upsert vulnerability: " + err.Error())
+ }
+
+ // Blast radius is only fully known once this row exists: inserting it may
+ // have grown the distinct-asset count for the CVE (the pre-insert count
+ // missed the row we just added). Refresh count + priority if so.
+ if v.CVEID != "" {
+ if n, e := uc.vulnRepo.CountAffectedAssets(ctx, tenantID, v.CVEID); e == nil && n != v.AffectedAssetsCount {
+ v.AffectedAssetsCount = n
+ uc.reprioritize(v)
+ _ = uc.vulnRepo.Update(ctx, v)
+ }
+ }
+
+ if created {
+ res.Created++
+ } else {
+ res.Updated++
+ }
+ res.Vulnerabilities = append(res.Vulnerabilities, *v)
+ }
+ return res, nil
+}
+
+// priorityInput derives the prioritisation input from a (built) vulnerability.
+func priorityInput(v *domain.Vulnerability) vulnprio.Input {
+ factor := 1.5
+ if v.AssetCriticality != "" {
+ factor = domain.AssetCriticality(v.AssetCriticality).ScoreFactor()
+ }
+ return vulnprio.Input{
+ CVSS: v.CVSSScore,
+ EPSS: v.EPSS,
+ KEV: v.KEV,
+ ExploitAvailable: v.ExploitAvailable,
+ ExploitMaturity: v.ExploitMaturity,
+ AssetCriticalityFactor: factor,
+ AffectedAssets: v.AffectedAssetsCount,
+ }
+}
+
+// reprioritize recomputes and applies the priority for a vulnerability in place.
+func (uc *IngestUseCase) reprioritize(v *domain.Vulnerability) {
+ prio := vulnprio.Compute(priorityInput(v))
+ v.PriorityScore, v.PriorityTier, v.PriorityExplanation = prio.Score, prio.Tier, prio.Explanation
+}
+
+// build maps a normalised finding + resolved asset into a prioritised Vulnerability.
+func (uc *IngestUseCase) build(ctx context.Context, tenantID uuid.UUID, source domain.VulnSource, nf vulnscan.NormalizedFinding, asset *domain.Asset) *domain.Vulnerability {
+ severity := domain.VulnSeverity(strings.ToLower(nf.Severity))
+ if severity == "" {
+ severity = domain.SeverityFromCVSS(nf.CVSSScore)
+ }
+
+ // Business criticality factor from the linked asset (unknown → MEDIUM).
+ critFactor := 1.5
+ assetName, assetCrit := nf.AssetName, ""
+ var assetID *uuid.UUID
+ if asset != nil {
+ critFactor = asset.Criticality.ScoreFactor()
+ assetName = asset.Name
+ assetCrit = string(asset.Criticality)
+ id := asset.ID
+ assetID = &id
+ }
+
+ // Blast radius: existing distinct assets carrying this CVE, plus a hint the
+ // source may have provided (e.g. Defender exposedMachinesCount).
+ affected := 1
+ if nf.CVEID != "" {
+ if n, err := uc.vulnRepo.CountAffectedAssets(ctx, tenantID, nf.CVEID); err == nil {
+ affected = n
+ }
+ }
+ if hint, ok := nf.Raw["_affected_hint"].(int); ok && hint > affected {
+ affected = hint
+ }
+
+ prio := vulnprio.Compute(vulnprio.Input{
+ CVSS: nf.CVSSScore,
+ EPSS: nf.EPSS,
+ KEV: nf.KEV,
+ ExploitAvailable: nf.ExploitAvailable,
+ ExploitMaturity: nf.ExploitMaturity,
+ AssetCriticalityFactor: critFactor,
+ AffectedAssets: affected,
+ })
+
+ v := &domain.Vulnerability{
+ TenantID: tenantID,
+ CVEID: nf.CVEID,
+ Title: strings.TrimSpace(nf.Title),
+ Description: nf.Description,
+ CVSSScore: nf.CVSSScore,
+ CVSSVector: nf.CVSSVector,
+ Severity: severity,
+ EPSS: nf.EPSS,
+ KEV: nf.KEV,
+ ExploitAvailable: nf.ExploitAvailable,
+ ExploitMaturity: nf.ExploitMaturity,
+ PriorityScore: prio.Score,
+ PriorityTier: prio.Tier,
+ PriorityExplanation: prio.Explanation,
+ AssetID: assetID,
+ AssetName: assetName,
+ AssetCriticality: assetCrit,
+ AffectedAssetsCount: affected,
+ Source: source,
+ ExternalID: nf.ExternalID,
+ Status: domain.VulnStatusOpen,
+ RemediationHint: nf.RemediationHint,
+ }
+ if v.Title == "" {
+ v.Title = nf.CVEID
+ }
+ return v
+}
diff --git a/backend/internal/application/vulnerability/mutations.go b/backend/internal/application/vulnerability/mutations.go
new file mode 100644
index 00000000..d4d45f0c
--- /dev/null
+++ b/backend/internal/application/vulnerability/mutations.go
@@ -0,0 +1,58 @@
+// Copyright (c) 2026 OpenDefender Contributors
+// SPDX-License-Identifier: BUSL-1.1
+// This Source Code Form is subject to the terms of the Business Source License, Version 1.1.
+// If a copy of the BUSL was not distributed with this file, You can obtain one at https://mariadb.com/bsl11/
+
+package vulnerability
+
+import (
+ "context"
+ "time"
+
+ "github.com/google/uuid"
+ "github.com/opendefender/openrisk/internal/domain"
+)
+
+// UpdateStatusUseCase moves a vulnerability through its remediation lifecycle
+// (open → triaged → in_remediation → remediated / accepted / false_positive).
+type UpdateStatusUseCase struct{ repo domain.VulnerabilityRepository }
+
+func NewUpdateStatusUseCase(r domain.VulnerabilityRepository) *UpdateStatusUseCase {
+ return &UpdateStatusUseCase{repo: r}
+}
+
+func (uc *UpdateStatusUseCase) Execute(ctx context.Context, tenantID, id uuid.UUID, rawStatus string) (*domain.Vulnerability, error) {
+ status, err := domain.ParseVulnStatus(rawStatus)
+ if err != nil {
+ return nil, err
+ }
+ v, err := uc.repo.GetByID(ctx, id, tenantID)
+ if err != nil {
+ return nil, domain.NewInternalError(err.Error())
+ }
+ if v == nil {
+ return nil, domain.NewNotFoundError("vulnerability", id)
+ }
+ v.Status = status
+ v.UpdatedAt = time.Now()
+ if err := uc.repo.Update(ctx, v); err != nil {
+ return nil, domain.NewInternalError("failed to update vulnerability status: " + err.Error())
+ }
+ return v, nil
+}
+
+// DeleteUseCase soft-deletes a vulnerability (tenant-scoped).
+type DeleteUseCase struct{ repo domain.VulnerabilityRepository }
+
+func NewDeleteUseCase(r domain.VulnerabilityRepository) *DeleteUseCase { return &DeleteUseCase{repo: r} }
+
+func (uc *DeleteUseCase) Execute(ctx context.Context, tenantID, id uuid.UUID) error {
+ v, err := uc.repo.GetByID(ctx, id, tenantID)
+ if err != nil {
+ return domain.NewInternalError(err.Error())
+ }
+ if v == nil {
+ return domain.NewNotFoundError("vulnerability", id)
+ }
+ return uc.repo.Delete(ctx, id, tenantID)
+}
diff --git a/backend/internal/application/vulnerability/queries.go b/backend/internal/application/vulnerability/queries.go
new file mode 100644
index 00000000..f448ab45
--- /dev/null
+++ b/backend/internal/application/vulnerability/queries.go
@@ -0,0 +1,47 @@
+// Copyright (c) 2026 OpenDefender Contributors
+// SPDX-License-Identifier: BUSL-1.1
+// This Source Code Form is subject to the terms of the Business Source License, Version 1.1.
+// If a copy of the BUSL was not distributed with this file, You can obtain one at https://mariadb.com/bsl11/
+
+package vulnerability
+
+import (
+ "context"
+
+ "github.com/google/uuid"
+ "github.com/opendefender/openrisk/internal/domain"
+)
+
+// ListUseCase returns a tenant's vulnerabilities, prioritised.
+type ListUseCase struct{ repo domain.VulnerabilityRepository }
+
+func NewListUseCase(r domain.VulnerabilityRepository) *ListUseCase { return &ListUseCase{repo: r} }
+
+func (uc *ListUseCase) Execute(ctx context.Context, tenantID uuid.UUID, q domain.VulnerabilityQuery) (*domain.PaginatedResult[domain.Vulnerability], error) {
+ return uc.repo.List(ctx, tenantID, q)
+}
+
+// GetUseCase returns a single vulnerability (tenant-scoped → 404 on cross-tenant).
+type GetUseCase struct{ repo domain.VulnerabilityRepository }
+
+func NewGetUseCase(r domain.VulnerabilityRepository) *GetUseCase { return &GetUseCase{repo: r} }
+
+func (uc *GetUseCase) Execute(ctx context.Context, tenantID, id uuid.UUID) (*domain.Vulnerability, error) {
+ v, err := uc.repo.GetByID(ctx, id, tenantID)
+ if err != nil {
+ return nil, domain.NewInternalError(err.Error())
+ }
+ if v == nil {
+ return nil, domain.NewNotFoundError("vulnerability", id)
+ }
+ return v, nil
+}
+
+// StatsUseCase returns the aggregate posture.
+type StatsUseCase struct{ repo domain.VulnerabilityRepository }
+
+func NewStatsUseCase(r domain.VulnerabilityRepository) *StatsUseCase { return &StatsUseCase{repo: r} }
+
+func (uc *StatsUseCase) Execute(ctx context.Context, tenantID uuid.UUID) (*domain.VulnStats, error) {
+ return uc.repo.Stats(ctx, tenantID)
+}
diff --git a/backend/internal/application/vulnerability/vulnerability_test.go b/backend/internal/application/vulnerability/vulnerability_test.go
new file mode 100644
index 00000000..374d43c3
--- /dev/null
+++ b/backend/internal/application/vulnerability/vulnerability_test.go
@@ -0,0 +1,206 @@
+// Copyright (c) 2026 OpenDefender Contributors
+// SPDX-License-Identifier: BUSL-1.1
+// This Source Code Form is subject to the terms of the Business Source License, Version 1.1.
+// If a copy of the BUSL was not distributed with this file, You can obtain one at https://mariadb.com/bsl11/
+
+package vulnerability
+
+import (
+ "context"
+ "errors"
+ "testing"
+
+ "github.com/google/uuid"
+ "github.com/opendefender/openrisk/internal/domain"
+)
+
+// --- mocks ---
+
+type mockVulnRepo struct {
+ store map[uuid.UUID]*domain.Vulnerability
+ byKey map[string]*domain.Vulnerability
+ affected int
+}
+
+func newMockVulnRepo() *mockVulnRepo {
+ return &mockVulnRepo{store: map[uuid.UUID]*domain.Vulnerability{}, byKey: map[string]*domain.Vulnerability{}, affected: 1}
+}
+
+func (m *mockVulnRepo) Upsert(ctx context.Context, v *domain.Vulnerability) (bool, error) {
+ if v.TenantID == uuid.Nil {
+ return false, errors.New("missing tenant")
+ }
+ if ex, ok := m.byKey[v.DedupKey()]; ok {
+ v.ID = ex.ID
+ m.store[ex.ID] = v
+ m.byKey[v.DedupKey()] = v
+ return false, nil
+ }
+ if v.ID == uuid.Nil {
+ v.ID = uuid.New()
+ }
+ m.store[v.ID] = v
+ m.byKey[v.DedupKey()] = v
+ return true, nil
+}
+func (m *mockVulnRepo) GetByID(ctx context.Context, id, tenantID uuid.UUID) (*domain.Vulnerability, error) {
+ v, ok := m.store[id]
+ if !ok || v.TenantID != tenantID {
+ return nil, nil
+ }
+ return v, nil
+}
+func (m *mockVulnRepo) List(ctx context.Context, tenantID uuid.UUID, q domain.VulnerabilityQuery) (*domain.PaginatedResult[domain.Vulnerability], error) {
+ var out []domain.Vulnerability
+ for _, v := range m.store {
+ if v.TenantID == tenantID {
+ out = append(out, *v)
+ }
+ }
+ return &domain.PaginatedResult[domain.Vulnerability]{Data: out, Total: int64(len(out))}, nil
+}
+func (m *mockVulnRepo) Update(ctx context.Context, v *domain.Vulnerability) error {
+ m.store[v.ID] = v
+ return nil
+}
+func (m *mockVulnRepo) Delete(ctx context.Context, id, tenantID uuid.UUID) error {
+ delete(m.store, id)
+ return nil
+}
+func (m *mockVulnRepo) Stats(ctx context.Context, tenantID uuid.UUID) (*domain.VulnStats, error) {
+ return &domain.VulnStats{Total: int64(len(m.store))}, nil
+}
+func (m *mockVulnRepo) CountAffectedAssets(ctx context.Context, tenantID uuid.UUID, cve string) (int, error) {
+ return m.affected, nil
+}
+
+type mockAssetRepo struct{ assets []domain.Asset }
+
+func (m *mockAssetRepo) Create(ctx context.Context, a *domain.Asset) error { return nil }
+func (m *mockAssetRepo) GetByID(ctx context.Context, id, t uuid.UUID) (*domain.Asset, error) {
+ for i := range m.assets {
+ if m.assets[i].ID == id {
+ return &m.assets[i], nil
+ }
+ }
+ return nil, nil
+}
+func (m *mockAssetRepo) List(ctx context.Context, t uuid.UUID) ([]domain.Asset, error) {
+ return m.assets, nil
+}
+func (m *mockAssetRepo) Update(ctx context.Context, a *domain.Asset) error { return nil }
+func (m *mockAssetRepo) Delete(ctx context.Context, id, t uuid.UUID) error { return nil }
+func (m *mockAssetRepo) CreateSnapshot(ctx context.Context, s *domain.AssetSnapshot) error {
+ return nil
+}
+func (m *mockAssetRepo) ListSnapshots(ctx context.Context, id, t uuid.UUID) ([]domain.AssetSnapshot, error) {
+ return nil, nil
+}
+
+// --- tests ---
+
+func TestIngest_NessusFinding_NormalizesAndPrioritizes(t *testing.T) {
+ tenant := uuid.New()
+ assetID := uuid.New()
+ vrepo := newMockVulnRepo()
+ arepo := &mockAssetRepo{assets: []domain.Asset{{ID: assetID, Name: "web-01", Criticality: domain.CriticalityCritical}}}
+ uc := NewIngestUseCase(vrepo, arepo)
+
+ res, err := uc.Execute(context.Background(), tenant, IngestInput{
+ Source: domain.VulnSourceNessus,
+ Findings: []map[string]any{{
+ "plugin_id": "12345",
+ "plugin_name": "Apache Log4j RCE",
+ "cvss3_base_score": 9.8,
+ "cve": "CVE-2021-44228",
+ "severity": 4.0,
+ "host": "web-01",
+ "solution": "Upgrade log4j",
+ }},
+ })
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if res.Created != 1 || res.Received != 1 {
+ t.Fatalf("expected 1 created/received, got created=%d received=%d", res.Created, res.Received)
+ }
+ v := res.Vulnerabilities[0]
+ if v.CVEID != "CVE-2021-44228" {
+ t.Errorf("expected CVE extracted, got %q", v.CVEID)
+ }
+ if v.Severity != domain.VulnSeverityCritical {
+ t.Errorf("expected critical severity, got %q", v.Severity)
+ }
+ if v.AssetID == nil || *v.AssetID != assetID {
+ t.Error("expected finding matched to web-01 asset by hostname")
+ }
+ if v.AssetCriticality != "CRITICAL" {
+ t.Errorf("expected business criticality snapshot CRITICAL, got %q", v.AssetCriticality)
+ }
+ // Risk-based prioritisation: a CVSS 9.8 on a critical asset with NO exploit
+ // signal is P2, not P1 — exploitability is what earns P1 ("patch now"). See
+ // the KEV/exploit tests in pkg/vulnprio for the P1 cases.
+ if v.PriorityScore < 60 || v.PriorityTier != "P2" {
+ t.Errorf("expected P2 (>=60) for a CVSS 9.8 no-exploit finding on a critical asset, got %.2f %s", v.PriorityScore, v.PriorityTier)
+ }
+}
+
+func TestIngest_Reingest_UpdatesNotDuplicates(t *testing.T) {
+ tenant := uuid.New()
+ vrepo := newMockVulnRepo()
+ uc := NewIngestUseCase(vrepo, &mockAssetRepo{})
+ find := IngestInput{Source: domain.VulnSourceQualys, Findings: []map[string]any{{
+ "QID": "90001", "TITLE": "OpenSSL flaw", "CVSS_BASE": 7.5, "CVE_ID": "CVE-2022-0001", "SEVERITY": 4.0,
+ }}}
+ _, _ = uc.Execute(context.Background(), tenant, find)
+ res2, err := uc.Execute(context.Background(), tenant, find)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if res2.Created != 0 || res2.Updated != 1 {
+ t.Errorf("re-ingest should update not duplicate: created=%d updated=%d", res2.Created, res2.Updated)
+ }
+ if len(vrepo.store) != 1 {
+ t.Errorf("expected 1 stored vuln after re-ingest, got %d", len(vrepo.store))
+ }
+}
+
+func TestIngest_UnknownSource_Rejected(t *testing.T) {
+ uc := NewIngestUseCase(newMockVulnRepo(), &mockAssetRepo{})
+ _, err := uc.Execute(context.Background(), uuid.New(), IngestInput{Source: domain.VulnSource("banana"), Findings: []map[string]any{{"title": "x"}}})
+ if !errors.Is(err, domain.ErrValidation) {
+ t.Errorf("expected ErrValidation for unknown source, got %v", err)
+ }
+}
+
+func TestIngest_EmptyBatch_Rejected(t *testing.T) {
+ uc := NewIngestUseCase(newMockVulnRepo(), &mockAssetRepo{})
+ _, err := uc.Execute(context.Background(), uuid.New(), IngestInput{Source: domain.VulnSourceNessus})
+ if !errors.Is(err, domain.ErrValidation) {
+ t.Errorf("expected ErrValidation for empty batch, got %v", err)
+ }
+}
+
+func TestUpdateStatus_Success_And_NotFound(t *testing.T) {
+ tenant := uuid.New()
+ vrepo := newMockVulnRepo()
+ v := &domain.Vulnerability{ID: uuid.New(), TenantID: tenant, Status: domain.VulnStatusOpen}
+ vrepo.store[v.ID] = v
+ uc := NewUpdateStatusUseCase(vrepo)
+
+ updated, err := uc.Execute(context.Background(), tenant, v.ID, "remediated")
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if updated.Status != domain.VulnStatusRemediated {
+ t.Errorf("expected remediated, got %s", updated.Status)
+ }
+ // cross-tenant → not found
+ if _, err := uc.Execute(context.Background(), uuid.New(), v.ID, "accepted"); !errors.Is(err, domain.ErrNotFound) {
+ t.Errorf("expected ErrNotFound for cross-tenant, got %v", err)
+ }
+ // invalid status
+ if _, err := uc.Execute(context.Background(), tenant, v.ID, "banana"); !errors.Is(err, domain.ErrValidation) {
+ t.Errorf("expected ErrValidation for bad status, got %v", err)
+ }
+}
diff --git a/backend/internal/domain/vulnerability.go b/backend/internal/domain/vulnerability.go
new file mode 100644
index 00000000..6d9f06cb
--- /dev/null
+++ b/backend/internal/domain/vulnerability.go
@@ -0,0 +1,186 @@
+// Copyright (c) 2026 OpenDefender Contributors
+// SPDX-License-Identifier: BUSL-1.1
+// This Source Code Form is subject to the terms of the Business Source License, Version 1.1.
+// If a copy of the BUSL was not distributed with this file, You can obtain one at https://mariadb.com/bsl11/
+
+package domain
+
+import (
+ "fmt"
+ "time"
+
+ "github.com/google/uuid"
+ "gorm.io/datatypes"
+ "gorm.io/gorm"
+)
+
+// VulnSeverity is the coarse severity band of a vulnerability, aligned with the
+// scanner + CTI vocabularies (critical|high|medium|low|info).
+type VulnSeverity string
+
+const (
+ VulnSeverityCritical VulnSeverity = "critical"
+ VulnSeverityHigh VulnSeverity = "high"
+ VulnSeverityMedium VulnSeverity = "medium"
+ VulnSeverityLow VulnSeverity = "low"
+ VulnSeverityInfo VulnSeverity = "info"
+)
+
+// SeverityFromCVSS maps a CVSS base score (0–10) to a severity band (CVSS v3 ranges).
+func SeverityFromCVSS(cvss float64) VulnSeverity {
+ switch {
+ case cvss >= 9.0:
+ return VulnSeverityCritical
+ case cvss >= 7.0:
+ return VulnSeverityHigh
+ case cvss >= 4.0:
+ return VulnSeverityMedium
+ case cvss > 0.0:
+ return VulnSeverityLow
+ default:
+ return VulnSeverityInfo
+ }
+}
+
+// VulnStatus is the remediation lifecycle state of a vulnerability.
+type VulnStatus string
+
+const (
+ VulnStatusOpen VulnStatus = "open"
+ VulnStatusTriaged VulnStatus = "triaged"
+ VulnStatusInRemediation VulnStatus = "in_remediation"
+ VulnStatusRemediated VulnStatus = "remediated"
+ VulnStatusAccepted VulnStatus = "accepted"
+ VulnStatusFalsePositive VulnStatus = "false_positive"
+)
+
+// ParseVulnStatus validates a status string (empty → open).
+func ParseVulnStatus(s string) (VulnStatus, error) {
+ if s == "" {
+ return VulnStatusOpen, nil
+ }
+ switch VulnStatus(s) {
+ case VulnStatusOpen, VulnStatusTriaged, VulnStatusInRemediation,
+ VulnStatusRemediated, VulnStatusAccepted, VulnStatusFalsePositive:
+ return VulnStatus(s), nil
+ default:
+ return "", NewValidationError(fmt.Sprintf("invalid vulnerability status: %q", s))
+ }
+}
+
+// VulnSource identifies where a vulnerability came from — the named integrations
+// plus the built-in scanner and manual entry.
+type VulnSource string
+
+const (
+ VulnSourceNessus VulnSource = "nessus" // Tenable Nessus
+ VulnSourceOpenVAS VulnSource = "openvas" // OpenVAS / Greenbone GVM
+ VulnSourceQualys VulnSource = "qualys" // Qualys VMDR
+ VulnSourceMSDefender VulnSource = "ms_defender" // Microsoft Defender for Endpoint
+ VulnSourceAWSInspector VulnSource = "aws_inspector" // AWS Inspector
+ VulnSourceAzureDefender VulnSource = "azure_defender" // Microsoft Defender for Cloud
+ VulnSourceCrowdStrike VulnSource = "crowdstrike" // CrowdStrike Falcon Spotlight
+ VulnSourceScanner VulnSource = "scanner" // OpenRisk built-in scanner (Module 6)
+ VulnSourceManual VulnSource = "manual"
+)
+
+// SupportedVulnSources is the ordered list of integration sources surfaced in
+// the UI's "Connectors" panel.
+var SupportedVulnSources = []VulnSource{
+ VulnSourceNessus, VulnSourceOpenVAS, VulnSourceQualys, VulnSourceMSDefender,
+ VulnSourceAWSInspector, VulnSourceAzureDefender, VulnSourceCrowdStrike,
+ VulnSourceScanner, VulnSourceManual,
+}
+
+// ParseVulnSource validates a source string (empty → manual).
+func ParseVulnSource(s string) (VulnSource, error) {
+ if s == "" {
+ return VulnSourceManual, nil
+ }
+ for _, src := range SupportedVulnSources {
+ if VulnSource(s) == src {
+ return src, nil
+ }
+ }
+ return "", NewValidationError(fmt.Sprintf("invalid vulnerability source: %q", s))
+}
+
+// Vulnerability is a persistent, tenant-scoped finding awaiting triage and
+// remediation. It is DISTINCT from a Risk (business-level) and from the
+// scanner's transient FindingDiscovery preview: this is the vulnerability
+// register, prioritised by pkg/vulnprio.
+type Vulnerability struct {
+ ID uuid.UUID `gorm:"type:uuid;default:gen_random_uuid();primaryKey" json:"id"`
+ TenantID uuid.UUID `gorm:"type:uuid;not null;index" json:"tenant_id"`
+
+ // Identity
+ CVEID string `gorm:"size:64;index" json:"cve_id"` // may be empty for non-CVE findings
+ Title string `gorm:"size:512;not null" json:"title"`
+ Description string `gorm:"type:text" json:"description"`
+
+ // Technical scoring
+ CVSSScore float64 `gorm:"type:numeric(4,1);index" json:"cvss_score"` // 0.0–10.0
+ CVSSVector string `gorm:"size:128" json:"cvss_vector"`
+ Severity VulnSeverity `gorm:"type:varchar(16);index" json:"severity"`
+
+ // Exploitability signals
+ EPSS float64 `gorm:"type:numeric(6,5)" json:"epss"` // 0.0–1.0 (FIRST EPSS probability)
+ KEV bool `gorm:"index" json:"kev"` // CISA Known-Exploited
+ ExploitAvailable bool `json:"exploit_available"`
+ ExploitMaturity string `gorm:"size:32" json:"exploit_maturity"` // none|poc|functional|high
+
+ // Prioritisation (computed by pkg/vulnprio, persisted so the register can sort on it)
+ PriorityScore float64 `gorm:"type:numeric(5,2);index" json:"priority_score"` // 0–100
+ PriorityTier string `gorm:"size:8;index" json:"priority_tier"` // P1|P2|P3|P4
+
+ // Asset linkage — business criticality + blast radius
+ AssetID *uuid.UUID `gorm:"type:uuid;index" json:"asset_id"`
+ AssetName string `gorm:"size:255" json:"asset_name"`
+ AssetCriticality string `gorm:"size:16" json:"asset_criticality"` // snapshot: LOW|MEDIUM|HIGH|CRITICAL
+ AffectedAssetsCount int `gorm:"default:1" json:"affected_assets_count"`
+
+ // Provenance
+ Source VulnSource `gorm:"type:varchar(24);index" json:"source"`
+ ExternalID string `gorm:"size:255;index" json:"external_id"` // id in the source tool (dedup key)
+
+ // Remediation lifecycle
+ Status VulnStatus `gorm:"type:varchar(24);default:'open';index" json:"status"`
+ RemediationHint string `gorm:"type:text" json:"remediation_hint"`
+
+ FirstSeen time.Time `json:"first_seen"`
+ LastSeen time.Time `gorm:"index" json:"last_seen"`
+ RawData datatypes.JSON `gorm:"type:jsonb" json:"raw_data,omitempty"`
+
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+ DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
+
+ // Computed, NOT persisted — filled by the use case for the API response.
+ PriorityExplanation string `gorm:"-" json:"priority_explanation,omitempty"`
+}
+
+// DedupKey is the natural identity used to upsert a vulnerability on repeated
+// ingests: the source tool's own id when present, otherwise CVE+asset. Keeps a
+// re-scan from creating duplicates while letting scores/last-seen refresh.
+func (v *Vulnerability) DedupKey() string {
+ if v.ExternalID != "" {
+ return string(v.Source) + "|" + v.ExternalID
+ }
+ asset := ""
+ if v.AssetID != nil {
+ asset = v.AssetID.String()
+ }
+ return string(v.Source) + "|" + v.CVEID + "|" + asset
+}
+
+// VulnStats is the aggregate posture returned by GET /vulnerabilities/stats.
+type VulnStats struct {
+ Total int64 `json:"total"`
+ Open int64 `json:"open"`
+ BySeverity map[string]int64 `json:"by_severity"`
+ ByStatus map[string]int64 `json:"by_status"`
+ BySource map[string]int64 `json:"by_source"`
+ ByTier map[string]int64 `json:"by_tier"`
+ KEVCount int64 `json:"kev_count"`
+ ExploitCount int64 `json:"exploit_count"`
+}
diff --git a/backend/internal/domain/vulnerability_repository.go b/backend/internal/domain/vulnerability_repository.go
new file mode 100644
index 00000000..b3f61877
--- /dev/null
+++ b/backend/internal/domain/vulnerability_repository.go
@@ -0,0 +1,86 @@
+// Copyright (c) 2026 OpenDefender Contributors
+// SPDX-License-Identifier: BUSL-1.1
+// This Source Code Form is subject to the terms of the Business Source License, Version 1.1.
+// If a copy of the BUSL was not distributed with this file, You can obtain one at https://mariadb.com/bsl11/
+
+package domain
+
+import (
+ "context"
+
+ "github.com/google/uuid"
+)
+
+// VulnerabilityQuery encapsulates filtering/sorting/pagination for the register.
+type VulnerabilityQuery struct {
+ Search string // matches CVE id + title
+ Severities []string // OR
+ Statuses []string // OR
+ Sources []string // OR
+ Tiers []string // OR (P1..P4)
+ AssetID *uuid.UUID
+ KEVOnly bool
+ MinCVSS *float64
+ MinPriority *float64
+
+ Page int
+ Limit int
+ // Sorting — whitelisted in the repository. Default: priority_score desc.
+ SortBy string
+ SortOrder string
+}
+
+// VulnerabilityRepository is the persistence port. ABSOLUTE RULE: every method
+// filters by tenant_id in the repository — a vuln from another tenant is 404.
+type VulnerabilityRepository interface {
+ // Upsert inserts a new vulnerability or refreshes an existing one matched by
+ // its DedupKey within the tenant (score/last-seen/exploitability refresh).
+ // Returns the persisted row and whether it was newly created.
+ Upsert(ctx context.Context, v *Vulnerability) (created bool, err error)
+
+ GetByID(ctx context.Context, id, tenantID uuid.UUID) (*Vulnerability, error)
+ List(ctx context.Context, tenantID uuid.UUID, q VulnerabilityQuery) (*PaginatedResult[Vulnerability], error)
+ Update(ctx context.Context, v *Vulnerability) error
+ Delete(ctx context.Context, id, tenantID uuid.UUID) error
+ Stats(ctx context.Context, tenantID uuid.UUID) (*VulnStats, error)
+
+ // CountAffectedAssets returns how many distinct assets carry the same CVE for
+ // a tenant — the blast radius used by the prioritisation engine.
+ CountAffectedAssets(ctx context.Context, tenantID uuid.UUID, cveID string) (int, error)
+}
+
+// NewVulnerabilityQuery returns a query with sensible defaults.
+func NewVulnerabilityQuery() VulnerabilityQuery {
+ return VulnerabilityQuery{Page: 1, Limit: 25, SortBy: "priority_score", SortOrder: "desc"}
+}
+
+// Sanitize clamps pagination and whitelists the sort column.
+func (q *VulnerabilityQuery) Sanitize() {
+ if q.Page < 1 {
+ q.Page = 1
+ }
+ if q.Limit < 1 {
+ q.Limit = 25
+ }
+ if q.Limit > 200 {
+ q.Limit = 200
+ }
+ allowed := map[string]bool{
+ "priority_score": true, "cvss_score": true, "severity": true,
+ "last_seen": true, "created_at": true, "status": true,
+ }
+ if !allowed[q.SortBy] {
+ q.SortBy = "priority_score"
+ }
+ if q.SortOrder != "asc" && q.SortOrder != "desc" {
+ q.SortOrder = "desc"
+ }
+}
+
+// Offset is the SQL offset for the current page.
+func (q VulnerabilityQuery) Offset() int {
+ if q.Page < 1 {
+ return 0
+ }
+ return (q.Page - 1) * q.Limit
+}
diff --git a/backend/internal/handler/vulnerability_handler.go b/backend/internal/handler/vulnerability_handler.go
new file mode 100644
index 00000000..8fccc491
--- /dev/null
+++ b/backend/internal/handler/vulnerability_handler.go
@@ -0,0 +1,191 @@
+// Copyright (c) 2026 OpenDefender Contributors
+// SPDX-License-Identifier: BUSL-1.1
+// This Source Code Form is subject to the terms of the Business Source License, Version 1.1.
+// If a copy of the BUSL was not distributed with this file, You can obtain one at https://mariadb.com/bsl11/
+
+package handler
+
+import (
+ "strconv"
+ "strings"
+
+ "github.com/gofiber/fiber/v2"
+ "github.com/google/uuid"
+ vulnapp "github.com/opendefender/openrisk/internal/application/vulnerability"
+ "github.com/opendefender/openrisk/internal/domain"
+ "github.com/opendefender/openrisk/internal/middleware"
+ "github.com/opendefender/openrisk/internal/vulnscan"
+)
+
+// VulnerabilityHandler exposes the vulnerability-management use cases.
+type VulnerabilityHandler struct {
+ ingest *vulnapp.IngestUseCase
+ list *vulnapp.ListUseCase
+ get *vulnapp.GetUseCase
+ updateStatus *vulnapp.UpdateStatusUseCase
+ del *vulnapp.DeleteUseCase
+ stats *vulnapp.StatsUseCase
+}
+
+func NewVulnerabilityHandler(
+ ingest *vulnapp.IngestUseCase,
+ list *vulnapp.ListUseCase,
+ get *vulnapp.GetUseCase,
+ updateStatus *vulnapp.UpdateStatusUseCase,
+ del *vulnapp.DeleteUseCase,
+ stats *vulnapp.StatsUseCase,
+) *VulnerabilityHandler {
+ return &VulnerabilityHandler{ingest: ingest, list: list, get: get, updateStatus: updateStatus, del: del, stats: stats}
+}
+
+func (h *VulnerabilityHandler) tenant(c *fiber.Ctx) uuid.UUID {
+ if mw := middleware.GetContext(c); mw != nil {
+ return mw.OrganizationID
+ }
+ return uuid.Nil
+}
+
+// ListConnectors GET /vulnerability-connectors — the supported integrations.
+func (h *VulnerabilityHandler) ListConnectors(c *fiber.Ctx) error {
+ return c.JSON(fiber.Map{"connectors": vulnscan.Connectors()})
+}
+
+// IngestInput is the POST /vulnerabilities/ingest body.
+type IngestReqBody struct {
+ Source string `json:"source" validate:"required"`
+ Findings []map[string]any `json:"findings" validate:"required"`
+ DefaultAssetID string `json:"default_asset_id"`
+}
+
+// Ingest POST /vulnerabilities/ingest — normalise + prioritise + upsert findings
+// from a named integration (Nessus/OpenVAS/Qualys/Defender/Inspector/AzureDefender/CrowdStrike).
+func (h *VulnerabilityHandler) Ingest(c *fiber.Ctx) error {
+ var body IngestReqBody
+ if err := c.BodyParser(&body); err != nil {
+ return c.Status(400).JSON(fiber.Map{"error": "invalid input", "details": err.Error()})
+ }
+ if len(body.Findings) == 0 {
+ return c.Status(400).JSON(fiber.Map{"error": "no findings provided"})
+ }
+ in := vulnapp.IngestInput{Source: domain.VulnSource(body.Source), Findings: body.Findings}
+ if body.DefaultAssetID != "" {
+ id, err := uuid.Parse(body.DefaultAssetID)
+ if err != nil {
+ return c.Status(400).JSON(fiber.Map{"error": "invalid default_asset_id"})
+ }
+ in.DefaultAssetID = &id
+ }
+ res, err := h.ingest.Execute(c.UserContext(), h.tenant(c), in)
+ if err != nil {
+ return writeAppError(c, err)
+ }
+ return c.Status(201).JSON(res)
+}
+
+// List GET /vulnerabilities — filtered, prioritised register.
+func (h *VulnerabilityHandler) List(c *fiber.Ctx) error {
+ q := domain.NewVulnerabilityQuery()
+ if v := c.Query("q"); v != "" {
+ q.Search = v
+ }
+ if v := c.Query("severity"); v != "" {
+ q.Severities = strings.Split(v, ",")
+ }
+ if v := c.Query("status"); v != "" {
+ q.Statuses = strings.Split(v, ",")
+ }
+ if v := c.Query("source"); v != "" {
+ q.Sources = strings.Split(v, ",")
+ }
+ if v := c.Query("tier"); v != "" {
+ q.Tiers = strings.Split(v, ",")
+ }
+ if c.Query("kev") == "true" {
+ q.KEVOnly = true
+ }
+ if v := c.Query("min_cvss"); v != "" {
+ if f, err := strconv.ParseFloat(v, 64); err == nil {
+ q.MinCVSS = &f
+ }
+ }
+ if v := c.Query("asset_id"); v != "" {
+ if id, err := uuid.Parse(v); err == nil {
+ q.AssetID = &id
+ }
+ }
+ if v := c.Query("page"); v != "" {
+ if p, err := strconv.Atoi(v); err == nil {
+ q.Page = p
+ }
+ }
+ if v := c.Query("limit"); v != "" {
+ if l, err := strconv.Atoi(v); err == nil {
+ q.Limit = l
+ }
+ }
+ if v := c.Query("sort_by"); v != "" {
+ q.SortBy = v
+ }
+ if v := c.Query("sort_dir"); v != "" {
+ q.SortOrder = strings.ToLower(v)
+ }
+
+ res, err := h.list.Execute(c.UserContext(), h.tenant(c), q)
+ if err != nil {
+ return c.Status(500).JSON(fiber.Map{"error": "could not list vulnerabilities", "details": err.Error()})
+ }
+ return c.JSON(fiber.Map{"items": res.Data, "total": res.Total, "page": res.Page, "limit": res.Limit})
+}
+
+// Get GET /vulnerabilities/:id
+func (h *VulnerabilityHandler) Get(c *fiber.Ctx) error {
+ id, err := uuid.Parse(c.Params("id"))
+ if err != nil {
+ return c.Status(400).JSON(fiber.Map{"error": "invalid uuid"})
+ }
+ v, err := h.get.Execute(c.UserContext(), h.tenant(c), id)
+ if err != nil {
+ return writeAppError(c, err)
+ }
+ return c.JSON(v)
+}
+
+// Stats GET /vulnerabilities/stats
+func (h *VulnerabilityHandler) Stats(c *fiber.Ctx) error {
+ s, err := h.stats.Execute(c.UserContext(), h.tenant(c))
+ if err != nil {
+ return c.Status(500).JSON(fiber.Map{"error": "could not compute stats", "details": err.Error()})
+ }
+ return c.JSON(s)
+}
+
+// UpdateStatus PATCH /vulnerabilities/:id/status
+func (h *VulnerabilityHandler) UpdateStatus(c *fiber.Ctx) error {
+ id, err := uuid.Parse(c.Params("id"))
+ if err != nil {
+ return c.Status(400).JSON(fiber.Map{"error": "invalid uuid"})
+ }
+ var body struct {
+ Status string `json:"status"`
+ }
+ if err := c.BodyParser(&body); err != nil {
+ return c.Status(400).JSON(fiber.Map{"error": "invalid input"})
+ }
+ v, err := h.updateStatus.Execute(c.UserContext(), h.tenant(c), id, body.Status)
+ if err != nil {
+ return writeAppError(c, err)
+ }
+ return c.JSON(v)
+}
+
+// Delete DELETE /vulnerabilities/:id
+func (h *VulnerabilityHandler) Delete(c *fiber.Ctx) error {
+ id, err := uuid.Parse(c.Params("id"))
+ if err != nil {
+ return c.Status(400).JSON(fiber.Map{"error": "invalid uuid"})
+ }
+ if err := h.del.Execute(c.UserContext(), h.tenant(c), id); err != nil {
+ return writeAppError(c, err)
+ }
+ return c.SendStatus(204)
+}
diff --git a/backend/internal/infrastructure/repository/gorm_vulnerability_repository.go b/backend/internal/infrastructure/repository/gorm_vulnerability_repository.go
new file mode 100644
index 00000000..8d47e88f
--- /dev/null
+++ b/backend/internal/infrastructure/repository/gorm_vulnerability_repository.go
@@ -0,0 +1,216 @@
+// Copyright (c) 2026 OpenDefender Contributors
+// SPDX-License-Identifier: BUSL-1.1
+// This Source Code Form is subject to the terms of the Business Source License, Version 1.1.
+// If a copy of the BUSL was not distributed with this file, You can obtain one at https://mariadb.com/bsl11/
+
+package repository
+
+import (
+ "context"
+ "strings"
+ "time"
+
+ "github.com/google/uuid"
+ "github.com/opendefender/openrisk/internal/domain"
+ "gorm.io/gorm"
+)
+
+// GormVulnerabilityRepository is the Postgres-backed vulnerability register.
+// ABSOLUTE RULE: every query filters by tenant_id.
+type GormVulnerabilityRepository struct {
+ db *gorm.DB
+}
+
+func NewGormVulnerabilityRepository(db *gorm.DB) *GormVulnerabilityRepository {
+ return &GormVulnerabilityRepository{db: db}
+}
+
+var _ domain.VulnerabilityRepository = (*GormVulnerabilityRepository)(nil)
+
+// Upsert inserts or refreshes a vulnerability matched by its dedup identity
+// within the tenant. On a match it refreshes the volatile fields (scores,
+// exploitability, last-seen, priority) but preserves the human triage Status.
+func (r *GormVulnerabilityRepository) Upsert(ctx context.Context, v *domain.Vulnerability) (bool, error) {
+ q := r.db.WithContext(ctx).Where("tenant_id = ? AND source = ?", v.TenantID, v.Source)
+ if v.ExternalID != "" {
+ q = q.Where("external_id = ?", v.ExternalID)
+ } else {
+ q = q.Where("external_id = '' AND cve_id = ?", v.CVEID)
+ if v.AssetID != nil {
+ q = q.Where("asset_id = ?", *v.AssetID)
+ } else {
+ q = q.Where("asset_id IS NULL")
+ }
+ }
+
+ var existing domain.Vulnerability
+ err := q.First(&existing).Error
+ if err == gorm.ErrRecordNotFound {
+ if v.FirstSeen.IsZero() {
+ v.FirstSeen = time.Now()
+ }
+ if v.LastSeen.IsZero() {
+ v.LastSeen = time.Now()
+ }
+ if err := r.db.WithContext(ctx).Create(v).Error; err != nil {
+ return false, err
+ }
+ return true, nil
+ }
+ if err != nil {
+ return false, err
+ }
+
+ // Refresh volatile fields, keep triage status + first_seen.
+ existing.Title = v.Title
+ existing.Description = v.Description
+ existing.CVSSScore = v.CVSSScore
+ existing.CVSSVector = v.CVSSVector
+ existing.Severity = v.Severity
+ existing.EPSS = v.EPSS
+ existing.KEV = v.KEV
+ existing.ExploitAvailable = v.ExploitAvailable
+ existing.ExploitMaturity = v.ExploitMaturity
+ existing.PriorityScore = v.PriorityScore
+ existing.PriorityTier = v.PriorityTier
+ existing.AssetID = v.AssetID
+ existing.AssetName = v.AssetName
+ existing.AssetCriticality = v.AssetCriticality
+ existing.AffectedAssetsCount = v.AffectedAssetsCount
+ existing.RemediationHint = v.RemediationHint
+ existing.RawData = v.RawData
+ existing.LastSeen = time.Now()
+ if err := r.db.WithContext(ctx).Save(&existing).Error; err != nil {
+ return false, err
+ }
+ *v = existing
+ return false, nil
+}
+
+func (r *GormVulnerabilityRepository) GetByID(ctx context.Context, id, tenantID uuid.UUID) (*domain.Vulnerability, error) {
+ var v domain.Vulnerability
+ err := r.db.WithContext(ctx).Where("id = ? AND tenant_id = ?", id, tenantID).First(&v).Error
+ if err == gorm.ErrRecordNotFound {
+ return nil, nil
+ }
+ if err != nil {
+ return nil, err
+ }
+ return &v, nil
+}
+
+func (r *GormVulnerabilityRepository) List(ctx context.Context, tenantID uuid.UUID, q domain.VulnerabilityQuery) (*domain.PaginatedResult[domain.Vulnerability], error) {
+ q.Sanitize()
+ tx := r.db.WithContext(ctx).Model(&domain.Vulnerability{}).Where("tenant_id = ?", tenantID)
+
+ if q.Search != "" {
+ like := "%" + strings.ToLower(q.Search) + "%"
+ tx = tx.Where("LOWER(cve_id) LIKE ? OR LOWER(title) LIKE ?", like, like)
+ }
+ if len(q.Severities) > 0 {
+ tx = tx.Where("severity IN ?", q.Severities)
+ }
+ if len(q.Statuses) > 0 {
+ tx = tx.Where("status IN ?", q.Statuses)
+ }
+ if len(q.Sources) > 0 {
+ tx = tx.Where("source IN ?", q.Sources)
+ }
+ if len(q.Tiers) > 0 {
+ tx = tx.Where("priority_tier IN ?", q.Tiers)
+ }
+ if q.AssetID != nil {
+ tx = tx.Where("asset_id = ?", *q.AssetID)
+ }
+ if q.KEVOnly {
+ tx = tx.Where("kev = ?", true)
+ }
+ if q.MinCVSS != nil {
+ tx = tx.Where("cvss_score >= ?", *q.MinCVSS)
+ }
+ if q.MinPriority != nil {
+ tx = tx.Where("priority_score >= ?", *q.MinPriority)
+ }
+
+ var total int64
+ if err := tx.Count(&total).Error; err != nil {
+ return nil, err
+ }
+
+ var rows []domain.Vulnerability
+ err := tx.Order(q.SortBy + " " + q.SortOrder).
+ Limit(q.Limit).Offset(q.Offset()).
+ Find(&rows).Error
+ if err != nil {
+ return nil, err
+ }
+
+ pages := int((total + int64(q.Limit) - 1) / int64(q.Limit))
+ return &domain.PaginatedResult[domain.Vulnerability]{
+ Data: rows, Total: total, Page: q.Page, Limit: q.Limit, TotalPages: pages,
+ }, nil
+}
+
+func (r *GormVulnerabilityRepository) Update(ctx context.Context, v *domain.Vulnerability) error {
+ return r.db.WithContext(ctx).Where("id = ? AND tenant_id = ?", v.ID, v.TenantID).Save(v).Error
+}
+
+func (r *GormVulnerabilityRepository) Delete(ctx context.Context, id, tenantID uuid.UUID) error {
+ return r.db.WithContext(ctx).Where("id = ? AND tenant_id = ?", id, tenantID).
+ Delete(&domain.Vulnerability{}).Error
+}
+
+func (r *GormVulnerabilityRepository) CountAffectedAssets(ctx context.Context, tenantID uuid.UUID, cveID string) (int, error) {
+ if cveID == "" {
+ return 1, nil
+ }
+ var n int64
+ err := r.db.WithContext(ctx).Model(&domain.Vulnerability{}).
+ Where("tenant_id = ? AND cve_id = ? AND asset_id IS NOT NULL", tenantID, cveID).
+ Distinct("asset_id").Count(&n).Error
+ if err != nil {
+ return 1, err
+ }
+ if n < 1 {
+ return 1, nil
+ }
+ return int(n), nil
+}
+
+func (r *GormVulnerabilityRepository) Stats(ctx context.Context, tenantID uuid.UUID) (*domain.VulnStats, error) {
+ stats := &domain.VulnStats{
+ BySeverity: map[string]int64{},
+ ByStatus: map[string]int64{},
+ BySource: map[string]int64{},
+ ByTier: map[string]int64{},
+ }
+ base := func() *gorm.DB {
+ return r.db.WithContext(ctx).Model(&domain.Vulnerability{}).Where("tenant_id = ?", tenantID)
+ }
+
+ if err := base().Count(&stats.Total).Error; err != nil {
+ return nil, err
+ }
+ base().Where("status = ?", domain.VulnStatusOpen).Count(&stats.Open)
+ base().Where("kev = ?", true).Count(&stats.KEVCount)
+ base().Where("exploit_available = ?", true).Count(&stats.ExploitCount)
+
+ type kv struct {
+ K string
+ C int64
+ }
+ group := func(col string, dst map[string]int64) {
+ var rows []kv
+ base().Select(col + " as k, COUNT(*) as c").Group(col).Scan(&rows)
+ for _, row := range rows {
+ if row.K != "" {
+ dst[row.K] = row.C
+ }
+ }
+ }
+ group("severity", stats.BySeverity)
+ group("status", stats.ByStatus)
+ group("source", stats.BySource)
+ group("priority_tier", stats.ByTier)
+ return stats, nil
+}
diff --git a/backend/internal/vulnscan/connectors.go b/backend/internal/vulnscan/connectors.go
new file mode 100644
index 00000000..e9c699c2
--- /dev/null
+++ b/backend/internal/vulnscan/connectors.go
@@ -0,0 +1,35 @@
+// Copyright (c) 2026 OpenDefender Contributors
+// SPDX-License-Identifier: BUSL-1.1
+// This Source Code Form is subject to the terms of the Business Source License, Version 1.1.
+// If a copy of the BUSL was not distributed with this file, You can obtain one at https://mariadb.com/bsl11/
+
+package vulnscan
+
+import "github.com/opendefender/openrisk/internal/domain"
+
+// ConnectorInfo describes a supported vulnerability integration for the UI.
+type ConnectorInfo struct {
+ Source domain.VulnSource `json:"source"`
+ Label string `json:"label"`
+ Category string `json:"category"` // network_scanner | edr | cloud
+ Ingest bool `json:"ingest"` // findings can be imported/normalised
+ LivePull bool `json:"live_pull"` // API polling implemented (vs import-only)
+ Notes string `json:"notes"`
+}
+
+// Connectors returns the catalogue surfaced in the UI. Every provider supports
+// normalised INGEST (upload/POST the tool's native findings). Live API polling
+// is implemented for AWS Inspector (SDK already vendored); the others expose the
+// same honest seam as the scanner — import works today, live pull activates when
+// credentials + client are configured (never fabricated data).
+func Connectors() []ConnectorInfo {
+ return []ConnectorInfo{
+ {domain.VulnSourceNessus, "Tenable Nessus", "network_scanner", true, false, "Import .nessus / vuln-export JSON."},
+ {domain.VulnSourceOpenVAS, "OpenVAS / Greenbone", "network_scanner", true, false, "Import GMP results JSON."},
+ {domain.VulnSourceQualys, "Qualys VMDR", "network_scanner", true, false, "Import VM detection JSON."},
+ {domain.VulnSourceMSDefender, "Microsoft Defender for Endpoint", "edr", true, false, "Import TVM vulnerabilities."},
+ {domain.VulnSourceAWSInspector, "AWS Inspector", "cloud", true, true, "Import findings or live-pull via SDK."},
+ {domain.VulnSourceAzureDefender, "Microsoft Defender for Cloud", "cloud", true, false, "Import security sub-assessments."},
+ {domain.VulnSourceCrowdStrike, "CrowdStrike Falcon Spotlight", "edr", true, false, "Import combined-vulnerabilities JSON."},
+ }
+}
diff --git a/backend/internal/vulnscan/normalize.go b/backend/internal/vulnscan/normalize.go
new file mode 100644
index 00000000..f606cdd6
--- /dev/null
+++ b/backend/internal/vulnscan/normalize.go
@@ -0,0 +1,402 @@
+// Copyright (c) 2026 OpenDefender Contributors
+// SPDX-License-Identifier: BUSL-1.1
+// This Source Code Form is subject to the terms of the Business Source License, Version 1.1.
+// If a copy of the BUSL was not distributed with this file, You can obtain one at https://mariadb.com/bsl11/
+
+// Package vulnscan is the vulnerability-management integration layer. Each
+// supported product (Nessus, OpenVAS, Qualys, Microsoft Defender, AWS Inspector,
+// Azure Defender, CrowdStrike) has a normaliser that maps its native finding
+// JSON onto a provider-agnostic NormalizedFinding. Ingest → normalise → prioritise
+// → upsert lives in application/vulnerability; this package is pure mapping, no I/O.
+package vulnscan
+
+import (
+ "regexp"
+ "strconv"
+ "strings"
+
+ "github.com/opendefender/openrisk/internal/domain"
+)
+
+// NormalizedFinding is the common shape produced by every connector.
+type NormalizedFinding struct {
+ CVEID string
+ Title string
+ Description string
+ CVSSScore float64
+ CVSSVector string
+ Severity string // may be empty → derived from CVSS by the use case
+ EPSS float64
+ KEV bool
+ ExploitAvailable bool
+ ExploitMaturity string
+ ExternalID string // the source tool's own finding id (dedup key)
+ AssetName string // hostname / resource id from the tool
+ AssetExternalID string // used to match an existing asset, when known
+ RemediationHint string
+ Raw map[string]any
+}
+
+// Normalizer converts one raw finding (decoded JSON object) into a NormalizedFinding.
+type Normalizer func(raw map[string]any) NormalizedFinding
+
+var normalizers = map[domain.VulnSource]Normalizer{
+ domain.VulnSourceNessus: normalizeNessus,
+ domain.VulnSourceOpenVAS: normalizeOpenVAS,
+ domain.VulnSourceQualys: normalizeQualys,
+ domain.VulnSourceMSDefender: normalizeMSDefender,
+ domain.VulnSourceAWSInspector: normalizeAWSInspector,
+ domain.VulnSourceAzureDefender: normalizeAzureDefender,
+ domain.VulnSourceCrowdStrike: normalizeCrowdStrike,
+ domain.VulnSourceManual: normalizeGeneric,
+ domain.VulnSourceScanner: normalizeGeneric,
+}
+
+// SupportsNormalization reports whether a source has a normaliser.
+func SupportsNormalization(src domain.VulnSource) bool {
+ _, ok := normalizers[src]
+ return ok
+}
+
+// Normalize maps a single raw finding for the given source. Unknown sources fall
+// back to the generic normaliser (best-effort field guessing).
+func Normalize(src domain.VulnSource, raw map[string]any) NormalizedFinding {
+ fn, ok := normalizers[src]
+ if !ok {
+ fn = normalizeGeneric
+ }
+ nf := fn(raw)
+ nf.Raw = raw
+ return nf
+}
+
+// NormalizeBatch maps a slice of raw findings.
+func NormalizeBatch(src domain.VulnSource, raws []map[string]any) []NormalizedFinding {
+ out := make([]NormalizedFinding, 0, len(raws))
+ for _, r := range raws {
+ out = append(out, Normalize(src, r))
+ }
+ return out
+}
+
+var cveRe = regexp.MustCompile(`(?i)CVE-\d{4}-\d{4,7}`)
+
+// ---- provider normalisers -------------------------------------------------
+
+// Nessus (Tenable) export / vuln API.
+func normalizeNessus(r map[string]any) NormalizedFinding {
+ nf := NormalizedFinding{
+ Title: firstStr(r, "plugin_name", "pluginName", "name", "synopsis"),
+ Description: firstStr(r, "description", "synopsis"),
+ CVSSScore: firstFloat(r, "cvss3_base_score", "cvssV3BaseScore", "cvss_base_score", "cvss_score"),
+ CVSSVector: firstStr(r, "cvss3_vector", "cvss_vector"),
+ Severity: nessusSeverity(r),
+ ExternalID: firstStr(r, "plugin_id", "pluginID", "uuid"),
+ AssetName: firstStr(r, "host", "hostname", "host-fqdn", "host_ip"),
+ RemediationHint: firstStr(r, "solution", "remediation"),
+ }
+ nf.CVEID = firstCVE(r, "cve")
+ return nf
+}
+
+// nessusSeverity: Nessus uses 0–4 (0 Info … 4 Critical), sometimes a word.
+func nessusSeverity(r map[string]any) string {
+ if s := firstStr(r, "severity_name", "risk_factor"); s != "" {
+ return strings.ToLower(s)
+ }
+ switch int(firstFloat(r, "severity")) {
+ case 4:
+ return "critical"
+ case 3:
+ return "high"
+ case 2:
+ return "medium"
+ case 1:
+ return "low"
+ }
+ return ""
+}
+
+// OpenVAS / Greenbone GVM result.
+func normalizeOpenVAS(r map[string]any) NormalizedFinding {
+ nf := NormalizedFinding{
+ Title: firstStr(r, "name", "nvt_name"),
+ Description: firstStr(r, "description", "summary"),
+ CVSSScore: firstFloat(r, "severity", "cvss", "cvss_base"),
+ Severity: strings.ToLower(firstStr(r, "threat")),
+ ExternalID: firstStr(r, "oid", "nvt_oid", "id"),
+ AssetName: firstStr(r, "host", "hostname"),
+ RemediationHint: firstStr(r, "solution"),
+ }
+ nf.CVEID = firstCVE(r, "cve", "cves")
+ return nf
+}
+
+// Qualys VMDR.
+func normalizeQualys(r map[string]any) NormalizedFinding {
+ nf := NormalizedFinding{
+ Title: firstStr(r, "TITLE", "title"),
+ Description: firstStr(r, "DIAGNOSIS", "THREAT", "description"),
+ CVSSScore: firstFloat(r, "CVSS_BASE", "CVSS", "cvss"),
+ Severity: qualysSeverity(r),
+ ExternalID: firstStr(r, "QID", "qid"),
+ AssetName: firstStr(r, "DNS", "IP", "dns", "ip"),
+ RemediationHint: firstStr(r, "SOLUTION", "solution"),
+ }
+ nf.CVEID = firstCVE(r, "CVE_ID", "CVE_ID_LIST", "cve_id", "cve")
+ return nf
+}
+
+// qualysSeverity: Qualys uses 1–5.
+func qualysSeverity(r map[string]any) string {
+ switch int(firstFloat(r, "SEVERITY", "severity")) {
+ case 5:
+ return "critical"
+ case 4:
+ return "high"
+ case 3:
+ return "medium"
+ case 2:
+ return "low"
+ case 1:
+ return "info"
+ }
+ return ""
+}
+
+// Microsoft Defender for Endpoint (TVM vulnerabilities).
+func normalizeMSDefender(r map[string]any) NormalizedFinding {
+ nf := NormalizedFinding{
+ Title: firstStr(r, "name", "id"),
+ Description: firstStr(r, "description"),
+ CVSSScore: firstFloat(r, "cvssV3", "cvss", "cvssScore"),
+ Severity: strings.ToLower(firstStr(r, "severity")),
+ ExploitAvailable: firstBool(r, "publicExploit", "exploitVerified", "exploitInKit"),
+ ExternalID: firstStr(r, "id"),
+ AssetName: firstStr(r, "deviceName", "computerDnsName"),
+ }
+ nf.AffectedFromCount(int(firstFloat(r, "exposedMachinesCount", "exposedMachines")))
+ nf.CVEID = firstCVE(r, "id", "cveId")
+ return nf
+}
+
+// AWS Inspector (inspector2 finding).
+func normalizeAWSInspector(r map[string]any) NormalizedFinding {
+ nf := NormalizedFinding{
+ Title: firstStr(r, "title", "description"),
+ Description: firstStr(r, "description"),
+ CVSSScore: firstFloat(r, "inspectorScore", "cvss", "cvssScore"),
+ Severity: strings.ToLower(firstStr(r, "severity")),
+ ExploitAvailable: strings.EqualFold(firstStr(r, "exploitAvailable"), "YES"),
+ ExternalID: firstStr(r, "findingArn", "arn", "id"),
+ }
+ // packageVulnerabilityDetails.vulnerabilityId holds the CVE; resources[0].id the asset.
+ if pvd, ok := r["packageVulnerabilityDetails"].(map[string]any); ok {
+ nf.CVEID = firstCVE(pvd, "vulnerabilityId", "cve")
+ }
+ if nf.CVEID == "" {
+ nf.CVEID = firstCVE(r, "vulnerabilityId", "cve")
+ }
+ if res, ok := r["resources"].([]any); ok && len(res) > 0 {
+ if r0, ok := res[0].(map[string]any); ok {
+ nf.AssetExternalID = firstStr(r0, "id")
+ nf.AssetName = firstStr(r0, "id")
+ }
+ }
+ if rem, ok := r["remediation"].(map[string]any); ok {
+ if rec, ok := rem["recommendation"].(map[string]any); ok {
+ nf.RemediationHint = firstStr(rec, "text")
+ }
+ }
+ return nf
+}
+
+// Azure Defender for Cloud (assessment / sub-assessment).
+func normalizeAzureDefender(r map[string]any) NormalizedFinding {
+ nf := NormalizedFinding{
+ Title: firstStr(r, "displayName", "name"),
+ Description: firstStr(r, "description"),
+ ExternalID: firstStr(r, "id", "name"),
+ }
+ // severity often under status.severity
+ if st, ok := r["status"].(map[string]any); ok {
+ nf.Severity = strings.ToLower(firstStr(st, "severity"))
+ }
+ if nf.Severity == "" {
+ nf.Severity = strings.ToLower(firstStr(r, "severity"))
+ }
+ if ad, ok := r["additionalData"].(map[string]any); ok {
+ nf.CVSSScore = firstFloat(ad, "cvss", "cvssScore")
+ nf.CVEID = firstCVE(ad, "cve", "cveId")
+ nf.RemediationHint = firstStr(ad, "remediation")
+ }
+ if nf.CVEID == "" {
+ nf.CVEID = firstCVE(r, "id", "displayName")
+ }
+ if rd, ok := r["resourceDetails"].(map[string]any); ok {
+ nf.AssetExternalID = firstStr(rd, "id", "resourceId")
+ nf.AssetName = firstStr(rd, "id", "resourceId")
+ }
+ return nf
+}
+
+// CrowdStrike Falcon Spotlight (combined vulnerabilities).
+func normalizeCrowdStrike(r map[string]any) NormalizedFinding {
+ nf := NormalizedFinding{ExternalID: firstStr(r, "id")}
+ if cve, ok := r["cve"].(map[string]any); ok {
+ nf.CVEID = strings.ToUpper(firstStr(cve, "id"))
+ nf.CVSSScore = firstFloat(cve, "base_score", "score")
+ nf.Severity = strings.ToLower(firstStr(cve, "severity"))
+ nf.Title = firstStr(cve, "description", "id")
+ nf.Description = firstStr(cve, "description")
+ // exploit_status: CrowdStrike uses 0/30/60/90; >0 means a known exploit.
+ if es := firstFloat(cve, "exploit_status"); es > 0 {
+ nf.ExploitAvailable = true
+ switch {
+ case es >= 90:
+ nf.ExploitMaturity = "high"
+ case es >= 60:
+ nf.ExploitMaturity = "functional"
+ default:
+ nf.ExploitMaturity = "poc"
+ }
+ }
+ if strings.EqualFold(firstStr(cve, "exprt_rating"), "CRITICAL") {
+ nf.ExploitMaturity = "high"
+ }
+ }
+ if hi, ok := r["host_info"].(map[string]any); ok {
+ nf.AssetName = firstStr(hi, "hostname", "local_ip")
+ }
+ if rem, ok := r["remediation"].(map[string]any); ok {
+ if ents, ok := rem["entities"].([]any); ok && len(ents) > 0 {
+ if e0, ok := ents[0].(map[string]any); ok {
+ nf.RemediationHint = firstStr(e0, "action")
+ }
+ }
+ }
+ return nf
+}
+
+// Generic best-effort normaliser (manual entry, built-in scanner, unknown tools).
+func normalizeGeneric(r map[string]any) NormalizedFinding {
+ nf := NormalizedFinding{
+ Title: firstStr(r, "title", "name", "plugin_name"),
+ Description: firstStr(r, "description", "summary"),
+ CVSSScore: firstFloat(r, "cvss", "cvss_score", "cvssScore", "score"),
+ CVSSVector: firstStr(r, "cvss_vector", "cvssVector"),
+ Severity: strings.ToLower(firstStr(r, "severity")),
+ EPSS: firstFloat(r, "epss"),
+ KEV: firstBool(r, "kev", "known_exploited"),
+ ExploitAvailable: firstBool(r, "exploit_available", "exploitAvailable"),
+ ExploitMaturity: strings.ToLower(firstStr(r, "exploit_maturity")),
+ ExternalID: firstStr(r, "external_id", "id", "external_id"),
+ AssetName: firstStr(r, "asset", "asset_name", "host", "hostname"),
+ AssetExternalID: firstStr(r, "asset_external_id", "asset_id"),
+ RemediationHint: firstStr(r, "remediation", "solution", "remediation_hint"),
+ }
+ nf.CVEID = firstCVE(r, "cve", "cve_id", "cveId")
+ if nf.CVEID == "" {
+ nf.CVEID = firstCVE(r, "title", "name")
+ }
+ return nf
+}
+
+// AffectedFromCount records a blast-radius hint carried by the source; stored on
+// Raw so the use case can prefer the tenant-wide DB count when available.
+func (nf *NormalizedFinding) AffectedFromCount(n int) {
+ if n > 0 {
+ if nf.Raw == nil {
+ nf.Raw = map[string]any{}
+ }
+ nf.Raw["_affected_hint"] = n
+ }
+}
+
+// ---- tolerant getters -----------------------------------------------------
+
+func firstStr(m map[string]any, keys ...string) string {
+ for _, k := range keys {
+ if v, ok := m[k]; ok {
+ switch t := v.(type) {
+ case string:
+ if t != "" {
+ return t
+ }
+ case []any:
+ if len(t) > 0 {
+ if s, ok := t[0].(string); ok && s != "" {
+ return s
+ }
+ }
+ }
+ }
+ }
+ return ""
+}
+
+func firstFloat(m map[string]any, keys ...string) float64 {
+ for _, k := range keys {
+ if v, ok := m[k]; ok {
+ switch t := v.(type) {
+ case float64:
+ return t
+ case int:
+ return float64(t)
+ case string:
+ if f, err := strconv.ParseFloat(strings.TrimSpace(t), 64); err == nil {
+ return f
+ }
+ case map[string]any:
+ // e.g. Azure additionalData.cvss = {"base": 7.5}
+ if b := firstFloat(t, "base", "baseScore", "score"); b > 0 {
+ return b
+ }
+ }
+ }
+ }
+ return 0
+}
+
+func firstBool(m map[string]any, keys ...string) bool {
+ for _, k := range keys {
+ if v, ok := m[k]; ok {
+ switch t := v.(type) {
+ case bool:
+ return t
+ case string:
+ if b, err := strconv.ParseBool(strings.TrimSpace(t)); err == nil {
+ return b
+ }
+ if strings.EqualFold(t, "yes") {
+ return true
+ }
+ }
+ }
+ }
+ return false
+}
+
+// firstCVE extracts the first CVE id from any of the given keys (string, array,
+// or a value that merely contains a CVE substring).
+func firstCVE(m map[string]any, keys ...string) string {
+ for _, k := range keys {
+ if v, ok := m[k]; ok {
+ switch t := v.(type) {
+ case string:
+ if id := cveRe.FindString(t); id != "" {
+ return strings.ToUpper(id)
+ }
+ case []any:
+ for _, e := range t {
+ if s, ok := e.(string); ok {
+ if id := cveRe.FindString(s); id != "" {
+ return strings.ToUpper(id)
+ }
+ }
+ }
+ }
+ }
+ }
+ return ""
+}
diff --git a/backend/pkg/vulnprio/vulnprio.go b/backend/pkg/vulnprio/vulnprio.go
new file mode 100644
index 00000000..a90ac95b
--- /dev/null
+++ b/backend/pkg/vulnprio/vulnprio.go
@@ -0,0 +1,164 @@
+// Copyright (c) 2026 OpenDefender Contributors
+// SPDX-License-Identifier: BUSL-1.1
+// This Source Code Form is subject to the terms of the Business Source License, Version 1.1.
+// If a copy of the BUSL was not distributed with this file, You can obtain one at https://mariadb.com/bsl11/
+
+// Package vulnprio is a pure, deterministic vulnerability-prioritisation engine.
+//
+// It scores a vulnerability on four axes the user asked for — CVSS, exploitability,
+// business criticality (of the affected asset), and blast radius (number of
+// affected assets) — into a single 0–100 priority with a P1..P4 tier and a
+// human-readable explanation. No I/O, no dependencies: trivially testable.
+package vulnprio
+
+import (
+ "fmt"
+ "math"
+ "strings"
+)
+
+// Input is everything the engine needs. All fields are optional; zero values
+// degrade gracefully (an unknown CVSS just contributes nothing).
+type Input struct {
+ CVSS float64 // base score 0–10
+ EPSS float64 // FIRST EPSS exploit probability 0–1
+ KEV bool // CISA Known-Exploited Vulnerability
+ ExploitAvailable bool // a public exploit / weaponised PoC exists
+ ExploitMaturity string // none|poc|functional|high (optional, refines exploit weight)
+
+ // AssetCriticalityFactor is the Score-Engine factor of the affected asset
+ // (0.1–3.0; 3.0 = CRITICAL). 0 → treated as unknown/medium.
+ AssetCriticalityFactor float64
+ AffectedAssets int // distinct assets carrying this vuln (blast radius)
+}
+
+// Result is the computed priority.
+type Result struct {
+ Score float64 // 0–100
+ Tier string // P1|P2|P3|P4
+ Explanation string
+}
+
+// Axis weights (sum = 1.0). CVSS and exploitability dominate; business
+// criticality and blast radius refine. Tuned to be defensible, not magic.
+const (
+ wSeverity = 0.40
+ wExploit = 0.30
+ wBusiness = 0.20
+ wExposure = 0.10
+
+ // KEV is a hard signal: CISA-known-exploited means "patch now" regardless of
+ // the other axes, so we floor the score into P1 (>= 80).
+ kevFloor = 80.0
+)
+
+// clamp01 bounds v to [0,1].
+func clamp01(v float64) float64 {
+ if v < 0 {
+ return 0
+ }
+ if v > 1 {
+ return 1
+ }
+ return v
+}
+
+// exploitWeight combines the exploitability signals into 0–1. KEV dominates
+// (known-exploited in the wild), then a weaponised exploit, then EPSS, then a
+// bare maturity hint.
+func exploitWeight(in Input) float64 {
+ w := clamp01(in.EPSS)
+ if in.KEV {
+ w = math.Max(w, 0.95)
+ }
+ if in.ExploitAvailable {
+ w = math.Max(w, 0.70)
+ }
+ switch strings.ToLower(in.ExploitMaturity) {
+ case "high":
+ w = math.Max(w, 0.90)
+ case "functional":
+ w = math.Max(w, 0.75)
+ case "poc":
+ w = math.Max(w, 0.50)
+ }
+ return clamp01(w)
+}
+
+// businessWeight maps the asset criticality factor (0.1–3.0) to 0–1. Unknown
+// (0) is treated as MEDIUM (factor 1.5).
+func businessWeight(factor float64) float64 {
+ if factor <= 0 {
+ factor = 1.5
+ }
+ return clamp01(factor / 3.0)
+}
+
+// exposureWeight turns the number of affected assets into 0–1 on a log scale:
+// 1 asset → 0.30, 3 → 0.60, 9 → 1.0, capped. A single affected asset still
+// contributes; a fleet-wide vuln saturates.
+func exposureWeight(affected int) float64 {
+ if affected <= 1 {
+ return 0.30
+ }
+ return clamp01(math.Log10(float64(affected)) + 0.30)
+}
+
+// tierFor buckets a 0–100 score into P1..P4.
+func tierFor(score float64) string {
+ switch {
+ case score >= 80:
+ return "P1"
+ case score >= 60:
+ return "P2"
+ case score >= 40:
+ return "P3"
+ default:
+ return "P4"
+ }
+}
+
+// Compute scores a vulnerability. Deterministic and side-effect free.
+func Compute(in Input) Result {
+ sev := clamp01(in.CVSS / 10.0)
+ exp := exploitWeight(in)
+ biz := businessWeight(in.AssetCriticalityFactor)
+ exo := exposureWeight(in.AffectedAssets)
+
+ score := 100.0 * (wSeverity*sev + wExploit*exp + wBusiness*biz + wExposure*exo)
+
+ floored := false
+ if in.KEV && score < kevFloor {
+ score = kevFloor
+ floored = true
+ }
+ score = math.Round(score*100) / 100
+ if score > 100 {
+ score = 100
+ }
+
+ // Build a short, honest explanation of the main drivers.
+ var parts []string
+ parts = append(parts, fmt.Sprintf("CVSS %.1f", in.CVSS))
+ if in.KEV {
+ parts = append(parts, "CISA-KEV (exploited in the wild)")
+ } else if in.ExploitAvailable {
+ parts = append(parts, "public exploit available")
+ } else if in.EPSS > 0 {
+ parts = append(parts, fmt.Sprintf("EPSS %.0f%%", in.EPSS*100))
+ }
+ if in.AssetCriticalityFactor >= 2.5 {
+ parts = append(parts, "critical asset")
+ } else if in.AssetCriticalityFactor >= 2.0 {
+ parts = append(parts, "high-value asset")
+ }
+ if in.AffectedAssets > 1 {
+ parts = append(parts, fmt.Sprintf("%d affected assets", in.AffectedAssets))
+ }
+ explanation := strings.Join(parts, " · ")
+ if floored {
+ explanation += " — floored to P1 by CISA-KEV"
+ }
+
+ return Result{Score: score, Tier: tierFor(score), Explanation: explanation}
+}
diff --git a/backend/pkg/vulnprio/vulnprio_test.go b/backend/pkg/vulnprio/vulnprio_test.go
new file mode 100644
index 00000000..15c1d72a
--- /dev/null
+++ b/backend/pkg/vulnprio/vulnprio_test.go
@@ -0,0 +1,96 @@
+// Copyright (c) 2026 OpenDefender Contributors
+// SPDX-License-Identifier: BUSL-1.1
+// This Source Code Form is subject to the terms of the Business Source License, Version 1.1.
+// If a copy of the BUSL was not distributed with this file, You can obtain one at https://mariadb.com/bsl11/
+
+package vulnprio
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestCompute_KEVFloorsToP1(t *testing.T) {
+ // A medium CVSS with no asset context would score low — but CISA-KEV must
+ // floor it into P1 ("patch now").
+ r := Compute(Input{CVSS: 5.0, KEV: true, AssetCriticalityFactor: 0.5, AffectedAssets: 1})
+ if r.Score < kevFloor {
+ t.Errorf("KEV should floor score to >= %.0f, got %.2f", kevFloor, r.Score)
+ }
+ if r.Tier != "P1" {
+ t.Errorf("KEV vuln should be P1, got %s", r.Tier)
+ }
+ if !strings.Contains(r.Explanation, "KEV") {
+ t.Errorf("explanation should mention KEV: %q", r.Explanation)
+ }
+}
+
+func TestCompute_CriticalEverything_IsP1(t *testing.T) {
+ r := Compute(Input{CVSS: 9.8, EPSS: 0.9, ExploitAvailable: true, AssetCriticalityFactor: 3.0, AffectedAssets: 20})
+ if r.Tier != "P1" || r.Score < 90 {
+ t.Errorf("critical-everything should be a high P1, got tier=%s score=%.2f", r.Tier, r.Score)
+ }
+}
+
+func TestCompute_LowEverything_IsP4(t *testing.T) {
+ r := Compute(Input{CVSS: 2.0, EPSS: 0.0, AssetCriticalityFactor: 0.5, AffectedAssets: 1})
+ if r.Tier != "P4" {
+ t.Errorf("low-everything should be P4, got tier=%s score=%.2f", r.Tier, r.Score)
+ }
+}
+
+func TestCompute_BusinessCriticalityMatters(t *testing.T) {
+ // Same CVE, different asset criticality → the critical asset must rank higher.
+ base := Input{CVSS: 7.5, EPSS: 0.1, AffectedAssets: 1}
+ low := base
+ low.AssetCriticalityFactor = 0.5
+ crit := base
+ crit.AssetCriticalityFactor = 3.0
+ if Compute(crit).Score <= Compute(low).Score {
+ t.Error("a vuln on a CRITICAL asset must outrank the same vuln on a LOW asset")
+ }
+}
+
+func TestCompute_BlastRadiusMatters(t *testing.T) {
+ base := Input{CVSS: 6.0, AssetCriticalityFactor: 1.5}
+ one := base
+ one.AffectedAssets = 1
+ many := base
+ many.AffectedAssets = 50
+ if Compute(many).Score <= Compute(one).Score {
+ t.Error("more affected assets must raise the priority")
+ }
+}
+
+func TestCompute_ExploitAvailableRaisesScore(t *testing.T) {
+ base := Input{CVSS: 7.0, AssetCriticalityFactor: 1.5, AffectedAssets: 1}
+ no := base
+ yes := base
+ yes.ExploitAvailable = true
+ if Compute(yes).Score <= Compute(no).Score {
+ t.Error("a public exploit must raise the priority")
+ }
+}
+
+func TestCompute_ScoreBounded(t *testing.T) {
+ r := Compute(Input{CVSS: 10, EPSS: 1, KEV: true, ExploitAvailable: true, ExploitMaturity: "high", AssetCriticalityFactor: 3.0, AffectedAssets: 10000})
+ if r.Score > 100 {
+ t.Errorf("score must be capped at 100, got %.2f", r.Score)
+ }
+ empty := Compute(Input{})
+ if empty.Score < 0 {
+ t.Errorf("score must be >= 0, got %.2f", empty.Score)
+ }
+}
+
+func TestTierBoundaries(t *testing.T) {
+ cases := []struct {
+ score float64
+ tier string
+ }{{80, "P1"}, {79.99, "P2"}, {60, "P2"}, {59.99, "P3"}, {40, "P3"}, {39.99, "P4"}, {0, "P4"}}
+ for _, c := range cases {
+ if got := tierFor(c.score); got != c.tier {
+ t.Errorf("tierFor(%.2f) = %s, want %s", c.score, got, c.tier)
+ }
+ }
+}
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index eb7a19c9..18de7750 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -26,6 +26,7 @@ import { SettingsScreen } from './features/settings/SettingsScreen';
import { DashboardPage } from './features/dashboard/DashboardPage';
import { ImportRisksPage } from './features/risks/ImportRisksPage';
import { RiskRegisterPage } from './features/risks/RiskRegisterPage';
+import { VulnerabilitiesPage } from './features/vulnerabilities/VulnerabilitiesPage';
import { MitigationsBoard } from './features/mitigations/MitigationsBoard';
import { ComplianceScreen } from './features/compliance/ComplianceScreen';
import { FrameworkDetail } from './features/compliance/FrameworkDetail';
@@ -135,6 +136,7 @@ function App() {
>
| {h} | + ))} +|||||||
|---|---|---|---|---|---|---|---|
|
+
+ {v.priority_tier}
+ {v.priority_score.toFixed(0)}
+ {v.kev &&
+ |
+ {v.cve_id || '—'} | +{v.title} |
+ {v.cvss_score ? v.cvss_score.toFixed(1) : '—'} | +{v.asset_name || '—'} | +{SOURCE_LABEL[v.source] ?? v.source} | +