apcore_cli/security/
audit.rs1use 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
12const HASH_SALT: &str = "apcore-cli-audit-v1";
17
18#[derive(Debug, Clone)]
26pub struct AuditLogger {
27 path: Option<PathBuf>,
28}
29
30impl AuditLogger {
31 pub fn default_path() -> Option<PathBuf> {
33 dirs::home_dir().map(|h| h.join(".apcore-cli").join("audit.jsonl"))
34 }
35
36 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 let _ = std::fs::create_dir_all(parent);
47 }
48 }
49 Self { path: resolved }
50 }
51
52 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 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 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 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; };
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#[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#[cfg(test)]
169mod tests {
170 use super::*;
171 use serde_json::json;
172
173 #[test]
174 fn test_audit_logger_disabled_no_op() {
175 let logger = AuditLogger { path: None };
177 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); assert_eq!(entry["status"], "success");
220 assert!(entry["exit_code"].is_number());
221 assert!(entry["duration_ms"].is_number());
222 }
223}