From 2122101f35a31d6cb3bdc0bd5a8f7003ccdcd92c Mon Sep 17 00:00:00 2001 From: Tanishka A Date: Thu, 17 Sep 2026 04:50:28 +0000 Subject: [PATCH 1/2] Make syslog enterprise_number configurable (fixes #218) --- crates/sysknife-core/src/config.rs | 74 ++++++++++++++++++ crates/sysknife-daemon/src/audit_forward.rs | 84 ++++++++++++++------- crates/sysknife-daemon/src/main.rs | 4 + crates/sysknife-daemon/src/state.rs | 1 + 4 files changed, 137 insertions(+), 26 deletions(-) diff --git a/crates/sysknife-core/src/config.rs b/crates/sysknife-core/src/config.rs index dd51a937..af5b0ed8 100644 --- a/crates/sysknife-core/src/config.rs +++ b/crates/sysknife-core/src/config.rs @@ -202,6 +202,7 @@ pub struct AuditForwardSection { /// [audit.forward.syslog] /// host = "siem.internal:514" /// facility = 1 # 1 = user-level (default) +/// enterprise_number = 99999 # your own IANA PEN; defaults to the doc/test PEN /// ``` #[derive(Debug, Deserialize)] pub struct SyslogForwardSection { @@ -210,12 +211,53 @@ pub struct SyslogForwardSection { /// Syslog facility number (default 1 = user-level messages). #[serde(default = "default_syslog_facility")] pub facility: u8, + /// IANA Private Enterprise Number used to build the syslog SD-ID + /// (`sysknife@`, see RFC 5424 §7.2.2). + /// + /// Defaults to [`DOCUMENTATION_PEN`], RFC 5612's reserved + /// documentation/test PEN — see the warning comment on + /// `format_rfc5424` in `sysknife-daemon::audit_forward`. Operators + /// forwarding into a regulated SIEM should set this to their own + /// assigned PEN; see [`SyslogForwardSection::validate`]. + #[serde(default = "default_enterprise_number")] + pub enterprise_number: u32, } fn default_syslog_facility() -> u8 { 1 } +/// RFC 5612's reserved documentation/test Private Enterprise Number. Used +/// as [`SyslogForwardSection::enterprise_number`]'s default so the syslog +/// forwarder keeps working out of the box while still carrying the +/// "not a real PEN" signal until an operator configures one. +pub const DOCUMENTATION_PEN: u32 = 32473; + +fn default_enterprise_number() -> u32 { + DOCUMENTATION_PEN +} + +impl SyslogForwardSection { + /// Reject an `enterprise_number` of `0`, which is not a valid IANA PEN + /// (the registry's `enterprise-numbers` file starts numbering at 1). + /// Called at config-parse time, alongside any `facility` validation. + /// + /// Every other `u32` value is accepted: IANA PENs are allocated + /// sequentially with no reserved upper gap, so any nonzero `u32` is a + /// syntactically valid PEN even if it has not actually been assigned to + /// this operator. + pub fn validate(&self) -> Result<(), String> { + if self.enterprise_number == 0 { + return Err( + "[audit.forward.syslog] enterprise_number must be a nonzero IANA \ + Private Enterprise Number" + .to_string(), + ); + } + Ok(()) + } +} + /// `[policy]` section. Currently holds per-action risk-level overrides. /// /// See [`PolicySection::risk_overrides`] for semantics. Absent → no overrides @@ -842,6 +884,38 @@ model = "qwen3:8b" ); } + #[test] + fn syslog_forward_section_default_enterprise_number_is_documentation_pen() { + let s = SyslogForwardSection { + host: "siem.internal:514".to_string(), + facility: default_syslog_facility(), + enterprise_number: default_enterprise_number(), + }; + assert_eq!(s.enterprise_number, DOCUMENTATION_PEN); + assert!(s.validate().is_ok()); + } + + #[test] + fn syslog_forward_section_accepts_custom_enterprise_number() { + let s = SyslogForwardSection { + host: "siem.internal:514".to_string(), + facility: default_syslog_facility(), + enterprise_number: 99999, + }; + assert!(s.validate().is_ok()); + } + + #[test] + fn syslog_forward_section_rejects_zero_enterprise_number() { + let s = SyslogForwardSection { + host: "siem.internal:514".to_string(), + facility: default_syslog_facility(), + enterprise_number: 0, + }; + let err = s.validate().unwrap_err(); + assert!(err.contains("enterprise_number"), "got: {err}"); + } + use std::sync::Mutex; static ENV_LOCK: Mutex<()> = Mutex::new(()); } diff --git a/crates/sysknife-daemon/src/audit_forward.rs b/crates/sysknife-daemon/src/audit_forward.rs index 982a6502..b9f9ebf4 100644 --- a/crates/sysknife-daemon/src/audit_forward.rs +++ b/crates/sysknife-daemon/src/audit_forward.rs @@ -60,9 +60,19 @@ pub enum AuditSinkSpec { host: SocketAddr, /// Syslog facility (default `1` = user-level messages). facility: u8, + /// IANA Private Enterprise Number for the `sysknife@` SD-ID. + /// Defaults to [`DOCUMENTATION_PEN`] at the config layer — see + /// `sysknife_core::config::SyslogForwardSection::enterprise_number`. + enterprise_number: u32, }, } +/// RFC 5612's reserved documentation/test Private Enterprise Number. Kept +/// here (mirroring `sysknife_core::config::DOCUMENTATION_PEN`) so this +/// crate's tests and doc examples don't need a cross-crate import just to +/// name the default PEN. +pub const DOCUMENTATION_PEN: u32 = 32473; + /// One audit event handed to the forwarder. Mirrors the chain content /// captured at INSERT time so SIEM rules can correlate by `transaction_id` /// and `request_hash` against the local hash-chained log. @@ -169,7 +179,11 @@ pub fn spawn(spec: AuditSinkSpec) -> AuditForwarder { async fn forwarder_task(spec: AuditSinkSpec, mut rx: mpsc::Receiver) { match spec { - AuditSinkSpec::SyslogUdp { host, facility } => { + AuditSinkSpec::SyslogUdp { + host, + facility, + enterprise_number, + } => { let mut socket = open_udp(host).await; // Exponential backoff on consecutive bind failures so a transient // outage does not produce a tight retry loop that pegs a CPU. @@ -178,7 +192,7 @@ async fn forwarder_task(spec: AuditSinkSpec, mut rx: mpsc::Receiver) // to `INITIAL_BACKOFF_SECS` on the first successful bind. let mut backoff_secs: u64 = INITIAL_BACKOFF_SECS; while let Some(event) = rx.recv().await { - let frame = format_rfc5424(&event, facility); + let frame = format_rfc5424(&event, facility, enterprise_number); // **Event-drop semantics:** if the socket is `None` (bind has // never succeeded, or a previous send failed and we have not // yet rebound) the formatted frame is computed and then @@ -268,12 +282,12 @@ async fn open_udp(host: SocketAddr) -> Option { /// /// Layout (one line, no trailing newline — UDP datagrams don't need one): /// ```text -/// 1 TIMESTAMP HOSTNAME APP-NAME PROCID MSGID [SD@32473 ...] MSG +/// 1 TIMESTAMP HOSTNAME APP-NAME PROCID MSGID [SD@ ...] MSG /// ``` /// /// We hold to the spec's printable-USASCII rule for the structured-data /// (SD) section by escaping `]`, `"`, and `\` per §6.3.3. -pub fn format_rfc5424(event: &AuditEvent, facility: u8) -> String { +pub fn format_rfc5424(event: &AuditEvent, facility: u8, enterprise_number: u32) -> String { // Severity 5 = NOTICE for audit events. PRI = facility * 8 + severity. let severity = 5u8; let pri = (facility as u32) * 8 + severity as u32; @@ -291,14 +305,16 @@ pub fn format_rfc5424(event: &AuditEvent, facility: u8) -> String { // RFC 5424 §6.3 structured data. // - // **PEN 32473 is RFC 5612's documentation/test PEN — it MUST be replaced - // before production deployment.** RFC 5424 §7.2.2 requires an - // IANA-assigned Private Enterprise Number on `SD-ID`s emitted in - // production frames. The SysKnife project does not yet hold a PEN; the - // 32473 reservation is used here only so the formatter is bytewise - // testable. Operators running a SIEM under a regulated framework should - // either patch `SD@32473` to their own PEN at build time or treat this - // as a known gap (see issue tracker). + // `enterprise_number` defaults to [`DOCUMENTATION_PEN`] — RFC 5612's + // documentation/test PEN — at the config layer + // (`sysknife_core::config::SyslogForwardSection::enterprise_number`). + // **That default MUST be replaced before production deployment.** RFC + // 5424 §7.2.2 requires an IANA-assigned Private Enterprise Number on + // `SD-ID`s emitted in production frames. The SysKnife project does not + // yet hold its own PEN, so the documentation PEN remains the built-in + // default; operators running a SIEM under a regulated framework should + // set `[audit.forward.syslog].enterprise_number` in config to their own + // assigned PEN rather than patching this format string. // // `terminal_status` is appended ONLY for the execute-time forward, so // preview frames remain byte-for-byte unchanged. @@ -307,7 +323,7 @@ pub fn format_rfc5424(event: &AuditEvent, facility: u8) -> String { None => String::new(), }; let sd = format!( - "[sysknife@32473 \ + "[sysknife@{enterprise_number} \ seq=\"{}\" \ tx=\"{}\" \ action=\"{}\" \ @@ -472,14 +488,14 @@ mod tests { #[test] fn rfc5424_starts_with_pri_and_version() { - let frame = format_rfc5424(&sample_event(), 1); + let frame = format_rfc5424(&sample_event(), 1, DOCUMENTATION_PEN); // facility=1, severity=5 → PRI = 13. Version = 1. assert!(frame.starts_with("<13>1 ")); } #[test] fn rfc5424_contains_sd_with_chain_hash_and_seq() { - let frame = format_rfc5424(&sample_event(), 1); + let frame = format_rfc5424(&sample_event(), 1, DOCUMENTATION_PEN); assert!(frame.contains("[sysknife@32473")); assert!(frame.contains("seq=\"42\"")); assert!(frame.contains("chain_hash=\"deadbeef\"")); @@ -488,9 +504,18 @@ mod tests { assert!(frame.contains("role=\"Dev\"")); } + #[test] + fn rfc5424_uses_configured_enterprise_number() { + // Pins the substitution actually happens for a non-default PEN, + // rather than just leaving the default path unexercised. + let frame = format_rfc5424(&sample_event(), 1, 99999); + assert!(frame.contains("[sysknife@99999")); + assert!(!frame.contains("[sysknife@32473")); + } + #[test] fn rfc5424_message_section_contains_summary_and_action_tag() { - let frame = format_rfc5424(&sample_event(), 1); + let frame = format_rfc5424(&sample_event(), 1, DOCUMENTATION_PEN); assert!(frame.ends_with("[InstallFlatpak] Install Firefox")); } @@ -515,7 +540,7 @@ mod tests { fn rfc5424_missing_approval_renders_empty_string() { let mut e = sample_event(); e.approval_id = None; - let frame = format_rfc5424(&e, 1); + let frame = format_rfc5424(&e, 1, DOCUMENTATION_PEN); assert!(frame.contains("approval=\"\"")); } @@ -523,14 +548,14 @@ mod tests { fn rfc5424_caller_role_with_quote_is_escaped() { let mut e = sample_event(); e.caller_role = Some(r#"Dev"name"#.to_string()); - let frame = format_rfc5424(&e, 1); + let frame = format_rfc5424(&e, 1, DOCUMENTATION_PEN); assert!(frame.contains("role=\"Dev\\\"name\"")); } #[test] fn rfc5424_facility_changes_pri() { // facility=23 (local7), severity=5 → PRI = 189. - let frame = format_rfc5424(&sample_event(), 23); + let frame = format_rfc5424(&sample_event(), 23, DOCUMENTATION_PEN); assert!(frame.starts_with("<189>1 ")); } @@ -542,7 +567,7 @@ mod tests { // joining against a second log source. let mut e = sample_event(); e.final_status = Some("succeeded".to_string()); - let frame = format_rfc5424(&e, 1); + let frame = format_rfc5424(&e, 1, DOCUMENTATION_PEN); assert!( frame.contains("terminal_status=\"succeeded\""), "frame missing terminal_status: {frame}" @@ -611,7 +636,11 @@ mod tests { let port = listener.local_addr().unwrap().port(); let host: SocketAddr = format!("127.0.0.1:{port}").parse().unwrap(); - let forwarder = spawn(AuditSinkSpec::SyslogUdp { host, facility: 1 }); + let forwarder = spawn(AuditSinkSpec::SyslogUdp { + host, + facility: 1, + enterprise_number: DOCUMENTATION_PEN, + }); forwarder.submit(sample_event()); // Receive with a generous timeout. @@ -689,7 +718,8 @@ mod tests { fn rfc5424_round_trip_through_syslog_loose() { let event = sample_event(); let facility = 1u8; // user-level - let frame = format_rfc5424(&event, facility); + let enterprise_number = 55512u32; // non-default, to prove the config knob round-trips + let frame = format_rfc5424(&event, facility, enterprise_number); let parsed = syslog_loose::parse_message(&frame, syslog_loose::Variant::RFC5424); @@ -713,13 +743,15 @@ mod tests { assert_eq!(parsed.msgid, Some("AUDIT")); assert_eq!(parsed.appname, Some("sysknife-daemon")); - // Structured-data block: one SD element with id "sysknife@32473" - // and the audit fields as params. + // Structured-data block: one SD element whose id carries the + // configured enterprise_number (not the documentation-PEN default), + // pinning that the config knob actually reaches the wire. + let expected_sd_id = format!("sysknife@{enterprise_number}"); let sd = parsed .structured_data .iter() - .find(|s| s.id == "sysknife@32473") - .expect("frame has the sysknife@32473 SD element"); + .find(|s| s.id == expected_sd_id) + .unwrap_or_else(|| panic!("frame has the {expected_sd_id} SD element: {frame}")); let get = |key: &str| -> String { sd.params diff --git a/crates/sysknife-daemon/src/main.rs b/crates/sysknife-daemon/src/main.rs index c12e5e68..ff0ab3fb 100644 --- a/crates/sysknife-daemon/src/main.rs +++ b/crates/sysknife-daemon/src/main.rs @@ -429,6 +429,9 @@ fn build_forwarder( let Some(syslog) = forward.syslog.as_ref() else { return Ok(None); }; + syslog + .validate() + .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e))?; let host: std::net::SocketAddr = syslog.host.parse().map_err(|e| { std::io::Error::new( std::io::ErrorKind::InvalidInput, @@ -441,6 +444,7 @@ fn build_forwarder( Ok(Some(audit_forward::spawn(AuditSinkSpec::SyslogUdp { host, facility: syslog.facility, + enterprise_number: syslog.enterprise_number, }))) } diff --git a/crates/sysknife-daemon/src/state.rs b/crates/sysknife-daemon/src/state.rs index 9dc64834..a1132e94 100644 --- a/crates/sysknife-daemon/src/state.rs +++ b/crates/sysknife-daemon/src/state.rs @@ -215,6 +215,7 @@ mod tests { crate::audit_forward::spawn(crate::audit_forward::AuditSinkSpec::SyslogUdp { host: "127.0.0.1:65000".parse().unwrap(), facility: 1, + enterprise_number: crate::audit_forward::DOCUMENTATION_PEN, }) }); let state = DaemonState::open_full(cfg2, PolicyTable::empty(), Some(forwarder)) From ad2e7ea87b0fc48dd18937b5d57316ab0ca79ff6 Mon Sep 17 00:00:00 2001 From: Tanishka A Date: Thu, 17 Sep 2026 05:06:10 +0000 Subject: [PATCH 2/2] fix formatting --- crates/sysknife-daemon/src/main.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/sysknife-daemon/src/main.rs b/crates/sysknife-daemon/src/main.rs index ff0ab3fb..1e37ceb9 100644 --- a/crates/sysknife-daemon/src/main.rs +++ b/crates/sysknife-daemon/src/main.rs @@ -429,7 +429,7 @@ fn build_forwarder( let Some(syslog) = forward.syslog.as_ref() else { return Ok(None); }; - syslog + syslog .validate() .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e))?; let host: std::net::SocketAddr = syslog.host.parse().map_err(|e| {