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
36 changes: 31 additions & 5 deletions packages/cmd/agent_proxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,11 @@ import (
"github.com/Infisical/infisical-merge/packages/config"
"github.com/Infisical/infisical-merge/packages/models"
"github.com/Infisical/infisical-merge/packages/sandbox"
"github.com/Infisical/infisical-merge/packages/telemetry"
"github.com/Infisical/infisical-merge/packages/util"
"github.com/fatih/color"
"github.com/go-resty/resty/v2"
"github.com/posthog/posthog-go"
"github.com/rs/zerolog/log"
"github.com/spf13/cobra"
)
Expand Down Expand Up @@ -159,7 +161,16 @@ func runAgentProxyConnect(cmd *cobra.Command, args []string) {
util.HandleError(fmt.Errorf("project id is required; pass --projectId, set INFISICAL_PROJECT_ID, or run inside a project with .infisical.json"))
}

token := resolveAgentToken(cmd)
token, tokenSource := resolveAgentToken(cmd)

allowReadableBrokered := util.GetBoolFlagOrEnv(cmd, "allow-readable-brokered-secrets", util.INFISICAL_AGENT_PROXY_ALLOW_READABLE_BROKERED_SECRETS_NAME)

Telemetry.SetActor(telemetry.IdentityClaimsFromToken(token.Token))
Telemetry.CaptureEvent("cli-command:agent-proxy connect", posthog.NewProperties().
Set("version", util.CLI_VERSION).
Set("agent", telemetryAgentName(args)).
Set("credentialSource", tokenSource).
Set("allowReadableBrokeredSecrets", allowReadableBrokered))

httpClient := resty.New().SetAuthToken(token.Token)

