Skip to main content

apcore_cli/security/
audit.rs

1// apcore-cli — Audit logger.
2// Protocol spec: SEC-01 (AuditLogger)
3
4use std::io::{BufWriter, Write};
5use std::path::PathBuf;
6
7use chrono::Utc;
8use serde_json::{json, Value};
9use sha2::{Digest, Sha256};
10use thiserror::Error;
11
12// ---------------------------------------------------------------------------
13// Constants
14// ---------------------------------------------------------------------------
15
16const HASH_SALT: &str = "apcore-cli-audit-v1";
17
18// ---------------------------------------------------------------------------
19// AuditLogger
20// ---------------------------------------------------------------------------
21
22/// Append-only audit logger that records each module execution to a JSONL file.
23///
24/// When constructed with `path = None`, logging is a no-op (disabled).
25#[derive(Debug, Clone)]
26pub struct AuditLogger {
27    path: Option<PathBuf>,
28}
29
30impl AuditLogger {
31    /// Return the default path: `~/.apcore-cli/audit.jsonl`.
32    pub fn default_path() -> Option<PathBuf> {
33        dirs::home_dir().map(|h| h.join(".apcore-cli").join("audit.jsonl"))
34    }
35
36    /// Create a new `AuditLogger`.
37    ///
38    /// # Arguments
39    /// * `path` — path to the JSONL audit log file; `None` uses the default
40    ///   path `~/.apcore-cli/audit.jsonl`.
41    pub fn new(path: Option<PathBuf>) -> Self {
42        let resolved = path.or_else(Self::default_path);
43        if let Some(ref p) = resolved {
44            if let Some(parent) = p.parent() {
45                // Best-effort; failure is silent.
46                let _ = std::fs::create_dir_all(parent);
47            }
48        }
49        Self { path: resolved }
50    }
51
52    /// Return the username from the environment: `USER` -> `LOGNAME` -> `"unknown"`.
53    fn get_user() -> String {
54        std::env::var("USER")
55            .or_else(|_| std::env::var("LOGNAME"))
56            .unwrap_or_else(|_| "unknown".to_string())
57    }
58
59    /// Hash `input_data` with a fresh 16-byte random salt.
60    ///
61    /// Digest = SHA-256(`random_salt` `:` `HASH_SALT` `:` stable_json(`input_data`)).
62    /// Returns a lowercase hex string (64 chars).
63    ///
64    /// **Design note:** A random salt is used per call to prevent correlation
65    /// of audit entries by input content. This is a privacy design choice
66    /// matching the Python reference implementation — the hash proves an input
67    /// was logged but cannot be used to find duplicate inputs across entries.
68    fn hash_input(input_data: &Value) -> String {
69        use aes_gcm::aead::rand_core::RngCore;
70        use aes_gcm::aead::OsRng;
71
72        let mut salt = [0u8; 16];
73        OsRng.fill_bytes(&mut salt);
74
75        let payload = Self::stable_json(input_data);
76        let salted = format!("{}:{}", HASH_SALT, payload);
77
78        let mut hasher = Sha256::new();
79        hasher.update(salt);
80        hasher.update(salted.as_bytes());
81        format!("{:x}", hasher.finalize())
82    }
83
84    /// Produce a stable (sorted-key) JSON string for `v`.
85    fn stable_json(v: &Value) -> String {
86        match v {
87            Value::Object(map) => {
88                let sorted: std::collections::BTreeMap<_, _> = map.iter().collect();
89                let pairs: Vec<String> = sorted
90                    .iter()
91                    .map(|(k, val)| format!("{}:{}", serde_json::json!(k), Self::stable_json(val)))
92                    .collect();
93                format!("{{{}}}", pairs.join(","))
94            }
95            other => other.to_string(),
96        }
97    }
98
99    /// Log a single module execution event.
100    ///
101    /// Appends one JSON line to the audit log. IO failures emit a
102    /// `tracing::warn!` and are otherwise ignored — this method never panics
103    /// or propagates an error.
104    ///
105    /// # Fields written
106    /// * `timestamp`   — ISO 8601 UTC timestamp
107    /// * `user`        — username from `USER`/`LOGNAME`
108    /// * `module_id`   — the executed module's identifier
109    /// * `input_hash`  — salted SHA-256 of the JSON-serialised input
110    /// * `status`      — `"success"` or `"error"`
111    /// * `exit_code`   — process exit code
112    /// * `duration_ms` — wall-clock execution time in milliseconds
113    pub fn log_execution(
114        &self,
115        module_id: &str,
116        input_data: &Value,
117        status: &str,
118        exit_code: i32,
119        duration_ms: u64,
120    ) {
121        let Some(ref path) = self.path else {
122            return; // logging disabled
123        };
124
125        let timestamp = Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
126        let entry = json!({
127            "timestamp":   timestamp,
128            "user":        Self::get_user(),
129            "module_id":   module_id,
130            "input_hash":  Self::hash_input(input_data),
131            "status":      status,
132            "exit_code":   exit_code,
133            "duration_ms": duration_ms,
134        });
135
136        let result = (|| -> std::io::Result<()> {
137            let file = std::fs::OpenOptions::new()
138                .create(true)
139                .append(true)
140                .open(path)?;
141            let mut writer = BufWriter::new(file);
142            serde_json::to_writer(&mut writer, &entry).map_err(std::io::Error::other)?;
143            writeln!(writer)?;
144            writer.flush()?;
145            Ok(())
146        })();
147
148        if let Err(e) = result {
149            tracing::warn!("Could not write audit log: {e}");
150        }
151    }
152}
153
154/// Errors produced by the audit logger (reserved for future use).
155#[derive(Debug, Error)]
156pub enum AuditLogError {
157    #[error("failed to write audit log: {0}")]
158    Io(#[from] std::io::Error),
159
160    #[error("failed to serialise audit record: {0}")]
161    Serialise(#[from] serde_json::Error),
162}
163
164// ---------------------------------------------------------------------------
165// Unit tests
166// ---------------------------------------------------------------------------
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171    use serde_json::json;
172
173    #[test]
174    fn test_audit_logger_disabled_no_op() {
175        // AuditLogger with path=None must not write any files.
176        let logger = AuditLogger { path: None };
177        // Should not panic even with no path.
178        logger.log_execution("mod.test", &json!({}), "success", 0, 1);
179    }
180
181    #[test]
182    fn test_audit_logger_writes_jsonl_record() {
183        let dir = tempfile::tempdir().unwrap();
184        let path = dir.path().join("audit.jsonl");
185        let logger = AuditLogger::new(Some(path.clone()));
186        logger.log_execution("math.add", &json!({"a": 1}), "success", 0, 42);
187        let content = std::fs::read_to_string(&path).unwrap();
188        let entry: serde_json::Value = serde_json::from_str(content.trim()).unwrap();
189        assert_eq!(entry["module_id"], "math.add");
190        assert_eq!(entry["status"], "success");
191        assert_eq!(entry["exit_code"], 0);
192        assert_eq!(entry["duration_ms"], 42);
193    }
194
195    #[test]
196    fn test_audit_logger_appends_multiple_records() {
197        let dir = tempfile::tempdir().unwrap();
198        let path = dir.path().join("audit.jsonl");
199        let logger = AuditLogger::new(Some(path.clone()));
200        logger.log_execution("a.b", &json!({}), "success", 0, 1);
201        logger.log_execution("c.d", &json!({}), "error", 1, 2);
202        let content = std::fs::read_to_string(&path).unwrap();
203        let lines: Vec<&str> = content.lines().collect();
204        assert_eq!(lines.len(), 2);
205    }
206
207    #[test]
208    fn test_audit_logger_record_contains_required_fields() {
209        let dir = tempfile::tempdir().unwrap();
210        let path = dir.path().join("audit.jsonl");
211        let logger = AuditLogger::new(Some(path.clone()));
212        logger.log_execution("x.y", &json!({"k": "v"}), "success", 0, 10);
213        let raw = std::fs::read_to_string(&path).unwrap();
214        let entry: serde_json::Value = serde_json::from_str(raw.trim()).unwrap();
215        assert!(entry["timestamp"].as_str().unwrap().ends_with('Z'));
216        assert!(entry["user"].is_string());
217        assert_eq!(entry["module_id"], "x.y");
218        assert!(entry["input_hash"].as_str().unwrap().len() == 64); // hex SHA-256
219        assert_eq!(entry["status"], "success");
220        assert!(entry["exit_code"].is_number());
221        assert!(entry["duration_ms"].is_number());
222    }
223}