Skip to main content

sz_orm_audit/
lib.rs

1//! # SZ-ORM Audit — SQL 审计日志
2//!
3//! 提供 SQL 执行审计记录,对 password/token/credit_card 等敏感关键词进行
4//! 大小写不敏感脱敏,确保审计日志不泄露敏感信息。
5//!
6//! ## 主要类型
7//!
8//! - [`SqlAuditContext`] — 审计上下文(SQL/用户/时间戳)
9//! - [`SqlAuditor`] — 审计执行器
10
11use serde::{Deserialize, Serialize};
12use std::sync::Mutex;
13
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct SqlAuditContext {
16    pub sql: String,
17    pub user: String,
18    pub timestamp: i64,
19}
20
21/// Sensitive keywords that should be masked in audit logs. Matching is
22/// case-insensitive on the ASCII bytes of the SQL string.
23const SENSITIVE_KEYWORDS: &[&str] = &[
24    "password",
25    "pwd",
26    "passwd",
27    "secret",
28    "token",
29    "api_key",
30    "apikey",
31    "access_key",
32    "accesskey",
33    "session",
34    "credit_card",
35    "creditcard",
36    "cvv",
37    "ssn",
38];
39
40pub struct SqlAuditor {
41    logs: Mutex<Vec<SqlAuditContext>>,
42}
43
44impl SqlAuditor {
45    pub fn new() -> Self {
46        Self {
47            logs: Mutex::new(vec![]),
48        }
49    }
50
51    /// Log an audit entry. The SQL is masked for sensitive keywords before
52    /// being stored in the in-memory buffer.
53    pub fn log(&self, ctx: &SqlAuditContext) {
54        let masked_sql = mask_sensitive(&ctx.sql);
55        let entry = SqlAuditContext {
56            sql: masked_sql,
57            user: ctx.user.clone(),
58            timestamp: ctx.timestamp,
59        };
60        let mut logs = self.logs.lock().unwrap();
61        logs.push(entry);
62    }
63
64    /// Return a snapshot of all stored audit entries.
65    pub fn get_logs(&self) -> Vec<SqlAuditContext> {
66        let logs = self.logs.lock().unwrap();
67        logs.iter().cloned().collect()
68    }
69
70    /// Flush all stored audit entries to a JSON file at `path`.
71    /// Returns the number of entries written.
72    pub fn flush(&self, path: &str) -> Result<usize, String> {
73        let logs = self.logs.lock().unwrap();
74        let snapshot: Vec<&SqlAuditContext> = logs.iter().collect();
75        let json = serde_json::to_string_pretty(&snapshot).map_err(|e| e.to_string())?;
76        std::fs::write(path, json).map_err(|e| e.to_string())?;
77        Ok(logs.len())
78    }
79
80    /// Mask all sensitive keywords in `sql` with `******`. Matching is
81    /// case-insensitive.
82    pub fn mask_sensitive(&self, sql: &str) -> String {
83        mask_sensitive(sql)
84    }
85}
86
87impl Default for SqlAuditor {
88    fn default() -> Self {
89        Self::new()
90    }
91}
92
93/// Mask all sensitive keywords in `sql` with `******`. Matching is
94/// case-insensitive over the ASCII bytes of the string.
95fn mask_sensitive(sql: &str) -> String {
96    let lower = sql.to_ascii_lowercase();
97    let mut result = String::with_capacity(sql.len());
98    let mut i = 0;
99    let bytes = sql.as_bytes();
100    let lower_bytes = lower.as_bytes();
101    while i < bytes.len() {
102        let mut matched_len: Option<usize> = None;
103        for keyword in SENSITIVE_KEYWORDS {
104            let kw_bytes = keyword.as_bytes();
105            if i + kw_bytes.len() <= bytes.len() && &lower_bytes[i..i + kw_bytes.len()] == kw_bytes
106            {
107                // Only treat as a keyword match if it's not part of a longer identifier.
108                // Boundary check: previous and next char must be non-alphanumeric/underscore
109                let prev_ok = i == 0 || !is_ident_char(bytes[i - 1]);
110                let next_idx = i + kw_bytes.len();
111                let next_ok = next_idx >= bytes.len() || !is_ident_char(bytes[next_idx]);
112                if prev_ok && next_ok {
113                    matched_len = Some(kw_bytes.len());
114                    break;
115                }
116            }
117        }
118        if let Some(kw_len) = matched_len {
119            result.push_str("******");
120            i += kw_len;
121        } else {
122            // Push one char (handles UTF-8 properly since we step by char).
123            let ch = sql[i..].chars().next().unwrap();
124            result.push(ch);
125            i += ch.len_utf8();
126        }
127    }
128    result
129}
130
131/// Returns true if `b` is an ASCII identifier character (alphanumeric or '_').
132fn is_ident_char(b: u8) -> bool {
133    b.is_ascii_alphanumeric() || b == b'_'
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139
140    fn ctx(sql: &str, user: &str, ts: i64) -> SqlAuditContext {
141        SqlAuditContext {
142            sql: sql.to_string(),
143            user: user.to_string(),
144            timestamp: ts,
145        }
146    }
147
148    #[test]
149    fn test_log_stores_in_memory() {
150        let a = SqlAuditor::new();
151        a.log(&ctx("SELECT * FROM users", "admin", 1000));
152        a.log(&ctx("INSERT INTO logs VALUES(1)", "admin", 1001));
153        let logs = a.get_logs();
154        assert_eq!(logs.len(), 2);
155        assert_eq!(logs[0].sql, "SELECT * FROM users");
156        assert_eq!(logs[0].user, "admin");
157        assert_eq!(logs[0].timestamp, 1000);
158        assert_eq!(logs[1].timestamp, 1001);
159    }
160
161    #[test]
162    fn test_log_masks_sensitive_in_storage() {
163        let a = SqlAuditor::new();
164        a.log(&ctx(
165            "SELECT * FROM users WHERE password='secret'",
166            "admin",
167            1000,
168        ));
169        let logs = a.get_logs();
170        assert_eq!(logs.len(), 1);
171        let stored_sql = &logs[0].sql;
172        assert!(!stored_sql.contains("password"));
173        assert!(!stored_sql.contains("secret"));
174        assert!(stored_sql.contains("******"));
175    }
176
177    #[test]
178    fn test_mask_sensitive_password() {
179        let a = SqlAuditor::new();
180        let masked = a.mask_sensitive("SELECT * FROM users WHERE password='secret'");
181        assert!(!masked.contains("password"));
182        assert!(!masked.contains("secret"));
183        assert!(masked.contains("******"));
184    }
185
186    #[test]
187    fn test_mask_sensitive_case_insensitive() {
188        let a = SqlAuditor::new();
189        let masked = a.mask_sensitive("UPDATE users SET PASSWORD='abc', Token='x'");
190        let lower = masked.to_lowercase();
191        assert!(!lower.contains("password"));
192        assert!(!lower.contains("token"));
193        assert!(masked.contains("******"));
194    }
195
196    #[test]
197    fn test_mask_sensitive_extended_keywords() {
198        let a = SqlAuditor::new();
199        let inputs = [
200            "pwd",
201            "passwd",
202            "secret",
203            "api_key",
204            "access_key",
205            "session",
206            "credit_card",
207            "cvv",
208            "ssn",
209        ];
210        for kw in inputs {
211            let sql = format!("SELECT * FROM t WHERE k = '{}'", kw);
212            let masked = a.mask_sensitive(&sql);
213            let lower = masked.to_lowercase();
214            assert!(
215                !lower.contains(kw),
216                "keyword '{}' should be masked in: {}",
217                kw,
218                masked
219            );
220            assert!(masked.contains("******"));
221        }
222    }
223
224    #[test]
225    fn test_mask_sensitive_preserves_non_sensitive() {
226        let a = SqlAuditor::new();
227        let masked = a.mask_sensitive("SELECT id, name FROM users WHERE active = 1");
228        assert_eq!(masked, "SELECT id, name FROM users WHERE active = 1");
229    }
230
231    #[test]
232    fn test_mask_sensitive_does_not_match_substrings() {
233        // "passworded" should not be partially matched as "password"
234        // because we require word boundaries.
235        let a = SqlAuditor::new();
236        let masked = a.mask_sensitive("SELECT * FROM users WHERE note='passworded'");
237        // The 'passworded' word should remain intact because of boundary check
238        assert!(masked.contains("passworded"));
239        // Ensure we did NOT replace anything (no ****** from this substring)
240        // Actually, "passworded" still has 'password' as a prefix but our
241        // boundary check requires the char AFTER the keyword to be non-ident.
242        // In "passworded", after "password" comes "e" which IS an ident char,
243        // so it should NOT be matched.
244        assert_eq!(masked, "SELECT * FROM users WHERE note='passworded'");
245    }
246
247    #[test]
248    fn test_mask_sensitive_multiple_occurrences() {
249        let a = SqlAuditor::new();
250        let masked = a.mask_sensitive("INSERT INTO t (password, token) VALUES ('p1', 't1')");
251        // Both keywords should be masked
252        let lower = masked.to_lowercase();
253        assert!(!lower.contains("password"));
254        assert!(!lower.contains("token"));
255        // Verify there are at least 2 mask replacements
256        let count = masked.matches("******").count();
257        assert!(count >= 2, "expected at least 2 masks, got: {}", masked);
258    }
259
260    #[test]
261    fn test_get_logs_empty_initially() {
262        let a = SqlAuditor::new();
263        assert!(a.get_logs().is_empty());
264    }
265
266    #[test]
267    fn test_get_logs_returns_snapshot_independent_of_changes() {
268        let a = SqlAuditor::new();
269        a.log(&ctx("SELECT 1", "u", 1));
270        let snap = a.get_logs();
271        a.log(&ctx("SELECT 2", "u", 2));
272        assert_eq!(snap.len(), 1, "snapshot should not change after new log");
273        assert_eq!(a.get_logs().len(), 2);
274    }
275
276    #[test]
277    fn test_flush_writes_json_file() {
278        let a = SqlAuditor::new();
279        a.log(&ctx("SELECT * FROM users WHERE password='p'", "admin", 123));
280        a.log(&ctx("INSERT INTO logs VALUES(1)", "user2", 456));
281        let path = std::env::temp_dir().join("sz_orm_audit_flush_test.json");
282        let path_str = path.to_str().unwrap();
283        let count = a.flush(path_str).expect("flush should succeed");
284        assert_eq!(count, 2);
285        // Read back the file and verify it contains valid JSON
286        let content = std::fs::read_to_string(path_str).expect("file should be readable");
287        let parsed: Vec<SqlAuditContext> =
288            serde_json::from_str(&content).expect("should parse as JSON array");
289        assert_eq!(parsed.len(), 2);
290        assert_eq!(parsed[0].user, "admin");
291        assert_eq!(parsed[1].timestamp, 456);
292        // Verify masking was applied during log()
293        assert!(!parsed[0].sql.contains("password"));
294        // Cleanup
295        let _ = std::fs::remove_file(path_str);
296    }
297
298    #[test]
299    fn test_flush_empty_writes_empty_array() {
300        let a = SqlAuditor::new();
301        let path = std::env::temp_dir().join("sz_orm_audit_flush_empty_test.json");
302        let path_str = path.to_str().unwrap();
303        let count = a.flush(path_str).expect("flush should succeed");
304        assert_eq!(count, 0);
305        let content = std::fs::read_to_string(path_str).expect("file should be readable");
306        assert_eq!(content.trim(), "[]");
307        let _ = std::fs::remove_file(path_str);
308    }
309
310    #[test]
311    fn test_default_creates_new_auditor() {
312        let a = SqlAuditor::default();
313        assert!(a.get_logs().is_empty());
314    }
315
316    #[test]
317    fn test_original_test_compatibility() {
318        // Backward compatibility: the original test asserts that masking
319        // "SELECT * FROM users WHERE password='secret'" removes "password".
320        let a = SqlAuditor::new();
321        let masked = a.mask_sensitive("SELECT * FROM users WHERE password='secret'");
322        assert!(!masked.contains("password"));
323    }
324}