Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/src/content/docs/cli/audit.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ Show only entries before the given time. Same format as `--since`.

Filter entries whose command string contains the given substring.

### `--dir <path>`

Filter entries by working directory. Only shows entries executed in the given directory or its subdirectories. The path is resolved to its canonical (absolute, symlink-resolved) form before matching.

### `--limit <n>`

Maximum number of entries to display.
Expand Down
4 changes: 4 additions & 0 deletions src/audit/filter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,8 @@ pub struct AuditFilter {
pub until: Option<TimeSpec>,
/// Filter by command substring match.
pub command_pattern: Option<String>,
/// Filter by working directory (prefix match, includes subdirectories).
pub cwd: Option<String>,
/// Maximum number of entries to return (default: 50).
pub limit: usize,
}
Expand All @@ -104,6 +106,7 @@ impl Default for AuditFilter {
since: None,
until: None,
command_pattern: None,
cwd: None,
limit: 50,
}
}
Expand Down Expand Up @@ -187,5 +190,6 @@ mod tests {
assert!(filter.since.is_none());
assert!(filter.until.is_none());
assert!(filter.command_pattern.is_none());
assert!(filter.cwd.is_none());
}
}
176 changes: 148 additions & 28 deletions src/audit/reader.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,15 @@ use super::log_rotator::parse_log_date;
use super::model::{AuditEntry, SerializableAction};
use crate::config::ActionKind;

/// Time-resolved filter criteria ready for matching against entries.
struct ResolvedFilter<'a> {
action: &'a Option<ActionKind>,
since: Option<DateTime<Utc>>,
until: Option<DateTime<Utc>>,
command_pattern: &'a Option<String>,
cwd: &'a Option<String>,
}

