security-rust 4.0.0

A pure Rust attack detection library with 32 detectors across 4 categories — injection, protocol, data, and file — plus stateful session, throttling, and risk scoring. Zero framework dependencies.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
<!-- Copyright (c) 2026 erik <erik@erik.xyz> — https://erik.xyz -->

# security-rust API 参考

[中文](../README.md) | [English](./i18n/en/API.md) | [한국어](./i18n/ko/API.md) | [Русский](./i18n/ru/API.md) | [Deutsch](./i18n/de/API.md) | [Français](./i18n/fr/API.md) | [Español](./i18n/es/API.md) | [Português](./i18n/pt/API.md) | [हिन्दी](./i18n/hi/API.md) | [العربية](./i18n/ar/API.md) | [বাংলা](./i18n/bn/API.md) | [Bahasa Indonesia](./i18n/id/API.md) | [日本語](./i18n/ja/API.md)

---

crate 级属性:`#![forbid(unsafe_code)]`(`src/lib.rs`)—— 本库至今零 `unsafe`,且已升级为编译期强制:新增一句 `unsafe` 会直接编译失败。

## 核心 Trait

### `Detector`

所有检测器的唯一契约:

```rust
pub trait Detector: Send + Sync {
    fn name(&self) -> &'static str;
    fn detect(&self, input: &str) -> Option<DetectionResult>;
}
```

- `name()` — 检测器名称(如 `"xss"`、`"sql_injection"`)。契约:非空、snake_case、全局唯一,同一实例恒定,且等于结果里的 `attack_type`(`tests/invariants.rs` 对全部内置实现逐个核验)
- `detect()` — 扫描输入,命中则返回 `Some(DetectionResult)`,未命中返回 `None`,**至多一条**结果;`offset` 是输入的 **UTF-8 字节**偏移(不是字符数),`matched_pattern` 恰好等于 `&input[offset..offset + matched_pattern.len()]`(即命中片段本身)

`Detector` 只覆盖 32 个检测器。`session` / `throttle` **不实现**该 trait:它们是**有状态、身份相关**的,要读写的输入是「token + 指纹 + 位置 + 时间」这类复合值,还要访问存储后端,`detect(&str)` 表达不了。它们各自提供显式 API(见下文),也不在 `Scanner` 的装配范围内。

## 检测结果结构

```rust
pub struct DetectionResult {
    pub attack_type: String,      // "xss", "sql_injection" ...
    pub category: AttackCategory, // Injection | Protocol | Data | File
    pub severity: Severity,       // Critical | High | Medium | Low
    pub matched_pattern: String,  // 匹配到的具体模式片段
    pub offset: usize,            // 输入中的字节偏移
    pub message: String,          // 人类可读说明
}
```

## 两档信号:强信号与弱信号

32 个检测器里有 18 个存在弱信号档(命中一律上报固定 `Severity::Low`)。其中 17 个同时有强档与弱档 —— 15 个用源码里的 `STRONG_PATTERNS` / `WEAK_PATTERNS` 两个静态常量分档,`cors` / `hpp` 的强档是内联的条件判定;剩下 `dns_rebinding` 全档皆弱(见「已知上限」)。`DetectionResult` 的字段结构没变,变的是 `severity` 的取值:

| 档位 | 判据 | `severity` | 单条能否越过拒绝线 |
|------|------|-----------|------------------|
| **强信号** | 形态本身只可能来自攻击 | 检测器声明的等级 | 能 |
| **弱信号** | 该 token「出现」而已,正常内容里遍地都是 | 固定 `Severity::Low`(5 分) | **不能** |

同一个检测器、同一个 `attack_type`,只有 `severity` 不同;`detect()` 先试强档,强档不中再试弱档,因此**每个检测器最多返回一条**结果。弱信号仍然被检出,不会静默漏报。

`DetectionResult` 本身不区分档位 —— 想知道一条命中是强是弱,看 `severity == Severity::Low` 即可(弱档是唯一会上报 `Low` 的来源)。参考流水线的拒绝线是 40 分(`risk.level >= RiskLevel::High`,见 [`examples/waf.rs:166`](../examples/waf.rs)),单条弱信号只有 5 分,进不了这一支。

