diff --git a/config.go b/config.go index cacb3e4..09db0f9 100644 --- a/config.go +++ b/config.go @@ -161,10 +161,10 @@ func logToFile(config *Config, message string) { // LogRequest logs HTTP request details to the configured log file func LogRequest(config *Config, method, url string) { - logToFile(config, fmt.Sprintf("REQUEST: %s %s", method, url)) + logToFile(config, fmt.Sprintf("REQUEST: %s %s", method, redactAuthToken(url))) } // LogResponse logs HTTP response details to the configured log file func LogResponse(config *Config, statusCode int, url string) { - logToFile(config, fmt.Sprintf("RESPONSE: %d %s", statusCode, url)) + logToFile(config, fmt.Sprintf("RESPONSE: %d %s", statusCode, redactAuthToken(url))) } diff --git a/config_test.go b/config_test.go index 0295493..06216c8 100644 --- a/config_test.go +++ b/config_test.go @@ -401,3 +401,101 @@ func TestConfigWithLogFile(t *testing.T) { } }) } + +// TestLoggingRedactsAuthToken tests that auth tokens are redacted in logs +func TestLoggingRedactsAuthToken(t *testing.T) { + t.Run("LogRequest redacts auth_token from URL", func(t *testing.T) { + tmpDir := t.TempDir() + logFile := filepath.Join(tmpDir, "test_log.txt") + + config := &Config{ + Username: "testuser", + AuthToken: "secret123", + LogFile: logFile, + } + + // Log a request with auth_token in URL + LogRequest(config, "GET", "https://api.example.com/goals?auth_token=secret123") + + // Read log file + data, err := os.ReadFile(logFile) + if err != nil { + t.Fatalf("Failed to read log file: %v", err) + } + + content := string(data) + + // Verify auth_token is redacted + if strings.Contains(content, "secret123") { + t.Errorf("Log should not contain actual auth token, got: %s", content) + } + if !strings.Contains(content, "auth_token=***") { + t.Errorf("Log should contain redacted auth token (auth_token=***), got: %s", content) + } + }) + + t.Run("LogResponse redacts auth_token from URL", func(t *testing.T) { + tmpDir := t.TempDir() + logFile := filepath.Join(tmpDir, "test_log.txt") + + config := &Config{ + Username: "testuser", + AuthToken: "secret456", + LogFile: logFile, + } + + // Log a response with auth_token in URL + LogResponse(config, 200, "https://api.example.com/goals?auth_token=secret456") + + // Read log file + data, err := os.ReadFile(logFile) + if err != nil { + t.Fatalf("Failed to read log file: %v", err) + } + + content := string(data) + + // Verify auth_token is redacted + if strings.Contains(content, "secret456") { + t.Errorf("Log should not contain actual auth token, got: %s", content) + } + if !strings.Contains(content, "auth_token=***") { + t.Errorf("Log should contain redacted auth token (auth_token=***), got: %s", content) + } + }) + + t.Run("LogRequest redacts auth_token from middle of URL", func(t *testing.T) { + tmpDir := t.TempDir() + logFile := filepath.Join(tmpDir, "test_log.txt") + + config := &Config{ + Username: "testuser", + AuthToken: "mysecret", + LogFile: logFile, + } + + // Log request with auth_token in middle of query params + LogRequest(config, "GET", "https://api.example.com/goals?user=alice&auth_token=mysecret&other=value") + + data, err := os.ReadFile(logFile) + if err != nil { + t.Fatalf("Failed to read log file: %v", err) + } + + content := string(data) + + // Verify auth_token is redacted but other params remain + if strings.Contains(content, "mysecret") { + t.Errorf("Log should not contain actual auth token, got: %s", content) + } + if !strings.Contains(content, "user=alice") { + t.Errorf("Log should still contain other parameters, got: %s", content) + } + if !strings.Contains(content, "auth_token=***") { + t.Errorf("Log should contain redacted auth token, got: %s", content) + } + if !strings.Contains(content, "other=value") { + t.Errorf("Log should still contain parameters after auth_token, got: %s", content) + } + }) +} diff --git a/main.go b/main.go index c6d63f3..1327d08 100644 --- a/main.go +++ b/main.go @@ -313,7 +313,7 @@ func main() { // No arguments, run the interactive TUI p := tea.NewProgram(initialModel(), tea.WithAltScreen()) if _, err := p.Run(); err != nil { - fmt.Printf("Alas, there's been an error: %v", err) + fmt.Printf("Alas, there's been an error: %s", redactError(err)) os.Exit(1) } } @@ -330,7 +330,7 @@ func handleNextCommand() { fmt.Println("Usage: buzz next [-w|--watch]") return } - fmt.Fprintf(os.Stderr, "Error parsing flags: %v\n", err) + fmt.Fprintf(os.Stderr, "Error parsing flags: %s\n", redactError(err)) fmt.Fprintln(os.Stderr, "Usage: buzz next [-w|--watch]") os.Exit(2) } @@ -348,7 +348,7 @@ func handleNextCommand() { } else { // One-shot mode - display and exit if err := displayNextGoal(); err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) + fmt.Fprintf(os.Stderr, "Error: %s\n", redactError(err)) os.Exit(1) } } @@ -440,7 +440,7 @@ func clearScreen() { func displayNextGoalWithTimestamp() { fmt.Printf("[%s]\n", time.Now().Format("2006-01-02 15:04:05")) if err := displayNextGoal(); err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) + fmt.Fprintf(os.Stderr, "Error: %s\n", redactError(err)) } fmt.Printf("\nRefreshing every %dm... (Press Ctrl+C to exit)\n", int(RefreshInterval.Minutes())) } @@ -487,14 +487,14 @@ func handleFilteredCommand(filterName string, filter func(Goal) bool) { config, err := LoadConfig() if err != nil { - fmt.Printf("Error: Failed to load config: %v\n", err) + fmt.Printf("Error: Failed to load config: %s\n", redactError(err)) os.Exit(1) } // Fetch goals goals, err := FetchGoals(config) if err != nil { - fmt.Printf("Error: Failed to fetch goals: %v\n", err) + fmt.Printf("Error: Failed to fetch goals: %s\n", redactError(err)) os.Exit(1) } @@ -561,7 +561,7 @@ func handleAddCommand() { fmt.Println("Note: Flags must come BEFORE positional arguments.") return } - fmt.Fprintf(os.Stderr, "Error parsing flags: %v\n", err) + fmt.Fprintf(os.Stderr, "Error parsing flags: %s\n", redactError(err)) printAddUsageAndExit("Invalid flags") } @@ -614,7 +614,7 @@ func handleAddCommand() { config, err := LoadConfig() if err != nil { - fmt.Printf("Error: Failed to load config: %v\n", err) + fmt.Printf("Error: Failed to load config: %s\n", redactError(err)) os.Exit(1) } @@ -640,14 +640,14 @@ func handleAddCommand() { // Create the datapoint err = CreateDatapoint(config, goalSlug, timestamp, value, comment, *requestid) if err != nil { - fmt.Printf("Error: Failed to add datapoint: %v\n", err) + fmt.Printf("Error: Failed to add datapoint: %s\n", redactError(err)) os.Exit(1) } // Signal any running TUI instances to refresh if err := createRefreshFlag(); err != nil { // Don't fail the command if flag creation fails - fmt.Fprintf(os.Stderr, "Warning: Could not create refresh flag: %v\n", err) + fmt.Fprintf(os.Stderr, "Warning: Could not create refresh flag: %s\n", redactError(err)) } successMsg := fmt.Sprintf("Successfully added datapoint to %s: value=%s, comment=\"%s\"", goalSlug, value, comment) @@ -663,7 +663,7 @@ func handleAddCommand() { goal, err := FetchGoal(config, goalSlug) if err != nil { // Don't fail the command if fetching limsum fails, just skip displaying it - fmt.Fprintf(os.Stderr, "Warning: Could not fetch goal status: %v\n", err) + fmt.Fprintf(os.Stderr, "Warning: Could not fetch goal status: %s\n", redactError(err)) } else { fmt.Printf("Limsum: %s\n", goal.Limsum) } @@ -691,14 +691,14 @@ func handleRefreshCommand() { config, err := LoadConfig() if err != nil { - fmt.Printf("Error: Failed to load config: %v\n", err) + fmt.Printf("Error: Failed to load config: %s\n", redactError(err)) os.Exit(1) } // Refresh the goal queued, err := RefreshGoal(config, goalSlug) if err != nil { - fmt.Printf("Error: Failed to refresh goal: %v\n", err) + fmt.Printf("Error: Failed to refresh goal: %s\n", redactError(err)) os.Exit(1) } @@ -725,7 +725,7 @@ func handleViewCommand() { fmt.Println("Usage: buzz view [--web] [--json] [--datapoints]") return } - fmt.Fprintf(os.Stderr, "Error parsing flags: %v\n", err) + fmt.Fprintf(os.Stderr, "Error parsing flags: %s\n", redactError(err)) fmt.Fprintln(os.Stderr, "Usage: buzz view [--web] [--json] [--datapoints]") os.Exit(2) } @@ -769,14 +769,14 @@ func handleViewCommand() { config, err := LoadConfig() if err != nil { - fmt.Fprintf(os.Stderr, "Error: Failed to load config: %v\n", err) + fmt.Fprintf(os.Stderr, "Error: Failed to load config: %s\n", redactError(err)) os.Exit(1) } // If --web flag is present, open in browser and exit if webFlag { if err := openBrowser(config, goalSlug); err != nil { - fmt.Fprintf(os.Stderr, "Error: Failed to open browser: %v\n", err) + fmt.Fprintf(os.Stderr, "Error: Failed to open browser: %s\n", redactError(err)) os.Exit(1) } return @@ -786,14 +786,14 @@ func handleViewCommand() { if jsonFlag { rawJSON, err := FetchGoalRawJSON(config, goalSlug, datapointsFlag) if err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) + fmt.Fprintf(os.Stderr, "Error: %s\n", redactError(err)) os.Exit(1) } // Pretty print the raw JSON var prettyJSON bytes.Buffer if err := json.Indent(&prettyJSON, rawJSON, "", " "); err != nil { - fmt.Fprintf(os.Stderr, "Error: Failed to format JSON: %v\n", err) + fmt.Fprintf(os.Stderr, "Error: Failed to format JSON: %s\n", redactError(err)) os.Exit(1) } fmt.Println(prettyJSON.String()) @@ -808,7 +808,7 @@ func handleViewCommand() { // Fetch the goal for human-readable output goal, err := FetchGoal(config, goalSlug) if err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) + fmt.Fprintf(os.Stderr, "Error: %s\n", redactError(err)) os.Exit(1) } @@ -830,14 +830,14 @@ func handleReviewCommand() { config, err := LoadConfig() if err != nil { - fmt.Fprintf(os.Stderr, "Error: Failed to load config: %v\n", err) + fmt.Fprintf(os.Stderr, "Error: Failed to load config: %s\n", redactError(err)) os.Exit(1) } // Fetch goals goals, err := FetchGoals(config) if err != nil { - fmt.Fprintf(os.Stderr, "Error: Failed to fetch goals: %v\n", err) + fmt.Fprintf(os.Stderr, "Error: Failed to fetch goals: %s\n", redactError(err)) os.Exit(1) } @@ -852,7 +852,7 @@ func handleReviewCommand() { // Launch the interactive review TUI p := tea.NewProgram(initialReviewModel(goals, config), tea.WithAltScreen()) if _, err := p.Run(); err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) + fmt.Fprintf(os.Stderr, "Error: %s\n", redactError(err)) os.Exit(1) } } @@ -906,14 +906,14 @@ func handleChargeCommand() { config, err := LoadConfig() if err != nil { - fmt.Fprintf(os.Stderr, "Error: Failed to load config: %v\n", err) + fmt.Fprintf(os.Stderr, "Error: Failed to load config: %s\n", redactError(err)) os.Exit(1) } // Create the charge (API returns the created/dry-run charge) ch, err := CreateCharge(config, amount, note, dryrun) if err != nil { - fmt.Fprintf(os.Stderr, "Error: Failed to create charge: %v\n", err) + fmt.Fprintf(os.Stderr, "Error: Failed to create charge: %s\n", redactError(err)) os.Exit(1) } diff --git a/utils.go b/utils.go index 451bcdb..1774acf 100644 --- a/utils.go +++ b/utils.go @@ -4,10 +4,19 @@ import ( "bufio" "fmt" "os" + "regexp" "strconv" "strings" ) +// Compile regex patterns once at package initialization for efficiency +var ( + // authTokenQueryParamRegex matches auth_token in query parameters + authTokenQueryParamRegex = regexp.MustCompile(`([?&]auth_token=)[^&\s"]+`) + // authTokenFormDataRegex matches auth_token in form data + authTokenFormDataRegex = regexp.MustCompile(`\bauth_token=([^&\s"]+)`) +) + // Helper functions for min/max func min(a, b int) int { if a < b { @@ -23,6 +32,29 @@ func max(a, b int) int { return b } +// redactAuthToken redacts auth_token values from strings (URLs, error messages, logs) +// This prevents accidental exposure of authentication credentials in logs and error output. +// It replaces auth_token parameter values in URLs and form data with "***" +func redactAuthToken(s string) string { + // Match auth_token in query parameters (e.g., ?auth_token=abc123 or &auth_token=abc123) + s = authTokenQueryParamRegex.ReplaceAllString(s, "${1}***") + + // Match auth_token in form data (e.g., auth_token=abc123 in URL-encoded form bodies) + // This second pattern handles cases where auth_token appears without ? or & prefix + s = authTokenFormDataRegex.ReplaceAllString(s, "auth_token=***") + + return s +} + +// redactError redacts auth tokens from error messages +// Use this when displaying errors to users to prevent exposing authentication credentials +func redactError(err error) string { + if err == nil { + return "" + } + return redactAuthToken(err.Error()) +} + // calculateColumns determines the optimal number of columns based on terminal width func calculateColumns(width int) int { // Each cell needs approximately: diff --git a/utils_test.go b/utils_test.go index 062f961..e896179 100644 --- a/utils_test.go +++ b/utils_test.go @@ -1,6 +1,7 @@ package main import ( + "fmt" "testing" ) @@ -455,3 +456,116 @@ func TestDetectMisplacedFlag(t *testing.T) { }) } } + +// TestRedactAuthToken tests the redactAuthToken function +func TestRedactAuthToken(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + "query parameter with ?", + "https://example.com/api?auth_token=secret123", + "https://example.com/api?auth_token=***", + }, + { + "query parameter with &", + "https://example.com/api?user=alice&auth_token=secret123&other=value", + "https://example.com/api?user=alice&auth_token=***&other=value", + }, + { + "multiple occurrences", + "url1?auth_token=abc123 and url2?auth_token=xyz789", + "url1?auth_token=*** and url2?auth_token=***", + }, + { + "form data", + "auth_token=secret123&username=alice", + "auth_token=***&username=alice", + }, + { + "no auth token", + "https://example.com/api?user=alice", + "https://example.com/api?user=alice", + }, + { + "auth_token at end of URL", + "https://example.com/api?user=alice&auth_token=secret123", + "https://example.com/api?user=alice&auth_token=***", + }, + { + "auth_token with special characters", + "https://example.com/api?auth_token=abc-123_xyz.789", + "https://example.com/api?auth_token=***", + }, + { + "empty string", + "", + "", + }, + { + "URL with no query parameters", + "https://example.com/api", + "https://example.com/api", + }, + { + "auth_token in URL path (should not match)", + "https://example.com/auth_token/endpoint", + "https://example.com/auth_token/endpoint", + }, + { + "error message with URL", + "failed to fetch: GET https://api.beeminder.com/api/v1/users/alice/goals.json?auth_token=abc123", + "failed to fetch: GET https://api.beeminder.com/api/v1/users/alice/goals.json?auth_token=***", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := redactAuthToken(tt.input) + if result != tt.expected { + t.Errorf("redactAuthToken(%q) = %q, want %q", tt.input, result, tt.expected) + } + }) + } +} + +// TestRedactError tests the redactError function +func TestRedactError(t *testing.T) { + tests := []struct { + name string + err error + expected string + }{ + { + "nil error", + nil, + "", + }, + { + "error without auth token", + fmt.Errorf("failed to connect"), + "failed to connect", + }, + { + "error with auth token in URL", + fmt.Errorf("Get \"https://example.com/api?auth_token=secret123\": connection failed"), + "Get \"https://example.com/api?auth_token=***\": connection failed", + }, + { + "wrapped error with auth token", + fmt.Errorf("failed to fetch: %w", fmt.Errorf("Get \"https://api.com/v1/users/alice/goals.json?auth_token=abc123\": dial tcp: timeout")), + "failed to fetch: Get \"https://api.com/v1/users/alice/goals.json?auth_token=***\": dial tcp: timeout", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := redactError(tt.err) + if result != tt.expected { + t.Errorf("redactError(%v) = %q, want %q", tt.err, result, tt.expected) + } + }) + } +}