From f19d566793578d54dbf4209ea5bc3887a66d151b Mon Sep 17 00:00:00 2001 From: CyberMind Date: Tue, 7 Jul 2026 08:13:48 +0200 Subject: [PATCH] =?UTF-8?q?fix(sentinel):=20C2=20auto-learn=20false=20posi?= =?UTF-8?q?tive=20=E2=80=94=20require=20a=20strong=20signal=20(ref=20#826)?= =?UTF-8?q?=20(#828)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * release: toolbox-ng 0.1.30 + toolbox 2.8.2 — Sentinel C2 auto-learning (ref #826) * fix(sentinel): C2 false-positive — require strong signal (dga/non-browser), gate non_browser_ja on configured browser set (ref #826) * release: toolbox-ng 0.1.31 — C2 false-positive fix (ref #826) --------- Co-authored-by: CyberMind-FR --- packages/secubox-toolbox-ng/debian/changelog | 11 ++++++++ .../internal/sentinel/c2learn.go | 21 +++++++-------- .../internal/sentinel/c2learn_test.go | 26 +++++++++++++++++++ .../internal/sentinel/c2signal.go | 25 ++++++++++++++++-- .../internal/sentinel/c2signal_test.go | 25 ++++++++++++++++++ 5 files changed, 95 insertions(+), 13 deletions(-) diff --git a/packages/secubox-toolbox-ng/debian/changelog b/packages/secubox-toolbox-ng/debian/changelog index 24e59700..8c7e09af 100644 --- a/packages/secubox-toolbox-ng/debian/changelog +++ b/packages/secubox-toolbox-ng/debian/changelog @@ -1,3 +1,14 @@ +secubox-toolbox-ng (0.1.31-1~bookworm1) bookworm; urgency=medium + + * #826 fix: C2 autolearn false-positive — a first-seen browser-driven host + (e.g. an admin dashboard) was learnable on rarity alone, and non_browser_ja + over-fired when the browser-JA4 set was unconfigured. Promotion now requires + a STRONG signal (DGA host or confirmed non-browser fingerprint); "rare" is a + supporting signal only. non_browser_ja is disabled when no browser set is + configured (empty seed) rather than flagging every fingerprinted flow. + + -- Gerald KERMA Mon, 07 Jul 2026 14:00:00 +0200 + secubox-toolbox-ng (0.1.30-1~bookworm1) bookworm; urgency=medium * #826 Sentinel C2/botnet auto-learning — the daemon now promotes sustained, diff --git a/packages/secubox-toolbox-ng/internal/sentinel/c2learn.go b/packages/secubox-toolbox-ng/internal/sentinel/c2learn.go index 341c8821..58b9e959 100644 --- a/packages/secubox-toolbox-ng/internal/sentinel/c2learn.go +++ b/packages/secubox-toolbox-ng/internal/sentinel/c2learn.go @@ -148,8 +148,8 @@ func (l *C2Learner) startBeaconing(m MirrorMsg, beacon *Verdict) { return } fired := l.signals.Fired(m.Meta) - if len(fired) == 0 { - return // no corroboration — periodicity alone never promotes + if !hasStrongSignal(fired) { + return // periodicity + rarity alone never promotes — need dga / non-browser } interval := parseIntervalSec(beacon.Evidence["interval_s"]) @@ -218,15 +218,14 @@ func (l *C2Learner) tickWindow(m MirrorMsg) { l.beaconMu.Unlock() fired := l.signals.Fired(m.Meta) - if len(fired) == 0 { - // No corroboration on THIS contact — do not advance the candidate - // window. C2Cand unions signals across windows permanently (once - // fired, a signal name stays), so recording a window here would let - // a signal that only ever fired transiently (e.g. "rare" during an - // initial low-hit-count burst that later becomes common) silently - // carry a common, browser-fingerprinted host across c2MinWindows on - // periodicity alone — exactly the false positive the signal gate - // exists to prevent. + if !hasStrongSignal(fired) { + // No STRONG corroboration on THIS contact — do not advance the candidate + // window. C2Cand unions signals across windows permanently (once fired, a + // signal name stays), so recording a window on a weak-only ("rare") + // contact would let a transient rarity (an initial low-hit-count burst + // that later becomes common) silently carry a common, browser-driven host + // across c2MinWindows on periodicity+rarity alone — exactly the false + // positive the strong-signal requirement exists to prevent. return } l.recordWindow(host, m.Meta.MacHash, m.TS, interval, fired) diff --git a/packages/secubox-toolbox-ng/internal/sentinel/c2learn_test.go b/packages/secubox-toolbox-ng/internal/sentinel/c2learn_test.go index 7dd7e899..e957e0ba 100644 --- a/packages/secubox-toolbox-ng/internal/sentinel/c2learn_test.go +++ b/packages/secubox-toolbox-ng/internal/sentinel/c2learn_test.go @@ -193,3 +193,29 @@ func TestC2LearnerWindowAdvanceOwnTiming(t *testing.T) { t.Fatalf("expected sustained own-timing window-advance to promote the host; candidates=%v", l.Candidates()) } } + +// A first-seen (rare), browser-fingerprinted, non-DGA host beaconed steadily +// over a long span must NOT be learned — rarity alone is not enough. This is +// the admin-dashboard false positive the strong-signal requirement prevents. +func TestC2LearnerRareOnlyBrowserHostNotLearned(t *testing.T) { + l := c2TestLearner(t) // browser set = {"t13d1516h2_browserfp"} + host := "portal.example.com" // common word → no dga + mac := "beefbeefbeef0001" + ja4 := "t13d1516h2_browserfp" // a KNOWN browser → no non_browser_ja + ts := int64(6_000_000) + // 15 contacts (stays < c2RareMaxHits=20, so "rare" fires the whole time), + // 600s apart → spans 8400s (> c2MinSpanSec) with many window ticks. + for i := 0; i < 15; i++ { + l.Analyze(MirrorMsg{Meta: FlowMeta{Host: host, MacHash: mac, JA4: ja4}, TS: ts}) + ts += 600 + } + if len(l.Learned()) != 0 { + t.Errorf("rare-only browser host must not be learned, got %v", l.Learned()) + } + // and it must not even become a promotable candidate carrying only rare + for _, c := range l.Candidates() { + if c.Host == host { + t.Errorf("rare-only browser host should not be a tracked candidate, got %+v", c) + } + } +} diff --git a/packages/secubox-toolbox-ng/internal/sentinel/c2signal.go b/packages/secubox-toolbox-ng/internal/sentinel/c2signal.go index 98725485..51a7e020 100644 --- a/packages/secubox-toolbox-ng/internal/sentinel/c2signal.go +++ b/packages/secubox-toolbox-ng/internal/sentinel/c2signal.go @@ -95,12 +95,17 @@ func (s *C2Signals) Fired(m FlowMeta) []string { if s.hits(m.Host) <= c2RareMaxHits { out = append(out, "rare") } - // non-browser: only when a fingerprint is PRESENT and not a known browser. + // non-browser: only when a fingerprint is PRESENT, the known-browser set is + // CONFIGURED (non-empty), and the fingerprint is not in it. An empty browser + // set means we cannot tell browser from non-browser traffic — firing here + // would flag every fingerprinted flow (incl. real browser dashboards) as + // non-browser, the exact false positive this signal must avoid. So an + // unconfigured browser set disables the signal rather than over-firing it. fp := m.JA4 if fp == "" { fp = m.JA3 } - if fp != "" && !s.browser[fp] { + if len(s.browser) > 0 && fp != "" && !s.browser[fp] { out = append(out, "non_browser_ja") } if isDGA(m.Host) { @@ -109,6 +114,22 @@ func (s *C2Signals) Fired(m FlowMeta) []string { return out } +// hasStrongSignal reports whether fired contains a signal strong enough to +// qualify a beacon for learning on its own. "rare" (first-seen / low global +// frequency) is a SUPPORTING signal only — a brand-new legitimate service also +// looks rare — so it never qualifies alone; a strong signal (DGA-looking host +// or a confirmed non-browser client fingerprint) must also be present. This is +// the high-precision guard that keeps a first-seen browser dashboard from being +// learned on rarity alone. +func hasStrongSignal(fired []string) bool { + for _, s := range fired { + if s == "dga" || s == "non_browser_ja" { + return true + } + } + return false +} + // isDGA reports whether host's most-significant label looks algorithmically // generated (long + high own-entropy). Fail-safe on empty/short input. func isDGA(host string) bool { diff --git a/packages/secubox-toolbox-ng/internal/sentinel/c2signal_test.go b/packages/secubox-toolbox-ng/internal/sentinel/c2signal_test.go index 2c83a092..749e6f10 100644 --- a/packages/secubox-toolbox-ng/internal/sentinel/c2signal_test.go +++ b/packages/secubox-toolbox-ng/internal/sentinel/c2signal_test.go @@ -53,3 +53,28 @@ func contains(ss []string, v string) bool { } return false } + +func TestC2SignalsEmptyBrowserSetDisablesNonBrowser(t *testing.T) { + // With NO configured browser set, a present fingerprint must NOT fire + // non_browser_ja (we cannot classify browser vs non-browser). + s := NewC2Signals(nil) + fired := s.Fired(FlowMeta{Host: "news.example.com", JA4: "anythingfp"}) + if contains(fired, "non_browser_ja") { + t.Errorf("empty browser set must disable non_browser_ja, got %v", fired) + } +} + +func TestHasStrongSignal(t *testing.T) { + if hasStrongSignal([]string{"rare"}) { + t.Error("rare alone must not be a strong signal") + } + if !hasStrongSignal([]string{"rare", "dga"}) { + t.Error("dga must be strong") + } + if !hasStrongSignal([]string{"non_browser_ja"}) { + t.Error("non_browser_ja must be strong") + } + if hasStrongSignal(nil) { + t.Error("empty must not be strong") + } +}