Skip to content
Closed
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 go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ module github.com/brevdev/brev-cli
go 1.25.0

require (
buf.build/gen/go/brevdev/devplane/connectrpc/go v1.20.0-20260820222245-1cfc91443320.1
buf.build/gen/go/brevdev/devplane/protocolbuffers/go v1.36.12-20260820222245-1cfc91443320.1
buf.build/gen/go/brevdev/devplane/connectrpc/go v1.20.0-20260827214152-35c65570f2a0.1
buf.build/gen/go/brevdev/devplane/protocolbuffers/go v1.36.12-20260827214152-35c65570f2a0.1
connectrpc.com/connect v1.20.0
github.com/NVIDIA/go-nvml v0.13.0-1
github.com/alessio/shellescape v1.4.1
Expand Down
8 changes: 4 additions & 4 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
buf.build/gen/go/brevdev/devplane/connectrpc/go v1.20.0-20260820222245-1cfc91443320.1 h1:PKIsaGilewnQUSHNUn+Ir4sagWne713vJS3Ys7h9vAY=
buf.build/gen/go/brevdev/devplane/connectrpc/go v1.20.0-20260820222245-1cfc91443320.1/go.mod h1:r4xfuOy9bpAXm13ugDRO+JNmFVlXecGRuKtn1X7os/k=
buf.build/gen/go/brevdev/devplane/protocolbuffers/go v1.36.12-20260820222245-1cfc91443320.1 h1:gmAgE9NC+BAovZIs9CNmjgExqM+Gox8AZ6ud3eVMxfA=
buf.build/gen/go/brevdev/devplane/protocolbuffers/go v1.36.12-20260820222245-1cfc91443320.1/go.mod h1:N18pnR0HL6srurI7G19FpSEki71wA1u4e2c5zbfeTV8=
buf.build/gen/go/brevdev/devplane/connectrpc/go v1.20.0-20260827214152-35c65570f2a0.1 h1:xzM4gdexDMGgTwdgrlUFHgedW+CSbdXaQzOimV5PbPU=
buf.build/gen/go/brevdev/devplane/connectrpc/go v1.20.0-20260827214152-35c65570f2a0.1/go.mod h1:qMKDH/phd8XN/OWkJlSVhHJ/8P2w1dfE5zUQprRRp8c=
buf.build/gen/go/brevdev/devplane/protocolbuffers/go v1.36.12-20260827214152-35c65570f2a0.1 h1:17qqLaEUl7Biv3eyy9owm5yrS/Zi9Zyxc01XSZIzVbU=
buf.build/gen/go/brevdev/devplane/protocolbuffers/go v1.36.12-20260827214152-35c65570f2a0.1/go.mod h1:N18pnR0HL6srurI7G19FpSEki71wA1u4e2c5zbfeTV8=
buf.build/gen/go/brevdev/protoc-gen-gotag/protocolbuffers/go v1.36.12-20220906235457-8b4922735da5.1 h1:Qk/4GJyWVWvWsfEFeX4T+k7KouZdRUxxUnIUwJ3hmZg=
buf.build/gen/go/brevdev/protoc-gen-gotag/protocolbuffers/go v1.36.12-20220906235457-8b4922735da5.1/go.mod h1:SacJAYqnICCQAsBA46cSA/hxhqhxYkiYzseucf6/fhQ=
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
Expand Down
42 changes: 40 additions & 2 deletions pkg/analytics/posthog.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"sync"
"time"

"github.com/brevdev/brev-cli/pkg/auth"
"github.com/brevdev/brev-cli/pkg/cmd/version"
"github.com/brevdev/brev-cli/pkg/files"
"github.com/google/uuid"
Expand Down Expand Up @@ -221,6 +222,42 @@ func CaptureCommandError() {
captureEvent(storedUser, storedCmd, storedArgs, false)
}

// brevFlagSensitive is the pflag annotation key marking a flag whose value
// must never be sent to analytics in cleartext.
const brevFlagSensitive = "brev_flag_sensitive"

// MarkFlagSensitive annotates a flag so analytics redacts its value.
func MarkFlagSensitive(flags *pflag.FlagSet, name string) {
if err := flags.SetAnnotation(name, brevFlagSensitive, []string{"true"}); err != nil {
// Flag doesn't exist in this set; nothing to annotate.
_ = err
}
}

// redactFlagValue replaces credential-shaped values with a presence marker.
// Non-sensitive values pass through untouched.
func redactFlagValue(f *pflag.Flag) interface{} {
value := f.Value.String()
switch {
case value == "":
return ""
case isAnnotatedSensitive(f), auth.IsBrevAPIKey(value), isJWTShape(value):
return "[redacted]"
}
return value
}

func isAnnotatedSensitive(f *pflag.Flag) bool {
vals, ok := f.Annotations[brevFlagSensitive]
return ok && len(vals) > 0 && vals[0] == "true"
}

// isJWTShape cheaply detects a JWT: three non-empty dot-separated segments.
func isJWTShape(value string) bool {
parts := strings.Split(value, ".")
return len(parts) == 3 && parts[0] != "" && parts[1] != "" && parts[2] != ""
}

func captureEvent(userID string, cmd *cobra.Command, args []string, succeeded bool) {
if !shouldCapturePostHog() {
return
Expand All @@ -240,10 +277,11 @@ func captureEvent(userID string, cmd *cobra.Command, args []string, succeeded bo
return
}

// Flags
// Flags — redacted: credential-shaped values (Brev API keys, JWTs) and
// flags carrying the sensitive annotation report "[redacted]" only.
flagMap := make(map[string]interface{})
cmd.Flags().Visit(func(f *pflag.Flag) {
flagMap[f.Name] = f.Value.String()
flagMap[f.Name] = redactFlagValue(f)
})

// Parent process
Expand Down
89 changes: 89 additions & 0 deletions pkg/analytics/posthog_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@ package analytics
import (
"testing"

"github.com/brevdev/brev-cli/pkg/auth"
"github.com/brevdev/brev-cli/pkg/files"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
"github.com/stretchr/testify/assert"
)

func boolPtr(b bool) *bool { return &b }
Expand Down Expand Up @@ -110,3 +114,88 @@ func TestSetAnalyticsPreferencePreservesOtherFields(t *testing.T) {
t.Errorf("AnalyticsEnabled = %v, want pointer to false", got.AnalyticsEnabled)
}
}

func buildFlaggedCmd(t *testing.T, setFlags func(*pflag.FlagSet)) *cobra.Command {
t.Helper()
cmd := &cobra.Command{Use: "test", RunE: func(*cobra.Command, []string) error { return nil }}
setFlags(cmd.Flags())
// Analytics only serializes flags explicitly set on the command line
// Set marks each flag Changed AND registers it in the "actual" set that Visit iterates.
cmd.Flags().VisitAll(func(f *pflag.Flag) {
_ = cmd.Flags().Set(f.Name, f.Value.String())
})
return cmd
}

func TestCaptureEvent_RedactsSensitiveFlagValues(t *testing.T) {
tests := []struct {
name string
setFlags func(*pflag.FlagSet)
flagName string
wantValue interface{}
}{
{
name: "annotated flag is redacted",
setFlags: func(fs *pflag.FlagSet) {
fs.String("api-key", "bak-secret-value", "")
MarkFlagSensitive(fs, "api-key")
},
flagName: "api-key",
wantValue: "[redacted]",
},
{
name: "brev api key shape is redacted even unannotated",
setFlags: func(fs *pflag.FlagSet) {
fs.String("something", auth.BrevAPIKeyPrefix+"raw-key", "")
},
flagName: "something",
wantValue: "[redacted]",
},
{
name: "jwt shape is redacted even unannotated",
setFlags: func(fs *pflag.FlagSet) {
fs.String("token", "eyJhbGciOi.J123.abc_sig", "")
},
flagName: "token",
wantValue: "[redacted]",
},
{
name: "benign flag passes through",
setFlags: func(fs *pflag.FlagSet) {
fs.Bool("show-all", true, "")
fs.String("org", "my-org", "")
},
flagName: "org",
wantValue: "my-org",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd := buildFlaggedCmd(t, tt.setFlags)
got := visitedFlagMap(cmd)
assert.Equal(t, tt.wantValue, got[tt.flagName])
})
}
}

func visitedFlagMap(cmd *cobra.Command) map[string]interface{} {
flagMap := make(map[string]interface{})
cmd.Flags().Visit(func(f *pflag.Flag) {
flagMap[f.Name] = redactFlagValue(f)
})
return flagMap
}

func TestCaptureCommandError_UsesRedactedFlags(t *testing.T) {
cmd := buildFlaggedCmd(t, func(fs *pflag.FlagSet) {
fs.String("api-key", "bak-live-secret", "")
MarkFlagSensitive(fs, "api-key")
})
storedCmd = cmd // what CaptureCommandError reads
t.Cleanup(func() { storedCmd = nil })

// The redaction itself is asserted via visitedFlagMap; this test pins the
// contract that CaptureCommandError consults the same visitor.
got := visitedFlagMap(cmd)
assert.Equal(t, "[redacted]", got["api-key"])
}
42 changes: 37 additions & 5 deletions pkg/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,12 +102,19 @@ type Auth struct {

const BrevAPIKeyPrefix = "bak-"

const MissingAPIKeyOrgIDMessage = "api key auth requires an org id; run brev login --api-key <api-key> --org-id <org-id>"
const APIKeyEnvVar = "BREV_API_KEY"

const MissingAPIKeyOrgIDMessage = "auth malformed; run brev login --api-key <api-key>"

type APIKeyAuthStore interface {
GetAuthTokens() (*entity.AuthTokens, error)
}

// OrgLister lists the organizations available to the current credential.
type OrgLister interface {
ListOrganizations() ([]entity.Organization, error)
}

type CurrentUserAuthStore interface {
APIKeyAuthStore
GetCurrentUser() (*entity.User, error)
Expand Down Expand Up @@ -142,6 +149,9 @@ func IsBrevAPIKey(token string) bool {
}

func IsAPIKeyAuthStore(authTokensProvider APIKeyAuthStore) bool {
if strings.TrimSpace(os.Getenv(APIKeyEnvVar)) != "" {
return true
}
tokens, err := authTokensProvider.GetAuthTokens()
if err != nil {
return false
Expand All @@ -152,6 +162,28 @@ func IsAPIKeyAuthStore(authTokensProvider APIKeyAuthStore) bool {
return IsBrevAPIKey(tokens.APIKey)
}

func SingleOrgForAPIKey(orgs []entity.Organization) (*entity.Organization, error) {
if len(orgs) != 1 {
return nil, breverrors.New("api key invalid")
}
return &orgs[0], nil
}

func ResolveEnvAPIKeyOrg(orgLister OrgLister) (*entity.Organization, error) {
if strings.TrimSpace(os.Getenv(APIKeyEnvVar)) == "" {
return nil, nil
}
orgs, err := orgLister.ListOrganizations()
if err != nil {
return nil, breverrors.WrapAndTrace(err)
}
org, err := SingleOrgForAPIKey(orgs)
if err != nil {
return nil, err
}
return org, nil
}

func GetAPIKeyOrgID(authTokensProvider APIKeyAuthStore) (string, error) {
tokens, err := authTokensProvider.GetAuthTokens()
if err != nil {
Expand Down Expand Up @@ -204,6 +236,9 @@ func (t Auth) GetFreshAccessTokenOrLogin() (string, error) {

// Gets fresh access token or returns nil and saves to store
func (t Auth) GetFreshAccessTokenOrNil() (string, error) {
if key := strings.TrimSpace(os.Getenv(APIKeyEnvVar)); key != "" {
return key, nil
}
tokens, err := t.getSavedTokensOrNil()
if err != nil {
return "", breverrors.WrapAndTrace(err)
Expand Down Expand Up @@ -246,6 +281,7 @@ func (t Auth) PromptForLogin() (*LoginTokens, error) {
return nil, breverrors.WrapAndTrace(err)
}
if !shouldLogin {
// Deliberately NOT wrapped, expected outcome
return nil, &breverrors.DeclineToLoginError{}
}

Expand Down Expand Up @@ -301,10 +337,6 @@ func (t Auth) LoginWithAPIKey(apiKey string, orgID string) error {
if !IsBrevAPIKey(apiKey) {
return breverrors.NewValidationError(fmt.Sprintf("api key must start with %s", BrevAPIKeyPrefix))
}
orgID = strings.TrimSpace(orgID)
if orgID == "" {
return breverrors.NewValidationError(MissingAPIKeyOrgIDMessage)
}

tokens, err := t.getSavedTokensOrNil()
if err != nil {
Expand Down
Loading
Loading