From 924cd1e5f556bec626e99902e727118f932824fa Mon Sep 17 00:00:00 2001 From: alex-dembele Date: Thu, 8 Oct 2026 08:33:14 +0100 Subject: [PATCH 1/8] feat(vulnerabilities): count KEV flaws still waiting for a fix (#900) kev_count includes remediated and accepted findings, so it cannot back a badge that says how many exploited flaws are still exposed. kev_open counts the open, triaged and in-remediation ones; the sidebar and the dashboard read it. Signed-off-by: alex-dembele --- backend/internal/domain/vulnerability.go | 20 +++-- .../gorm_vulnerability_repository.go | 3 + .../gorm_vulnerability_stats_test.go | 87 +++++++++++++++++++ 3 files changed, 102 insertions(+), 8 deletions(-) create mode 100644 backend/internal/infrastructure/repository/gorm_vulnerability_stats_test.go diff --git a/backend/internal/domain/vulnerability.go b/backend/internal/domain/vulnerability.go index f1ad8f83..3206471a 100644 --- a/backend/internal/domain/vulnerability.go +++ b/backend/internal/domain/vulnerability.go @@ -203,12 +203,16 @@ func (v *Vulnerability) DedupKey() string { // 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"` + 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"` + // KEVOpen counts KEV-listed vulnerabilities still waiting for a fix (open, + // triaged or in remediation). KEVCount includes remediated and accepted ones, + // so it cannot answer "how many exploited flaws are still exposed". + KEVOpen int64 `json:"kev_open"` + ExploitCount int64 `json:"exploit_count"` } diff --git a/backend/internal/infrastructure/repository/gorm_vulnerability_repository.go b/backend/internal/infrastructure/repository/gorm_vulnerability_repository.go index 02543e07..cd951fed 100644 --- a/backend/internal/infrastructure/repository/gorm_vulnerability_repository.go +++ b/backend/internal/infrastructure/repository/gorm_vulnerability_repository.go @@ -247,6 +247,9 @@ func (r *GormVulnerabilityRepository) Stats(ctx context.Context, tenantID uuid.U } base().Where("status = ?", domain.VulnStatusOpen).Count(&stats.Open) base().Where("kev = ?", true).Count(&stats.KEVCount) + base().Where("kev = ? AND status IN ?", true, []domain.VulnStatus{ + domain.VulnStatusOpen, domain.VulnStatusTriaged, domain.VulnStatusInRemediation, + }).Count(&stats.KEVOpen) base().Where("exploit_available = ?", true).Count(&stats.ExploitCount) type kv struct { diff --git a/backend/internal/infrastructure/repository/gorm_vulnerability_stats_test.go b/backend/internal/infrastructure/repository/gorm_vulnerability_stats_test.go new file mode 100644 index 00000000..5d8fb49e --- /dev/null +++ b/backend/internal/infrastructure/repository/gorm_vulnerability_stats_test.go @@ -0,0 +1,87 @@ +// Copyright (c) 2026 OpenDefender Contributors +// SPDX-License-Identifier: AGPL-3.0-only +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU Affero General Public License v3.0 (see LICENSE). + +package repository + +import ( + "context" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" + + "github.com/opendefender/openrisk/internal/domain" + "github.com/opendefender/openrisk/internal/testsupport/sqliteschema" +) + +func setupVulnStatsRepo(t *testing.T) (*GormVulnerabilityRepository, *gorm.DB) { + t.Helper() + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Silent), + }) + require.NoError(t, err) + require.NoError(t, db.Exec(`CREATE TABLE vulnerabilities (id TEXT PRIMARY KEY)`).Error) + require.NoError(t, sqliteschema.Reconcile(db, "vulnerabilities", &domain.Vulnerability{})) + return NewGormVulnerabilityRepository(db), db +} + +func seedStatsVuln(t *testing.T, db *gorm.DB, tenant uuid.UUID, kev bool, status domain.VulnStatus) { + t.Helper() + v := domain.Vulnerability{ + ID: uuid.New(), + TenantID: tenant, + CVEID: "CVE-2026-" + uuid.NewString()[:5], + Title: "test finding", + Severity: domain.VulnSeverity("high"), + Status: status, + KEV: kev, + } + require.NoError(t, db.Create(&v).Error) +} + +// The sidebar badge and the dashboard counter both read kev_open: a KEV flaw is +// counted while it still waits for a fix, and drops out once remediated, +// accepted or dismissed. +func TestVulnStats_KEVOpen_Success(t *testing.T) { + repo, db := setupVulnStatsRepo(t) + tenant := uuid.New() + seedStatsVuln(t, db, tenant, true, domain.VulnStatusOpen) + seedStatsVuln(t, db, tenant, true, domain.VulnStatusTriaged) + seedStatsVuln(t, db, tenant, true, domain.VulnStatusInRemediation) + seedStatsVuln(t, db, tenant, true, domain.VulnStatusRemediated) + seedStatsVuln(t, db, tenant, true, domain.VulnStatusAccepted) + seedStatsVuln(t, db, tenant, true, domain.VulnStatusFalsePositive) + seedStatsVuln(t, db, tenant, false, domain.VulnStatusOpen) + + stats, err := repo.Stats(context.Background(), tenant) + require.NoError(t, err) + assert.Equal(t, int64(6), stats.KEVCount, "kev_count keeps its historical meaning") + assert.Equal(t, int64(3), stats.KEVOpen) +} + +// A tenant with no rows gets zeroes, not an error. +func TestVulnStats_KEVOpen_NotFound(t *testing.T) { + repo, _ := setupVulnStatsRepo(t) + stats, err := repo.Stats(context.Background(), uuid.New()) + require.NoError(t, err) + assert.Equal(t, int64(0), stats.KEVOpen) +} + +// Another tenant's open KEV flaws never reach this tenant's count. +func TestVulnStats_KEVOpen_Unauthorized(t *testing.T) { + repo, db := setupVulnStatsRepo(t) + mine, theirs := uuid.New(), uuid.New() + seedStatsVuln(t, db, theirs, true, domain.VulnStatusOpen) + seedStatsVuln(t, db, theirs, true, domain.VulnStatusOpen) + seedStatsVuln(t, db, mine, true, domain.VulnStatusOpen) + + stats, err := repo.Stats(context.Background(), mine) + require.NoError(t, err) + assert.Equal(t, int64(1), stats.KEVOpen) +} From d6a66ebbc593b6b42254d563103de2596c594751 Mon Sep 17 00:00:00 2001 From: alex-dembele Date: Thu, 8 Oct 2026 08:51:07 +0100 Subject: [PATCH 2/8] fix(shell): open the command palette with Cmd/Ctrl+K before its first use (#900) The palette is mounted only once opened, and it was the palette's own listener that handled the shortcut, so Cmd/Ctrl+K did nothing until the search field had been clicked once. The shell now listens for it and the palette keeps handling the toggle while open. Signed-off-by: alex-dembele --- frontend/src/App.tsx | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a0d2eb3a..4d8a99cb 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -397,6 +397,21 @@ const DashboardLayout = () => { }; }, []); + // ⌘K / Ctrl+K opens the palette from anywhere. The palette is mounted only + // once opened, so its own listener cannot be the one that opens it: before + // #900 the shortcut did nothing until the search field had been clicked once. + // While it is open, the palette's listener handles the toggle. + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (!(e.metaKey || e.ctrlKey) || e.key.toLowerCase() !== 'k') return; + if (useUIStore.getState().cmdkOpen) return; + e.preventDefault(); + setCmdkOpen(true); + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [setCmdkOpen]); + // Discoverable global shortcuts (UX-26). Rows shown in ShortcutsOverlay must // mirror these handlers. The hook ignores keys while typing / with ⌘/Ctrl/Alt. useHotkeys([ From 2092cf9268a5fb1e82b9145b99a487b98afcb450 Mon Sep 17 00:00:00 2001 From: alex-dembele Date: Thu, 8 Oct 2026 08:51:07 +0100 Subject: [PATCH 3/8] feat(shell): rebuild the sidebar on the October 2026 redesign (#900) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sidebar on surface-1 with the redesign's measurements: 34px entries with an accent keyline on the active one, a divider under the pinned dashboard, plain group labels, a bordered org switcher card and a 36px new-risk button. Labels follow the design copy (Recommandations, Rôles et accès, Administration) and Recommendations and Emerging risks get the lightbulb and radar icons. The badges are the design's four, each read from a tenant-scoped endpoint and only fetched when the member may open the screen: action items, KEV flaws still open, open incidents and pending invitations. The drawer now sits above the sticky header on mobile; it used to slide under it. Signed-off-by: alex-dembele --- .../src/components/layout/OrgPlanLabel.tsx | 2 +- .../src/components/layout/OrgSwitcher.tsx | 6 +- frontend/src/components/layout/Sidebar.tsx | 162 ++++++++++-------- .../src/components/layout/useNavCounts.ts | 54 ++++++ .../src/features/organization/OrgLogo.tsx | 15 +- .../vulnerabilities/vulnerabilityService.ts | 2 + frontend/src/shared/navModel.ts | 46 ++++- frontend/src/shared/uiStrings.ts | 56 +++++- 8 files changed, 249 insertions(+), 94 deletions(-) create mode 100644 frontend/src/components/layout/useNavCounts.ts diff --git a/frontend/src/components/layout/OrgPlanLabel.tsx b/frontend/src/components/layout/OrgPlanLabel.tsx index 1c48fa8d..d486aded 100644 --- a/frontend/src/components/layout/OrgPlanLabel.tsx +++ b/frontend/src/components/layout/OrgPlanLabel.tsx @@ -43,7 +43,7 @@ export function OrgPlanLabel() { if (!name) return null; return ( -
+
{t('common.planName', { plan: name })}
); diff --git a/frontend/src/components/layout/OrgSwitcher.tsx b/frontend/src/components/layout/OrgSwitcher.tsx index 3136206c..51646fe0 100644 --- a/frontend/src/components/layout/OrgSwitcher.tsx +++ b/frontend/src/components/layout/OrgSwitcher.tsx @@ -174,7 +174,7 @@ export function OrgSwitcher({ doSwitch.reset(); setOpen((v) => !v); }} - className="w-full flex items-center gap-2.5 px-2 py-1.5 rounded-[9px] hover:bg-hover focus-visible:bg-hover outline-none focus-visible:ring-2 focus-visible:ring-accent transition-colors" + className="w-full flex items-center gap-2.5 px-2 py-1.5 rounded-[10px] border border-border-default bg-surface-0 hover:border-border-strong outline-none focus-visible:ring-2 focus-visible:ring-accent transition-colors" > {badge ?? (
)}
-
+
{orgName}
-