Skip to content
Merged
20 changes: 12 additions & 8 deletions backend/internal/domain/vulnerability.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
6 changes: 3 additions & 3 deletions frontend/.lint-ceiling.json
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
{
"total": 99,
"total": 97,
"rules": {
"@typescript-eslint/no-unused-vars": 27,
"@typescript-eslint/no-explicit-any": 21,
"react-hooks/set-state-in-effect": 13,
"react-hooks/preserve-manual-memoization": 12,
"react-refresh/only-export-components": 10,
"react-hooks/set-state-in-effect": 12,
"react-refresh/only-export-components": 9,
"react-hooks/static-components": 6,
"react-hooks/purity": 3,
"prefer-const": 2,
Expand Down
20 changes: 16 additions & 4 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand Down Expand Up @@ -442,10 +457,7 @@ const DashboardLayout = () => {
{/* The organization's accent, worn by every member (#718). */}
<BrandingSync />
<Sidebar mobileOpen={mobileNavOpen} onMobileClose={() => setMobileNavOpen(false)} />
<div
className="flex-1 flex flex-col h-screen overflow-hidden relative min-w-0"
style={{ background: 'var(--bg-primary)' }}
>
<div className="flex-1 flex flex-col h-screen overflow-hidden relative min-w-0 bg-surface-0">
{/* Demo (permanent, not dismissible by design — see shared/DemoBanner)
on top; Offline below it, closest to the header it pushes. */}
<BannerStack>
Expand Down
Loading
Loading