diff --git a/internal/commands/root.go b/internal/commands/root.go index 28b950e..af85e88 100644 --- a/internal/commands/root.go +++ b/internal/commands/root.go @@ -282,13 +282,14 @@ func SendTelemetry(err error) { } evt := telemetry.Event{ - Command: commandPath, - ExitCode: exitCode, - ErrorClass: telemetry.ErrorClassFromExitCode(exitCode), - DurationMs: time.Since(commandStartTime).Milliseconds(), - CLIVersion: cliVersion, - IsAgent: isAgent, - AgentName: agentName, + Command: commandPath, + ExitCode: exitCode, + ErrorClass: telemetry.ErrorClassFromExitCode(exitCode), + ErrorMessage: telemetry.SanitizeErrorMessage(err), + DurationMs: time.Since(commandStartTime).Milliseconds(), + CLIVersion: cliVersion, + IsAgent: isAgent, + AgentName: agentName, } // Run in a goroutine but wait up to 2s so the HTTP request diff --git a/internal/commands/telemetry.go b/internal/commands/telemetry.go index 81ec3b1..dfd3cc0 100644 --- a/internal/commands/telemetry.go +++ b/internal/commands/telemetry.go @@ -16,8 +16,10 @@ func newTelemetryCmd() *cobra.Command { Short: "Manage anonymous usage telemetry", Long: `Control anonymous telemetry that helps improve the DeployHQ CLI. -What we collect: command name, exit code, duration, CLI version, OS, arch, agent flag. -What we never collect: account, email, project, arguments, file paths, error messages.`, +What we collect: command name, exit code, duration, CLI version, OS, arch, agent flag, +and a sanitized one-line error summary on failure (home paths, emails, UUIDs, and +secrets are redacted; truncated to 200 chars). +What we never collect: account, project, arguments, full stack traces, or unredacted credentials.`, } cmd.AddCommand( diff --git a/internal/telemetry/telemetry.go b/internal/telemetry/telemetry.go index 51cb181..ff784c9 100644 --- a/internal/telemetry/telemetry.go +++ b/internal/telemetry/telemetry.go @@ -3,7 +3,10 @@ package telemetry import ( "context" "net/http" + "os" + "regexp" "runtime" + "strings" "time" "github.com/mixpanel/mixpanel-go" @@ -26,13 +29,14 @@ const ( // Event holds the properties sent with each telemetry event. type Event struct { - Command string - ExitCode int - ErrorClass string - DurationMs int64 - CLIVersion string - IsAgent bool - AgentName string + Command string + ExitCode int + ErrorClass string + ErrorMessage string // sanitized first line of the error; empty on success + DurationMs int64 + CLIVersion string + IsAgent bool + AgentName string } // Tracker is the interface used for sending telemetry. @@ -102,16 +106,17 @@ func (t *mixpanelTracker) Track(distinctID string, event Event) { env := Environment() e := t.mp.NewEvent(eventName, distinctID, map[string]any{ - "command": event.Command, - "exit_code": event.ExitCode, - "error_class": event.ErrorClass, - "duration_ms": event.DurationMs, - "cli_version": event.CLIVersion, - "environment": env, - "os": runtime.GOOS, - "arch": runtime.GOARCH, - "is_agent": event.IsAgent, - "agent_name": event.AgentName, + "command": event.Command, + "exit_code": event.ExitCode, + "error_class": event.ErrorClass, + "error_message": event.ErrorMessage, + "duration_ms": event.DurationMs, + "cli_version": event.CLIVersion, + "environment": env, + "os": runtime.GOOS, + "arch": runtime.GOARCH, + "is_agent": event.IsAgent, + "agent_name": event.AgentName, }) // Synchronous send with timeout. The caller (SendTelemetry) runs @@ -126,3 +131,46 @@ func (t *mixpanelTracker) Track(distinctID string, event Event) { type nopTracker struct{} func (nopTracker) Track(string, Event) {} + +const errorMessageMaxLen = 200 + +//nolint:gochecknoglobals // compiled once, used by SanitizeErrorMessage +var ( + emailRE = regexp.MustCompile(`[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}`) + uuidRE = regexp.MustCompile(`[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}`) + // bearerRE catches "Bearer " and "Authorization: Bearer " patterns. + bearerRE = regexp.MustCompile(`(?i)(bearer\s+)[A-Za-z0-9._\-]{8,}`) + // kvSecretRE catches key=value / token=value style leaks (api_key=..., secret=..., token=...). + kvSecretRE = regexp.MustCompile(`(?i)\b(api[_-]?key|api[_-]?token|secret|token|password|passwd)\s*[=:]\s*\S+`) +) + +// SanitizeErrorMessage produces a privacy-safe, single-line summary of an +// error suitable for telemetry. It: +// - returns "" if err is nil +// - keeps only the first line +// - replaces the user's home directory with "~" +// - redacts emails, UUIDs, bearer tokens, and key=value secrets +// - truncates to 200 characters +func SanitizeErrorMessage(err error) string { + if err == nil { + return "" + } + s := err.Error() + if s == "" { + return "" + } + if i := strings.IndexByte(s, '\n'); i >= 0 { + s = s[:i] + } + if home, herr := os.UserHomeDir(); herr == nil && home != "" && home != "/" { + s = strings.ReplaceAll(s, home, "~") + } + s = kvSecretRE.ReplaceAllString(s, "$1=") + s = bearerRE.ReplaceAllString(s, "${1}") + s = emailRE.ReplaceAllString(s, "") + s = uuidRE.ReplaceAllString(s, "") + if len(s) > errorMessageMaxLen { + s = s[:errorMessageMaxLen] + "…" + } + return s +} diff --git a/internal/telemetry/telemetry_test.go b/internal/telemetry/telemetry_test.go index d4b8e15..d61293c 100644 --- a/internal/telemetry/telemetry_test.go +++ b/internal/telemetry/telemetry_test.go @@ -1,6 +1,7 @@ package telemetry import ( + "errors" "os" "path/filepath" "strings" @@ -163,3 +164,76 @@ func TestMockTracker(t *testing.T) { assert.Equal(t, "deploy", m.events[0].Command) assert.Equal(t, "test-id", m.ids[0]) } + +// --- SanitizeErrorMessage tests --- + +func TestSanitizeErrorMessage_Nil(t *testing.T) { + assert.Equal(t, "", SanitizeErrorMessage(nil)) +} + +func TestSanitizeErrorMessage_FirstLineOnly(t *testing.T) { + err := errors.New("primary cause\nstack: foo\nstack: bar") + got := SanitizeErrorMessage(err) + assert.Equal(t, "primary cause", got) +} + +func TestSanitizeErrorMessage_RedactsEmail(t *testing.T) { + err := errors.New("user alice@example.com not found") + got := SanitizeErrorMessage(err) + assert.Equal(t, "user not found", got) + assert.NotContains(t, got, "alice@example.com") +} + +func TestSanitizeErrorMessage_RedactsUUID(t *testing.T) { + err := errors.New("project 550e8400-e29b-41d4-a716-446655440000 not found") + got := SanitizeErrorMessage(err) + assert.Equal(t, "project not found", got) +} + +func TestSanitizeErrorMessage_RedactsBearer(t *testing.T) { + err := errors.New("invalid Bearer abc123def456ghi token") + got := SanitizeErrorMessage(err) + assert.NotContains(t, got, "abc123def456ghi") + assert.Contains(t, got, "") +} + +func TestSanitizeErrorMessage_RedactsKVSecrets(t *testing.T) { + cases := []string{ + "failed: api_key=sk_live_abc123 not accepted", + "failed: token=eyJhbGciOiJIUzI1NiJ9 expired", + "failed: password=hunter2 invalid", + "failed: secret=topsecret123", + } + for _, msg := range cases { + got := SanitizeErrorMessage(errors.New(msg)) + assert.Contains(t, got, "", "input: %q", msg) + assert.NotContains(t, got, "sk_live_abc123", "input: %q", msg) + assert.NotContains(t, got, "eyJhbGciOiJIUzI1NiJ9", "input: %q", msg) + assert.NotContains(t, got, "hunter2", "input: %q", msg) + assert.NotContains(t, got, "topsecret123", "input: %q", msg) + } +} + +func TestSanitizeErrorMessage_ReplacesHomeDir(t *testing.T) { + home, err := os.UserHomeDir() + require.NoError(t, err) + if home == "" || home == "/" { + t.Skip("no usable home dir") + } + in := errors.New("read " + filepath.Join(home, ".deployhq", "config.toml") + ": permission denied") + got := SanitizeErrorMessage(in) + assert.NotContains(t, got, home) + assert.Contains(t, got, "~") +} + +func TestSanitizeErrorMessage_Truncates(t *testing.T) { + long := strings.Repeat("x", 500) + got := SanitizeErrorMessage(errors.New(long)) + // 200 chars + the truncation marker rune + assert.True(t, len(got) <= errorMessageMaxLen+len("…"), "got %d chars: %q", len(got), got) + assert.True(t, strings.HasSuffix(got, "…"), "expected ellipsis suffix, got %q", got) +} + +func TestSanitizeErrorMessage_EmptyError(t *testing.T) { + assert.Equal(t, "", SanitizeErrorMessage(errors.New(""))) +}