mirror of
https://github.com/CyberMind-FR/secubox-deb.git
synced 2026-07-28 21:17:36 +00:00
Merge feat/sbxwaf-mode-escalate: escalate mode — observe then ban a persistent scanner (#873)
Some checks are pending
License Headers / check (push) Waiting to run
Some checks are pending
License Headers / check (push) Waiting to run
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
This commit is contained in:
commit
fd8fe87a4c
|
|
@ -128,6 +128,12 @@ type Server struct {
|
|||
// Nil means no ban tracking (legacy: plain 403 on WAF hit).
|
||||
ban *Ban
|
||||
|
||||
// escalateBan counts hits on `escalate`-mode categories in a SEPARATE
|
||||
// sliding window (long, e.g. 24h) from `ban`. escalate observes (passes,
|
||||
// logs "detect") until this counter says banned, then bans via crowdsec.
|
||||
// Nil means escalate behaves like detect (observe, never ban).
|
||||
escalateBan *Ban
|
||||
|
||||
// threatLog appends one JSON line per WAF hit to the threats log file.
|
||||
// Wired in main() via NewThreatLog(--threat-log); tests can inject.
|
||||
// Nil means no threat logging.
|
||||
|
|
@ -407,6 +413,36 @@ func (s *Server) handler() http.Handler {
|
|||
}
|
||||
hit = false // fall through to the normal proxy path
|
||||
}
|
||||
if hit && mode == modeEscalate {
|
||||
// Observe first, ban a persistent scanner. Uses a SEPARATE
|
||||
// counter (escalateBan) so probes for absent products never
|
||||
// mix with the block-mode ban signal. Self-contained: it does
|
||||
// NOT fall through to the block path below, which would double
|
||||
// count into s.ban.
|
||||
if s.escalateBan == nil {
|
||||
// No counter → observe like detect, never ban.
|
||||
s.logEscalate(r, ip, rawPath, cat, sev, "detect")
|
||||
hit = false
|
||||
} else if count, banned := s.escalateBan.Record(ip, time.Now().Unix()); banned {
|
||||
// Threshold crossed: ban for real, exactly as the block
|
||||
// path's ban branch does — but gated on escalateBan.
|
||||
s.logEscalate(r, ip, rawPath, cat, sev, "banned")
|
||||
// Mirror the block path's journald line so an operator
|
||||
// tailing `journalctl -u secubox-waf-ng` for THREAT sees
|
||||
// escalate bans too — otherwise a banned scanner is
|
||||
// visible in the dashboard JSON but silent in the logs.
|
||||
log.Printf("sbxwaf: THREAT [%s] %s (escalate %d): %s", sev, ip, count, cat)
|
||||
if s.crowdsec != nil {
|
||||
go s.crowdsec.Report(ip, cat, sev)
|
||||
}
|
||||
writeBan(w)
|
||||
return
|
||||
} else {
|
||||
// Still observing: log and let it through.
|
||||
s.logEscalate(r, ip, rawPath, cat, sev, "detect")
|
||||
hit = false
|
||||
}
|
||||
}
|
||||
if hit {
|
||||
// Task 3.2 — graduated WARNING/BAN response.
|
||||
//
|
||||
|
|
@ -539,6 +575,25 @@ func (s *Server) handler() http.Handler {
|
|||
})
|
||||
}
|
||||
|
||||
// logEscalate writes one threat record for an escalate-mode hit. `action` is
|
||||
// "detect" while observing and "banned" once the threshold is crossed.
|
||||
func (s *Server) logEscalate(r *http.Request, ip, rawPath, cat, sev, action string) {
|
||||
if s.threatLog == nil {
|
||||
return
|
||||
}
|
||||
s.threatLog.Record(ThreatRecord{
|
||||
ClientIP: ip,
|
||||
Host: r.Host,
|
||||
Method: r.Method,
|
||||
Path: rawPath,
|
||||
Category: cat,
|
||||
Severity: sev,
|
||||
RuleID: "",
|
||||
Action: action,
|
||||
UA: r.Header.Get("User-Agent"),
|
||||
})
|
||||
}
|
||||
|
||||
// parseTrustedHosts parses a comma-separated list of hostnames into a set.
|
||||
// Empty entries are silently skipped.
|
||||
func parseTrustedHosts(csv string) map[string]struct{} {
|
||||
|
|
@ -634,6 +689,13 @@ func main() {
|
|||
wafSkipHosts := flag.String("waf-skip-hosts",
|
||||
"git.gk2.secubox.in,git.secubox.in,admin.gk2.secubox.in,10.100.0.1:9080",
|
||||
"comma-separated hostnames to bypass WAF inspection entirely (mirrors Python trusted-host list)")
|
||||
// escalate mode: separate long-window counter (a slow scanner probing over
|
||||
// hours/days must still trip a ban even though each individual probe is
|
||||
// only observed, not blocked).
|
||||
escalateWindow := flag.Duration("escalate-window", 24*time.Hour,
|
||||
"sliding window for escalate-mode categories (a slow scanner needs a long window)")
|
||||
escalateThreshold := flag.Int("escalate-threshold", 3,
|
||||
"probes within the escalate window before an IP is banned")
|
||||
flag.Parse()
|
||||
|
||||
// rules is consumed below when --rules is provided.
|
||||
|
|
@ -679,6 +741,8 @@ func main() {
|
|||
// Task 3.2: graduated ban (window=300s, threshold=3, matches Python
|
||||
// BAN_WINDOW=300 / BAN_THRESHOLD=3 from secubox_waf.py lines 82-83).
|
||||
ban: NewBan(300*time.Second, 3),
|
||||
// escalate mode: separate long-window counter (default 24h/3).
|
||||
escalateBan: NewBan(*escalateWindow, *escalateThreshold),
|
||||
// Task 3.2: append-only threat log.
|
||||
threatLog: NewThreatLog(*threatLog),
|
||||
// crowdsec: wired below when --crowdsec-url and --crowdsec-jwt-file are set.
|
||||
|
|
|
|||
|
|
@ -298,6 +298,134 @@ func TestAbsentModeStillBlocks(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// ─── escalate mode (Task 2) ─────────────────────────────────────────────────
|
||||
|
||||
// escalate observes the first N-1 probes: they PASS (like detect) and do NOT
|
||||
// ban. Only the Nth probe from the same IP bans.
|
||||
func TestEscalateObservesThenBansAtThreshold(t *testing.T) {
|
||||
rulesPath := writeRulesFile(t, `{"probes":{"name":"P","severity":"high","mode":"escalate",
|
||||
"patterns":[{"id":"f5-1","pattern":"/mgmt/tm/util/bash","desc":"F5 probe"}]}}`)
|
||||
|
||||
backendHits := 0
|
||||
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
backendHits++
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer backend.Close()
|
||||
|
||||
s := newDetectTestServer(t, backend.URL, rulesPath)
|
||||
s.escalateBan = NewBan(time.Hour, 3) // threshold 3
|
||||
handler := s.handler()
|
||||
|
||||
// Probes 1 and 2: observed, pass through, backend reached.
|
||||
for i := 1; i <= 2; i++ {
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, detectTestReq("203.0.113.60"))
|
||||
if rec.Code == http.StatusForbidden {
|
||||
t.Fatalf("probe %d: got 403, escalate must observe (pass) before the threshold", i)
|
||||
}
|
||||
}
|
||||
if backendHits != 2 {
|
||||
t.Fatalf("expected 2 observed probes to reach the backend, got %d", backendHits)
|
||||
}
|
||||
if s.escalateBan.Count("203.0.113.60") < 2 {
|
||||
t.Fatal("escalate did not count the observed probes")
|
||||
}
|
||||
|
||||
// Probe 3: threshold reached → ban (403).
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, detectTestReq("203.0.113.60"))
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("probe 3 (threshold): got %d, want 403 (ban)", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// escalate must NOT touch the block counter (s.ban): the two signals are
|
||||
// separate. Probing an escalate category must not advance s.ban.
|
||||
func TestEscalateUsesItsOwnCounterNotTheBlockBan(t *testing.T) {
|
||||
rulesPath := writeRulesFile(t, `{"probes":{"name":"P","severity":"high","mode":"escalate",
|
||||
"patterns":[{"id":"f5-1","pattern":"/mgmt/tm/util/bash","desc":"F5 probe"}]}}`)
|
||||
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer backend.Close()
|
||||
|
||||
s := newDetectTestServer(t, backend.URL, rulesPath)
|
||||
s.escalateBan = NewBan(time.Hour, 3)
|
||||
handler := s.handler()
|
||||
|
||||
handler.ServeHTTP(httptest.NewRecorder(), detectTestReq("203.0.113.61"))
|
||||
handler.ServeHTTP(httptest.NewRecorder(), detectTestReq("203.0.113.61"))
|
||||
|
||||
if s.ban != nil && s.ban.Count("203.0.113.61") != 0 {
|
||||
t.Fatalf("escalate advanced the block counter s.ban (%d); the counters must be separate",
|
||||
s.ban.Count("203.0.113.61"))
|
||||
}
|
||||
if s.escalateBan.Count("203.0.113.61") != 2 {
|
||||
t.Fatalf("escalate counter = %d, want 2", s.escalateBan.Count("203.0.113.61"))
|
||||
}
|
||||
}
|
||||
|
||||
// With no escalate counter, an escalate category behaves like detect: it
|
||||
// observes and never bans — never like block.
|
||||
func TestEscalateWithNilCounterBehavesLikeDetect(t *testing.T) {
|
||||
rulesPath := writeRulesFile(t, `{"probes":{"name":"P","severity":"high","mode":"escalate",
|
||||
"patterns":[{"id":"f5-1","pattern":"/mgmt/tm/util/bash","desc":"F5 probe"}]}}`)
|
||||
reached := false
|
||||
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
reached = true
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer backend.Close()
|
||||
|
||||
s := newDetectTestServer(t, backend.URL, rulesPath)
|
||||
s.escalateBan = nil // no counter
|
||||
handler := s.handler()
|
||||
|
||||
for i := 0; i < 5; i++ {
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, detectTestReq("203.0.113.62"))
|
||||
if rec.Code == http.StatusForbidden {
|
||||
t.Fatalf("nil escalate counter must observe (pass), never ban; got 403 on probe %d", i+1)
|
||||
}
|
||||
}
|
||||
if !reached {
|
||||
t.Fatal("nil escalate counter: request was swallowed")
|
||||
}
|
||||
}
|
||||
|
||||
// The ban at threshold logs action="banned" (counted as a block); the observed
|
||||
// probes log action="detect" (excluded from the block counters).
|
||||
func TestEscalateLogsDetectWhileObservingAndBannedAtThreshold(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
logPath := filepath.Join(dir, "threats.jsonl")
|
||||
rulesPath := writeRulesFile(t, `{"probes":{"name":"P","severity":"high","mode":"escalate",
|
||||
"patterns":[{"id":"f5-1","pattern":"/mgmt/tm/util/bash","desc":"F5 probe"}]}}`)
|
||||
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer backend.Close()
|
||||
|
||||
s := newDetectTestServer(t, backend.URL, rulesPath)
|
||||
s.escalateBan = NewBan(time.Hour, 2) // threshold 2 for a short test
|
||||
s.threatLog = NewThreatLog(logPath)
|
||||
handler := s.handler()
|
||||
|
||||
handler.ServeHTTP(httptest.NewRecorder(), detectTestReq("203.0.113.63")) // observe → "detect"
|
||||
handler.ServeHTTP(httptest.NewRecorder(), detectTestReq("203.0.113.63")) // threshold → "banned"
|
||||
|
||||
raw, err := os.ReadFile(logPath)
|
||||
if err != nil {
|
||||
t.Fatalf("threat log not written: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(raw), `"detect"`) {
|
||||
t.Fatalf("observed probe must log action=detect, got: %s", raw)
|
||||
}
|
||||
if !strings.Contains(string(raw), `"banned"`) {
|
||||
t.Fatalf("threshold probe must log action=banned, got: %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
// The threat record must say "detect" — otherwise stats conflate "blocked" with
|
||||
// "would have blocked" and the 198k threat counter becomes a lie.
|
||||
func TestDetectModeLogsActionDetect(t *testing.T) {
|
||||
|
|
|
|||
|
|
@ -69,12 +69,17 @@ import (
|
|||
// modeDetect — a match is counted and logged, the request PASSES, and nothing
|
||||
// is banned. Lets an operator try a rule against real traffic
|
||||
// before arming it.
|
||||
// modeEscalate — a match is observed first; after N occurrences from the same
|
||||
// source within a time window, the source is banned. Intermediate
|
||||
// protection: observe persistent scanners, block them, but allow
|
||||
// transient false positives.
|
||||
//
|
||||
// A category with no "mode" is modeBlock. A detect default would silently turn
|
||||
// the whole WAF into an observer — a mute security outage.
|
||||
const (
|
||||
modeBlock = "block"
|
||||
modeDetect = "detect"
|
||||
modeBlock = "block"
|
||||
modeDetect = "detect"
|
||||
modeEscalate = "escalate"
|
||||
)
|
||||
|
||||
// compiledPattern holds a compiled regex and its metadata.
|
||||
|
|
@ -210,11 +215,11 @@ func loadRulesJSON(path string) *rulesData {
|
|||
mode := modeBlock
|
||||
if cat.Mode != nil && *cat.Mode != "" {
|
||||
switch *cat.Mode {
|
||||
case modeBlock, modeDetect:
|
||||
case modeBlock, modeDetect, modeEscalate:
|
||||
mode = *cat.Mode
|
||||
default:
|
||||
log.Printf("sbxwaf/rules: category %q has unknown mode %q — falling back to %q (known modes: %q, %q)",
|
||||
catID, *cat.Mode, modeBlock, modeBlock, modeDetect)
|
||||
log.Printf("sbxwaf/rules: category %q has unknown mode %q — falling back to %q (known modes: %q, %q, %q)",
|
||||
catID, *cat.Mode, modeBlock, modeBlock, modeDetect, modeEscalate)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -414,3 +414,26 @@ func TestEnabledFalseWinsOverMode(t *testing.T) {
|
|||
t.Fatal("disabled category must not match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestModeEscalateIsParsed(t *testing.T) {
|
||||
p := writeRulesFile(t, `{"probes":{"name":"Absent-product probes","severity":"high","mode":"escalate",
|
||||
"patterns":[{"id":"f5-1","pattern":"/mgmt/tm/util/bash","desc":"F5 RCE probe"}]}}`)
|
||||
if got := catMode(LoadRules(p), "probes"); got != modeEscalate {
|
||||
t.Fatalf("got %q, want %q", got, modeEscalate)
|
||||
}
|
||||
}
|
||||
|
||||
// escalate must be a first-class known mode — an unknown value still fails
|
||||
// closed to block, and "escalate" must NOT be treated as unknown.
|
||||
func TestModeEscalateIsNotTreatedAsUnknown(t *testing.T) {
|
||||
p := writeRulesFile(t, `{"probes":{"name":"P","mode":"escalate",
|
||||
"patterns":[{"id":"f5-1","pattern":"/mgmt/tm/util/bash","desc":"x"}]}}`)
|
||||
r := LoadRules(p)
|
||||
if got := catMode(r, "probes"); got == modeBlock {
|
||||
t.Fatal("escalate fell through to block — it must be recognised, not treated as an unknown value")
|
||||
}
|
||||
// The category is still evaluated (a probe hits).
|
||||
if _, _, _, hit := r.Match("GET", "/mgmt/tm/util/bash", "", "", ""); !hit {
|
||||
t.Fatal("escalate category was dropped")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user