1 Commits
Author SHA1 Message Date
sneak 230db01127 servewaf: each decision heuristic is its own module (closes #195)
check / check (push) Successful in 3m17s
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 after_answer_heuristic
and after_answer_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
2026-11-06 20:16:47 +00:00
9 changed files with 36 additions and 35 deletions

No files matched your search

+1 -1
View File
@@ -125,7 +125,7 @@ produces a final answer ends the checks. Step 15 runs for each request
limits apply to the whole exchange. Each step names the section that holds its limits apply to the whole exchange. Each step names the section that holds its
rules. Each check is a heuristic. Its name, in parentheses below, is the value rules. Each check is a heuristic. Its name, in parentheses below, is the value
of the request log's `heuristic` field. `refusal_limit` and `byte_limits` give of the request log's `heuristic` field. `refusal_limit` and `byte_limits` give
theirs in `report_heuristic` instead. theirs in `after_answer_heuristic` instead.
1. `GET /_smallwebwaf/healthz` is answered at once by `rproxy`, before any 1. `GET /_smallwebwaf/healthz` is answered at once by `rproxy`, before any
check, so a health checker never needs an exemption and is never refused. check, so a health checker never needs an exemption and is never refused.
+5 -4
View File
@@ -127,10 +127,11 @@ Every one of these lines has:
- `core_rule_set`: `score`, and `threshold`, `SWWAF_WAF_ANOMALY_THRESHOLD`; - `core_rule_set`: `score`, and `threshold`, `SWWAF_WAF_ANOMALY_THRESHOLD`;
- `refusal_limit`: `window`, `count` and `limit`, of the error burst; - `refusal_limit`: `window`, `count` and `limit`, of the error burst;
- `byte_limits`: `window`, `count` and `limit`, the count in bytes. - `byte_limits`: `window`, `count` and `limit`, the count in bytes.
- `report_heuristic`: the heuristic that banned the client after `servewaf` - `after_answer_heuristic`: the heuristic that banned the client after
answered the request, in `observe` mode too: `refusal_limit` or `byte_limits`. `servewaf` answered the request, in `observe` mode too: `refusal_limit` or
- `report_heuristic_figures`: an object of the figures that heuristic acted on, `byte_limits`.
as listed above. - `after_answer_heuristic_figures`: an object of the figures that heuristic
acted on, as listed above.
`byte_limits` bans when the report comes, so only the `report` line names `byte_limits` bans when the report comes, so only the `report` line names
it. it.
+5 -4
View File
@@ -136,10 +136,11 @@ type Line struct {
// name. // name.
Heuristic string `json:"heuristic,omitempty"` Heuristic string `json:"heuristic,omitempty"`
HeuristicFigures map[string]any `json:"heuristic_figures,omitempty"` HeuristicFigures map[string]any `json:"heuristic_figures,omitempty"`
// ReportHeuristic and ReportHeuristicFigures are the same for the // AfterAnswerHeuristic and AfterAnswerHeuristicFigures are the same for
// heuristic that banned the client after servewaf answered the request. // the heuristic that banned the client after servewaf answered the
ReportHeuristic string `json:"report_heuristic,omitempty"` // request.
ReportHeuristicFigures map[string]any `json:"report_heuristic_figures,omitempty"` AfterAnswerHeuristic string `json:"after_answer_heuristic,omitempty"`
AfterAnswerHeuristicFigures map[string]any `json:"after_answer_heuristic_figures,omitempty"` //nolint:lll // a struct tag cannot be split
// LimitPercent and LimitPercentSetting are, for a request the rate // LimitPercent and LimitPercentSetting are, for a request the rate
// limits counted whose client a biased threshold gives a percentage of // limits counted whose client a biased threshold gives a percentage of
// the rate limits below 100, that percentage and the setting that gave // the rate limits below 100, that percentage and the setting that gave
+2 -2
View File
@@ -104,12 +104,12 @@ func TestByteLimitBanNamesItsHeuristicWindowCountAndLimit(t *testing.T) {
s, _ := startWithAnswers(t, map[string]string{bytesLimitPerMinute: byteLimit}) s, _ := startWithAnswers(t, map[string]string{bytesLimitPerMinute: byteLimit})
line, _ := s.download() line, _ := s.download()
wantReportHeuristic(t, line, "", "null") wantAfterAnswerHeuristic(t, line, "", "null")
// Two answers of 70 bytes are over the limit of 99. // Two answers of 70 bytes are over the limit of 99.
line, _ = s.download() line, _ = s.download()
wantHeuristic(t, line, "", "null") wantHeuristic(t, line, "", "null")
wantReportHeuristic(t, line, "byte_limits", wantAfterAnswerHeuristic(t, line, "byte_limits",
`{"window": "minute", "count": 140, "limit": 99}`) `{"window": "minute", "count": 140, "limit": 99}`)
} }
+5 -7
View File
@@ -25,7 +25,7 @@ func (headerLimits) name() string { return "header_limits" }
func (headerLimits) check(_ context.Context, rq *request, _ time.Time) *verdict { func (headerLimits) check(_ context.Context, rq *request, _ time.Time) *verdict {
fields, cookies := rq.headerCounts() fields, cookies := rq.headerCounts()
if fields <= maxHeaderFields && cookies <= maxCookies { if !overHeaderLimits(fields, cookies) {
return nil return nil
} }
@@ -38,12 +38,10 @@ func (headerLimits) check(_ context.Context, rq *request, _ time.Time) *verdict
} }
} }
// overHeaderLimits reports whether the request is over the header limits: it // overHeaderLimits reports whether a request with these counts of header fields
// has more than maxHeaderFields fields or more than maxCookies cookies, as // and cookies, as headerCounts counts them, is over the header limits: it has
// headerCounts counts them. // more than maxHeaderFields fields or more than maxCookies cookies.
func (rq *request) overHeaderLimits() bool { func overHeaderLimits(fields, cookies int) bool {
fields, cookies := rq.headerCounts()
return fields > maxHeaderFields || cookies > maxCookies return fields > maxHeaderFields || cookies > maxCookies
} }
+6 -6
View File
@@ -104,15 +104,15 @@ func (rq *request) runHeuristics(
} }
// runBanningHeuristics runs list, afterAnswer or onReport, on the request at // runBanningHeuristics runs list, afterAnswer or onReport, on the request at
// the time it is called. The log line's report_heuristic and // the time it is called. The log line's after_answer_heuristic and
// report_heuristic_figures name the heuristic that gave a verdict, and give its // after_answer_heuristic_figures name the heuristic that gave a verdict, and
// figures, so that heuristic and heuristic_figures still name the one that // give its figures, so that heuristic and heuristic_figures still name the one
// refused the request. ctx is the request's own context. // that refused the request. ctx is the request's own context.
func (rq *request) runBanningHeuristics(ctx context.Context, list []heuristic) { func (rq *request) runBanningHeuristics(ctx context.Context, list []heuristic) {
name, v := rq.firstVerdict(ctx, list, rq.h.now()) name, v := rq.firstVerdict(ctx, list, rq.h.now())
if v != nil { if v != nil {
rq.line.ReportHeuristic = name rq.line.AfterAnswerHeuristic = name
rq.line.ReportHeuristicFigures = v.figures rq.line.AfterAnswerHeuristicFigures = v.figures
} }
} }
+2 -2
View File
@@ -115,13 +115,13 @@ func TestRefusalLimitBanNamesItsHeuristicWindowCountAndLimit(t *testing.T) {
line := blockRule.send(s, client) line := blockRule.send(s, client)
wantHeuristic(t, line, "rule_files", ruleFigures) wantHeuristic(t, line, "rule_files", ruleFigures)
wantReportHeuristic(t, line, "", "null") wantAfterAnswerHeuristic(t, line, "", "null")
// The second refusal is the request refusal_limit bans for, and the line // The second refusal is the request refusal_limit bans for, and the line
// names both. // names both.
line = blockRule.send(s, client) line = blockRule.send(s, client)
wantHeuristic(t, line, "rule_files", ruleFigures) wantHeuristic(t, line, "rule_files", ruleFigures)
wantReportHeuristic(t, line, "refusal_limit", wantAfterAnswerHeuristic(t, line, "refusal_limit",
`{"window": "minute", "count": 2, "limit": 1}`) `{"window": "minute", "count": 2, "limit": 1}`)
} }
+9 -8
View File
@@ -286,16 +286,17 @@ func wantHeuristic(t *testing.T, line logLine, heuristic, figures string) {
} }
} }
// wantReportHeuristic checks report_heuristic and report_heuristic_figures as // wantAfterAnswerHeuristic checks after_answer_heuristic and
// wantHeuristic checks heuristic and heuristic_figures. // after_answer_heuristic_figures as wantHeuristic checks heuristic and
func wantReportHeuristic(t *testing.T, line logLine, heuristic, figures string) { // heuristic_figures.
func wantAfterAnswerHeuristic(t *testing.T, line logLine, heuristic, figures string) {
t.Helper() t.Helper()
if line.ReportHeuristic != heuristic || if line.AfterAnswerHeuristic != heuristic ||
!reflect.DeepEqual(line.ReportHeuristicFigures, decodeFigures(t, figures)) { !reflect.DeepEqual(line.AfterAnswerHeuristicFigures, decodeFigures(t, figures)) {
t.Errorf("log line has report_heuristic %q with report_heuristic_figures %v, "+ t.Errorf("log line has after_answer_heuristic %q with "+
"want %q with %s", "after_answer_heuristic_figures %v, want %q with %s",
line.ReportHeuristic, line.ReportHeuristicFigures, heuristic, figures) line.AfterAnswerHeuristic, line.AfterAnswerHeuristicFigures, heuristic, figures)
} }
} }
+1 -1
View File
@@ -152,7 +152,7 @@ func (rq *request) refusedWhateverTheAnswer(now time.Time) bool {
_, decided := rq.crowdSecDecisionInForce(now) _, decided := rq.crowdSecDecisionInForce(now)
return decided || rq.overHeaderLimits() || return decided || overHeaderLimits(rq.headerCounts()) ||
rq.refusedByBlocklists(rq.h.lists.ListedBy(rq.client)) || rq.refusedByBlocklists(rq.h.lists.ListedBy(rq.client)) ||
rq.overAWholeRateLimit(now) rq.overAWholeRateLimit(now)
} }