diff --git a/extension/credential/secplugin/provider.go b/extension/credential/secplugin/provider.go new file mode 100644 index 000000000..a9280a9f9 --- /dev/null +++ b/extension/credential/secplugin/provider.go @@ -0,0 +1,227 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +// Package secplugin provides a placeholder credential provider for SEC_AUTH mode. +// +// When ~/.lark-cli/sec_config.json has: +// +// LARKSUITE_CLI_SEC_ENABLE=true +// LARKSUITE_CLI_SEC_AUTH=true +// +// this provider returns a minimal Account and placeholder tokens. The proxy +// is expected to replace the placeholder tokens with real ones. +package secplugin + +import ( + "context" + "fmt" + "os" + "strings" + + "github.com/larksuite/cli/extension/credential" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/envvars" + internalsec "github.com/larksuite/cli/internal/secplugin" +) + +// Provider supplies placeholder credentials when SEC_AUTH mode is enabled. +type Provider struct{} + +// Name returns the registered credential provider name. +func (p *Provider) Name() string { return "secplugin" } + +// Priority is higher than env (default 10) but lower than sidecar (0), +// so authsidecar builds keep sidecar semantics when both are present. +func (p *Provider) Priority() int { return 1 } + +// loadSecConfig is replaceable in tests so provider behavior can be isolated +// from on-disk SEC configuration state. +var loadSecConfig = internalsec.Load + +func validateDefaultAs(value string) error { + switch id := credential.Identity(strings.TrimSpace(value)); id { + case "", credential.IdentityAuto, credential.IdentityUser, credential.IdentityBot: + return nil + default: + return fmt.Errorf("invalid %s %q (want user, bot, or auto)", envvars.CliDefaultAs, id) + } +} + +// ResolveAccount builds an account that advertises SEC_AUTH placeholder support. +func (p *Provider) ResolveAccount(ctx context.Context) (*credential.Account, error) { + cfg, err := loadSecConfig() + if err != nil { + return nil, &credential.BlockError{Provider: p.Name(), Reason: err.Error()} + } + if cfg == nil || !cfg.AuthEnabled() { + return nil, nil + } + + appID := strings.TrimSpace(os.Getenv(envvars.CliAppID)) + brand := credential.Brand(strings.TrimSpace(os.Getenv(envvars.CliBrand))) + var defaultAs credential.Identity + + // Prefer explicit env; if missing, allow sec_config.json to provide defaults. + if appID == "" && strings.TrimSpace(cfg.AppID) != "" { + appID = strings.TrimSpace(cfg.AppID) + } + if brand == "" && strings.TrimSpace(cfg.Brand) != "" { + brand = credential.Brand(strings.TrimSpace(cfg.Brand)) + } + if defaultAs == "" && strings.TrimSpace(cfg.DefaultAs) != "" { + defaultAs = credential.Identity(strings.TrimSpace(cfg.DefaultAs)) + if err := validateDefaultAs(string(defaultAs)); err != nil { + return nil, &credential.BlockError{ + Provider: p.Name(), + Reason: err.Error(), + } + } + } + + // Prefer explicit env for sandbox use; otherwise fall back to on-disk config + // without resolving any secrets. + if appID == "" || brand == "" { + multi, err := core.LoadMultiAppConfig() + if err != nil || multi == nil { + return nil, &credential.BlockError{ + Provider: p.Name(), + Reason: "SEC_AUTH is enabled but no app config is available; run `lark-cli config init --new` (trusted env), or set " + envvars.CliAppID + " and " + envvars.CliBrand, + } + } + app := multi.CurrentAppConfig("") // profile override not available in provider API + if app == nil { + return nil, &credential.BlockError{ + Provider: p.Name(), + Reason: "SEC_AUTH is enabled but no active profile is available in config.json", + } + } + if appID == "" { + appID = app.AppId + } + if brand == "" { + brand = credential.Brand(app.Brand) + } + if defaultAs == "" { + defaultAs = credential.Identity(app.DefaultAs) + } + + // Map strict mode to supported identities (0 = allow all). + mode := multi.StrictMode + if app.StrictMode != nil { + mode = *app.StrictMode + } + switch mode { + case core.StrictModeBot: + // Keep sandbox locked down to bot. + return &credential.Account{ + AppID: appID, + AppSecret: credential.NoAppSecret, + Brand: brand, + DefaultAs: defaultAs, + SupportedIdentities: credential.SupportsBot, + }, nil + case core.StrictModeUser: + return &credential.Account{ + AppID: appID, + AppSecret: credential.NoAppSecret, + Brand: brand, + DefaultAs: defaultAs, + SupportedIdentities: credential.SupportsUser, + }, nil + } + } + + if appID == "" { + return nil, &credential.BlockError{ + Provider: p.Name(), + Reason: "SEC_AUTH is enabled but " + envvars.CliAppID + " is missing", + } + } + if brand == "" { + brand = credential.BrandFeishu + } + if brand != credential.BrandFeishu && brand != credential.BrandLark { + return nil, &credential.BlockError{ + Provider: p.Name(), + Reason: fmt.Sprintf("invalid %s %q (want feishu or lark)", envvars.CliBrand, brand), + } + } + + // DefaultAs comes from env if present (optional). + envDefaultAs := strings.TrimSpace(os.Getenv(envvars.CliDefaultAs)) + if err := validateDefaultAs(envDefaultAs); err != nil { + return nil, &credential.BlockError{ + Provider: p.Name(), + Reason: err.Error(), + } + } + switch id := credential.Identity(envDefaultAs); id { + case "", credential.IdentityAuto: + // keep defaultAs from config/env; empty is allowed + case credential.IdentityUser, credential.IdentityBot: + defaultAs = id + } + + // If STRICT_MODE env is not set, allow sec_config.json to provide a default. + strictModeRaw := strings.TrimSpace(os.Getenv(envvars.CliStrictMode)) + if strictModeRaw == "" && strings.TrimSpace(cfg.StrictMode) != "" { + strictModeRaw = strings.TrimSpace(cfg.StrictMode) + } + + // SupportedIdentities from STRICT_MODE (optional). Default: allow both. + support := credential.SupportsAll + switch strictMode := strictModeRaw; strictMode { + case "bot": + support = credential.SupportsBot + case "user": + support = credential.SupportsUser + case "off", "": + // Keep the default: allow both identities. + default: + return nil, &credential.BlockError{ + Provider: p.Name(), + Reason: fmt.Sprintf("invalid %s %q (want bot, user, or off)", envvars.CliStrictMode, strictMode), + } + } + + return &credential.Account{ + AppID: appID, + AppSecret: credential.NoAppSecret, + Brand: brand, + DefaultAs: defaultAs, + SupportedIdentities: support, + }, nil +} + +// ResolveToken returns placeholder tokens that a trusted proxy must replace. +func (p *Provider) ResolveToken(ctx context.Context, req credential.TokenSpec) (*credential.Token, error) { + cfg, err := internalsec.Load() + if err != nil { + return nil, &credential.BlockError{Provider: p.Name(), Reason: err.Error()} + } + if cfg == nil || !cfg.AuthEnabled() { + return nil, nil + } + + switch req.Type { + case credential.TokenTypeUAT: + return &credential.Token{ + Value: internalsec.SentinelUAT, + Scopes: "", // empty => skip scope pre-check + Source: "secplugin", + }, nil + case credential.TokenTypeTAT: + return &credential.Token{ + Value: internalsec.SentinelTAT, + Scopes: "", + Source: "secplugin", + }, nil + default: + return nil, nil + } +} + +// init registers the SEC_AUTH placeholder credential provider. +func init() { + credential.Register(&Provider{}) +} diff --git a/extension/credential/secplugin/provider_test.go b/extension/credential/secplugin/provider_test.go new file mode 100644 index 000000000..08fb5b2a9 --- /dev/null +++ b/extension/credential/secplugin/provider_test.go @@ -0,0 +1,486 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package secplugin + +import ( + "context" + "strings" + "testing" + + "github.com/larksuite/cli/extension/credential" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/envvars" + internalsec "github.com/larksuite/cli/internal/secplugin" +) + +// TestProvider_Metadata verifies the registered provider metadata. +func TestProvider_Metadata(t *testing.T) { + p := &Provider{} + if p.Name() != "secplugin" { + t.Fatalf("Name() = %q, want secplugin", p.Name()) + } + if p.Priority() != 1 { + t.Fatalf("Priority() = %d, want 1", p.Priority()) + } +} + +// TestProvider_UsesSecConfigDefaults verifies that SEC config defaults populate +// the placeholder account when env vars are absent. +func TestProvider_UsesSecConfigDefaults(t *testing.T) { + prev := loadSecConfig + t.Cleanup(func() { loadSecConfig = prev }) + + loadSecConfig = func() (*internalsec.Config, error) { + return &internalsec.Config{ + Enable: true, + Auth: true, + AppID: "cli_test_app", + Brand: "lark", + DefaultAs: "bot", + StrictMode: "bot", + }, nil + } + + t.Setenv(envvars.CliAppID, "") + t.Setenv(envvars.CliBrand, "") + t.Setenv(envvars.CliDefaultAs, "") + t.Setenv(envvars.CliStrictMode, "") + + p := &Provider{} + acct, err := p.ResolveAccount(context.Background()) + if err != nil { + t.Fatalf("ResolveAccount() error = %v", err) + } + if acct == nil { + t.Fatal("ResolveAccount() = nil, want account") + } + if acct.AppID != "cli_test_app" { + t.Fatalf("acct.AppID = %q, want %q", acct.AppID, "cli_test_app") + } + if string(acct.Brand) != "lark" { + t.Fatalf("acct.Brand = %q, want %q", acct.Brand, "lark") + } + if string(acct.DefaultAs) != "bot" { + t.Fatalf("acct.DefaultAs = %q, want %q", acct.DefaultAs, "bot") + } + // StrictMode=bot => SupportsBot only. + if acct.SupportedIdentities != 2 { + t.Fatalf("acct.SupportedIdentities = %d, want %d (SupportsBot)", acct.SupportedIdentities, 2) + } +} + +// TestProvider_EnvOverridesSecConfigDefaults verifies that explicit environment +// variables override SEC config defaults. +func TestProvider_EnvOverridesSecConfigDefaults(t *testing.T) { + prev := loadSecConfig + t.Cleanup(func() { loadSecConfig = prev }) + + loadSecConfig = func() (*internalsec.Config, error) { + return &internalsec.Config{ + Enable: true, + Auth: true, + AppID: "cli_test_app", + Brand: "feishu", + DefaultAs: "bot", + StrictMode: "bot", + }, nil + } + + t.Setenv(envvars.CliAppID, "cli_env_app") + t.Setenv(envvars.CliBrand, "lark") + t.Setenv(envvars.CliDefaultAs, "user") + t.Setenv(envvars.CliStrictMode, "user") + + p := &Provider{} + acct, err := p.ResolveAccount(context.Background()) + if err != nil { + t.Fatalf("ResolveAccount() error = %v", err) + } + if acct == nil { + t.Fatal("ResolveAccount() = nil, want account") + } + if acct.AppID != "cli_env_app" { + t.Fatalf("acct.AppID = %q, want %q", acct.AppID, "cli_env_app") + } + if string(acct.Brand) != "lark" { + t.Fatalf("acct.Brand = %q, want %q", acct.Brand, "lark") + } + if string(acct.DefaultAs) != "user" { + t.Fatalf("acct.DefaultAs = %q, want %q", acct.DefaultAs, "user") + } + // StrictMode=user => SupportsUser only (bit 1). + if acct.SupportedIdentities != 1 { + t.Fatalf("acct.SupportedIdentities = %d, want %d (SupportsUser)", acct.SupportedIdentities, 1) + } +} + +// TestProvider_ResolveAccount_ReturnsNilWhenDisabled verifies early nil returns +// when SEC_AUTH mode is unavailable. +func TestProvider_ResolveAccount_ReturnsNilWhenDisabled(t *testing.T) { + prev := loadSecConfig + t.Cleanup(func() { loadSecConfig = prev }) + + cases := []struct { + name string + cfg *internalsec.Config + }{ + {name: "nil config", cfg: nil}, + {name: "auth disabled", cfg: &internalsec.Config{Enable: true, Auth: false}}, + } + + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + loadSecConfig = func() (*internalsec.Config, error) { return tt.cfg, nil } + acct, err := (&Provider{}).ResolveAccount(context.Background()) + if err != nil { + t.Fatalf("ResolveAccount() error = %v", err) + } + if acct != nil { + t.Fatalf("ResolveAccount() = %#v, want nil", acct) + } + }) + } +} + +// TestProvider_ResolveAccount_LoadErrorBlocks verifies that SEC config load failures +// stop provider resolution. +func TestProvider_ResolveAccount_LoadErrorBlocks(t *testing.T) { + prev := loadSecConfig + t.Cleanup(func() { loadSecConfig = prev }) + + loadSecConfig = func() (*internalsec.Config, error) { + return nil, context.DeadlineExceeded + } + + acct, err := (&Provider{}).ResolveAccount(context.Background()) + if err == nil { + t.Fatal("ResolveAccount() error = nil, want block error") + } + if acct != nil { + t.Fatalf("ResolveAccount() = %#v, want nil", acct) + } + blockErr, ok := err.(*credential.BlockError) + if !ok { + t.Fatalf("ResolveAccount() error = %T, want *credential.BlockError", err) + } + if blockErr.Provider != "secplugin" { + t.Fatalf("blockErr.Provider = %q, want secplugin", blockErr.Provider) + } + if !strings.Contains(blockErr.Reason, context.DeadlineExceeded.Error()) { + t.Fatalf("blockErr.Reason = %q, want load error text", blockErr.Reason) + } +} + +// TestProvider_ResolveAccount_DefaultsBrandAndSupport verifies fallback defaults +// for brand and supported identities. +func TestProvider_ResolveAccount_DefaultsBrandAndSupport(t *testing.T) { + prev := loadSecConfig + t.Cleanup(func() { loadSecConfig = prev }) + + loadSecConfig = func() (*internalsec.Config, error) { + return &internalsec.Config{ + Enable: true, + Auth: true, + }, nil + } + + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + t.Setenv(envvars.CliAppID, "") + t.Setenv(envvars.CliBrand, "") + t.Setenv(envvars.CliDefaultAs, "") + t.Setenv(envvars.CliStrictMode, "") + if err := core.SaveMultiAppConfig(&core.MultiAppConfig{ + Apps: []core.AppConfig{{ + Name: "default", + AppId: "app_from_disk", + AppSecret: core.PlainSecret("secret"), + DefaultAs: core.AsBot, + }}, + }); err != nil { + t.Fatalf("SaveMultiAppConfig() error = %v", err) + } + + acct, err := (&Provider{}).ResolveAccount(context.Background()) + if err != nil { + t.Fatalf("ResolveAccount() error = %v", err) + } + if acct == nil { + t.Fatal("ResolveAccount() = nil, want account") + } + if acct.Brand != credential.BrandFeishu { + t.Fatalf("acct.Brand = %q, want %q", acct.Brand, credential.BrandFeishu) + } + if acct.SupportedIdentities != credential.SupportsAll { + t.Fatalf("acct.SupportedIdentities = %d, want %d", acct.SupportedIdentities, credential.SupportsAll) + } + if acct.DefaultAs != credential.Identity("bot") { + t.Fatalf("acct.DefaultAs = %q, want bot", acct.DefaultAs) + } + if acct.AppID != "app_from_disk" { + t.Fatalf("acct.AppID = %q, want app_from_disk", acct.AppID) + } +} + +// TestProvider_ResolveAccount_InvalidValuesBlock verifies validation failures for +// brand and identity-related settings. +func TestProvider_ResolveAccount_InvalidValuesBlock(t *testing.T) { + prev := loadSecConfig + t.Cleanup(func() { loadSecConfig = prev }) + + cases := []struct { + name string + cfg *internalsec.Config + envKey string + envValue string + want string + }{ + { + name: "invalid brand from config", + cfg: &internalsec.Config{Enable: true, Auth: true, AppID: "cli_test_app", Brand: "bad-brand"}, + want: "invalid " + envvars.CliBrand, + }, + { + name: "invalid default as from config", + cfg: &internalsec.Config{ + Enable: true, + Auth: true, + AppID: "cli_test_app", + Brand: "lark", + DefaultAs: "bad", + }, + want: "invalid " + envvars.CliDefaultAs, + }, + { + name: "invalid default as from env", + cfg: &internalsec.Config{Enable: true, Auth: true, AppID: "cli_test_app", Brand: "lark"}, + envKey: envvars.CliDefaultAs, + envValue: "bad", + want: "invalid " + envvars.CliDefaultAs, + }, + { + name: "invalid strict mode from env", + cfg: &internalsec.Config{Enable: true, Auth: true, AppID: "cli_test_app", Brand: "lark"}, + envKey: envvars.CliStrictMode, + envValue: "bad", + want: "invalid " + envvars.CliStrictMode, + }, + } + + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + loadSecConfig = func() (*internalsec.Config, error) { return tt.cfg, nil } + t.Setenv(envvars.CliAppID, "") + t.Setenv(envvars.CliBrand, "") + t.Setenv(envvars.CliDefaultAs, "") + t.Setenv(envvars.CliStrictMode, "") + if tt.envKey != "" { + t.Setenv(tt.envKey, tt.envValue) + } + + acct, err := (&Provider{}).ResolveAccount(context.Background()) + if err == nil { + t.Fatal("ResolveAccount() error = nil, want block error") + } + if acct != nil { + t.Fatalf("ResolveAccount() = %#v, want nil", acct) + } + blockErr, ok := err.(*credential.BlockError) + if !ok { + t.Fatalf("ResolveAccount() error = %T, want *credential.BlockError", err) + } + if !strings.Contains(blockErr.Reason, tt.want) { + t.Fatalf("blockErr.Reason = %q, want substring %q", blockErr.Reason, tt.want) + } + }) + } +} + +// TestProvider_ResolveAccount_FallbackToDiskConfig verifies fallback behavior +// when SEC config omits app identity fields. +func TestProvider_ResolveAccount_FallbackToDiskConfig(t *testing.T) { + prev := loadSecConfig + t.Cleanup(func() { loadSecConfig = prev }) + + loadSecConfig = func() (*internalsec.Config, error) { + return &internalsec.Config{Enable: true, Auth: true}, nil + } + + t.Run("missing config blocks", func(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + t.Setenv(envvars.CliAppID, "") + t.Setenv(envvars.CliBrand, "") + acct, err := (&Provider{}).ResolveAccount(context.Background()) + if err == nil { + t.Fatal("ResolveAccount() error = nil, want block error") + } + if acct != nil { + t.Fatalf("ResolveAccount() = %#v, want nil", acct) + } + blockErr := err.(*credential.BlockError) + if !strings.Contains(blockErr.Reason, "no app config is available") { + t.Fatalf("blockErr.Reason = %q, want missing app config message", blockErr.Reason) + } + }) + + t.Run("missing active profile blocks", func(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + if err := core.SaveMultiAppConfig(&core.MultiAppConfig{ + CurrentApp: "missing", + Apps: []core.AppConfig{{ + Name: "default", + AppId: "app_from_disk", + AppSecret: core.PlainSecret("secret"), + Brand: core.LarkBrand("lark"), + }}, + }); err != nil { + t.Fatalf("SaveMultiAppConfig() error = %v", err) + } + + acct, err := (&Provider{}).ResolveAccount(context.Background()) + if err == nil { + t.Fatal("ResolveAccount() error = nil, want block error") + } + if acct != nil { + t.Fatalf("ResolveAccount() = %#v, want nil", acct) + } + blockErr := err.(*credential.BlockError) + if !strings.Contains(blockErr.Reason, "no active profile") { + t.Fatalf("blockErr.Reason = %q, want no active profile message", blockErr.Reason) + } + }) + + t.Run("strict mode from disk", func(t *testing.T) { + cases := []struct { + name string + mode core.StrictMode + wantIDs credential.IdentitySupport + }{ + {name: "bot", mode: core.StrictModeBot, wantIDs: credential.SupportsBot}, + {name: "user", mode: core.StrictModeUser, wantIDs: credential.SupportsUser}, + } + + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + mode := tt.mode + if err := core.SaveMultiAppConfig(&core.MultiAppConfig{ + Apps: []core.AppConfig{{ + Name: "default", + AppId: "app_from_disk", + AppSecret: core.PlainSecret("secret"), + Brand: core.LarkBrand("lark"), + DefaultAs: core.AsBot, + StrictMode: &mode, + }}, + }); err != nil { + t.Fatalf("SaveMultiAppConfig() error = %v", err) + } + + acct, err := (&Provider{}).ResolveAccount(context.Background()) + if err != nil { + t.Fatalf("ResolveAccount() error = %v", err) + } + if acct == nil { + t.Fatal("ResolveAccount() = nil, want account") + } + if acct.AppID != "app_from_disk" { + t.Fatalf("acct.AppID = %q, want app_from_disk", acct.AppID) + } + if acct.Brand != credential.Brand("lark") { + t.Fatalf("acct.Brand = %q, want lark", acct.Brand) + } + if acct.DefaultAs != credential.Identity("bot") { + t.Fatalf("acct.DefaultAs = %q, want bot", acct.DefaultAs) + } + if acct.SupportedIdentities != tt.wantIDs { + t.Fatalf("acct.SupportedIdentities = %d, want %d", acct.SupportedIdentities, tt.wantIDs) + } + }) + } + }) +} + +// TestProvider_ResolveAccount_StrictModePreservesConfiguredDefaultAs verifies +// cfg.DefaultAs is not overwritten by disk profile default in strict-mode path. +func TestProvider_ResolveAccount_StrictModePreservesConfiguredDefaultAs(t *testing.T) { + prev := loadSecConfig + t.Cleanup(func() { loadSecConfig = prev }) + + loadSecConfig = func() (*internalsec.Config, error) { + return &internalsec.Config{ + Enable: true, + Auth: true, + Brand: "lark", + DefaultAs: "user", + StrictMode: "bot", + }, nil + } + + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + t.Setenv(envvars.CliAppID, "") + t.Setenv(envvars.CliBrand, "") + t.Setenv(envvars.CliDefaultAs, "") + t.Setenv(envvars.CliStrictMode, "") + if err := core.SaveMultiAppConfig(&core.MultiAppConfig{ + Apps: []core.AppConfig{{ + Name: "default", + AppId: "app_from_disk", + AppSecret: core.PlainSecret("secret"), + Brand: core.LarkBrand("lark"), + DefaultAs: core.AsBot, + }}, + }); err != nil { + t.Fatalf("SaveMultiAppConfig() error = %v", err) + } + + acct, err := (&Provider{}).ResolveAccount(context.Background()) + if err != nil { + t.Fatalf("ResolveAccount() error = %v", err) + } + if acct == nil { + t.Fatal("ResolveAccount() = nil, want account") + } + if acct.DefaultAs != credential.IdentityUser { + t.Fatalf("acct.DefaultAs = %q, want %q", acct.DefaultAs, credential.IdentityUser) + } + if acct.SupportedIdentities != credential.SupportsBot { + t.Fatalf("acct.SupportedIdentities = %d, want %d (SupportsBot)", acct.SupportedIdentities, credential.SupportsBot) + } +} + +// TestProvider_ResolveToken_ReturnsSentinels verifies placeholder token behavior +// for SEC_AUTH mode. +func TestProvider_ResolveToken_ReturnsSentinels(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + t.Setenv(envvars.CliSecEnable, "true") + t.Setenv(envvars.CliSecAuth, "true") + t.Setenv(envvars.CliSecProxy, "http://127.0.0.1:3128") + t.Setenv(envvars.CliSecCA, "") + + p := &Provider{} + + uat, err := p.ResolveToken(context.Background(), credential.TokenSpec{Type: credential.TokenTypeUAT}) + if err != nil { + t.Fatalf("ResolveToken(UAT) error = %v", err) + } + if uat == nil || uat.Value != internalsec.SentinelUAT || uat.Source != "secplugin" { + t.Fatalf("ResolveToken(UAT) = %#v, want sentinel UAT token", uat) + } + + tat, err := p.ResolveToken(context.Background(), credential.TokenSpec{Type: credential.TokenTypeTAT}) + if err != nil { + t.Fatalf("ResolveToken(TAT) error = %v", err) + } + if tat == nil || tat.Value != internalsec.SentinelTAT || tat.Source != "secplugin" { + t.Fatalf("ResolveToken(TAT) = %#v, want sentinel TAT token", tat) + } + + tok, err := p.ResolveToken(context.Background(), credential.TokenSpec{Type: credential.TokenType("other")}) + if err != nil { + t.Fatalf("ResolveToken(other) error = %v", err) + } + if tok != nil { + t.Fatalf("ResolveToken(other) = %#v, want nil", tok) + } +} diff --git a/internal/envvars/envvars.go b/internal/envvars/envvars.go index 41560ec9d..032b39834 100644 --- a/internal/envvars/envvars.go +++ b/internal/envvars/envvars.go @@ -4,18 +4,45 @@ package envvars const ( - CliAppID = "LARKSUITE_CLI_APP_ID" - CliAppSecret = "LARKSUITE_CLI_APP_SECRET" - CliBrand = "LARKSUITE_CLI_BRAND" - CliUserAccessToken = "LARKSUITE_CLI_USER_ACCESS_TOKEN" + // CliAppID is the app ID environment variable consumed by the CLI. + CliAppID = "LARKSUITE_CLI_APP_ID" + + // CliAppSecret is the app secret environment variable consumed by the CLI. + CliAppSecret = "LARKSUITE_CLI_APP_SECRET" + + // CliBrand selects the tenant brand environment variable consumed by the CLI. + CliBrand = "LARKSUITE_CLI_BRAND" + + // CliUserAccessToken is the user access token override environment variable. + CliUserAccessToken = "LARKSUITE_CLI_USER_ACCESS_TOKEN" + + // CliTenantAccessToken is the tenant access token override environment variable. CliTenantAccessToken = "LARKSUITE_CLI_TENANT_ACCESS_TOKEN" - CliDefaultAs = "LARKSUITE_CLI_DEFAULT_AS" - CliStrictMode = "LARKSUITE_CLI_STRICT_MODE" - // Sidecar proxy (auth proxy mode) + // CliDefaultAs selects the default identity environment variable. + CliDefaultAs = "LARKSUITE_CLI_DEFAULT_AS" + + // CliStrictMode selects the strict identity mode environment variable. + CliStrictMode = "LARKSUITE_CLI_STRICT_MODE" + + // CliAuthProxy is the auth sidecar HTTP address environment variable. CliAuthProxy = "LARKSUITE_CLI_AUTH_PROXY" // sidecar HTTP address, e.g. "http://127.0.0.1:16384" - CliProxyKey = "LARKSUITE_CLI_PROXY_KEY" // HMAC signing key shared with sidecar - // Content safety scanning mode + // CliProxyKey is the shared HMAC signing key environment variable for the sidecar. + CliProxyKey = "LARKSUITE_CLI_PROXY_KEY" // HMAC signing key shared with sidecar + + // CliSecEnable enables sec plugin mode from the environment. + CliSecEnable = "LARKSUITE_CLI_SEC_ENABLE" + + // CliSecProxy sets the fixed sec plugin HTTP proxy address. + CliSecProxy = "LARKSUITE_CLI_SEC_PROXY" + + // CliSecCA points to an extra PEM bundle trusted by sec plugin mode. + CliSecCA = "LARKSUITE_CLI_SEC_CA" + + // CliSecAuth enables placeholder-token auth mode for sec plugin flows. + CliSecAuth = "LARKSUITE_CLI_SEC_AUTH" + + // CliContentSafetyMode selects the content safety scanning mode. CliContentSafetyMode = "LARKSUITE_CLI_CONTENT_SAFETY_MODE" ) diff --git a/internal/secplugin/README.md b/internal/secplugin/README.md new file mode 100644 index 000000000..f38fcbe03 --- /dev/null +++ b/internal/secplugin/README.md @@ -0,0 +1,135 @@ +# secplugin Usage Guide + +Chinese version: see `README.zh-CN.md`. + +`secplugin` enables a secure proxy mode for the CLI. It forces outbound HTTP(S) +requests to go through a local security proxy and can optionally trust an +additional CA certificate bundle. + +It supports two configuration methods: + +1. `sec_config.json` +2. `LARKSUITE_CLI_SEC_*` environment variables + +## Config File Location + +Default config file path: + +```text +~/.lark-cli/sec_config.json +``` + +If `LARKSUITE_CLI_CONFIG_DIR` is set, the path becomes: + +```text +$LARKSUITE_CLI_CONFIG_DIR/sec_config.json +``` + +## Option 1: Config File + +Put the following content into `sec_config.json`: + +```json +{ + "LARKSUITE_CLI_SEC_ENABLE": true, + "LARKSUITE_CLI_SEC_PROXY": "http://127.0.0.1:3128", + "LARKSUITE_CLI_SEC_CA": "/absolute/path/to/proxy-ca.pem", + "LARKSUITE_CLI_SEC_AUTH": true, + "LARKSUITE_CLI_APP_ID": "cli_xxx", + "LARKSUITE_CLI_BRAND": "feishu", + "LARKSUITE_CLI_DEFAULT_AS": "bot", + "LARKSUITE_CLI_STRICT_MODE": "bot" +} +``` + +Field descriptions: + +- `LARKSUITE_CLI_SEC_ENABLE`: Enables secplugin. Boolean values are supported. +- `LARKSUITE_CLI_SEC_PROXY`: Local HTTP proxy address. It must be `http://127.0.0.1:`. +- `LARKSUITE_CLI_SEC_CA`: Absolute path to an extra trusted root CA PEM file. Leave empty if not needed. +- `LARKSUITE_CLI_SEC_AUTH`: Enables proxy-injected token mode. +- `LARKSUITE_CLI_APP_ID`: Optional app ID used in `SEC_AUTH` mode. +- `LARKSUITE_CLI_BRAND`: Optional, must be `feishu` or `lark`. +- `LARKSUITE_CLI_DEFAULT_AS`: Optional, must be `user`, `bot`, or `auto`. +- `LARKSUITE_CLI_STRICT_MODE`: Optional, must be `user`, `bot`, or `off`. + +## Option 2: Environment Variables + +You can also enable secplugin directly with environment variables without +creating `sec_config.json`: + +```bash +export LARKSUITE_CLI_SEC_ENABLE=true +export LARKSUITE_CLI_SEC_PROXY=http://127.0.0.1:3128 +export LARKSUITE_CLI_SEC_CA=/absolute/path/to/proxy-ca.pem +export LARKSUITE_CLI_SEC_AUTH=true +``` + +If you want to provide app metadata in `SEC_AUTH` mode, set these as well: + +```bash +export LARKSUITE_CLI_APP_ID=cli_xxx +export LARKSUITE_CLI_BRAND=feishu +export LARKSUITE_CLI_DEFAULT_AS=bot +export LARKSUITE_CLI_STRICT_MODE=bot +``` + +## Precedence + +The following environment variables override the corresponding fields in +`sec_config.json` when they are present: + +- `LARKSUITE_CLI_SEC_ENABLE` +- `LARKSUITE_CLI_SEC_PROXY` +- `LARKSUITE_CLI_SEC_CA` +- `LARKSUITE_CLI_SEC_AUTH` +- `LARKSUITE_CLI_APP_ID` +- `LARKSUITE_CLI_BRAND` +- `LARKSUITE_CLI_DEFAULT_AS` +- `LARKSUITE_CLI_STRICT_MODE` + +This means: + +- Put stable defaults in `sec_config.json`. +- Use environment variables for temporary overrides. +- SEC-related environment variables can work even without a config file. + +## SEC_AUTH Mode + +The CLI enters `SEC_AUTH` mode when both of the following are true: + +```text +LARKSUITE_CLI_SEC_ENABLE=true +LARKSUITE_CLI_SEC_AUTH=true +``` + +In this mode, the CLI does not read real tokens directly. Instead, it returns +placeholder tokens and expects the proxy to replace them with real credentials. + +App information is resolved in this order: + +1. `LARKSUITE_CLI_APP_ID` and `LARKSUITE_CLI_BRAND` from environment variables +2. The same fields in `sec_config.json` +3. The active profile in the regular CLI `config.json` + +If no valid app information can be resolved from any source, the command fails. + +## Constraints + +- `LARKSUITE_CLI_SEC_PROXY` must use the `http` scheme only. +- The host of `LARKSUITE_CLI_SEC_PROXY` must be `127.0.0.1`. +- `LARKSUITE_CLI_SEC_PROXY` must not contain a path. +- `LARKSUITE_CLI_SEC_CA` must be an absolute path to a PEM file. +- Boolean values support `true/false`, `1/0`, `on/off`, `yes/no`, and `y/n`. + +## Recommendations + +For long-term stable setup, prefer `sec_config.json`: + +- Good for developer machines or controlled environments. +- Avoids repeatedly injecting environment variables into the shell. + +For temporary debugging, prefer environment variables: + +- Good for switching proxy or CA for just one session. +- No need to modify files on disk. diff --git a/internal/secplugin/README.zh-CN.md b/internal/secplugin/README.zh-CN.md new file mode 100644 index 000000000..df2011b70 --- /dev/null +++ b/internal/secplugin/README.zh-CN.md @@ -0,0 +1,130 @@ +# secplugin 使用说明 + +English version: see `README.md`. + +`secplugin` 用于开启安全代理模式,让 CLI 的 HTTP(S) 请求固定走本地安全代理,并按需信任额外 CA 证书。 + +支持两种配置方式: + +1. `sec_config.json` +2. `LARKSUITE_CLI_SEC_*` 环境变量 + +## 配置文件位置 + +默认配置文件路径: + +```text +~/.lark-cli/sec_config.json +``` + +如果设置了 `LARKSUITE_CLI_CONFIG_DIR`,则配置文件路径变为: + +```text +$LARKSUITE_CLI_CONFIG_DIR/sec_config.json +``` + +## 方式一:使用配置文件 + +在 `sec_config.json` 中写入: + +```json +{ + "LARKSUITE_CLI_SEC_ENABLE": true, + "LARKSUITE_CLI_SEC_PROXY": "http://127.0.0.1:3128", + "LARKSUITE_CLI_SEC_CA": "/absolute/path/to/proxy-ca.pem", + "LARKSUITE_CLI_SEC_AUTH": true, + "LARKSUITE_CLI_APP_ID": "cli_xxx", + "LARKSUITE_CLI_BRAND": "feishu", + "LARKSUITE_CLI_DEFAULT_AS": "bot", + "LARKSUITE_CLI_STRICT_MODE": "bot" +} +``` + +字段说明: + +- `LARKSUITE_CLI_SEC_ENABLE`: 是否启用 secplugin,支持布尔值。 +- `LARKSUITE_CLI_SEC_PROXY`: 本地 HTTP 代理地址,必须是 `http://127.0.0.1:`。 +- `LARKSUITE_CLI_SEC_CA`: 额外信任的根证书 PEM 文件绝对路径;不需要时可留空。 +- `LARKSUITE_CLI_SEC_AUTH`: 是否启用代理注入 token 模式。 +- `LARKSUITE_CLI_APP_ID`: 可选,`SEC_AUTH` 模式下使用的应用 ID。 +- `LARKSUITE_CLI_BRAND`: 可选,取值为 `feishu` 或 `lark`。 +- `LARKSUITE_CLI_DEFAULT_AS`: 可选,取值为 `user`、`bot` 或 `auto`。 +- `LARKSUITE_CLI_STRICT_MODE`: 可选,取值为 `user`、`bot` 或 `off`。 + +## 方式二:使用环境变量 + +也可以不写 `sec_config.json`,直接通过环境变量启用: + +```bash +export LARKSUITE_CLI_SEC_ENABLE=true +export LARKSUITE_CLI_SEC_PROXY=http://127.0.0.1:3128 +export LARKSUITE_CLI_SEC_CA=/absolute/path/to/proxy-ca.pem +export LARKSUITE_CLI_SEC_AUTH=true +``` + +如果你在 `SEC_AUTH` 模式下希望同时提供应用信息,也可以继续设置: + +```bash +export LARKSUITE_CLI_APP_ID=cli_xxx +export LARKSUITE_CLI_BRAND=feishu +export LARKSUITE_CLI_DEFAULT_AS=bot +export LARKSUITE_CLI_STRICT_MODE=bot +``` + +## 配置优先级 + +以下环境变量存在时,会覆盖 `sec_config.json` 中对应字段: + +- `LARKSUITE_CLI_SEC_ENABLE` +- `LARKSUITE_CLI_SEC_PROXY` +- `LARKSUITE_CLI_SEC_CA` +- `LARKSUITE_CLI_SEC_AUTH` +- `LARKSUITE_CLI_APP_ID` +- `LARKSUITE_CLI_BRAND` +- `LARKSUITE_CLI_DEFAULT_AS` +- `LARKSUITE_CLI_STRICT_MODE` + +也就是说: + +- 你可以把默认值写进 `sec_config.json`。 +- 再用环境变量做临时覆盖。 +- 如果没有配置文件,但设置了 SEC 相关环境变量,也可以正常工作。 + +## SEC_AUTH 模式说明 + +当同时满足以下条件时,CLI 会进入 `SEC_AUTH` 模式: + +```text +LARKSUITE_CLI_SEC_ENABLE=true +LARKSUITE_CLI_SEC_AUTH=true +``` + +此时 CLI 不直接读取真实 token,而是返回占位 token,由代理替换成真实凭证。 + +应用信息来源优先级如下: + +1. 环境变量中的 `LARKSUITE_CLI_APP_ID` 和 `LARKSUITE_CLI_BRAND` +2. `sec_config.json` 中的同名字段 +3. 常规 CLI 配置文件 `config.json` 的当前 profile + +如果以上来源都拿不到可用应用信息,命令会报错。 + +## 参数约束 + +- `LARKSUITE_CLI_SEC_PROXY` 只允许 `http` 协议。 +- `LARKSUITE_CLI_SEC_PROXY` 的 host 必须是 `127.0.0.1`。 +- `LARKSUITE_CLI_SEC_PROXY` 不能带路径。 +- `LARKSUITE_CLI_SEC_CA` 必须是 PEM 文件的绝对路径。 +- 布尔值支持 `true/false`、`1/0`、`on/off`、`yes/no`、`y/n`。 + +## 推荐用法 + +长期固定配置建议使用 `sec_config.json`: + +- 适合开发机或受控环境的稳定配置。 +- 避免在 shell 中反复注入环境变量。 + +临时调试建议使用环境变量: + +- 适合本次会话临时切换代理或证书。 +- 不需要修改磁盘上的配置文件。 diff --git a/internal/secplugin/config.go b/internal/secplugin/config.go new file mode 100644 index 000000000..db660b922 --- /dev/null +++ b/internal/secplugin/config.go @@ -0,0 +1,277 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +// Package secplugin implements the ~/.lark-cli/sec_config.json based security proxy plugin mode. +// +// It supports: +// - forcing all outbound HTTP(S) requests through a fixed HTTP proxy +// - trusting an additional root CA PEM bundle for MITM/inspection proxies +// - optional "proxy injects token" mode via placeholder tokens (SEC_AUTH) +// +// In sec plugin mode, certain common CLI env vars (APP_ID / BRAND / DEFAULT_AS / +// STRICT_MODE) can also be set in sec_config.json so sandboxes can avoid +// environment injection. When both are present, environment variables win. +package secplugin + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "os" + "path/filepath" + "strconv" + "strings" + "sync" + + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/envvars" + "github.com/larksuite/cli/internal/vfs" +) + +// SEC plugin constants cover the config file name and placeholder token values. +const ( + // ConfigFileName is the fixed config file name under core.GetConfigDir(). + ConfigFileName = "sec_config.json" + + // SentinelUAT is the placeholder user access token used in SEC_AUTH mode. + SentinelUAT = "secplugin-managed-uat" + + // SentinelTAT is the placeholder tenant access token used in SEC_AUTH mode. + SentinelTAT = "secplugin-managed-tat" +) + +// Config is the on-disk config format. Keys intentionally mirror env var names. +type Config struct { + // Enable turns on sec plugin transport handling. + Enable bool `json:"LARKSUITE_CLI_SEC_ENABLE"` + + // Proxy is the fixed HTTP proxy address used for all outbound requests. + Proxy string `json:"LARKSUITE_CLI_SEC_PROXY"` + + // CAPath points to an extra PEM bundle trusted for proxy TLS interception. + CAPath string `json:"LARKSUITE_CLI_SEC_CA"` + + // Auth enables placeholder-token mode for proxy-side credential injection. + Auth bool `json:"LARKSUITE_CLI_SEC_AUTH"` + + // Optional defaults for sec plugin mode; env vars override these. + // AppID supplies the app ID when the environment does not set one. + AppID string `json:"LARKSUITE_CLI_APP_ID,omitempty"` + + // Brand supplies the tenant brand when the environment does not set one. + Brand string `json:"LARKSUITE_CLI_BRAND,omitempty"` // feishu | lark + + // DefaultAs supplies the default identity when the environment does not set one. + DefaultAs string `json:"LARKSUITE_CLI_DEFAULT_AS,omitempty"` // user | bot | auto + + // StrictMode supplies the strict mode when the environment does not set one. + StrictMode string `json:"LARKSUITE_CLI_STRICT_MODE,omitempty"` // user | bot | off +} + +// Path returns the absolute path to the sec plugin config file. +func Path() string { + return filepath.Join(core.GetConfigDir(), ConfigFileName) +} + +// loadOnce guards one-time SEC config loading for process-wide transport reuse. +var loadOnce sync.Once + +// loadCfg stores the cached SEC config after the first successful Load call. +var loadCfg *Config + +// loadErr stores the cached Load error observed during the first load attempt. +var loadErr error + +// Load reads ~/.lark-cli/sec_config.json once and caches the parsed result. +// Environment variables (CliSec*) take precedence over config file values. +// +// Returns (nil, nil) only when: +// - the config file does not exist AND +// - none of the SEC-related env vars are present. +func Load() (*Config, error) { + loadOnce.Do(func() { + // Start from env-only config if any SEC env var is present. + cfg, hasEnv, err := loadFromEnv() + if err != nil { + loadErr = err + return + } + + p := Path() + if _, err := vfs.Stat(p); err != nil { + if errors.Is(err, os.ErrNotExist) { + // No file: return env-only config (if any), else nil. + if hasEnv { + loadCfg = cfg + } else { + loadCfg = nil + } + loadErr = nil + return + } + loadErr = fmt.Errorf("failed to stat sec plugin config %q: %w", p, err) + return + } + b, err := vfs.ReadFile(p) + if err != nil { + loadErr = fmt.Errorf("failed to read sec plugin config %q: %w", p, err) + return + } + var fileCfg Config + if err := json.Unmarshal(b, &fileCfg); err != nil { + loadErr = fmt.Errorf("invalid sec plugin config %q: %w", p, err) + return + } + + // Merge: file base + env overrides. + if cfg == nil { + cfg = &fileCfg + } else { + *cfg = fileCfg + applyEnvOverrides(cfg) + } + loadCfg = cfg + }) + return loadCfg, loadErr +} + +// Enabled reports whether SEC plugin mode is enabled. +func (c *Config) Enabled() bool { return c != nil && c.Enable } + +// AuthEnabled reports whether SEC_AUTH token placeholder mode is enabled. +func (c *Config) AuthEnabled() bool { return c != nil && c.Enable && c.Auth } + +// loadFromEnv builds a config from SEC-related environment variables only. +// It reports whether any SEC-related environment variable was present. +func loadFromEnv() (*Config, bool, error) { + _, hasEnable := os.LookupEnv(envvars.CliSecEnable) + _, hasProxy := os.LookupEnv(envvars.CliSecProxy) + _, hasCA := os.LookupEnv(envvars.CliSecCA) + _, hasAuth := os.LookupEnv(envvars.CliSecAuth) + hasAny := hasEnable || hasProxy || hasCA || hasAuth + if !hasAny { + return nil, false, nil + } + cfg := &Config{} + if err := applyEnvOverrides(cfg); err != nil { + return nil, true, err + } + return cfg, true, nil +} + +// applyEnvOverrides copies SEC-related environment variable values into cfg. +func applyEnvOverrides(cfg *Config) error { + if v, ok := os.LookupEnv(envvars.CliSecEnable); ok { + b, err := parseBoolEnv(envvars.CliSecEnable, v) + if err != nil { + return err + } + cfg.Enable = b + } + if v, ok := os.LookupEnv(envvars.CliSecAuth); ok { + b, err := parseBoolEnv(envvars.CliSecAuth, v) + if err != nil { + return err + } + cfg.Auth = b + } + if v, ok := os.LookupEnv(envvars.CliSecProxy); ok { + cfg.Proxy = v + } + if v, ok := os.LookupEnv(envvars.CliSecCA); ok { + cfg.CAPath = v + } + return nil +} + +// parseBoolEnv accepts common boolean spellings used in environment variables. +func parseBoolEnv(name, raw string) (bool, error) { + s := strings.TrimSpace(strings.ToLower(raw)) + if s == "" { + // Treat empty as false when explicitly present. + return false, nil + } + switch s { + case "1", "true", "on", "yes", "y": + return true, nil + case "0", "false", "off", "no", "n": + return false, nil + } + if b, err := strconv.ParseBool(s); err == nil { + return b, nil + } + return false, fmt.Errorf("invalid %s %q (want true/false/1/0)", name, raw) +} + +// proxyURL validates the fixed SEC proxy configuration and returns its URL. +func (c *Config) proxyURL() (*url.URL, error) { + raw := strings.TrimSpace(c.Proxy) + if raw == "" { + return nil, fmt.Errorf("%s is empty", envvars.CliSecProxy) + } + redacted := redactProxyURL(raw) + u, err := url.Parse(raw) + if err != nil { + return nil, fmt.Errorf("invalid %s %q: %w", envvars.CliSecProxy, redacted, err) + } + if u.Scheme != "http" { + return nil, fmt.Errorf("invalid %s %q: scheme must be http", envvars.CliSecProxy, redacted) + } + if u.Host == "" { + return nil, fmt.Errorf("invalid %s %q: missing host", envvars.CliSecProxy, redacted) + } + // Security hardening: only allow a loopback proxy. This prevents accidental + // cross-machine proxying of credentials/traffic. + if u.Hostname() != "127.0.0.1" { + return nil, fmt.Errorf("invalid %s %q: host must be 127.0.0.1", envvars.CliSecProxy, redacted) + } + if u.Port() == "" { + return nil, fmt.Errorf("invalid %s %q: explicit port is required", envvars.CliSecProxy, redacted) + } + if u.Path != "" && u.Path != "/" { + return nil, fmt.Errorf("invalid %s %q: path is not allowed", envvars.CliSecProxy, redacted) + } + if u.RawQuery != "" { + return nil, fmt.Errorf("invalid %s %q: query is not allowed", envvars.CliSecProxy, redacted) + } + if u.Fragment != "" { + return nil, fmt.Errorf("invalid %s %q: fragment is not allowed", envvars.CliSecProxy, redacted) + } + return u, nil +} + +// redactProxyURL masks userinfo (username:password) in a proxy URL. +// Handles both scheme-prefixed ("http://user:pass@host") and bare formats. +func redactProxyURL(raw string) string { + u, err := url.Parse(raw) + if err == nil && u.User != nil { + u.User = url.User("***") + return u.String() + } + // Fallback: handle "user:pass@proxy:8080" + if at := strings.LastIndex(raw, "@"); at > 0 { + return "***@" + raw[at+1:] + } + return raw +} + +// ApplyToTransport clones base and applies SEC plugin settings to the clone. +// Caller owns the returned *http.Transport. +func (c *Config) ApplyToTransport(base *http.Transport) (*http.Transport, error) { + if base == nil { + base = http.DefaultTransport.(*http.Transport) + } + u, err := c.proxyURL() + if err != nil { + return nil, err + } + + t := base.Clone() + t.Proxy = http.ProxyURL(u) // fixed proxy overrides environment proxy vars + if err := applyExtraRootCA(t, c.CAPath); err != nil { + return nil, err + } + return t, nil +} diff --git a/internal/secplugin/config_test.go b/internal/secplugin/config_test.go new file mode 100644 index 000000000..7d6116481 --- /dev/null +++ b/internal/secplugin/config_test.go @@ -0,0 +1,245 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package secplugin + +import ( + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "sync" + "testing" + + "github.com/larksuite/cli/internal/envvars" +) + +// unsetEnv clears key for the duration of the test and restores its original value. +func unsetEnv(t *testing.T, key string) { + t.Helper() + old, had := os.LookupEnv(key) + _ = os.Unsetenv(key) + t.Cleanup(func() { + if had { + _ = os.Setenv(key, old) + } else { + _ = os.Unsetenv(key) + } + }) +} + +// unsetSecPluginEnv clears SEC-related environment variables for deterministic tests. +func unsetSecPluginEnv(t *testing.T) { + t.Helper() + unsetEnv(t, envvars.CliSecEnable) + unsetEnv(t, envvars.CliSecProxy) + unsetEnv(t, envvars.CliSecCA) + unsetEnv(t, envvars.CliSecAuth) +} + +// writeFile creates parent directories and writes test data for fixtures. +func writeFile(t *testing.T, path string, data []byte, perm os.FileMode) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := os.WriteFile(path, data, perm); err != nil { + t.Fatalf("WriteFile: %v", err) + } +} + +// TestLoad_MissingFileReturnsNil verifies that Load reports no config when no file +// or SEC environment overrides exist. +func TestLoad_MissingFileReturnsNil(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + loadOnce = sync.Once{} + loadCfg = nil + loadErr = nil + unsetSecPluginEnv(t) + // Reset cache for this test run by forking package state: + // Load() uses sync.Once; the repository test runner executes packages + // in separate processes, so this is safe without manual reset. + cfg, err := Load() + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if cfg != nil { + t.Fatalf("Load() = %#v, want nil (missing file)", cfg) + } +} + +// TestApplyToTransport_SetsProxy verifies that a valid SEC config installs a fixed proxy. +func TestApplyToTransport_SetsProxy(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + loadOnce = sync.Once{} + loadCfg = nil + loadErr = nil + unsetSecPluginEnv(t) + + cfgPath := Path() + writeFile(t, cfgPath, []byte(`{ + "LARKSUITE_CLI_SEC_ENABLE": true, + "LARKSUITE_CLI_SEC_PROXY": "http://127.0.0.1:3128", + "LARKSUITE_CLI_SEC_CA": "", + "LARKSUITE_CLI_SEC_AUTH": false +}`), 0600) + + cfg, err := Load() + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if cfg == nil || !cfg.Enabled() { + t.Fatalf("cfg.Enabled() = %v, want true", cfg) + } + + base := http.DefaultTransport.(*http.Transport) + tr, err := cfg.ApplyToTransport(base) + if err != nil { + t.Fatalf("ApplyToTransport() error = %v", err) + } + if tr.Proxy == nil { + t.Fatal("Proxy func is nil, want fixed proxy") + } + u, err := tr.Proxy(&http.Request{URL: &url.URL{Scheme: "https", Host: "open.feishu.cn"}}) + if err != nil { + t.Fatalf("Proxy() error = %v", err) + } + if u == nil || u.String() != "http://127.0.0.1:3128" { + t.Fatalf("Proxy() = %v, want http://127.0.0.1:3128", u) + } +} + +// TestLoad_RejectsNonLoopbackProxy verifies that SEC mode rejects non-loopback proxies. +func TestLoad_RejectsNonLoopbackProxy(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + loadOnce = sync.Once{} + loadCfg = nil + loadErr = nil + unsetSecPluginEnv(t) + + cfgPath := Path() + writeFile(t, cfgPath, []byte(`{ + "LARKSUITE_CLI_SEC_ENABLE": true, + "LARKSUITE_CLI_SEC_PROXY": "http://10.0.0.1:3128", + "LARKSUITE_CLI_SEC_CA": "", + "LARKSUITE_CLI_SEC_AUTH": false +}`), 0600) + + cfg, err := Load() + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if cfg == nil || !cfg.Enabled() { + t.Fatalf("cfg.Enabled() = %v, want true", cfg) + } + _, err = cfg.ApplyToTransport(http.DefaultTransport.(*http.Transport)) + if err == nil { + t.Fatal("ApplyToTransport() error = nil, want invalid proxy host error") + } +} + +// TestConfig_ProxyURLRejectsUnsupportedParts verifies the SEC proxy validator +// rejects URLs with missing ports, queries, and fragments. +func TestConfig_ProxyURLRejectsUnsupportedParts(t *testing.T) { + cases := []struct { + name string + raw string + want string + }{ + { + name: "missing explicit port", + raw: "http://127.0.0.1", + want: "explicit port is required", + }, + { + name: "query string", + raw: "http://127.0.0.1:3128?foo=bar", + want: "query is not allowed", + }, + { + name: "fragment", + raw: "http://127.0.0.1:3128#frag", + want: "fragment is not allowed", + }, + } + + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + _, err := (&Config{Proxy: tt.raw}).proxyURL() + if err == nil { + t.Fatalf("proxyURL() error = nil, want substring %q", tt.want) + } + if !strings.Contains(err.Error(), tt.want) { + t.Fatalf("proxyURL() error = %q, want substring %q", err, tt.want) + } + }) + } +} + +// TestLoad_EnvOnlyConfig verifies that SEC settings can come entirely from environment variables. +func TestLoad_EnvOnlyConfig(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + loadOnce = sync.Once{} + loadCfg = nil + loadErr = nil + + t.Setenv(envvars.CliSecEnable, "true") + t.Setenv(envvars.CliSecProxy, "http://127.0.0.1:7777") + t.Setenv(envvars.CliSecCA, "") + t.Setenv(envvars.CliSecAuth, "true") + + cfg, err := Load() + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if cfg == nil || !cfg.Enabled() { + t.Fatalf("cfg.Enabled() = %v, want true", cfg) + } + if !cfg.AuthEnabled() { + t.Fatalf("cfg.AuthEnabled() = false, want true") + } + tr, err := cfg.ApplyToTransport(http.DefaultTransport.(*http.Transport)) + if err != nil { + t.Fatalf("ApplyToTransport() error = %v", err) + } + u, err := tr.Proxy(&http.Request{URL: &url.URL{Scheme: "https", Host: "open.feishu.cn"}}) + if err != nil { + t.Fatalf("Proxy() error = %v", err) + } + if u == nil || u.String() != "http://127.0.0.1:7777" { + t.Fatalf("Proxy() = %v, want http://127.0.0.1:7777", u) + } +} + +// TestLoad_EnvOverridesFile verifies that SEC environment variables override file values. +func TestLoad_EnvOverridesFile(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + loadOnce = sync.Once{} + loadCfg = nil + loadErr = nil + + // File enables with one proxy. + cfgPath := Path() + writeFile(t, cfgPath, []byte(`{ + "LARKSUITE_CLI_SEC_ENABLE": true, + "LARKSUITE_CLI_SEC_PROXY": "http://127.0.0.1:3128", + "LARKSUITE_CLI_SEC_CA": "", + "LARKSUITE_CLI_SEC_AUTH": false +}`), 0600) + + // Env overrides: disable + different proxy (should be irrelevant once disabled). + t.Setenv(envvars.CliSecEnable, "false") + t.Setenv(envvars.CliSecProxy, "http://127.0.0.1:9999") + + cfg, err := Load() + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if cfg == nil { + t.Fatalf("Load() = nil, want non-nil (file exists)") + } + if cfg.Enabled() { + t.Fatalf("cfg.Enabled() = true, want false (env override)") + } +} diff --git a/internal/secplugin/tls_ca.go b/internal/secplugin/tls_ca.go new file mode 100644 index 000000000..35fec8243 --- /dev/null +++ b/internal/secplugin/tls_ca.go @@ -0,0 +1,51 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package secplugin + +import ( + "crypto/tls" + "crypto/x509" + "fmt" + "net/http" + "path/filepath" + "strings" + + "github.com/larksuite/cli/internal/envvars" + "github.com/larksuite/cli/internal/vfs" +) + +// applyExtraRootCA augments t with an additional PEM bundle used for SEC proxy +// TLS interception. +func applyExtraRootCA(t *http.Transport, caPath string) error { + caPath = strings.TrimSpace(caPath) + if caPath == "" { + return nil + } + if !filepath.IsAbs(caPath) { + return fmt.Errorf("invalid %s %q: must be an absolute path to a PEM file", envvars.CliSecCA, caPath) + } + pemBytes, err := vfs.ReadFile(caPath) + if err != nil { + return fmt.Errorf("failed to read %s %q: %w", envvars.CliSecCA, caPath, err) + } + + // Start from system pool when possible; if unavailable, create a new pool. + pool, _ := x509.SystemCertPool() + if pool == nil { + pool = x509.NewCertPool() + } + if ok := pool.AppendCertsFromPEM(pemBytes); !ok { + return fmt.Errorf("invalid %s %q: no certificates parsed from PEM", envvars.CliSecCA, caPath) + } + + if t.TLSClientConfig == nil { + t.TLSClientConfig = &tls.Config{} + } else { + // Clone to avoid mutating shared config from the base transport. + t.TLSClientConfig = t.TLSClientConfig.Clone() + } + t.TLSClientConfig.MinVersion = tls.VersionTLS12 + t.TLSClientConfig.RootCAs = pool + return nil +} diff --git a/internal/secplugin/tls_ca_test.go b/internal/secplugin/tls_ca_test.go new file mode 100644 index 000000000..0904b5d50 --- /dev/null +++ b/internal/secplugin/tls_ca_test.go @@ -0,0 +1,138 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package secplugin + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" + "net/http" + "path/filepath" + "strings" + "testing" + "time" +) + +// mustCreateTestCertPEM generates a short-lived self-signed CA certificate for tests. +func mustCreateTestCertPEM(t *testing.T) []byte { + t.Helper() + + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("GenerateKey() error = %v", err) + } + + der, err := x509.CreateCertificate(rand.Reader, &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{ + CommonName: "secplugin-test-ca", + }, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, + IsCA: true, + BasicConstraintsValid: true, + }, &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{ + CommonName: "secplugin-test-ca", + }, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, + IsCA: true, + BasicConstraintsValid: true, + }, &key.PublicKey, key) + if err != nil { + t.Fatalf("CreateCertificate() error = %v", err) + } + + return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) +} + +// TestApplyExtraRootCA_EmptyPathIsNoop verifies that an empty CA path leaves the transport unchanged. +func TestApplyExtraRootCA_EmptyPathIsNoop(t *testing.T) { + tr := &http.Transport{} + + if err := applyExtraRootCA(tr, " "); err != nil { + t.Fatalf("applyExtraRootCA() error = %v", err) + } + if tr.TLSClientConfig != nil { + t.Fatalf("TLSClientConfig = %#v, want nil", tr.TLSClientConfig) + } +} + +// TestApplyExtraRootCA_RejectsRelativePath verifies that CA paths must be absolute. +func TestApplyExtraRootCA_RejectsRelativePath(t *testing.T) { + tr := &http.Transport{} + + err := applyExtraRootCA(tr, "ca.pem") + if err == nil || !strings.Contains(err.Error(), "must be an absolute path") { + t.Fatalf("applyExtraRootCA() error = %v, want absolute-path error", err) + } +} + +// TestApplyExtraRootCA_RejectsMissingFile verifies read errors for missing PEM bundles. +func TestApplyExtraRootCA_RejectsMissingFile(t *testing.T) { + tr := &http.Transport{} + + err := applyExtraRootCA(tr, filepath.Join(t.TempDir(), "missing.pem")) + if err == nil || !strings.Contains(err.Error(), "failed to read") { + t.Fatalf("applyExtraRootCA() error = %v, want read error", err) + } +} + +// TestApplyExtraRootCA_RejectsInvalidPEM verifies validation of malformed PEM bundles. +func TestApplyExtraRootCA_RejectsInvalidPEM(t *testing.T) { + caPath := filepath.Join(t.TempDir(), "invalid.pem") + writeFile(t, caPath, []byte("not a pem"), 0600) + + tr := &http.Transport{} + err := applyExtraRootCA(tr, caPath) + if err == nil || !strings.Contains(err.Error(), "no certificates parsed from PEM") { + t.Fatalf("applyExtraRootCA() error = %v, want invalid PEM error", err) + } +} + +// TestApplyExtraRootCA_SetsTLSConfigWhenMissing verifies initialization of TLSClientConfig when absent. +func TestApplyExtraRootCA_SetsTLSConfigWhenMissing(t *testing.T) { + caPath := filepath.Join(t.TempDir(), "ca.pem") + writeFile(t, caPath, mustCreateTestCertPEM(t), 0600) + + tr := &http.Transport{} + if err := applyExtraRootCA(tr, caPath); err != nil { + t.Fatalf("applyExtraRootCA() error = %v", err) + } + if tr.TLSClientConfig == nil { + t.Fatal("TLSClientConfig = nil, want initialized config") + } + if tr.TLSClientConfig.RootCAs == nil { + t.Fatal("RootCAs = nil, want cert pool") + } +} + +// TestApplyExtraRootCA_ClonesExistingTLSConfig verifies cloning when the base transport already has TLS settings. +func TestApplyExtraRootCA_ClonesExistingTLSConfig(t *testing.T) { + caPath := filepath.Join(t.TempDir(), "ca.pem") + writeFile(t, caPath, mustCreateTestCertPEM(t), 0600) + + original := &tls.Config{ServerName: "open.feishu.cn"} + tr := &http.Transport{TLSClientConfig: original} + if err := applyExtraRootCA(tr, caPath); err != nil { + t.Fatalf("applyExtraRootCA() error = %v", err) + } + if tr.TLSClientConfig == original { + t.Fatal("TLSClientConfig pointer reused, want clone") + } + if tr.TLSClientConfig.ServerName != original.ServerName { + t.Fatalf("ServerName = %q, want %q", tr.TLSClientConfig.ServerName, original.ServerName) + } + if tr.TLSClientConfig.RootCAs == nil { + t.Fatal("RootCAs = nil, want cert pool") + } +} diff --git a/internal/util/proxy.go b/internal/util/proxy.go index d9e251859..845877da4 100644 --- a/internal/util/proxy.go +++ b/internal/util/proxy.go @@ -11,8 +11,11 @@ import ( "os" "strings" "sync" + + "github.com/larksuite/cli/internal/secplugin" ) +// Proxy environment constants control shared transport proxy behavior. const ( // EnvNoProxy disables automatic proxy support when set to any non-empty value. EnvNoProxy = "LARK_CLI_NO_PROXY" @@ -36,6 +39,7 @@ func DetectProxyEnv() (key, value string) { return "", "" } +// proxyWarningOnce ensures proxy environment warnings are emitted at most once. var proxyWarningOnce sync.Once // redactProxyURL masks userinfo (username:password) in a proxy URL. @@ -84,6 +88,31 @@ var noProxyTransport = sync.OnceValue(func() *http.Transport { return t }) +// secProxyTransport is a fixed-proxy clone of http.DefaultTransport (with optional +// custom root CA), lazily built on first use when sec plugin mode is enabled. +var secProxyTransport = sync.OnceValue(func() *http.Transport { + def, ok := http.DefaultTransport.(*http.Transport) + if !ok { + return &http.Transport{} + } + + cfg, err := secplugin.Load() + if err != nil || cfg == nil || !cfg.Enabled() { + return def + } + t, err := cfg.ApplyToTransport(def) + if err != nil { + // Fail closed: do not silently fall back to direct egress when the + // operator explicitly enabled SEC plugin mode. + blocked := def.Clone() + blocked.Proxy = func(*http.Request) (*url.URL, error) { + return nil, fmt.Errorf("sec plugin enabled but config is invalid: %v", err) + } + return blocked + } + return t +}) + // SharedTransport returns the base http.RoundTripper for CLI HTTP clients. // // By default it returns http.DefaultTransport — the stdlib-provided @@ -99,6 +128,23 @@ var noProxyTransport = sync.OnceValue(func() *http.Transport { // goroutines are reused; cloning per call leaks them until IdleConnTimeout // (~90s) fires. func SharedTransport() http.RoundTripper { + // SEC plugin mode overrides all other proxy behavior (env proxies and + // LARK_CLI_NO_PROXY), per operator intent. + if cfg, err := secplugin.Load(); err != nil { + // Fail closed: if the config file exists but is malformed/unreadable, + // do not silently fall back to direct egress. + def, ok := http.DefaultTransport.(*http.Transport) + if !ok { + return http.DefaultTransport + } + blocked := def.Clone() + blocked.Proxy = func(*http.Request) (*url.URL, error) { + return nil, fmt.Errorf("sec plugin config is invalid: %v", err) + } + return blocked + } else if cfg != nil && cfg.Enabled() { + return secProxyTransport() + } if os.Getenv(EnvNoProxy) != "" { return noProxyTransport() } diff --git a/internal/util/proxy_test.go b/internal/util/proxy_test.go index f78720963..ffef49230 100644 --- a/internal/util/proxy_test.go +++ b/internal/util/proxy_test.go @@ -6,11 +6,43 @@ package util import ( "bytes" "net/http" + "os" "sync" "testing" + + "github.com/larksuite/cli/internal/envvars" ) +// unsetEnv clears key for the duration of the test and restores its original value. +func unsetEnv(t *testing.T, key string) { + t.Helper() + old, had := os.LookupEnv(key) + _ = os.Unsetenv(key) + t.Cleanup(func() { + if had { + _ = os.Setenv(key, old) + } else { + _ = os.Unsetenv(key) + } + }) +} + +// unsetSecPluginEnv clears SEC-related environment variables for deterministic tests. +func unsetSecPluginEnv(t *testing.T) { + t.Helper() + // Ensure developer machine env doesn't accidentally enable SEC plugin mode + // and change expectations for SharedTransport(). + unsetEnv(t, envvars.CliSecEnable) + unsetEnv(t, envvars.CliSecProxy) + unsetEnv(t, envvars.CliSecCA) + unsetEnv(t, envvars.CliSecAuth) +} + +// TestDetectProxyEnv verifies proxy environment detection priority and empty-state behavior. func TestDetectProxyEnv(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + unsetSecPluginEnv(t) + // Clear all proxy env vars first for _, k := range proxyEnvKeys { t.Setenv(k, "") @@ -28,7 +60,10 @@ func TestDetectProxyEnv(t *testing.T) { } } +// TestSharedTransport_DefaultReturnsStdlibSingleton verifies the default shared transport. func TestSharedTransport_DefaultReturnsStdlibSingleton(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + unsetSecPluginEnv(t) t.Setenv(EnvNoProxy, "") tr := SharedTransport() if tr != http.DefaultTransport { @@ -36,7 +71,10 @@ func TestSharedTransport_DefaultReturnsStdlibSingleton(t *testing.T) { } } +// TestSharedTransport_NoProxyReturnsClone verifies that disabling proxying returns a cloned transport. func TestSharedTransport_NoProxyReturnsClone(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + unsetSecPluginEnv(t) t.Setenv(EnvNoProxy, "1") tr := SharedTransport() if tr == http.DefaultTransport { @@ -51,7 +89,10 @@ func TestSharedTransport_NoProxyReturnsClone(t *testing.T) { } } +// TestSharedTransport_NoProxyIsCachedSingleton verifies singleton caching for the no-proxy transport. func TestSharedTransport_NoProxyIsCachedSingleton(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + unsetSecPluginEnv(t) t.Setenv(EnvNoProxy, "1") a := SharedTransport() b := SharedTransport() @@ -60,7 +101,10 @@ func TestSharedTransport_NoProxyIsCachedSingleton(t *testing.T) { } } +// TestSharedTransport_EnvUnsetAfterSetFallsBackToDefault verifies fallback to the stdlib transport after unsetting EnvNoProxy. func TestSharedTransport_EnvUnsetAfterSetFallsBackToDefault(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + unsetSecPluginEnv(t) // Simulate a process that first runs with LARK_CLI_NO_PROXY=1 (populating // the no-proxy singleton), then unsets it. Subsequent calls must return // http.DefaultTransport, NOT the cached no-proxy clone. @@ -77,7 +121,10 @@ func TestSharedTransport_EnvUnsetAfterSetFallsBackToDefault(t *testing.T) { } } +// TestSharedTransport_NoProxyOverridesSystemProxy verifies that EnvNoProxy disables system proxies. func TestSharedTransport_NoProxyOverridesSystemProxy(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + unsetSecPluginEnv(t) t.Setenv("HTTPS_PROXY", "http://should-be-ignored:8888") t.Setenv(EnvNoProxy, "1") @@ -90,7 +137,10 @@ func TestSharedTransport_NoProxyOverridesSystemProxy(t *testing.T) { } } +// TestWarnIfProxied_WithProxy verifies that proxy detection emits a warning. func TestWarnIfProxied_WithProxy(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + unsetSecPluginEnv(t) // Reset the once guard for this test proxyWarningOnce = sync.Once{} @@ -111,7 +161,10 @@ func TestWarnIfProxied_WithProxy(t *testing.T) { } } +// TestWarnIfProxied_WithoutProxy verifies that no warning is emitted without proxy settings. func TestWarnIfProxied_WithoutProxy(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + unsetSecPluginEnv(t) proxyWarningOnce = sync.Once{} for _, k := range proxyEnvKeys { @@ -126,7 +179,10 @@ func TestWarnIfProxied_WithoutProxy(t *testing.T) { } } +// TestWarnIfProxied_SilentWhenDisabled verifies that EnvNoProxy suppresses warnings. func TestWarnIfProxied_SilentWhenDisabled(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + unsetSecPluginEnv(t) proxyWarningOnce = sync.Once{} t.Setenv("HTTPS_PROXY", "http://proxy:8080") @@ -140,7 +196,10 @@ func TestWarnIfProxied_SilentWhenDisabled(t *testing.T) { } } +// TestWarnIfProxied_OnlyOnce verifies that proxy warnings are emitted only once. func TestWarnIfProxied_OnlyOnce(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + unsetSecPluginEnv(t) proxyWarningOnce = sync.Once{} t.Setenv("HTTP_PROXY", "http://proxy:1234") @@ -160,7 +219,10 @@ func TestWarnIfProxied_OnlyOnce(t *testing.T) { } } +// TestRedactProxyURL verifies redaction of proxy credentials across supported formats. func TestRedactProxyURL(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + unsetSecPluginEnv(t) tests := []struct { input string want string @@ -183,7 +245,10 @@ func TestRedactProxyURL(t *testing.T) { } } +// TestWarnIfProxied_RedactsCredentials verifies that warning output never leaks credentials. func TestWarnIfProxied_RedactsCredentials(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + unsetSecPluginEnv(t) proxyWarningOnce = sync.Once{} t.Setenv("HTTPS_PROXY", "http://admin:s3cret@proxy:8080") diff --git a/main.go b/main.go index 02469bd7a..5bc94890c 100644 --- a/main.go +++ b/main.go @@ -9,7 +9,8 @@ import ( "github.com/larksuite/cli/cmd" - _ "github.com/larksuite/cli/extension/credential/env" // activate env credential provider + _ "github.com/larksuite/cli/extension/credential/env" // activate env credential provider + _ "github.com/larksuite/cli/extension/credential/secplugin" // activate sec plugin credential provider (SEC_AUTH placeholder tokens) ) func main() {