// ============================================================================ // Imara - Suspicious behaviour: three statistical checks on the recorded // connections and name lookups. // ============================================================================ // The behavioral detectors are statistical checks the control plane runs every // 5 minutes over the flow and DNS telemetry in ClickHouse // (control-plane/internal/behavior). Each card states the formula, the window // and the exact threshold for the selected sensitivity; findings are the real // alerts of that kind from /api/alerts. desc is the one sentence on the card; // more opens with the rest under "How it works". const BEHAVIORAL = [ { key: "beaconing", name: "Beaconing", icon: "RadioTower", desc: "Connections to one external address and port at regular, timer-like intervals, typical of command-and-control (C2) malware.", more: "Private addresses and your own servers are ignored.", method: "coefficient of variation of the gaps between connections", window: "last 6 h · average gap 10 s to 1 h", flags: { low: "at least 30 connections with gap CV below 0.10", medium: "at least 20 connections with gap CV below 0.20", high: "at least 12 connections with gap CV below 0.35", }, }, { key: "lateral_movement", name: "Lateral movement", icon: "Spline", desc: "A server contacts far more internal hosts than in its busiest hour of the past week, including new ones.", more: "Internal hosts: your servers and private addresses. Servers enrolled less than a day ago are skipped (no baseline yet).", method: "distinct internal peers in the last hour vs the 7-day hourly maximum", window: "last 1 h vs each hour of the previous 7 days", flags: { low: "max(3, 3 x baseline) peers and at least 5 new", medium: "max(3, 2 x baseline) peers and at least 3 new", high: "max(3, 1.5 x baseline) peers and at least 2 new · raised as critical", }, }, { key: "dns_tunneling", name: "DNS tunnelling", icon: "Globe", desc: "Many DNS queries under one domain with long, random-looking names, typical of data tunnelled out over DNS.", method: "Shannon entropy and length of the leftmost DNS label", window: "last 1 h · per host and registered domain", flags: { low: "at least 200 queries, entropy 4.0 bits/char, labels 30 chars", medium: "at least 100 queries, entropy 3.6 bits/char, labels 24 chars", high: "at least 50 queries, entropy 3.2 bits/char, labels 18 chars", }, }, ]; // The key numbers each detector records in alert.details, shown as chips. const EVIDENCE = { beaconing: [["peer", (v) => `to ${v}`], ["port", (v) => `port ${v}`], ["processes", (v) => `process ${Array.isArray(v) ? v.join(", ") : v}`], ["period_seconds", (v) => `every ${v} s`], ["connections", (v) => `${v} connections`], ["cv", (v) => `gap CV ${v}`]], lateral_movement: [["internal_peers_last_hour", (v) => `${v} internal hosts in the last hour`], ["new_peers", (v) => `${v} new`], ["new_peer_sample", (v) => `new: ${Array.isArray(v) ? v.slice(0, 5).join(", ") + (v.length > 5 ? " and " + (v.length - 5) + " more" : "") : v}`], ["processes", (v) => `process ${Array.isArray(v) ? v.join(", ") : v}`], ["baseline_max_per_hour", (v) => `7-day hourly peak ${v}`]], dns_tunneling: [["domain", (v) => v], ["queries", (v) => `${v} queries`], ["sample_names", (v) => `e.g. ${Array.isArray(v) ? v.slice(0, 3).join(", ") : v}`], ["avg_label_length", (v) => `avg label ${v} chars`], ["avg_label_entropy", (v) => `entropy ${v} bits/char`]], }; const SENS_LABEL = { low: "Fewer alerts", medium: "Balanced", high: "More alerts" }; // The alert panel shows the same evidence. window.ALERT_EVIDENCE = EVIDENCE; function DetectionsPage({ openAlert, alerts: alertsProp, onStatus }) { const D = window.MS_DATA; const ALERTS = alertsProp || D.ALERTS || []; const [cfg, setCfg] = useState(() => { const o = {}; BEHAVIORAL.forEach((b) => { o[b.key] = { on: true, sens: "medium" }; }); return o; }); // Settings are shown only once the Imara server answered (no saved row // means on and balanced, which is also what the server uses). Until then, // or if reading fails, they read "unknown" and cannot be changed. Each // check saves one change at a time; the screen follows the server. const [saveErr, setSaveErr] = useState(""); const [loaded, setLoaded] = useState("loading"); // loading | ok | failed const [saving, setSaving] = useState({}); useEffect(() => { if (!window.ImaraAPI || !window.ImaraAPI.listDetectors) { setLoaded("failed"); return; } window.ImaraAPI.listDetectors().then((list) => { setCfg((c) => { const n = { ...c }; (list || []).forEach((d) => { if (n[d.key]) n[d.key] = { on: d.enabled, sens: d.sensitivity }; }); return n; }); setLoaded("ok"); }).catch(() => setLoaded("failed")); }, []); const setOne = (k, patch) => { if (loaded !== "ok" || saving[k]) return; if (patch && patch.on === false && cfg[k] && cfg[k].on && !window.confirm("Turn this check off for all servers? It raises no alerts until turned back on. Recorded in the audit log.")) return; const prev = cfg[k]; const next = { ...prev, ...patch }; setCfg((c) => ({ ...c, [k]: next })); if (!window.ImaraAPI || !window.ImaraAPI.saveDetector) return; setSaving((m) => ({ ...m, [k]: true })); window.ImaraAPI.saveDetector(k, next.on, next.sens) .then((saved) => { setSaveErr(""); if (saved && typeof saved === "object" && saved.key === k) setCfg((c) => ({ ...c, [k]: { on: saved.enabled, sens: saved.sensitivity } })); }) .catch((e) => { setCfg((c) => ({ ...c, [k]: prev })); setSaveErr((e && e.message) || "save failed"); }) .finally(() => setSaving((m) => ({ ...m, [k]: false }))); }; // Alerts marked false positive from this page are hidden at once; the app's // alert list catches up on its next refresh. const [closedHere, setClosedHere] = useState({}); const markFalsePositive = (a) => { const scope = a.kind === "lateral_movement" ? "Lateral movement alerts for this server are muted for 7 days, whatever it connects to." : a.kind === "beaconing" ? "Beaconing alerts for this server and this external address and port are muted for 7 days." : a.kind === "dns_tunneling" ? "DNS tunnelling alerts for this server and this domain are muted for 7 days." : "Alerts from this check for this server and this destination are muted for 7 days."; const also = a.details && Array.isArray(a.details.also_flagged) ? a.details.also_flagged : []; const alsoText = also.length ? ` Also muted: ${also.length} other finding${also.length === 1 ? "" : "s"} in this alert (${also.slice(0, 2).join("; ")}${also.length > 2 ? "; ..." : ""}).` : ""; if (!window.confirm(`Mark this alert as a false positive? It closes. ${scope}${alsoText}`)) return; // Through the shared alert update, so counts, lists and the audit log follow // (it says so itself when the update fails). Promise.resolve(onStatus(a, "false_positive")).then((ok) => { if (ok !== false) setClosedHere((m) => ({ ...m, [a.id]: true })); }); }; // Only unresolved alerts count as "open" findings on the detector cards. const isOpen = (a) => a.status !== "resolved" && a.status !== "false_positive" && !closedHere[a.id]; const findingFor = (k) => ALERTS.filter((a) => a.kind === k && isOpen(a)); const behavioralOpen = ALERTS.filter((a) => isOpen(a) && BEHAVIORAL.some((b) => b.key === a.kind)); const enabledCount = loaded === "ok" ? Object.values(cfg).filter((c) => c.on).length : null; const hostOf = (a) => (D.byId && D.byId[a.server_id] && D.byId[a.server_id].hostname) || (a.details && a.details.hostname) || a.server_id || "all servers"; const T = window.ImaraTerms; const [how, setHow] = useState({}); // The console holds the newest open alerts (up to 500) and the newest 100 // of any state: counts here are of those, said so when there are more. const D0 = window.MS_DATA, oc0 = D0.ALERT_OPEN_COUNTS; const partialOpen = !!(oc0 && D0.ALERT_OPEN_LOADED != null && (oc0.critical || 0) + (oc0.warn || 0) + (oc0.info || 0) > D0.ALERT_OPEN_LOADED) || !!D0.ALERT_ACK_PARTIAL; // Real SIEM fan-out status: the configured connectors reported by the control // plane. Null while loading; [] when none configured. const [siem, setSiem] = useState(null); useEffect(() => { if (window.ImaraAPI?.siemStatus) window.ImaraAPI.siemStatus().then(setSiem).catch(() => setSiem("failed")); else setSiem([]); }, []); const siemLabel = siem == null ? "…" : siem === "failed" ? "unknown" : (siem.length ? (siem.length === 1 ? siem[0] : `${siem.length} connected`) : "none"); return (
Fixed statistical thresholds over recorded connections and DNS queries, not a trained model.
Changes to a check (on, off, sensitivity) are saved, recorded in the audit log, and apply from the next run.
An open finding stays one alert. Once it is resolved, the same server and destination are not raised again until the check's time window has passed. False positives are muted for 7 days.