From 1215cc762246b334c2cd9241af5a0bd03747ba19 Mon Sep 17 00:00:00 2001 From: Taylor Meek Date: Wed, 22 Oct 2025 15:07:19 -0700 Subject: [PATCH] Add --redact to mask execs that stdout/err secrets --- README.md | 88 +++++++ cli/exec.go | 270 +++++++++++++++++++-- cli/exec_integration_test.go | 170 +++++++++++++ cli/redaction_test.go | 426 +++++++++++++++++++++++++++++++++ vault/config.go | 37 +++ vault/redaction_config_test.go | 122 ++++++++++ 6 files changed, 1094 insertions(+), 19 deletions(-) create mode 100644 cli/exec_integration_test.go create mode 100644 cli/redaction_test.go create mode 100644 vault/redaction_config_test.go diff --git a/README.md b/README.md index ca531b2a..54a46924 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,94 @@ AWS Vault then exposes the temporary credentials to the sub-process in one of tw The default is to use environment variables, but you can opt-in to the local instance metadata server with the `--server` flag on the `exec` command. +## Credential Redaction + +AWS Vault can automatically redact AWS credentials from subprocess output to prevent accidental credential leakage. This is similar to the behavior of `op run` from 1Password. + +### Configuration + +Add an `[aws-vault]` section to your `~/.aws/config` file to enable redaction globally: + +```ini +[aws-vault] +redact_secrets = true +``` + +### Command-line Usage + +You can also enable redaction for individual commands using the `--redact` flag: + +```shell +$ aws-vault exec --redact myprofile ./my-script.sh +``` + +The `--redact` flag overrides the configuration file setting. + +### How Redaction Works + +When redaction is enabled, AWS Vault will: +- Monitor stdout and stderr from subprocesses +- Replace any AWS credentials with `` +- Match actual credentials being used (access keys, secret keys, session tokens) +- Use exact string matching to prevent false positives +- Handle credentials split across buffer boundaries with a sliding window + +This prevents credentials from appearing in logs, terminal history, or process output while maintaining full functionality. + +### Advanced Configuration + +#### Stderr Window Size + +By default, stderr uses a 256-byte sliding window for credential redaction, +providing near-real-time output while still protecting against credentials +split at buffer boundaries. + +For scripts that output very long credentials (>256 bytes) to stderr, you +may notice a slight delay in output. You can adjust the window size: + +```bash +# Increase stderr window size for more buffering (more secure, slower) +export AWS_VAULT_STDERR_WINDOW_SIZE=512 +aws-vault exec --redact myprofile ./my-script.sh + +# Decrease for faster stderr output (less secure, faster) +export AWS_VAULT_STDERR_WINDOW_SIZE=128 +aws-vault exec --redact myprofile ./my-script.sh +``` + +**Security Note:** `stdout` always uses the full credential-length window +regardless of this setting, as it's more likely to contain credential dumps. + +#### Child Process Buffering +**Note:** Some programs (like Python) buffer their `stderr` output when not +connected to a terminal. If you notice delayed `stderr` output even with +redaction enabled, you may need to configure the child process to disable +its own buffering. For example: + +```bash +# Python: disable buffering +python3 -u script.py + +# Ruby: sync stderr +ruby -e '$stderr.sync = true' script.rb +``` + +### Known Limitations + +**Output Ordering:** When redaction is enabled, `stdout` and `stderr` are processed +by separate goroutines reading from separate pipes. This means that output from +these streams may appear in a different order than when running the command +directly in a terminal. This is a fundamental limitation of the pipe-based +interception approach and does not affect the correctness of redaction. + +If your script requires precise `stdout`/`stderr` interleaving, you can redirect +`stderr` to `stdout` in your script: + +```bash +aws-vault exec --redact profile -- bash -c "./script.sh 2>&1" +``` +**Note:** This merges the streams, so you won't be able to separately capture `stderr`. + ## Roles and MFA [Best-practice](https://docs.aws.amazon.com/IAM/latest/UserGuide/best-practices.html#delegate-using-roles) is to [create Roles to delegate permissions](https://docs.aws.amazon.com/cli/latest/userguide/cli-roles.html). For security, you should also require that users provide a one-time key generated from a multi-factor authentication (MFA) device. diff --git a/cli/exec.go b/cli/exec.go index d3a65aeb..d94cf91c 100644 --- a/cli/exec.go +++ b/cli/exec.go @@ -1,15 +1,19 @@ package cli import ( + "bytes" "context" "fmt" + "io" "log" "net/http" "os" osexec "os/exec" "os/signal" "runtime" + "strconv" "strings" + "sync" "syscall" "time" @@ -34,6 +38,7 @@ type ExecCommandInput struct { NoSession bool UseStdout bool ShowHelpMessages bool + RedactSecrets bool } func (input ExecCommandInput) validate() error { @@ -107,6 +112,9 @@ func ConfigureExecCommand(app *kingpin.Application, a *AwsVault) { cmd.Flag("stdout", "Print the SSO link to the terminal without automatically opening the browser"). BoolVar(&input.UseStdout) + cmd.Flag("redact", "Redact AWS credentials from subprocess output"). + BoolVar(&input.RedactSecrets) + cmd.Arg("profile", "Name of the profile"). //Required(). Default(os.Getenv("AWS_PROFILE")). @@ -158,6 +166,10 @@ func ConfigureExecCommand(app *kingpin.Application, a *AwsVault) { err = ExportCommand(exportCommandInput, f, keyring) } else { + // Determine final redaction setting: CLI flag overrides config file + if input.RedactSecrets { + input.Config.RedactSecrets = true + } exitcode, err = ExecCommand(input, f, keyring) } @@ -197,6 +209,16 @@ func ExecCommand(input ExecCommandInput, f *vault.ConfigFile, keyring keyring.Ke cmdEnv := createEnv(input.ProfileName, config.Region) + // Get credentials for redaction if needed + var credentials aws.Credentials + if config.RedactSecrets { + creds, err := credsProvider.Retrieve(context.TODO()) + if err != nil { + return 0, fmt.Errorf("Failed to get credentials for redaction: %w", err) + } + credentials = creds + } + if input.StartEc2Server { if server.IsProxyRunning() { return 0, fmt.Errorf("Another process is already bound to 169.254.169.254:80") @@ -224,14 +246,23 @@ func ExecCommand(input ExecCommandInput, f *vault.ConfigFile, keyring keyring.Ke } printHelpMessage(subshellHelp, input.ShowHelpMessages) - err = doExecSyscall(input.Command, input.Args, cmdEnv) // will not return if exec syscall succeeds - if err != nil { - log.Println("Error doing execve syscall:", err.Error()) - log.Println("Falling back to running a subprocess") + if config.RedactSecrets { + // When redaction is enabled, we must use runSubProcess to wrap stdout/stderr + return runSubProcess(input.Command, input.Args, cmdEnv, config.RedactSecrets, credentials) + } else { + // When redaction is disabled, try doExecSyscall first for better performance + err = doExecSyscall(input.Command, input.Args, cmdEnv) // will not return if exec syscall succeeds + if err != nil { + log.Println("Error doing execve syscall:", err.Error()) + log.Println("Falling back to running a subprocess") + return runSubProcess(input.Command, input.Args, cmdEnv, config.RedactSecrets, credentials) + } + // If doExecSyscall succeeded, we never reach here (it replaces the process) } } - return runSubProcess(input.Command, input.Args, cmdEnv) + // This should never be reached in the non-redaction case + return runSubProcess(input.Command, input.Args, cmdEnv, config.RedactSecrets, credentials) } func printHelpMessage(helpMsg string, showHelpMessages bool) { @@ -345,38 +376,239 @@ func getDefaultShell() string { return command } -func runSubProcess(command string, args []string, env []string) (int, error) { +func runSubProcess(command string, args []string, env []string, redactSecrets bool, credentials aws.Credentials) (int, error) { log.Printf("Starting a subprocess: %s %s", command, strings.Join(args, " ")) cmd := osexec.Command(command, args...) cmd.Stdin = os.Stdin - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr cmd.Env = env - sigChan := make(chan os.Signal, 1) - signal.Notify(sigChan) + if redactSecrets { + return runSubProcessWithRedaction(cmd, credentials) + } else { + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan) + + if err := cmd.Start(); err != nil { + return 0, err + } + + // proxy signals to process + done := make(chan struct{}) + go func() { + for { + select { + case sig := <-sigChan: + if cmd.Process != nil { + _ = cmd.Process.Signal(sig) + } + case <-done: + return + } + } + }() + + if err := cmd.Wait(); err != nil { + _ = cmd.Process.Signal(os.Kill) + close(done) + signal.Stop(sigChan) + return 0, fmt.Errorf("subprocess exited with error: %w", err) + } + + close(done) + signal.Stop(sigChan) + waitStatus := cmd.ProcessState.Sys().(syscall.WaitStatus) + return waitStatus.ExitStatus(), nil + } +} + +func getStderrWindowSize(maxCredLen int) int { + const defaultSize = 256 + + if envVal := os.Getenv("AWS_VAULT_STDERR_WINDOW_SIZE"); envVal != "" { + if size, err := strconv.Atoi(envVal); err == nil { + if size < 0 { + log.Printf("Invalid AWS_VAULT_STDERR_WINDOW_SIZE: %d, using default %d", size, defaultSize) + return defaultSize + } + if size > maxCredLen { + // Cap at maxCredLen - no point going higher + return maxCredLen + } + return size + } + log.Printf("Invalid AWS_VAULT_STDERR_WINDOW_SIZE: %s, using default %d", envVal, defaultSize) + } + + // Ensure we don't exceed maxCredLen + if defaultSize > maxCredLen { + return maxCredLen + } + + return defaultSize +} + +func runSubProcessWithRedaction(cmd *osexec.Cmd, credentials aws.Credentials) (int, error) { + // Create pipes for stdout/stderr + stdoutPipe, err := cmd.StdoutPipe() + if err != nil { + return 0, fmt.Errorf("Failed to create stdout pipe: %w", err) + } + stderrPipe, err := cmd.StderrPipe() + if err != nil { + return 0, fmt.Errorf("Failed to create stderr pipe: %w", err) + } + // Start the process (fork + exec happens here) if err := cmd.Start(); err != nil { - return 0, err + return 0, fmt.Errorf("Failed to start process: %w", err) } - // proxy signals to process + // Create WaitGroup to wait for output goroutines + var wg sync.WaitGroup + wg.Add(2) + + // Calculate max credential length for sliding window + maxCredLen := maxCredentialLength(credentials) + stderrWindowSize := getStderrWindowSize(maxCredLen) + + // Handle stdout redaction with sliding window + go func() { + defer wg.Done() + streamWithRedaction(stdoutPipe, os.Stdout, credentials, maxCredLen) + }() + + // Handle stderr redaction with sliding window + go func() { + defer wg.Done() + streamWithRedaction(stderrPipe, os.Stderr, credentials, stderrWindowSize) + }() + + // Set up signal forwarding + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM, syscall.SIGHUP, syscall.SIGQUIT) + done := make(chan struct{}) go func() { for { - sig := <-sigChan - _ = cmd.Process.Signal(sig) + select { + case sig := <-sigChan: + if cmd.Process != nil { + cmd.Process.Signal(sig) + } + case <-done: + return + } } }() - if err := cmd.Wait(); err != nil { - _ = cmd.Process.Signal(os.Kill) - return 0, fmt.Errorf("Failed to wait for command termination: %v", err) + // Wait for process to complete + err = cmd.Wait() + + // Clean up signal handler + close(done) + signal.Stop(sigChan) + + // Wait for output goroutines to finish + wg.Wait() + + if err != nil { + if exitErr, ok := err.(*osexec.ExitError); ok { + return exitErr.ExitCode(), nil + } + return 0, fmt.Errorf("subprocess exited with error: %w", err) + } + + return 0, nil +} + +// maxCredentialLength returns the length of the longest credential +func maxCredentialLength(credentials aws.Credentials) int { + maxLen := 0 + if len(credentials.AccessKeyID) > maxLen { + maxLen = len(credentials.AccessKeyID) } + if len(credentials.SecretAccessKey) > maxLen { + maxLen = len(credentials.SecretAccessKey) + } + if len(credentials.SessionToken) > maxLen { + maxLen = len(credentials.SessionToken) + } + + // Session tokens can be 1000+ chars, cap at reasonable limit + if maxLen > 2048 { + maxLen = 2048 + } + + return maxLen + 100 // Add safety buffer +} + +// streamWithRedaction reads from src, redacts credentials, and writes to dst +// Uses a sliding window to handle credentials split across buffer boundaries +func streamWithRedaction(src io.Reader, dst io.Writer, credentials aws.Credentials, maxCredLen int) { + const bufSize = 4096 + buf := make([]byte, bufSize) + overlap := make([]byte, 0, maxCredLen) + + for { + n, err := src.Read(buf) + if n > 0 { + // Combine overlap from previous iteration with new data + combined := append(overlap, buf[:n]...) + redacted := redactBytes(combined, credentials) + + // Write everything except the last maxCredLen bytes (keep as overlap) + if len(redacted) > maxCredLen { + toWrite := redacted[:len(redacted)-maxCredLen] + if _, writeErr := dst.Write(toWrite); writeErr != nil { + log.Printf("Error writing output: %v", writeErr) + } + // Keep the last maxCredLen bytes as overlap for next iteration + overlap = redacted[len(redacted)-maxCredLen:] + } else { + // Not enough data yet, keep accumulating + overlap = redacted + } + } + + if err != nil { + // Flush any remaining overlap + if len(overlap) > 0 { + if _, writeErr := dst.Write(overlap); writeErr != nil { + log.Printf("Error writing final output: %v", writeErr) + } + } - waitStatus := cmd.ProcessState.Sys().(syscall.WaitStatus) + if err != io.EOF { + log.Printf("Error reading: %v", err) + } + break + } + } +} - return waitStatus.ExitStatus(), nil +// redactBytes redacts AWS credentials from byte data +func redactBytes(data []byte, credentials aws.Credentials) []byte { + result := data + + // Redact access key ID (exact match) + if credentials.AccessKeyID != "" { + result = bytes.ReplaceAll(result, []byte(credentials.AccessKeyID), []byte("")) + } + + // Redact secret access key (exact match) + if credentials.SecretAccessKey != "" { + result = bytes.ReplaceAll(result, []byte(credentials.SecretAccessKey), []byte("")) + } + + // Redact session token (exact match) + if credentials.SessionToken != "" { + result = bytes.ReplaceAll(result, []byte(credentials.SessionToken), []byte("")) + } + + return result } func doExecSyscall(command string, args []string, env []string) error { diff --git a/cli/exec_integration_test.go b/cli/exec_integration_test.go new file mode 100644 index 00000000..ae710d80 --- /dev/null +++ b/cli/exec_integration_test.go @@ -0,0 +1,170 @@ +package cli + +import ( + "os" + "path/filepath" + "testing" + + "github.com/byteness/aws-vault/v7/vault" + "github.com/byteness/keyring" +) + +func TestExecCommandWithRedaction(t *testing.T) { + // Create a temporary config file + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config") + + configContent := `[default] +region = us-east-1 +aws_access_key_id = AKIAIOSFODNN7EXAMPLE +aws_secret_access_key = wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY + +[aws-vault] +redact_secrets = true +` + + err := os.WriteFile(configPath, []byte(configContent), 0644) + if err != nil { + t.Fatalf("Failed to write config file: %v", err) + } + + // Create a mock keyring with credentials + keyring := keyring.NewArrayKeyring([]keyring.Item{ + {Key: "default", Data: []byte(`{"AccessKeyID":"AKIAIOSFODNN7EXAMPLE","SecretAccessKey":"wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY","SessionToken":"","ProviderName":"StaticProvider"}`)}, + }) + + // Load the config file + configFile, err := vault.LoadConfig(configPath) + if err != nil { + t.Fatalf("Failed to load config file: %v", err) + } + + // Test ExecCommand with redaction enabled and no-session to avoid MFA + input := ExecCommandInput{ + ProfileName: "default", + Command: "echo", + Args: []string{"Access key: AKIAIOSFODNN7EXAMPLE"}, + NoSession: true, // Skip session creation to avoid MFA + Config: vault.ProfileConfig{ + RedactSecrets: true, + }, + } + + exitCode, err := ExecCommand(input, configFile, keyring) + + if err != nil { + t.Errorf("ExecCommand() error = %v", err) + } + + if exitCode != 0 { + t.Errorf("ExecCommand() exitCode = %d, want 0", exitCode) + } + + // Note: This test verifies that ExecCommand runs without error when redaction is enabled. + // The actual redaction verification would require more complex subprocess output capture. +} + +func TestExecCommandWithoutRedaction(t *testing.T) { + // Create a temporary config file without redaction + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config") + + configContent := `[default] +region = us-east-1 +aws_access_key_id = AKIAIOSFODNN7EXAMPLE +aws_secret_access_key = wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY +` + + err := os.WriteFile(configPath, []byte(configContent), 0644) + if err != nil { + t.Fatalf("Failed to write config file: %v", err) + } + + // Create a mock keyring with credentials + keyring := keyring.NewArrayKeyring([]keyring.Item{ + {Key: "default", Data: []byte(`{"AccessKeyID":"AKIAIOSFODNN7EXAMPLE","SecretAccessKey":"wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY","SessionToken":"","ProviderName":"StaticProvider"}`)}, + }) + + // Load the config file + configFile, err := vault.LoadConfig(configPath) + if err != nil { + t.Fatalf("Failed to load config file: %v", err) + } + + // Test ExecCommand without redaction + input := ExecCommandInput{ + ProfileName: "default", + Command: "echo", + Args: []string{"Access key: AKIAIOSFODNN7EXAMPLE"}, + NoSession: true, // Skip session creation to avoid MFA + Config: vault.ProfileConfig{ + RedactSecrets: false, + }, + } + + exitCode, err := ExecCommand(input, configFile, keyring) + + if err != nil { + t.Errorf("ExecCommand() error = %v", err) + } + + if exitCode != 0 { + t.Errorf("ExecCommand() exitCode = %d, want 0", exitCode) + } + + // Note: This test verifies that ExecCommand runs without error when redaction is disabled. +} + +func TestExecCommandCLIFlagOverridesConfig(t *testing.T) { + // Create a temporary config file with redaction enabled + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config") + + configContent := `[default] +region = us-east-1 +aws_access_key_id = AKIAIOSFODNN7EXAMPLE +aws_secret_access_key = wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY + +[aws-vault] +redact_secrets = true +` + + err := os.WriteFile(configPath, []byte(configContent), 0644) + if err != nil { + t.Fatalf("Failed to write config file: %v", err) + } + + // Create a mock keyring with credentials + keyring := keyring.NewArrayKeyring([]keyring.Item{ + {Key: "default", Data: []byte(`{"AccessKeyID":"AKIAIOSFODNN7EXAMPLE","SecretAccessKey":"wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY","SessionToken":"","ProviderName":"StaticProvider"}`)}, + }) + + // Load the config file + configFile, err := vault.LoadConfig(configPath) + if err != nil { + t.Fatalf("Failed to load config file: %v", err) + } + + // Test ExecCommand with CLI flag overriding config (redaction disabled) + input := ExecCommandInput{ + ProfileName: "default", + Command: "echo", + Args: []string{"Access key: AKIAIOSFODNN7EXAMPLE"}, + NoSession: true, // Skip session creation to avoid MFA + Config: vault.ProfileConfig{ + RedactSecrets: false, // CLI flag would override this + }, + } + + exitCode, err := ExecCommand(input, configFile, keyring) + + if err != nil { + t.Errorf("ExecCommand() error = %v", err) + } + + if exitCode != 0 { + t.Errorf("ExecCommand() exitCode = %d, want 0", exitCode) + } + + // Note: This test verifies that ExecCommand runs without error when CLI flag overrides config. +} diff --git a/cli/redaction_test.go b/cli/redaction_test.go new file mode 100644 index 00000000..96ea7136 --- /dev/null +++ b/cli/redaction_test.go @@ -0,0 +1,426 @@ +package cli + +import ( + "bytes" + "os" + osexec "os/exec" + "strings" + "testing" + + "github.com/aws/aws-sdk-go-v2/aws" +) + +func TestRedactBytes(t *testing.T) { + tests := []struct { + name string + input string + credentials aws.Credentials + expected string + }{ + { + name: "no credentials", + input: "This is just normal text", + credentials: aws.Credentials{ + AccessKeyID: "", + SecretAccessKey: "", + SessionToken: "", + }, + expected: "This is just normal text", + }, + { + name: "access key only", + input: "AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE", + credentials: aws.Credentials{ + AccessKeyID: "AKIAIOSFODNN7EXAMPLE", + SecretAccessKey: "", + SessionToken: "", + }, + expected: "AWS_ACCESS_KEY_ID=", + }, + { + name: "secret key only", + input: "AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + credentials: aws.Credentials{ + AccessKeyID: "", + SecretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + SessionToken: "", + }, + expected: "AWS_SECRET_ACCESS_KEY=", + }, + { + name: "session token only", + input: "AWS_SESSION_TOKEN=AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKwRcOIfrRh3c/LTo6UDdyJwOOvEVPvLXCrrrUtdnniCEXAMPLE/IvP1EAXGJ2R5O5R8ksWOnUkrUsUSSTS2FAKE", + credentials: aws.Credentials{ + AccessKeyID: "", + SecretAccessKey: "", + SessionToken: "AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKwRcOIfrRh3c/LTo6UDdyJwOOvEVPvLXCrrrUtdnniCEXAMPLE/IvP1EAXGJ2R5O5R8ksWOnUkrUsUSSTS2FAKE", + }, + expected: "AWS_SESSION_TOKEN=", + }, + { + name: "all credentials", + input: "Access: AKIAIOSFODNN7EXAMPLE Secret: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY Token: AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKwRcOIfrRh3c/LTo6UDdyJwOOvEVPvLXCrrrUtdnniCEXAMPLE/IvP1EAXGJ2R5O5R8ksWOnUkrUsUSSTS2FAKE", + credentials: aws.Credentials{ + AccessKeyID: "AKIAIOSFODNN7EXAMPLE", + SecretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + SessionToken: "AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKwRcOIfrRh3c/LTo6UDdyJwOOvEVPvLXCrrrUtdnniCEXAMPLE/IvP1EAXGJ2R5O5R8ksWOnUkrUsUSSTS2FAKE", + }, + expected: "Access: Secret: Token: ", + }, + { + name: "partial match should not redact", + input: "AWS_ACCESS_KEY_ID=AKIA1234567890ABCDEF", + credentials: aws.Credentials{ + AccessKeyID: "AKIAIOSFODNN7EXAMPLE", + SecretAccessKey: "", + SessionToken: "", + }, + expected: "AWS_ACCESS_KEY_ID=AKIA1234567890ABCDEF", // Should NOT be redacted + }, + { + name: "credentials in middle of text", + input: "Found credentials: AKIAIOSFODNN7EXAMPLE in the logs", + credentials: aws.Credentials{ + AccessKeyID: "AKIAIOSFODNN7EXAMPLE", + SecretAccessKey: "", + SessionToken: "", + }, + expected: "Found credentials: in the logs", + }, + { + name: "multiple occurrences", + input: "Key1: AKIAIOSFODNN7EXAMPLE Key2: AKIAIOSFODNN7EXAMPLE", + credentials: aws.Credentials{ + AccessKeyID: "AKIAIOSFODNN7EXAMPLE", + SecretAccessKey: "", + SessionToken: "", + }, + expected: "Key1: Key2: ", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := redactBytes([]byte(tt.input), tt.credentials) + if string(result) != tt.expected { + t.Errorf("redactBytes() = %q, want %q", string(result), tt.expected) + } + }) + } +} + +func TestMaxCredentialLength(t *testing.T) { + tests := []struct { + name string + credentials aws.Credentials + expected int + }{ + { + name: "empty credentials", + credentials: aws.Credentials{ + AccessKeyID: "", + SecretAccessKey: "", + SessionToken: "", + }, + expected: 100, // Just the safety buffer + }, + { + name: "access key longest", + credentials: aws.Credentials{ + AccessKeyID: "AKIAIOSFODNN7EXAMPLE", + SecretAccessKey: "short", + SessionToken: "also_short", + }, + expected: len("AKIAIOSFODNN7EXAMPLE") + 100, + }, + { + name: "secret key longest", + credentials: aws.Credentials{ + AccessKeyID: "short", + SecretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + SessionToken: "also_short", + }, + expected: len("wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY") + 100, + }, + { + name: "session token longest", + credentials: aws.Credentials{ + AccessKeyID: "short", + SecretAccessKey: "also_short", + SessionToken: "AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKwRcOIfrRh3c/LTo6UDdyJwOOvEVPvLXCrrrUtdnniCEXAMPLE/IvP1EAXGJ2R5O5R8ksWOnUkrUsUSSTS2FAKE", + }, + expected: len("AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKwRcOIfrRh3c/LTo6UDdyJwOOvEVPvLXCrrrUtdnniCEXAMPLE/IvP1EAXGJ2R5O5R8ksWOnUkrUsUSSTS2FAKE") + 100, + }, + { + name: "very long session token capped", + credentials: aws.Credentials{ + AccessKeyID: "short", + SecretAccessKey: "also_short", + SessionToken: strings.Repeat("A", 3000), // Very long token + }, + expected: 2048 + 100, // Should be capped at 2048 + buffer + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := maxCredentialLength(tt.credentials) + if result != tt.expected { + t.Errorf("maxCredentialLength() = %d, want %d", result, tt.expected) + } + }) + } +} + +func TestGetStderrWindowSize(t *testing.T) { + // Save and restore environment + originalEnv := os.Getenv("AWS_VAULT_STDERR_WINDOW_SIZE") + defer func() { + if originalEnv == "" { + os.Unsetenv("AWS_VAULT_STDERR_WINDOW_SIZE") + } else { + os.Setenv("AWS_VAULT_STDERR_WINDOW_SIZE", originalEnv) + } + }() + + tests := []struct { + name string + envValue string + maxCredLen int + expected int + }{ + { + name: "no environment variable", + envValue: "", + maxCredLen: 1000, + expected: 256, + }, + { + name: "valid environment variable", + envValue: "512", + maxCredLen: 1000, + expected: 512, + }, + { + name: "environment variable exceeds maxCredLen", + envValue: "2000", + maxCredLen: 1000, + expected: 1000, // Should be capped + }, + { + name: "environment variable negative", + envValue: "-100", + maxCredLen: 1000, + expected: 256, // Should fall back to default + }, + { + name: "environment variable invalid", + envValue: "not_a_number", + maxCredLen: 1000, + expected: 256, // Should fall back to default + }, + { + name: "default exceeds maxCredLen", + envValue: "", + maxCredLen: 200, + expected: 200, // Should be capped at maxCredLen + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.envValue == "" { + os.Unsetenv("AWS_VAULT_STDERR_WINDOW_SIZE") + } else { + os.Setenv("AWS_VAULT_STDERR_WINDOW_SIZE", tt.envValue) + } + + result := getStderrWindowSize(tt.maxCredLen) + if result != tt.expected { + t.Errorf("getStderrWindowSize() = %d, want %d", result, tt.expected) + } + }) + } +} + +func TestStreamWithRedactionSlidingWindow(t *testing.T) { + // Test that credentials split across buffer boundaries are properly redacted + credentials := aws.Credentials{ + AccessKeyID: "AKIAIOSFODNN7EXAMPLE", + SecretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + SessionToken: "", + } + + // Create a credential that will be split across two reads + credential := "AKIAIOSFODNN7EXAMPLE" + splitPoint := len(credential) / 2 + + // First part of credential + part1 := "Found: " + credential[:splitPoint] + // Second part of credential + part2 := credential[splitPoint:] + " in logs" + + // Create input that simulates the credential being split + input := part1 + part2 + + // Test with sliding window + var output bytes.Buffer + reader := strings.NewReader(input) + + streamWithRedaction(reader, &output, credentials, len(credential)+100) + + result := output.String() + + // The credential should be redacted even though it was split + if strings.Contains(result, credential) { + t.Errorf("Credential %q should be redacted but was found in output: %q", credential, result) + } + + if !strings.Contains(result, "") { + t.Errorf("Expected in output but got: %q", result) + } +} + +func TestStreamWithRedactionBasic(t *testing.T) { + // Test basic streaming functionality + credentials := aws.Credentials{ + AccessKeyID: "AKIAIOSFODNN7EXAMPLE", + SecretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + SessionToken: "", + } + + tests := []struct { + name string + input string + expected string + }{ + { + name: "simple credential redaction", + input: "Access key: AKIAIOSFODNN7EXAMPLE", + expected: "Access key: ", + }, + { + name: "no credentials", + input: "Just normal text", + expected: "Just normal text", + }, + { + name: "multiple credentials", + input: "Key: AKIAIOSFODNN7EXAMPLE Secret: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + expected: "Key: Secret: ", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var output bytes.Buffer + reader := strings.NewReader(tt.input) + + streamWithRedaction(reader, &output, credentials, 1000) + + result := output.String() + if result != tt.expected { + t.Errorf("streamWithRedaction() = %q, want %q", result, tt.expected) + } + }) + } +} + +func TestStreamWithRedactionEmptyInput(t *testing.T) { + // Test with empty input + credentials := aws.Credentials{ + AccessKeyID: "AKIAIOSFODNN7EXAMPLE", + SecretAccessKey: "", + SessionToken: "", + } + + var output bytes.Buffer + reader := strings.NewReader("") + + streamWithRedaction(reader, &output, credentials, 1000) + + result := output.String() + if result != "" { + t.Errorf("Expected empty output, got %q", result) + } +} + +func TestStreamWithRedactionLargeBuffer(t *testing.T) { + // Test with large input that exceeds buffer size + credentials := aws.Credentials{ + AccessKeyID: "AKIAIOSFODNN7EXAMPLE", + SecretAccessKey: "", + SessionToken: "", + } + + // Create large input with credential in the middle + largeText := strings.Repeat("A", 10000) + "AKIAIOSFODNN7EXAMPLE" + strings.Repeat("B", 10000) + + var output bytes.Buffer + reader := strings.NewReader(largeText) + + streamWithRedaction(reader, &output, credentials, 1000) + + result := output.String() + if strings.Contains(result, "AKIAIOSFODNN7EXAMPLE") { + t.Errorf("Credential should be redacted in large buffer") + } + if !strings.Contains(result, "") { + t.Errorf("Expected in large buffer output") + } +} + +func TestRunSubProcessWithRedaction(t *testing.T) { + // Test the runSubProcessWithRedaction function with a simple command + credentials := aws.Credentials{ + AccessKeyID: "AKIAIOSFODNN7EXAMPLE", + SecretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + SessionToken: "", + } + + // Test with echo command that outputs credentials + cmd := osexec.Command("echo", "Access key: AKIAIOSFODNN7EXAMPLE") + + // Create pipes to capture output + stdoutPipe, err := cmd.StdoutPipe() + if err != nil { + t.Fatalf("Failed to create stdout pipe: %v", err) + } + stderrPipe, err := cmd.StderrPipe() + if err != nil { + t.Fatalf("Failed to create stderr pipe: %v", err) + } + + // Start the process + if err := cmd.Start(); err != nil { + t.Fatalf("Failed to start process: %v", err) + } + + // Capture output + var stdout, stderr bytes.Buffer + go func() { + streamWithRedaction(stdoutPipe, &stdout, credentials, 1000) + }() + go func() { + streamWithRedaction(stderrPipe, &stderr, credentials, 1000) + }() + + // Wait for process to complete + err = cmd.Wait() + if err != nil { + t.Errorf("Process failed: %v", err) + } + + // Check that credentials are redacted in output + output := stdout.String() + if strings.Contains(output, "AKIAIOSFODNN7EXAMPLE") { + t.Errorf("Credential should be redacted in subprocess output: %q", output) + } + if !strings.Contains(output, "") { + t.Errorf("Expected in subprocess output: %q", output) + } +} + +// Note: Integration tests for runSubProcessWithRedaction are complex due to +// subprocess execution and stdout/stderr redirection. The core functionality +// is tested through the streamWithRedaction tests above. diff --git a/vault/config.go b/vault/config.go index a4fdc1e7..4c0291f5 100644 --- a/vault/config.go +++ b/vault/config.go @@ -156,6 +156,12 @@ type SSOSessionSection struct { SSORegistrationScopes string `ini:"sso_registration_scopes,omitempty"` } +// AwsVaultSection is an [aws-vault] section of the config file +type AwsVaultSection struct { + Name string `ini:"-"` + RedactSecrets bool `ini:"redact_secrets,omitempty"` +} + func (s ProfileSection) IsEmpty() bool { s.Name = "" return s == ProfileSection{} @@ -181,6 +187,9 @@ func (c *ConfigFile) ProfileSections() []ProfileSection { } else if strings.HasPrefix(section, "sso-session ") { // Not a profile continue + } else if section == "aws-vault" { + // Not a profile + continue } else { log.Printf("Unrecognised ini file section: %s", section) continue @@ -234,6 +243,25 @@ func (c *ConfigFile) SSOSessionSection(name string) (SSOSessionSection, bool) { return ssoSession, true } +// AwsVaultSection returns the [aws-vault] section. If there isn't any, +// an empty aws-vault section is returned, along with false. +func (c *ConfigFile) AwsVaultSection() (AwsVaultSection, bool) { + awsVault := AwsVaultSection{ + Name: "aws-vault", + } + if c.iniFile == nil { + return awsVault, false + } + section, err := c.iniFile.GetSection("aws-vault") + if err != nil { + return awsVault, false + } + if err = section.MapTo(&awsVault); err != nil { + panic(err) + } + return awsVault, true +} + func (c *ConfigFile) Save() error { return c.iniFile.SaveTo(c.Path) } @@ -417,6 +445,12 @@ func (cl *ConfigLoader) populateFromConfigFile(config *ProfileConfig, profileNam config.SourceProfileName = "" } + // Read aws-vault section for global settings + awsVaultSection, ok := cl.File.AwsVaultSection() + if ok { + config.RedactSecrets = awsVaultSection.RedactSecrets + } + return nil } @@ -617,6 +651,9 @@ type ProfileConfig struct { // CredentialProcess specifies external command to run to get an AWS credential CredentialProcess string + + // RedactSecrets specifies whether to redact AWS credentials from subprocess output + RedactSecrets bool } // SetSessionTags parses a comma separated key=vaue string and sets Config.SessionTags map diff --git a/vault/redaction_config_test.go b/vault/redaction_config_test.go new file mode 100644 index 00000000..e28036c7 --- /dev/null +++ b/vault/redaction_config_test.go @@ -0,0 +1,122 @@ +package vault_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/byteness/aws-vault/v7/vault" +) + +func TestAwsVaultSection(t *testing.T) { + // Create a temporary config file + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config") + + configContent := `[default] +region = us-east-1 + +[aws-vault] +redact_secrets = true + +[profile test] +region = us-west-2 +` + + err := os.WriteFile(configPath, []byte(configContent), 0644) + if err != nil { + t.Fatalf("Failed to write config file: %v", err) + } + + // Load the config file + configFile, err := vault.LoadConfig(configPath) + if err != nil { + t.Fatalf("Failed to load config file: %v", err) + } + + // Test that the aws-vault section is parsed correctly + awsVaultSection, ok := configFile.AwsVaultSection() + if !ok { + t.Fatal("Expected to find [aws-vault] section") + } + + if !awsVaultSection.RedactSecrets { + t.Error("Expected redact_secrets to be true") + } + + if awsVaultSection.Name != "aws-vault" { + t.Errorf("Expected section name to be 'aws-vault', got %q", awsVaultSection.Name) + } +} + +func TestAwsVaultSectionMissing(t *testing.T) { + // Create a temporary config file without [aws-vault] section + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config") + + configContent := `[default] +region = us-east-1 + +[profile test] +region = us-west-2 +` + + err := os.WriteFile(configPath, []byte(configContent), 0644) + if err != nil { + t.Fatalf("Failed to write config file: %v", err) + } + + // Load the config file + configFile, err := vault.LoadConfig(configPath) + if err != nil { + t.Fatalf("Failed to load config file: %v", err) + } + + // Test that the aws-vault section is not found + awsVaultSection, ok := configFile.AwsVaultSection() + if ok { + t.Fatal("Expected not to find [aws-vault] section") + } + + // Should return default values + if awsVaultSection.RedactSecrets { + t.Error("Expected redact_secrets to be false by default") + } +} + +func TestAwsVaultSectionRedactSecretsFalse(t *testing.T) { + // Create a temporary config file with redact_secrets = false + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config") + + configContent := `[default] +region = us-east-1 + +[aws-vault] +redact_secrets = false + +[profile test] +region = us-west-2 +` + + err := os.WriteFile(configPath, []byte(configContent), 0644) + if err != nil { + t.Fatalf("Failed to write config file: %v", err) + } + + // Load the config file + configFile, err := vault.LoadConfig(configPath) + if err != nil { + t.Fatalf("Failed to load config file: %v", err) + } + + // Test that the aws-vault section is parsed correctly + awsVaultSection, ok := configFile.AwsVaultSection() + if !ok { + t.Fatal("Expected to find [aws-vault] section") + } + + if awsVaultSection.RedactSecrets { + t.Error("Expected redact_secrets to be false") + } +}