diff --git a/backend/app/analysis/profiles.py b/backend/app/analysis/profiles.py new file mode 100644 index 0000000..6c7b7c9 --- /dev/null +++ b/backend/app/analysis/profiles.py @@ -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) diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py index 8869236..9fc8fb9 100644 --- a/backend/app/api/__init__.py +++ b/backend/app/api/__init__.py @@ -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 @@ -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) @@ -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): @@ -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) @@ -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]) @@ -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, @@ -510,10 +526,12 @@ 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) @@ -521,7 +539,10 @@ 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) @@ -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): diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 08f2e93..de7c8bf 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -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 ──────────────────────────────────────────────── diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx index a8d91df..b1f817a 100644 --- a/frontend/src/pages/DashboardPage.tsx +++ b/frontend/src/pages/DashboardPage.tsx @@ -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 = { @@ -159,6 +163,27 @@ function usePostureBadge() { return { posture, yesCount, total }; } +const PROFILE_LABELS: Record = { + standard_home: "Standard Home", + home_lab: "Home Lab", + privacy_focused: "Privacy Focused", +}; + +function useActiveProfile() { + const [profile, setProfile] = useState(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(null); @@ -290,6 +315,7 @@ export function DashboardPage() { const { toasts, addToast, dismissToast } = useToast(); const [scanStartedId, setScanStartedId] = useState(null); const { posture, yesCount, total } = usePostureBadge(); + const activeProfile = useActiveProfile(); const segmentation = useSegmentation(); const [segmentationDismissed, setSegmentationDismissed] = useState(false); @@ -340,7 +366,15 @@ export function DashboardPage() { /> )} - + {noScansYet && ( diff --git a/frontend/src/pages/DeviceDetailPage.tsx b/frontend/src/pages/DeviceDetailPage.tsx index 2ad49f3..f49f2f6 100644 --- a/frontend/src/pages/DeviceDetailPage.tsx +++ b/frontend/src/pages/DeviceDetailPage.tsx @@ -22,7 +22,9 @@ const RiskCard = memo(function RiskCard({ risk }: { risk: Risk }) { return (
- {risk.severity} + + {risk.display_severity ?? risk.severity} + {risk.title}

diff --git a/frontend/src/pages/RisksPage.tsx b/frontend/src/pages/RisksPage.tsx index de0797a..d882fb8 100644 --- a/frontend/src/pages/RisksPage.tsx +++ b/frontend/src/pages/RisksPage.tsx @@ -145,7 +145,9 @@ function RiskModal({ >

- {risk.severity} + + {risk.display_severity ?? risk.severity} + {isAcknowledged && ( accepted @@ -434,7 +436,9 @@ export function RisksPage() { onClick={() => setSelectedRisk(risk)} aria-label={`View details for ${risk.title}`} > - {risk.severity} + + {risk.display_severity ?? risk.severity} + {risk.title} diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx index 4174bc8..993df38 100644 --- a/frontend/src/pages/SettingsPage.tsx +++ b/frontend/src/pages/SettingsPage.tsx @@ -9,6 +9,7 @@ import type { ChecklistAnswer, ChecklistItem, ChecklistState, + NetworkProfile, PostureBadge, } from "../types/api"; @@ -87,7 +88,62 @@ function useWebhookSettings() { }; } -// ── Checklist helpers ───────────────────────────────────────────────────────── +// ── Network profile hook ────────────────────────────────────────────────────── + +const PROFILE_OPTIONS: { + value: NetworkProfile; + label: string; + description: string; +}[] = [ + { + value: "standard_home", + label: "Standard Home", + description: + "Conservative defaults. SSH open = high. Any open admin panel = high.", + }, + { + value: "home_lab", + label: "Home Lab", + description: + "Relaxed. SSH with key-auth = low. Multiple open ports are expected.", + }, + { + value: "privacy_focused", + label: "Privacy Focused", + description: + "Stricter. Any unencrypted protocol = critical. DNS resolver = critical.", + }, +]; + +function useNetworkProfile() { + const [profile, setProfile] = useState("standard_home"); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + + useEffect(() => { + fetch("/api/settings") + .then((r) => r.json()) + .then((d: { network_profile?: NetworkProfile }) => { + if (d.network_profile) setProfile(d.network_profile); + }) + .catch(() => {}) + .finally(() => setLoading(false)); + }, []); + + const save = (value: NetworkProfile) => { + setProfile(value); + setSaving(true); + fetch("/api/settings", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ network_profile: value }), + }) + .catch(() => {}) + .finally(() => setSaving(false)); + }; + + return { profile, loading, saving, save }; +} const POSTURE_CONFIG: Record< PostureBadge, @@ -239,6 +295,12 @@ export function SettingsPage() { save, test, } = useWebhookSettings(); + const { + profile, + loading: profileLoading, + saving: profileSaving, + save: saveProfile, + } = useNetworkProfile(); const handleCopyVersion = () => { if (!version) return; @@ -359,6 +421,60 @@ export function SettingsPage() { + {/* ── Network Context Profile ──────────────────────────────────── */} +
+

+ Network Context Profile +

+ +

+ Adjusts how risk severities are displayed based on your network + context. The stored risk severity is never changed — only the + presentation layer is affected. +

+
+ Network context profile +
+ {PROFILE_OPTIONS.map((opt) => ( + + ))} +
+
+ {profileSaving && ( +

+ Saving… +

+ )} +
+
+ {/* ── Network Health Checklist ─────────────────────────────────── */}

= {}) { posture_label: "At Risk", yes_count: 0, }, - "/api/settings": { webhook_url: null }, + "/api/settings": { webhook_url: null, network_profile: "standard_home" }, "/api/insights/segmentation": { flat_network: false, iot_count: 0, diff --git a/frontend/tests/version.test.tsx b/frontend/tests/version.test.tsx index fb7d0b7..86b4841 100644 --- a/frontend/tests/version.test.tsx +++ b/frontend/tests/version.test.tsx @@ -56,7 +56,7 @@ function mockHealthFetch(data: HealthResponse, ok = true) { return Promise.resolve({ ok: true, status: 200, - json: () => Promise.resolve({ webhook_url: null }), + json: () => Promise.resolve({ webhook_url: null, network_profile: "standard_home" }), }); } return Promise.resolve({ @@ -95,7 +95,7 @@ function mockAllFetch(healthData: HealthResponse) { return Promise.resolve({ ok: true, status: 200, - json: () => Promise.resolve({ webhook_url: null }), + json: () => Promise.resolve({ webhook_url: null, network_profile: "standard_home" }), }); } return Promise.resolve({