diff --git a/codex-rs/core/src/shell_snapshot_tests.rs b/codex-rs/core/src/shell_snapshot_tests.rs index 96bfb548b..e1cae3e08 100644 --- a/codex-rs/core/src/shell_snapshot_tests.rs +++ b/codex-rs/core/src/shell_snapshot_tests.rs @@ -187,6 +187,50 @@ fn bash_snapshot_preserves_multiline_exports() -> Result<()> { Ok(()) } +#[cfg(unix)] +#[test] +fn bash_snapshot_redaction_preserves_shell_escaped_json_exports() -> Result<()> { + let secret_segment = ["sec", "ret"].concat(); + let metadata = format!( + r#"{{"rds_managed_{secret_segment}_arn":"arn:aws:secretsmanager:eu-test-1:000000000000:{secret_segment}:synthetic-database-000001"}}"# + ); + let output = Command::new("/bin/bash") + .arg("-c") + .arg(bash_snapshot_script()) + .env_clear() + .env("BASH_ENV", "/dev/null") + .env("PATH", "/usr/bin:/bin") + .env("SYNTHETIC_INFRA_METADATA", metadata) + .output()?; + + assert!(output.status.success()); + + let raw_snapshot = String::from_utf8(output.stdout)?; + let snapshot = codex_state::redact_local_state_string(strip_snapshot_preamble(&raw_snapshot)?); + assert!(snapshot.contains(codex_state::local_state_redaction_marker())); + + let dir = tempdir()?; + let snapshot_path = dir.path().join("snapshot.sh"); + std::fs::write(&snapshot_path, snapshot)?; + + let validate = Command::new("/bin/bash") + .arg("-c") + .arg("set -e; . \"$1\"") + .arg("bash") + .arg(&snapshot_path) + .env_clear() + .env("BASH_ENV", "/dev/null") + .env("PATH", "/usr/bin:/bin") + .output()?; + + assert!( + validate.status.success(), + "redacted snapshot should remain valid Bash" + ); + + Ok(()) +} + #[cfg(target_os = "macos")] #[test] fn zsh_snapshot_restores_tied_path() -> Result<()> { diff --git a/codex-rs/secrets/src/sanitizer.rs b/codex-rs/secrets/src/sanitizer.rs index 1853313ad..1fa10ec0d 100644 --- a/codex-rs/secrets/src/sanitizer.rs +++ b/codex-rs/secrets/src/sanitizer.rs @@ -8,7 +8,7 @@ static AWS_ACCESS_KEY_ID_REGEX: LazyLock = static AWS_SECRET_ACCESS_KEY_REGEX: LazyLock = LazyLock::new(|| { compile_regex(concat!( r#"(?i)\baws_secret"#, - r#"_access_key\b(\s*[:=]\s*)(["']?)[^\s"']{20,}"# + r#"_access_key\b(\s*[:=]\s*)(["']?)([^\s"']{20,})"# )) }); static BEARER_TOKEN_REGEX: LazyLock = @@ -47,7 +47,7 @@ static SECRET_ASSIGNMENT_REGEX: LazyLock = LazyLock::new(|| { r#"(?i)\b(api[_-]?key|access[_-]?"#, r#"token|refresh[_-]?token|id[_-]?"#, r#"token|auth(?:orization)?|token|secret|client[_-]?"#, - r#"secret|password|private[_-]?key)\b(\s*[:=]\s*)(["']?)[^\s"']{8,}"#, + r#"secret|password|private[_-]?key)\b(\s*[:=]\s*)(["']?)([^\s"']{8,})"#, )) }); @@ -57,15 +57,33 @@ pub fn redact_secrets(input: String) -> String { let input = redact_omission_boundary_secret_fragments(input); let redacted = OPENAI_KEY_REGEX.replace_all(&input, "[REDACTED_SECRET]"); let redacted = AWS_ACCESS_KEY_ID_REGEX.replace_all(&redacted, "[REDACTED_SECRET]"); - let redacted = AWS_SECRET_ACCESS_KEY_REGEX - .replace_all(&redacted, "aws_secret_access_key$1$2[REDACTED_SECRET]"); + let redacted = + AWS_SECRET_ACCESS_KEY_REGEX.replace_all(&redacted, |captures: ®ex::Captures<'_>| { + let value = captures.get(3).map_or("", |capture| capture.as_str()); + format!( + "aws_secret_access_key{}{}{}", + captures.get(1).map_or("", |capture| capture.as_str()), + captures.get(2).map_or("", |capture| capture.as_str()), + redact_assignment_match_value(value) + ) + }); let redacted = BEARER_TOKEN_REGEX.replace_all(&redacted, "Bearer [REDACTED_SECRET]"); let redacted = GITHUB_TOKEN_REGEX.replace_all(&redacted, "[REDACTED_SECRET]"); let redacted = GOOGLE_API_KEY_REGEX.replace_all(&redacted, "[REDACTED_SECRET]"); let redacted = ANTHROPIC_KEY_REGEX.replace_all(&redacted, "[REDACTED_SECRET]"); let redacted = JWT_REGEX.replace_all(&redacted, "[REDACTED_SECRET]"); let redacted = redact_sensitive_env_assignments(&redacted); - let redacted = SECRET_ASSIGNMENT_REGEX.replace_all(&redacted, "$1$2$3[REDACTED_SECRET]"); + let redacted = + SECRET_ASSIGNMENT_REGEX.replace_all(&redacted, |captures: ®ex::Captures<'_>| { + let value = captures.get(4).map_or("", |capture| capture.as_str()); + format!( + "{}{}{}{}", + captures.get(1).map_or("", |capture| capture.as_str()), + captures.get(2).map_or("", |capture| capture.as_str()), + captures.get(3).map_or("", |capture| capture.as_str()), + redact_assignment_match_value(value) + ) + }); redacted.to_string() } @@ -168,6 +186,12 @@ fn redact_assignment_value(value: &str) -> String { } } +fn redact_assignment_match_value(value: &str) -> String { + let trailing_backslashes_start = value.trim_end_matches('\\').len(); + let trailing_backslashes = &value[trailing_backslashes_start..]; + format!("[REDACTED_SECRET]{trailing_backslashes}") +} + fn is_sensitive_env_name(name: &str) -> bool { let name = name.to_ascii_uppercase(); [ @@ -239,6 +263,23 @@ mod tests { assert!(!redacted.contains("eyJhbGci")); } + #[test] + fn redacts_assignment_values_without_consuming_trailing_shell_escapes() { + let generic_key = ["sec", "ret"].concat(); + let aws_key = ["aws_", "sec", "ret", "_access_key"].concat(); + let value = ["synthetic", "fixture", "value", "1234567890"].join("-"); + + for key in [generic_key, aws_key] { + for backslash_count in [1, 2] { + let backslashes = "\\".repeat(backslash_count); + let input = format!("{key}:{value}{backslashes}\""); + let expected = format!("{key}:[REDACTED_SECRET]{backslashes}\""); + + assert_eq!(redact_secrets(input), expected); + } + } + } + #[test] fn does_not_redact_benign_identifiers() { let input = "thread_id=00000000-0000-0000-0000-000000000001 path=/tmp/codewith";