Skip to main content

farhand_core/
audit.rs

1//! The audit log: one JSON object per line, one file per day. It records
2//! what was asked, where, and how it ended — never file contents and never
3//! the text a guard rule matched.
4
5use std::fs::{File, OpenOptions};
6use std::io::Write;
7use std::path::PathBuf;
8use std::sync::Mutex;
9
10use serde::Serialize;
11
12use crate::error::Result;
13
14#[derive(Debug, Serialize)]
15pub struct Record<'a> {
16    pub ts: String,
17    pub session: &'a str,
18    pub host: &'a str,
19    pub tool: &'a str,
20    #[serde(skip_serializing_if = "Option::is_none")]
21    pub command: Option<&'a str>,
22    #[serde(skip_serializing_if = "Option::is_none")]
23    pub path: Option<&'a str>,
24    #[serde(skip_serializing_if = "Option::is_none")]
25    pub target: Option<&'a str>,
26    #[serde(skip_serializing_if = "Option::is_none")]
27    pub bytes: Option<u64>,
28    pub outcome: &'a str,
29    #[serde(skip_serializing_if = "Option::is_none")]
30    pub exit_code: Option<i32>,
31    #[serde(skip_serializing_if = "Option::is_none")]
32    pub error: Option<String>,
33    pub duration_ms: u128,
34}
35
36pub struct Audit {
37    dir: PathBuf,
38    session: String,
39    host: String,
40    file: Mutex<Option<(String, File)>>,
41}
42
43impl Audit {
44    pub fn open(dir: PathBuf, host: &str) -> Result<Self> {
45        std::fs::create_dir_all(&dir)?;
46        let session = format!(
47            "{}-{}",
48            chrono::Utc::now().format("%Y%m%dT%H%M%S"),
49            std::process::id()
50        );
51        Ok(Self {
52            dir,
53            session,
54            host: host.to_string(),
55            file: Mutex::new(None),
56        })
57    }
58
59    pub fn session(&self) -> &str {
60        &self.session
61    }
62
63    pub fn dir(&self) -> &PathBuf {
64        &self.dir
65    }
66
67    /// Append one record. A failure to write the log is reported on stderr
68    /// and otherwise ignored: losing an audit line must not block work,
69    /// but it must not be silent either.
70    pub fn record<'a>(&'a self, mut rec: Record<'a>) {
71        rec.ts = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
72        rec.session = &self.session;
73        rec.host = &self.host;
74        let line = match serde_json::to_string(&rec) {
75            Ok(l) => l,
76            Err(e) => {
77                eprintln!("farhand: audit serialise failed: {e}");
78                return;
79            }
80        };
81        let day = chrono::Utc::now().format("%Y-%m-%d").to_string();
82        let mut guard = match self.file.lock() {
83            Ok(g) => g,
84            Err(p) => p.into_inner(),
85        };
86        let needs_open = !matches!(&*guard, Some((d, _)) if *d == day);
87        if needs_open {
88            let path = self.dir.join(format!("{day}.jsonl"));
89            match OpenOptions::new().create(true).append(true).open(&path) {
90                Ok(f) => *guard = Some((day, f)),
91                Err(e) => {
92                    eprintln!("farhand: cannot open audit log {}: {e}", path.display());
93                    return;
94                }
95            }
96        }
97        if let Some((_, f)) = &mut *guard {
98            if let Err(e) = writeln!(f, "{line}") {
99                eprintln!("farhand: audit write failed: {e}");
100            }
101        }
102    }
103}
104
105impl<'a> Record<'a> {
106    pub fn new(tool: &'a str) -> Self {
107        Self {
108            ts: String::new(),
109            session: "",
110            host: "",
111            tool,
112            command: None,
113            path: None,
114            target: None,
115            bytes: None,
116            outcome: "ok",
117            exit_code: None,
118            error: None,
119            duration_ms: 0,
120        }
121    }
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127
128    #[test]
129    fn writes_one_line_per_record() {
130        let tmp = tempfile::tempdir().unwrap();
131        let audit = Audit::open(tmp.path().join("audit"), "devbox").unwrap();
132        let mut r = Record::new("remote_shell");
133        r.command = Some("ls");
134        r.exit_code = Some(0);
135        audit.record(r);
136        let mut r = Record::new("upload");
137        r.outcome = "denied";
138        r.error = Some("rule: private-key-block".into());
139        audit.record(r);
140        let files: Vec<_> = std::fs::read_dir(tmp.path().join("audit"))
141            .unwrap()
142            .collect();
143        assert_eq!(files.len(), 1);
144        let text = std::fs::read_to_string(files[0].as_ref().unwrap().path()).unwrap();
145        let lines: Vec<&str> = text.lines().collect();
146        assert_eq!(lines.len(), 2);
147        let v: serde_json::Value = serde_json::from_str(lines[0]).unwrap();
148        assert_eq!(v["host"], "devbox");
149        assert_eq!(v["command"], "ls");
150        assert!(v["path"].is_null());
151    }
152}