feat(waf): StatusRatio/StatusCount 支持 2xx/4xx/5xx 类写法

自动 IP 组表达式可按状态码类汇总占比与计数,兼容原有精确状态码。
This commit is contained in:
ryan
2026-07-20 15:39:14 +08:00
parent 67a30eb5ed
commit 351e8ce78c
4 changed files with 173 additions and 6 deletions
+4
View File
@@ -26,6 +26,10 @@ sidebar: false
- 反代站点「流量限制」支持配置单 IP 请求频率:空或 0 继承全局默认,-1 关闭,填写如 10r/s、100r/m 为站点自定义;不同站点可使用不同频率并按站点隔离计数。
### 改进
- IP 组自动规则中的 `StatusCount` / `StatusRatio` 支持状态码类写法(如 `"2xx"`、`"4xx"`、`"5xx"`),便于按整类错误率匹配。
### 修复
- 修复 IP 组自动抓取使用预设规则时未写入 `ttl` 字段的问题,避免配置 JSON 缺少封禁时长。
+22 -2
View File
@@ -61,8 +61,14 @@ Host 是否为“通过 IP 访问”按请求日志中的 `Host` 字段判断:
如果内置的 `status_404_count` 和 `status_404_ratio` 不能满足您的需求,您可以使用以下内置方法来匹配任意状态码的请求数与占比:
* **`StatusCount(code)`**: 获取当前 IP 在回看窗口内返回指定状态码的请求数(如 `StatusCount(403) > 10`)
* **`StatusRatio(code)`**: 获取当前 IP 在回看窗口内返回指定状态码的请求数占该 IP 总请求数的比例(如 `StatusRatio(502) >= 0.5`)
* **`StatusCount(code)`**: 获取当前 IP 在回看窗口内返回指定状态码(或状态码类)的请求数。
* 精确状态码:`StatusCount(403) > 10`
* 状态码类(`1xx`–`5xx`,大小写不敏感):`StatusCount("4xx") > 50`
* **`StatusRatio(code)`**: 获取上述计数占该 IP 总请求数的比例。
* 精确状态码:`StatusRatio(502) >= 0.5`
* 状态码类:`StatusRatio("4xx") >= 0.8`、`StatusRatio("5xx") >= 0.3`
状态码类会汇总该百位区间内全部状态码,例如 `"4xx"` 包含 400–499,`"2xx"` 包含 200–299。
## Expr 常用写法
@@ -147,6 +153,20 @@ IP 直连访问异常:
}
```
使用状态码类写法(与 `client_error_count` / `server_error_count` 等价思路):
```json
{
"lookback_minutes": 120,
"rules": [
{
"name": "高 4xx 或 5xx 占比",
"expr": "request_count > 100 && (StatusRatio(\"4xx\") >= 0.8 || StatusRatio(\"5xx\") >= 0.3)"
}
]
}
```
排除可信 IP:
```json
+88 -4
View File
@@ -40,18 +40,102 @@ type ipGroupAutoRuleEnv struct {
statusCounts map[int]int
}
func (env ipGroupAutoRuleEnv) StatusCount(code int) int {
func (env ipGroupAutoRuleEnv) StatusCount(code any) int {
if env.statusCounts == nil {
return 0
}
return env.statusCounts[code]
return countStatusMatches(env.statusCounts, code)
}
func (env ipGroupAutoRuleEnv) StatusRatio(code int) float64 {
func (env ipGroupAutoRuleEnv) StatusRatio(code any) float64 {
if env.RequestCount <= 0 || env.statusCounts == nil {
return 0.0
}
return float64(env.statusCounts[code]) / float64(env.RequestCount)
return float64(countStatusMatches(env.statusCounts, code)) / float64(env.RequestCount)
}
// countStatusMatches sums status counts for an exact code or class token.
// Accepted forms:
// - int / int64 / float64: exact status code (e.g. 404)
// - string digits: exact status code (e.g. "404")
// - string class: "1xx".."5xx" (case-insensitive), matching that hundred range
func countStatusMatches(statusCounts map[int]int, code any) int {
if statusCounts == nil {
return 0
}
switch v := code.(type) {
case int:
return statusCounts[v]
case int8:
return statusCounts[int(v)]
case int16:
return statusCounts[int(v)]
case int32:
return statusCounts[int(v)]
case int64:
return statusCounts[int(v)]
case uint:
return statusCounts[int(v)]
case uint8:
return statusCounts[int(v)]
case uint16:
return statusCounts[int(v)]
case uint32:
return statusCounts[int(v)]
case uint64:
return statusCounts[int(v)]
case float32:
if v != float32(int(v)) {
return 0
}
return statusCounts[int(v)]
case float64:
if v != float64(int(v)) {
return 0
}
return statusCounts[int(v)]
case string:
return countStatusMatchesString(statusCounts, v)
default:
return 0
}
}
func countStatusMatchesString(statusCounts map[int]int, raw string) int {
token := strings.TrimSpace(strings.ToLower(raw))
if token == "" {
return 0
}
if len(token) == 3 && token[1] == 'x' && token[2] == 'x' {
classDigit := token[0]
if classDigit < '1' || classDigit > '5' {
return 0
}
base := int(classDigit-'0') * 100
total := 0
for code, count := range statusCounts {
if code >= base && code < base+100 {
total += count
}
}
return total
}
// exact numeric string, e.g. "404"
var code int
for _, ch := range token {
if ch < '0' || ch > '9' {
return 0
}
code = code*10 + int(ch-'0')
if code > 999 {
return 0
}
}
if code < 100 || code > 599 {
// still allow lookup for non-standard codes if present
return statusCounts[code]
}
return statusCounts[code]
}
type ipGroupAutoAccumulator struct {
@@ -209,3 +209,62 @@ func seedWAFAccessLogs(t *testing.T, ctx context.Context, loggedAt time.Time, re
}
require.NoError(t, model.InsertOpenFlareAccessLogsBatch(ctx, records))
}
func TestCountStatusMatchesSupportsClassTokens(t *testing.T) {
counts := map[int]int{
200: 10,
201: 5,
404: 20,
403: 10,
500: 4,
502: 1,
}
cases := []struct {
name string
code any
want int
}{
{name: "exact int", code: 404, want: 20},
{name: "exact string", code: "403", want: 10},
{name: "2xx class", code: "2xx", want: 15},
{name: "4xx class upper", code: "4XX", want: 30},
{name: "5xx class", code: "5xx", want: 5},
{name: "unknown class", code: "9xx", want: 0},
{name: "invalid token", code: "abc", want: 0},
{name: "float exact", code: float64(200), want: 10},
{name: "float non-int", code: 200.5, want: 0},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := countStatusMatches(counts, tc.code)
if got != tc.want {
t.Errorf("countStatusMatches(%v) = %d, want %d", tc.code, got, tc.want)
}
})
}
}
func TestStatusRatioClassTokenInExpr(t *testing.T) {
cleanup := setupIPGroupSyncTestDB(t)
defer cleanup()
ctx := context.Background()
now := time.Now().UTC()
// 100 requests, 80 of which are 404 → 4xx ratio 0.8
seedWAFAccessLogs(t, ctx, now, "203.0.113.40", "app.example.com", 100, 80)
// mostly OK → should not match
seedWAFAccessLogs(t, ctx, now, "203.0.113.41", "app.example.com", 100, 10)
result, err := TestIPGroupAutoConfig(ctx, IPGroupAutoTestInput{
AutoConfig: json.RawMessage(`{
"lookback_minutes": 60,
"rules": [
{"name":"高 4xx 占比","expr":"request_count >= 100 && StatusRatio(\"4xx\") >= 0.8"}
]
}`),
})
require.NoError(t, err)
assert.Equal(t, 1, result.MatchedCount)
require.Len(t, result.MatchedIPs, 1)
assert.Equal(t, "203.0.113.40", result.MatchedIPs[0])
}