diff --git a/packages/secubox-toolbox-ng/cmd/sbxwaf/main.go b/packages/secubox-toolbox-ng/cmd/sbxwaf/main.go index 86af2c74..009ec9bc 100644 --- a/packages/secubox-toolbox-ng/cmd/sbxwaf/main.go +++ b/packages/secubox-toolbox-ng/cmd/sbxwaf/main.go @@ -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. diff --git a/packages/secubox-toolbox-ng/cmd/sbxwaf/main_test.go b/packages/secubox-toolbox-ng/cmd/sbxwaf/main_test.go index 9dec9fc8..6c8a4a80 100644 --- a/packages/secubox-toolbox-ng/cmd/sbxwaf/main_test.go +++ b/packages/secubox-toolbox-ng/cmd/sbxwaf/main_test.go @@ -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) { diff --git a/packages/secubox-toolbox-ng/cmd/sbxwaf/rules.go b/packages/secubox-toolbox-ng/cmd/sbxwaf/rules.go index 2f78e943..9ca5da88 100644 --- a/packages/secubox-toolbox-ng/cmd/sbxwaf/rules.go +++ b/packages/secubox-toolbox-ng/cmd/sbxwaf/rules.go @@ -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) } } diff --git a/packages/secubox-toolbox-ng/cmd/sbxwaf/rules_test.go b/packages/secubox-toolbox-ng/cmd/sbxwaf/rules_test.go index bceac654..5707ab8f 100644 --- a/packages/secubox-toolbox-ng/cmd/sbxwaf/rules_test.go +++ b/packages/secubox-toolbox-ng/cmd/sbxwaf/rules_test.go @@ -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") + } +}