`Severity` 与 `AttackCategory` 实现了 `Copy`(两个小枚举,取值直接拷贝,不必 `clone()`);`DetectionResult` 本身不是 `Copy`(持有 `String` 字段)。

要看穿弱信号背后的攻击,靠的是 `assess()` 把多个检测器的命中叠起来:

```rust
let scanner = Scanner::default();

// 三条弱信号命中三个不同检测器,叠起来才够到 Medium(15 分),仍低于 High
let a = scanner.assess("<script src=\"/app.js\"></script>\n../config\n__proto__");
assert_eq!(a.results, 3);
assert_eq!(a.score, 15);
assert_eq!(a.level, RiskLevel::Medium);
```

被降为弱信号的形态举例(完整名单见各检测器的 `WEAK_PATTERNS`):`<script src=...>`、单级 `../`、行首 `-2`、裸 `__proto__`、`${env:}`、`X-Forwarded-Host`、`Host: localhost`、裸 `10.0.0.5`、`//evil.com`、`information_schema`。

判据是**形态**不是文件名:同样是 `../`,单级 `../x` 报 `Low`,多级 `../../` 报 `Critical`([`src/file/path_traversal.rs`](../src/file/path_traversal.rs))。各检测器能到多高见下方各表与 [README](../README.md) 的功能表。

## Scanner

### 安装

```toml
[dependencies]
security-rust = "4.0.0"
```

### 快速开始

```rust
use security_rust::Scanner;

fn main() {
    // 零配置:装配全部 32 个检测器
    let scanner = Scanner::default();

    // 扫描输入,返回所有检测到的攻击(每个检测器最多一条)
    let results = scanner.scan("<img src=x onerror=alert(1)>");

    for r in &results {
        println!("[{}] {} — offset: {}, pattern: {}",
            r.severity, r.message, r.offset, r.matched_pattern);
    }
    // 输出:
    // [CRITICAL] XSS cross-site scripting detected — offset: 11, pattern: onerror=

    // 弱信号走同一个检测器、同一个 attack_type,只是 severity 为 Low
    let weak = scanner.scan("<script src=\"/app.js\"></script>");
    // [LOW] XSS tag present (weak signal) — offset: 0, pattern: <script (含末尾空格)
}
```

### 选择性扫描

```rust
let scanner = Scanner::default();

// 只运行指定的检测器
let results = scanner.scan_with(
    "1 UNION SELECT password FROM users",
    &["sql_injection", "xss"],
);
```

### 自定义配置

```rust
use security_rust::injection::{XssDetector, SqlInjectionDetector};

// 通过 builder 只装配需要的检测器
let scanner = Scanner::builder()
    .with_detector(Box::new(XssDetector))
    .with_detector(Box::new(SqlInjectionDetector))
    .build();
```

`Scanner::builder()` **从空表起步**:一个检测器都不装也能 `build()` 成功,得到一个恒返回空结果的 scanner(这是行为不是错误,静默)。装配后先用 `Scanner::detector_names()` 自检:

```rust
// 按注册顺序列出检测器名字(`'static` 字面量,收集后可脱离 scanner 保存)
let names: Vec<&str> = scanner.detector_names().collect();
assert_eq!(names, ["xss", "sql_injection"]);
```

`scan` / `assess` / `scan_with` 与 builder 的 `with_detector` 都标了 `#[must_use]`(`score` 模块的 `total` / `score` / `assess` 同样如此)—— 丢掉扫描结果等于静默放行,编译器会替你把这一条提示出来。`scan_with` 名单里的**未知名字静默跳过**(拼错一个名字只会少扫一个检测器,不会报错),重复名字也不会让检测器跑两遍。

### 风险评分

```rust
use security_rust::RiskLevel;

let scanner = Scanner::default();

// 干净输入
let clean = scanner.assess("hello world 123");
assert_eq!(clean.level, RiskLevel::None); // score 0, results 0

// assess 内部就是 scan + score,省得算两遍
let a = scanner.assess("=cmd|' /C calc'!A0 `cat /etc/passwd` ../../../etc/passwd");
println!("{} score={} results={}", a.level, a.score, a.results);
// CRITICAL score=150 results=4
```

### 严重度展示

