mirror of
https://github.com/CyberMind-FR/secubox-deb.git
synced 2026-07-28 21:17:36 +00:00
docs(plan): sbxwaf mode detect — 3 tâches TDD
Some checks are pending
License Headers / check (push) Waiting to run
Some checks are pending
License Headers / check (push) Waiting to run
Task 1: parser `mode` par catégorie (défaut block, fail-closed sur valeur inconnue). Task 2: Match remonte le mode; le chemin detect journalise action=detect et laisse passer, sans jamais bannir. Task 3: déploiement gk2 + preuve sur trafic réel (bascule cve_2024 en detect à chaud, puis restauration). Un seul site d'appel de Match (main.go:376) — rayon de souffle contenu. Chaque tâche exige une preuve par mutation: casser le comportement, constater l'échec du test, restaurer. Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
This commit is contained in:
parent
572d6eece9
commit
a1b9add575
607
docs/superpowers/plans/2026-07-17-sbxwaf-mode-detect.md
Normal file
607
docs/superpowers/plans/2026-07-17-sbxwaf-mode-detect.md
Normal file
|
|
@ -0,0 +1,607 @@
|
|||
# sbxwaf — mode `detect` — Plan d'implémentation
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Permettre à une catégorie de règles WAF de **matcher sans bloquer** — comptée et journalisée, requête laissée passer, aucun bannissement.
|
||||
|
||||
**Architecture:** Un champ `mode` par catégorie dans `waf-rules.json`, parsé en `compiledCategory`, remonté par `Rules.Match`, et honoré à l'unique site d'appel dans `main.go`. `mode` absent ⇒ `block` : les 17 catégories existantes ne changent pas d'un iota.
|
||||
|
||||
**Tech Stack:** Go (paquet `cmd/sbxwaf` de `secubox-toolbox-ng`), tests `go test`. Aucune dépendance nouvelle.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-07-17-sbxwaf-mode-detect-design.md`
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- **`mode` absent ⇒ `block`.** Décision structurante : un défaut `detect` désarmerait silencieusement tout le WAF — une panne de sécurité muette. Même raisonnement que `Enabled *bool` (pointeur, `nil` = absent = `true`) déjà en place à `rules.go:144`.
|
||||
- **Valeur inconnue (`"monitor"`, `"xyz"`) ⇒ `block` + log d'erreur bruyant.** Fail-**closed**. Ne jamais désactiver ni passer en `detect` sur un mode mal orthographié : un typo ne doit pas retirer une protection.
|
||||
- **Chaîne vide `""` et `null` ⇒ `block`**, silencieusement (c'est « absent », pas une erreur) — cohérent avec `if sev == "" { sev = "medium" }` à `rules.go:186`.
|
||||
- **`enabled: false` prime sur `mode`** : la catégorie n'est pas évaluée du tout, coût nul.
|
||||
- **Un match en `detect` ne déclenche AUCUN effet de bord** : pas de ban, pas d'appel CrowdSec, pas de décision nft, pas de compteur de bannissement. Invariant central : une catégorie en `detect` est aussi inoffensive qu'un `enabled: false`, à la journalisation près.
|
||||
- **`ThreatRecord.Action`** vaut aujourd'hui `"warning"` ou `"banned"` (`threatlog.go:46`). `detect` ajoute la valeur **`"detect"`** — sans quoi les statistiques mélangeraient « bloqué » et « aurait bloqué », et le compteur de menaces (198k) deviendrait un mensonge.
|
||||
- **Go, stdlib uniquement.** Pas de nouvelle dépendance.
|
||||
- **Déploiement** : `cp` échoue (« text file busy ») → **`mv`** ; **jamais** `kill -HUP` → `systemctl restart secubox-waf-ng`. sbxwaf est en frontal de TOUS les vhosts publics.
|
||||
- Commits terminés par `Co-Authored-By: Gerald KERMA <devel@cybermind.fr>`. Aucune référence à Claude.
|
||||
|
||||
---
|
||||
|
||||
## Structure des fichiers
|
||||
|
||||
```
|
||||
packages/secubox-toolbox-ng/cmd/sbxwaf/
|
||||
├── rules.go # MODIFIER : categoryJSON.Mode, compiledCategory.mode, Match rend le mode
|
||||
├── rules_test.go # MODIFIER : défaut block, valeur inconnue, detect
|
||||
├── main.go # MODIFIER : l'unique site d'appel (ligne ~376) — chemin detect
|
||||
├── main_test.go # MODIFIER : detect laisse passer + ne bannit pas
|
||||
└── threatlog.go # MODIFIER : commentaire de Action (la valeur "detect")
|
||||
```
|
||||
|
||||
Un seul site d'appel de `Match` (`main.go:376`) — le rayon de souffle est contenu.
|
||||
|
||||
---
|
||||
|
||||
### Task 1 : Parser `mode` (défaut `block`, fail-closed)
|
||||
|
||||
**Files:**
|
||||
- Modify: `packages/secubox-toolbox-ng/cmd/sbxwaf/rules.go`
|
||||
- Test: `packages/secubox-toolbox-ng/cmd/sbxwaf/rules_test.go`
|
||||
|
||||
**Interfaces:**
|
||||
- Consomme : rien.
|
||||
- Produit : `compiledCategory.mode string` (toujours `"block"` ou `"detect"`, jamais vide) ; les constantes `modeBlock = "block"` et `modeDetect = "detect"`.
|
||||
|
||||
- [ ] **Step 1 : écrire les tests qui échouent**
|
||||
|
||||
Ajouter à `cmd/sbxwaf/rules_test.go` :
|
||||
|
||||
```go
|
||||
// writeRules writes a waf-rules.json with the given categories body and returns its path.
|
||||
func writeRulesFile(t *testing.T, categories string) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "waf-rules.json")
|
||||
body := `{"_meta":{"version":"test"},"categories":` + categories + `}`
|
||||
if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
|
||||
t.Fatalf("write rules: %v", err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// catMode returns the compiled mode for category id, or "" if absent.
|
||||
func catMode(r *Rules, id string) string {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
for _, c := range r.current.cats {
|
||||
if c.id == id {
|
||||
return c.data.mode
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// A category with no "mode" MUST block. This is the most important test in the
|
||||
// file: a detect default would silently disarm all 17 existing categories.
|
||||
func TestModeAbsentDefaultsToBlock(t *testing.T) {
|
||||
p := writeRulesFile(t, `{"sqli":{"name":"SQLi","severity":"critical",
|
||||
"patterns":[{"id":"sqli-001","pattern":"union select","desc":"x"}]}}`)
|
||||
r := LoadRules(p)
|
||||
if got := catMode(r, "sqli"); got != modeBlock {
|
||||
t.Fatalf("absent mode: got %q, want %q", got, modeBlock)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModeBlockExplicit(t *testing.T) {
|
||||
p := writeRulesFile(t, `{"sqli":{"name":"SQLi","mode":"block",
|
||||
"patterns":[{"id":"sqli-001","pattern":"union select","desc":"x"}]}}`)
|
||||
if got := catMode(LoadRules(p), "sqli"); got != modeBlock {
|
||||
t.Fatalf("got %q, want %q", got, modeBlock)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModeDetectIsParsed(t *testing.T) {
|
||||
p := writeRulesFile(t, `{"cve_2024":{"name":"CVE","mode":"detect",
|
||||
"patterns":[{"id":"cve-1","pattern":"/mgmt/tm/util/bash","desc":"x"}]}}`)
|
||||
if got := catMode(LoadRules(p), "cve_2024"); got != modeDetect {
|
||||
t.Fatalf("got %q, want %q", got, modeDetect)
|
||||
}
|
||||
}
|
||||
|
||||
// A typo must NOT disarm the category and must NOT become detect: fail closed.
|
||||
func TestModeUnknownFailsClosedToBlock(t *testing.T) {
|
||||
for _, bad := range []string{"monitor", "dryrun", "BLOCK ", "xyz"} {
|
||||
p := writeRulesFile(t, `{"sqli":{"name":"SQLi","mode":"`+bad+`",
|
||||
"patterns":[{"id":"sqli-001","pattern":"union select","desc":"x"}]}}`)
|
||||
r := LoadRules(p)
|
||||
if got := catMode(r, "sqli"); got != modeBlock {
|
||||
t.Fatalf("mode %q: got %q, want %q (must fail closed)", bad, got, modeBlock)
|
||||
}
|
||||
// And the category must still be evaluated — a typo must not remove protection.
|
||||
if _, _, hit := r.Match("GET", "/x", "q=union+select", "", ""); !hit {
|
||||
t.Fatalf("mode %q: category was dropped; a typo must not disable a rule", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// "" and null are "absent", not errors.
|
||||
func TestModeEmptyAndNullDefaultToBlock(t *testing.T) {
|
||||
for _, body := range []string{`"mode":"",`, `"mode":null,`} {
|
||||
p := writeRulesFile(t, `{"sqli":{"name":"SQLi",`+body+`
|
||||
"patterns":[{"id":"sqli-001","pattern":"union select","desc":"x"}]}}`)
|
||||
if got := catMode(LoadRules(p), "sqli"); got != modeBlock {
|
||||
t.Fatalf("%s got %q, want %q", body, got, modeBlock)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// enabled:false wins over mode — the category is not evaluated at all.
|
||||
func TestEnabledFalseWinsOverMode(t *testing.T) {
|
||||
p := writeRulesFile(t, `{"sqli":{"name":"SQLi","enabled":false,"mode":"detect",
|
||||
"patterns":[{"id":"sqli-001","pattern":"union select","desc":"x"}]}}`)
|
||||
r := LoadRules(p)
|
||||
if got := catMode(r, "sqli"); got != "" {
|
||||
t.Fatalf("disabled category should not be loaded at all, got mode %q", got)
|
||||
}
|
||||
if _, _, hit := r.Match("GET", "/x", "q=union+select", "", ""); hit {
|
||||
t.Fatal("disabled category must not match")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Vérifier que `rules_test.go` importe `os`, `path/filepath` et `testing` ; les ajouter si absents.
|
||||
|
||||
- [ ] **Step 2 : lancer — doit échouer**
|
||||
|
||||
```bash
|
||||
cd /home/reepost/CyberMindStudio/secubox-deb/secubox-deb/packages/secubox-toolbox-ng
|
||||
go test ./cmd/sbxwaf/ -run 'TestMode|TestEnabledFalseWins' 2>&1 | tail -5
|
||||
```
|
||||
Attendu : échec de compilation — `undefined: modeBlock`, `undefined: modeDetect`, et `c.data.mode` inconnu.
|
||||
|
||||
- [ ] **Step 3 : implémenter**
|
||||
|
||||
Dans `cmd/sbxwaf/rules.go`, après le bloc d'imports, ajouter les constantes :
|
||||
|
||||
```go
|
||||
// Rule evaluation modes for a category.
|
||||
//
|
||||
// modeBlock — a match blocks the request (the historical, only behaviour).
|
||||
// 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.
|
||||
//
|
||||
// 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"
|
||||
)
|
||||
```
|
||||
|
||||
Dans `type compiledCategory struct` (~ligne 76), ajouter le champ :
|
||||
|
||||
```go
|
||||
type compiledCategory struct {
|
||||
name string
|
||||
severity string
|
||||
mode string // modeBlock | modeDetect — never empty after loadFile
|
||||
patterns []compiledPattern
|
||||
}
|
||||
```
|
||||
|
||||
Dans `categoryJSON` (~ligne 143), ajouter le champ — **pointeur**, comme `Enabled`, pour distinguer absent de vide :
|
||||
|
||||
```go
|
||||
type categoryJSON struct {
|
||||
Name string `json:"name"`
|
||||
Severity string `json:"severity"`
|
||||
Enabled *bool `json:"enabled"` // pointer: nil means absent (default true)
|
||||
Mode *string `json:"mode"` // pointer: nil means absent (default block)
|
||||
Patterns []patternJSON `json:"patterns"`
|
||||
}
|
||||
```
|
||||
|
||||
Juste après le bloc `// Default severity.` (~ligne 185), ajouter la résolution du mode :
|
||||
|
||||
```go
|
||||
// Resolve the evaluation mode. Absent, null or "" ⇒ block: the 17
|
||||
// shipped categories carry no "mode" and must keep blocking.
|
||||
//
|
||||
// An UNKNOWN value also ⇒ block, loudly. Fail closed: a typo like
|
||||
// "monitor" must never silently drop a protection nor downgrade it to
|
||||
// observation. This is the same instinct as Enabled's pointer default.
|
||||
mode := modeBlock
|
||||
if cat.Mode != nil && *cat.Mode != "" {
|
||||
switch *cat.Mode {
|
||||
case modeBlock, modeDetect:
|
||||
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)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Et renseigner le champ dans le littéral `compiledCategory` (~ligne 189) :
|
||||
|
||||
```go
|
||||
cc := compiledCategory{
|
||||
name: cat.Name,
|
||||
severity: sev,
|
||||
mode: mode,
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4 : lancer — doit passer**
|
||||
|
||||
```bash
|
||||
go test ./cmd/sbxwaf/ -run 'TestMode|TestEnabledFalseWins' -v 2>&1 | tail -12
|
||||
```
|
||||
Attendu : `ok` — 6 tests PASS.
|
||||
|
||||
- [ ] **Step 5 : non-régression complète du paquet**
|
||||
|
||||
```bash
|
||||
go test ./cmd/sbxwaf/ 2>&1 | tail -3
|
||||
```
|
||||
Attendu : `ok` — aucune régression (notamment `TestRulesDefaultEnabledTrue`).
|
||||
|
||||
- [ ] **Step 6 : prouver que les tests peuvent échouer**
|
||||
|
||||
Remplacer temporairement `mode := modeBlock` par `mode := modeDetect`, relancer :
|
||||
```bash
|
||||
go test ./cmd/sbxwaf/ -run TestModeAbsentDefaultsToBlock 2>&1 | tail -3
|
||||
```
|
||||
Attendu : **FAIL**. Restaurer, reconfirmer PASS. Consigner les deux observations dans le rapport. Un test qui ne peut pas échouer n'est pas un test.
|
||||
|
||||
- [ ] **Step 7 : commit**
|
||||
|
||||
```bash
|
||||
git add packages/secubox-toolbox-ng/cmd/sbxwaf/rules.go packages/secubox-toolbox-ng/cmd/sbxwaf/rules_test.go
|
||||
git commit -m "feat(sbxwaf): parse a per-category mode (default block, fail closed)
|
||||
|
||||
Absent, empty or null mode means block: the 17 shipped categories carry no
|
||||
mode and must keep blocking. An unknown value also means block, loudly — a
|
||||
typo like 'monitor' must never silently drop a protection nor downgrade it to
|
||||
observation.
|
||||
|
||||
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2 : Honorer `detect` — matcher, journaliser, laisser passer, ne rien bannir
|
||||
|
||||
**Files:**
|
||||
- Modify: `packages/secubox-toolbox-ng/cmd/sbxwaf/rules.go` (signature de `Match`)
|
||||
- Modify: `packages/secubox-toolbox-ng/cmd/sbxwaf/main.go` (~ligne 376, unique site d'appel)
|
||||
- Modify: `packages/secubox-toolbox-ng/cmd/sbxwaf/threatlog.go` (commentaire de `Action`)
|
||||
- Test: `packages/secubox-toolbox-ng/cmd/sbxwaf/main_test.go`
|
||||
|
||||
**Interfaces:**
|
||||
- Consomme : `modeBlock`, `modeDetect`, `compiledCategory.mode` (Task 1).
|
||||
- Produit : `Rules.Match(method, rawPath, rawQuery, body, ua string) (cat, sev, mode string, hit bool)` — **la signature change** : `mode` est inséré avant `hit`. Unique appelant : `main.go`.
|
||||
|
||||
- [ ] **Step 1 : écrire les tests qui échouent**
|
||||
|
||||
Ajouter à `cmd/sbxwaf/main_test.go` :
|
||||
|
||||
```go
|
||||
// A detect category matches but the request MUST pass through, and NOTHING may
|
||||
// be banned. A detect category must be as harmless as enabled:false, minus the
|
||||
// log line.
|
||||
func TestDetectModeLetsRequestThroughAndDoesNotBan(t *testing.T) {
|
||||
rulesPath := writeRulesFile(t, `{"cve_2024":{"name":"CVE","severity":"critical","mode":"detect",
|
||||
"patterns":[{"id":"cve-1","pattern":"/mgmt/tm/util/bash","desc":"F5 RCE"}]}}`)
|
||||
|
||||
upstreamHit := false
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
upstreamHit = true
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("upstream reached"))
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
s := newTestServer(t, upstream.URL, rulesPath)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/mgmt/tm/util/bash", nil)
|
||||
s.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code == http.StatusForbidden {
|
||||
t.Fatalf("detect mode returned 403; it must let the request through")
|
||||
}
|
||||
if !upstreamHit {
|
||||
t.Fatal("detect mode did not reach the upstream; the request was swallowed")
|
||||
}
|
||||
if s.ban != nil && s.ban.Count(testClientIP) != 0 {
|
||||
t.Fatalf("detect mode incremented the ban counter (%d); detect must never punish",
|
||||
s.ban.Count(testClientIP))
|
||||
}
|
||||
}
|
||||
|
||||
// The same pattern in block mode must still block — the detect path must not
|
||||
// leak into the default behaviour.
|
||||
func TestBlockModeStillBlocks(t *testing.T) {
|
||||
rulesPath := writeRulesFile(t, `{"cve_2024":{"name":"CVE","severity":"critical","mode":"block",
|
||||
"patterns":[{"id":"cve-1","pattern":"/mgmt/tm/util/bash","desc":"F5 RCE"}]}}`)
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatal("upstream must never be reached in block mode")
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
s := newTestServer(t, upstream.URL, rulesPath)
|
||||
rec := httptest.NewRecorder()
|
||||
s.ServeHTTP(rec, httptest.NewRequest("GET", "/mgmt/tm/util/bash", nil))
|
||||
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("block mode: got %d, want 403", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// A category with no mode must block — non-regression for the 17 shipped ones.
|
||||
func TestAbsentModeStillBlocks(t *testing.T) {
|
||||
rulesPath := writeRulesFile(t, `{"cve_2024":{"name":"CVE","severity":"critical",
|
||||
"patterns":[{"id":"cve-1","pattern":"/mgmt/tm/util/bash","desc":"F5 RCE"}]}}`)
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatal("upstream must never be reached when mode is absent (defaults to block)")
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
s := newTestServer(t, upstream.URL, rulesPath)
|
||||
rec := httptest.NewRecorder()
|
||||
s.ServeHTTP(rec, httptest.NewRequest("GET", "/mgmt/tm/util/bash", nil))
|
||||
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("absent mode: got %d, want 403", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
dir := t.TempDir()
|
||||
logPath := filepath.Join(dir, "threats.jsonl")
|
||||
rulesPath := writeRulesFile(t, `{"cve_2024":{"name":"CVE","severity":"critical","mode":"detect",
|
||||
"patterns":[{"id":"cve-1","pattern":"/mgmt/tm/util/bash","desc":"F5 RCE"}]}}`)
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
s := newTestServer(t, upstream.URL, rulesPath)
|
||||
s.threatLog = NewThreatLog(logPath)
|
||||
|
||||
s.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "/mgmt/tm/util/bash", nil))
|
||||
|
||||
raw, err := os.ReadFile(logPath)
|
||||
if err != nil {
|
||||
t.Fatalf("threat log not written: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(raw), `"detect"`) {
|
||||
t.Fatalf("threat record must carry action=detect, got: %s", raw)
|
||||
}
|
||||
if strings.Contains(string(raw), `"banned"`) || strings.Contains(string(raw), `"warning"`) {
|
||||
t.Fatalf("detect must not be logged as warning/banned, got: %s", raw)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Note à l'implémenteur** : `newTestServer(t, upstreamURL, rulesPath)` et `testClientIP` peuvent ne pas exister sous ce nom dans `main_test.go`. **Lire le fichier d'abord** et réutiliser le harnais existant (nom et signature réels) plutôt que d'en inventer un ; adapter ces tests au harnais, sans changer ce qu'ils affirment. Réutiliser `writeRulesFile` de Task 1 (même paquet).
|
||||
|
||||
- [ ] **Step 2 : lancer — doit échouer**
|
||||
|
||||
```bash
|
||||
go test ./cmd/sbxwaf/ -run 'TestDetectMode|TestBlockModeStill|TestAbsentModeStill' 2>&1 | tail -5
|
||||
```
|
||||
Attendu : échec de compilation (`Match` rend 3 valeurs, pas 4) ou FAIL (detect renvoie 403).
|
||||
|
||||
- [ ] **Step 3 : implémenter — `Match` remonte le mode**
|
||||
|
||||
Dans `cmd/sbxwaf/rules.go`, changer la signature (~ligne 287) et les retours :
|
||||
|
||||
```go
|
||||
// Match reports whether any enabled category matches the request, and in which
|
||||
// mode. `mode` is modeBlock or modeDetect and is only meaningful when hit is
|
||||
// true; the caller decides what to do with it.
|
||||
func (r *Rules) Match(method, rawPath, rawQuery, body, ua string) (cat, sev, mode string, hit bool) {
|
||||
```
|
||||
|
||||
Adapter les `return` de la fonction :
|
||||
- l'early return `if cur == nil` : `return "", "", "", false`
|
||||
- le return du match : ajouter `c.data.mode` en 3ᵉ position
|
||||
- le return final (aucun match) : `return "", "", "", false`
|
||||
|
||||
- [ ] **Step 4 : implémenter — le chemin `detect` dans `main.go`**
|
||||
|
||||
À `main.go:376`, remplacer l'appel et la garde :
|
||||
|
||||
```go
|
||||
cat, sev, mode, hit := s.rules.Match(
|
||||
r.Method,
|
||||
rawPath,
|
||||
r.URL.RawQuery,
|
||||
string(bodyBytes),
|
||||
r.Header.Get("User-Agent"),
|
||||
)
|
||||
if hit && mode == modeDetect {
|
||||
// Observe only: count it, log it, let it through. A detect
|
||||
// category must be as harmless as enabled:false, minus the
|
||||
// log line — so NO ban, NO CrowdSec report, NO nft decision.
|
||||
if s.threatLog != nil {
|
||||
s.threatLog.Append(ThreatRecord{
|
||||
ClientIP: clientIP,
|
||||
Host: r.Host,
|
||||
Method: r.Method,
|
||||
Path: rawPath,
|
||||
Category: cat,
|
||||
Severity: sev,
|
||||
RuleID: "",
|
||||
Action: "detect",
|
||||
UA: r.Header.Get("User-Agent"),
|
||||
})
|
||||
}
|
||||
hit = false // fall through to the normal proxy path
|
||||
}
|
||||
if hit {
|
||||
```
|
||||
|
||||
**Note à l'implémenteur** : `clientIP` et le champ `RuleID` doivent reprendre **exactement** ce que fait le chemin bloquant existant juste en dessous (lire les lignes qui suivent `if hit {`). Ne pas inventer la façon dont l'IP client est calculée ni comment `RuleID` est renseigné — copier le motif en place.
|
||||
|
||||
- [ ] **Step 5 : documenter la valeur dans `threatlog.go`**
|
||||
|
||||
À `threatlog.go:46`, corriger le commentaire :
|
||||
|
||||
```go
|
||||
Action string // "detect" | "warning" | "banned"
|
||||
```
|
||||
|
||||
- [ ] **Step 6 : lancer — doit passer**
|
||||
|
||||
```bash
|
||||
go test ./cmd/sbxwaf/ 2>&1 | tail -3
|
||||
```
|
||||
Attendu : `ok` — tout le paquet, sans régression.
|
||||
|
||||
- [ ] **Step 7 : prouver que les tests peuvent échouer**
|
||||
|
||||
Supprimer temporairement la ligne `hit = false` du chemin detect, relancer :
|
||||
```bash
|
||||
go test ./cmd/sbxwaf/ -run TestDetectModeLetsRequestThroughAndDoesNotBan 2>&1 | tail -3
|
||||
```
|
||||
Attendu : **FAIL** (403 au lieu du passage). Restaurer, reconfirmer PASS. Consigner les deux observations.
|
||||
|
||||
- [ ] **Step 8 : commit**
|
||||
|
||||
```bash
|
||||
git add packages/secubox-toolbox-ng/cmd/sbxwaf/
|
||||
git commit -m "feat(sbxwaf): honour detect mode — match, log, pass, never ban
|
||||
|
||||
Match now reports the category mode; main.go's single call site treats a detect
|
||||
hit as observation: the threat record is written with action=detect and the
|
||||
request continues to the upstream. No ban, no CrowdSec report, no nft decision —
|
||||
a detect category is as harmless as enabled:false, minus the log line.
|
||||
|
||||
action=detect keeps stats honest: without it, 'blocked' and 'would have blocked'
|
||||
would be indistinguishable.
|
||||
|
||||
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3 : Déployer sur gk2 et prouver sur du trafic réel
|
||||
|
||||
**Files:** aucun (déploiement).
|
||||
|
||||
**Interfaces:** consomme le binaire construit depuis Tasks 1-2.
|
||||
|
||||
⚠️ **sbxwaf est en frontal de TOUS les vhosts publics de la box** (Nextcloud, PeerTube, Gitea, billets, admin…). Une régression coupe tout. La box tourne 118 services à load ~5-11 sur 4 cœurs.
|
||||
|
||||
- [ ] **Step 1 : construire pour arm64**
|
||||
|
||||
```bash
|
||||
cd /home/reepost/CyberMindStudio/secubox-deb/secubox-deb/packages/secubox-toolbox-ng
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -trimpath -ldflags=-s -o /tmp/sbxwaf ./cmd/sbxwaf
|
||||
file /tmp/sbxwaf
|
||||
```
|
||||
Attendu : `ELF 64-bit LSB executable, ARM aarch64`.
|
||||
|
||||
- [ ] **Step 2 : relever l'état AVANT**
|
||||
|
||||
```bash
|
||||
ssh root@192.168.1.200 'echo "waf: $(systemctl is-active secubox-waf-ng)"; \
|
||||
cp /usr/sbin/sbxwaf /tmp/sbxwaf.bak.pre-detect && echo "binaire sauvegardé"; \
|
||||
curl -sk -o /dev/null -w "admin https -> %{http_code}\n" https://admin.gk2.secubox.in/ --resolve admin.gk2.secubox.in:443:127.0.0.1'
|
||||
```
|
||||
Attendu : `active`, sauvegarde faite, `200`.
|
||||
|
||||
- [ ] **Step 3 : déployer**
|
||||
|
||||
```bash
|
||||
scp /tmp/sbxwaf root@192.168.1.200:/tmp/sbxwaf.new
|
||||
ssh root@192.168.1.200 'mv /tmp/sbxwaf.new /usr/sbin/sbxwaf && chmod 755 /usr/sbin/sbxwaf && \
|
||||
systemctl restart secubox-waf-ng && sleep 3 && systemctl is-active secubox-waf-ng'
|
||||
```
|
||||
`mv`, **jamais** `cp` (« text file busy »). `systemctl restart`, **jamais** `kill -HUP`.
|
||||
Attendu : `active`.
|
||||
|
||||
- [ ] **Step 4 : prouver la non-régression sur du trafic réel**
|
||||
|
||||
```bash
|
||||
ssh root@192.168.1.200 'for h in admin.gk2.secubox.in billets.gk2.secubox.in; do
|
||||
curl -sk -o /dev/null -w " $h -> %{http_code}\n" https://$h/ --resolve $h:443:127.0.0.1
|
||||
done
|
||||
echo "--- une attaque connue est-elle toujours bloquée ? ---"
|
||||
curl -sk -o /dev/null -w " sqli -> %{http_code} (attendu 403)\n" \
|
||||
"https://admin.gk2.secubox.in/?q=union+select+1" --resolve admin.gk2.secubox.in:443:127.0.0.1
|
||||
echo "--- patterns chargés ---"
|
||||
journalctl -u secubox-waf-ng -n 5 --no-pager | grep -i "loaded.*patterns"'
|
||||
```
|
||||
Attendu : les vhosts en `200`, le SQLi en **403** (les catégories sans `mode` bloquent toujours), et le log confirme 149 patterns / 17 catégories.
|
||||
|
||||
- [ ] **Step 5 : prouver le mode `detect` en vrai**
|
||||
|
||||
Basculer `cve_2024` en `detect` — la catégorie dont les 6 patterns visent PAN-OS/Ivanti/F5, absents de cette box :
|
||||
|
||||
```bash
|
||||
ssh root@192.168.1.200 'cp /etc/secubox/waf/waf-rules.json /etc/secubox/waf/waf-rules.json.bak.pre-detect
|
||||
python3 - <<PY
|
||||
import json
|
||||
p = "/etc/secubox/waf/waf-rules.json"
|
||||
d = json.load(open(p))
|
||||
d["categories"]["cve_2024"]["mode"] = "detect"
|
||||
json.dump(d, open(p, "w"), indent=2, ensure_ascii=False)
|
||||
print("cve_2024 -> mode detect")
|
||||
PY
|
||||
sleep 3 # rechargement à chaud (watcher)
|
||||
echo "--- un pattern cve_2024 doit maintenant PASSER (et être journalisé) ---"
|
||||
curl -sk -o /dev/null -w " /mgmt/tm/util/bash -> %{http_code} (attendu != 403)\n" \
|
||||
"https://admin.gk2.secubox.in/mgmt/tm/util/bash" --resolve admin.gk2.secubox.in:443:127.0.0.1
|
||||
echo "--- le journal porte-t-il action=detect ? ---"
|
||||
tail -3 /var/log/secubox/waf-threats.jsonl 2>/dev/null | grep -o "\"action\":\"[a-z]*\"" | tail -2
|
||||
echo "--- le SQLi (sans mode) bloque-t-il toujours ? ---"
|
||||
curl -sk -o /dev/null -w " sqli -> %{http_code} (attendu 403)\n" \
|
||||
"https://admin.gk2.secubox.in/?q=union+select+1" --resolve admin.gk2.secubox.in:443:127.0.0.1'
|
||||
```
|
||||
Attendu : le pattern `cve_2024` **passe** (≠ 403) et apparaît avec `"action":"detect"` ; le SQLi **bloque toujours** (403).
|
||||
|
||||
⚠️ Le chemin exact du journal de menaces est le `--threat-log` de l'unit : le lire avec
|
||||
`systemctl show secubox-waf-ng -p ExecStart` plutôt que supposer `/var/log/secubox/waf-threats.jsonl`.
|
||||
|
||||
- [ ] **Step 6 : rendre l'état initial**
|
||||
|
||||
```bash
|
||||
ssh root@192.168.1.200 'mv /etc/secubox/waf/waf-rules.json.bak.pre-detect /etc/secubox/waf/waf-rules.json && sleep 3
|
||||
curl -sk -o /dev/null -w " cve_2024 rearmé -> %{http_code} (attendu 403)\n" \
|
||||
"https://admin.gk2.secubox.in/mgmt/tm/util/bash" --resolve admin.gk2.secubox.in:443:127.0.0.1'
|
||||
```
|
||||
Attendu : `403` — la démonstration ne laisse aucun résidu. Ne PAS laisser `cve_2024` en `detect` : c'est une décision d'exploitation, elle appartient à l'utilisateur.
|
||||
|
||||
- [ ] **Step 7 : consigner**
|
||||
|
||||
Reporter dans le rapport : les codes AVANT/APRÈS de chaque vhost, la preuve `detect` (passage + `action=detect`), la preuve de non-régression (SQLi 403), et le retour à l'état initial.
|
||||
|
||||
---
|
||||
|
||||
## Auto-revue du plan
|
||||
|
||||
**Couverture du spec** :
|
||||
|
||||
| Exigence du spec | Tâche |
|
||||
|---|---|
|
||||
| Champ `mode` par catégorie | 1 |
|
||||
| `mode` absent ⇒ `block` (non-régression des 17) | 1 (Step 1 : `TestModeAbsentDefaultsToBlock`), 2 (`TestAbsentModeStillBlocks`) |
|
||||
| `""` / `null` ⇒ `block` silencieux | 1 |
|
||||
| Valeur inconnue ⇒ `block` + log bruyant (fail-closed) | 1 |
|
||||
| `enabled:false` prime | 1 |
|
||||
| Match `detect` : compté, journalisé, **laisse passer** | 2 |
|
||||
| `detect` ne bannit **jamais** | 2 (assertion sur le compteur de ban) |
|
||||
| `ThreatRecord.Action = "detect"` | 2 (+ commentaire `threatlog.go`) |
|
||||
| Rechargement à chaud d'un changement de `mode` | 3 (Step 5 : bascule à chaud, `sleep 3`, sans redémarrage) |
|
||||
| Tests capables d'échouer (mutation) | 1 (Step 6), 2 (Step 7) |
|
||||
| Déploiement `mv` + `systemctl restart` | 3 |
|
||||
|
||||
Hors périmètre, donc absent (et c'est voulu) : `mode` par pattern, promotion automatique `detect`→`block`, nettoyage des 149 patterns, génération de règles depuis les CVE.
|
||||
|
||||
**Placeholders** : aucun — chaque étape porte son code et sa commande exacte. Deux « notes à l'implémenteur » demandent explicitement de **lire le code existant** (harnais de `main_test.go`, calcul de `clientIP`/`RuleID`) plutôt que d'inventer : c'est une instruction, pas un trou.
|
||||
|
||||
**Cohérence des types** : `modeBlock`/`modeDetect` (Task 1) sont consommés par Task 2 ; `compiledCategory.mode` (Task 1) est lu par `Match` (Task 2) ; la nouvelle signature `Match(...) (cat, sev, mode string, hit bool)` est déclarée en Task 2 et n'a qu'un seul appelant (`main.go:376`), modifié dans la même tâche.
|
||||
Loading…
Reference in New Issue
Block a user