Skip to content

Commit f379978

Browse files
committed
feat: Improve dashboard
1 parent 6d5a388 commit f379978

19 files changed

Lines changed: 526 additions & 271 deletions

File tree

CLAUDE.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,10 @@ Access control rules:
139139
- Prioritized CVEs must always sort to the top in `/cves`, regardless of selected sort column.
140140
- Dashboard chart datasets must apply the same visibility logic as `stat_total_cves`.
141141
- Severity distribution must classify each visible CVE exactly once so the bucket sum matches `stat_total_cves`.
142-
- Dashboard payload includes both `priority_cves` and `high_epss_cves`.
142+
- Dashboard payload includes both `priority_cves` and `high_epss_cves`, plus `fix_first_cves` (ranked actionable list) and `cve_history` (real per-day totals).
143+
- `stat_open_risk_acceptances` is scoped: sec team sees the global `requested` count; regular users only count RAs they can access (`user_can_access_ra`), so the stat matches the `/risk-acceptances?status=requested` list.
144+
- The `cve_history` trend is sourced from the `cve_snapshots` table, filled by the daily `cve_snapshot` scheduler job (runs in the single `worker` process only). The `'*'`/`'*'` rows are org-wide counts deduplicated across namespaces; per-namespace rows may count a CVE once per namespace. `count_visible` applies CVSS/EPSS thresholds (always-show CVEs bypass), `count_total` does not.
145+
- `mttr_by_severity` is a sec-team-only audit metric; the endpoint returns an empty list for non-sec-team users.
143146

144147
### CVE detail and workflow rules
145148

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
"""Add cve_snapshots table for dashboard history chart
2+
3+
Revision ID: 019
4+
Revises: 018
5+
Create Date: 2026-07-04 00:00:00.000000
6+
"""
7+
8+
import sqlalchemy as sa
9+
10+
from alembic import op
11+
12+
revision = "019"
13+
down_revision = "018"
14+
branch_labels = None
15+
depends_on = None
16+
17+
18+
def upgrade() -> None:
19+
op.create_table(
20+
"cve_snapshots",
21+
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
22+
sa.Column("snapshot_date", sa.Date(), nullable=False),
23+
sa.Column("cluster_name", sa.String(255), nullable=False),
24+
sa.Column("namespace", sa.String(255), nullable=False),
25+
sa.Column("severity", sa.Integer(), nullable=False),
26+
sa.Column("count_total", sa.Integer(), nullable=False, server_default="0"),
27+
sa.Column("count_visible", sa.Integer(), nullable=False, server_default="0"),
28+
sa.UniqueConstraint("snapshot_date", "cluster_name", "namespace", "severity", name="uq_cve_snapshot"),
29+
)
30+
op.create_index("ix_cve_snapshots_snapshot_date", "cve_snapshots", ["snapshot_date"])
31+
32+
33+
def downgrade() -> None:
34+
op.drop_index("ix_cve_snapshots_snapshot_date", table_name="cve_snapshots")
35+
op.drop_table("cve_snapshots")

backend/app/models/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from .badge import BadgeToken
33
from .cve_comment import CveComment
44
from .cve_priority import CvePriority, PriorityLevel
5+
from .cve_snapshot import CveSnapshot
56
from .escalation import Escalation
67
from .global_settings import GlobalSettings
78
from .namespace_contact import NamespaceContact
@@ -20,6 +21,7 @@
2021
"CveComment",
2122
"CvePriority",
2223
"PriorityLevel",
24+
"CveSnapshot",
2325
"GlobalSettings",
2426
"Escalation",
2527
"BadgeToken",