```rust
use security_rust::Severity;

let r = &results[0];
println!("{}", r.severity);  // CRITICAL | HIGH | MEDIUM | LOW
```

### 状态标签的 `Display`

除 `Severity` / `RiskLevel` 外,下列枚举也实现了 `Display`,日志里可直接插值,不必打印 `Debug` 形状:

| 类型 | 输出 |
|------|------|
| `AttackCategory` | `injection` / `protocol` / `data` / `file` |
| `Decision` | `ALLOW` / `CHALLENGE` / `BLOCK` |
| `SessionThreat` | 人类可读描述(`token expired`、`fingerprint mismatch` …);`ImpossibleTravel { kmh }` 带上数值:`impossible travel (11205 km/h)` |
| `ThrottleDecision` | `ALLOW` / `BANNED` / `UNAVAILABLE` |
| `ThrottleOutcome` | `ALLOW` / `BANNED` |

```rust
println!("{} {}", verdict.decision, verdict.threats.len());  // BLOCK 2
```

## 模块路径

| 模块 | 路径 | 公开项 | 检测器数 |
|------|------|--------|---------|
| 核心 | `src/lib.rs` `result.rs` `scanner.rs` | `Detector`、`DetectionResult`、`Scanner`、`ScannerBuilder`、`AttackCategory`、`Severity` | — |
| 注入 | `src/injection/` | `*Detector` 共 11 个 | 11 |
| 协议 | `src/protocol/` | `*Detector` 共 11 个 | 11 |
| 数据 | `src/data/` | `*Detector` 共 7 个 | 7 |
| 文件 | `src/file/` | `PathTraversalDetector`、`UploadDetector`、`DataLeakDetector` | 3 |
| 会话 | `src/session/` | `SessionGuard`、`RequestContext`、`SessionVerdict`、`Decision`、`SessionThreat`、`SessionConfig`、`SessionRecord`、`LoginPoint`、`SessionStore`、`MemoryStore`、`SessionError`、`StoreError` | — |
| 限流 | `src/throttle/` | `Throttle`、`ThrottleConfig`、`ThrottleDecision`、`ThrottleOutcome`、`ThrottleStore`、`MemoryThrottleStore` | — |
| 评分 | `src/score.rs` | `RiskLevel`、`RiskAssessment`、`assess`、`score`、`total` | — |
| 宠物 | `src/pet.rs` | `NAME`、`TAGLINE`、`ASCII`、`SVG` | — |

`session` / `throttle` / `score` 的类型都从 crate 根 re-export(如 `use security_rust::{RiskLevel, SessionGuard, Throttle}`),也可走模块路径(如 `use security_rust::session::MemoryStore`)。两个例外:`score` 模块的 `score` / `total` 函数只从模块路径可达(`security_rust::score::score`),crate 根 re-export 的是 `assess`。

## 枚举的演进策略(semver)

库里的枚举分两类,对下游的兼容承诺不同,升级前先看这里:

| 类别 | 枚举 | 承诺 |
|------|------|------|
| **封口(sealed)** | `SessionThreat`、`SessionError`、`StoreError` | 已标 `#[non_exhaustive]`:下游 `match` **必须**带 `_` 兜底臂(否则编译错误)。检测面 / 故障面扩大时新增变体只会落进兜底臂,属 **minor** 版本 |
| **封闭(closed)** | `Severity`、`AttackCategory`、`RiskLevel`、`Decision`、`ThrottleDecision`、`ThrottleOutcome` | 语义是固定的分档 / 处置集合,不随检测能力增长:minor 版本**不会**新增变体,真要新增属 breaking change(major)。这些枚举可以放心穷举 `match`,不必写 `_` 臂 |

规则很直接:**随「检测到什么」增长的枚举封口,固定分档的枚举封闭。** 判定结果是封闭的 —— 「允许 / 挑战 / 拒绝」是处置动作的完整枚举,不会因为多了检测器就多一档。

## Session 会话安全

会话级威胁检测:客户端劫持(指纹不符)、数据篡改(签名不符)、异地登录 / 不可能旅行、token 过期与吊销。

### 核心类型