/// Reads and filters audit log entries from JSONL date-partitioned files.
pub struct AuditReader {
log_dir: PathBuf,
Expand All @@ -34,22 +43,20 @@ impl AuditReader {
filter: &AuditFilter,
now: DateTime<Utc>,
) -> Result<Vec<AuditEntry>, anyhow::Error> {
let since = filter.since.as_ref().map(|ts| ts.resolve(now));
let until = filter.until.as_ref().map(|ts| ts.resolve(now));
let resolved = ResolvedFilter {
action: &filter.action,
since: filter.since.as_ref().map(|ts| ts.resolve(now)),
until: filter.until.as_ref().map(|ts| ts.resolve(now)),
command_pattern: &filter.command_pattern,
cwd: &filter.cwd,
};

let date_files = self.collect_date_files(since)?;
let date_files = self.collect_date_files(resolved.since)?;

let mut entries = Vec::new();

for path in &date_files {
self.read_file(
path,
&filter.action,
since,
until,
&filter.command_pattern,
&mut entries,
)?;
self.read_file(path, &resolved, &mut entries)?;
}

// Partial sort: find the newest `limit` entries, then sort ascending (oldest first)
Expand Down Expand Up @@ -106,10 +113,7 @@ impl AuditReader {
fn read_file(
&self,
path: &Path,
action_filter: &Option<ActionKind>,
since: Option<DateTime<Utc>>,
until: Option<DateTime<Utc>>,
command_pattern: &Option<String>,
resolved: &ResolvedFilter,
entries: &mut Vec<AuditEntry>,
) -> Result<(), anyhow::Error> {
let file = fs::File::open(path)?;
Expand Down Expand Up @@ -146,7 +150,7 @@ impl AuditReader {
}
};

if !Self::matches_filter(&entry, action_filter, since, until, command_pattern) {
if !Self::matches_filter(&entry, resolved) {
continue;
}

Expand All @@ -156,23 +160,17 @@ impl AuditReader {
Ok(())
}

fn matches_filter(
entry: &AuditEntry,
action_filter: &Option<ActionKind>,
since: Option<DateTime<Utc>>,
until: Option<DateTime<Utc>>,
command_pattern: &Option<String>,
) -> bool {
fn matches_filter(entry: &AuditEntry, filter: &ResolvedFilter) -> bool {
// Check timestamp filters; malformed timestamps cannot satisfy time filters
if since.is_some() || until.is_some() {
if filter.since.is_some() || filter.until.is_some() {
match entry.timestamp.parse::<DateTime<Utc>>() {
Ok(ts) => {
if let Some(since_dt) = since
if let Some(since_dt) = filter.since
&& ts < since_dt
{
return false;
}
if let Some(until_dt) = until
if let Some(until_dt) = filter.until
&& ts > until_dt
{
return false;
Expand All @@ -183,19 +181,31 @@ impl AuditReader {
}

// Check action filter
if let Some(action_kind) = action_filter
if let Some(action_kind) = filter.action
&& !Self::action_matches(&entry.action, action_kind)
{
return false;
}

// Check command pattern (substring match)
if let Some(pattern) = command_pattern
if let Some(pattern) = filter.command_pattern
&& !entry.command.contains(pattern.as_str())
{
return false;
}

// Check cwd filter (prefix match, includes subdirectories)
if let Some(filter_cwd) = filter.cwd {
match &entry.metadata.cwd {
Some(entry_cwd) => {
if !Path::new(entry_cwd).starts_with(filter_cwd) {
return false;
}
}
None => return false,
}
}

true
}

Expand Down Expand Up @@ -240,6 +250,21 @@ mod tests {
}
}

fn make_entry_with_cwd(
timestamp: &str,
command: &str,
action: SerializableAction,
cwd: Option<&str>,
) -> AuditEntry {
AuditEntry {
metadata: AuditMetadata {
cwd: cwd.map(|s| s.to_owned()),
..AuditMetadata::default()
},
..make_entry(timestamp, command, action)
}
}

fn write_jsonl(dir: &Path, filename: &str, entries: &[AuditEntry]) {
let path = dir.join(filename);
let mut file = fs::File::create(path).unwrap();
Expand Down Expand Up @@ -564,6 +589,101 @@ mod tests {
assert_eq!(result[0].command, "echo good");
}

#[rstest]
fn filter_by_cwd(temp_log_dir: TempDir) {
let entries = vec![
make_entry_with_cwd(
"2026-02-25T10:00:00Z",
"echo project",
SerializableAction::Allow,
Some("/home/user/project"),
),
make_entry_with_cwd(
"2026-02-25T11:00:00Z",
"echo subdir",
SerializableAction::Allow,
Some("/home/user/project/src"),
),
make_entry_with_cwd(
"2026-02-25T12:00:00Z",
"echo other",
SerializableAction::Allow,
Some("/home/user/other"),
),
make_entry_with_cwd(
"2026-02-25T13:00:00Z",
"echo no-cwd",
SerializableAction::Allow,
None,
),
];
write_jsonl(temp_log_dir.path(), "audit-2026-02-25.jsonl", &entries);

let reader = AuditReader::new(temp_log_dir.path().to_path_buf());
let mut filter = AuditFilter::new();
filter.cwd = Some("/home/user/project".to_owned());
let result = reader.read(&filter).unwrap();

assert_eq!(result.len(), 2);
assert_eq!(result[0].command, "echo project");
assert_eq!(result[1].command, "echo subdir");
}

#[rstest]
fn filter_by_cwd_root_directory(temp_log_dir: TempDir) {
let entries = vec![
make_entry_with_cwd(
"2026-02-25T10:00:00Z",
"echo root",
SerializableAction::Allow,
Some("/"),
),
make_entry_with_cwd(
"2026-02-25T11:00:00Z",
"echo subdir",
SerializableAction::Allow,
Some("/home/user"),
),
];
write_jsonl(temp_log_dir.path(), "audit-2026-02-25.jsonl", &entries);

let reader = AuditReader::new(temp_log_dir.path().to_path_buf());
let mut filter = AuditFilter::new();
filter.cwd = Some("/".to_owned());
let result = reader.read(&filter).unwrap();

assert_eq!(result.len(), 2);
assert_eq!(result[0].command, "echo root");
assert_eq!(result[1].command, "echo subdir");
}

#[rstest]
fn filter_by_cwd_no_false_prefix_match(temp_log_dir: TempDir) {
let entries = vec![
make_entry_with_cwd(
"2026-02-25T10:00:00Z",
"echo project",
SerializableAction::Allow,
Some("/home/user/project"),
),
make_entry_with_cwd(
"2026-02-25T11:00:00Z",
"echo project2",
SerializableAction::Allow,
Some("/home/user/project2"),
),
];
write_jsonl(temp_log_dir.path(), "audit-2026-02-25.jsonl", &entries);

let reader = AuditReader::new(temp_log_dir.path().to_path_buf());
let mut filter = AuditFilter::new();
filter.cwd = Some("/home/user/project".to_owned());
let result = reader.read(&filter).unwrap();

assert_eq!(result.len(), 1);
assert_eq!(result[0].command, "echo project");
}

#[rstest]
fn skip_empty_lines(temp_log_dir: TempDir) {
let valid_entry = make_entry(
Expand Down
16 changes: 12 additions & 4 deletions src/cli/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,10 @@ pub struct AuditArgs {
#[arg(long)]
pub command: Option<String>,

/// Filter by working directory (includes subdirectories)
#[arg(long)]
pub dir: Option<String>,

/// Maximum number of entries to show
#[arg(long, default_value_t = 50)]
pub limit: usize,
Expand Down Expand Up @@ -210,19 +214,23 @@ mod tests {
)]
#[case::audit_default(
&["runok", "audit"],
Commands::Audit(AuditArgs { action: None, since: None, until: None, command: None, limit: 50, json: false }),
Commands::Audit(AuditArgs { action: None, since: None, until: None, command: None, dir: None, limit: 50, json: false }),
)]
#[case::audit_with_action(
&["runok", "audit", "--action", "deny"],
Commands::Audit(AuditArgs { action: Some("deny".into()), since: None, until: None, command: None, limit: 50, json: false }),
Commands::Audit(AuditArgs { action: Some("deny".into()), since: None, until: None, command: None, dir: None, limit: 50, json: false }),
)]
#[case::audit_with_since(
&["runok", "audit", "--since", "1h"],
Commands::Audit(AuditArgs { action: None, since: Some("1h".into()), until: None, command: None, limit: 50, json: false }),
Commands::Audit(AuditArgs { action: None, since: Some("1h".into()), until: None, command: None, dir: None, limit: 50, json: false }),
)]
#[case::audit_with_dir(
&["runok", "audit", "--dir", "/home/user/project"],
Commands::Audit(AuditArgs { action: None, since: None, until: None, command: None, dir: Some("/home/user/project".into()), limit: 50, json: false }),
)]
#[case::audit_with_all_options(
&["runok", "audit", "--action", "allow", "--since", "7d", "--until", "1h", "--command", "git", "--limit", "10", "--json"],
Commands::Audit(AuditArgs { action: Some("allow".into()), since: Some("7d".into()), until: Some("1h".into()), command: Some("git".into()), limit: 10, json: true }),
Commands::Audit(AuditArgs { action: Some("allow".into()), since: Some("7d".into()), until: Some("1h".into()), command: Some("git".into()), dir: None, limit: 10, json: true }),
)]
#[case::init_defaults(
&["runok", "init"],
Expand Down
11 changes: 11 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,17 @@ fn run_audit(args: AuditArgs, cwd: &std::path::Path) -> i32 {

filter.command_pattern = args.command;

if let Some(dir_arg) = args.dir {
let dir_path = std::path::Path::new(&dir_arg);
match dir_path.canonicalize() {
Ok(canonical) => filter.cwd = Some(canonical.to_string_lossy().into_owned()),
Err(e) => {
eprintln!("runok: failed to resolve directory path '{}': {e}", dir_arg);
return 1;
}
}
}

let reader = AuditReader::new(log_dir);
let entries = match reader.read(&filter) {
Ok(e) => e,
Expand Down
Loading