readd tests, remove markdown files
This commit is contained in:
parent
e2634c72c2
commit
9a6fa76825
17 changed files with 1352 additions and 0 deletions
|
|
@ -332,4 +332,92 @@ mod tests {
|
|||
assert!(!tmp.path().join("audit.log").exists());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── §8.1 Log rotation tests ─────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn audit_logger_writes_event_when_enabled() -> Result<()> {
|
||||
let tmp = TempDir::new()?;
|
||||
let config = AuditConfig {
|
||||
enabled: true,
|
||||
max_size_mb: 10,
|
||||
..Default::default()
|
||||
};
|
||||
let logger = AuditLogger::new(config, tmp.path().to_path_buf())?;
|
||||
let event = AuditEvent::new(AuditEventType::CommandExecution)
|
||||
.with_actor("cli".to_string(), None, None)
|
||||
.with_action("ls".to_string(), "low".to_string(), false, true);
|
||||
|
||||
logger.log(&event)?;
|
||||
|
||||
let log_path = tmp.path().join("audit.log");
|
||||
assert!(log_path.exists(), "audit log file must be created");
|
||||
|
||||
let content = std::fs::read_to_string(&log_path)?;
|
||||
assert!(!content.is_empty(), "audit log must not be empty");
|
||||
|
||||
let parsed: AuditEvent = serde_json::from_str(content.trim())?;
|
||||
assert!(parsed.action.is_some());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audit_log_command_event_writes_structured_entry() -> Result<()> {
|
||||
let tmp = TempDir::new()?;
|
||||
let config = AuditConfig {
|
||||
enabled: true,
|
||||
max_size_mb: 10,
|
||||
..Default::default()
|
||||
};
|
||||
let logger = AuditLogger::new(config, tmp.path().to_path_buf())?;
|
||||
|
||||
logger.log_command_event(CommandExecutionLog {
|
||||
channel: "telegram",
|
||||
command: "echo test",
|
||||
risk_level: "low",
|
||||
approved: false,
|
||||
allowed: true,
|
||||
success: true,
|
||||
duration_ms: 42,
|
||||
})?;
|
||||
|
||||
let log_path = tmp.path().join("audit.log");
|
||||
let content = std::fs::read_to_string(&log_path)?;
|
||||
let parsed: AuditEvent = serde_json::from_str(content.trim())?;
|
||||
|
||||
let action = parsed.action.unwrap();
|
||||
assert_eq!(action.command, Some("echo test".to_string()));
|
||||
assert_eq!(action.risk_level, Some("low".to_string()));
|
||||
assert!(action.allowed);
|
||||
|
||||
let result = parsed.result.unwrap();
|
||||
assert!(result.success);
|
||||
assert_eq!(result.duration_ms, Some(42));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audit_rotation_creates_numbered_backup() -> Result<()> {
|
||||
let tmp = TempDir::new()?;
|
||||
let config = AuditConfig {
|
||||
enabled: true,
|
||||
max_size_mb: 0, // Force rotation on first write
|
||||
..Default::default()
|
||||
};
|
||||
let logger = AuditLogger::new(config, tmp.path().to_path_buf())?;
|
||||
|
||||
// Write initial content that triggers rotation
|
||||
let log_path = tmp.path().join("audit.log");
|
||||
std::fs::write(&log_path, "initial content\n")?;
|
||||
|
||||
let event = AuditEvent::new(AuditEventType::CommandExecution);
|
||||
logger.log(&event)?;
|
||||
|
||||
let rotated = format!("{}.1.log", log_path.display());
|
||||
assert!(
|
||||
std::path::Path::new(&rotated).exists(),
|
||||
"rotation must create .1.log backup"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -94,4 +94,90 @@ mod tests {
|
|||
// Either way, the name should still work
|
||||
assert_eq!(sandbox.name(), "bubblewrap");
|
||||
}
|
||||
|
||||
// ── §1.1 Sandbox isolation flag tests ──────────────────────
|
||||
|
||||
#[test]
|
||||
fn bubblewrap_wrap_command_includes_isolation_flags() {
|
||||
let sandbox = BubblewrapSandbox;
|
||||
let mut cmd = Command::new("echo");
|
||||
cmd.arg("hello");
|
||||
sandbox.wrap_command(&mut cmd).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
cmd.get_program().to_string_lossy(),
|
||||
"bwrap",
|
||||
"wrapped command should use bwrap as program"
|
||||
);
|
||||
|
||||
let args: Vec<String> = cmd
|
||||
.get_args()
|
||||
.map(|s| s.to_string_lossy().to_string())
|
||||
.collect();
|
||||
|
||||
assert!(
|
||||
args.contains(&"--unshare-all".to_string()),
|
||||
"must include --unshare-all for namespace isolation"
|
||||
);
|
||||
assert!(
|
||||
args.contains(&"--die-with-parent".to_string()),
|
||||
"must include --die-with-parent to prevent orphan processes"
|
||||
);
|
||||
assert!(
|
||||
!args.contains(&"--share-net".to_string()),
|
||||
"must NOT include --share-net (network should be blocked)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bubblewrap_wrap_command_preserves_original_command() {
|
||||
let sandbox = BubblewrapSandbox;
|
||||
let mut cmd = Command::new("ls");
|
||||
cmd.arg("-la");
|
||||
cmd.arg("/tmp");
|
||||
sandbox.wrap_command(&mut cmd).unwrap();
|
||||
|
||||
let args: Vec<String> = cmd
|
||||
.get_args()
|
||||
.map(|s| s.to_string_lossy().to_string())
|
||||
.collect();
|
||||
|
||||
assert!(
|
||||
args.contains(&"ls".to_string()),
|
||||
"original program must be passed as argument"
|
||||
);
|
||||
assert!(
|
||||
args.contains(&"-la".to_string()),
|
||||
"original args must be preserved"
|
||||
);
|
||||
assert!(
|
||||
args.contains(&"/tmp".to_string()),
|
||||
"original args must be preserved"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bubblewrap_wrap_command_binds_required_paths() {
|
||||
let sandbox = BubblewrapSandbox;
|
||||
let mut cmd = Command::new("echo");
|
||||
sandbox.wrap_command(&mut cmd).unwrap();
|
||||
|
||||
let args: Vec<String> = cmd
|
||||
.get_args()
|
||||
.map(|s| s.to_string_lossy().to_string())
|
||||
.collect();
|
||||
|
||||
assert!(
|
||||
args.contains(&"--ro-bind".to_string()),
|
||||
"must include read-only bind for /usr"
|
||||
);
|
||||
assert!(
|
||||
args.contains(&"--dev".to_string()),
|
||||
"must include /dev mount"
|
||||
);
|
||||
assert!(
|
||||
args.contains(&"--proc".to_string()),
|
||||
"must include /proc mount"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -117,4 +117,100 @@ mod tests {
|
|||
Err(_) => assert!(!DockerSandbox::is_installed()),
|
||||
}
|
||||
}
|
||||
|
||||
// ── §1.1 Sandbox isolation flag tests ──────────────────────
|
||||
|
||||
#[test]
|
||||
fn docker_wrap_command_includes_isolation_flags() {
|
||||
let sandbox = DockerSandbox::default();
|
||||
let mut cmd = Command::new("echo");
|
||||
cmd.arg("hello");
|
||||
sandbox.wrap_command(&mut cmd).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
cmd.get_program().to_string_lossy(),
|
||||
"docker",
|
||||
"wrapped command should use docker as program"
|
||||
);
|
||||
|
||||
let args: Vec<String> = cmd
|
||||
.get_args()
|
||||
.map(|s| s.to_string_lossy().to_string())
|
||||
.collect();
|
||||
|
||||
assert!(
|
||||
args.contains(&"run".to_string()),
|
||||
"must include 'run' subcommand"
|
||||
);
|
||||
assert!(
|
||||
args.contains(&"--rm".to_string()),
|
||||
"must include --rm for auto-cleanup"
|
||||
);
|
||||
assert!(
|
||||
args.contains(&"--network".to_string()),
|
||||
"must include --network flag"
|
||||
);
|
||||
assert!(
|
||||
args.contains(&"none".to_string()),
|
||||
"network must be set to 'none' for isolation"
|
||||
);
|
||||
assert!(
|
||||
args.contains(&"--memory".to_string()),
|
||||
"must include --memory limit"
|
||||
);
|
||||
assert!(
|
||||
args.contains(&"512m".to_string()),
|
||||
"memory limit must be 512m"
|
||||
);
|
||||
assert!(
|
||||
args.contains(&"--cpus".to_string()),
|
||||
"must include --cpus limit"
|
||||
);
|
||||
assert!(args.contains(&"1.0".to_string()), "CPU limit must be 1.0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn docker_wrap_command_preserves_original_command() {
|
||||
let sandbox = DockerSandbox::default();
|
||||
let mut cmd = Command::new("ls");
|
||||
cmd.arg("-la");
|
||||
sandbox.wrap_command(&mut cmd).unwrap();
|
||||
|
||||
let args: Vec<String> = cmd
|
||||
.get_args()
|
||||
.map(|s| s.to_string_lossy().to_string())
|
||||
.collect();
|
||||
|
||||
assert!(
|
||||
args.contains(&"alpine:latest".to_string()),
|
||||
"must include the container image"
|
||||
);
|
||||
assert!(
|
||||
args.contains(&"ls".to_string()),
|
||||
"original program must be passed as argument"
|
||||
);
|
||||
assert!(
|
||||
args.contains(&"-la".to_string()),
|
||||
"original args must be preserved"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn docker_wrap_command_uses_custom_image() {
|
||||
let sandbox = DockerSandbox {
|
||||
image: "ubuntu:22.04".to_string(),
|
||||
};
|
||||
let mut cmd = Command::new("echo");
|
||||
sandbox.wrap_command(&mut cmd).unwrap();
|
||||
|
||||
let args: Vec<String> = cmd
|
||||
.get_args()
|
||||
.map(|s| s.to_string_lossy().to_string())
|
||||
.collect();
|
||||
|
||||
assert!(
|
||||
args.contains(&"ubuntu:22.04".to_string()),
|
||||
"must use the custom image"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -125,4 +125,71 @@ mod tests {
|
|||
assert_eq!(cmd.get_program().to_string_lossy(), "firejail");
|
||||
}
|
||||
}
|
||||
|
||||
// ── §1.1 Sandbox isolation flag tests ──────────────────────
|
||||
|
||||
#[test]
|
||||
fn firejail_wrap_command_includes_all_security_flags() {
|
||||
let sandbox = FirejailSandbox;
|
||||
let mut cmd = Command::new("echo");
|
||||
cmd.arg("test");
|
||||
sandbox.wrap_command(&mut cmd).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
cmd.get_program().to_string_lossy(),
|
||||
"firejail",
|
||||
"wrapped command should use firejail as program"
|
||||
);
|
||||
|
||||
let args: Vec<String> = cmd
|
||||
.get_args()
|
||||
.map(|s| s.to_string_lossy().to_string())
|
||||
.collect();
|
||||
|
||||
let expected_flags = [
|
||||
"--private=home",
|
||||
"--private-dev",
|
||||
"--nosound",
|
||||
"--no3d",
|
||||
"--novideo",
|
||||
"--nowheel",
|
||||
"--notv",
|
||||
"--noprofile",
|
||||
"--quiet",
|
||||
];
|
||||
|
||||
for flag in &expected_flags {
|
||||
assert!(
|
||||
args.contains(&flag.to_string()),
|
||||
"must include security flag: {flag}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn firejail_wrap_command_preserves_original_command() {
|
||||
let sandbox = FirejailSandbox;
|
||||
let mut cmd = Command::new("ls");
|
||||
cmd.arg("-la");
|
||||
cmd.arg("/workspace");
|
||||
sandbox.wrap_command(&mut cmd).unwrap();
|
||||
|
||||
let args: Vec<String> = cmd
|
||||
.get_args()
|
||||
.map(|s| s.to_string_lossy().to_string())
|
||||
.collect();
|
||||
|
||||
assert!(
|
||||
args.contains(&"ls".to_string()),
|
||||
"original program must be passed as argument"
|
||||
);
|
||||
assert!(
|
||||
args.contains(&"-la".to_string()),
|
||||
"original args must be preserved"
|
||||
);
|
||||
assert!(
|
||||
args.contains(&"/workspace".to_string()),
|
||||
"original args must be preserved"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -231,4 +231,31 @@ mod tests {
|
|||
))),
|
||||
}
|
||||
}
|
||||
|
||||
// ── §1.1 Landlock stub tests ──────────────────────────────
|
||||
|
||||
#[cfg(not(all(feature = "sandbox-landlock", target_os = "linux")))]
|
||||
#[test]
|
||||
fn landlock_stub_wrap_command_returns_unsupported() {
|
||||
let sandbox = LandlockSandbox;
|
||||
let mut cmd = std::process::Command::new("echo");
|
||||
let result = sandbox.wrap_command(&mut cmd);
|
||||
assert!(result.is_err());
|
||||
assert_eq!(result.unwrap_err().kind(), std::io::ErrorKind::Unsupported);
|
||||
}
|
||||
|
||||
#[cfg(not(all(feature = "sandbox-landlock", target_os = "linux")))]
|
||||
#[test]
|
||||
fn landlock_stub_new_returns_unsupported() {
|
||||
let result = LandlockSandbox::new();
|
||||
assert!(result.is_err());
|
||||
assert_eq!(result.unwrap_err().kind(), std::io::ErrorKind::Unsupported);
|
||||
}
|
||||
|
||||
#[cfg(not(all(feature = "sandbox-landlock", target_os = "linux")))]
|
||||
#[test]
|
||||
fn landlock_stub_probe_returns_unsupported() {
|
||||
let result = LandlockSandbox::probe();
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1388,4 +1388,112 @@ mod tests {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── §1.2 Path resolution / symlink bypass tests ──────────
|
||||
|
||||
#[test]
|
||||
fn resolved_path_blocks_outside_workspace() {
|
||||
let workspace = std::env::temp_dir().join("zeroclaw_test_resolved_path");
|
||||
let _ = std::fs::create_dir_all(&workspace);
|
||||
|
||||
// Use the canonicalized workspace so starts_with checks match
|
||||
let canonical_workspace = workspace
|
||||
.canonicalize()
|
||||
.unwrap_or_else(|_| workspace.clone());
|
||||
|
||||
let policy = SecurityPolicy {
|
||||
workspace_dir: canonical_workspace.clone(),
|
||||
..SecurityPolicy::default()
|
||||
};
|
||||
|
||||
// A resolved path inside the workspace should be allowed
|
||||
let inside = canonical_workspace.join("subdir").join("file.txt");
|
||||
assert!(
|
||||
policy.is_resolved_path_allowed(&inside),
|
||||
"path inside workspace should be allowed"
|
||||
);
|
||||
|
||||
// A resolved path outside the workspace should be blocked
|
||||
let canonical_temp = std::env::temp_dir()
|
||||
.canonicalize()
|
||||
.unwrap_or_else(|_| std::env::temp_dir());
|
||||
let outside = canonical_temp.join("outside_workspace_zeroclaw");
|
||||
assert!(
|
||||
!policy.is_resolved_path_allowed(&outside),
|
||||
"path outside workspace must be blocked"
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&workspace);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolved_path_blocks_root_escape() {
|
||||
let policy = SecurityPolicy {
|
||||
workspace_dir: PathBuf::from("/home/zeroclaw_user/project"),
|
||||
..SecurityPolicy::default()
|
||||
};
|
||||
|
||||
assert!(
|
||||
!policy.is_resolved_path_allowed(Path::new("/etc/passwd")),
|
||||
"resolved path to /etc/passwd must be blocked"
|
||||
);
|
||||
assert!(
|
||||
!policy.is_resolved_path_allowed(Path::new("/root/.bashrc")),
|
||||
"resolved path to /root/.bashrc must be blocked"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn resolved_path_blocks_symlink_escape() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let root = std::env::temp_dir().join("zeroclaw_test_symlink_escape");
|
||||
let workspace = root.join("workspace");
|
||||
let outside = root.join("outside_target");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
std::fs::create_dir_all(&workspace).unwrap();
|
||||
std::fs::create_dir_all(&outside).unwrap();
|
||||
|
||||
// Create a symlink inside workspace pointing outside
|
||||
let link_path = workspace.join("escape_link");
|
||||
symlink(&outside, &link_path).unwrap();
|
||||
|
||||
let policy = SecurityPolicy {
|
||||
workspace_dir: workspace.clone(),
|
||||
..SecurityPolicy::default()
|
||||
};
|
||||
|
||||
// The resolved symlink target should be outside workspace
|
||||
let resolved = link_path.canonicalize().unwrap();
|
||||
assert!(
|
||||
!policy.is_resolved_path_allowed(&resolved),
|
||||
"symlink-resolved path outside workspace must be blocked"
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_path_allowed_blocks_null_bytes() {
|
||||
let policy = default_policy();
|
||||
assert!(
|
||||
!policy.is_path_allowed("file\0.txt"),
|
||||
"paths with null bytes must be blocked"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_path_allowed_blocks_url_encoded_traversal() {
|
||||
let policy = default_policy();
|
||||
assert!(
|
||||
!policy.is_path_allowed("..%2fetc%2fpasswd"),
|
||||
"URL-encoded path traversal must be blocked"
|
||||
);
|
||||
assert!(
|
||||
!policy.is_path_allowed("subdir%2f..%2f..%2fetc"),
|
||||
"URL-encoded parent dir traversal must be blocked"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue