From 01cd26dc082499df8d0b6829f7691fb2b91d82f2 Mon Sep 17 00:00:00 2001 From: alex-dembele Date: Thu, 16 Jul 2026 14:46:59 +0200 Subject: [PATCH] =?UTF-8?q?feat(vuln):=20vulnerability=20management=20?= =?UTF-8?q?=E2=80=94=207=20integrations=20+=20risk-based=20prioritisation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dedicated Vulnerability Management module (user request: integrate Nessus, OpenVAS, Qualys, Microsoft Defender, AWS Inspector, Azure Defender, CrowdStrike; prioritise by CVSS, exploitability, business criticality, affected assets). No such module existed — the scanner produces transient findings and CTI holds feed data, but there was no persistent, per-asset, prioritised vulnerability register. Backend: - domain.Vulnerability (tenant-scoped: CVSS/EPSS/KEV/exploit, linked-asset business criticality, blast radius, remediation status, priority score + P1..P4 tier; in AutoMigrate) + VulnerabilityRepository port + Gorm impl (dedup upsert that preserves triage status, filtered/sorted list, stats, distinct-asset count). - pkg/vulnprio: pure, deterministic prioritisation engine (8 tests) over the 4 requested axes — CVSS 0.40, exploitability 0.30 (EPSS/KEV/exploit; KEV floors to P1), business criticality 0.20, affected assets 0.10 — → 0-100 + tier + why. - internal/vulnscan: a normaliser per product (native finding JSON → common shape) for all 7 tools + connector catalogue (AWS Inspector live-pull; others import + honest seam, never fabricated data). - application/vulnerability: ingest (normalise → resolve asset → recount blast radius post-upsert → re-prioritise → upsert), list/get/update-status/delete/stats (tenant-safe, typed errors, tests). Handler + routes under vulnerabilities:*. Frontend (features/vulnerabilities): register sorted by priority + KPI stats + filters, detail drawer (prioritisation breakdown, KEV/exploit signals, status lifecycle, delete), multi-source ingest modal, connectors panel; sidebar item + route + FR/EN i18n. Live-verified (Postgres:5434): 7 connectors; Nessus Log4Shell → CVE extracted, matched to web-01 (CRITICAL) → P2 62.2; CrowdStrike exploit → maturity high 76.4; manual KEV CVSS 5.0 → floored P1 (80); same CVE on 3 assets → blast radius 3 → 66.97; priority-sorted list; stats; status update (invalid 400); delete 204 → 404; bad source 400; headless screenshot of the rendered page. go build/vet/test + tsc/vite all green. --- CLAUDE.md | 1 + ROADMAP.md | 1 + backend/cmd/server/main.go | 31 ++ .../application/vulnerability/ingest.go | 213 ++++++++++ .../application/vulnerability/mutations.go | 58 +++ .../application/vulnerability/queries.go | 47 ++ .../vulnerability/vulnerability_test.go | 206 +++++++++ backend/internal/domain/vulnerability.go | 186 ++++++++ .../domain/vulnerability_repository.go | 86 ++++ .../internal/handler/vulnerability_handler.go | 191 +++++++++ .../gorm_vulnerability_repository.go | 216 ++++++++++ backend/internal/vulnscan/connectors.go | 35 ++ backend/internal/vulnscan/normalize.go | 402 ++++++++++++++++++ backend/pkg/vulnprio/vulnprio.go | 164 +++++++ backend/pkg/vulnprio/vulnprio_test.go | 96 +++++ frontend/src/App.tsx | 2 + .../vulnerabilities/ConnectorsPanel.tsx | 72 ++++ .../features/vulnerabilities/IngestModal.tsx | 100 +++++ .../vulnerabilities/VulnerabilitiesPage.tsx | 285 +++++++++++++ .../vulnerabilities/useVulnerabilities.ts | 50 +++ .../src/features/vulnerabilities/vulnMeta.ts | 49 +++ .../vulnerabilities/vulnerabilityService.ts | 133 ++++++ frontend/src/shared/navModel.ts | 3 +- frontend/src/shared/uiStrings.ts | 4 +- 24 files changed, 2628 insertions(+), 3 deletions(-) create mode 100644 backend/internal/application/vulnerability/ingest.go create mode 100644 backend/internal/application/vulnerability/mutations.go create mode 100644 backend/internal/application/vulnerability/queries.go create mode 100644 backend/internal/application/vulnerability/vulnerability_test.go create mode 100644 backend/internal/domain/vulnerability.go create mode 100644 backend/internal/domain/vulnerability_repository.go create mode 100644 backend/internal/handler/vulnerability_handler.go create mode 100644 backend/internal/infrastructure/repository/gorm_vulnerability_repository.go create mode 100644 backend/internal/vulnscan/connectors.go create mode 100644 backend/internal/vulnscan/normalize.go create mode 100644 backend/pkg/vulnprio/vulnprio.go create mode 100644 backend/pkg/vulnprio/vulnprio_test.go create mode 100644 frontend/src/features/vulnerabilities/ConnectorsPanel.tsx create mode 100644 frontend/src/features/vulnerabilities/IngestModal.tsx create mode 100644 frontend/src/features/vulnerabilities/VulnerabilitiesPage.tsx create mode 100644 frontend/src/features/vulnerabilities/useVulnerabilities.ts create mode 100644 frontend/src/features/vulnerabilities/vulnMeta.ts create mode 100644 frontend/src/features/vulnerabilities/vulnerabilityService.ts 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() { > } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/features/vulnerabilities/ConnectorsPanel.tsx b/frontend/src/features/vulnerabilities/ConnectorsPanel.tsx new file mode 100644 index 00000000..22f42ec6 --- /dev/null +++ b/frontend/src/features/vulnerabilities/ConnectorsPanel.tsx @@ -0,0 +1,72 @@ +// Copyright (c) 2026 OpenDefender Contributors +// SPDX-License-Identifier: BUSL-1.1 +// +// The supported vulnerability integrations, grouped by category. Every provider +// supports normalised import today; live API polling is flagged per provider +// (implemented for AWS Inspector; the others activate when creds are configured). + +import { X, Server, Cpu, Cloud, Upload, Radio } from 'lucide-react'; +import { useUIStore } from '../../store/uiStore'; +import { useVulnConnectors } from './useVulnerabilities'; + +const CATEGORY = { + network_scanner: { label: ['Scanner réseau', 'Network scanner'], icon: Server, color: 'var(--accent)' }, + edr: { label: ['EDR / Endpoint', 'EDR / Endpoint'], icon: Cpu, color: 'var(--high)' }, + cloud: { label: ['Cloud', 'Cloud'], icon: Cloud, color: 'var(--info)' }, +} as const; + +export function ConnectorsPanel({ isOpen, onClose, onImport }: { isOpen: boolean; onClose: () => void; onImport: () => void }) { + const lang = useUIStore((s) => s.lang); + const tr = (fr: string, en: string) => (lang === 'fr' ? fr : en); + const { data: connectors, isLoading } = useVulnConnectors(); + if (!isOpen) return null; + + return ( +
+
e.stopPropagation()} className="w-full max-w-[620px] rounded-[16px] flex flex-col" style={{ maxHeight: '90vh', background: 'var(--bg-secondary)', border: '1px solid var(--border)', boxShadow: 'var(--shadow-lg)' }}> +
+
{tr('Connecteurs de vulnérabilités', 'Vulnerability connectors')}
+ +
+ +
+ {isLoading ? ( +
{tr('Chargement…', 'Loading…')}
+ ) : ( + (connectors ?? []).map((c) => { + const cat = CATEGORY[c.category]; + const Icon = cat.icon; + return ( +
+
+ +
+
+
{c.label}
+
{tr(cat.label[0], cat.label[1])} · {c.notes}
+
+
+ + {tr('Import', 'Import')} + + {c.live_pull && ( + + {tr('Live', 'Live')} + + )} +
+
+ ); + }) + )} +
+ +
+ +
+
+
+ ); +} diff --git a/frontend/src/features/vulnerabilities/IngestModal.tsx b/frontend/src/features/vulnerabilities/IngestModal.tsx new file mode 100644 index 00000000..a15ff6ea --- /dev/null +++ b/frontend/src/features/vulnerabilities/IngestModal.tsx @@ -0,0 +1,100 @@ +// Copyright (c) 2026 OpenDefender Contributors +// SPDX-License-Identifier: BUSL-1.1 +// +// Import findings from a named integration: pick the source, paste the tool's +// native findings JSON (an array of objects, exactly as exported), submit. The +// backend normalises + risk-based prioritises + upserts them. + +import { useState } from 'react'; +import { toast } from 'sonner'; +import { X, Upload } from 'lucide-react'; +import { useUIStore } from '../../store/uiStore'; +import { useVulnMutations } from './useVulnerabilities'; +import type { VulnSource } from './vulnerabilityService'; +import { SOURCE_LABEL } from './vulnMeta'; + +const SOURCES: VulnSource[] = [ + 'nessus', 'openvas', 'qualys', 'ms_defender', 'aws_inspector', 'azure_defender', 'crowdstrike', 'manual', +]; + +// A tiny sample per source so the user knows the expected native shape. +const SAMPLE: Record = { + nessus: `[{ "plugin_id": "156032", "plugin_name": "Apache Log4j RCE", "cvss3_base_score": 9.8, "cve": "CVE-2021-44228", "severity": 4, "host": "web-01", "solution": "Upgrade log4j" }]`, + crowdstrike: `[{ "id": "cs-1", "cve": { "id": "CVE-2023-23397", "base_score": 9.1, "severity": "CRITICAL", "exploit_status": 90 }, "host_info": { "hostname": "pc-42" } }]`, + manual: `[{ "title": "SMB legacy", "cve": "CVE-2017-0144", "cvss": 5.0, "kev": true, "host": "web-01" }]`, +}; + +export function IngestModal({ isOpen, onClose }: { isOpen: boolean; onClose: () => void }) { + const lang = useUIStore((s) => s.lang); + const tr = (fr: string, en: string) => (lang === 'fr' ? fr : en); + const { ingest } = useVulnMutations(); + const [source, setSource] = useState('nessus'); + const [raw, setRaw] = useState(''); + + if (!isOpen) return null; + + const submit = async () => { + let findings: Record[]; + try { + const parsed = JSON.parse(raw); + findings = Array.isArray(parsed) ? parsed : [parsed]; + } catch { + toast.error(tr('JSON invalide', 'Invalid JSON')); + return; + } + if (findings.length === 0) { + toast.error(tr('Aucun finding', 'No findings')); + return; + } + try { + const res = await ingest.mutateAsync({ source, findings }); + toast.success(tr(`${res.created} créées · ${res.updated} mises à jour`, `${res.created} created · ${res.updated} updated`)); + setRaw(''); + onClose(); + } catch { + toast.error(tr('Échec de l’import', 'Import failed')); + } + }; + + return ( +
+
e.stopPropagation()} className="w-full max-w-[560px] rounded-[16px] flex flex-col" style={{ maxHeight: '90vh', background: 'var(--bg-secondary)', border: '1px solid var(--border)', boxShadow: 'var(--shadow-lg)' }}> +
+
{tr('Importer des findings', 'Import findings')}
+ +
+ +
+
+
{tr('Source', 'Source')}
+
+ {SOURCES.map((s) => ( + + ))} +
+
+ +
+
+
{tr('Findings (JSON du tableau exporté)', 'Findings (exported array JSON)')}
+ {SAMPLE[source] && ( + + )} +
+