* docs: spec — Sentinel C2/botnet auto-learning (high-precision, report-only) (ref #823) * docs: implementation plan — Sentinel C2/botnet auto-learning (ref #823) * feat(sentinel): C2 autolearn false-positive allowlist gate (ref #826) * fix(sentinel): serialize c2allow Add + robust atomicWriteFile (relative-path + temp cleanup) (ref #826) * feat(sentinel): C2 autolearn corroborating signals (rare/non-browser-JA/DGA) (ref #826) * feat(sentinel): C2 autolearn candidate lifecycle + persistence (ref #826) * fix(sentinel): c2cand deep-copy snapshots (persist/snapshot/record race) + evict order + device cap (ref #826) * feat(sentinel): C2 autolearn orchestrator — learned set + report-only verdicts (ref #826) * feat(sentinel): wire C2 learner into daemon + /c2 endpoints + seeded allowlist (ref #826) * fix(sentinel): make /c2/allow writable in packaged deploy (RW path + ownership) + sanitize Add (ref #826) POST /c2/allow could never write in production: sbx-sentinel.service runs User=secubox-toolbox under ProtectSystem=strict + ReadOnlyPaths=/etc/secubox, and the seeded c2-allow.txt ships root:root. Fix both halves: nest ReadWritePaths=/etc/secubox/sentinel under the existing ReadOnlyPaths=/etc/secubox in the unit, and chown/chmod 0750 the sentinel/ subdir + file (never the shared /etc/secubox parent) in postinst's configure branch, fail-safe. Also: fix the /c2/allow doc comment (form/query only, not JSON — r.FormValue never parses a body), and sanitize C2Allow.Add against newline injection now that host is network-reachable. * feat(toolbox): WebUI C2 appris sub-view + Ignorer allowlist proxy (ref #826) * fix(toolbox): C2 view XSS (data-host not onclick) + normalize candidate signal maps (ref #826) * fix(sentinel): bound c2 beaconing/learned maps + drop promoted from candidates + reject bare-TLD allow (ref #826) --------- Co-authored-by: CyberMind-FR <gandalf@Gk2.net>
12 KiB
Sentinel C2/Botnet Auto-Learning — Design
Date: 2026-07-07 Related: #823 (Sentinel engine), builds on the live activation (#825) Status: Design — pending user review
Goal
Automatically learn C2/botnet indicators from observed traffic and grow a learned indicator overlay, with high-precision false-positive suppression so legitimate periodic traffic (mail/webmail polling, admin WebUI dashboards, monitoring/health checks, NTP, update-checkers) is never learned as C2.
Report-only. Learned indicators carry action=report; FinalizeAction
keeps behavioral/learned classes report-only forever — never auto-block.
Non-goal: new detection of beaconing (the Behavioral engine already fires
ClassBotnetC2 beacon verdicts). This adds a promotion layer + FP gate that
turns sustained, corroborated beaconing into persistent learned indicators.
Background — what already exists
internal/sentinel/behavioral.go: the beaconing detector. Per(MacHash, Host)it tracks inter-arrival times; oncebeaconMinHits=6hits with coefficient-of-variation≤ beaconJitterCV=0.20, it fires oneClassBotnetC2verdict (sev/conf 70,ActionReport, evidence{pattern:beaconing, host, interval_s}), latched once per key.internal/sentinel/pack.go:Loaderglobs an overlay directory of*.jsonpacks,MergePacksmerges base + overlays (overlay entry overrides base on same(Type,Value)), hot-reload with a throttle, fail-closed on base-dir disappearance. Overlay dir on gk2 =/var/lib/secubox/sentinel/overlay.- IOC pack entry shape (
botnet.json):{type, value, class, severity, source, action}.type ∈ {domain, ip, ja3, ...}. FlowMetacarries:Host, URL, ClientIP, JA3, JA4, CertSHA1, FileSHA256, MacHash. (No Referer — signals are computed from these fields only.)cmd/sbxmitm/adcand*.go: the existing autolearn candidate→confirm pattern (records a candidate host with a hits count, an allowlist, and first-party suppression) — the model this mirrors.- The live-feed fetcher
secubox-sentinel-feedswrites overlay packs atomically (write-temp + validate non-empty JSON + rename) — the atomic-write model this reuses.
Architecture
A new package unit internal/sentinel/c2learn.go (C2Learner) sits downstream
of Behavioral. It is an Analyzer wrapper / post-step in the daemon: it never
blocks the analyze path, is fully fail-safe, and bounded in memory.
mirror → Behavioral(beacon verdict) ─┐
├─▶ C2Learner.Observe(flow, beaconVerdict)
FlowMeta ────────────────────────────┘ │
FP-gate (c2allow) ──drop──▶ (suppressed) │
signals (rare / non-browser JA4 / DGA) ─────┤
candidate store (c2cand) ───────────────────┤
sustained + corroborated? ──▶ promote ───▶ learned-c2.json (overlay)
│
Loader hot-reload → Gate match (report)
│
/stats · /verdicts · 3 surfaces
Component 1 — FP gate (c2allow.go)
Allowed(host) bool — the host is allowlisted (never learned) if it matches
ANY of:
- box's own vhosts / admin domains — read from a box-domains source at
startup + on reload. Source: the HAProxy routes file
(
/etc/secubox/waf/haproxy-routes.json) keys and/or a seededbox-domains.txt; both optional/fail-safe. This is the primary "admin WebUI" suppressor. - first-party / LAN —
Hostwhose registrable domain equals the box's own registrable domain, or aHostthat is an RFC1918 / loopback / link-local IP literal. - seeded operator allowlist
c2-allow.txt— one host/suffix per line, pre-seeded with: mail/webmail providers, monitoring/health endpoints, NTP, OS/browser update + telemetry, and major CDNs. Operator-editable; hot-reloaded. - (optional, bundled) a high-reputation top-domains list — suffix match.
Matching is suffix-aware (sub.example.com matches an example.com entry).
All sources are fail-safe: a missing/corrupt file contributes no entries (logs
once), never errors the learner.
Component 2 — corroborating signals (c2signal.go)
Computed from FlowMeta for the beaconing host. Promotion requires ≥1:
- rare/unknown destination —
Hostglobal first-seen recently / very low cumulative hit-frequency across the daemon's observation window (a bounded frequency map). A long-established destination is not "rare". - non-browser JA3/JA4 —
JA4(fallbackJA3) not in a bundled known-browser fingerprint set. An admin dashboard polled by a real browser has a browser JA4 → this signal does NOT fire → suppressed. Empty JA4 (non-TLS or unknown) is treated as unknown, not automatically "non-browser" (avoid FP on missing data) — configurable. - DGA-ish / high-entropy domain — Shannon entropy of the registrable label
above a threshold, or long random-looking label. Reuses the entropy helper
style from
behavioral.go's one-time-link check.
Component 3 — candidate store + confirm (c2cand.go)
Bounded (LRU-capped) map keyed by Host:
{host, firstSeen, lastSeen, windows, distinctDevices set, signals set, meanInterval}. A beacon that passes the gate AND has ≥1 signal
records/updates a candidate. Promotion criteria (all required):
- sustained: observed across
≥ c2MinWindows(e.g. 3) separate beacon windows spanning≥ c2MinSpan(e.g. 30 min) — not one burst; - corroborated:
≥1corroborating signal present (see Component 2); - distinct-device count raises the recorded confidence (more devices → higher), but a single device still promotes (targeted implant).
Persisted to /var/lib/secubox/sentinel/c2-candidates.json (atomic write) so
candidates survive daemon restarts. LRU + TTL bounded.
Component 4 — promotion to learned overlay
On confirm, upsert the host into
/var/lib/secubox/sentinel/overlay/learned-c2.json:
{type:"domain", value:<host>, class:"botnet_c2", severity:75, action:"report", source:"autolearn", evidence:{signals, interval_s, devices}}.
- Atomic write-temp-rename; validated non-empty JSON before rename (reuse the feeds fetcher's fail-safe model).
- Deduped by host; size-capped (e.g. ≤ 2000 entries, evict oldest).
- TTL decay: an entry not re-confirmed within
c2LearnedTTL(e.g. 30 days) is aged out on the next write — the list self-cleans when a host goes quiet. - The existing
Loaderhot-reloads the overlay → theGatematches the host (report-only) → verdicts flow to/stats,/verdicts, and all three surfaces withclass=botnet_c2, source=autolearn.
Component 5 — surfaces + safety valve
- The daemon status HTTP gains read endpoints:
GET /c2/learnedandGET /c2/candidates(bounded, read-only, localhost). - The portal proxies them (
/admin/sentinel/c2), and the WebUI Sentinelle tab gains a "C2 appris" sub-view listing learned + candidate hosts with their signals/interval/devices. - Each entry has "Ignorer (allowlist)" →
POST /admin/sentinel/c2/allow(host) → the daemon appends the host toc2-allow.txtand removes it from the learned overlay + candidate store. One click corrects a rare FP; the allowlist addition prevents re-promotion.
Error handling
- Fully fail-safe: the learner never blocks or panics on the analyze path;
malformed
FlowMetafields make a signal a no-op. - All file reads (allowlist, box-domains, candidates, overlay) fail to empty/no-op on missing/corrupt input, logged once — never fatal.
- All file writes atomic (temp + rename); a write failure is logged and dropped, never corrupts the live overlay.
- Bounded memory: LRU caps on the candidate map and the frequency map.
- Report-only invariant enforced at the source (
action:reporton every learned entry) AND byFinalizeAction(ClassBotnetC2is heuristic → report).
Testing
- FP gate: box-vhost host suppressed; first-party (box registrable) host
suppressed; RFC1918 IP host suppressed; a
c2-allow.txtentry (and its subdomains) suppressed; a browser-JA4 beacon to a known domain not learned. - Multi-signal: a sustained beacon to an unknown-but-browser-JA4 host with no other signal → NOT promoted (periodicity alone insufficient); the same host with a non-browser JA4 → candidate.
- Lifecycle: one burst (single window) → candidate, not promoted; sustained
across
≥ c2MinWindowsover≥ c2MinSpan+ ≥1 signal → promoted; distinct devices raise recorded confidence. - Promotion: learned-c2.json written atomically, deduped, capped; a never-re-confirmed entry past TTL is evicted on next write; overlay hot-reload makes the Gate match the host (report-only).
- Safety valve: allow-add removes the learned entry + candidate and prevents
re-promotion;
c2-allow.txtgets the host. - Report-only: no learned/behavioral path ever yields
action=block. - Fail-safe: missing/corrupt allowlist, box-domains, candidates, overlay → learner still runs, no panic, no false promotion from garbage.
- Surfaces:
/c2/learned+/c2/candidatesshapes; portal proxy fail-safe (daemon dark → empty, HTTP 200); WebUI sub-view renders learned/candidate/empty states; escaping on all daemon-supplied host strings.
Files
packages/secubox-toolbox-ng/internal/sentinel/c2learn.go— create (orchestrator: Observe + promote).packages/secubox-toolbox-ng/internal/sentinel/c2allow.go— create (FP gate).packages/secubox-toolbox-ng/internal/sentinel/c2signal.go— create (rare / non-browser-JA4 / DGA signals + bounded frequency map).packages/secubox-toolbox-ng/internal/sentinel/c2cand.go— create (candidate store + confirm + persistence).packages/secubox-toolbox-ng/internal/sentinel/*_test.go— create.packages/secubox-toolbox-ng/cmd/sbx-sentinel/main.go— modify (wire the learner after Behavioral; pass overlay/allowlist/candidate paths from env).packages/secubox-toolbox-ng/cmd/sbx-sentinel/http.go— modify (/c2/learned,/c2/candidates,POST /c2/allow).packages/secubox-toolbox-ng/debian/— modify (ship a seededc2-allow.txt+ known-browser-JA4 set; env vars insentinel.env).packages/secubox-toolbox/secubox_toolbox/sentinel_link.py+api.py— modify (proxy/admin/sentinel/c2*+ allow POST, fail-safe).packages/secubox-toolbox/www/toolbox/index.html— modify (C2 appris sub-view + Ignorer button).
Config (env, defaults tuned for precision)
SENTINEL_C2_ALLOW_FILE=/etc/secubox/sentinel/c2-allow.txtSENTINEL_C2_BOX_DOMAINS=/etc/secubox/waf/haproxy-routes.json(+ optionalbox-domains.txt)SENTINEL_C2_CANDIDATES=/var/lib/secubox/sentinel/c2-candidates.jsonSENTINEL_C2_LEARNED=/var/lib/secubox/sentinel/overlay/learned-c2.json- thresholds:
c2MinWindows=3,c2MinSpan=30m,c2LearnedTTL=720h, learnedseverity=75, candidate/learned caps2000. Constants, tunable in one place.
Out of scope (deferred)
- Auto-blocking of learned C2 (stays report-only; a future operator opt-in).
- IP-literal C2 beyond the RFC1918 suppression (mirror is mostly HTTP host-based).
- Cross-device fleet correlation via the mesh (single-node learner for now).
- Payload-size / cert-chain signals (not reliably available from the async mirror today).