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
15 changes: 8 additions & 7 deletions internal/commands/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions internal/commands/telemetry.go
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
82 changes: 65 additions & 17 deletions internal/telemetry/telemetry.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@ package telemetry
import (
"context"
"net/http"
"os"
"regexp"
"runtime"
"strings"
"time"

"github.com/mixpanel/mixpanel-go"
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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 <token>" and "Authorization: Bearer <token>" 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+`)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Redact access_token-style secrets in telemetry sanitizer

SanitizeErrorMessage is intended to prevent credential leakage, but kvSecretRE only matches token when it appears as a standalone key, so values like access_token=... or id_token=... pass through unredacted. Because many OAuth/API errors include those exact keys, telemetry can still emit live tokens in error_message when such errors are reported. Expanding the key pattern (or matching token-like suffixes) would close this leak.

Useful? React with 👍 / 👎.

)

// 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=<redacted>")
s = bearerRE.ReplaceAllString(s, "${1}<redacted>")
s = emailRE.ReplaceAllString(s, "<email>")
s = uuidRE.ReplaceAllString(s, "<uuid>")
if len(s) > errorMessageMaxLen {
s = s[:errorMessageMaxLen] + "…"
}
return s
}
74 changes: 74 additions & 0 deletions internal/telemetry/telemetry_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package telemetry

import (
"errors"
"os"
"path/filepath"
"strings"
Expand Down Expand Up @@ -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 <email> 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 <uuid> 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, "<redacted>")
}

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, "<redacted>", "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("")))
}
Loading