Skip to main content

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}