mirror of
https://github.com/CyberMind-FR/secubox-deb.git
synced 2026-07-28 21:17:36 +00:00
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
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:
parent
0008c89e58
commit
f19d566793
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user