| 类型 | 说明 |
|------|------|
| `SessionGuard<S: SessionStore>` | 会话闸门。`bind` / `verify` / `revoke` / `revoke_all` / `rotate` / `purge_expired` |
| `RequestContext<'a>` | 一次请求的全部输入:`token`、`subject`、`fingerprint`、`location`、`coords`、`signature`、`at`。`subject` **仅 `bind` 使用** |
| `SessionVerdict` | 校验结论:`decision`、`severity: Option<Severity>`、`threats` |
| `Decision` | `Allow` < `Challenge` < `Block`(声明顺序即严格度顺序) |
| `SessionThreat` | 11 种威胁,各自映射固定的 `severity()` 与 `decision()` |
| `SessionConfig` | 校准旋钮:`ttl_secs` = 3600、`impossible_travel_kmh` = 900.0、`timestamp_skew_secs` = 300 |
| `SessionStore` | 存储抽象 trait,多实例部署实现它接 Redis 即可。`SessionGuard` **按值**持有 store,没有 `Arc<S>` 的 blanket 实现(`Arc<MemoryStore>` 不是 `SessionStore`):两个 guard 共享一份 store 需要自己写转发全部方法的 newtype;多线程场景更推荐共享一个 `Arc<SessionGuard<S>>` |
| `MemoryStore` | 内置内存后端(`Mutex<HashMap>`,无后台线程) |
| `SessionRecord` / `LoginPoint` | 会话记录 / 登录位置快照 |
| `SessionError` / `StoreError` | 调用方误用 / 存储后端故障(两者均已封口,见「枚举的演进策略」)。分裂规则:校验调用方输入、产出安全结论的方法(`bind` / `rotate`)返回 `SessionError`;纯转发存储操作的 `revoke` / `revoke_all` / `purge_expired` 原样透传 `StoreError`。`TokenInUse` 的 Display 文本为 `new token is already in use` |

### 方法

```rust
impl<S: SessionStore> SessionGuard<S> {
    pub fn new(store: S, config: SessionConfig) -> Self;
    pub fn config(&self) -> &SessionConfig;

    pub fn bind(&self, ctx: &RequestContext, now: u64) -> Result<SessionVerdict, SessionError>;
    pub fn verify(&self, ctx: &RequestContext, now: u64) -> SessionVerdict;
    pub fn revoke(&self, token: &str) -> Result<(), StoreError>;
    pub fn revoke_all(&self, subject: &str) -> Result<usize, StoreError>;
    pub fn rotate(&self, old: &str, new: &str, ctx: &RequestContext, now: u64) -> Result<(), SessionError>;
    pub fn purge_expired(&self, now: u64) -> Result<usize, StoreError>;
}
```

| 方法 | 用途 |
|------|------|
| `bind` | 登录:建会话、绑指纹、记登录位置,并返回异地判定 |
| `verify` | 每请求校验,返回处置建议 |
| `revoke` | 吊销单个会话(登出) |
| `revoke_all` | 吊销某 subject 的全部会话(改密码 / 踢下线),返回受影响条数 |
| `rotate` | 续期换 token:旧 token 立即失效,新 token 由调用方提供。`new` 必须是**未在用**的 token(不存在 / 已过期 / 已吊销均可覆盖),否则返回 `SessionError::TokenInUse` |
| `purge_expired` | 清除过期会话与休眠 subject 的登录历史。**返回值只计会话条数**,不含被回收的登录历史 |

### 最小示例

```rust
use security_rust::session::{Decision, MemoryStore, RequestContext, SessionConfig, SessionGuard};

let guard = SessionGuard::new(MemoryStore::new(), SessionConfig::default());

let login = RequestContext {
    token: "tok-abc",
    subject: "u-1",
    fingerprint: "ip=1.2.3.4|ua=curl",
    location: Some("CN-BJ"),
    coords: Some((39.9042, 116.4074)),
    signature: None,
    at: None,
};

guard.bind(&login, 1_700_000_000).unwrap();

// 同一个 token,换一个指纹 ⇒ 客户端劫持
let verdict = guard.verify(&RequestContext { fingerprint: "ip=5.6.7.8|ua=curl", ..login }, 1_700_000_010);
assert_eq!(verdict.decision, Decision::Block);
```

### 关键语义

