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

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
This commit is contained in:
CyberMind-FR 2026-07-17 18:36:19 +02:00
commit fd8fe87a4c
4 changed files with 225 additions and 5 deletions

View File

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

View File

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

View File

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

View File

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