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
69 changes: 69 additions & 0 deletions backend/app/analysis/profiles.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
"""Network context profiles — adjust how risk severities are displayed.

The stored severity is never mutated; only the presentation layer uses
these overrides so that re-scans don't lose the original classification.

Supported profiles:
standard_home Conservative defaults (the out-of-the-box experience).
home_lab Relaxed — SSH/multiple ports are expected, not alarming.
privacy_focused Stricter — unencrypted protocols escalate to critical.
"""

from __future__ import annotations

VALID_PROFILES = ("standard_home", "home_lab", "privacy_focused")
DEFAULT_PROFILE = "standard_home"

PROFILE_LABELS: dict[str, str] = {
"standard_home": "Standard Home",
"home_lab": "Home Lab",
"privacy_focused": "Privacy Focused",
}

PROFILE_DESCRIPTIONS: dict[str, str] = {
"standard_home": ("Conservative defaults. SSH open = high. Any open admin panel = high."),
"home_lab": (
"Relaxed. SSH with key-auth = low. Multiple open ports are expected."
" Focus on externally-reachable risks."
),
"privacy_focused": ("Stricter. Any unencrypted protocol = critical. DNS resolver = critical."),
}

# check_id → override severity per profile.
# Only entries that differ from the stored severity need to be listed.
_OVERRIDES: dict[str, dict[str, str]] = {
"home_lab": {
"open_ssh": "low",
"open_telnet": "medium", # still bad but less alarming in a lab
"multiple_open_ports": "low",
"open_rdp": "medium",
},
"privacy_focused": {
"open_http": "critical",
"open_telnet": "critical",
"open_ftp": "critical",
"open_dns": "critical",
"open_snmp": "critical",
"weak_tls": "critical",
"unencrypted_protocol": "critical",
},
}


def display_severity(stored_severity: str, profile: str) -> str:
"""Return the display severity for a check under the given profile.

Falls back to ``stored_severity`` when no override exists.
"""
if profile not in VALID_PROFILES:
profile = DEFAULT_PROFILE
overrides = _OVERRIDES.get(profile, {})
return overrides.get(stored_severity, stored_severity)