- **`Decision` 三档** — `Allow` 放行;`Challenge` 放行但要求二次验证(异地、时钟偏离、签名意外,这三项是「信号」而非「结论」);`Block` 拒绝。多个威胁同时命中时取最严格者,`severity` 取最严重者。
- **`severity` 是 `Option<Severity>`,放行为 `None`** — 没有威胁就是没有严重度,由类型说明。不要用某个低危值占位:占位值在日志里跟「发现了一条低危」长得一模一样,一条完全放行的正常请求会被读成有发现。
- **`RequestContext.subject` 仅 `bind` 使用,`verify` 完全忽略它** — 每请求校验的身份一律取自服务端 `SessionRecord`(异地历史按 `record.subject` 聚合),**请求方提供的 subject 不可信**。因此中间件里传 `subject: ""` 是合法的(`bind` 才要求非空)。**绝不要**把请求头里的用户标识填进来当身份:现在它进不了判定,将来重构未必。
- **fail-closed** — 存储后端故障时返回 `SessionThreat::StoreUnavailable` ⇒ `Block`,绝不放行。放行所有请求是一个可被攻击者主动触发的绕过。
- **`verify` 的三步是三次独立锁获取,不原子(有意为之)** — `get` → `recent_logins` → `touch` 之间可能并发 `revoke` / `purge_expired`:竞争只会产生过期观测(`touch` 落在刚被吊销 / 回收的记录上是空操作,历史可能差一条),**不构成绕过** —— 每一步判定消费的是自己的克隆快照。要消除窗口需要存储 trait 提供「单次组合操作」原语。
- **`verify` 返回 `SessionVerdict` 而非 `Result`** — 认证路径上「拒绝」是正常结果而非错误,强制调用方在类型层面处理每一种拒绝。只有 `bind` / `rotate` 会因调用方误用或后端故障返回 `Result`。
- **只有放行才刷新活跃度** — 被拦的请求不会延长会话寿命。
- **`rotate` 绑定指纹** — 旧会话必须存在、未吊销、未过期,且指纹与本次 `ctx` 相符,否则返回 `UnknownSession`;新记录的身份字段(`subject` / `location` / `coords` / `signature`)一律以服务端记录为准,不接受 `ctx` 覆盖。
- **`rotate` 要求 `new` 未被占用** — 目标 token 指向一个在用会话(未吊销且未过期)时返回 `SessionError::TokenInUse`,且**零副作用**(失败的 rotate 不写任何记录、不吊销旧会话)。store 的写入是无条件覆盖,不挡这一下,攻击者就能拿自己的会话把受害者的记录整个盖掉 —— 受害者下次校验读到的是攻击者的指纹,被被动踢下线。`new == old` 同样按 `TokenInUse` 拒绝(既要吊销 old 又要 new 可用,语义自相矛盾);已吊销 / 已过期的死记录可以覆盖。查 `new` 与随后的写入之间不原子:两个并发 `rotate` 抢同一个新 token 仍可能互相覆盖(需要攻击者猜到对方的新 token 并卡进窄窗)。
- **指纹与签名用常数时间比较** — 直接 `==` 会在首个不同字节处提前返回,泄露「前 N 个字节猜对了」的时序信息。
- **缺失即不判定** — 位置、坐标、时间任一缺失时不产生对应威胁,避免误报;坐标在信任边界清洗,非有限值 / 越界一律视为「没有坐标」。
- **`now` 由调用方传入** — 全部时间参数都是 unix 秒,调用方须保证单调不减。
- **内存后端的 subject 数量无上限** — `MemoryStore` 每个 subject 的登录历史条数由 `MAX_LOGINS_PER_SUBJECT` = 10 限死,**无上限的是 subject 数量**(`Mutex<HashMap>`,无后台线程,条目只增不减)。长期运行的进程应按 `ttl_secs` 量级的间隔定时调用 `purge_expired`:它删除 `expires_at <= now` 的会话,以及最后一个登录点早于 `now - LOGIN_HISTORY_KEEP_SECS`(7 天)的 subject 的整条登录历史。**返回值只计会话条数**,不含被回收的登录历史。回收休眠 subject 的历史,代价是他的下一次登录少做一次异地 / 不可能旅行判定 —— 那是漏报而非误报,之后历史立即重建。

