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: 2 additions & 2 deletions config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)))
}
98 changes: 98 additions & 0 deletions config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
})
}
48 changes: 24 additions & 24 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Expand All @@ -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)
}
Expand All @@ -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)
}
}
Expand Down Expand Up @@ -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()))
}
Expand Down Expand Up @@ -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)
}

Expand Down Expand Up @@ -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")
}

Expand Down Expand Up @@ -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)
}

Expand All @@ -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)
Expand All @@ -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)
}
Expand Down Expand Up @@ -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)
}

Expand All @@ -725,7 +725,7 @@ func handleViewCommand() {
fmt.Println("Usage: buzz view <goalslug> [--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 <goalslug> [--web] [--json] [--datapoints]")
os.Exit(2)
}
Expand Down Expand Up @@ -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
Expand All @@ -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())
Expand All @@ -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)
}

Expand All @@ -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)
}

Expand All @@ -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)
}
}
Expand Down Expand Up @@ -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)
}

Expand Down
32 changes: 32 additions & 0 deletions utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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:
Expand Down
Loading