backend/app/models/cve_snapshot.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
from datetime import date
2+
3+
from sqlalchemy import Date, Integer, String, UniqueConstraint
4+
from sqlalchemy.orm import Mapped, mapped_column
5+
6+
from ..database import Base
7+
8+
9+
class CveSnapshot(Base):
10+
"""Daily CVE counts per (cluster, namespace, severity) for the dashboard history chart.
11+
12+
The special row cluster_name='*' / namespace='*' holds org-wide counts
13+
deduplicated across namespaces (a CVE in 3 namespaces counts once there).
14+
count_total: all unsuppressed CVEs. count_visible: additionally filtered by the
15+
global CVSS/EPSS thresholds at snapshot time (always-show CVEs bypass them).
16+
"""
17+
18+
__tablename__ = "cve_snapshots"
19+
__table_args__ = (
20+
UniqueConstraint("snapshot_date", "cluster_name", "namespace", "severity", name="uq_cve_snapshot"),
21+
)
22+
23+
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
24+
snapshot_date: Mapped[date] = mapped_column(Date, nullable=False, index=True)
25+
cluster_name: Mapped[str] = mapped_column(String(255), nullable=False)
26+
namespace: Mapped[str] = mapped_column(String(255), nullable=False)
27+
severity: Mapped[int] = mapped_column(Integer, nullable=False)
28+
count_total: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
29+
count_visible: Mapped[int] = mapped_column(Integer, nullable=False, default=0)

backend/app/routers/dashboard.py

Lines changed: 72 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,11 @@
1919
AgingBucket,
2020
ClusterHeatmapRow,
2121
ComponentCveCount,
22+
CveHistoryPoint,
2223
CveTrendPoint,
2324
DashboardData,
2425
EpssMatrixPoint,
2526
FixabilityCount,
26-
FixableTrendPoint,
2727
MttrSeverity,
2828
NamespaceCveCount,
2929
RiskAcceptancePipeline,
@@ -34,6 +34,7 @@
3434
compute_suppression_sets,
3535
)
3636
from ..services.escalation_preview import compute_upcoming_escalations
37+
from ..services.risk_acceptance_service import user_can_access_ra
3738
from ..stackrox import queries as sx
3839
from ._scope import narrow_namespaces
3940

@@ -238,22 +239,47 @@ async def _sx_fixability_breakdown(
238239
)
239240

240241

241-
async def _sx_fixable_trend(
242-
ns: list[tuple[str, str]] | None,
243-
min_cvss: float,
244-
min_epss: float,
245-
always_show: set[str],
246-
exclude: set[str],
247-
) -> list[dict]:
248-
async with _stackrox_semaphore, StackRoxSessionLocal() as db:
249-
return await sx.get_fixable_trend(
250-
db,
251-
ns,
252-
min_cvss=min_cvss,
253-
min_epss=min_epss,
254-
always_show_cve_ids=always_show,
255-
exclude_cve_ids=exclude,
256-
)
242+
async def _cve_history(
243+
ns_list: list[tuple[str, str]] | None,
244+
use_visible_counts: bool,
245+
days: int = 90,
246+
) -> list[CveHistoryPoint]:
247+
"""Aggregate stored snapshots into a per-day severity series.
248+
249+
ns_list None => use the org-wide '*' rows (exact, deduped across namespaces).
250+
Otherwise sum the user's namespace rows (a CVE present in several visible
251+
namespaces counts once per namespace, same semantics as NamespaceBreakdown).
252+
"""
253+
from collections import defaultdict
254+
255+
from ..models.cve_snapshot import CveSnapshot
256+
257+
since = datetime.now(UTC).date() - timedelta(days=days)
258+
async with AppSessionLocal() as db:
259+
q = select(
260+
CveSnapshot.snapshot_date,
261+
CveSnapshot.severity,
262+
func.sum(CveSnapshot.count_visible if use_visible_counts else CveSnapshot.count_total),
263+
).where(CveSnapshot.snapshot_date >= since)
264+
if ns_list is None:
265+
q = q.where(CveSnapshot.namespace == "*")
266+
else:
267+
q = q.where(
268+
CveSnapshot.namespace != "*",
269+
tuple_(CveSnapshot.namespace, CveSnapshot.cluster_name).in_(ns_list),
270+
)
271+
q = q.group_by(CveSnapshot.snapshot_date, CveSnapshot.severity)
272+
rows = (await db.execute(q)).all()
273+
274+
by_date: dict = defaultdict(lambda: {"critical": 0, "important": 0, "moderate": 0, "low": 0, "unknown": 0})
275+
sev_key = {4: "critical", 3: "important", 2: "moderate", 1: "low", 0: "unknown"}
276+
for snapshot_date, severity, count in rows:
277+
by_date[snapshot_date][sev_key.get(severity, "unknown")] += int(count or 0)
278+
return [CveHistoryPoint(date=str(d), **v) for d, v in sorted(by_date.items())]
279+
280+
281+
async def _empty_mttr() -> list[MttrSeverity]:
282+
return []
257283