## Throttle 限流与封禁

滑动窗口计数 + 阈值封禁 + 账户锁定,用于兜住暴力破解与撞库。

### 核心类型

| 类型 | 说明 |
|------|------|
| `Throttle<S: ThrottleStore>` | 限流闸门。`check` / `check_any` / `record_failure` / `record_success` / `reset` / `purge_expired` |
| `ThrottleDecision` | `check` / `check_any` 的结果:`Allow { remaining }` / `Banned { until }` / `Unavailable` |
| `ThrottleOutcome` | `record_failure` 的结果:`Allow { remaining }` / `Banned { until }`。**没有 `Unavailable`** —— 存储故障走 `Err` |
| `ThrottleConfig` | `threshold` = 5、`window_secs` = 60、`ban_secs` = 900 |
| `ThrottleStore` | 存储抽象 trait,多实例部署实现它接 Redis 即可 |
| `MemoryThrottleStore` | 内置内存后端 |

### 方法

```rust
impl<S: ThrottleStore> Throttle<S> {
    pub fn new(store: S, config: ThrottleConfig) -> Self;
    pub fn config(&self) -> &ThrottleConfig;

    pub fn check(&self, key: &str, now: u64) -> ThrottleDecision;
    /// 同时检查多个维度(如 [ip_key, account_key]),返回最严格的结果。
    pub fn check_any(&self, keys: &[&str], now: u64) -> ThrottleDecision;
    pub fn record_failure(&self, key: &str, now: u64) -> Result<ThrottleOutcome, StoreError>;
    pub fn record_success(&self, key: &str) -> Result<(), StoreError>;
    pub fn reset(&self, key: &str) -> Result<(), StoreError>;
    pub fn purge_expired(&self, now: u64) -> Result<usize, StoreError>;
}
```

| 方法 | 用途 |
|------|------|
| `check` | 请求进入时调用:先查封禁,再算剩余额度。**不计入失败** |
| `check_any` | 一次查多个维度(如 `[ip_key, account_key]`),返回最严格的结果。**不计入失败** |
| `record_failure` | 认证失败时调用:达到 `threshold` 即封禁并返回 `Banned`;返回 `ThrottleOutcome` 而非 `ThrottleDecision` |
| `record_success` | 认证成功时调用:**只清失败计数,保留封禁** |
| `reset` | 人工解封 / 解限(清计数 + 清封禁) |
| `purge_expired` | 清除已过期状态,返回清除条数 |

### 最小示例

```rust
use security_rust::throttle::{MemoryThrottleStore, Throttle, ThrottleConfig, ThrottleDecision};

let throttle = Throttle::new(MemoryThrottleStore::new(), ThrottleConfig::default());
let key = "acct:u-1"; // key 由调用方构造并规范化
let now = 1_700_000_000;

// 真实请求天然有两个维度:IP 与账户。一次问完,合并规则由库负责
match throttle.check_any(&["ip:1.2.3.4", key], now) {
    ThrottleDecision::Allow { remaining } => { /* 剩余额度 remaining */ }
    ThrottleDecision::Banned { until } => { /* 封禁中,until 解封 */ }
    ThrottleDecision::Unavailable => { /* 限流后端不可用 */ }
}

// record_failure 只有两个可达状态:存储故障走 Err,不混在返回值里
match throttle.record_failure(key, now) {
    Ok(outcome) => println!("{outcome}"),
    Err(e) => { /* 后端故障 */ }
}
```

### 关键语义

