From 7e532e738a08f78154a4965739208868185e5bb8 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 19 Jun 2026 16:22:54 +0000 Subject: [PATCH 1/4] fix: extend offline secret caching to token-based auth and fix connection check - ValidateInfisicalAPIConnection now checks HTTP status code, not just transport errors. A 503 from a load balancer/proxy no longer counts as 'connected', allowing the cache fallback to trigger correctly. - Added secret backup/restore for service token and Universal Auth (machine identity) code paths. Previously, offline caching only worked for infisical-login user auth. Now all auth methods write secrets to the encrypted backup on success and fall back to cached secrets on fetch failure. Co-Authored-By: jake --- packages/util/common.go | 7 +++++-- packages/util/secrets.go | 17 +++++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/packages/util/common.go b/packages/util/common.go index 125db9e6..212f02d8 100644 --- a/packages/util/common.go +++ b/packages/util/common.go @@ -41,8 +41,11 @@ func WriteToFile(fileName string, dataToWrite []byte, filePerm os.FileMode) erro } func ValidateInfisicalAPIConnection() (ok bool) { - _, err := http.Get(fmt.Sprintf("%v/status", config.INFISICAL_URL)) - return err == nil + resp, err := http.Get(fmt.Sprintf("%v/status", config.INFISICAL_URL)) + if err != nil { + return false + } + return resp.StatusCode >= 200 && resp.StatusCode < 300 } func GetRestyClientWithCustomHeaders() (*resty.Client, error) { diff --git a/packages/util/secrets.go b/packages/util/secrets.go index 5b1a884e..255ba2d8 100644 --- a/packages/util/secrets.go +++ b/packages/util/secrets.go @@ -374,6 +374,23 @@ func GetAllEnvironmentVariables(params models.GetAllSecretsParameters, projectCo errorToReturn = err secretsToReturn = res.Secrets } + + // cache secrets on success, fallback to cached secrets on failure + if errorToReturn == nil && params.WorkspaceId != "" { + if backupEncryptionKey, err := GetBackupEncryptionKey(); err == nil { + WriteBackupSecrets(params.WorkspaceId, params.Environment, params.SecretsPath, backupEncryptionKey, secretsToReturn) + } + } else if errorToReturn != nil && params.WorkspaceId != "" { + backupEncryptionKey, _ := GetBackupEncryptionKey() + if backupEncryptionKey != nil { + backedUpSecrets, err := ReadBackupSecrets(params.WorkspaceId, params.Environment, params.SecretsPath, backupEncryptionKey) + if len(backedUpSecrets) > 0 { + PrintWarning("Unable to fetch the latest secret(s) due to connection error, serving secrets from last successful fetch. For more info, run with --debug") + secretsToReturn = backedUpSecrets + errorToReturn = err + } + } + } } return secretsToReturn, errorToReturn From d7e51f3784ec1f097a31b68e2f19e4f7540c960a Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 22 Jun 2026 20:00:45 +0000 Subject: [PATCH 2/4] fix: close resp.Body in connection check and restrict cache fallback to non-auth errors Address review feedback: - Close resp.Body in ValidateInfisicalAPIConnection to prevent connection leak - Only fall back to cached secrets on connection/server errors (5xx), not on client errors (4xx) like 401/403 which indicate auth issues (revoked token, permission denied). This prevents silently serving stale secrets when the real problem is invalid credentials. Co-Authored-By: jake --- packages/util/common.go | 1 + packages/util/secrets.go | 26 ++++++++++++++++++-------- 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/packages/util/common.go b/packages/util/common.go index 212f02d8..d5ea9504 100644 --- a/packages/util/common.go +++ b/packages/util/common.go @@ -45,6 +45,7 @@ func ValidateInfisicalAPIConnection() (ok bool) { if err != nil { return false } + defer resp.Body.Close() return resp.StatusCode >= 200 && resp.StatusCode < 300 } diff --git a/packages/util/secrets.go b/packages/util/secrets.go index 255ba2d8..e49cf82f 100644 --- a/packages/util/secrets.go +++ b/packages/util/secrets.go @@ -375,19 +375,29 @@ func GetAllEnvironmentVariables(params models.GetAllSecretsParameters, projectCo secretsToReturn = res.Secrets } - // cache secrets on success, fallback to cached secrets on failure + // cache secrets on success, fallback to cached secrets on connection/server failure if errorToReturn == nil && params.WorkspaceId != "" { if backupEncryptionKey, err := GetBackupEncryptionKey(); err == nil { WriteBackupSecrets(params.WorkspaceId, params.Environment, params.SecretsPath, backupEncryptionKey, secretsToReturn) } } else if errorToReturn != nil && params.WorkspaceId != "" { - backupEncryptionKey, _ := GetBackupEncryptionKey() - if backupEncryptionKey != nil { - backedUpSecrets, err := ReadBackupSecrets(params.WorkspaceId, params.Environment, params.SecretsPath, backupEncryptionKey) - if len(backedUpSecrets) > 0 { - PrintWarning("Unable to fetch the latest secret(s) due to connection error, serving secrets from last successful fetch. For more info, run with --debug") - secretsToReturn = backedUpSecrets - errorToReturn = err + // Only fall back to cache for connection errors or server errors (5xx). + // Do not mask client errors (4xx) like 401/403 which indicate auth issues. + shouldFallback := true + var apiErr *api.APIError + if errors.As(errorToReturn, &apiErr) && apiErr.StatusCode >= 400 && apiErr.StatusCode < 500 { + shouldFallback = false + } + + if shouldFallback { + backupEncryptionKey, _ := GetBackupEncryptionKey() + if backupEncryptionKey != nil { + backedUpSecrets, err := ReadBackupSecrets(params.WorkspaceId, params.Environment, params.SecretsPath, backupEncryptionKey) + if len(backedUpSecrets) > 0 { + PrintWarning("Unable to fetch the latest secret(s) due to connection error, serving secrets from last successful fetch. For more info, run with --debug") + secretsToReturn = backedUpSecrets + errorToReturn = err + } } } } From a0dac7cdb721998489c91c1e1eb5db7d2c91f323 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 29 Jun 2026 21:07:00 +0000 Subject: [PATCH 3/4] fix: use %w for error wrapping in service token path to preserve error chain Change fmt.Errorf wrapping from %v to %w in GetPlainTextSecretsViaServiceToken so errors.As can unwrap the underlying *api.APIError. Without this, a revoked service token returning 401 would bypass the 4xx guard and silently serve stale cached secrets. Co-Authored-By: jake --- packages/util/secrets.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/util/secrets.go b/packages/util/secrets.go index e49cf82f..81785eeb 100644 --- a/packages/util/secrets.go +++ b/packages/util/secrets.go @@ -30,7 +30,7 @@ func GetPlainTextSecretsViaServiceToken(fullServiceToken string, environment str httpClient, err := GetRestyClientWithCustomHeaders() if err != nil { - return nil, fmt.Errorf("unable to get client with custom headers [err=%v]", err) + return nil, fmt.Errorf("unable to get client with custom headers [err=%w]", err) } httpClient.SetAuthToken(serviceToken). @@ -38,7 +38,7 @@ func GetPlainTextSecretsViaServiceToken(fullServiceToken string, environment str serviceTokenDetails, err := api.CallGetServiceTokenDetailsV2(httpClient) if err != nil { - return nil, fmt.Errorf("unable to get service token details. [err=%v]", err) + return nil, fmt.Errorf("unable to get service token details. [err=%w]", err) } // if multiple scopes are there then user needs to specify which environment and secret path From 93447a5bf3209d160dd1db9f1fc254d5d9ae3f18 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 29 Jun 2026 23:06:37 +0000 Subject: [PATCH 4/4] fix: expose workspace ID from service token path to enable caching without --projectId GetPlainTextSecretsViaServiceToken now returns the workspace ID it discovers from the token details, so the caller can use it for caching even when --projectId is not explicitly passed. Previously, service token users without --projectId would silently get no offline caching because params.WorkspaceId was empty. Co-Authored-By: jake --- packages/util/secrets.go | 38 ++++++++++++++++++++++++-------------- 1 file changed, 24 insertions(+), 14 deletions(-) diff --git a/packages/util/secrets.go b/packages/util/secrets.go index 81785eeb..feab6589 100644 --- a/packages/util/secrets.go +++ b/packages/util/secrets.go @@ -20,17 +20,17 @@ import ( "gopkg.in/yaml.v3" ) -func GetPlainTextSecretsViaServiceToken(fullServiceToken string, environment string, secretPath string, includeImports bool, recursive bool, tagSlugs string, expandSecretReferences bool) ([]models.SingleEnvironmentVariable, error) { +func GetPlainTextSecretsViaServiceToken(fullServiceToken string, environment string, secretPath string, includeImports bool, recursive bool, tagSlugs string, expandSecretReferences bool) ([]models.SingleEnvironmentVariable, string, error) { serviceTokenParts := strings.SplitN(fullServiceToken, ".", 4) if len(serviceTokenParts) < 4 { - return nil, fmt.Errorf("invalid service token entered. Please double check your service token and try again") + return nil, "", fmt.Errorf("invalid service token entered. Please double check your service token and try again") } serviceToken := fmt.Sprintf("%v.%v.%v", serviceTokenParts[0], serviceTokenParts[1], serviceTokenParts[2]) httpClient, err := GetRestyClientWithCustomHeaders() if err != nil { - return nil, fmt.Errorf("unable to get client with custom headers [err=%w]", err) + return nil, "", fmt.Errorf("unable to get client with custom headers [err=%w]", err) } httpClient.SetAuthToken(serviceToken). @@ -38,20 +38,22 @@ func GetPlainTextSecretsViaServiceToken(fullServiceToken string, environment str serviceTokenDetails, err := api.CallGetServiceTokenDetailsV2(httpClient) if err != nil { - return nil, fmt.Errorf("unable to get service token details. [err=%w]", err) + return nil, "", fmt.Errorf("unable to get service token details. [err=%w]", err) } + workspaceId := serviceTokenDetails.Workspace + // if multiple scopes are there then user needs to specify which environment and secret path if environment == "" { if len(serviceTokenDetails.Scopes) != 1 { - return nil, fmt.Errorf("you need to provide the --env for multiple environment scoped token") + return nil, workspaceId, fmt.Errorf("you need to provide the --env for multiple environment scoped token") } else { environment = serviceTokenDetails.Scopes[0].Environment } } rawSecrets, err := api.CallGetRawSecretsV3(httpClient, api.GetRawSecretsV3Request{ - WorkspaceId: serviceTokenDetails.Workspace, + WorkspaceId: workspaceId, Environment: environment, SecretPath: secretPath, IncludeImport: includeImports, @@ -61,7 +63,7 @@ func GetPlainTextSecretsViaServiceToken(fullServiceToken string, environment str }) if err != nil { - return nil, err + return nil, workspaceId, err } plainTextSecrets := []models.SingleEnvironmentVariable{} @@ -73,11 +75,11 @@ func GetPlainTextSecretsViaServiceToken(fullServiceToken string, environment str if includeImports { plainTextSecrets, err = InjectRawImportedSecret(plainTextSecrets, rawSecrets.Imports) if err != nil { - return nil, err + return nil, workspaceId, err } } - return plainTextSecrets, nil + return plainTextSecrets, workspaceId, nil } @@ -359,9 +361,17 @@ func GetAllEnvironmentVariables(params models.GetAllSecretsParameters, projectCo } } else { + // workspaceId for caching — use params.WorkspaceId if provided (via --projectId), + // otherwise populated from the service token details. + cacheWorkspaceId := params.WorkspaceId + if params.InfisicalToken != "" { log.Debug().Msg("Trying to fetch secrets using service token") - secretsToReturn, errorToReturn = GetPlainTextSecretsViaServiceToken(params.InfisicalToken, params.Environment, params.SecretsPath, params.IncludeImport, params.Recursive, params.TagSlugs, params.ExpandSecretReferences) + var tokenWorkspaceId string + secretsToReturn, tokenWorkspaceId, errorToReturn = GetPlainTextSecretsViaServiceToken(params.InfisicalToken, params.Environment, params.SecretsPath, params.IncludeImport, params.Recursive, params.TagSlugs, params.ExpandSecretReferences) + if cacheWorkspaceId == "" { + cacheWorkspaceId = tokenWorkspaceId + } } else if params.UniversalAuthAccessToken != "" { if params.WorkspaceId == "" { @@ -376,11 +386,11 @@ func GetAllEnvironmentVariables(params models.GetAllSecretsParameters, projectCo } // cache secrets on success, fallback to cached secrets on connection/server failure - if errorToReturn == nil && params.WorkspaceId != "" { + if errorToReturn == nil && cacheWorkspaceId != "" { if backupEncryptionKey, err := GetBackupEncryptionKey(); err == nil { - WriteBackupSecrets(params.WorkspaceId, params.Environment, params.SecretsPath, backupEncryptionKey, secretsToReturn) + WriteBackupSecrets(cacheWorkspaceId, params.Environment, params.SecretsPath, backupEncryptionKey, secretsToReturn) } - } else if errorToReturn != nil && params.WorkspaceId != "" { + } else if errorToReturn != nil && cacheWorkspaceId != "" { // Only fall back to cache for connection errors or server errors (5xx). // Do not mask client errors (4xx) like 401/403 which indicate auth issues. shouldFallback := true @@ -392,7 +402,7 @@ func GetAllEnvironmentVariables(params models.GetAllSecretsParameters, projectCo if shouldFallback { backupEncryptionKey, _ := GetBackupEncryptionKey() if backupEncryptionKey != nil { - backedUpSecrets, err := ReadBackupSecrets(params.WorkspaceId, params.Environment, params.SecretsPath, backupEncryptionKey) + backedUpSecrets, err := ReadBackupSecrets(cacheWorkspaceId, params.Environment, params.SecretsPath, backupEncryptionKey) if len(backedUpSecrets) > 0 { PrintWarning("Unable to fetch the latest secret(s) due to connection error, serving secrets from last successful fetch. For more info, run with --debug") secretsToReturn = backedUpSecrets