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 {
102 if let Some(serde_json::Value::String(mac)) = obj.get("mac") {
103 return mac.clone();
104 }
105 }
106 break;
107 }
108 AUDIT_CHAIN_GENESIS.to_owned()
109}
110
111pub(crate) fn record(event: &str, outcome: &str, fields: &[(&str, &str)]) {
120 tracing::info!(
122 target: "audit",
123 event,
124 outcome,
125 fields = ?fields,
126 "security audit event"
127 );
128
129 if let Some(path) = audit_log_path() {
130 append_json_line(&path, event, outcome, fields);
131 }
132}
133
134fn append_json_line(path: &str, event: &str, outcome: &str, fields: &[(&str, &str)]) {
138 let _guard = write_lock()
143 .lock()
144 .unwrap_or_else(std::sync::PoisonError::into_inner);
145
146 let max_bytes = audit_log_max_bytes();
149 if max_bytes > 0 {
150 if let Err(e) =
151 sloc_core::rotate_log(std::path::Path::new(path), max_bytes, audit_log_keep())
152 {
153 tracing::error!(target: "audit", error = %e, path = %path,
154 "failed to rotate audit log");
155 }
156 }
157
158 let mut map = serde_json::Map::with_capacity(fields.len() + 5);
159 map.insert(
160 "ts".to_owned(),
161 serde_json::Value::String(chrono::Utc::now().to_rfc3339()),
162 );
163 map.insert(
164 "event".to_owned(),
165 serde_json::Value::String(event.to_owned()),
166 );
167 map.insert(
168 "outcome".to_owned(),
169 serde_json::Value::String(outcome.to_owned()),
170 );
171 for (k, v) in fields {
172 map.insert((*k).to_owned(), serde_json::Value::String((*v).to_owned()));
173 }
174
175 if let Some(key) = audit_hmac_key() {
181 let mut last = chain_last()
182 .lock()
183 .unwrap_or_else(std::sync::PoisonError::into_inner);
184 if last.is_none() {
185 *last = Some(seed_prev_from_file(path));
186 }
187 let prev = last
188 .clone()
189 .unwrap_or_else(|| AUDIT_CHAIN_GENESIS.to_owned());
190 map.insert("prev".to_owned(), serde_json::Value::String(prev));
191 let Ok(body) = serde_json::to_string(&serde_json::Value::Object(map.clone())) else {
192 return;
193 };
194 let mac = sloc_git::hmac_sha256_hex(key.as_bytes(), body.as_bytes());
195 map.insert("mac".to_owned(), serde_json::Value::String(mac.clone()));
196 *last = Some(mac);
197 }
198
199 let Ok(mut line) = serde_json::to_string(&serde_json::Value::Object(map)) else {
200 return;
201 };
202 line.push('\n');
203
204 match OpenOptions::new().create(true).append(true).open(path) {
205 Ok(mut f) => {
206 if let Err(e) = f.write_all(line.as_bytes()) {
207 tracing::error!(target: "audit", error = %e, path = %path,
208 "failed to write audit log line");
209 }
210 }
211 Err(e) => {
212 tracing::error!(target: "audit", error = %e, path = %path,
213 "failed to open audit log file");
214 }
215 }
216}
217
218#[derive(Debug)]
220pub struct AuditVerifyReport {
221 pub records: usize,
223 pub ok: bool,
225 pub first_bad_line: Option<usize>,
227 pub detail: Option<String>,
229}
230
231fn verify_failure(records: usize, line: usize, msg: &str) -> AuditVerifyReport {
232 AuditVerifyReport {
233 records,
234 ok: false,
235 first_bad_line: Some(line),
236 detail: Some(msg.to_owned()),
237 }
238}
239
240#[must_use]
251pub fn verify_audit_file(path: &std::path::Path, key: &str) -> AuditVerifyReport {
252 let contents = match std::fs::read_to_string(path) {
253 Ok(c) => c,
254 Err(e) => {
255 return AuditVerifyReport {
256 records: 0,
257 ok: false,
258 first_bad_line: None,
259 detail: Some(format!("cannot read log: {e}")),
260 }
261 }
262 };
263 let mut expected_prev = AUDIT_CHAIN_GENESIS.to_owned();
264 let mut records = 0usize;
265 for (idx, raw) in contents.lines().enumerate() {
266 let line = raw.trim();
267 if line.is_empty() {
268 continue;
269 }
270 records += 1;
271 let lineno = idx + 1;
272 let Ok(serde_json::Value::Object(mut obj)) =
273 serde_json::from_str::<serde_json::Value>(line)
274 else {
275 return verify_failure(records, lineno, "line is not a JSON object");
276 };
277 let Some(serde_json::Value::String(stored_mac)) = obj.remove("mac") else {
278 return verify_failure(records, lineno, "record has no `mac` (not a chained log?)");
279 };
280 let Some(serde_json::Value::String(prev)) = obj.get("prev").cloned() else {
281 return verify_failure(records, lineno, "record has no `prev` link");
282 };
283 if prev != expected_prev {
284 return verify_failure(
285 records,
286 lineno,
287 "prev-hash does not match previous record (chain broken)",
288 );
289 }
290 let Ok(body) = serde_json::to_string(&serde_json::Value::Object(obj)) else {
291 return verify_failure(records, lineno, "re-serialisation failed");
292 };
293 let recomputed = sloc_git::hmac_sha256_hex(key.as_bytes(), body.as_bytes());
294 if recomputed != stored_mac {
295 return verify_failure(records, lineno, "MAC mismatch (record was modified)");
296 }
297 expected_prev = stored_mac;
298 }
299 AuditVerifyReport {
300 records,
301 ok: true,
302 first_bad_line: None,
303 detail: None,
304 }
305}
306
307#[cfg(test)]
308mod tests {
309 use super::*;
310
311 #[test]
312 fn record_does_not_panic() {
313 record("unit_test_event", "success", &[("k", "v")]);
317 }
318
319 #[test]
320 fn append_json_line_writes_one_record_per_event() {
321 let dir = std::env::temp_dir().join("sloc_audit_test");
324 let _ = std::fs::create_dir_all(&dir);
325 let path = dir.join(format!("audit-{}.log", uuid::Uuid::new_v4()));
326 let path_str = path.to_string_lossy().into_owned();
327
328 append_json_line(
329 &path_str,
330 "auth_failure",
331 "failure",
332 &[("peer_ip", "10.0.0.9"), ("path", "/analyze")],
333 );
334 append_json_line(
335 &path_str,
336 "auth_success",
337 "success",
338 &[("peer_ip", "10.0.0.9")],
339 );
340
341 let contents = std::fs::read_to_string(&path).expect("audit file written");
342 let lines: Vec<&str> = contents.lines().collect();
343 assert_eq!(lines.len(), 2, "one JSON line per event");
344
345 let first: serde_json::Value =
346 serde_json::from_str(lines[0]).expect("each line is valid JSON");
347 assert_eq!(first["event"], "auth_failure");
348 assert_eq!(first["outcome"], "failure");
349 assert_eq!(first["peer_ip"], "10.0.0.9");
350 assert_eq!(first["path"], "/analyze");
351 assert!(
352 first["ts"].is_string(),
353 "record carries an RFC3339 timestamp"
354 );
355
356 let _ = std::fs::remove_file(&path);
357 }
358
359 fn build_chained_line(
364 key: &str,
365 prev: &str,
366 ts: &str,
367 event: &str,
368 outcome: &str,
369 extra: &[(&str, &str)],
370 ) -> (String, String) {
371 let mut map = serde_json::Map::new();
372 map.insert("ts".to_owned(), serde_json::Value::String(ts.to_owned()));
373 map.insert(
374 "event".to_owned(),
375 serde_json::Value::String(event.to_owned()),
376 );
377 map.insert(
378 "outcome".to_owned(),
379 serde_json::Value::String(outcome.to_owned()),
380 );
381 for (k, v) in extra {
382 map.insert((*k).to_owned(), serde_json::Value::String((*v).to_owned()));
383 }
384 map.insert(
385 "prev".to_owned(),
386 serde_json::Value::String(prev.to_owned()),
387 );
388 let body = serde_json::to_string(&serde_json::Value::Object(map.clone())).unwrap();
389 let mac = sloc_git::hmac_sha256_hex(key.as_bytes(), body.as_bytes());
390 map.insert("mac".to_owned(), serde_json::Value::String(mac.clone()));
391 let line = serde_json::to_string(&serde_json::Value::Object(map)).unwrap();
392 (line, mac)
393 }
394
395 #[test]
396 fn verify_accepts_intact_chain_and_flags_tampering() {
397 let key = "unit-test-audit-key";
398 let (l1, m1) = build_chained_line(
399 key,
400 AUDIT_CHAIN_GENESIS,
401 "2026-07-20T00:00:00+00:00",
402 "login_success",
403 "success",
404 &[("peer_ip", "10.0.0.1")],
405 );
406 let (l2, _m2) = build_chained_line(
407 key,
408 &m1,
409 "2026-07-20T00:00:01+00:00",
410 "auth_failure",
411 "failure",
412 &[("peer_ip", "10.0.0.2")],
413 );
414
415 let dir = std::env::temp_dir().join("sloc_audit_chain_test");
416 let _ = std::fs::create_dir_all(&dir);
417 let path = dir.join(format!("chain-{}.log", uuid::Uuid::new_v4()));
418
419 std::fs::write(&path, format!("{l1}\n{l2}\n")).unwrap();
421 let ok = verify_audit_file(&path, key);
422 assert!(ok.ok, "intact chain must verify: {ok:?}");
423 assert_eq!(ok.records, 2);
424
425 let tampered = format!("{}\n{l2}\n", l1.replace("success", "denied"));
427 std::fs::write(&path, tampered).unwrap();
428 let bad = verify_audit_file(&path, key);
429 assert!(!bad.ok, "tampered record must fail verification");
430 assert_eq!(bad.first_bad_line, Some(1));
431
432 std::fs::write(&path, format!("{l2}\n")).unwrap();
434 let broken = verify_audit_file(&path, key);
435 assert!(!broken.ok, "removed record must break the chain");
436 assert_eq!(broken.first_bad_line, Some(1));
437
438 let _ = std::fs::remove_file(&path);
439 }
440}