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
240pub fn verify_audit_file(path: &std::path::Path, key: &str) -> AuditVerifyReport {
251 let contents = match std::fs::read_to_string(path) {
252 Ok(c) => c,
253 Err(e) => {
254 return AuditVerifyReport {
255 records: 0,
256 ok: false,
257 first_bad_line: None,
258 detail: Some(format!("cannot read log: {e}")),
259 }
260 }
261 };
262 let mut expected_prev = AUDIT_CHAIN_GENESIS.to_owned();
263 let mut records = 0usize;
264 for (idx, raw) in contents.lines().enumerate() {
265 let line = raw.trim();
266 if line.is_empty() {
267 continue;
268 }
269 records += 1;
270 let lineno = idx + 1;
271 let Ok(serde_json::Value::Object(mut obj)) =
272 serde_json::from_str::<serde_json::Value>(line)
273 else {
274 return verify_failure(records, lineno, "line is not a JSON object");
275 };
276 let Some(serde_json::Value::String(stored_mac)) = obj.remove("mac") else {
277 return verify_failure(records, lineno, "record has no `mac` (not a chained log?)");
278 };
279 let Some(serde_json::Value::String(prev)) = obj.get("prev").cloned() else {
280 return verify_failure(records, lineno, "record has no `prev` link");
281 };
282 if prev != expected_prev {
283 return verify_failure(
284 records,
285 lineno,
286 "prev-hash does not match previous record (chain broken)",
287 );
288 }
289 let Ok(body) = serde_json::to_string(&serde_json::Value::Object(obj)) else {
290 return verify_failure(records, lineno, "re-serialisation failed");
291 };
292 let recomputed = sloc_git::hmac_sha256_hex(key.as_bytes(), body.as_bytes());
293 if recomputed != stored_mac {
294 return verify_failure(records, lineno, "MAC mismatch (record was modified)");
295 }
296 expected_prev = stored_mac;
297 }
298 AuditVerifyReport {
299 records,
300 ok: true,
301 first_bad_line: None,
302 detail: None,
303 }
304}
305
306#[cfg(test)]
307mod tests {
308 use super::*;
309
310 #[test]
311 fn record_does_not_panic() {
312 record("unit_test_event", "success", &[("k", "v")]);
316 }
317
318 #[test]
319 fn append_json_line_writes_one_record_per_event() {
320 let dir = std::env::temp_dir().join("sloc_audit_test");
323 let _ = std::fs::create_dir_all(&dir);
324 let path = dir.join(format!("audit-{}.log", uuid::Uuid::new_v4()));
325 let path_str = path.to_string_lossy().into_owned();
326
327 append_json_line(
328 &path_str,
329 "auth_failure",
330 "failure",
331 &[("peer_ip", "10.0.0.9"), ("path", "/analyze")],
332 );
333 append_json_line(
334 &path_str,
335 "auth_success",
336 "success",
337 &[("peer_ip", "10.0.0.9")],
338 );
339
340 let contents = std::fs::read_to_string(&path).expect("audit file written");
341 let lines: Vec<&str> = contents.lines().collect();
342 assert_eq!(lines.len(), 2, "one JSON line per event");
343
344 let first: serde_json::Value =
345 serde_json::from_str(lines[0]).expect("each line is valid JSON");
346 assert_eq!(first["event"], "auth_failure");
347 assert_eq!(first["outcome"], "failure");
348 assert_eq!(first["peer_ip"], "10.0.0.9");
349 assert_eq!(first["path"], "/analyze");
350 assert!(
351 first["ts"].is_string(),
352 "record carries an RFC3339 timestamp"
353 );
354
355 let _ = std::fs::remove_file(&path);
356 }
357
358 fn build_chained_line(
363 key: &str,
364 prev: &str,
365 ts: &str,
366 event: &str,
367 outcome: &str,
368 extra: &[(&str, &str)],
369 ) -> (String, String) {
370 let mut map = serde_json::Map::new();
371 map.insert("ts".to_owned(), serde_json::Value::String(ts.to_owned()));
372 map.insert(
373 "event".to_owned(),
374 serde_json::Value::String(event.to_owned()),
375 );
376 map.insert(
377 "outcome".to_owned(),
378 serde_json::Value::String(outcome.to_owned()),
379 );
380 for (k, v) in extra {
381 map.insert((*k).to_owned(), serde_json::Value::String((*v).to_owned()));
382 }
383 map.insert(
384 "prev".to_owned(),
385 serde_json::Value::String(prev.to_owned()),
386 );
387 let body = serde_json::to_string(&serde_json::Value::Object(map.clone())).unwrap();
388 let mac = sloc_git::hmac_sha256_hex(key.as_bytes(), body.as_bytes());
389 map.insert("mac".to_owned(), serde_json::Value::String(mac.clone()));
390 let line = serde_json::to_string(&serde_json::Value::Object(map)).unwrap();
391 (line, mac)
392 }
393
394 #[test]
395 fn verify_accepts_intact_chain_and_flags_tampering() {
396 let key = "unit-test-audit-key";
397 let (l1, m1) = build_chained_line(
398 key,
399 AUDIT_CHAIN_GENESIS,
400 "2026-07-20T00:00:00+00:00",
401 "login_success",
402 "success",
403 &[("peer_ip", "10.0.0.1")],
404 );
405 let (l2, _m2) = build_chained_line(
406 key,
407 &m1,
408 "2026-07-20T00:00:01+00:00",
409 "auth_failure",
410 "failure",
411 &[("peer_ip", "10.0.0.2")],
412 );
413
414 let dir = std::env::temp_dir().join("sloc_audit_chain_test");
415 let _ = std::fs::create_dir_all(&dir);
416 let path = dir.join(format!("chain-{}.log", uuid::Uuid::new_v4()));
417
418 std::fs::write(&path, format!("{l1}\n{l2}\n")).unwrap();
420 let ok = verify_audit_file(&path, key);
421 assert!(ok.ok, "intact chain must verify: {ok:?}");
422 assert_eq!(ok.records, 2);
423
424 let tampered = format!("{}\n{l2}\n", l1.replace("success", "denied"));
426 std::fs::write(&path, tampered).unwrap();
427 let bad = verify_audit_file(&path, key);
428 assert!(!bad.ok, "tampered record must fail verification");
429 assert_eq!(bad.first_bad_line, Some(1));
430
431 std::fs::write(&path, format!("{l2}\n")).unwrap();
433 let broken = verify_audit_file(&path, key);
434 assert!(!broken.ok, "removed record must break the chain");
435 assert_eq!(broken.first_bad_line, Some(1));
436
437 let _ = std::fs::remove_file(&path);
438 }
439}