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
8 changes: 8 additions & 0 deletions backend/app/api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ class ScanOut(BaseModel):
duration_seconds: float | None
devices_found: int | None
error_message: str | None
risks_critical: int | None
risks_high: int | None
risks_medium: int | None
risks_low: int | None

model_config = {"from_attributes": True}

Expand Down Expand Up @@ -138,6 +142,10 @@ def _scan_to_out(s) -> ScanOut: # noqa: ANN001 — SQLAlchemy instance
duration_seconds=s.duration_seconds,
devices_found=s.devices_found,
error_message=s.error_message,
risks_critical=s.risks_critical,
risks_high=s.risks_high,
risks_medium=s.risks_medium,
risks_low=s.risks_low,
)


Expand Down
5 changes: 5 additions & 0 deletions backend/app/models/scan.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,8 @@ class Scan(Base):
duration_seconds = Column(Float, nullable=True)
devices_found = Column(Integer, nullable=True)
error_message = Column(Text, nullable=True)
# Risk counts snapshotted at scan completion
risks_critical = Column(Integer, nullable=True)
risks_high = Column(Integer, nullable=True)
risks_medium = Column(Integer, nullable=True)
risks_low = Column(Integer, nullable=True)
21 changes: 21 additions & 0 deletions backend/app/scan_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,23 @@ def run_scan_and_persist(triggered_by: str = "scheduler") -> int:

run_all_checks(db)

# Snapshot risk counts for trend tracking
from sqlalchemy import func as sqlfunc
from sqlalchemy import select as sa_select

from app.models.risk import (
Risk, # noqa: PLC0415 — deferred to avoid circular import at module level
)

risk_counts: dict[str, int] = {"critical": 0, "high": 0, "medium": 0, "low": 0}
for sev in risk_counts:
risk_counts[sev] = (
db.execute(
sa_select(sqlfunc.count()).select_from(Risk).where(Risk.severity == sev)
).scalar_one()
or 0
)

# Generate/update hardening recommendations for all devices
from app.recommendations import (
generate_all_recommendations, # noqa: PLC0415 — deferred to avoid circular import at module level
Expand All @@ -59,6 +76,10 @@ def run_scan_and_persist(triggered_by: str = "scheduler") -> int:
scan.finished_at = datetime.now(tz=UTC)
scan.duration_seconds = round(time.monotonic() - t0, 2)
scan.devices_found = devices_found
scan.risks_critical = risk_counts["critical"]
scan.risks_high = risk_counts["high"]
scan.risks_medium = risk_counts["medium"]
scan.risks_low = risk_counts["low"]
db.commit()
logger.info("Scan %d completed: %d devices", scan_id, devices_found)

Expand Down
4 changes: 4 additions & 0 deletions backend/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,10 @@ def test_scan_schema_fields(client, seeded_db):
assert "finished_at" in scan
assert "duration_seconds" in scan
assert "devices_found" in scan
assert "risks_critical" in scan
assert "risks_high" in scan
assert "risks_medium" in scan
assert "risks_low" in scan


# ── POST /api/scans/trigger ───────────────────────────────────────────────────
Expand Down
27 changes: 27 additions & 0 deletions backend/tests/test_scan_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ def in_memory_session_factory():
and therefore sees the same in-memory database.
"""
import app.models.device # noqa: F401 — side-effect import registers ORM tables
import app.models.recommendation # noqa: F401 — side-effect import registers ORM tables
import app.models.risk # noqa: F401 — side-effect import registers ORM tables
import app.models.scan # noqa: F401 — side-effect import registers ORM tables
from app.db import Base

Expand Down Expand Up @@ -206,3 +208,28 @@ def test_run_scan_returns_scan_id(in_memory_session_factory, monkeypatch):
result = run_scan_and_persist("scheduler")

assert isinstance(result, int)


@pytest.mark.integration
def test_run_scan_populates_risk_counts(in_memory_session_factory, monkeypatch):
"""Completed scan record includes per-severity risk counts (all zero when no risks)."""
from app.models.scan import Scan
from app.scan_runner import run_scan_and_persist

monkeypatch.setattr("app.scan_runner.SessionLocal", in_memory_session_factory)

with patch("app.scan_runner.orchestrate_scan", return_value=_make_scan_result(n_hosts=1)):
scan_id = run_scan_and_persist("manual")

db = in_memory_session_factory()
scan = db.get(Scan, scan_id)
assert scan.risks_critical is not None # type: ignore[union-attr]
assert scan.risks_high is not None # type: ignore[union-attr]
assert scan.risks_medium is not None # type: ignore[union-attr]
assert scan.risks_low is not None # type: ignore[union-attr]
# All counts must be non-negative integers
assert scan.risks_critical >= 0 # type: ignore[union-attr]
assert scan.risks_high >= 0 # type: ignore[union-attr]
assert scan.risks_medium >= 0 # type: ignore[union-attr]
assert scan.risks_low >= 0 # type: ignore[union-attr]
db.close()
1 change: 1 addition & 0 deletions frontend/src/components/Layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const navLinks = [
{ to: "/", label: "Dashboard", end: true },
{ to: "/devices", label: "Devices", end: false },
{ to: "/risks", label: "Risks", end: false },
{ to: "/history", label: "History", end: false },
{ to: "/recommendations", label: "Recommendations", end: false },
{ to: "/settings", label: "Settings", end: false },
];
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
DashboardPage,
DevicesPage,
DeviceDetailPage,
HistoryPage,
RisksPage,
RecommendationsPage,
RecommendationDetailPage,
Expand All @@ -22,6 +23,7 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
<Route path="devices" element={<DevicesPage />} />
<Route path="devices/:id" element={<DeviceDetailPage />} />
<Route path="risks" element={<RisksPage />} />
<Route path="history" element={<HistoryPage />} />
<Route path="recommendations" element={<RecommendationsPage />} />
<Route
path="recommendations/:id"
Expand Down
Loading