#![allow(clippy::redundant_pub_crate)]
use std::{
fs::OpenOptions,
io::Write as _,
sync::{Mutex, OnceLock},
};
fn write_lock() -> &'static Mutex<()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
}
fn audit_log_path() -> Option<String> {
std::env::var("SLOC_AUDIT_LOG")
.ok()
.filter(|s| !s.trim().is_empty())
}
fn audit_log_max_bytes() -> u64 {
if let Some(bytes) = std::env::var("SLOC_AUDIT_LOG_MAX_BYTES")
.ok()
.and_then(|v| v.parse::<u64>().ok())
{
return bytes;
}
std::env::var("SLOC_AUDIT_LOG_MAX_MB")
.ok()
.and_then(|v| v.parse::<u64>().ok())
.unwrap_or(10)
* 1024
* 1024
}
fn audit_log_keep() -> u32 {
std::env::var("SLOC_AUDIT_LOG_KEEP")
.ok()
.and_then(|v| v.parse::<u32>().ok())
.unwrap_or(5)
}
const AUDIT_CHAIN_GENESIS: &str = "genesis";
fn audit_hmac_key() -> Option<String> {
std::env::var("SLOC_AUDIT_HMAC_KEY")
.ok()
.filter(|s| !s.is_empty())
}
fn chain_last() -> &'static Mutex<Option<String>> {
static L: OnceLock<Mutex<Option<String>>> = OnceLock::new();
L.get_or_init(|| Mutex::new(None))
}
fn seed_prev_from_file(path: &str) -> String {
let Ok(contents) = std::fs::read_to_string(path) else {
return AUDIT_CHAIN_GENESIS.to_owned();
};
for line in contents.lines().rev() {
let line = line.trim();
if line.is_empty() {
continue;
}
if let Ok(serde_json::Value::Object(obj)) = serde_json::from_str::<serde_json::Value>(line)
{
if let Some(serde_json::Value::String(mac)) = obj.get("mac") {
return mac.clone();
}
}
break;
}
AUDIT_CHAIN_GENESIS.to_owned()
}
pub(crate) fn record(event: &str, outcome: &str, fields: &[(&str, &str)]) {
tracing::info!(
target: "audit",
event,
outcome,
fields = ?fields,
"security audit event"
);
if let Some(path) = audit_log_path() {
append_json_line(&path, event, outcome, fields);
}
}
fn append_json_line(path: &str, event: &str, outcome: &str, fields: &[(&str, &str)]) {
let _guard = write_lock()
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let max_bytes = audit_log_max_bytes();
if max_bytes > 0 {
if let Err(e) =
sloc_core::rotate_log(std::path::Path::new(path), max_bytes, audit_log_keep())
{
tracing::error!(target: "audit", error = %e, path = %path,
"failed to rotate audit log");
}
}
let mut map = serde_json::Map::with_capacity(fields.len() + 5);
map.insert(
"ts".to_owned(),
serde_json::Value::String(chrono::Utc::now().to_rfc3339()),
);
map.insert(
"event".to_owned(),
serde_json::Value::String(event.to_owned()),
);
map.insert(
"outcome".to_owned(),
serde_json::Value::String(outcome.to_owned()),
);
for (k, v) in fields {
map.insert((*k).to_owned(), serde_json::Value::String((*v).to_owned()));
}
if let Some(key) = audit_hmac_key() {
let mut last = chain_last()
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if last.is_none() {
*last = Some(seed_prev_from_file(path));
}
let prev = last
.clone()
.unwrap_or_else(|| AUDIT_CHAIN_GENESIS.to_owned());
map.insert("prev".to_owned(), serde_json::Value::String(prev));
let Ok(body) = serde_json::to_string(&serde_json::Value::Object(map.clone())) else {
return;
};
let mac = sloc_git::hmac_sha256_hex(key.as_bytes(), body.as_bytes());
map.insert("mac".to_owned(), serde_json::Value::String(mac.clone()));
*last = Some(mac);
}
let Ok(mut line) = serde_json::to_string(&serde_json::Value::Object(map)) else {
return;
};
line.push('\n');
match OpenOptions::new().create(true).append(true).open(path) {
Ok(mut f) => {
if let Err(e) = f.write_all(line.as_bytes()) {
tracing::error!(target: "audit", error = %e, path = %path,
"failed to write audit log line");
}
}
Err(e) => {
tracing::error!(target: "audit", error = %e, path = %path,
"failed to open audit log file");
}
}
}
#[derive(Debug)]
pub struct AuditVerifyReport {
pub records: usize,
pub ok: bool,
pub first_bad_line: Option<usize>,
pub detail: Option<String>,
}
fn verify_failure(records: usize, line: usize, msg: &str) -> AuditVerifyReport {
AuditVerifyReport {
records,
ok: false,
first_bad_line: Some(line),
detail: Some(msg.to_owned()),
}
}
pub fn verify_audit_file(path: &std::path::Path, key: &str) -> AuditVerifyReport {
let contents = match std::fs::read_to_string(path) {
Ok(c) => c,
Err(e) => {
return AuditVerifyReport {
records: 0,
ok: false,
first_bad_line: None,
detail: Some(format!("cannot read log: {e}")),
}
}
};
let mut expected_prev = AUDIT_CHAIN_GENESIS.to_owned();
let mut records = 0usize;
for (idx, raw) in contents.lines().enumerate() {
let line = raw.trim();
if line.is_empty() {
continue;
}
records += 1;
let lineno = idx + 1;
let Ok(serde_json::Value::Object(mut obj)) =
serde_json::from_str::<serde_json::Value>(line)
else {
return verify_failure(records, lineno, "line is not a JSON object");
};
let Some(serde_json::Value::String(stored_mac)) = obj.remove("mac") else {
return verify_failure(records, lineno, "record has no `mac` (not a chained log?)");
};
let Some(serde_json::Value::String(prev)) = obj.get("prev").cloned() else {
return verify_failure(records, lineno, "record has no `prev` link");
};
if prev != expected_prev {
return verify_failure(
records,
lineno,
"prev-hash does not match previous record (chain broken)",
);
}
let Ok(body) = serde_json::to_string(&serde_json::Value::Object(obj)) else {
return verify_failure(records, lineno, "re-serialisation failed");
};
let recomputed = sloc_git::hmac_sha256_hex(key.as_bytes(), body.as_bytes());
if recomputed != stored_mac {
return verify_failure(records, lineno, "MAC mismatch (record was modified)");
}
expected_prev = stored_mac;
}
AuditVerifyReport {
records,
ok: true,
first_bad_line: None,
detail: None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn record_does_not_panic() {
record("unit_test_event", "success", &[("k", "v")]);
}
#[test]
fn append_json_line_writes_one_record_per_event() {
let dir = std::env::temp_dir().join("sloc_audit_test");
let _ = std::fs::create_dir_all(&dir);
let path = dir.join(format!("audit-{}.log", uuid::Uuid::new_v4()));
let path_str = path.to_string_lossy().into_owned();
append_json_line(
&path_str,
"auth_failure",
"failure",
&[("peer_ip", "10.0.0.9"), ("path", "/analyze")],
);
append_json_line(
&path_str,
"auth_success",
"success",
&[("peer_ip", "10.0.0.9")],
);
let contents = std::fs::read_to_string(&path).expect("audit file written");
let lines: Vec<&str> = contents.lines().collect();
assert_eq!(lines.len(), 2, "one JSON line per event");
let first: serde_json::Value =
serde_json::from_str(lines[0]).expect("each line is valid JSON");
assert_eq!(first["event"], "auth_failure");
assert_eq!(first["outcome"], "failure");
assert_eq!(first["peer_ip"], "10.0.0.9");
assert_eq!(first["path"], "/analyze");
assert!(
first["ts"].is_string(),
"record carries an RFC3339 timestamp"
);
let _ = std::fs::remove_file(&path);
}
fn build_chained_line(
key: &str,
prev: &str,
ts: &str,
event: &str,
outcome: &str,
extra: &[(&str, &str)],
) -> (String, String) {
let mut map = serde_json::Map::new();
map.insert("ts".to_owned(), serde_json::Value::String(ts.to_owned()));
map.insert(
"event".to_owned(),
serde_json::Value::String(event.to_owned()),
);
map.insert(
"outcome".to_owned(),
serde_json::Value::String(outcome.to_owned()),
);
for (k, v) in extra {
map.insert((*k).to_owned(), serde_json::Value::String((*v).to_owned()));
}
map.insert(
"prev".to_owned(),
serde_json::Value::String(prev.to_owned()),
);
let body = serde_json::to_string(&serde_json::Value::Object(map.clone())).unwrap();
let mac = sloc_git::hmac_sha256_hex(key.as_bytes(), body.as_bytes());
map.insert("mac".to_owned(), serde_json::Value::String(mac.clone()));
let line = serde_json::to_string(&serde_json::Value::Object(map)).unwrap();
(line, mac)
}
#[test]
fn verify_accepts_intact_chain_and_flags_tampering() {
let key = "unit-test-audit-key";
let (l1, m1) = build_chained_line(
key,
AUDIT_CHAIN_GENESIS,
"2026-07-20T00:00:00+00:00",
"login_success",
"success",
&[("peer_ip", "10.0.0.1")],
);
let (l2, _m2) = build_chained_line(
key,
&m1,
"2026-07-20T00:00:01+00:00",
"auth_failure",
"failure",
&[("peer_ip", "10.0.0.2")],
);
let dir = std::env::temp_dir().join("sloc_audit_chain_test");
let _ = std::fs::create_dir_all(&dir);
let path = dir.join(format!("chain-{}.log", uuid::Uuid::new_v4()));
std::fs::write(&path, format!("{l1}\n{l2}\n")).unwrap();
let ok = verify_audit_file(&path, key);
assert!(ok.ok, "intact chain must verify: {ok:?}");
assert_eq!(ok.records, 2);
let tampered = format!("{}\n{l2}\n", l1.replace("success", "denied"));
std::fs::write(&path, tampered).unwrap();
let bad = verify_audit_file(&path, key);
assert!(!bad.ok, "tampered record must fail verification");
assert_eq!(bad.first_bad_line, Some(1));
std::fs::write(&path, format!("{l2}\n")).unwrap();
let broken = verify_audit_file(&path, key);
assert!(!broken.ok, "removed record must break the chain");
assert_eq!(broken.first_bad_line, Some(1));
let _ = std::fs::remove_file(&path);
}
}