security_rust/lib.rs
1// Copyright (c) 2026 erik <erik@erik.xyz> — https://erik.xyz
2
3// 内层属性必须出现在任何 item 之前;版权头是注释,放它上面不违反 rustc 规则
4// (Linux 内核的 SPDX 头同样是这个位置)。本库至今零 unsafe,用 forbid 把这条
5// 从口头承诺升级成编译期强制 —— 新增一句 unsafe 会直接编译失败。
6#![forbid(unsafe_code)]
7
8//! Rust 编写的攻击检测库:32 个无状态检测器(注入 / 协议 / 数据 / 文件四类)
9//! + 三个有状态模块(会话安全、限流封禁、风险评分)。`[dependencies]` 只有 `regex`。
10//!
11//! ```text
12//! (o)(o)
13//! ⌕┬─────────┬! 甲哨 Sentri
14//! │ · · · · │ 只报告,不拦截
15//! └──┬───┬──┘ 32 detectors / 4 categories
16//! / \ deps = regex ×1
17//! ```
18//!
19//! 项目宠物 **甲哨 Sentri**:32 片甲是 32 个检测器,左钳的放大镜负责看,右钳的
20//! 告示牌负责报,但两只钳子都不替调用方做决定(唯一的例外是
21//! [`SessionGuard`])。形象见 [`pet`] 模块。
22//!
23//! ```
24//! use security_rust::Scanner;
25//!
26//! let results = Scanner::default().scan("<script>alert(1)</script>");
27//! let first = results.first().expect("xss should be detected");
28//! assert_eq!(first.attack_type, "xss");
29//! ```
30
31use regex::Regex;
32
33pub mod data;
34pub mod file;
35pub mod injection;
36pub mod pet;
37pub mod protocol;
38pub mod result;
39pub mod scanner;
40pub mod score;
41pub mod session;
42pub mod throttle;
43
44pub use result::{AttackCategory, DetectionResult, Severity};
45pub use scanner::{Scanner, ScannerBuilder};
46pub use score::{RiskAssessment, RiskLevel, assess};
47pub use session::{
48 Decision, LoginPoint, MemoryStore, RequestContext, SessionConfig, SessionError, SessionGuard,
49 SessionRecord, SessionStore, SessionThreat, SessionVerdict, StoreError,
50};
51pub use throttle::{
52 MemoryThrottleStore, Throttle, ThrottleConfig, ThrottleDecision, ThrottleOutcome, ThrottleStore,
53};
54
55/// 所有检测器的唯一契约:一个名字,一次检测。
56///
57/// 32 个内置检测器都只实现这两个方法。`session` / `throttle` **不实现**本 trait
58/// —— 它们是有状态的,输入不是单个字符串(token + 指纹 + 位置 + 时间),也不在
59/// [`Scanner`] 的装配范围内,见各自模块。
60///
61/// 实现契约(`tests/invariants.rs` 对全部内置实现逐个核验):
62/// - [`name`](Detector::name) 非空、snake_case、全局唯一;
63/// - [`detect`](Detector::detect) 返回的 [`DetectionResult::attack_type`] 必须等于
64/// `name()`,且命中时 `matched_pattern` 非空;
65/// - 同一实例的 `name()` 恒定(扫过任意输入都不变);
66/// - `Send + Sync`:`Scanner` 靠它把检测器跨线程共享。
67pub trait Detector: Send + Sync {
68 /// 检测器名:snake_case 且全局唯一,同时是
69 /// [`Scanner::scan_with`] 的过滤键与 [`DetectionResult::attack_type`] 的取值。
70 ///
71 /// 返回 `&'static str`:名字是编译期字面量,调用方可以收集成静态名单长期保存,
72 /// 不受检测器生命周期约束。
73 fn name(&self) -> &'static str;
74
75 /// 扫描一个输入:命中返回 `Some`,未命中返回 `None`,**至多一条**结果。
76 ///
77 /// 返回字段的契约(`tests/invariants.rs` 与各检测器自测逐一核验):
78 /// - `attack_type == self.name()`;
79 /// - `offset` 是 `input` 内的**字节**偏移(UTF-8 字节下标,不是字符数);
80 /// - `matched_pattern` 恰好是 `&input[offset..offset + matched_pattern.len()]`,
81 /// 即命中片段本身;
82 /// - 有强弱两档的检测器先试强档:强档命中报声明的等级,未中再试弱档,弱档固定报
83 /// [`Severity::Low`](弱信号,单条不构成拒绝理由,见 [`DetectionResult::severity`])。
84 ///
85 /// 无状态:`&self` 只读,同一输入任意次调用结果相同;实现不得借检测写入内部状态
86 /// (需要状态请用 `session` / `throttle` 这类独立模块)。
87 fn detect(&self, input: &str) -> Option<DetectionResult>;
88}
89
90pub(crate) fn regex_detect(
91 patterns: &[Regex],
92 name: &'static str,
93 category: AttackCategory,
94 severity: Severity,
95 message: &'static str,
96 input: &str,
97) -> Option<DetectionResult> {
98 for re in patterns {
99 if let Some(m) = re.find(input) {
100 return Some(DetectionResult {
101 attack_type: name.to_string(),
102 category,
103 severity,
104 matched_pattern: m.as_str().to_string(),
105 offset: m.start(),
106 message: message.into(),
107 });
108 }
109 }
110 None
111}
112
113#[cfg(test)]
114pub(crate) mod test_helpers {
115 use super::*;
116
117 pub(crate) fn assert_detected<D: Detector>(
118 d: &D,
119 input: &str,
120 category: AttackCategory,
121 severity: Severity,
122 ) {
123 let r = d.detect(input).expect("expected detection");
124 assert_eq!(r.attack_type, d.name());
125 assert_eq!(r.category, category);
126 assert_eq!(r.severity, severity);
127 assert!(!r.matched_pattern.is_empty(), "matched_pattern empty");
128 assert!(
129 r.offset <= input.len(),
130 "offset {} > len {}",
131 r.offset,
132 input.len()
133 );
134 assert_eq!(
135 &input[r.offset..r.offset + r.matched_pattern.len()],
136 r.matched_pattern
137 );
138 assert!(!r.message.is_empty());
139 }
140
141 pub(crate) fn assert_clean<D: Detector>(d: &D, input: &str) {
142 // 断言是「必须干净」,所以只有在**检出**时才会 panic —— 报错信息必须
143 // 说明是误报,而不是"未检出"(旧文案正好说反,读起来与事实相反)。
144 assert!(d.detect(input).is_none(), "expected clean, but detected: {input:?}");
145 }
146}
147
148#[cfg(test)]
149mod tests {
150 use super::*;
151
152 #[test]
153 fn detector_trait_object_is_send_sync() {
154 let detector: Box<dyn Detector> = Box::new(injection::XssDetector);
155 assert_eq!(detector.name(), "xss");
156 }
157
158 #[test]
159 fn detector_name_is_static_str() {
160 let name: &'static str = injection::XssDetector.name();
161 assert_eq!(name, "xss");
162 }
163}