Task 1: reconnaître modeEscalate (fail-closed préservé). Task 2: champ escalateBan (compteur SÉPARÉ, défauts 24h/3), branche self-contained qui observe (action=detect, passe) sous le seuil et banne au seuil via crowdsec+writeBan — gatée sur escalateBan, JAMAIS s.ban, sinon les signaux se mélangent. nil → comme detect. Task 3: déploiement + preuve live (observation; le ban au seuil est prouvé déterministement par les tests unitaires). Un seul site d'appel de Match. Mutation obligatoire par tâche. Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
24 KiB
sbxwaf — mode escalate — 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: Ajouter un mode de catégorie escalate : observer et laisser passer les premières sondes d'une IP (comme detect), puis la bannir réellement (cscli → nft) une fois qu'elle a sondé N fois dans une fenêtre longue.
Architecture: escalate compose les deux comportements déjà livrés (detect = passe+journalise, block = 403+cscli), gatés sur un compteur Ban séparé (fenêtre longue, seuil propre) distinct de celui du mode block. Aucune logique de ban ni de report nouvelle.
Tech Stack: Go (cmd/sbxwaf de secubox-toolbox-ng), go test. Aucune dépendance nouvelle.
Spec: docs/superpowers/specs/2026-07-17-sbxwaf-mode-escalate-design.md
Global Constraints
escalaten'utilise JAMAISs.ban(le compteur du modeblock). Il a sa propre instances.escalateBan. Mélanger les deux ferait « 1 SQLi bloqué + 2 sondes = ban » — deux signaux confondus, ce que le spec interdit.- La branche
escalateest self-contained : elle ne « tombe » PAS dans le blocif hit { … s.ban.Record … }existant, qui incrémenterait le compteurblock. Son action de ban (403 +crowdsec.Report+writeBan+ threatLogbanned) est faite dans la branche, surescalateBan. modeabsent /""/null⇒block; valeur inconnue ⇒block+ log bruyant (fail-closed). Les 17 catégories livrées ne changent pas.escalateBan == nil⇒ la catégorieescalatese comporte commedetect(observe, ne banne jamais). Fail-open côté ban : l'absence de compteur ne transforme pas l'observation en blocage.- Journal honnête (PR #872) : les sondes observées portent
Action: "detect"(exclues du compteur « bloqué ») ; le ban porteAction: "banned"(compté). - Défauts :
--escalate-window= 24h,--escalate-threshold= 3. - Déploiement :
cpéchoue (« text file busy ») →mv; jamaiskill -HUP→systemctl restart secubox-waf-ng. sbxwaf est en frontal de TOUS les vhosts publics. - Go, stdlib uniquement. 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 : ajouter modeEscalate au switch de résolution
├── rules_test.go # MODIFIER : parsing "escalate"
├── main.go # MODIFIER : champ escalateBan, flags, branche escalate + helper
└── main_test.go # MODIFIER : observe-avant-seuil, ban-au-seuil, compteur séparé, nil
Un seul site d'appel de Match (main.go:383) — le rayon de souffle est contenu.
Task 1 : Parser le mode escalate
Files:
- Modify:
packages/secubox-toolbox-ng/cmd/sbxwaf/rules.go - Test:
packages/secubox-toolbox-ng/cmd/sbxwaf/rules_test.go
Interfaces:
-
Consomme :
modeBlock,modeDetect(livrés). -
Produit : la constante
modeEscalate = "escalate"; une catégoriemode: "escalate"compile encompiledCategory.mode == modeEscalate. -
Step 1 : écrire les tests qui échouent
Ajouter à cmd/sbxwaf/rules_test.go (réutilise writeRulesFile et catMode, déjà présents) :
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")
}
}
- Step 2 : lancer — doit échouer
cd /home/reepost/CyberMindStudio/secubox-deb/secubox-deb/packages/secubox-toolbox-ng
go test ./cmd/sbxwaf/ -run 'TestModeEscalate' 2>&1 | tail -5
Attendu : échec de compilation — undefined: modeEscalate.
- Step 3 : implémenter
Dans cmd/sbxwaf/rules.go, ajouter modeEscalate à la déclaration des constantes de mode (à côté de modeBlock/modeDetect) :
const (
modeBlock = "block"
modeDetect = "detect"
modeEscalate = "escalate"
)
(Si les constantes sont déclarées individuellement plutôt qu'en bloc, ajouter modeEscalate = "escalate" dans le même style — lire le fichier avant d'éditer.)
Puis, dans le switch de résolution du mode (~ligne 213), ajouter modeEscalate aux valeurs connues et à la liste du message d'erreur :
switch *cat.Mode {
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, %q)",
catID, *cat.Mode, modeBlock, modeBlock, modeDetect, modeEscalate)
}
- Step 4 : lancer — doit passer
go test ./cmd/sbxwaf/ -run 'TestModeEscalate' -v 2>&1 | tail -8
Attendu : ok — 2 tests PASS.
- Step 5 : non-régression + preuve par mutation
go test ./cmd/sbxwaf/ 2>&1 | tail -2
Attendu : ok, aucune régression (TestModeUnknownFailsClosedToBlock etc. toujours verts).
Preuve par mutation : retirer temporairement modeEscalate de la clause case (le laisser tomber dans default), relancer TestModeEscalateIsParsed → FAIL (il devient block). Restaurer, reconfirmer PASS. Consigner les deux observations.
- Step 6 : commit
git add packages/secubox-toolbox-ng/cmd/sbxwaf/rules.go packages/secubox-toolbox-ng/cmd/sbxwaf/rules_test.go
git commit -m "feat(sbxwaf): recognise the escalate mode value
escalate is a first-class known mode alongside block/detect. Absent/unknown
still fail closed to block; escalate itself must not be treated as unknown.
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>"
Task 2 : Le chemin escalate — compteur séparé, observe puis banne
Files:
- Modify:
packages/secubox-toolbox-ng/cmd/sbxwaf/main.go - Test:
packages/secubox-toolbox-ng/cmd/sbxwaf/main_test.go
Interfaces:
-
Consomme :
modeEscalate(Task 1) ;Ban/NewBan(window, threshold)/Ban.Record(ip, now) → (count, banned);CscliReportervias.crowdsec;writeBan(w);ThreatRecord/s.threatLog.Record;Server(main.go:119). -
Produit : le champ
Server.escalateBan *Ban; les flags--escalate-window/--escalate-threshold; la brancheescalatedans le handler. -
Step 1 : écrire les tests qui échouent
Ajouter à cmd/sbxwaf/main_test.go. Réutiliser le harnais existant : newDetectTestServer(t, backendURL, rulesPath), detectTestReq(clientIP), s.handler(), writeRulesFile. Lire ces helpers d'abord — ne pas en inventer.
newDetectTestServer construit un Server avec ban: NewBan(300s, 3) mais sans escalateBan. Ces tests l'injectent après construction : s.escalateBan = NewBan(time.Hour, 3).
// 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)
}
}
- Step 2 : lancer — doit échouer
go test ./cmd/sbxwaf/ -run 'TestEscalate' 2>&1 | tail -5
Attendu : échec de compilation (s.escalateBan inconnu).
- Step 3 : implémenter — champ + flags + construction
Dans type Server struct (~ligne 128, à côté de ban *Ban), ajouter :
// 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
Dans main(), à côté des autres flags (~ligne 592+) :
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")
Dans la construction du Server (~ligne 676, à côté de ban: NewBan(...)), ajouter :
escalateBan: NewBan(*escalateWindow, *escalateThreshold),
- Step 4 : implémenter — la branche
escalatedans le handler
À main.go, juste après le bloc if hit && mode == modeDetect { … } et avant if hit { (le chemin block), insérer :
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 _, 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")
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
}
}
Ajouter le helper (près du handler, ou en méthode sur *Server) — factorise l'écriture du threat record, identique aux deux branches sauf action :
// 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"),
})
}
Note à l'implémenteur : lire les lignes autour de if hit && mode == modeDetect pour reprendre EXACTEMENT le nom de la variable IP (ip), de rawPath, et la façon dont r est disponible dans ce scope. Ne pas inventer.
- Step 5 : lancer — doit passer
go test ./cmd/sbxwaf/ 2>&1 | tail -3
Attendu : ok — tout le paquet, les 4 tests escalate verts, aucune régression.
- Step 6 : race + vet
go test -race ./cmd/sbxwaf/ 2>&1 | tail -2
go vet ./cmd/sbxwaf/ 2>&1 | tail -1 && echo "vet ✓"
Attendu : ok (le champ escalateBan est un *Ban déjà mutex-protégé), vet propre.
- Step 7 : preuve par mutation
Remplacer temporairement, dans la branche escalate, s.escalateBan.Record par s.ban.Record, relancer :
go test ./cmd/sbxwaf/ -run TestEscalateUsesItsOwnCounterNotTheBlockBan 2>&1 | tail -3
Attendu : FAIL (le compteur block avance). Restaurer, reconfirmer PASS. Puis retirer la ligne hit = false de la branche « still observing » → TestEscalateObservesThenBansAtThreshold FAIL (403 dès la 1re sonde). Restaurer. Consigner les deux observations.
- Step 8 : commit
git add packages/secubox-toolbox-ng/cmd/sbxwaf/main.go packages/secubox-toolbox-ng/cmd/sbxwaf/main_test.go
git commit -m "feat(sbxwaf): escalate path — observe on a separate counter, then ban
A separate escalateBan (default 24h/3, --escalate-window/--escalate-threshold)
counts hits on escalate-mode categories. Below the threshold the request passes
and logs action=detect (like detect); at the threshold it bans exactly as the
block path does (crowdsec.Report + writeBan) — but gated on escalateBan, never
s.ban, so the slow-scanner signal never mixes with the block ban. A nil
escalateBan degrades to detect (observe, never ban), never to block.
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).
⚠️ sbxwaf est en frontal de TOUS les vhosts publics. Une régression coupe tout. Juger par les codes HTTP (valides sous charge), avec retry — la box peut être à load élevé (transcodage). admin.gk2 contourne le WAF (webui_direct + trustedHosts) : tester via billets.gk2 + un X-Forwarded-For public pour traverser réellement le WAF.
- Step 1 : construire pour arm64
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 | grep -o "ARM aarch64"
Attendu : ARM aarch64.
- Step 2 : AVANT — santé + sauvegarde
ssh root@192.168.1.200 'systemctl is-active secubox-waf-ng
cp /usr/sbin/sbxwaf /tmp/sbxwaf.bak.pre-escalate && echo "binaire sauvegardé"
curl -sk -o /dev/null -w "billets -> %{http_code}\n" --max-time 15 https://billets.gk2.secubox.in/ --resolve billets.gk2.secubox.in:443:127.0.0.1'
Attendu : active, sauvegarde faite, 200.
- Step 3 : le nouveau binaire doit passer
--escalate-window/--escalate-threshold— vérifier l'unit
ssh root@192.168.1.200 'systemctl show secubox-waf-ng -p ExecStart --value | tr " " "\n" | grep -E "escalate" || echo " (flags escalate ABSENTS de l unit — défauts 24h/3 utilisés)"'
Les défauts (24h/3) s'appliquent si les flags ne sont pas dans l'unit — acceptable pour cette preuve. Ne pas modifier l'unit dans ce plan (c'est une décision d'exploitation).
- Step 4 : déployer
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'
Attendu : active.
- Step 5 : non-régression — le block banne toujours
ssh root@192.168.1.200 'for i in 1 2 3; do
curl -sk -o /dev/null -w " sqli essai $i -> %{http_code}\n" --max-time 20 -H "X-Forwarded-For: 203.0.113.201" "https://billets.gk2.secubox.in/?q=union+select+1" --resolve billets.gk2.secubox.in:443:127.0.0.1
done'
Attendu : 403 (au moins sur retry). Les catégories sans mode bloquent toujours.
- Step 6 : preuve
escalatelive (seuil bas, catégorie de test, à chaud)
Créer une catégorie escalate de test avec un seuil de 2 et un pattern inoffensif, la charger à chaud (hot-reload livré), envoyer 3 sondes depuis une même IP forgée, vérifier passe→ban, puis restaurer. Le seuil du binaire est 24h/3 par défaut ; pour prouver rapidement sans reconfigurer l'unit, on ne peut pas baisser le seuil binaire à chaud — donc cette preuve démontre l'observation (les sondes passent et sont journalisées detect) et laisse la preuve du ban au seuil aux tests unitaires (Task 2), qui l'établissent déterministement.
ssh root@192.168.1.200 'bash -s' <<'REMOTE'
set -e
cp /etc/secubox/waf/waf-rules.json /etc/secubox/waf/waf-rules.json.bak.esc
FF="--max-time 20 --resolve billets.gk2.secubox.in:443:127.0.0.1"
python3 - <<'PY'
import json
p="/etc/secubox/waf/waf-rules.json"
d=json.load(open(p))
d["categories"]["esc_test"]={"name":"Escalate test","severity":"high","mode":"escalate",
"patterns":[{"id":"esc-1","pattern":"/__escalate_probe__","desc":"test probe"}]}
json.dump(d, open(p,"w"), indent=2, ensure_ascii=False)
print(" esc_test ajouté en mode escalate (hot-reload)")
PY
sleep 2
echo "=== 3 sondes: doivent PASSER (observation, seuil binaire 3 non atteint en une passe) ==="
for i in 1 2 3; do
curl -sk -o /dev/null -w " sonde $i -> %{http_code} (attendu != 403, observé)\n" $FF -H "X-Forwarded-For: 203.0.113.210" "https://billets.gk2.secubox.in/__escalate_probe__"
done
echo "=== le threat log porte-t-il action=detect pour ces sondes ? ==="
TL=$(systemctl show secubox-waf-ng -p ExecStart --value | tr " " "\n" | grep -A1 "threat-log" | tail -1)
tail -8 "${TL:-/var/log/secubox/waf/waf-threats.log}" 2>/dev/null | grep "__escalate_probe__" | grep -o '"action":"[a-z]*"' | tail -3
echo "=== restauration ==="
mv /etc/secubox/waf/waf-rules.json.bak.esc /etc/secubox/waf/waf-rules.json
sleep 2
curl -sk -o /dev/null -w " esc_test retiré -> catégorie disparue (404 attendu)\n" $FF -H "X-Forwarded-For: 203.0.113.211" "https://billets.gk2.secubox.in/__escalate_probe__"
REMOTE
Attendu : les 3 sondes passent (≠403), le threat log porte "action":"detect", la catégorie disparaît après restauration. Le ban au seuil est prouvé par les tests unitaires (déterministe, sans dépendre du seuil binaire de prod).
- Step 7 : consigner
Reporter : codes AVANT/APRÈS des vhosts, non-régression SQLi (403), observation escalate (sondes passent + action=detect), restauration propre.
Auto-revue du plan
Couverture du spec :
| Exigence du spec | Tâche |
|---|---|
mode: escalate reconnu, fail-closed préservé |
1 |
Compteur SÉPARÉ (2e instance Ban) |
2 (Step 3 champ + Step 7 mutation s.ban vs escalateBan) |
| Fenêtre longue + seuil configurables (défauts 24h/3) | 2 (flags) |
Observe avant seuil (passe, action=detect) |
2 (TestEscalateObservesThenBansAtThreshold, …LogsDetect…) |
Ban au seuil (403 + crowdsec.Report + action=banned) |
2 |
Ne PAS tomber dans le chemin block (s.ban) |
2 (branche self-contained + mutation Step 7) |
escalateBan == nil ⇒ comme detect |
2 (TestEscalateWithNilCounterBehavesLikeDetect) |
| Journal honnête (detect exclu, banned compté) | 2 (helper logEscalate + PR #872) |
Déploiement mv + systemctl restart + preuve live |
3 |
| Tests capables d'échouer (mutation) | 1 (Step 5), 2 (Step 7) |
Hors périmètre, donc absent (voulu) : génération de règles (spec B), corrélation ASN/JA4, promotion auto detect→escalate, câblage des flags dans l'unit systemd (décision d'exploitation).
Placeholders : aucun — chaque étape porte son code et sa commande. Deux « notes à l'implémenteur » demandent de lire le code existant (harnais main_test.go, variables ip/rawPath/r du scope) plutôt que d'inventer — instruction, pas trou.
Cohérence des types : modeEscalate (Task 1) consommé par Task 2 ; Server.escalateBan *Ban (Task 2) est un *Ban existant, Ban.Record/Ban.Count déjà livrés ; la signature de Match (cat, sev, mode, hit) est celle en place depuis PR #872, non modifiée ici ; le helper logEscalate est défini et appelé dans la même tâche.