- **`Allow { remaining: 0 }` 表示本请求应被拒绝** — 额度已耗尽,不是「还能再试一次」。调用方必须据此拒绝,否则最后一次额度形同虚设。仍叫 `Allow` 是因为此刻并没有封禁在生效——例如 `ban_secs = 0` 的配置下,额度耗尽的 key 会一直落在这一支。
- **`Banned { until }`** — `until` 是解封时刻(unix 秒),`now >= until` 即视为已解封。
- **`check_any` 的合并规则** — 任一 `Banned` → `Banned`(取**最晚**的 `until`);否则任一 `Unavailable` → `Unavailable`;否则 `Allow` 取**最小** `remaining`。每个 key 各自独立查询,**不合并计数**:`ip:` 与 `acct:` 是两类互不干扰的桶,合并会让 NAT 后面的其他人替攻击者吃掉额度。`keys` 为空返回 `Allow { remaining: 0 }` 而非满额 —— 这个数字会写进 `X-RateLimit-*` 响应头,凭空报满额等于谎报额度。
- **`Unavailable` 是唯一的 fail-open 例外,且是有意的** — 限流是纵深防御,不是主认证闸门。后端故障时返回 `Banned` 会把全体用户挡在门外(自我 DoS,且攻击者可能主动诱发),而放行只是暂时失去暴力破解防护——主认证闸门 `SessionGuard` 仍然在拦。调用方拿到该变体后自行选择(建议放行 + 告警)。**该变体只由 `check` / `check_any` 产生**:`check` 只把 `Err` 映射到它,绝不映射到 `Banned`;`record_failure` 返回的 `ThrottleOutcome` 压根没有这个变体(故障走 `Err`),调用方不必为一个永不执行的 `match` 臂写死代码。
- **key 由调用方构造** — 约定形如 `"ip:1.2.3.4"` 或 `"acct:u-1"`;两类前缀不同,天然互不干扰,可同时启用。不要把用户输入原样当 key:攻击者每次换一个值就能把自己拆成无限多个桶;空 key 同理。调用方须先规范化(截断长度、统一大小写、限制字符集)并保证非空。
- **`record_success` 只清计数、保留封禁** — 「凭据正确 ⇒ 顺手解封」只对 `acct:` 桶成立;对 `ip:` 这类共享桶,换成 `reset` 意味着桶里任意另一个用户认证成功就能替爆破者解封。代价是账户桶下被爆破牵连的用户即使立刻输对密码也要等满 `ban_secs`。
- **`record_failure` 的计数与封禁不原子** — 两次独立的存储写入之间并发一次 `reset` 是可能的,结果是刚认证成功的用户又被封上(可用性问题,不构成绕过)。
- **内存后端:map 条目数无上限,单 key 失败记录有硬上限** — `MemoryThrottleStore` 每个 key 保留的失败记录上限为 `min(窗口内失败数, 1024)`。1024 是硬上限,挡住时钟冻结 / 回拨时(没有任何失败滑出窗口)向量的无界增长:计数在饱和值停住。**阈值 ≤ 1024 的配置(默认 5)判定逐字不变**,只有把 `threshold` 配到 1024 以上才会观察到计数封顶、永不触发封禁。map 条目数仍无上限(key 只增不减),长期运行的进程应按 `window_secs` 量级的间隔定时调用 `purge_expired`。

## Score 风险评分

把单条低危信号聚合成可观测量:多条低危命中叠加可升级,给 WAF 调误报留旋钮。

### 核心类型

| 类型 | 说明 |
|------|------|
| `RiskLevel` | `None` < `Low` < `Medium` < `High` < `Critical`(声明顺序即强度顺序,已派生 `Ord`) |
| `RiskAssessment` | `level` + `score`(原始分)+ `results`(参与聚合的命中条数) |

```rust
pub enum RiskLevel { None, Low, Medium, High, Critical }

pub struct RiskAssessment {
    pub level: RiskLevel,
    pub score: u32,
    pub results: usize,
}

pub fn assess(results: &[DetectionResult]) -> RiskAssessment;
pub fn score(results: &[DetectionResult]) -> RiskLevel;
pub fn total(results: &[DetectionResult]) -> u32;
```

### 评分规则

| `Severity` | 权重 |
|------------|------|
| Critical | 100 |
| High | 40 |
| Medium | 15 |
| Low | 5 |

- 空结果 ⇒ `None`
- 任一 `Severity::Critical` ⇒ 直接 `Critical`(短路,不靠累加)
- 否则按总分分档:`1..=14` → Low、`15..=39` → Medium、`40..=99` → High、`≥100` → Critical
- 因此 3 × Low(15 分)升级为 Medium,8 × Low(40 分)升级为 High

**这是弱信号唯一的升级路径** —— 单条弱信号 5 分永远越不过 40 分的拒绝线,只有多条(来自不同检测器)叠起来才会。因此把多个维度的输入都喂给同一个 `Scanner`,比只扫单一字段更能看见弱信号背后的攻击;反过来,只扫一个短字段时不必为弱信号做任何处置。