def display_severity_for_check(check_id: str, stored_severity: str, profile: str) -> str:
"""Return display severity using check_id-specific overrides."""
if profile not in VALID_PROFILES:
profile = DEFAULT_PROFILE
overrides = _OVERRIDES.get(profile, {})
return overrides.get(check_id, stored_severity)
49 changes: 41 additions & 8 deletions backend/app/api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,7 @@ class RiskOut(BaseModel):
ip_address: str
hostname: str | None
severity: str
display_severity: str # may differ from severity based on active network profile
check_id: str
title: str
description: str
Expand Down Expand Up @@ -292,7 +293,8 @@ def list_risks(
risks_sorted = sorted(
risks, key=lambda r: (severity_order.get(r.severity, 99), r.detected_at or "")
)
return [_risk_to_out(r) for r in risks_sorted]
profile = _get_active_profile(db)
return [_risk_to_out(r, profile) for r in risks_sorted]


@router.get("/risks/summary", response_model=RiskSummary)
Expand Down Expand Up @@ -323,7 +325,7 @@ def get_risk(risk_id: int, db: Annotated[Session, Depends(get_db)]) -> RiskOut:
risk = db.execute(stmt).scalar_one_or_none()
if risk is None:
raise HTTPException(status_code=404, detail="Risk not found")
return _risk_to_out(risk)
return _risk_to_out(risk, _get_active_profile(db))


class _AcknowledgeBody(BaseModel):
Expand All @@ -349,7 +351,7 @@ def acknowledge_risk(
risk.acknowledged_note = body.note
db.commit()
db.refresh(risk)
return _risk_to_out(risk)
return _risk_to_out(risk, _get_active_profile(db))


@router.patch("/risks/{risk_id}/unacknowledge", response_model=RiskOut)
Expand All @@ -368,7 +370,7 @@ def unacknowledge_risk(
risk.acknowledged_note = None
db.commit()
db.refresh(risk)
return _risk_to_out(risk)
return _risk_to_out(risk, _get_active_profile(db))


@router.get("/devices/{device_id}/risks", response_model=list[RiskOut])
Expand All @@ -382,16 +384,30 @@ def device_risks(device_id: int, db: Annotated[Session, Depends(get_db)]) -> lis
raise HTTPException(status_code=404, detail="Device not found")
stmt = select(Risk).options(selectinload(Risk.device)).where(Risk.device_id == device_id)
risks = db.execute(stmt).scalars().all()
return [_risk_to_out(r) for r in risks]
profile = _get_active_profile(db)
return [_risk_to_out(r, profile) for r in risks]


def _risk_to_out(r) -> RiskOut: # noqa: ANN001 — SQLAlchemy instance
def _get_active_profile(db: Session) -> str:
from app.analysis.profiles import DEFAULT_PROFILE, VALID_PROFILES
from app.models.settings import AppSetting

row = db.get(AppSetting, "network_profile")
if row and row.value in VALID_PROFILES:
return row.value
return DEFAULT_PROFILE


def _risk_to_out(r, profile: str = "standard_home") -> RiskOut: # noqa: ANN001 — SQLAlchemy instance
from app.analysis.profiles import display_severity_for_check

return RiskOut(
id=r.id,
device_id=r.device_id,
ip_address=r.device.ip_address,
hostname=r.device.hostname,
severity=r.severity,
display_severity=display_severity_for_check(r.check_id, r.severity, profile),
check_id=r.check_id,
title=r.title,
description=r.description,
Expand Down Expand Up @@ -510,18 +526,23 @@ def _rec_to_out(r) -> RecommendationOut: # noqa: ANN001 — SQLAlchemy instance

class SettingsOut(BaseModel):
webhook_url: str | None
network_profile: str


class _SettingsUpdate(BaseModel):
webhook_url: str | None = None
network_profile: str | None = None


@router.get("/settings", response_model=SettingsOut)
def get_settings(db: Annotated[Session, Depends(get_db)]) -> SettingsOut:
"""Return current application settings."""
from app.notifications import get_webhook_url

return SettingsOut(webhook_url=get_webhook_url(db))
return SettingsOut(
webhook_url=get_webhook_url(db),
network_profile=_get_active_profile(db),
)


@router.patch("/settings", response_model=SettingsOut)
Expand All @@ -530,11 +551,23 @@ def update_settings(
db: Annotated[Session, Depends(get_db)],
) -> SettingsOut:
"""Persist application settings."""
from app.analysis.profiles import VALID_PROFILES
from app.models.settings import AppSetting
from app.notifications import get_webhook_url, set_webhook_url

if body.webhook_url is not None:
set_webhook_url(db, body.webhook_url.strip() or None)
return SettingsOut(webhook_url=get_webhook_url(db))
if body.network_profile is not None and body.network_profile in VALID_PROFILES:
row = db.get(AppSetting, "network_profile")
if row is None:
db.add(AppSetting(key="network_profile", value=body.network_profile))
else:
row.value = body.network_profile
db.commit()
return SettingsOut(
webhook_url=get_webhook_url(db),
network_profile=_get_active_profile(db),
)


class _TestWebhookResponse(BaseModel):
Expand Down
70 changes: 70 additions & 0 deletions backend/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -703,6 +703,76 @@ def test_post_checklist_ignores_invalid_values(client):
assert upnp["answer"] == "unknown"


# ── /api/settings — network_profile ──────────────────────────────────────────


def test_get_settings_includes_network_profile(client):
"""GET /api/settings returns network_profile field (defaults to standard_home)."""
resp = client.get("/api/settings")
assert resp.status_code == 200
data = resp.json()
assert "network_profile" in data
assert data["network_profile"] == "standard_home"


def test_patch_settings_updates_network_profile(client):
"""PATCH /api/settings with network_profile persists the value."""
resp = client.patch("/api/settings", json={"network_profile": "home_lab"})
assert resp.status_code == 200
assert resp.json()["network_profile"] == "home_lab"

# reset
client.patch("/api/settings", json={"network_profile": "standard_home"})


def test_patch_settings_ignores_invalid_network_profile(client):
"""PATCH /api/settings silently ignores unknown profile values."""
resp = client.patch("/api/settings", json={"network_profile": "not_a_profile"})
assert resp.status_code == 200
# profile should remain unchanged (standard_home default)
assert resp.json()["network_profile"] == "standard_home"


def test_risk_display_severity_present(client, seeded_db):
"""GET /api/risks returns display_severity field on every risk."""
resp = client.get("/api/risks")
assert resp.status_code == 200
for risk in resp.json():
assert "display_severity" in risk


def test_risk_display_severity_overridden_by_home_lab(client, db_engine, seeded_db):
"""With home_lab profile, open_ssh risk has display_severity=low."""

from app.models.risk import Risk

S = sessionmaker(bind=db_engine) # noqa: N806 -- uppercase matches SQLAlchemy Session convention
db = S()
device_id = seeded_db["device_id"]
risk = Risk(
device_id=device_id,
severity="high",
check_id="open_ssh",
title="SSH open",
description="SSH port is open",
)
db.add(risk)
db.commit()

client.patch("/api/settings", json={"network_profile": "home_lab"})
resp = client.get("/api/risks")
client.patch("/api/settings", json={"network_profile": "standard_home"})

db.delete(risk)
db.commit()
db.close()

ssh_risks = [r for r in resp.json() if r["check_id"] == "open_ssh"]
assert len(ssh_risks) == 1
assert ssh_risks[0]["severity"] == "high"
assert ssh_risks[0]["display_severity"] == "low"


# ── /api/insights/segmentation ────────────────────────────────────────────────


Expand Down
38 changes: 36 additions & 2 deletions frontend/src/pages/DashboardPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,11 @@ import {
import { useDevices, useScans, useTriggerScan, useRiskSummary } from "../hooks";
import { useScanStatus } from "../hooks/useScanStatus";
import { useToast } from "../hooks/useToast";
import type { PostureBadge, SegmentationInsight } from "../types/api";
import type {
NetworkProfile,
PostureBadge,
SegmentationInsight,
} from "../types/api";

// ── Accent stripe colours per stat card ───────────────────────────────────────
const STRIPE: Record<string, string> = {
Expand Down Expand Up @@ -159,6 +163,27 @@ function usePostureBadge() {
return { posture, yesCount, total };
}

const PROFILE_LABELS: Record<NetworkProfile, string> = {
standard_home: "Standard Home",
home_lab: "Home Lab",
privacy_focused: "Privacy Focused",
};

function useActiveProfile() {
const [profile, setProfile] = useState<NetworkProfile | null>(null);

useEffect(() => {
fetch("/api/settings")
.then((r) => r.json())
.then((d: { network_profile?: NetworkProfile }) => {
if (d?.network_profile) setProfile(d.network_profile);
})
.catch(() => {});
}, []);

return profile;
}

function useSegmentation() {
const [data, setData] = useState<SegmentationInsight | null>(null);

Expand Down Expand Up @@ -290,6 +315,7 @@ export function DashboardPage() {
const { toasts, addToast, dismissToast } = useToast();
const [scanStartedId, setScanStartedId] = useState<number | null>(null);
const { posture, yesCount, total } = usePostureBadge();
const activeProfile = useActiveProfile();
const segmentation = useSegmentation();
const [segmentationDismissed, setSegmentationDismissed] = useState(false);

Expand Down Expand Up @@ -340,7 +366,15 @@ export function DashboardPage() {
/>
)}

<PageHeader title="Dashboard" action={triggerButton} />
<PageHeader
title="Dashboard"
subtitle={
activeProfile
? `Profile: ${PROFILE_LABELS[activeProfile]}`
: undefined
}
action={triggerButton}
/>

{noScansYet && (
<Card className="mb-6 flex flex-col items-center gap-4 py-10 text-center">
Expand Down
4 changes: 3 additions & 1 deletion frontend/src/pages/DeviceDetailPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,9 @@ const RiskCard = memo(function RiskCard({ risk }: { risk: Risk }) {
return (
<Card>
<div className="mb-2 flex flex-wrap items-center gap-3">
<Badge variant={risk.severity}>{risk.severity}</Badge>
<Badge variant={risk.display_severity ?? risk.severity}>
{risk.display_severity ?? risk.severity}
</Badge>
<span className="font-medium">{risk.title}</span>
</div>
<p className="text-sm text-[var(--color-text-secondary)]">
Expand Down
8 changes: 6 additions & 2 deletions frontend/src/pages/RisksPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,9 @@ function RiskModal({
>
<div className="mb-4 flex items-start justify-between gap-3">
<div className="flex flex-wrap items-center gap-2">
<Badge variant={risk.severity}>{risk.severity}</Badge>
<Badge variant={risk.display_severity ?? risk.severity}>
{risk.display_severity ?? risk.severity}
</Badge>
{isAcknowledged && (
<span className="rounded-full border border-[var(--color-border)] px-2 py-0.5 text-xs text-[var(--color-text-secondary)]">
accepted
Expand Down Expand Up @@ -434,7 +436,9 @@ export function RisksPage() {
onClick={() => setSelectedRisk(risk)}
aria-label={`View details for ${risk.title}`}
>
<Badge variant={risk.severity}>{risk.severity}</Badge>
<Badge variant={risk.display_severity ?? risk.severity}>
{risk.display_severity ?? risk.severity}
</Badge>
<span className="flex-1 text-sm font-medium">
{risk.title}
</span>
Expand Down
Loading
Loading