check / check (push) Waiting to run
Each check servewaf runs on a request is a type in its own file behind one interface, heuristic, and is registered in one of four ordered lists in heuristics.go. Order and behaviour are unchanged. Every refusal now logs heuristic, the name of the heuristic that made it, and heuristic_figures, the figures it acted on. A ban by refusal_limit or byte_limits after the answer logs report_heuristic and report_heuristic_figures instead, so a line can name both. All four are in docs/request-log.md, and SPEC.md names each heuristic at its step. Model: opus-5-5
99 lines
2.9 KiB
Go
99 lines
2.9 KiB
Go
package servewaf
|
|
|
|
// The heuristic core_rule_set. It reads SWWAF_WAF_MODE, SWWAF_WAF_EXEMPT_PATHS
|
|
// and SWWAF_WAF_ANOMALY_THRESHOLD.
|
|
|
|
import (
|
|
"context"
|
|
"time"
|
|
|
|
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
|
"sneak.berlin/go/smallwebwaf/internal/config"
|
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
|
"sneak.berlin/go/smallwebwaf/internal/waf"
|
|
)
|
|
|
|
// coreRuleSet inspects the request with the Core Rule Set, unless
|
|
// SWWAF_WAF_MODE is off or SWWAF_WAF_EXEMPT_PATHS exempts its path, as
|
|
// pathExempt decides, and notes the rules it matched and its score in the
|
|
// log line, and the rules in the metrics. A score at or over
|
|
// SWWAF_WAF_ANOMALY_THRESHOLD is a match: it raises the waf_block alert,
|
|
// and in block mode refuses the request with 403, which is an offence its
|
|
// client's history counts. Its figures are the score and the threshold.
|
|
type coreRuleSet struct{}
|
|
|
|
func (coreRuleSet) name() string { return "core_rule_set" }
|
|
|
|
func (coreRuleSet) check(_ context.Context, rq *request, _ time.Time) *verdict {
|
|
cfg := rq.h.config
|
|
if cfg.WAFMode == config.WAFModeOff || pathExempt(rq.in, cfg.WAFExemptPaths) {
|
|
return nil
|
|
}
|
|
|
|
start := time.Now()
|
|
|
|
// A question's body has been read into memory, so that reading it here
|
|
// cannot fail.
|
|
result, read, _ := rq.h.coreRuleSet.Inspect(rq.in, rq.client, rq.in.Body)
|
|
rq.requestBytes = int64(len(read))
|
|
|
|
rq.line.DurationWAF = new(requestlog.Milliseconds(time.Since(start)))
|
|
rq.line.WAFRuleIDs = result.RuleIDs
|
|
rq.line.WAFScore = &result.Score
|
|
|
|
for _, id := range result.RuleIDs {
|
|
rq.h.metrics.WAFMatched(cfg.WAFMode, id)
|
|
}
|
|
|
|
threshold := cfg.WAFAnomalyThreshold
|
|
if threshold == 0 || result.Score < threshold {
|
|
return nil
|
|
}
|
|
|
|
rq.alertWAFBlock(result)
|
|
|
|
if cfg.WAFMode == config.WAFModeDetect {
|
|
return nil
|
|
}
|
|
|
|
rq.wafBlocked = true
|
|
|
|
return &verdict{
|
|
action: requestlog.ActionWAFBlocked,
|
|
figures: map[string]any{figureScore: result.Score, "threshold": threshold},
|
|
}
|
|
}
|
|
|
|
// alertWAFBlock raises the waf_block alert for the request, which the Core
|
|
// Rule Set scored at result, at or over SWWAF_WAF_ANOMALY_THRESHOLD. Its
|
|
// detail gives the rule ids, the score, the method and the path with the
|
|
// query, and, for a request that is not refused for it, the mode: detect,
|
|
// or observe in observe mode.
|
|
func (rq *request) alertWAFBlock(result waf.Result) {
|
|
detail := map[string]any{
|
|
"rule_ids": result.RuleIDs,
|
|
"score": result.Score,
|
|
"method": rq.in.Method,
|
|
"path": rq.in.URL.RequestURI(),
|
|
}
|
|
|
|
switch {
|
|
case rq.h.config.WAFMode == config.WAFModeDetect:
|
|
detail["mode"] = config.WAFModeDetect
|
|
case rq.h.config.Observe:
|
|
detail["mode"] = "observe"
|
|
}
|
|
|
|
rq.h.alerts.Raise(alerts.Alert{
|
|
Event: alerts.EventWAFBlock,
|
|
Host: rq.site,
|
|
Client: rq.client,
|
|
Netblock: rq.h.clientGroup(rq.client),
|
|
ASN: rq.line.ASN,
|
|
ASName: rq.line.ASName,
|
|
Country: rq.line.Country,
|
|
Reason: "scored by the Core Rule Set at or over SWWAF_WAF_ANOMALY_THRESHOLD",
|
|
Detail: detail,
|
|
})
|
|
}
|