Skip to main content

sloc_web/
audit.rs

1// SPDX-License-Identifier: AGPL-3.0-or-later
2// Copyright (C) 2026 Nima Shafie <nimzshafie@gmail.com>
3#![allow(clippy::redundant_pub_crate)]
4
5//! Structured security audit logging for SIEM ingestion.
6//!
7//! Every security-relevant event — authentication success/failure, session
8//! lifecycle (login/logout), lockout, and unauthenticated-server refusals — is
9//! emitted through [`record`], which:
10//!
11//! 1. Always emits a structured `tracing` event on the `audit` target so it flows
12//!    into whatever subscriber the operator has configured (stdout, journald, …).
13//! 2. When `SLOC_AUDIT_LOG=<path>` is set, additionally appends the event as a
14//!    single JSON line (JSONL) to that file. JSONL is the lingua franca of SIEM
15//!    ingestion (Splunk, Elastic/Logstash, Sentinel, Loki) — one self-describing
16//!    record per line, tail-and-ship friendly.
17//!
18//! The JSON sink is deliberately append-only and best-effort: a failure to write
19//! an audit line must never take down a request, so write errors are swallowed
20//! after a single `tracing::error!`. Writes are serialised through a process-wide
21//! mutex so concurrent requests cannot interleave partial lines.
22
23use std::{
24    fs::OpenOptions,
25    io::Write as _,
26    sync::{Mutex, OnceLock},
27};
28
29/// Serialises concurrent appends to the audit file so lines never interleave.
30fn write_lock() -> &'static Mutex<()> {
31    static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
32    LOCK.get_or_init(|| Mutex::new(()))
33}
34
35/// Path of the JSONL audit sink, if `SLOC_AUDIT_LOG` is configured and non-empty.
36fn audit_log_path() -> Option<String> {
37    std::env::var("SLOC_AUDIT_LOG")
38        .ok()
39        .filter(|s| !s.trim().is_empty())
40}
41
42/// Size cap (bytes) that triggers rotation of the audit log. `SLOC_AUDIT_LOG_MAX_BYTES`
43/// takes precedence (byte-precise, for fine tuning); otherwise `SLOC_AUDIT_LOG_MAX_MB`
44/// (default 10 MB) is used. `0` from either disables rotation (grow forever).
45fn 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
60/// Number of rotated generations to keep, from `SLOC_AUDIT_LOG_KEEP` (default 5).
61fn 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
68/// Marker used as the `prev` link of the very first record in a chain.
69const AUDIT_CHAIN_GENESIS: &str = "genesis";
70
71/// The keyed-integrity secret from `SLOC_AUDIT_HMAC_KEY`, if configured and
72/// non-empty. When present, every appended record is hash-chained (tamper-evident);
73/// when absent, the record format is byte-for-byte identical to the legacy log.
74fn audit_hmac_key() -> Option<String> {
75    std::env::var("SLOC_AUDIT_HMAC_KEY")
76        .ok()
77        .filter(|s| !s.is_empty())
78}
79
80/// In-memory chain tip (the `mac` of the last record written this process). Guarded
81/// so the chain advances in the same order as the file appends. Seeded lazily from
82/// the existing log so the chain survives restarts.
83fn 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
88/// Recover the chain tip (last record's `mac`) from an existing log so a restart
89/// continues the same chain. Returns the genesis marker when the file is absent,
90/// empty, or its last record is not part of a chain.
91fn 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
111/// Record one security audit event.
112///
113/// * `event`   — stable machine-readable event name (e.g. `auth_failure`).
114/// * `outcome` — one of `success`, `failure`, `denied`, `warning`.
115/// * `fields`  — additional key/value context (peer IP, path, method, reason …).
116///
117/// Emits a structured `tracing` event always, and appends a JSON line to the
118/// `SLOC_AUDIT_LOG` file when configured.
119pub(crate) fn record(event: &str, outcome: &str, fields: &[(&str, &str)]) {
120    // Structured tracing: one event per security decision, on the `audit` target.
121    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
134/// Format one event as a JSON line and append it to `path`. Split out from
135/// [`record`] so it is directly testable with an explicit path, without touching
136/// the process-global `SLOC_AUDIT_LOG` env var (which would race across threads).
137fn append_json_line(path: &str, event: &str, outcome: &str, fields: &[(&str, &str)]) {
138    // Best-effort append under the process-wide write lock. Never propagate errors:
139    // an audit-sink failure must not affect the request being served. The lock is
140    // taken first so the (optional) hash-chain state and the file writes advance in
141    // the same order.
142    let _guard = write_lock()
143        .lock()
144        .unwrap_or_else(std::sync::PoisonError::into_inner);
145
146    // Self-maintaining: rotate the sink by size before appending so it can never
147    // grow without bound. Rotation failure is non-fatal — we still try to append.
148    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    // Opt-in tamper-evidence: when SLOC_AUDIT_HMAC_KEY is set, chain each record to
176    // the previous one with a keyed HMAC-SHA256 so any edit, reorder, or truncation
177    // of the log becomes detectable. The MAC covers the record including its `prev`
178    // link but excluding `mac` itself. Absent the key, nothing below runs and the
179    // record is identical to the legacy format.
180    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/// Outcome of verifying a tamper-evident audit log.
219#[derive(Debug)]
220pub struct AuditVerifyReport {
221    /// Number of records inspected (up to and including any failure).
222    pub records: usize,
223    /// True when the whole chain verified.
224    pub ok: bool,
225    /// 1-based line number of the first broken record, if any.
226    pub first_bad_line: Option<usize>,
227    /// Human-readable detail of the first failure, if any.
228    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/// Verify a hash-chained audit log written with `SLOC_AUDIT_HMAC_KEY`.
241///
242/// Walks every JSON line, recomputes each record's keyed MAC, and checks the
243/// `prev` linkage forms an unbroken chain from the genesis marker. Returns the
244/// first line that fails, so an operator can pinpoint tampering.
245///
246/// # Errors
247///
248/// Never returns `Err`; read/parse problems are reported via the returned
249/// [`AuditVerifyReport`] (`ok == false`).
250pub 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        // Smoke test: the public entry point must never panic regardless of whether
313        // a sink is configured (it reads the process-global env var, so it makes no
314        // assertions about file contents — that is covered below via append_json_line).
315        record("unit_test_event", "success", &[("k", "v")]);
316    }
317
318    #[test]
319    fn append_json_line_writes_one_record_per_event() {
320        // Uses an explicit unique path (no global env) so it is race-free under the
321        // parallel test runner.
322        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    /// Build one chained JSON line exactly the way `append_json_line` does, so the
359    /// verifier can be exercised without touching the process-global chain state or
360    /// the `SLOC_AUDIT_HMAC_KEY` env var (both of which would race under the parallel
361    /// test runner). Returns `(line, mac)`.
362    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        // Intact chain verifies.
419        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        // Editing a record's outcome breaks its MAC and is caught at that line.
425        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        // Deleting the first record breaks the second's prev linkage.
432        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}