diff --git a/crates/atuin-client/config.toml b/crates/atuin-client/config.toml index c40461ab6d5..ea3f8307263 100644 --- a/crates/atuin-client/config.toml +++ b/crates/atuin-client/config.toml @@ -154,6 +154,10 @@ ## 5. Stripe live/test keys # secrets_filter = true +## Defaults to false. If secrets_filter is also enabled, then when a secret is detected, secrets will instead be redacted. The secret +## itself will be replaced with the string "[REDACTED]" instead of filtering out the entire command. +# secrets_redact = true + ## Defaults to true. If enabled, upon hitting enter Atuin will immediately execute the command. Press tab to return to the shell and edit. # This applies for new installs. Old installs will keep the old behaviour unless configured otherwise. enter_accept = true diff --git a/crates/atuin-client/src/history.rs b/crates/atuin-client/src/history.rs index d9d5a203d45..8c1151ddc04 100644 --- a/crates/atuin-client/src/history.rs +++ b/crates/atuin-client/src/history.rs @@ -9,7 +9,7 @@ use atuin_common::utils::uuid_v7; use eyre::{Result, bail, eyre}; -use crate::secrets::SECRET_PATTERNS_RE; +use crate::secrets::{SECRET_PATTERNS_RE, redact_secrets}; use crate::settings::Settings; use crate::utils::get_host_user; use time::OffsetDateTime; @@ -374,11 +374,40 @@ impl History { } pub fn should_save(&self, settings: &Settings) -> bool { - !(self.command.starts_with(' ') + if self.command.starts_with(' ') || self.command.is_empty() || settings.history_filter.is_match(&self.command) || settings.cwd_filter.is_match(&self.cwd) - || (settings.secrets_filter && SECRET_PATTERNS_RE.is_match(&self.command))) + { + return false; + } + + if settings.secrets_filter && !settings.secrets_redact { + !SECRET_PATTERNS_RE.is_match(&self.command) + } else { + debug_assert!( + !settings.secrets_filter || settings.secrets_redact, + "Only return true if secrets_filter is off or redactions are enabled!" + ); + // secrets_redact is enabled, so `redact_if_needed` will remove the secret from the + // command, therefore it is save to save. + true + } + } + + /// Redacts secrets from the command if needed based on settings. + /// Returns a new History with the redacted command, or self if no redaction needed. + pub fn redact_if_needed(&self, settings: &Settings) -> Self { + if settings.secrets_filter + && settings.secrets_redact + && SECRET_PATTERNS_RE.is_match(&self.command) + { + let mut redacted = self.clone(); + redacted.command = redact_secrets(&self.command); + redacted + } else { + self.clone() + } } } @@ -397,6 +426,8 @@ mod tests { let settings = Settings { cwd_filter: RegexSet::new(["^/supasecret"]).unwrap(), history_filter: RegexSet::new(["^psql"]).unwrap(), + secrets_filter: true, + secrets_redact: false, ..Settings::utc() }; @@ -467,6 +498,67 @@ mod tests { assert!(stripe_key.should_save(&settings)); } + #[test] + fn redact_secrets() { + let settings = Settings { + secrets_filter: true, + secrets_redact: true, + ..Settings::utc() + }; + + let stripe_key: History = History::capture() + .timestamp(time::OffsetDateTime::now_utc()) + .command("curl foo.com/bar?key=sk_test_1234567890abcdefghijklmn") + .cwd("/") + .build() + .into(); + + assert!(stripe_key.should_save(&settings)); + + let redacted = stripe_key.redact_if_needed(&settings); + assert_eq!(redacted.command, "curl foo.com/bar?key=[REDACTED]"); + } + + #[test] + fn filter_secrets() { + let settings = Settings { + secrets_filter: true, + secrets_redact: false, + ..Settings::utc() + }; + + let stripe_key: History = History::capture() + .timestamp(time::OffsetDateTime::now_utc()) + .command("curl foo.com/bar?key=sk_test_1234567890abcdefghijklmn") + .cwd("/") + .build() + .into(); + + assert!(!stripe_key.should_save(&settings)); + } + + #[test] + fn redact_multiple_secrets() { + let settings = Settings { + secrets_filter: true, + secrets_redact: true, + ..Settings::utc() + }; + + let multi_secret: History = History::capture() + .timestamp(time::OffsetDateTime::now_utc()) + .command("export AWS_SECRET_ACCESS_KEY=foo && curl -H 'Authorization: Bearer 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' api.github.com") + .cwd("/") + .build() + .into(); + + let redacted = multi_secret.redact_if_needed(&settings); + assert_eq!( + redacted.command, + "export AWS_SECRET_ACCESS_KEY=foo && curl -H 'Authorization: Bearer [REDACTED]' api.github.com" + ); + } + #[test] fn test_serialize_deserialize() { let bytes = [ diff --git a/crates/atuin-client/src/secrets.rs b/crates/atuin-client/src/secrets.rs index 100bcc505ca..dba6cb27817 100644 --- a/crates/atuin-client/src/secrets.rs +++ b/crates/atuin-client/src/secrets.rs @@ -1,6 +1,6 @@ // This file will probably trigger a lot of scanners. Sorry. -use regex::RegexSet; +use regex::{Regex, RegexSet}; use std::sync::LazyLock; pub enum TestValue<'a> { @@ -153,6 +153,24 @@ pub static SECRET_PATTERNS_RE: LazyLock = LazyLock::new(|| { RegexSet::new(exprs).expect("Failed to build secrets regex") }); +static SECRET_PATTERNS_INDIVIDUAL: LazyLock> = LazyLock::new(|| { + SECRET_PATTERNS + .iter() + .map(|f| Regex::new(f.1).expect("Failed to compile secret pattern")) + .collect() +}); + +pub fn redact_secrets(command: &str) -> String { + let mut result = command.to_string(); + let matches = SECRET_PATTERNS_RE.matches(command); + for pattern_idx in matches.iter() { + if let Some(regex) = SECRET_PATTERNS_INDIVIDUAL.get(pattern_idx) { + result = regex.replace_all(&result, "[REDACTED]").to_string(); + } + } + result +} + #[cfg(test)] mod tests { use regex::Regex; diff --git a/crates/atuin-client/src/settings.rs b/crates/atuin-client/src/settings.rs index 489c1a83877..9a1c600d427 100644 --- a/crates/atuin-client/src/settings.rs +++ b/crates/atuin-client/src/settings.rs @@ -496,6 +496,7 @@ pub struct Settings { pub cwd_filter: RegexSet, pub secrets_filter: bool, + pub secrets_redact: bool, pub workspaces: bool, pub ctrl_n_shortcuts: bool, @@ -793,6 +794,7 @@ impl Settings { .set_default("workspaces", false)? .set_default("ctrl_n_shortcuts", false)? .set_default("secrets_filter", true)? + .set_default("secrets_redact", false)? .set_default("network_connect_timeout", 5)? .set_default("network_timeout", 30)? .set_default("local_timeout", 2.0)? diff --git a/crates/atuin/src/command/client/history.rs b/crates/atuin/src/command/client/history.rs index afa0f3bcae1..666536ebec8 100644 --- a/crates/atuin/src/command/client/history.rs +++ b/crates/atuin/src/command/client/history.rs @@ -366,6 +366,8 @@ impl Cmd { return Ok(()); } + let h = h.redact_if_needed(settings); + // print the ID // we use this as the key for calling end println!("{}", h.id); @@ -393,6 +395,8 @@ impl Cmd { return Ok(()); } + let h = h.redact_if_needed(settings); + let resp = atuin_daemon::client::HistoryClient::new( #[cfg(not(unix))] settings.daemon.tcp_port,