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            && let Some(serde_json::Value::String(mac)) = obj.get("mac")
102        {
103            return mac.clone();
104        }
105        break;
106    }
107    AUDIT_CHAIN_GENESIS.to_owned()
108}
109
110/// Record one security audit event.
111///
112/// * `event`   — stable machine-readable event name (e.g. `auth_failure`).
113/// * `outcome` — one of `success`, `failure`, `denied`, `warning`.
114/// * `fields`  — additional key/value context (peer IP, path, method, reason …).
115///
116/// Emits a structured `tracing` event always, and appends a JSON line to the
117/// `SLOC_AUDIT_LOG` file when configured.
118pub(crate) fn record(event: &str, outcome: &str, fields: &[(&str, &str)]) {
119    // Structured tracing: one event per security decision, on the `audit` target.
120    tracing::info!(
121        target: "audit",
122        event,
123        outcome,
124        fields = ?fields,
125        "security audit event"
126    );
127
128    if let Some(path) = audit_log_path() {
129        append_json_line(&path, event, outcome, fields);
130    }
131}
132
133/// Format one event as a JSON line and append it to `path`. Split out from
134/// [`record`] so it is directly testable with an explicit path, without touching
135/// the process-global `SLOC_AUDIT_LOG` env var (which would race across threads).
136fn append_json_line(path: &str, event: &str, outcome: &str, fields: &[(&str, &str)]) {
137    // Best-effort append under the process-wide write lock. Never propagate errors:
138    // an audit-sink failure must not affect the request being served. The lock is
139    // taken first so the (optional) hash-chain state and the file writes advance in
140    // the same order.
141    let _guard = write_lock()
142        .lock()
143        .unwrap_or_else(std::sync::PoisonError::into_inner);
144
145    // Self-maintaining: rotate the sink by size before appending so it can never
146    // grow without bound. Rotation failure is non-fatal — we still try to append.
147    let max_bytes = audit_log_max_bytes();
148    if max_bytes > 0
149        && let Err(e) =
150            sloc_core::rotate_log(std::path::Path::new(path), max_bytes, audit_log_keep())
151    {
152        tracing::error!(target: "audit", error = %e, path = %path,
153                "failed to rotate audit log");
154    }
155
156    let mut map = serde_json::Map::with_capacity(fields.len() + 5);
157    map.insert(
158        "ts".to_owned(),
159        serde_json::Value::String(chrono::Utc::now().to_rfc3339()),
160    );
161    map.insert(
162        "event".to_owned(),
163        serde_json::Value::String(event.to_owned()),
164    );
165    map.insert(
166        "outcome".to_owned(),
167        serde_json::Value::String(outcome.to_owned()),
168    );
169    for (k, v) in fields {
170        map.insert((*k).to_owned(), serde_json::Value::String((*v).to_owned()));
171    }
172
173    // Opt-in tamper-evidence: when SLOC_AUDIT_HMAC_KEY is set, chain each record to
174    // the previous one with a keyed HMAC-SHA256 so any edit, reorder, or truncation
175    // of the log becomes detectable. The MAC covers the record including its `prev`
176    // link but excluding `mac` itself. Absent the key, nothing below runs and the
177    // record is identical to the legacy format.
178    if let Some(key) = audit_hmac_key() {
179        let mut last = chain_last()
180            .lock()
181            .unwrap_or_else(std::sync::PoisonError::into_inner);
182        if last.is_none() {
183            *last = Some(seed_prev_from_file(path));
184        }
185        let prev = last
186            .clone()
187            .unwrap_or_else(|| AUDIT_CHAIN_GENESIS.to_owned());
188        map.insert("prev".to_owned(), serde_json::Value::String(prev));
189        let Ok(body) = serde_json::to_string(&serde_json::Value::Object(map.clone())) else {
190            return;
191        };
192        let mac = sloc_git::hmac_sha256_hex(key.as_bytes(), body.as_bytes());
193        map.insert("mac".to_owned(), serde_json::Value::String(mac.clone()));
194        *last = Some(mac);
195    }
196
197    let Ok(mut line) = serde_json::to_string(&serde_json::Value::Object(map)) else {
198        return;
199    };
200    line.push('\n');
201
202    match OpenOptions::new().create(true).append(true).open(path) {
203        Ok(mut f) => {
204            if let Err(e) = f.write_all(line.as_bytes()) {
205                tracing::error!(target: "audit", error = %e, path = %path,
206                    "failed to write audit log line");
207            }
208        }
209        Err(e) => {
210            tracing::error!(target: "audit", error = %e, path = %path,
211                "failed to open audit log file");
212        }
213    }
214}
215
216/// Outcome of verifying a tamper-evident audit log.
217#[derive(Debug)]
218pub struct AuditVerifyReport {
219    /// Number of records inspected (up to and including any failure).
220    pub records: usize,
221    /// True when the whole chain verified.
222    pub ok: bool,
223    /// 1-based line number of the first broken record, if any.
224    pub first_bad_line: Option<usize>,
225    /// Human-readable detail of the first failure, if any.
226    pub detail: Option<String>,
227}
228
229fn verify_failure(records: usize, line: usize, msg: &str) -> AuditVerifyReport {
230    AuditVerifyReport {
231        records,
232        ok: false,
233        first_bad_line: Some(line),
234        detail: Some(msg.to_owned()),
235    }
236}
237
238/// Verify a hash-chained audit log written with `SLOC_AUDIT_HMAC_KEY`.
239///
240/// Walks every JSON line, recomputes each record's keyed MAC, and checks the
241/// `prev` linkage forms an unbroken chain from the genesis marker. Returns the
242/// first line that fails, so an operator can pinpoint tampering.
243///
244/// # Errors
245///
246/// Never returns `Err`; read/parse problems are reported via the returned
247/// [`AuditVerifyReport`] (`ok == false`).
248#[must_use]
249pub fn verify_audit_file(path: &std::path::Path, key: &str) -> AuditVerifyReport {
250    let contents = match std::fs::read_to_string(path) {
251        Ok(c) => c,
252        Err(e) => {
253            return AuditVerifyReport {
254                records: 0,
255                ok: false,
256                first_bad_line: None,
257                detail: Some(format!("cannot read log: {e}")),
258            };
259        }
260    };
261    let mut expected_prev = AUDIT_CHAIN_GENESIS.to_owned();
262    let mut records = 0usize;
263    for (idx, raw) in contents.lines().enumerate() {
264        let line = raw.trim();
265        if line.is_empty() {
266            continue;
267        }
268        records += 1;
269        let lineno = idx + 1;
270        let Ok(serde_json::Value::Object(mut obj)) =
271            serde_json::from_str::<serde_json::Value>(line)
272        else {
273            return verify_failure(records, lineno, "line is not a JSON object");
274        };
275        let Some(serde_json::Value::String(stored_mac)) = obj.remove("mac") else {
276            return verify_failure(records, lineno, "record has no `mac` (not a chained log?)");
277        };
278        let Some(serde_json::Value::String(prev)) = obj.get("prev").cloned() else {
279            return verify_failure(records, lineno, "record has no `prev` link");
280        };
281        if prev != expected_prev {
282            return verify_failure(
283                records,
284                lineno,
285                "prev-hash does not match previous record (chain broken)",
286            );
287        }
288        let Ok(body) = serde_json::to_string(&serde_json::Value::Object(obj)) else {
289            return verify_failure(records, lineno, "re-serialisation failed");
290        };
291        let recomputed = sloc_git::hmac_sha256_hex(key.as_bytes(), body.as_bytes());
292        if recomputed != stored_mac {
293            return verify_failure(records, lineno, "MAC mismatch (record was modified)");
294        }
295        expected_prev = stored_mac;
296    }
297    AuditVerifyReport {
298        records,
299        ok: true,
300        first_bad_line: None,
301        detail: None,
302    }
303}
304
305#[cfg(test)]
306mod tests {
307    use super::*;
308
309    #[test]
310    fn record_does_not_panic() {
311        // Smoke test: the public entry point must never panic regardless of whether
312        // a sink is configured (it reads the process-global env var, so it makes no
313        // assertions about file contents — that is covered below via append_json_line).
314        record("unit_test_event", "success", &[("k", "v")]);
315    }
316
317    #[test]
318    fn append_json_line_writes_one_record_per_event() {
319        // Uses an explicit unique path (no global env) so it is race-free under the
320        // parallel test runner.
321        let dir = std::env::temp_dir().join("sloc_audit_test");
322        let _ = std::fs::create_dir_all(&dir);
323        let path = dir.join(format!("audit-{}.log", uuid::Uuid::new_v4()));
324        let path_str = path.to_string_lossy().into_owned();
325
326        append_json_line(
327            &path_str,
328            "auth_failure",
329            "failure",
330            &[("peer_ip", "10.0.0.9"), ("path", "/analyze")],
331        );
332        append_json_line(
333            &path_str,
334            "auth_success",
335            "success",
336            &[("peer_ip", "10.0.0.9")],
337        );
338
339        let contents = std::fs::read_to_string(&path).expect("audit file written");
340        let lines: Vec<&str> = contents.lines().collect();
341        assert_eq!(lines.len(), 2, "one JSON line per event");
342
343        let first: serde_json::Value =
344            serde_json::from_str(lines[0]).expect("each line is valid JSON");
345        assert_eq!(first["event"], "auth_failure");
346        assert_eq!(first["outcome"], "failure");
347        assert_eq!(first["peer_ip"], "10.0.0.9");
348        assert_eq!(first["path"], "/analyze");
349        assert!(
350            first["ts"].is_string(),
351            "record carries an RFC3339 timestamp"
352        );
353
354        let _ = std::fs::remove_file(&path);
355    }
356
357    /// Build one chained JSON line exactly the way `append_json_line` does, so the
358    /// verifier can be exercised without touching the process-global chain state or
359    /// the `SLOC_AUDIT_HMAC_KEY` env var (both of which would race under the parallel
360    /// test runner). Returns `(line, mac)`.
361    fn build_chained_line(
362        key: &str,
363        prev: &str,
364        ts: &str,
365        event: &str,
366        outcome: &str,
367        extra: &[(&str, &str)],
368    ) -> (String, String) {
369        let mut map = serde_json::Map::new();
370        map.insert("ts".to_owned(), serde_json::Value::String(ts.to_owned()));
371        map.insert(
372            "event".to_owned(),
373            serde_json::Value::String(event.to_owned()),
374        );
375        map.insert(
376            "outcome".to_owned(),
377            serde_json::Value::String(outcome.to_owned()),
378        );
379        for (k, v) in extra {
380            map.insert((*k).to_owned(), serde_json::Value::String((*v).to_owned()));
381        }
382        map.insert(
383            "prev".to_owned(),
384            serde_json::Value::String(prev.to_owned()),
385        );
386        let body = serde_json::to_string(&serde_json::Value::Object(map.clone())).unwrap();
387        let mac = sloc_git::hmac_sha256_hex(key.as_bytes(), body.as_bytes());
388        map.insert("mac".to_owned(), serde_json::Value::String(mac.clone()));
389        let line = serde_json::to_string(&serde_json::Value::Object(map)).unwrap();
390        (line, mac)
391    }
392
393    #[test]
394    fn verify_accepts_intact_chain_and_flags_tampering() {
395        let key = "unit-test-audit-key";
396        let (l1, m1) = build_chained_line(
397            key,
398            AUDIT_CHAIN_GENESIS,
399            "2026-07-20T00:00:00+00:00",
400            "login_success",
401            "success",
402            &[("peer_ip", "10.0.0.1")],
403        );
404        let (l2, _m2) = build_chained_line(
405            key,
406            &m1,
407            "2026-07-20T00:00:01+00:00",
408            "auth_failure",
409            "failure",
410            &[("peer_ip", "10.0.0.2")],
411        );
412
413        let dir = std::env::temp_dir().join("sloc_audit_chain_test");
414        let _ = std::fs::create_dir_all(&dir);
415        let path = dir.join(format!("chain-{}.log", uuid::Uuid::new_v4()));
416
417        // Intact chain verifies.
418        std::fs::write(&path, format!("{l1}\n{l2}\n")).unwrap();
419        let ok = verify_audit_file(&path, key);
420        assert!(ok.ok, "intact chain must verify: {ok:?}");
421        assert_eq!(ok.records, 2);
422
423        // Editing a record's outcome breaks its MAC and is caught at that line.
424        let tampered = format!("{}\n{l2}\n", l1.replace("success", "denied"));
425        std::fs::write(&path, tampered).unwrap();
426        let bad = verify_audit_file(&path, key);
427        assert!(!bad.ok, "tampered record must fail verification");
428        assert_eq!(bad.first_bad_line, Some(1));
429
430        // Deleting the first record breaks the second's prev linkage.
431        std::fs::write(&path, format!("{l2}\n")).unwrap();
432        let broken = verify_audit_file(&path, key);
433        assert!(!broken.ok, "removed record must break the chain");
434        assert_eq!(broken.first_bad_line, Some(1));
435
436        let _ = std::fs::remove_file(&path);
437    }
438}