| 1 | //! Lightweight audit logging for sensitive operations. |
| 2 | |
| 3 | use std::fs; |
| 4 | use std::path::{Path, PathBuf}; |
| 5 | |
| 6 | use chrono::Utc; |
| 7 | use serde_json::{Value, json}; |
| 8 | |
| 9 | use crate::utils::{flush_and_sync, open_append}; |
| 10 | |
| 11 | /// Append an audit event to `$CODEWHALE_HOME/audit.log` (or the default |
| 12 | /// `~/.codewhale/audit.log` when no explicit CodeWhale home is configured). |
| 13 | /// |
| 14 | /// This helper is best-effort by design: callers should not fail critical flows |
| 15 | /// if audit persistence fails. |
| 16 | pub fn log_sensitive_event(event: &str, details: Value) { |
| 17 | let result = default_audit_path().and_then(|path| append_event(&path, event, details)); |
| 18 | if let Err(err) = result { |
| 19 | crate::logging::warn(format!("audit log write failed: {err}")); |
| 20 | } |
| 21 | } |
| 22 | |
| 23 | /// Append an audit event to `<codewhale_home>/audit.log` for a caller that |
| 24 | /// already owns an explicit Codewhale home (for example the DSH integration's |
| 25 | /// `DshPaths`), so its audit record lands beside the rest of its state rather |
| 26 | /// than in whatever home the process environment names (#6534). |
| 27 | pub fn log_sensitive_event_in(codewhale_home: &Path, event: &str, details: Value) { |
| 28 | if let Err(err) = append_event(&codewhale_home.join("audit.log"), event, details) { |
| 29 | crate::logging::warn(format!("audit log write failed: {err}")); |
| 30 | } |
| 31 | } |
| 32 | |
| 33 | /// Size at which `audit.log` is rolled to `audit.log.1`. |
| 34 | /// |
| 35 | /// The log was append-only with no bound at all: a real `~/.codewhale/audit.log` |
| 36 | /// had reached 2.6 MB and was still growing, with nothing in the product that |
| 37 | /// would ever shrink it. Unbounded growth in the user's config directory is not |
| 38 | /// a viable end state, and neither is silently discarding the record — so one |
| 39 | /// previous generation is kept, which bounds the pair at ~2× this value while |
| 40 | /// preserving well over a year of ordinary use. |
| 41 | const AUDIT_LOG_ROTATE_BYTES: u64 = 16 * 1024 * 1024; |
| 42 | |
| 43 | /// Roll `audit.log` to `audit.log.1` once it passes [`AUDIT_LOG_ROTATE_BYTES`]. |
| 44 | /// |
| 45 | /// Exactly one previous generation is kept; the older `.1` is replaced. Rolling |
| 46 | /// is a rename, so no record is ever rewritten in place and an event is never |
| 47 | /// lost to a partially-copied file. |
| 48 | /// |
| 49 | /// Best-effort by the same rule as the rest of this module: if the roll fails |
| 50 | /// the event is still appended to the existing file. A too-large audit log is a |
| 51 | /// far better outcome than a dropped audit record. |
| 52 | fn rotate_if_oversized(path: &std::path::Path) { |
| 53 | let oversized = fs::metadata(path).is_ok_and(|meta| meta.len() >= AUDIT_LOG_ROTATE_BYTES); |
| 54 | if !oversized { |
| 55 | return; |
| 56 | } |
| 57 | let mut rolled = path.as_os_str().to_owned(); |
| 58 | rolled.push(".1"); |
| 59 | let _ = fs::rename(path, std::path::Path::new(&rolled)); |
| 60 | } |
| 61 | |
| 62 | fn append_event(path: &Path, event: &str, details: Value) -> anyhow::Result<()> { |
| 63 | let path = path.to_path_buf(); |
| 64 | let parent = path.parent().map(|p| p.to_path_buf()); |
| 65 | if let Some(ref parent) = parent { |
| 66 | fs::create_dir_all(parent)?; |
| 67 | } |
| 68 | rotate_if_oversized(&path); |
| 69 | // Open for append with a BufWriter for buffered I/O, then flush + fsync |
| 70 | // after each event so the record is durably on disk. |
| 71 | let mut writer = open_append(&path)?; |
| 72 | let record = json!({ |
| 73 | "ts": Utc::now().to_rfc3339(), |
| 74 | "event": event, |
| 75 | "details": details, |
| 76 | }); |
| 77 | let line = serde_json::to_string(&record)?; |
| 78 | use std::io::Write; |
| 79 | writeln!(writer, "{line}")?; |
| 80 | flush_and_sync(&mut writer)?; |
| 81 | Ok(()) |
| 82 | } |
| 83 | |
| 84 | fn default_audit_path() -> anyhow::Result<PathBuf> { |
| 85 | // A test that has not sealed its own home must never append to the |
| 86 | // developer's real ~/.codewhale/audit.log (#6534); it gets a per-process |
| 87 | // scratch log instead. An *ambient* CODEWHALE_HOME is not a seal: it names |
| 88 | // somebody's real profile just as `~/.codewhale` does. |
| 89 | #[cfg(test)] |
| 90 | if !crate::test_support::home_is_sealed() { |
| 91 | return Ok(std::env::temp_dir() |
| 92 | .join(format!("codewhale-test-audit-{}", std::process::id())) |
| 93 | .join("audit.log")); |
| 94 | } |
| 95 | Ok(codewhale_config::codewhale_home()?.join("audit.log")) |
| 96 | } |
| 97 | |
| 98 | /// Where audit events are written, for surfaces that point a person at the |
| 99 | /// full record (for example `/permissions`). `None` when no Codewhale home |
| 100 | /// resolves; callers show a placeholder rather than guessing a path. |
| 101 | #[must_use] |
| 102 | pub fn audit_log_path() -> Option<PathBuf> { |
| 103 | default_audit_path().ok() |
| 104 | } |
| 105 | |
| 106 | #[cfg(test)] |
| 107 | mod tests { |
| 108 | use super::{AUDIT_LOG_ROTATE_BYTES, rotate_if_oversized}; |
| 109 | |
| 110 | /// #6534 guard: with no explicit CODEWHALE_HOME, a test process resolves |
| 111 | /// the audit log outside the real user home, so no test can append to it. |
| 112 | #[test] |
| 113 | fn test_process_never_resolves_the_real_home_audit_log() { |
| 114 | let _lock = crate::test_support::lock_test_env(); |
| 115 | let _home = crate::test_support::EnvVarGuard::remove("CODEWHALE_HOME"); |
| 116 | let path = super::audit_log_path().expect("audit path"); |
| 117 | let real = codewhale_paths::user_home() |
| 118 | .expect("user home") |
| 119 | .join(".codewhale") |
| 120 | .join("audit.log"); |
| 121 | assert_ne!(path, real); |
| 122 | assert!(path.starts_with(std::env::temp_dir()), "{}", path.display()); |
| 123 | } |
| 124 | |
| 125 | #[test] |
| 126 | fn an_explicit_home_receives_its_own_events() { |
| 127 | let dir = tempfile::tempdir().expect("tempdir"); |
| 128 | super::log_sensitive_event_in(dir.path(), "test.event", serde_json::json!({"k": 1})); |
| 129 | let log = std::fs::read_to_string(dir.path().join("audit.log")).expect("audit.log"); |
| 130 | assert!(log.contains("\"event\":\"test.event\""), "{log}"); |
| 131 | } |
| 132 | |
| 133 | #[test] |
| 134 | fn a_small_log_is_left_alone() { |
| 135 | let dir = tempfile::TempDir::new().expect("tempdir"); |
| 136 | let path = dir.path().join("audit.log"); |
| 137 | std::fs::write(&path, b"{}\n").expect("write"); |
| 138 | rotate_if_oversized(&path); |
| 139 | assert!(path.exists(), "an ordinary log is never rolled"); |
| 140 | assert!(!dir.path().join("audit.log.1").exists()); |
| 141 | } |
| 142 | |
| 143 | #[test] |
| 144 | fn an_oversized_log_rolls_to_one_previous_generation() { |
| 145 | let dir = tempfile::TempDir::new().expect("tempdir"); |
| 146 | let path = dir.path().join("audit.log"); |
| 147 | let rolled = dir.path().join("audit.log.1"); |
| 148 | std::fs::write(&path, vec![b'x'; AUDIT_LOG_ROTATE_BYTES as usize]).expect("write"); |
| 149 | |
| 150 | rotate_if_oversized(&path); |
| 151 | |
| 152 | assert!( |
| 153 | !path.exists(), |
| 154 | "the live log is rolled aside, not truncated" |
| 155 | ); |
| 156 | assert_eq!( |
| 157 | std::fs::metadata(&rolled).expect("rolled log").len(), |
| 158 | AUDIT_LOG_ROTATE_BYTES, |
| 159 | "the previous generation keeps every byte — rolling is a rename" |
| 160 | ); |
| 161 | } |
| 162 | |
| 163 | #[test] |
| 164 | fn rolling_twice_keeps_exactly_one_previous_generation() { |
| 165 | let dir = tempfile::TempDir::new().expect("tempdir"); |
| 166 | let path = dir.path().join("audit.log"); |
| 167 | let rolled = dir.path().join("audit.log.1"); |
| 168 | |
| 169 | std::fs::write(&path, vec![b'a'; AUDIT_LOG_ROTATE_BYTES as usize]).expect("first"); |
| 170 | rotate_if_oversized(&path); |
| 171 | std::fs::write(&path, vec![b'b'; AUDIT_LOG_ROTATE_BYTES as usize]).expect("second"); |
| 172 | rotate_if_oversized(&path); |
| 173 | |
| 174 | let kept = std::fs::read(&rolled).expect("rolled log"); |
| 175 | assert_eq!(kept.len(), AUDIT_LOG_ROTATE_BYTES as usize); |
| 176 | assert_eq!(kept[0], b'b', "the newer generation replaces the older one"); |
| 177 | assert!( |
| 178 | !dir.path().join("audit.log.2").exists(), |
| 179 | "generations must not accumulate — that is the bug being fixed" |
| 180 | ); |
| 181 | } |
| 182 | |
| 183 | #[test] |
| 184 | fn a_missing_log_is_not_an_error() { |
| 185 | let dir = tempfile::TempDir::new().expect("tempdir"); |
| 186 | rotate_if_oversized(&dir.path().join("audit.log")); |
| 187 | } |
| 188 | } |
| 189 |