Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CLAUDE.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
31 changes: 31 additions & 0 deletions backend/cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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))
Expand Down
213 changes: 213 additions & 0 deletions backend/internal/application/vulnerability/ingest.go
Original file line number Diff line number Diff line change
@@ -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
}
58 changes: 58 additions & 0 deletions backend/internal/application/vulnerability/mutations.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading
Loading