feat: add suspicious traffic detection dashboard widget with sensitive port monitoring and security posture indicator

Add dashboard_suspicious_traffic to detect external connections to sensitive ports (SSH/RDP/SMB/VNC/PostgreSQL/MySQL/Redis) from outside IPAM subnets with severity classification, extend Dashboard type with security_posture/suspicious_traffic/last_syncs fields, implement BarList component for traffic visualization with percentage bars and byte formatting, add security posture card
This commit is contained in:
2026-07-09 15:50:42 +02:00
parent 32906bca1e
commit 571d1513e7
4 changed files with 182 additions and 17 deletions
+45 -1
View File
@@ -334,6 +334,47 @@ def dashboard_top_talkers(db: Session) -> list[dict[str, int | str]]:
] ]
def dashboard_suspicious_traffic(db: Session) -> list[dict[str, int | str]]:
sensitive_ports = {
22: "SSH exposed from outside IPAM",
3389: "RDP exposed from outside IPAM",
445: "SMB exposed from outside IPAM",
5900: "VNC exposed from outside IPAM",
5432: "PostgreSQL exposed from outside IPAM",
3306: "MySQL exposed from outside IPAM",
6379: "Redis exposed from outside IPAM",
}
subnets = db.scalars(select(Subnet).order_by(Subnet.cidr)).all()
workload_ips = {
address.address
for address in db.scalars(select(IpAddress).where(IpAddress.workload_id.is_not(None))).all()
}
events: dict[tuple[str, str, int], dict[str, int | str]] = {}
for flow in db.scalars(select(TrafficFlow).order_by(TrafficFlow.updated_at.desc()).limit(500)).all():
port = flow.destination_port or 0
if port not in sensitive_ports:
continue
source_internal = bool(subnet_label_for_ip(subnets, flow.source_ip))
destination_internal = bool(subnet_label_for_ip(subnets, flow.destination_ip)) or flow.destination_ip in workload_ips
if source_internal or not destination_internal:
continue
key = (flow.source_ip, flow.destination_ip, port)
event = events.setdefault(
key,
{
"source": flow.source_ip,
"destination": flow.destination_ip,
"protocol": flow.protocol,
"port": port,
"bytes": 0,
"reason": sensitive_ports[port],
"severity": "high" if port in {22, 3389, 445} else "medium",
},
)
event["bytes"] = int(event["bytes"]) + int(flow.bytes or 0)
return sorted(events.values(), key=lambda item: int(item["bytes"]), reverse=True)[:5]
def proxmox_action(action: str) -> str: def proxmox_action(action: str) -> str:
return {"allow": "ACCEPT", "deny": "DROP", "reject": "REJECT"}.get(action, "ACCEPT") return {"allow": "ACCEPT", "deny": "DROP", "reject": "REJECT"}.get(action, "ACCEPT")
@@ -455,12 +496,15 @@ def complete_setup(payload: SetupCompleteRequest, db: Session = Depends(get_db))
def dashboard(_: CurrentUser, db: Session = Depends(get_db)) -> dict: def dashboard(_: CurrentUser, db: Session = Depends(get_db)) -> dict:
last_syncs = db.scalars(select(Cluster).order_by(Cluster.updated_at.desc()).limit(5)).all() last_syncs = db.scalars(select(Cluster).order_by(Cluster.updated_at.desc()).limit(5)).all()
faulty_nodes = db.scalars(select(Node).where(Node.status != "online")).all() faulty_nodes = db.scalars(select(Node).where(Node.status != "online")).all()
suspicious = dashboard_suspicious_traffic(db)
return { return {
"clusters": db.scalar(select(func.count()).select_from(Cluster)), "clusters": db.scalar(select(func.count()).select_from(Cluster)),
"nodes": db.scalar(select(func.count()).select_from(Node)), "nodes": db.scalar(select(func.count()).select_from(Node)),
"workloads": db.scalar(select(func.count()).select_from(Workload)), "workloads": db.scalar(select(func.count()).select_from(Workload)),
"networks": db.scalar(select(func.count()).select_from(Network)), "networks": db.scalar(select(func.count()).select_from(Network)),
"open_policy_violations": 1, "open_policy_violations": len(suspicious),
"security_posture": "attention" if suspicious or faulty_nodes else "stable",
"suspicious_traffic": suspicious,
"last_syncs": [ "last_syncs": [
{ {
"id": cluster.id, "id": cluster.id,
+11
View File
@@ -6,8 +6,19 @@ export type Dashboard = {
workloads: number; workloads: number;
networks: number; networks: number;
open_policy_violations: number; open_policy_violations: number;
security_posture: string;
faulty_nodes: Array<{ id: string; name: string; status: string }>; faulty_nodes: Array<{ id: string; name: string; status: string }>;
last_syncs: Array<{ id: string; name: string; provider: string; status: string | null; error: string | null; at: string | null }>;
top_talkers: Array<{ name: string; bytes: number }>; top_talkers: Array<{ name: string; bytes: number }>;
suspicious_traffic: Array<{
source: string;
destination: string;
protocol: string;
port: number;
bytes: number;
reason: string;
severity: string;
}>;
}; };
export type SetupStatus = { export type SetupStatus = {
+14 -2
View File
@@ -9,6 +9,7 @@ import {
Flame, Flame,
GitBranch, GitBranch,
LayoutDashboard, LayoutDashboard,
LogOut,
LockKeyhole, LockKeyhole,
Moon, Moon,
Network, Network,
@@ -22,7 +23,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import { useEffect } from "react"; import { useEffect } from "react";
import { token } from "../api/client"; import { clearTokens, token } from "../api/client";
import { useTheme } from "../stores/theme"; import { useTheme } from "../stores/theme";
const navGroups = [ const navGroups = [
@@ -92,6 +93,11 @@ export function Layout() {
const navigate = useNavigate(); const navigate = useNavigate();
const { dark, toggle } = useTheme(); const { dark, toggle } = useTheme();
function logout() {
clearTokens();
navigate("/login");
}
useEffect(() => { useEffect(() => {
if (!token()) navigate("/login"); if (!token()) navigate("/login");
function handleAuthExpired() { function handleAuthExpired() {
@@ -135,9 +141,15 @@ export function Layout() {
<main className="md:pl-64"> <main className="md:pl-64">
<header className="sticky top-0 z-10 flex h-16 items-center justify-between border-b border-border bg-panel px-4 md:px-6"> <header className="sticky top-0 z-10 flex h-16 items-center justify-between border-b border-border bg-panel px-4 md:px-6">
<div className="text-sm text-slate-500 dark:text-slate-400">SDN-like network and security operations</div> <div className="text-sm text-slate-500 dark:text-slate-400">SDN-like network and security operations</div>
<button className="rounded-md border border-border p-2 hover:bg-slate-100 dark:hover:bg-slate-800" onClick={toggle} aria-label="Toggle theme"> <div className="flex items-center gap-2">
<button className="inline-flex h-9 w-9 items-center justify-center rounded-md border border-border hover:bg-slate-100 dark:hover:bg-slate-800" onClick={toggle} aria-label="Toggle theme">
{dark ? <Sun size={18} /> : <Moon size={18} />} {dark ? <Sun size={18} /> : <Moon size={18} />}
</button> </button>
<button className="inline-flex h-9 items-center gap-2 rounded-md border border-border px-3 text-sm hover:bg-slate-100 dark:hover:bg-slate-800" onClick={logout}>
<LogOut size={16} />
Logout
</button>
</div>
</header> </header>
<div className="p-4 md:p-6"> <div className="p-4 md:p-6">
<Outlet /> <Outlet />
+110 -12
View File
@@ -1,5 +1,5 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { AlertTriangle, Boxes, Network, Server, ShieldAlert } from "lucide-react"; import { Activity, AlertTriangle, Boxes, Network, Radar, Server, ShieldAlert, ShieldCheck, Wifi } from "lucide-react";
import { api, Dashboard as DashboardData } from "../api/client"; import { api, Dashboard as DashboardData } from "../api/client";
import { PageHeader } from "../components/PageHeader"; import { PageHeader } from "../components/PageHeader";
@@ -8,16 +8,78 @@ const cards = [
["clusters", "Clusters", Server], ["clusters", "Clusters", Server],
["nodes", "Nodes", Boxes], ["nodes", "Nodes", Boxes],
["workloads", "VMs/LXCs", Network], ["workloads", "VMs/LXCs", Network],
["networks", "Networks", Network], ["networks", "Networks", Wifi],
["open_policy_violations", "Policy Violations", ShieldAlert], ["open_policy_violations", "Signals", ShieldAlert],
] as const; ] as const;
function formatBytes(value: number) {
if (!value) {
return "0 B";
}
const units = ["B", "KB", "MB", "GB", "TB"];
const index = Math.min(Math.floor(Math.log(value) / Math.log(1024)), units.length - 1);
return `${(value / 1024 ** index).toFixed(index === 0 ? 0 : 1)} ${units[index]}`;
}
function BarList({ items }: { items: Array<{ name: string; bytes: number }> }) {
const max = Math.max(...items.map((item) => item.bytes), 1);
if (!items.length) {
return <div className="border-t border-border py-4 text-sm text-slate-500">No flow telemetry collected yet.</div>;
}
return (
<div className="space-y-3 border-t border-border pt-4">
{items.map((item) => (
<div key={item.name} className="grid gap-1">
<div className="flex items-center justify-between gap-3 text-sm">
<span className="truncate font-medium">{item.name}</span>
<span className="shrink-0 text-slate-500">{formatBytes(item.bytes)}</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-slate-200 dark:bg-slate-800">
<div className="h-full rounded-full bg-accent" style={{ width: `${Math.max((item.bytes / max) * 100, 4)}%` }} />
</div>
</div>
))}
</div>
);
}
export function Dashboard() { export function Dashboard() {
const { data } = useQuery({ queryKey: ["dashboard"], queryFn: () => api<DashboardData>("/dashboard") }); const { data } = useQuery({ queryKey: ["dashboard"], queryFn: () => api<DashboardData>("/dashboard") });
const suspicious = data?.suspicious_traffic ?? [];
const postureStable = data?.security_posture !== "attention";
return ( return (
<> <>
<PageHeader title="Dashboard" subtitle="Operational overview across clusters, networks, policies, and sync health." /> <PageHeader title="Dashboard" subtitle="Operational overview across clusters, networks, policies, and sync health." />
<section className="mb-5 rounded-md border border-border bg-panel p-4">
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div className="flex items-center gap-3">
<div className={`grid h-11 w-11 place-items-center rounded-md ${postureStable ? "bg-accent/10 text-accent" : "bg-danger/10 text-danger"}`}>
{postureStable ? <ShieldCheck size={22} /> : <Radar size={22} />}
</div>
<div>
<div className="text-lg font-semibold">{postureStable ? "Control plane stable" : "Attention required"}</div>
<div className="text-sm text-slate-500">
{postureStable ? "No suspicious traffic signals or faulty nodes detected." : `${suspicious.length} suspicious traffic signal${suspicious.length === 1 ? "" : "s"} require review.`}
</div>
</div>
</div>
<div className="grid grid-cols-3 gap-2 text-sm">
<div className="rounded-md border border-border px-3 py-2">
<div className="text-xs text-slate-500">Telemetry</div>
<div className="font-medium">{(data?.top_talkers ?? []).length ? "Active" : "Waiting"}</div>
</div>
<div className="rounded-md border border-border px-3 py-2">
<div className="text-xs text-slate-500">Faulty Nodes</div>
<div className="font-medium">{data?.faulty_nodes.length ?? 0}</div>
</div>
<div className="rounded-md border border-border px-3 py-2">
<div className="text-xs text-slate-500">Signals</div>
<div className="font-medium">{suspicious.length}</div>
</div>
</div>
</div>
</section>
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-5"> <div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-5">
{cards.map(([key, label, Icon]) => ( {cards.map(([key, label, Icon]) => (
<div key={key} className="rounded-md border border-border bg-panel p-4"> <div key={key} className="rounded-md border border-border bg-panel p-4">
@@ -29,27 +91,63 @@ export function Dashboard() {
</div> </div>
))} ))}
</div> </div>
<div className="mt-6 grid gap-4 lg:grid-cols-2"> <div className="mt-5 grid gap-4 xl:grid-cols-[1.2fr_0.8fr]">
<section className="rounded-md border border-border bg-panel p-4">
<div className="mb-3 flex items-center gap-2 font-medium">
<Radar size={18} />
Suspicious Traffic
</div>
{suspicious.length ? (
<div className="divide-y divide-border border-t border-border">
{suspicious.map((item) => (
<div key={`${item.source}-${item.destination}-${item.port}`} className="grid gap-2 py-3 md:grid-cols-[1fr_auto] md:items-center">
<div>
<div className="font-medium">{item.source} -&gt; {item.destination}</div>
<div className="text-xs text-slate-500">{item.protocol}:{item.port} · {item.reason}</div>
</div>
<div className="flex items-center gap-3 text-sm">
<span className={`rounded-md px-2 py-1 text-xs ${item.severity === "high" ? "bg-danger/10 text-danger" : "bg-amber-500/10 text-amber-500"}`}>{item.severity}</span>
<span className="text-slate-500">{formatBytes(item.bytes)}</span>
</div>
</div>
))}
</div>
) : (
<div className="border-t border-border py-4 text-sm text-slate-500">No suspicious traffic detected from current flow telemetry.</div>
)}
</section>
<section className="rounded-md border border-border bg-panel p-4">
<div className="mb-3 flex items-center gap-2 font-medium">
<Activity size={18} />
Top Talkers
</div>
<BarList items={data?.top_talkers ?? []} />
</section>
</div>
<div className="mt-5 grid gap-4 lg:grid-cols-2">
<section className="rounded-md border border-border bg-panel p-4"> <section className="rounded-md border border-border bg-panel p-4">
<div className="mb-3 flex items-center gap-2 font-medium"> <div className="mb-3 flex items-center gap-2 font-medium">
<AlertTriangle size={18} /> <AlertTriangle size={18} />
Faulty Nodes Faulty Nodes
</div> </div>
{(data?.faulty_nodes ?? []).map((node) => ( {(data?.faulty_nodes ?? []).length ? (data?.faulty_nodes ?? []).map((node) => (
<div key={node.id} className="flex justify-between border-t border-border py-3 text-sm"> <div key={node.id} className="flex justify-between border-t border-border py-3 text-sm">
<span>{node.name}</span> <span>{node.name}</span>
<span className="text-danger">{node.status}</span> <span className="text-danger">{node.status}</span>
</div> </div>
))} )) : <div className="border-t border-border py-3 text-sm text-slate-500">All known nodes are online.</div>}
</section> </section>
<section className="rounded-md border border-border bg-panel p-4"> <section className="rounded-md border border-border bg-panel p-4">
<div className="mb-3 font-medium">Top Talkers</div> <div className="mb-3 font-medium">Recent Cluster Sync</div>
{(data?.top_talkers ?? []).length ? (data?.top_talkers ?? []).map((item) => ( {(data?.last_syncs ?? []).length ? (data?.last_syncs ?? []).map((cluster) => (
<div key={item.name} className="flex justify-between border-t border-border py-3 text-sm"> <div key={cluster.id} className="flex justify-between gap-3 border-t border-border py-3 text-sm">
<span>{item.name}</span> <div>
<span>{Math.round(item.bytes / 1_000_000)} MB</span> <div className="font-medium">{cluster.name}</div>
<div className="text-xs text-slate-500">{cluster.provider} · {cluster.at ? new Date(cluster.at).toLocaleString() : "never synced"}</div>
</div> </div>
)) : <div className="border-t border-border py-3 text-sm text-slate-500">No flow telemetry collected yet.</div>} <span className={cluster.status === "failed" ? "text-danger" : "text-accent"}>{cluster.status ?? "unknown"}</span>
</div>
)) : <div className="border-t border-border py-3 text-sm text-slate-500">No cluster sync history yet.</div>}
</section> </section>
</div> </div>
</> </>