mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-09 01:49:49 -10:00
fix(audit): DSH integration audits into its own home; tests never touch the real audit.log
The DSH integration takes an explicit Codewhale home (DshPaths::under) for all of its state, but its five integration.dsh.* audit events went through log_sensitive_event, which resolves $CODEWHALE_HOME or the real ~/.codewhale. The DSH unit tests build DshPaths under a tempdir without setting CODEWHALE_HOME, so every run appended to the developer's real audit.log (the founder's has 20,010 integration.dsh lines). - DshPaths records its codewhale_home; the DSH audit events use the new audit::log_sensitive_event_in(home, ...) so they land beside the integration's state. - Under cfg(test), the default audit path with no explicit CODEWHALE_HOME is a per-process temp log, never the real home. - Guard test test_process_never_resolves_the_real_home_audit_log, plus checks that DSH connect writes its event to the test home's audit.log. Tests: - cargo test -p codewhale-tui --lib -- audit integrations::dsh -> 89 passed; 0 failed - Same filter run with CODEWHALE_HOME unset and the real HOME: real ~/.codewhale/audit.log size 11731929 -> 11731929, integration.dsh lines 20010 -> 20010 (unchanged). Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01Vjkk8uP5v3qYaBPonDEzBc
This commit is contained in:
1 parent
99c445c78f
commit
244368675b
3 files changed
+71
-11
No files matched your search
+47
-4
@@ -1,7 +1,7 @@
|
||||
//! Lightweight audit logging for sensitive operations.
|
||||
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use chrono::Utc;
|
||||
use serde_json::{Value, json};
|
||||
@@ -14,7 +14,18 @@ use crate::utils::{flush_and_sync, open_append};
|
||||
/// This helper is best-effort by design: callers should not fail critical flows
|
||||
/// if audit persistence fails.
|
||||
pub fn log_sensitive_event(event: &str, details: Value) {
|
||||
if let Err(err) = append_event(event, details) {
|
||||
let result = default_audit_path().and_then(|path| append_event(&path, event, details));
|
||||
if let Err(err) = result {
|
||||
crate::logging::warn(format!("audit log write failed: {err}"));
|
||||
}
|
||||
}
|
||||
|
||||
/// Append an audit event to `<codewhale_home>/audit.log` for a caller that
|
||||
/// already owns an explicit Codewhale home (for example the DSH integration's
|
||||
/// `DshPaths`), so its audit record lands beside the rest of its state rather
|
||||
/// than in whatever home the process environment names (#6534).
|
||||
pub fn log_sensitive_event_in(codewhale_home: &Path, event: &str, details: Value) {
|
||||
if let Err(err) = append_event(&codewhale_home.join("audit.log"), event, details) {
|
||||
crate::logging::warn(format!("audit log write failed: {err}"));
|
||||
}
|
||||
}
|
||||
@@ -48,8 +59,8 @@ fn rotate_if_oversized(path: &std::path::Path) {
|
||||
let _ = fs::rename(path, std::path::Path::new(&rolled));
|
||||
}
|
||||
|
||||
fn append_event(event: &str, details: Value) -> anyhow::Result<()> {
|
||||
let path = default_audit_path()?;
|
||||
fn append_event(path: &Path, event: &str, details: Value) -> anyhow::Result<()> {
|
||||
let path = path.to_path_buf();
|
||||
let parent = path.parent().map(|p| p.to_path_buf());
|
||||
if let Some(ref parent) = parent {
|
||||
fs::create_dir_all(parent)?;
|
||||
@@ -71,6 +82,15 @@ fn append_event(event: &str, details: Value) -> anyhow::Result<()> {
|
||||
}
|
||||
|
||||
fn default_audit_path() -> anyhow::Result<PathBuf> {
|
||||
// A test process without an explicit CODEWHALE_HOME must never append to
|
||||
// the developer's real ~/.codewhale/audit.log (#6534); it gets a
|
||||
// per-process scratch log instead.
|
||||
#[cfg(test)]
|
||||
if !codewhale_config::codewhale_home_is_explicit() {
|
||||
return Ok(std::env::temp_dir()
|
||||
.join(format!("codewhale-test-audit-{}", std::process::id()))
|
||||
.join("audit.log"));
|
||||
}
|
||||
Ok(codewhale_config::codewhale_home()?.join("audit.log"))
|
||||
}
|
||||
|
||||
@@ -86,6 +106,29 @@ pub fn audit_log_path() -> Option<PathBuf> {
|
||||
mod tests {
|
||||
use super::{AUDIT_LOG_ROTATE_BYTES, rotate_if_oversized};
|
||||
|
||||
/// #6534 guard: with no explicit CODEWHALE_HOME, a test process resolves
|
||||
/// the audit log outside the real user home, so no test can append to it.
|
||||
#[test]
|
||||
fn test_process_never_resolves_the_real_home_audit_log() {
|
||||
let _lock = crate::test_support::lock_test_env();
|
||||
let _home = crate::test_support::EnvVarGuard::remove("CODEWHALE_HOME");
|
||||
let path = super::audit_log_path().expect("audit path");
|
||||
let real = codewhale_paths::user_home()
|
||||
.expect("user home")
|
||||
.join(".codewhale")
|
||||
.join("audit.log");
|
||||
assert_ne!(path, real);
|
||||
assert!(path.starts_with(std::env::temp_dir()), "{}", path.display());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_explicit_home_receives_its_own_events() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
super::log_sensitive_event_in(dir.path(), "test.event", serde_json::json!({"k": 1}));
|
||||
let log = std::fs::read_to_string(dir.path().join("audit.log")).expect("audit.log");
|
||||
assert!(log.contains("\"event\":\"test.event\""), "{log}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_small_log_is_left_alone() {
|
||||
let dir = tempfile::TempDir::new().expect("tempdir");
|
||||
|
||||
@@ -55,6 +55,9 @@ pub(crate) const CLI_COMMAND: &str = "codewhale integrations dsh";
|
||||
/// Codewhale-owned files for this integration.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct DshPaths {
|
||||
/// The Codewhale home these paths live under; the integration's audit
|
||||
/// events are written to its `audit.log` (#6534).
|
||||
pub(crate) codewhale_home: PathBuf,
|
||||
pub(crate) root: PathBuf,
|
||||
pub(crate) overlay: PathBuf,
|
||||
pub(crate) receipt: PathBuf,
|
||||
@@ -74,6 +77,7 @@ impl DshPaths {
|
||||
skin_preview: root.join(SKIN_PREVIEW_FILE),
|
||||
bundle_dir: root.join(bundle::BUNDLE_DIR),
|
||||
root,
|
||||
codewhale_home: codewhale_home.to_path_buf(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -519,7 +523,8 @@ pub(crate) fn apply_plan(
|
||||
});
|
||||
doc.current = Some(record.clone());
|
||||
doc.save(&paths.receipt)?;
|
||||
crate::audit::log_sensitive_event(
|
||||
crate::audit::log_sensitive_event_in(
|
||||
&paths.codewhale_home,
|
||||
&format!("integration.dsh.{}", event.as_str()),
|
||||
serde_json::json!({
|
||||
"overlay_path": paths.overlay.display().to_string(),
|
||||
@@ -562,7 +567,8 @@ pub(crate) fn set_disabled(paths: &DshPaths, disabled: bool) -> Result<DshConnec
|
||||
});
|
||||
doc.current = Some(record.clone());
|
||||
doc.save(&paths.receipt)?;
|
||||
crate::audit::log_sensitive_event(
|
||||
crate::audit::log_sensitive_event_in(
|
||||
&paths.codewhale_home,
|
||||
&format!("integration.dsh.{}", event.as_str()),
|
||||
serde_json::json!({ "overlay_path": paths.overlay.display().to_string() }),
|
||||
);
|
||||
@@ -610,7 +616,8 @@ pub(crate) fn remove(paths: &DshPaths) -> Result<Vec<PathBuf>> {
|
||||
)),
|
||||
});
|
||||
doc.save(&paths.receipt)?;
|
||||
crate::audit::log_sensitive_event(
|
||||
crate::audit::log_sensitive_event_in(
|
||||
&paths.codewhale_home,
|
||||
"integration.dsh.remove",
|
||||
serde_json::json!({ "removed": removed.iter().map(|p| p.display().to_string()).collect::<Vec<_>>() }),
|
||||
);
|
||||
@@ -862,7 +869,8 @@ pub(crate) fn install_bundle(
|
||||
});
|
||||
doc.current = Some(record);
|
||||
doc.save(&paths.receipt)?;
|
||||
crate::audit::log_sensitive_event(
|
||||
crate::audit::log_sensitive_event_in(
|
||||
&paths.codewhale_home,
|
||||
"integration.dsh.install_bundle",
|
||||
serde_json::json!({
|
||||
"profile_dir": bundle_record.profile_dir.display().to_string(),
|
||||
@@ -914,7 +922,8 @@ pub(crate) fn remove_bundle(
|
||||
});
|
||||
doc.current = Some(record);
|
||||
doc.save(&paths.receipt)?;
|
||||
crate::audit::log_sensitive_event(
|
||||
crate::audit::log_sensitive_event_in(
|
||||
&paths.codewhale_home,
|
||||
"integration.dsh.remove_bundle",
|
||||
serde_json::json!({ "removed": removed.iter().map(|p| p.display().to_string()).collect::<Vec<_>>() }),
|
||||
);
|
||||
|
||||
@@ -604,10 +604,13 @@ fn connect_update_disable_enable_remove_lifecycle_writes_only_owned_files() {
|
||||
);
|
||||
let report = compute_status(&paths, detection, Ok(moved), false, avail()).unwrap();
|
||||
assert!(matches!(report.state, DshIntegrationState::Detected { .. }));
|
||||
// Every write stayed under the integration root.
|
||||
// Every write stayed under the integration root, apart from Codewhale's
|
||||
// own audit log in the same home (#6534).
|
||||
let audit_log = paths.codewhale_home.join("audit.log");
|
||||
assert!(audit_log.is_file(), "audit events land in this home");
|
||||
for entry in walk(&paths.root.parent().unwrap().parent().unwrap().to_path_buf()) {
|
||||
assert!(
|
||||
entry.starts_with(&paths.root),
|
||||
entry.starts_with(&paths.root) || entry == audit_log,
|
||||
"unexpected file {}",
|
||||
entry.display()
|
||||
);
|
||||
@@ -1097,6 +1100,11 @@ fn install_update_remove_bundle_lifecycle_uses_documented_plugin_commands() {
|
||||
assert!(install_bundle(&paths, &detection, &runner, &avail(), DshAppBundle::Web).is_err());
|
||||
let plan = super::plan(&paths, &detection, &id, "web", false, false, true).unwrap();
|
||||
apply_plan(&paths, &detection, &plan, DshReceiptEvent::Connect).unwrap();
|
||||
// #6534: the audit event lands in this test's Codewhale home, not the
|
||||
// process's (real) home.
|
||||
let audit = std::fs::read_to_string(paths.codewhale_home.join("audit.log"))
|
||||
.expect("audit log beside the integration state");
|
||||
assert!(audit.contains("integration.dsh.connect"), "{audit}");
|
||||
|
||||
// pnpm missing → truthful refusal, nothing written.
|
||||
let err = install_bundle(
|
||||
|
||||
Reference in new issue
Block a user