fix(sentinel): C2 auto-learn false positive — require a strong signal (ref #826) (#828)
Some checks are pending
License Headers / check (push) Waiting to run

* 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 <gandalf@Gk2.net>
This commit is contained in:
CyberMind 2026-07-07 08:13:48 +02:00 committed by GitHub
parent 0008c89e58
commit f19d566793
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 95 additions and 13 deletions

View File

@ -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 <devel@cybermind.fr> 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,

View File

@ -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)

View File

@ -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)
}
}
}

View File

@ -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 {

View File

@ -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")
}
}