logcheck_fluent_bit_filter/
lib.rs

1pub mod cli;
2pub mod regex_conversion;
3pub mod rules;
4
5#[cfg(test)]
6mod external_test;
7
8#[cfg(test)]
9mod production_test;
10
11use once_cell::sync::Lazy;
12use rules::{LogcheckDatabase, RuleCategory};
13use serde::{Deserialize, Serialize};
14use std::ffi::CString;
15use std::os::raw::c_char;
16
17/// Log entry structure matching the Python LogEntry model
18#[derive(Debug, Deserialize, Serialize)]
19struct LogEntry {
20    #[serde(rename = "@timestamp")]
21    timestamp: String,
22    #[serde(rename = "MESSAGE")]
23    message: String,
24    #[serde(rename = "PRIORITY")]
25    priority: Option<String>,
26    #[serde(rename = "SYSTEMD_UNIT")]
27    systemd_unit: Option<String>,
28    #[serde(rename = "PID")]
29    pid: Option<String>,
30    #[serde(rename = "HOSTNAME")]
31    hostname: Option<String>,
32    #[serde(rename = "SYSLOG_IDENTIFIER")]
33    syslog_identifier: Option<String>,
34}
35
36/// Initialize logcheck database with embedded rules
37/// In production, this would load from /etc/logcheck/ or similar
38static LOGCHECK_DB: Lazy<LogcheckDatabase> = Lazy::new(|| {
39    let mut db = LogcheckDatabase::new();
40
41    // Add some basic embedded rules for demonstration
42    // In production, you'd call db.load_from_directory("/etc/logcheck")
43
44    // Security violation rules
45    let _ = db
46        .violations_rules
47        .add_pattern("^.*authentication failure.*$".to_string());
48    let _ = db
49        .violations_rules
50        .add_pattern("^.*[Ff]ailed password.*$".to_string());
51    let _ = db
52        .violations_rules
53        .add_pattern("^.*sudo.*authentication failure.*$".to_string());
54    let _ = db
55        .violations_rules
56        .add_pattern("^.*segfault|segmentation fault.*$".to_string());
57    let _ = db
58        .violations_rules
59        .add_pattern("^.*Out of memory|OOM.*$".to_string());
60
61    // System events to ignore (normal operations)
62    let _ = db
63        .server
64        .add_pattern("^.*Started Session \\d+ of user.*$".to_string());
65    let _ = db
66        .server
67        .add_pattern("^.*Stopped Session \\d+ of user.*$".to_string());
68    let _ = db
69        .server
70        .add_pattern("^.*systemd-logind.*: New session.*of user.*$".to_string());
71    let _ = db
72        .server
73        .add_pattern("^.*systemd-logind.*: Session.*logged out.*$".to_string());
74    let _ = db
75        .server
76        .add_pattern("^.*sshd.*: Accepted (password|publickey) for.*$".to_string());
77    let _ = db
78        .server
79        .add_pattern("^.*sshd.*: Connection closed by.*$".to_string());
80
81    // Compile all rule sets
82    let _ = db.compile_all();
83
84    db
85});
86
87/// Apply logcheck rules to a log entry using the modern rule database
88fn apply_logcheck_rules(log_entry: &LogEntry) -> Option<RuleCategory> {
89    LOGCHECK_DB.match_message(&log_entry.message)
90}
91
92/// Create a filtered log entry with logcheck metadata
93fn create_filtered_entry(log_entry: &LogEntry, matched_category: Option<RuleCategory>) -> String {
94    let mut filtered_entry = serde_json::to_value(log_entry).unwrap();
95
96    if let Some(category) = matched_category {
97        let (rule_type, description) = match category {
98            RuleCategory::Cracking => ("cracking", "Active intrusion attempt detected"),
99            RuleCategory::CrackingIgnore => (
100                "cracking_ignore",
101                "Known false positive for cracking detection",
102            ),
103            RuleCategory::Violations => {
104                ("violations", "Security violation or critical system event")
105            }
106            RuleCategory::ViolationsIgnore => (
107                "violations_ignore",
108                "Known false positive for security violation",
109            ),
110            RuleCategory::SystemEvents => ("ignore", "Normal system event"),
111            RuleCategory::Workstation => ("ignore", "Normal workstation event"),
112            RuleCategory::Server => ("ignore", "Normal server event"),
113            RuleCategory::Local => ("ignore", "Local custom rule match"),
114        };
115
116        filtered_entry["logcheck_rule_type"] = serde_json::Value::String(rule_type.to_string());
117        filtered_entry["logcheck_description"] = serde_json::Value::String(description.to_string());
118        filtered_entry["logcheck_matched"] = serde_json::Value::Bool(true);
119
120        // Add category for more detailed classification
121        filtered_entry["logcheck_category"] = serde_json::Value::String(format!("{:?}", category));
122    } else {
123        filtered_entry["logcheck_matched"] = serde_json::Value::Bool(false);
124        filtered_entry["logcheck_rule_type"] =
125            serde_json::Value::String("unclassified".to_string());
126        filtered_entry["logcheck_description"] =
127            serde_json::Value::String("No matching logcheck rule found".to_string());
128    }
129
130    serde_json::to_string(&filtered_entry).unwrap_or_else(|_| "{}".to_string())
131}
132
133/// Parse input record as JSON and extract log entry
134fn parse_log_entry(record: *const c_char, record_len: usize) -> Option<LogEntry> {
135    if record.is_null() || record_len == 0 {
136        return None;
137    }
138
139    let record_slice = unsafe { std::slice::from_raw_parts(record as *const u8, record_len) };
140    let record_str = std::str::from_utf8(record_slice).ok()?;
141
142    serde_json::from_str::<LogEntry>(record_str).ok()
143}
144
145/// Convert Rust string to leaked C string for Fluent-Bit
146fn to_c_string(s: String) -> *const c_char {
147    let boxed = CString::new(s)
148        .unwrap_or_else(|_| CString::new("{}").unwrap())
149        .into_boxed_c_str();
150    Box::leak(boxed).as_ptr()
151}
152
153/// Main logcheck filter function for JSON format
154/// Returns: Modified JSON record with logcheck metadata, or NULL to drop the record
155#[unsafe(no_mangle)]
156pub extern "C" fn logcheck_filter_json(
157    _tag: *const c_char,
158    _tag_len: usize,
159    _time_sec: u32,
160    _time_nsec: u32,
161    record: *const c_char,
162    record_len: usize,
163) -> *const c_char {
164    // Parse the incoming log entry
165    let log_entry = match parse_log_entry(record, record_len) {
166        Some(entry) => entry,
167        None => {
168            // Return original record if parsing fails
169            let record_slice =
170                unsafe { std::slice::from_raw_parts(record as *const u8, record_len) };
171            let record_str = std::str::from_utf8(record_slice).unwrap_or("{}");
172            return to_c_string(record_str.to_string());
173        }
174    };
175
176    // Apply logcheck rules
177    let matched_category = apply_logcheck_rules(&log_entry);
178
179    // For now, let all messages through but add metadata
180    // In a production setup, you might want to drop "ignore" messages entirely
181    let filtered_json = create_filtered_entry(&log_entry, matched_category);
182
183    to_c_string(filtered_json)
184}
185
186/// Test/demo function that adds simple metadata
187#[unsafe(no_mangle)]
188pub extern "C" fn logcheck_demo_json(
189    _tag: *const c_char,
190    _tag_len: usize,
191    time_sec: u32,
192    _time_nsec: u32,
193    _record: *const c_char,
194    _record_len: usize,
195) -> *const c_char {
196    let demo_msg = format!(
197        r#"{{"logcheck_demo":"Hello from Rust WASM logcheck filter!","timestamp":{},"processed":true}}"#,
198        time_sec
199    );
200
201    to_c_string(demo_msg)
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207
208    #[test]
209    fn test_security_violation_matching() {
210        let log_entry = LogEntry {
211            timestamp: "2024-01-01T12:00:00Z".to_string(),
212            message: "sshd[1234]: Failed password for root from 192.168.1.1 port 22".to_string(),
213            priority: Some("4".to_string()),
214            systemd_unit: Some("ssh.service".to_string()),
215            pid: Some("1234".to_string()),
216            hostname: Some("testhost".to_string()),
217            syslog_identifier: Some("sshd".to_string()),
218        };
219
220        let matched_category = apply_logcheck_rules(&log_entry);
221        assert_eq!(matched_category, Some(RuleCategory::Violations));
222    }
223
224    #[test]
225    fn test_system_event_matching() {
226        let log_entry = LogEntry {
227            timestamp: "2024-01-01T12:00:00Z".to_string(),
228            message: "Started Session 123 of user alice.".to_string(),
229            priority: Some("6".to_string()),
230            systemd_unit: Some("systemd-logind.service".to_string()),
231            pid: Some("1".to_string()),
232            hostname: Some("testhost".to_string()),
233            syslog_identifier: Some("systemd-logind".to_string()),
234        };
235
236        let matched_category = apply_logcheck_rules(&log_entry);
237        assert_eq!(matched_category, Some(RuleCategory::SystemEvents));
238    }
239
240    #[test]
241    fn test_no_match() {
242        let log_entry = LogEntry {
243            timestamp: "2024-01-01T12:00:00Z".to_string(),
244            message: "Some random application message".to_string(),
245            priority: Some("6".to_string()),
246            systemd_unit: Some("myapp.service".to_string()),
247            pid: Some("9999".to_string()),
248            hostname: Some("testhost".to_string()),
249            syslog_identifier: Some("myapp".to_string()),
250        };
251
252        let matched_category = apply_logcheck_rules(&log_entry);
253        assert_eq!(matched_category, None);
254    }
255
256    #[test]
257    fn test_rule_database_stats() {
258        let stats = LOGCHECK_DB.get_stats();
259
260        // Should have some rules loaded
261        assert!(stats["violations_rules"] > 0);
262        assert!(stats["server"] > 0);
263        assert!(stats["total_rules"] > 0);
264
265        println!("Rule database stats: {:?}", stats);
266    }
267}