Skip to main content

rsigma_runtime/input/
syslog.rs

1//! Syslog RFC 3164 / 5424 input adapter.
2//!
3//! Wraps [`syslog_loose::parse_message`] and extracts structured data, header
4//! fields, and the message body into a [`KvEvent`].
5//!
6//! ## Edge cases handled
7//!
8//! - **Embedded JSON in msg**: if the `msg` field parses as a JSON object,
9//!   the adapter returns a `JsonEvent` with the syslog header fields merged in.
10//! - **Year resolution (RFC 3164)**: timestamps lack a year; defaults to
11//!   current year with December→January rollover logic.
12//! - **Timezone**: RFC 3164 may lack timezone info; configurable default (UTC).
13
14use rsigma_eval::{JsonEvent, KvEvent};
15use syslog_loose::Message;
16
17use super::EventInputDecoded;
18
19/// Configuration for the syslog adapter.
20#[derive(Debug, Clone, Default, PartialEq, Eq)]
21pub struct SyslogConfig {
22    /// Default timezone offset in seconds east of UTC for RFC 3164 messages
23    /// that lack timezone information.
24    pub default_tz_offset_secs: i32,
25}
26
27/// Parse a syslog line into an event.
28///
29/// If the syslog `msg` body contains a valid JSON object, returns a
30/// `JsonEvent` with syslog headers merged in. Otherwise returns a `KvEvent`
31/// with syslog fields as key-value pairs.
32pub fn parse_syslog(line: &str, config: &SyslogConfig) -> EventInputDecoded {
33    let tz = chrono::FixedOffset::east_opt(config.default_tz_offset_secs)
34        .unwrap_or(chrono::FixedOffset::east_opt(0).unwrap());
35
36    let parsed = syslog_loose::parse_message_with_year_tz(
37        line,
38        resolve_year,
39        Some(tz),
40        syslog_loose::Variant::Either,
41    );
42
43    build_event_from_message(&parsed)
44}
45
46/// Build an EventInputDecoded from a parsed syslog message.
47fn build_event_from_message(parsed: &Message<&str>) -> EventInputDecoded {
48    let msg_str = parsed.msg.trim();
49
50    // Try to parse the message body as JSON.
51    if let Ok(mut json_obj) = serde_json::from_str::<serde_json::Value>(msg_str)
52        && let Some(obj) = json_obj.as_object_mut()
53    {
54        inject_syslog_headers(parsed, obj);
55        return EventInputDecoded::Json(JsonEvent::owned(serde_json::Value::Object(obj.clone())));
56    }
57
58    // Not JSON — build a KvEvent from syslog fields.
59    let mut fields = Vec::new();
60
61    if let Some(ts) = &parsed.timestamp {
62        fields.push(("timestamp".to_string(), ts.to_rfc3339()));
63    }
64    if let Some(host) = &parsed.hostname {
65        fields.push(("hostname".to_string(), host.to_string()));
66    }
67    if let Some(app) = &parsed.appname {
68        fields.push(("appname".to_string(), app.to_string()));
69    }
70    if let Some(pid) = &parsed.procid {
71        fields.push(("procid".to_string(), pid.to_string()));
72    }
73    if let Some(mid) = &parsed.msgid {
74        fields.push(("msgid".to_string(), mid.to_string()));
75    }
76    if let Some(facility) = &parsed.facility {
77        fields.push(("facility".to_string(), format!("{facility:?}")));
78    }
79    if let Some(severity) = &parsed.severity {
80        fields.push(("severity".to_string(), format!("{severity:?}")));
81    }
82
83    // Extract RFC 5424 structured data key-value pairs.
84    for elem in &parsed.structured_data {
85        for (key, val) in elem.params() {
86            let prefixed_key = format!("{}.{}", elem.id, key);
87            fields.push((prefixed_key, val));
88        }
89    }
90
91    if !msg_str.is_empty() {
92        fields.push(("_raw".to_string(), msg_str.to_string()));
93    }
94
95    EventInputDecoded::Kv(KvEvent::new(fields))
96}
97
98/// Inject syslog header fields into a JSON object (for embedded-JSON case).
99///
100/// Includes all fields that the KvEvent path extracts: timestamp, hostname,
101/// appname, procid, msgid, facility, severity, and RFC 5424 structured data.
102fn inject_syslog_headers(
103    parsed: &Message<&str>,
104    obj: &mut serde_json::Map<String, serde_json::Value>,
105) {
106    if let Some(ts) = &parsed.timestamp {
107        obj.entry("syslog_timestamp")
108            .or_insert_with(|| serde_json::Value::String(ts.to_rfc3339()));
109    }
110    if let Some(host) = &parsed.hostname {
111        obj.entry("syslog_hostname")
112            .or_insert_with(|| serde_json::Value::String(host.to_string()));
113    }
114    if let Some(app) = &parsed.appname {
115        obj.entry("syslog_appname")
116            .or_insert_with(|| serde_json::Value::String(app.to_string()));
117    }
118    if let Some(pid) = &parsed.procid {
119        obj.entry("syslog_procid")
120            .or_insert_with(|| serde_json::Value::String(pid.to_string()));
121    }
122    if let Some(mid) = &parsed.msgid {
123        obj.entry("syslog_msgid")
124            .or_insert_with(|| serde_json::Value::String(mid.to_string()));
125    }
126    if let Some(facility) = &parsed.facility {
127        obj.entry("syslog_facility")
128            .or_insert_with(|| serde_json::Value::String(format!("{facility:?}")));
129    }
130    if let Some(severity) = &parsed.severity {
131        obj.entry("syslog_severity")
132            .or_insert_with(|| serde_json::Value::String(format!("{severity:?}")));
133    }
134
135    // RFC 5424 structured data parameters.
136    for elem in &parsed.structured_data {
137        for (key, val) in elem.params() {
138            let prefixed_key = format!("sd.{}.{}", elem.id, key);
139            obj.entry(prefixed_key)
140                .or_insert_with(|| serde_json::Value::String(val));
141        }
142    }
143}
144
145/// Year resolver for RFC 3164 timestamps.
146///
147/// `IncompleteDate` is `(month, day, hour, minute, second)`. Uses the current
148/// year, with December→January rollover: if the parsed month is January and
149/// we're in December, assume next year (and vice versa).
150fn resolve_year(date: syslog_loose::IncompleteDate) -> i32 {
151    let now = chrono::Utc::now();
152    let current_year = chrono::Datelike::year(&now);
153    let current_month = chrono::Datelike::month(&now);
154    let parsed_month = date.0;
155
156    if current_month == 12 && parsed_month == 1 {
157        current_year + 1
158    } else if current_month == 1 && parsed_month == 12 {
159        current_year - 1
160    } else {
161        current_year
162    }
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168    use rsigma_eval::Event;
169
170    #[test]
171    fn rfc5424_basic() {
172        let line = "<165>1 2024-01-15T10:30:00.000Z web01 myapp 1234 ID47 - Connection established";
173        let decoded = parse_syslog(line, &SyslogConfig::default());
174        assert!(decoded.get_field("hostname").is_some());
175        assert!(decoded.get_field("appname").is_some());
176        assert!(decoded.get_field("_raw").is_some());
177    }
178
179    #[test]
180    fn rfc3164_basic() {
181        let line = "<34>Oct 11 22:14:15 mymachine su: 'su root' failed for lonvick on /dev/pts/8";
182        let decoded = parse_syslog(line, &SyslogConfig::default());
183        assert!(decoded.any_string_value(&|s| s.contains("su root")));
184    }
185
186    #[test]
187    fn syslog_wrapped_json() {
188        let line = r#"<134>1 2024-01-15T10:30:00Z docker01 myapp 9876 MSGID1 - {"EventID": 1, "user": "admin"}"#;
189        let decoded = parse_syslog(line, &SyslogConfig::default());
190        assert!(decoded.get_field("EventID").is_some());
191        assert!(decoded.get_field("user").is_some());
192        // Syslog headers should be merged into the JSON object.
193        assert!(decoded.get_field("syslog_hostname").is_some());
194        assert!(decoded.get_field("syslog_appname").is_some());
195    }
196
197    #[test]
198    fn rfc5424_structured_data() {
199        let line = r#"<165>1 2024-01-15T10:30:00Z host app - ID1 [exampleSDID@32473 iut="3" eventSource="App" eventID="1011"] message"#;
200        let decoded = parse_syslog(line, &SyslogConfig::default());
201        let json = decoded.to_json();
202        let json_str = serde_json::to_string(&json).unwrap();
203        assert!(json_str.contains("eventSource") || json_str.contains("_raw"));
204    }
205
206    #[test]
207    fn empty_msg() {
208        let line = "<13>1 2024-01-15T10:30:00Z host app - - -";
209        let decoded = parse_syslog(line, &SyslogConfig::default());
210        assert!(decoded.get_field("hostname").is_some());
211    }
212
213    #[test]
214    fn custom_timezone() {
215        let config = SyslogConfig {
216            default_tz_offset_secs: 5 * 3600, // UTC+5
217        };
218        let line = "<34>Oct 11 22:14:15 mymachine su: test message";
219        let decoded = parse_syslog(line, &config);
220        assert!(decoded.any_string_value(&|s| s.contains("test message")));
221    }
222}