258284

259285
async def _upcoming_escalations(
@@ -403,8 +429,9 @@ async def dashboard(
403429
top_vulnerable_components=[],
404430
risk_acceptance_pipeline=RiskAcceptancePipeline(requested=0, approved=0, rejected=0, expired=0),
405431
fixability_breakdown=FixabilityCount(fixable=0, unfixable=0),
406-
fixable_trend=[],
432+
cve_history=[],
407433
mttr_by_severity=[],
434+
fix_first_cves=[],
408435
)
409436
namespaces = narrow_namespaces(current_user.namespaces, cluster, namespace)
410437

@@ -474,12 +501,16 @@ async def dashboard(
474501
escalations_result = await app_db.execute(select(func.count(Escalation.id)))
475502
escalations = escalations_result.scalar() or 0
476503

477-
open_ra_result = await app_db.execute(
478-
select(func.count(RiskAcceptance.id)).where(
479-
RiskAcceptance.status == RiskStatus.requested,
504+
# Open risk acceptances: sec team sees the global count; regular users only
505+
# count RAs they can actually see in the /risk-acceptances list.
506+
if current_user.is_sec_team:
507+
open_ra_result = await app_db.execute(
508+
select(func.count(RiskAcceptance.id)).where(RiskAcceptance.status == RiskStatus.requested)
480509
)
481-
)
482-
open_ra = open_ra_result.scalar() or 0
510+
open_ra = open_ra_result.scalar() or 0
511+
else:
512+
open_ra_rows = await app_db.execute(select(RiskAcceptance).where(RiskAcceptance.status == RiskStatus.requested))
513+
open_ra = sum(1 for ra in open_ra_rows.scalars().all() if user_can_access_ra(current_user, ra))
483514

484515
# Run all chart queries + upcoming escalations + RA pipeline concurrently
485516
upcoming_ns = namespaces if (has_scope or not current_user.can_see_all_namespaces) else []
@@ -492,7 +523,7 @@ async def dashboard(
492523
aging_rows,
493524
top_components_rows,
494525
fixability_data,
495-
fixable_trend_rows,
526+
cve_history_data,
496527
upcoming_escalations,
497528
risk_acceptance_pipeline,
498529
mttr_data,
@@ -505,10 +536,10 @@ async def dashboard(
505536
_sx_cve_aging(ns_list_for_queries, min_cvss, min_epss, always_show, suppressed_cve_ids),
506537
_sx_top_vulnerable_components(ns_list_for_queries, min_cvss, min_epss, always_show, suppressed_cve_ids),
507538
_sx_fixability_breakdown(ns_list_for_queries, min_cvss, min_epss, always_show, suppressed_cve_ids),
508-
_sx_fixable_trend(ns_list_for_queries, min_cvss, min_epss, always_show, suppressed_cve_ids),
539+
_cve_history(ns_list_for_queries, use_visible_counts=not current_user.is_sec_team),
509540
_upcoming_escalations(upcoming_ns, settings),
510541
_ra_pipeline(),
511-
_mttr_by_severity(ns_list_for_queries),
542+
_mttr_by_severity(ns_list_for_queries) if current_user.is_sec_team else _empty_mttr(),
512543
)
513544

514545
epss_matrix = [
@@ -550,6 +581,19 @@ async def dashboard(
550581
),
551582
)[:8]
552583

584+
# "Fix first" ranking for ops teams: actionable CVEs ordered by
585+
# sec-team priority, then fixability, then exploitation probability, then
586+
# severity. Fixable CVEs rank above unfixable ones; risk-accepted CVEs are excluded.
587+
fix_first = sorted(
588+
(item for item in unique_items if not item.has_risk_acceptance),
589+
key=lambda x: (
590+
not x.has_priority,
591+
not x.fixable,
592+
-x.epss_probability,
593+
-x.severity.value,
594+
),
595+
)[:10]
596+
553597
return DashboardData(
554598
stat_total_cves=total,
555599
stat_escalations=escalations,
@@ -594,9 +638,7 @@ async def dashboard(
594638
top_vulnerable_components=top_vulnerable_components,
595639
risk_acceptance_pipeline=risk_acceptance_pipeline,
596640
fixability_breakdown=FixabilityCount(**fixability_data),
597-
fixable_trend=[
598-
FixableTrendPoint(date=r["date"], fixable=r["fixable"], unfixable=r["unfixable"])
599-
for r in fixable_trend_rows
600-
],
641+
cve_history=cve_history_data,
601642
mttr_by_severity=mttr_data,
643+
fix_first_cves=fix_first,
602644
)

backend/app/routers/risk_acceptances.py

Lines changed: 9 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -22,15 +22,23 @@
2222
RiskAcceptanceResponse,
2323
RiskAcceptanceReview,
2424
RiskAcceptanceUpdate,
25-
RiskScope,
2625
)
2726
from ..services.audit_service import log_action
27+
from ..services.risk_acceptance_service import (
28+
get_scope_namespaces as _get_scope_namespaces,
29+
)
2830
from ..services.risk_acceptance_service import (
2931
is_single_team_scope as _is_single_team_scope,
3032
)
33+
from ..services.risk_acceptance_service import (
34+
normalize_scope as _normalize_scope,
35+
)
3136
from ..services.risk_acceptance_service import (
3237
scope_key as _scope_key,
3338
)
39+
from ..services.risk_acceptance_service import (
40+
user_can_access_ra as _user_can_access_ra,
41+
)
3442
from ..services.risk_acceptance_service import (
3543
validate_and_resolve_scope as _validate_and_resolve_scope,
3644
)
@@ -53,35 +61,6 @@ async def _effective_namespaces(user: CurrentUser, sx_db: AsyncSession) -> list[
5361
return user.namespaces
5462

5563

56-
def _normalize_scope(scope: dict) -> RiskScope:
57-
if isinstance(scope, dict) and "mode" in scope and "targets" in scope:
58-
return RiskScope.model_validate(scope)
59-
# Legacy records used {} or {images, namespaces}. Treat missing mode as global.
60-
return RiskScope(mode="all", targets=[])
61-
62-
63-
def _get_scope_namespaces(scope: dict) -> set[tuple[str, str]]:
64-
"""Extract (namespace, cluster_name) pairs from a risk acceptance scope."""
65-
ns_scope = _normalize_scope(scope)
66-
if ns_scope.mode == "all":
67-
return set()
68-
return {(t.namespace, t.cluster_name) for t in ns_scope.targets}
69-
70-
71-
def _user_can_access_ra(user: CurrentUser, ra: RiskAcceptance) -> bool:
72-
"""Check if user can access a risk acceptance based on namespace overlap."""
73-
if user.can_see_all_namespaces:
74-
return True
75-
if ra.created_by == user.id:
76-
return True
77-
scope_ns = _get_scope_namespaces(ra.scope)
78-
if not scope_ns:
79-
# 'all' scope — accessible to anyone with any namespace
80-
return user.has_namespaces
81-
user_ns = set(user.namespaces)
82-
return bool(scope_ns & user_ns)
83-
84-
8564
def _build_response(ra: RiskAcceptance, comment_count: int) -> RiskAcceptanceResponse:
8665
return RiskAcceptanceResponse(
8766
id=ra.id,

backend/app/schemas/dashboard.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -74,10 +74,13 @@ class FixabilityCount(BaseModel):
7474
unfixable: int
7575

7676

77-
class FixableTrendPoint(BaseModel):
77+
class CveHistoryPoint(BaseModel):
7878
date: str # YYYY-MM-DD
79-
fixable: int
80-
unfixable: int
79+
critical: int = 0
80+
important: int = 0
81+
moderate: int = 0
82+
low: int = 0
83+
unknown: int = 0
8184

8285

8386
class ThresholdPreview(BaseModel):
@@ -107,5 +110,6 @@ class DashboardData(BaseModel):
107110
top_vulnerable_components: list[ComponentCveCount]
108111
risk_acceptance_pipeline: RiskAcceptancePipeline
109112
fixability_breakdown: FixabilityCount
110-
fixable_trend: list[FixableTrendPoint]
113+
cve_history: list[CveHistoryPoint]
111114
mttr_by_severity: list[MttrSeverity]
115+
fix_first_cves: list[CveListItem]

0 commit comments

Comments
 (0)