1#![allow(clippy::redundant_pub_crate)]
4
5use std::{
24 fs::OpenOptions,
25 io::Write as _,
26 sync::{Mutex, OnceLock},
27};
28
29fn write_lock() -> &'static Mutex<()> {
31 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
32 LOCK.get_or_init(|| Mutex::new(()))
33}
34
35fn audit_log_path() -> Option<String> {
37 std::env::var("SLOC_AUDIT_LOG")
38 .ok()
39 .filter(|s| !s.trim().is_empty())
40}
41
42fn audit_log_max_bytes() -> u64 {
46 if let Some(bytes) = std::env::var("SLOC_AUDIT_LOG_MAX_BYTES")
47 .ok()
48 .and_then(|v| v.parse::<u64>().ok())
49 {
50 return bytes;
51 }
52 std::env::var("SLOC_AUDIT_LOG_MAX_MB")
53 .ok()
54 .and_then(|v| v.parse::<u64>().ok())
55 .unwrap_or(10)
56 * 1024
57 * 1024
58}
59
60fn audit_log_keep() -> u32 {
62 std::env::var("SLOC_AUDIT_LOG_KEEP")
63 .ok()
64 .and_then(|v| v.parse::<u32>().ok())
65 .unwrap_or(5)
66}
67
68const AUDIT_CHAIN_GENESIS: &str = "genesis";
70
71fn audit_hmac_key() -> Option<String> {
75 std::env::var("SLOC_AUDIT_HMAC_KEY")
76 .ok()
77 .filter(|s| !s.is_empty())
78}
79
80fn chain_last() -> &'static Mutex<Option<String>> {
84 static L: OnceLock<Mutex<Option<String>>> = OnceLock::new();
85 L.get_or_init(|| Mutex::new(None))
86}
87
88fn seed_prev_from_file(path: &str) -> String {
92 let Ok(contents) = std::fs::read_to_string(path) else {
93 return AUDIT_CHAIN_GENESIS.to_owned();
94 };
95 for line in contents.lines().rev() {
96 let line = line.trim();
97 if line.is_empty() {
98 continue;
99 }
100 if let Ok(serde_json::Value::Object(obj)) = serde_json::from_str::<serde_json::Value>(line)
101 && let Some(serde_json::Value::String(mac)) = obj.get("mac")
102 {
103 return mac.clone();
104 }
105 break;
106 }
107 AUDIT_CHAIN_GENESIS.to_owned()
108}
109
110pub(crate) fn record(event: &str, outcome: &str, fields: &[(&str, &str)]) {
119 tracing::info!(
121 target: "audit",
122 event,
123 outcome,
124 fields = ?fields,
125 "security audit event"
126 );
127
128 if let Some(path) = audit_log_path() {
129 append_json_line(&path, event, outcome, fields);
130 }
131}
132
133fn append_json_line(path: &str, event: &str, outcome: &str, fields: &[(&str, &str)]) {
137 let _guard = write_lock()
142 .lock()
143 .unwrap_or_else(std::sync::PoisonError::into_inner);
144
145 let max_bytes = audit_log_max_bytes();
148 if max_bytes > 0
149 && let Err(e) =
150 sloc_core::rotate_log(std::path::Path::new(path), max_bytes, audit_log_keep())
151 {
152 tracing::error!(target: "audit", error = %e, path = %path,
153 "failed to rotate audit log");
154 }
155
156 let mut map = serde_json::Map::with_capacity(fields.len() + 5);
157 map.insert(
158 "ts".to_owned(),
159 serde_json::Value::String(chrono::Utc::now().to_rfc3339()),
160 );
161 map.insert(
162 "event".to_owned(),
163 serde_json::Value::String(event.to_owned()),
164 );
165 map.insert(
166 "outcome".to_owned(),
167 serde_json::Value::String(outcome.to_owned()),
168 );
169 for (k, v) in fields {
170 map.insert((*k).to_owned(), serde_json::Value::String((*v).to_owned()));
171 }
172
173 if let Some(key) = audit_hmac_key() {
179 let mut last = chain_last()
180 .lock()
181 .unwrap_or_else(std::sync::PoisonError::into_inner);
182 if last.is_none() {
183 *last = Some(seed_prev_from_file(path));
184 }
185 let prev = last
186 .clone()
187 .unwrap_or_else(|| AUDIT_CHAIN_GENESIS.to_owned());
188 map.insert("prev".to_owned(), serde_json::Value::String(prev));
189 let Ok(body) = serde_json::to_string(&serde_json::Value::Object(map.clone())) else {
190 return;
191 };
192 let mac = sloc_git::hmac_sha256_hex(key.as_bytes(), body.as_bytes());
193 map.insert("mac".to_owned(), serde_json::Value::String(mac.clone()));
194 *last = Some(mac);
195 }
196
197 let Ok(mut line) = serde_json::to_string(&serde_json::Value::Object(map)) else {
198 return;
199 };
200 line.push('\n');
201
202 match OpenOptions::new().create(true).append(true).open(path) {
203 Ok(mut f) => {
204 if let Err(e) = f.write_all(line.as_bytes()) {
205 tracing::error!(target: "audit", error = %e, path = %path,
206 "failed to write audit log line");
207 }
208 }
209 Err(e) => {
210 tracing::error!(target: "audit", error = %e, path = %path,
211 "failed to open audit log file");
212 }
213 }
214}
215
216#[derive(Debug)]
218pub struct AuditVerifyReport {
219 pub records: usize,
221 pub ok: bool,
223 pub first_bad_line: Option<usize>,
225 pub detail: Option<String>,
227}
228
229fn verify_failure(records: usize, line: usize, msg: &str) -> AuditVerifyReport {
230 AuditVerifyReport {
231 records,
232 ok: false,
233 first_bad_line: Some(line),
234 detail: Some(msg.to_owned()),
235 }
236}
237
238#[must_use]
249pub fn verify_audit_file(path: &std::path::Path, key: &str) -> AuditVerifyReport {
250 let contents = match std::fs::read_to_string(path) {
251 Ok(c) => c,
252 Err(e) => {
253 return AuditVerifyReport {
254 records: 0,
255 ok: false,
256 first_bad_line: None,
257 detail: Some(format!("cannot read log: {e}")),
258 };
259 }
260 };
261 let mut expected_prev = AUDIT_CHAIN_GENESIS.to_owned();
262 let mut records = 0usize;
263 for (idx, raw) in contents.lines().enumerate() {
264 let line = raw.trim();
265 if line.is_empty() {
266 continue;
267 }
268 records += 1;
269 let lineno = idx + 1;
270 let Ok(serde_json::Value::Object(mut obj)) =
271 serde_json::from_str::<serde_json::Value>(line)
272 else {
273 return verify_failure(records, lineno, "line is not a JSON object");
274 };
275 let Some(serde_json::Value::String(stored_mac)) = obj.remove("mac") else {
276 return verify_failure(records, lineno, "record has no `mac` (not a chained log?)");
277 };
278 let Some(serde_json::Value::String(prev)) = obj.get("prev").cloned() else {
279 return verify_failure(records, lineno, "record has no `prev` link");
280 };
281 if prev != expected_prev {
282 return verify_failure(
283 records,
284 lineno,
285 "prev-hash does not match previous record (chain broken)",
286 );
287 }
288 let Ok(body) = serde_json::to_string(&serde_json::Value::Object(obj)) else {
289 return verify_failure(records, lineno, "re-serialisation failed");
290 };
291 let recomputed = sloc_git::hmac_sha256_hex(key.as_bytes(), body.as_bytes());
292 if recomputed != stored_mac {
293 return verify_failure(records, lineno, "MAC mismatch (record was modified)");
294 }
295 expected_prev = stored_mac;
296 }
297 AuditVerifyReport {
298 records,
299 ok: true,
300 first_bad_line: None,
301 detail: None,
302 }
303}
304
305#[cfg(test)]
306mod tests {
307 use super::*;
308
309 #[test]
310 fn record_does_not_panic() {
311 record("unit_test_event", "success", &[("k", "v")]);
315 }
316
317 #[test]
318 fn append_json_line_writes_one_record_per_event() {
319 let dir = std::env::temp_dir().join("sloc_audit_test");
322 let _ = std::fs::create_dir_all(&dir);
323 let path = dir.join(format!("audit-{}.log", uuid::Uuid::new_v4()));
324 let path_str = path.to_string_lossy().into_owned();
325
326 append_json_line(
327 &path_str,
328 "auth_failure",
329 "failure",
330 &[("peer_ip", "10.0.0.9"), ("path", "/analyze")],
331 );
332 append_json_line(
333 &path_str,
334 "auth_success",
335 "success",
336 &[("peer_ip", "10.0.0.9")],
337 );
338
339 let contents = std::fs::read_to_string(&path).expect("audit file written");
340 let lines: Vec<&str> = contents.lines().collect();
341 assert_eq!(lines.len(), 2, "one JSON line per event");
342
343 let first: serde_json::Value =
344 serde_json::from_str(lines[0]).expect("each line is valid JSON");
345 assert_eq!(first["event"], "auth_failure");
346 assert_eq!(first["outcome"], "failure");
347 assert_eq!(first["peer_ip"], "10.0.0.9");
348 assert_eq!(first["path"], "/analyze");
349 assert!(
350 first["ts"].is_string(),
351 "record carries an RFC3339 timestamp"
352 );
353
354 let _ = std::fs::remove_file(&path);
355 }
356
357 fn build_chained_line(
362 key: &str,
363 prev: &str,
364 ts: &str,
365 event: &str,
366 outcome: &str,
367 extra: &[(&str, &str)],
368 ) -> (String, String) {
369 let mut map = serde_json::Map::new();
370 map.insert("ts".to_owned(), serde_json::Value::String(ts.to_owned()));
371 map.insert(
372 "event".to_owned(),
373 serde_json::Value::String(event.to_owned()),
374 );
375 map.insert(
376 "outcome".to_owned(),
377 serde_json::Value::String(outcome.to_owned()),
378 );
379 for (k, v) in extra {
380 map.insert((*k).to_owned(), serde_json::Value::String((*v).to_owned()));
381 }
382 map.insert(
383 "prev".to_owned(),
384 serde_json::Value::String(prev.to_owned()),
385 );
386 let body = serde_json::to_string(&serde_json::Value::Object(map.clone())).unwrap();
387 let mac = sloc_git::hmac_sha256_hex(key.as_bytes(), body.as_bytes());
388 map.insert("mac".to_owned(), serde_json::Value::String(mac.clone()));
389 let line = serde_json::to_string(&serde_json::Value::Object(map)).unwrap();
390 (line, mac)
391 }
392
393 #[test]
394 fn verify_accepts_intact_chain_and_flags_tampering() {
395 let key = "unit-test-audit-key";
396 let (l1, m1) = build_chained_line(
397 key,
398 AUDIT_CHAIN_GENESIS,
399 "2026-07-20T00:00:00+00:00",
400 "login_success",
401 "success",
402 &[("peer_ip", "10.0.0.1")],
403 );
404 let (l2, _m2) = build_chained_line(
405 key,
406 &m1,
407 "2026-07-20T00:00:01+00:00",
408 "auth_failure",
409 "failure",
410 &[("peer_ip", "10.0.0.2")],
411 );
412
413 let dir = std::env::temp_dir().join("sloc_audit_chain_test");
414 let _ = std::fs::create_dir_all(&dir);
415 let path = dir.join(format!("chain-{}.log", uuid::Uuid::new_v4()));
416
417 std::fs::write(&path, format!("{l1}\n{l2}\n")).unwrap();
419 let ok = verify_audit_file(&path, key);
420 assert!(ok.ok, "intact chain must verify: {ok:?}");
421 assert_eq!(ok.records, 2);
422
423 let tampered = format!("{}\n{l2}\n", l1.replace("success", "denied"));
425 std::fs::write(&path, tampered).unwrap();
426 let bad = verify_audit_file(&path, key);
427 assert!(!bad.ok, "tampered record must fail verification");
428 assert_eq!(bad.first_bad_line, Some(1));
429
430 std::fs::write(&path, format!("{l2}\n")).unwrap();
432 let broken = verify_audit_file(&path, key);
433 assert!(!broken.ok, "removed record must break the chain");
434 assert_eq!(broken.first_bad_line, Some(1));
435
436 let _ = std::fs::remove_file(&path);
437 }
438}