Skip to main content

bastion_toolkit/
text_guard.rs

1//! # text_guard (Analyzer & Sanitizer)
2//! 
3//! メモリ枯渇攻撃(DoS)、インジェクション(Prompt/XSS)、およびBidi文字、
4//! Windows予約語などの特定文字列を検知・無害化するための産業グレードの総合ガード。
5
6use regex::Regex;
7use std::sync::OnceLock;
8
9#[cfg(feature = "text")]
10use unicode_normalization::UnicodeNormalization;
11
12/// 入力分析・バリデーションの結果
13#[derive(Debug, PartialEq, Eq)]
14pub enum ValidationResult {
15    /// 入力は安全
16    Valid,
17    /// 入力がブロックされた(理由を含む)
18    Blocked(String),
19}
20
21/// テキストの分析と無害化を行う構造体
22pub struct Guard {
23    max_len: usize,
24}
25
26impl Default for Guard {
27    fn default() -> Self {
28        Self { max_len: 4096 }
29    }
30}
31
32static INJECTION_PATTERNS: OnceLock<Vec<Regex>> = OnceLock::new();
33
34fn get_patterns() -> &'static Vec<Regex> {
35    INJECTION_PATTERNS.get_or_init(|| {
36        vec![
37            // プロンプトインジェクション系
38            Regex::new(r"(?i)ignore previous instructions").unwrap(),
39            Regex::new(r"(?i)system prompt").unwrap(),
40            Regex::new(r"(?i)you are an ai").unwrap(),
41            // XSS / インジェクション系
42            Regex::new(r"(?i)<script").unwrap(),
43            Regex::new(r"(?i)javascript:").unwrap(),
44            Regex::new(r"(?i)vbscript:").unwrap(),
45            Regex::new(r"(?i)data:text/html").unwrap(),
46            Regex::new(r#"(?i)alert\("#).unwrap(),
47        ]
48    })
49}
50
51impl Guard {
52    pub fn new() -> Self {
53        Self::default()
54    }
55
56    /// 最大入力長を設定する
57    pub fn max_len(mut self, len: usize) -> Self {
58        self.max_len = len;
59        self
60    }
61
62    /// 入力を分析し、危険なパターンが含まれていないかチェックする
63    pub fn analyze(&self, input: &str) -> ValidationResult {
64        // 1. 長さチェック (DoS対策)
65        if input.len() > self.max_len {
66            return ValidationResult::Blocked(format!(
67                "Input too long (max {} bytes, got {})",
68                self.max_len,
69                input.len()
70            ));
71        }
72
73        // 2. パターンマッチング (インジェクション対策)
74        let patterns = get_patterns();
75        for re in patterns {
76            if re.is_match(input) {
77                return ValidationResult::Blocked("Potential injection detected".to_string());
78            }
79        }
80
81        ValidationResult::Valid
82    }
83
84    /// 文字列をサニタイズ(無害化)する
85    pub fn sanitize(&self, input: &str) -> String {
86        // 1. DoS対策: バイト数で切り詰め
87        let mut text = if input.len() > self.max_len {
88            input[..self.max_len].to_string()
89        } else {
90            input.to_string()
91        };
92
93        // 2. Unicode正規化 (NFC)
94        #[cfg(feature = "text")]
95        {
96            text = text.nfc().collect::<String>();
97        }
98
99        // 3. 制御文字、Bidi制御文字、および危険なパスキャラクタの除去
100        text = text.chars().filter(|&c| !self.is_forbidden_char(c)).collect();
101
102        // 4. Windows 予約語対策
103        text = self.mask_windows_reserved(&text);
104
105        text
106    }
107
108    fn is_forbidden_char(&self, c: char) -> bool {
109        if c.is_control() {
110            return true;
111        }
112        match c {
113            '\u{200E}' | '\u{200F}' | '\u{202A}'..='\u{202A}' | '\u{202B}'..='\u{202B}' | 
114            '\u{202C}'..='\u{202C}' | '\u{202D}'..='\u{202D}' | '\u{202E}'..='\u{202E}' |
115            '\u{2066}'..='\u{2069}' => return true,
116            _ => {}
117        }
118        // パスとして危険な文字
119        matches!(c, '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|')
120    }
121
122    fn mask_windows_reserved(&self, name: &str) -> String {
123        let upper = name.to_uppercase();
124        let reserved = [
125            "CON", "PRN", "AUX", "NUL",
126            "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9",
127            "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
128        ];
129
130        if reserved.contains(&upper.as_str()) {
131            format!("_{}", name)
132        } else {
133            name.to_string()
134        }
135    }
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141
142    #[test]
143    fn test_analyze_and_sanitize() {
144        let guard = Guard::new().max_len(20);
145        
146        // Analyze
147        assert_eq!(guard.analyze("Hello"), ValidationResult::Valid);
148        assert!(matches!(guard.analyze("<script>"), ValidationResult::Blocked(_)));
149        
150        // Sanitize
151        assert_eq!(guard.sanitize("file/name.txt"), "filename.txt");
152        assert_eq!(guard.sanitize("CON"), "_CON");
153    }
154}