1use 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
21const 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 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 pub fn get_logs(&self) -> Vec<SqlAuditContext> {
66 let logs = self.logs.lock().unwrap();
67 logs.iter().cloned().collect()
68 }
69
70 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 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
93fn 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 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 let ch = sql[i..].chars().next().unwrap();
124 result.push(ch);
125 i += ch.len_utf8();
126 }
127 }
128 result
129}
130
131fn 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 let a = SqlAuditor::new();
236 let masked = a.mask_sensitive("SELECT * FROM users WHERE note='passworded'");
237 assert!(masked.contains("passworded"));
239 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 let lower = masked.to_lowercase();
253 assert!(!lower.contains("password"));
254 assert!(!lower.contains("token"));
255 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 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 assert!(!parsed[0].sql.contains("password"));
294 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 let a = SqlAuditor::new();
321 let masked = a.mask_sensitive("SELECT * FROM users WHERE password='secret'");
322 assert!(!masked.contains("password"));
323 }
324}