use std::{
io::{
BufWriter,
stderr,
stdout,
},
path::PathBuf,
};
use args::{
DebuggerArgs,
PtraceArgs,
TuiModeArgs,
};
use clap::{
CommandFactory,
Parser,
Subcommand,
};
use config::Config;
use options::ExportFormat;
use tracing::debug;
use self::{
args::{
LogModeArgs,
ModifierArgs,
TracerEventArgs,
},
options::Color,
};
use crate::{
cli::args::ExporterArgs,
output::Output,
};
pub mod args;
pub mod config;
pub mod keys;
pub mod options;
pub mod theme;
pub mod tui_theme;
#[derive(Parser, Debug)]
#[clap(
name = "tracexec",
author,
version,
about = "A small utility for tracing execve{,at} and pre-exec behavior."
)]
pub struct Cli {
#[arg(long, default_value_t = Color::Auto, help = "Control whether colored output is enabled. This flag has no effect on TUI mode.")]
pub color: Color,
#[arg(
short = 'C',
long,
help = "Change current directory to this path before doing anything"
)]
pub cwd: Option<PathBuf>,
#[arg(
short = 'P',
long,
help = "Load profile from this path",
conflicts_with = "no_profile"
)]
pub profile: Option<PathBuf>,
#[arg(long, help = "Do not load profiles")]
pub no_profile: bool,
#[arg(
short,
long,
help = "Run as user. This option is only available when running tracexec as root",
conflicts_with = "elevate"
)]
pub user: Option<String>,
#[arg(
long,
help = "Re-execute tracexec with privilege elevation (e.g. via sudo). \
The original user credentials are saved and the tracee will run as the original user.",
conflicts_with = "user"
)]
pub elevate: bool,
#[arg(
long,
hide = true,
conflicts_with = "elevate",
requires = "user",
help = "Internal: abstract Unix socket name used to request the original environment from --elevate"
)]
pub restore_env_socket: Option<String>,
#[arg(
long,
hide = true,
conflicts_with = "elevate",
requires_all = ["elevated_data_dir", "elevated_data_local_dir"],
help = "Internal: original user's config directory from --elevate"
)]
pub elevated_config_dir: Option<PathBuf>,
#[arg(
long,
hide = true,
conflicts_with = "elevate",
requires_all = ["elevated_config_dir", "elevated_data_local_dir"],
help = "Internal: original user's data directory from --elevate"
)]
pub elevated_data_dir: Option<PathBuf>,
#[arg(
long,
hide = true,
conflicts_with = "elevate",
requires_all = ["elevated_config_dir", "elevated_data_dir"],
help = "Internal: original user's local data directory from --elevate"
)]
pub elevated_data_local_dir: Option<PathBuf>,
#[clap(subcommand)]
pub cmd: CliCommand,
}
#[derive(Subcommand, Debug)]
pub enum CliCommand {
#[clap(about = "Run tracexec in logging mode")]
Log {
#[arg(last = true, required = true, help = "command to be executed")]
cmd: Vec<String>,
#[clap(flatten)]
tracing_args: LogModeArgs,
#[clap(flatten)]
modifier_args: ModifierArgs,
#[clap(flatten)]
ptrace_args: PtraceArgs,
#[clap(flatten)]
tracer_event_args: TracerEventArgs,
#[clap(
short,
long,
help = "Output, stderr by default. A single hyphen '-' represents stdout."
)]
output: Option<PathBuf>,
},
#[clap(about = "Run tracexec in TUI mode with a pseudo terminal by default")]
Tui {
#[arg(last = true, required = true, help = "command to be executed")]
cmd: Vec<String>,
#[clap(flatten)]
modifier_args: ModifierArgs,
#[clap(flatten)]
ptrace_args: PtraceArgs,
#[clap(flatten)]
tracer_event_args: TracerEventArgs,
#[clap(flatten)]
tui_args: TuiModeArgs,
#[clap(flatten)]
debugger_args: DebuggerArgs,
},
#[clap(about = "Generate shell completions for tracexec")]
GenerateCompletions {
#[arg(required = true, help = "The shell to generate completions for")]
shell: clap_complete::Shell,
},
#[clap(about = "Collect exec events and export them")]
Collect {
#[arg(last = true, required = true, help = "command to be executed")]
cmd: Vec<String>,
#[clap(flatten)]
modifier_args: ModifierArgs,
#[clap(flatten)]
ptrace_args: PtraceArgs,
#[clap(flatten)]
exporter_args: ExporterArgs,
#[clap(short = 'F', long, help = "the format for exported exec events")]
format: ExportFormat,
#[clap(
short,
long,
help = "Output, stderr by default. A single hyphen '-' represents stdout."
)]
output: Option<PathBuf>,
#[clap(
long,
help = "Set the terminal foreground process group to tracee. This option is useful when tracexec is used interactively. [default]",
conflicts_with = "no_foreground"
)]
foreground: bool,
#[clap(
long,
help = "Do not set the terminal foreground process group to tracee",
conflicts_with = "foreground"
)]
no_foreground: bool,
},
#[cfg(feature = "ebpf")]
#[clap(about = "Experimental ebpf mode")]
Ebpf {
#[clap(subcommand)]
command: EbpfCommand,
},
}
#[derive(Subcommand, Debug)]
#[cfg(feature = "ebpf")]
pub enum EbpfCommand {
#[clap(about = "Run tracexec in logging mode")]
Log {
#[arg(
last = true,
help = "command to be executed. Leave it empty to trace all exec on system"
)]
cmd: Vec<String>,
#[clap(
short,
long,
help = "Output, stderr by default. A single hyphen '-' represents stdout."
)]
output: Option<PathBuf>,
#[clap(flatten)]
modifier_args: ModifierArgs,
#[clap(flatten)]
log_args: LogModeArgs,
},
#[clap(about = "Run tracexec in TUI mode, with a pseudo terminal when following a command")]
Tui {
#[arg(
last = true,
help = "command to be executed. Leave it empty to trace all exec on system"
)]
cmd: Vec<String>,
#[clap(flatten)]
modifier_args: ModifierArgs,
#[clap(flatten)]
tracer_event_args: TracerEventArgs,
#[clap(flatten)]
tui_args: TuiModeArgs,
},
#[clap(about = "Collect exec events and export them")]
Collect {
#[arg(
last = true,
help = "command to be executed. Leave it empty to trace all exec on system"
)]
cmd: Vec<String>,
#[clap(flatten)]
modifier_args: ModifierArgs,
#[clap(short = 'F', long, help = "the format for exported exec events")]
format: ExportFormat,
#[clap(flatten)]
exporter_args: ExporterArgs,
#[clap(
short,
long,
help = "Output, stderr by default. A single hyphen '-' represents stdout."
)]
output: Option<PathBuf>,
#[clap(
long,
help = "Set the terminal foreground process group to tracee. This option is useful when tracexec is used interactively. [default]",
conflicts_with = "no_foreground"
)]
foreground: bool,
#[clap(
long,
help = "Do not set the terminal foreground process group to tracee",
conflicts_with = "foreground"
)]
no_foreground: bool,
},
}
impl Cli {
pub fn get_output(path: Option<PathBuf>, color: Color) -> std::io::Result<Box<Output>> {
Ok(match path {
None => Box::new(stderr()),
Some(ref x) if x.as_os_str() == "-" => Box::new(stdout()),
Some(path) => {
let file = std::fs::OpenOptions::new()
.create(true)
.truncate(true)
.write(true)
.open(path)?;
if color != Color::Always {
owo_colors::control::set_should_colorize(false);
}
Box::new(BufWriter::new(file))
}
})
}
pub fn generate_completions(shell: clap_complete::Shell) {
let mut cmd = Self::command();
clap_complete::generate(shell, &mut cmd, env!("CARGO_CRATE_NAME"), &mut stdout())
}
pub fn merge_config(&mut self, config: Config) {
debug!("Merging config: {config:?}");
match &mut self.cmd {
CliCommand::Log {
tracing_args,
modifier_args,
ptrace_args,
..
} => {
if let Some(c) = config.ptrace {
ptrace_args.merge_config(c);
}
if let Some(c) = config.modifier {
modifier_args.merge_config(c);
}
if let Some(c) = config.log {
tracing_args.merge_config(c);
}
}
CliCommand::Tui {
modifier_args,
ptrace_args,
tui_args,
debugger_args,
..
} => {
if let Some(c) = config.ptrace {
ptrace_args.merge_config(c);
}
if let Some(c) = config.modifier {
modifier_args.merge_config(c);
}
if let Some(c) = config.tui {
tui_args.merge_config(c);
}
if let Some(c) = config.debugger {
debugger_args.merge_config(c);
}
}
CliCommand::Collect {
foreground,
no_foreground,
ptrace_args,
..
} => {
if let Some(c) = config.ptrace {
ptrace_args.merge_config(c);
}
if let Some(c) = config.log
&& (!*foreground)
&& (!*no_foreground)
&& let Some(x) = c.foreground
{
if x {
*foreground = true;
} else {
*no_foreground = true;
}
}
}
#[cfg(feature = "ebpf")]
CliCommand::Ebpf { command } => command.merge_config(config),
CliCommand::GenerateCompletions { .. } => (),
}
}
}
#[cfg(feature = "ebpf")]
impl EbpfCommand {
fn merge_config(&mut self, config: Config) {
let modifier_args = match self {
Self::Log { modifier_args, .. }
| Self::Tui { modifier_args, .. }
| Self::Collect { modifier_args, .. } => modifier_args,
};
if let Some(c) = config.modifier {
modifier_args.merge_config(c);
}
match self {
Self::Log { log_args, .. } => {
if let Some(c) = config.log {
log_args.merge_config(c);
}
}
Self::Tui { tui_args, .. } => {
if let Some(c) = config.tui {
tui_args.merge_config(c);
}
}
Self::Collect {
foreground,
no_foreground,
..
} => {
if let Some(c) = config.log
&& (!*foreground)
&& (!*no_foreground)
&& let Some(x) = c.foreground
{
if x {
*foreground = true;
} else {
*no_foreground = true;
}
}
}
}
}
}
#[cfg(test)]
mod tests {
use std::{
fs,
io::Write,
path::PathBuf,
};
use test_that::prelude::*;
use super::*;
use crate::cli::{
args::{
DebuggerArgs,
LogModeArgs,
ModifierArgs,
PtraceArgs,
TracerEventArgs,
TuiModeArgs,
},
config::{
Config,
DebuggerConfig,
LogModeConfig,
ModifierConfig,
PtraceConfig,
TuiModeConfig,
},
options::{
Color,
ExportFormat,
SeccompBpf,
},
};
#[test]
fn test_cli_parse_log() {
let args = vec![
"tracexec",
"log",
"--show-interpreter",
"--successful-only",
"--",
"echo",
"hello",
];
let cli = Cli::parse_from(args);
if let CliCommand::Log {
cmd,
tracing_args,
modifier_args,
..
} = cli.cmd
{
assert_eq!(cmd, vec!["echo", "hello"]);
assert!(tracing_args.show_interpreter);
assert!(modifier_args.successful_only);
} else {
panic!("Expected Log command");
}
}
#[test]
fn test_cli_parse_tui() {
let args = vec!["tracexec", "tui", "--follow", "--", "bash"];
let cli = Cli::parse_from(args);
if let CliCommand::Tui { cmd, tui_args, .. } = cli.cmd {
assert_eq!(cmd, vec!["bash"]);
assert!(tui_args.tty());
assert!(tui_args.follow);
} else {
panic!("Expected Tui command");
}
}
#[test]
fn test_cli_parse_elevate_tui() {
let args = vec!["tracexec", "--elevate", "tui", "--", "sudo", "ls"];
let cli = Cli::parse_from(args);
assert!(cli.elevate);
assert_that!(cli.user, none());
if let CliCommand::Tui { cmd, .. } = cli.cmd {
assert_eq!(cmd, vec!["sudo", "ls"]);
} else {
panic!("Expected Tui command");
}
}
#[test]
fn test_cli_parse_elevate_log() {
let args = vec!["tracexec", "--elevate", "log", "--", "ls"];
let cli = Cli::parse_from(args);
assert!(cli.elevate);
if let CliCommand::Log { cmd, .. } = cli.cmd {
assert_eq!(cmd, vec!["ls"]);
} else {
panic!("Expected Log command");
}
}
#[test]
fn test_cli_parse_elevate_conflicts_with_user() {
let args = vec!["tracexec", "--elevate", "--user", "root", "log", "--", "ls"];
let result = Cli::try_parse_from(args);
assert_that!(result, err(anything()));
}
#[test]
fn test_cli_parse_restore_env_socket_requires_user() {
let args = vec![
"tracexec",
"--restore-env-socket",
"socket-name",
"log",
"--",
"ls",
];
let result = Cli::try_parse_from(args);
assert_that!(result, err(anything()));
}
#[test]
fn test_cli_parse_tui_theme_file_cli_source() {
let args = vec![
"tracexec",
"--no-profile",
"tui",
"--theme",
"cli.toml",
"--",
"bash",
];
let cli = Cli::parse_from(args);
assert!(cli.no_profile);
if let CliCommand::Tui { tui_args, .. } = cli.cmd {
assert_eq!(
tui_args.theme_file,
Some(crate::cli::args::ThemeFileValue::Cli(PathBuf::from(
"cli.toml"
)))
);
} else {
panic!("Expected Tui command");
}
}
#[test]
fn test_get_output_stderr_stdout_file() {
let out = Cli::get_output(None, Color::Auto).unwrap();
let _ = out;
let out = Cli::get_output(Some(PathBuf::from("-")), Color::Auto).unwrap();
let _ = out;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("test_output.txt");
let mut out = Cli::get_output(Some(path.clone()), Color::Auto).unwrap();
writeln!(out, "Hello world").unwrap();
drop(out);
let content = fs::read_to_string(path).unwrap();
assert_that!(content, contains_substring("Hello world"));
}
#[test]
fn test_merge_config_log() {
let mut cli = Cli {
color: Color::Auto,
cwd: None,
profile: None,
no_profile: false,
user: None,
elevate: false,
restore_env_socket: None,
elevated_config_dir: None,
elevated_data_dir: None,
elevated_data_local_dir: None,
cmd: CliCommand::Log {
cmd: vec!["ls".into()],
tracing_args: LogModeArgs {
show_interpreter: false,
..Default::default()
},
modifier_args: ModifierArgs::default(),
ptrace_args: PtraceArgs::default(),
tracer_event_args: TracerEventArgs::all(),
output: None,
},
};
let config = Config {
ptrace: Some(PtraceConfig {
seccomp_bpf: Some(SeccompBpf::On),
}),
modifier: Some(ModifierConfig {
successful_only: Some(true),
..Default::default()
}),
log: Some(LogModeConfig {
show_interpreter: Some(true),
..Default::default()
}),
tui: None,
debugger: None,
};
cli.merge_config(config);
if let CliCommand::Log {
tracing_args,
modifier_args,
ptrace_args,
..
} = cli.cmd
{
assert!(tracing_args.show_interpreter);
assert!(modifier_args.successful_only);
assert_eq!(ptrace_args.seccomp_bpf, SeccompBpf::On);
} else {
panic!("Expected Log command");
}
}
#[test]
fn test_merge_config_tui() {
let mut cli = Cli {
color: Color::Auto,
cwd: None,
profile: None,
no_profile: false,
user: None,
elevate: false,
restore_env_socket: None,
elevated_config_dir: None,
elevated_data_dir: None,
elevated_data_local_dir: None,
cmd: CliCommand::Tui {
cmd: vec!["bash".into()],
modifier_args: ModifierArgs::default(),
ptrace_args: PtraceArgs::default(),
tracer_event_args: TracerEventArgs::all(),
tui_args: TuiModeArgs::default(),
debugger_args: DebuggerArgs::default(),
},
};
let config = Config {
ptrace: Some(PtraceConfig {
seccomp_bpf: Some(SeccompBpf::Off),
}),
modifier: Some(ModifierConfig {
successful_only: Some(true),
..Default::default()
}),
log: None,
tui: Some(TuiModeConfig {
follow: Some(true),
frame_rate: Some(30.0),
..Default::default()
}),
debugger: Some(DebuggerConfig {
default_external_command: Some("echo hello".into()),
}),
};
cli.merge_config(config);
if let CliCommand::Tui {
modifier_args,
ptrace_args,
tui_args,
debugger_args,
..
} = cli.cmd
{
assert!(modifier_args.successful_only);
assert_eq!(ptrace_args.seccomp_bpf, SeccompBpf::Off);
assert_eq!(tui_args.frame_rate.unwrap(), 30.0);
assert_eq!(
debugger_args.default_external_command.as_ref().unwrap(),
"echo hello"
);
} else {
panic!("Expected Tui command");
}
}
#[test]
fn test_merge_config_collect_foreground() {
let mut cli = Cli {
color: Color::Auto,
cwd: None,
profile: None,
no_profile: false,
user: None,
elevate: false,
restore_env_socket: None,
elevated_config_dir: None,
elevated_data_dir: None,
elevated_data_local_dir: None,
cmd: CliCommand::Collect {
cmd: vec!["ls".into()],
modifier_args: ModifierArgs::default(),
ptrace_args: PtraceArgs::default(),
exporter_args: Default::default(),
format: ExportFormat::Json,
output: None,
foreground: false,
no_foreground: false,
},
};
let config = Config {
log: Some(LogModeConfig {
foreground: Some(true),
..Default::default()
}),
ptrace: None,
modifier: None,
tui: None,
debugger: None,
};
cli.merge_config(config);
if let CliCommand::Collect {
foreground,
no_foreground,
..
} = cli.cmd
{
assert!(foreground);
assert!(!no_foreground);
} else {
panic!("Expected Collect command");
}
}
#[test]
fn test_generate_completions_smoke() {
Cli::generate_completions(clap_complete::Shell::Bash);
}
#[cfg(feature = "ebpf")]
mod ebpf {
use super::*;
use crate::cli::args::ThemeFileValue;
fn load_profile(args: &[&str], profile: &str) -> color_eyre::Result<Cli> {
let mut file = tempfile::NamedTempFile::new()?;
file.write_all(profile.as_bytes())?;
let mut cli = Cli::try_parse_from(args)?;
cli.merge_config(Config::load(Some(file.path().to_path_buf()))?);
Ok(cli)
}
#[test]
fn test_merge_config_ebpf_log() -> color_eyre::Result<()> {
for with_command in [false, true] {
for override_config in [false, true] {
let mut args = vec!["tracexec", "ebpf", "log"];
if override_config {
args.extend(["--no-show-interpreter", "--foreground", "--no-timestamp"]);
}
if with_command {
args.extend(["--", "true"]);
}
let cli = load_profile(
&args,
r#"
[log]
show_interpreter = true
foreground = false
[modifier]
successful_only = true
timestamp = { enable = true }
"#,
)?;
let CliCommand::Ebpf {
command:
EbpfCommand::Log {
log_args,
modifier_args,
..
},
} = cli.cmd
else {
panic!("Expected eBPF Log command");
};
assert_eq!(log_args.show_interpreter, !override_config);
assert_eq!(log_args.foreground(), override_config);
let modifier_args = modifier_args.processed();
assert!(modifier_args.successful_only);
assert_eq!(modifier_args.timestamp, !override_config);
}
}
Ok(())
}
#[test]
fn test_merge_config_ebpf_tui() -> color_eyre::Result<()> {
for with_command in [false, true] {
for override_config in [false, true] {
let mut args = vec!["tracexec", "ebpf", "tui"];
if override_config {
args.extend([
"--frame-rate",
"45",
"--theme",
"cli.toml",
"--no-timestamp",
]);
}
if with_command {
args.extend(["--", "true"]);
}
let cli = load_profile(
&args,
r#"
[tui]
follow = true
frame_rate = 30.0
theme-file = "profile.toml"
[tui.keys]
quit = "q"
[modifier]
timestamp = { enable = true }
"#,
)?;
let CliCommand::Ebpf {
command:
EbpfCommand::Tui {
tui_args,
modifier_args,
..
},
} = cli.cmd
else {
panic!("Expected eBPF Tui command");
};
assert!(tui_args.follow);
assert_eq!(
tui_args.frame_rate,
Some(if override_config { 45.0 } else { 30.0 })
);
assert_eq!(
tui_args.theme_file,
Some(if override_config {
ThemeFileValue::Cli("cli.toml".into())
} else {
ThemeFileValue::Config("profile.toml".into())
})
);
assert!(tui_args.keys.is_some_and(|keys| keys.quit.is_some()));
assert_eq!(modifier_args.processed().timestamp, !override_config);
}
}
Ok(())
}
#[test]
fn test_merge_config_ebpf_collect() -> color_eyre::Result<()> {
for with_command in [false, true] {
for configured_foreground in [false, true] {
for cli_foreground in [None, Some(false), Some(true)] {
let mut args = vec!["tracexec", "ebpf", "collect", "--format", "json"];
if let Some(foreground) = cli_foreground {
args.push(if foreground {
"--foreground"
} else {
"--no-foreground"
});
}
if with_command {
args.extend(["--", "true"]);
}
let cli = load_profile(
&args,
&format!(
"[log]\nforeground = {configured_foreground}\n[modifier]\ncollect_cgroup = true"
),
)?;
let CliCommand::Ebpf {
command:
EbpfCommand::Collect {
foreground,
no_foreground,
modifier_args,
..
},
} = cli.cmd
else {
panic!("Expected eBPF Collect command");
};
let expected = cli_foreground.unwrap_or(configured_foreground);
assert_eq!((foreground, no_foreground), (expected, !expected));
assert!(modifier_args.processed().collect_cgroup);
}
}
}
Ok(())
}
}
}