注意 `Severity` 本身**没有** `Ord`(声明顺序是 Critical → Low 递减,派生 `Ord` 会让 `max()` 静默取到最轻的那条),权重表写在评分侧;`RiskLevel` 则相反,声明顺序即强度顺序。

### 最小示例

```rust
use security_rust::{RiskLevel, Scanner};

let scanner = Scanner::default();

// 干净输入
assert_eq!(scanner.assess("hello world 123").level, RiskLevel::None);

// 多条命中叠加:等级、原始分、命中条数一次拿到
let a = scanner.assess("=cmd|' /C calc'!A0 `cat /etc/passwd` ../../../etc/passwd");
println!("{} score={} results={}", a.level, a.score, a.results);
// CRITICAL score=150 results=4

// 已有 scan 结果时直接聚合(assess 从 crate 根导入,
// score / total 走模块路径 security_rust::score::{score, total})
// 用强信号载荷:标签「存在」是弱信号(Low,5 分),单独到不了 Critical。
let results = scanner.scan("<img src=x onerror=alert(1)>");
let a = security_rust::assess(&results);
assert_eq!(a.level, RiskLevel::Critical);
```

## 已知上限

以下几处是**已知且有意保留**的边界,不是待修的缺陷。改动前请先读依据 —— 每一条都出自实测,且都有人试过收紧后撞上同一堵墙。

### `dns_rebinding` 只上报,不拦截

判据是「`Host:` 头里出现内网地址」,而同一个形状也正是 k8s 里每个 pod 间调用(`Host: 10.244.1.5:8080`)、每次本地开发(`Host: localhost:8000`)、每个 Docker 容器网络请求(`172.18.0.2`)。真正的 rebinding 看的是「公网域名 + 解析结果指向内网」,而浏览器发出的 `Host` 恰恰是那个公网域名 —— **单条字符串里看不到解析历史**,本检测器测的形态与攻击形态并不重合,没有可收紧的方向。因此整个检测器只有弱档,一律 `Low`,无论叠加多少条都不会单独越过拒绝线。防护在解析**之后**比对结果 IP,不在字符串层。

### 这个库扫不动自己的源码、测试和文档

签名扫描器的天花板:把仓库目录下所有 UTF-8 文本文件(跳过 `target/` 与 `.git/`)逐个喂给 `Scanner`,实测 383 个文件里有 85 个越过拒绝线,而它们**按构造**全都含有攻击串 —— 测试载荷、检测器源码里的正则字面量自身,以及列出这些模式的 README 与 OWASP 表格。一份 README 不会因为写了 `(a+)+` 而变成缺陷。要扫自己的产物,得先把这些语料排除,或者换一个判据。

### `upload` 一律把 `<%@` / `<?php` 报为 Critical

该检测器的契约是「**这个 blob 是服务端可执行代码**」—— 出现即成立,因此不设强弱分层。JSP 页面与 JSP webshell 的前导字节逐字节相同(`<%@ page language="java" … %>` 与 `<%@ page import="java.io.*" %>` 是同一形态),把 `<%@`/`<%=` 降档等于让 webshell 落到拒绝线以下 —— 那是换个方式删检测。代价是扫描**正在对外提供的**页面(而不是上传的文件)时也会命中,那属于输入域不符。

### `path_traversal` 把 `(?:\.\./){2,}` 报为 Critical

monorepo 里的深层相对路径(`from '../../../shared/domain'`)会命中。没有进一步收紧,因为唯一能把它与攻击分开的约束是目标文件名列表(`../etc/passwd` 那一类),而那只覆盖系统文件 —— 攻方换一个 LFI 目标就绕过去了。

## 性能

每个检测器都以静态表 `static PATTERNS: LazyLock<Vec<Regex>>` 持有自己的正则,在进程内首次使用时编译一次,此后每次调用直接复用,不再产生编译开销。全量 32 个检测器的一次扫描在数十微秒量级,成本随检测器数量与输入长度增长;具体数值取决于硬件与负载,建议在自己的机器上实测。适合高吞吐量场景(API 网关、日志管道)。