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:
@@ -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:
|
||||
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:
|
||||
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()
|
||||
suspicious = dashboard_suspicious_traffic(db)
|
||||
return {
|
||||
"clusters": db.scalar(select(func.count()).select_from(Cluster)),
|
||||
"nodes": db.scalar(select(func.count()).select_from(Node)),
|
||||
"workloads": db.scalar(select(func.count()).select_from(Workload)),
|
||||
"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": [
|
||||
{
|
||||
"id": cluster.id,
|
||||
|
||||
@@ -6,8 +6,19 @@ export type Dashboard = {
|
||||
workloads: number;
|
||||
networks: number;
|
||||
open_policy_violations: number;
|
||||
security_posture: 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 }>;
|
||||
suspicious_traffic: Array<{
|
||||
source: string;
|
||||
destination: string;
|
||||
protocol: string;
|
||||
port: number;
|
||||
bytes: number;
|
||||
reason: string;
|
||||
severity: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type SetupStatus = {
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
Flame,
|
||||
GitBranch,
|
||||
LayoutDashboard,
|
||||
LogOut,
|
||||
LockKeyhole,
|
||||
Moon,
|
||||
Network,
|
||||
@@ -22,7 +23,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { token } from "../api/client";
|
||||
import { clearTokens, token } from "../api/client";
|
||||
import { useTheme } from "../stores/theme";
|
||||
|
||||
const navGroups = [
|
||||
@@ -92,6 +93,11 @@ export function Layout() {
|
||||
const navigate = useNavigate();
|
||||
const { dark, toggle } = useTheme();
|
||||
|
||||
function logout() {
|
||||
clearTokens();
|
||||
navigate("/login");
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!token()) navigate("/login");
|
||||
function handleAuthExpired() {
|
||||
@@ -135,9 +141,15 @@ export function Layout() {
|
||||
<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">
|
||||
<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} />}
|
||||
</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>
|
||||
<div className="p-4 md:p-6">
|
||||
<Outlet />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { PageHeader } from "../components/PageHeader";
|
||||
@@ -8,16 +8,78 @@ const cards = [
|
||||
["clusters", "Clusters", Server],
|
||||
["nodes", "Nodes", Boxes],
|
||||
["workloads", "VMs/LXCs", Network],
|
||||
["networks", "Networks", Network],
|
||||
["open_policy_violations", "Policy Violations", ShieldAlert],
|
||||
["networks", "Networks", Wifi],
|
||||
["open_policy_violations", "Signals", ShieldAlert],
|
||||
] 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() {
|
||||
const { data } = useQuery({ queryKey: ["dashboard"], queryFn: () => api<DashboardData>("/dashboard") });
|
||||
const suspicious = data?.suspicious_traffic ?? [];
|
||||
const postureStable = data?.security_posture !== "attention";
|
||||
|
||||
return (
|
||||
<>
|
||||
<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">
|
||||
{cards.map(([key, label, Icon]) => (
|
||||
<div key={key} className="rounded-md border border-border bg-panel p-4">
|
||||
@@ -29,27 +91,63 @@ export function Dashboard() {
|
||||
</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} -> {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">
|
||||
<div className="mb-3 flex items-center gap-2 font-medium">
|
||||
<AlertTriangle size={18} />
|
||||
Faulty Nodes
|
||||
</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">
|
||||
<span>{node.name}</span>
|
||||
<span className="text-danger">{node.status}</span>
|
||||
</div>
|
||||
))}
|
||||
)) : <div className="border-t border-border py-3 text-sm text-slate-500">All known nodes are online.</div>}
|
||||
</section>
|
||||
<section className="rounded-md border border-border bg-panel p-4">
|
||||
<div className="mb-3 font-medium">Top Talkers</div>
|
||||
{(data?.top_talkers ?? []).length ? (data?.top_talkers ?? []).map((item) => (
|
||||
<div key={item.name} className="flex justify-between border-t border-border py-3 text-sm">
|
||||
<span>{item.name}</span>
|
||||
<span>{Math.round(item.bytes / 1_000_000)} MB</span>
|
||||
<div className="mb-3 font-medium">Recent Cluster Sync</div>
|
||||
{(data?.last_syncs ?? []).length ? (data?.last_syncs ?? []).map((cluster) => (
|
||||
<div key={cluster.id} className="flex justify-between gap-3 border-t border-border py-3 text-sm">
|
||||
<div>
|
||||
<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 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>
|
||||
</div>
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user