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`).
250#[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        // Smoke test: the public entry point must never panic regardless of whether
314        // a sink is configured (it reads the process-global env var, so it makes no
315        // assertions about file contents — that is covered below via append_json_line).
316        record("unit_test_event", "success", &[("k", "v")]);
317    }
318
319    #[test]
320    fn append_json_line_writes_one_record_per_event() {
321        // Uses an explicit unique path (no global env) so it is race-free under the
322        // parallel test runner.
323        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    /// Build one chained JSON line exactly the way `append_json_line` does, so the
360    /// verifier can be exercised without touching the process-global chain state or
361    /// the `SLOC_AUDIT_HMAC_KEY` env var (both of which would race under the parallel
362    /// test runner). Returns `(line, mac)`.
363    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        // Intact chain verifies.
420        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        // Editing a record's outcome breaks its MAC and is caught at that line.
426        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        // Deleting the first record breaks the second's prev linkage.
433        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}