Expand All @@ -176,7 +187,6 @@ func runAgentProxyConnect(cmd *cobra.Command, args []string) {

realSecrets := fetchAgentRealSecrets(token, projectID, environment, secretPath)

allowReadableBrokered := util.GetBoolFlagOrEnv(cmd, "allow-readable-brokered-secrets", util.INFISICAL_AGENT_PROXY_ALLOW_READABLE_BROKERED_SECRETS_NAME)
if !allowReadableBrokered {
// static readability is derived from realSecrets we already fetch; dynamic lease-ability comes from
// the server (callerCanLease) since we don't fetch dynamic secrets here.
Expand All @@ -192,7 +202,23 @@ func runAgentProxyConnect(cmd *cobra.Command, args []string) {
}
}

func resolveAgentToken(cmd *cobra.Command) *models.TokenDetails {
// Only the executable name: argv past the first word routinely carries credentials.
func telemetryAgentName(args []string) string {
if len(args) == 0 {
return ""
}
return filepath.Base(args[0])
}

func universalAuthCredentialSource(cmd *cobra.Command) string {
if cmd.Flags().Changed("client-id") {
return "universal-auth-flag"
}
return "universal-auth-env"
}

// Returns the token and a label for the branch that produced it.
func resolveAgentToken(cmd *cobra.Command) (*models.TokenDetails, string) {
clientID, _ := util.GetCmdFlagOrEnvWithDefaultValue(cmd, "client-id", []string{util.INFISICAL_UNIVERSAL_AUTH_CLIENT_ID_NAME}, "")
clientSecret, _ := util.GetCmdFlagOrEnvWithDefaultValue(cmd, "client-secret", []string{util.INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET_NAME}, "")

Expand All @@ -204,7 +230,7 @@ func resolveAgentToken(cmd *cobra.Command) *models.TokenDetails {
return &models.TokenDetails{
Type: util.UNIVERSAL_AUTH_TOKEN_IDENTIFIER,
Token: loginResp.AccessToken,
}
}, universalAuthCredentialSource(cmd)
}

token, err := util.GetInfisicalToken(cmd)
Expand All @@ -214,7 +240,7 @@ func resolveAgentToken(cmd *cobra.Command) *models.TokenDetails {
if token == nil {
util.HandleError(fmt.Errorf("authentication required; provide --client-id/--client-secret, env vars, or a token"))
}
return token
return token, "token"
}

// Builds http://<projectId>:<env>/<path>:<jwt>@host:port (username=projectId, password="<env>/<path>:<jwt>", jwt last).
Expand Down
33 changes: 33 additions & 0 deletions packages/cmd/agent_proxy_run.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,12 @@ import (
"github.com/Infisical/infisical-merge/packages/agentproxy"
"github.com/Infisical/infisical-merge/packages/api"
"github.com/Infisical/infisical-merge/packages/sandbox"
"github.com/Infisical/infisical-merge/packages/telemetry"
"github.com/Infisical/infisical-merge/packages/util"
"github.com/fatih/color"
"github.com/go-resty/resty/v2"
"github.com/mattn/go-isatty"
"github.com/posthog/posthog-go"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"github.com/spf13/cobra"
Expand Down Expand Up @@ -169,6 +171,25 @@ func runAgentProxyRun(cmd *cobra.Command, args []string) {
util.PrintWarning(fmt.Sprintf("Unable to isolate the network on this host (%s), the agent will share your network connection. Requests are still routed through the proxy, but a program that ignores the proxy settings can reach the network directly. Credential protections are unchanged.", pre.Reason))
}

// After preflight so a downgraded fence is visible; before the proxy starts so a run that dies still counts.
passEnv, _ := cmd.Flags().GetStringArray("pass-env")
setEnv, _ := cmd.Flags().GetStringArray("set-env")
Telemetry.SetActor(telemetry.IdentityClaimsFromToken(src.token()))
Telemetry.CaptureEvent("cli-command:agent-proxy run", posthog.NewProperties().
Set("version", util.CLI_VERSION).
Set("agent", telemetryAgentName(args)).
Set("platform", runtime.GOOS).
Set("sandboxEnabled", sandboxEnabled).
Set("sandboxSource", sandboxSource(cmd)).
Set("netDowngraded", pre.FallbackToSharedNet).
Set("unmatchedHost", unmatchedHost).
Set("pollInterval", pollInterval).
Set("allowReadCount", len(extraRead)).
Set("allowWriteCount", len(extraWrite)).
Set("allowHostCount", len(allowHosts)).
Set("passEnvCount", len(passEnv)).
Set("setEnvCount", len(setEnv)))

proxy, err := agentproxy.New(agentproxy.Options{
UnmatchedHost: unmatchedHost,
PollInterval: time.Duration(pollInterval) * time.Second,
Expand Down Expand Up @@ -281,6 +302,18 @@ func shutdownProxy(proxy *agentproxy.Proxy) {
_ = proxy.Shutdown(ctx)
}

// Mirrors resolveSandboxEnabled's order, so a deliberate opt-out is distinguishable from the default.
func sandboxSource(cmd *cobra.Command) string {
switch {
case cmd.Flags().Changed("sandbox"), cmd.Flags().Changed("no-sandbox"):
return "flag"
case os.Getenv("INFISICAL_AGENT_PROXY_SANDBOX") != "":
return "env"
default:
return "default"
}
}

// resolveSandboxEnabled reads the toggle from flag or env only, never .infisical.json (a committed
// file must not be able to silently disable the boundary).
func resolveSandboxEnabled(cmd *cobra.Command) bool {
Expand Down
9 changes: 9 additions & 0 deletions packages/cmd/agent_proxy_start.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@ import (
"time"

"github.com/Infisical/infisical-merge/packages/agentproxy"
"github.com/Infisical/infisical-merge/packages/telemetry"
"github.com/Infisical/infisical-merge/packages/util"
"github.com/fatih/color"
"github.com/posthog/posthog-go"
"github.com/rs/zerolog/log"
"github.com/spf13/cobra"
)
Expand Down Expand Up @@ -45,6 +47,13 @@ func runAgentProxyStart(cmd *cobra.Command, args []string) {
util.HandleError(err, "Failed to authenticate the agent proxy machine identity")
}

Telemetry.SetActor(telemetry.IdentityClaimsFromToken(loginResp.AccessToken))
Telemetry.CaptureEvent("cli-command:agent-proxy start", posthog.NewProperties().
Set("version", util.CLI_VERSION).
Set("unmatchedHost", unmatchedHost).
Set("pollInterval", pollInterval).
Set("credentialSource", universalAuthCredentialSource(cmd)))

log.Info().Msg(color.GreenString("Agent proxy authenticated; starting MITM proxy"))

var proxyToken atomic.Value
Expand Down
139 changes: 83 additions & 56 deletions packages/telemetry/telemetry.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ var POSTHOG_API_KEY_FOR_CLI string
type Telemetry struct {
isEnabled bool
posthogClient posthog.Client

// Set by SetActor for commands whose credential is not in the environment.
attachedIdentityId string
attachedOrgId string
}

type NoOpLogger struct{}
Expand Down Expand Up @@ -60,16 +64,39 @@ func (t *Telemetry) CaptureEvent(eventName string, properties posthog.Properties
// is idempotent and persists its state in the local config file.
t.IdentifyUserIfNeeded()

t.posthogClient.Enqueue(posthog.Capture{
capture := posthog.Capture{
DistinctId: userIdentity,
Event: eventName,
Properties: properties,
})
}

if orgId := t.resolveOrganizationId(); orgId != "" {
capture.Groups = posthog.NewGroups().Set("organization", orgId)
}

t.posthogClient.Enqueue(capture)

defer t.posthogClient.Close()
}
}

// SetActor records who the command is acting as, for commands that resolve their
// own credential and so have nothing in the environment to read.
func (t *Telemetry) SetActor(identityId, orgId string) {
t.attachedIdentityId = identityId
t.attachedOrgId = orgId
}

// Once an actor is set, its organization is authoritative even when empty: falling
// back to the environment would group the event under a different actor's org.
func (t *Telemetry) resolveOrganizationId() string {
if t.attachedIdentityId != "" || t.attachedOrgId != "" {
return t.attachedOrgId
}
_, orgId := machineIdentityClaimsFromEnv()
return orgId
}

// IdentifyUserIfNeeded sends a PostHog Identify call to enrich the person
// record with the user's email, and aliases the anonymous machine ID to the
// email so that pre-login CLI events are merged into the same person.
Expand Down Expand Up @@ -146,74 +173,70 @@ func (t *Telemetry) IdentifyUserIfNeeded() {
}
}

// getMachineIdentityIdFromEnv inspects the environment variables that the
// CLI uses to receive machine-identity access tokens (the same set checked
// by util.GetInfisicalToken, minus the `--token` flag which is per-command
// and not visible to the telemetry layer) and, if a machine-identity JWT
// is present, returns the `identityId` claim from its payload.
// IdentityClaimsFromToken reads the identity and organization claims out of an
// Infisical JWT. Nothing from the token itself is kept. Machine identity tokens
// name the organization `orgId`, user session tokens `organizationId`.
//
// The function is intentionally best-effort and silent on failure:
// - returns "" if no token is set
// - returns "" for service tokens (`st.` prefix), which carry no JWT
// payload and represent the deprecated service-token auth mode
// - returns "" if the JWT is malformed or missing the `identityId` claim
//
// The token's signature is not verified — the value is only used to derive
// a PostHog distinctId, never for authorization. The same token has already
// been (or is about to be) sent to the Infisical API where its signature is
// verified server-side.
func getMachineIdentityIdFromEnv() string {
// Mirror the env-var precedence in util.GetInfisicalToken so that the
// telemetry distinctId aligns with the credential the API call will
// actually use:
// 1. INFISICAL_UNIVERSAL_AUTH_ACCESS_TOKEN
// 2. INFISICAL_TOKEN
// 3. TOKEN (legacy gateway env var)
envVars := []string{
util.INFISICAL_UNIVERSAL_AUTH_ACCESS_TOKEN_NAME,
util.INFISICAL_TOKEN_NAME,
util.INFISICAL_GATEWAY_TOKEN_NAME_LEGACY,
}

var token string
for _, name := range envVars {
if v := os.Getenv(name); v != "" {
token = v
break
}
}

if token == "" {
return ""
}

// Service tokens are deprecated and not JWTs — no identityId to extract.
if strings.HasPrefix(token, "st.") {
return ""
// Best-effort and silent on failure: returns empty strings for a service token
// (`st.` prefix, not a JWT) or a malformed payload. The signature is not
// verified because the values are only used for telemetry, never authorization.
func IdentityClaimsFromToken(token string) (identityId string, orgId string) {
if token == "" || strings.HasPrefix(token, "st.") {
return "", ""
}

parts := strings.Split(token, ".")
if len(parts) != 3 {
return ""
return "", ""
}

payloadBytes, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
// Some JWT issuers pad the payload with `=`; tolerate that variant.
payloadBytes, err = base64.URLEncoding.DecodeString(parts[1])
if err != nil {
return ""
return "", ""
}
}

var claims struct {
IdentityID string `json:"identityId"`
IdentityID string `json:"identityId"`
OrgID string `json:"orgId"`
OrganizationID string `json:"organizationId"`
}
if err := json.Unmarshal(payloadBytes, &claims); err != nil {
return ""
return "", ""
}

if claims.OrgID != "" {
return claims.IdentityID, claims.OrgID
}
return claims.IdentityID, claims.OrganizationID
}

// machineIdentityClaimsFromEnv returns the claims from the first machine-identity
// access token set in the environment (the same set util.GetInfisicalToken checks,
// minus the `--token` flag, which is per-command and not visible here).
func machineIdentityClaimsFromEnv() (identityId string, orgId string) {
// Mirror the env-var precedence in util.GetInfisicalToken so that the
// telemetry distinctId aligns with the credential the API call will
// actually use:
// 1. INFISICAL_UNIVERSAL_AUTH_ACCESS_TOKEN
// 2. INFISICAL_TOKEN
// 3. TOKEN (legacy gateway env var)
envVars := []string{
util.INFISICAL_UNIVERSAL_AUTH_ACCESS_TOKEN_NAME,
util.INFISICAL_TOKEN_NAME,
util.INFISICAL_GATEWAY_TOKEN_NAME_LEGACY,
}

for _, name := range envVars {
if v := os.Getenv(name); v != "" {
return IdentityClaimsFromToken(v)
}
}

return claims.IdentityID
return "", ""
}

func (t *Telemetry) GetDistinctId() (string, error) {
Expand All @@ -230,7 +253,9 @@ func (t *Telemetry) GetDistinctId() (string, error) {
}

// Resolution priority:
// 1. Logged-in user email from the persisted config. A logged-in user
// 1. Actor set by SetActor: the credential the command itself resolved, so it
// outranks anything on the machine. Empty for a user session token.
// 2. Logged-in user email from the persisted config. A logged-in user
// takes precedence over any machine-identity token that happens to
// be exported in the shell, because some commands never authenticate
// against the backend at all (e.g. `infisical user switch`, the
Expand All @@ -239,17 +264,19 @@ func (t *Telemetry) GetDistinctId() (string, error) {
// those events to a stale `identity-<id>` would corrupt person-level
// analytics, while attributing them to the logged-in email is always
// correct.
// 2. Machine-identity access token from env. This is the dominant case
// 3. Machine-identity access token from env. This is the dominant case
// in CI / containers / Kubernetes pods, where there is no logged-in
// user and the only credential is `INFISICAL_TOKEN` (or the UA-scoped
// env var). Aligns with the `identity-<id>` distinctId the backend
// uses for MachineIdentityLogin and other identity-scoped events,
// so CLI events flow into the same person record.
// 3. Anonymous fallback keyed by the local machine ID.
if infisicalConfig.LoggedInUserEmail != "" {
// 4. Anonymous fallback keyed by the local machine ID.
if t.attachedIdentityId != "" {
distinctId = "identity-" + t.attachedIdentityId
} else if infisicalConfig.LoggedInUserEmail != "" {
distinctId = infisicalConfig.LoggedInUserEmail
} else if identityId := getMachineIdentityIdFromEnv(); identityId != "" {
distinctId = "identity-" + identityId
} else if envIdentityId, _ := machineIdentityClaimsFromEnv(); envIdentityId != "" {
distinctId = "identity-" + envIdentityId
} else if machineId != "" {
distinctId = "anonymous_cli_" + machineId
}
Expand Down
Loading