From 2286937366862c8a4bfb6e43bffa02608377d465 Mon Sep 17 00:00:00 2001 From: zhaojunchang Date: Thu, 23 Apr 2026 17:06:53 +0800 Subject: [PATCH 1/4] feat(secplugin): add security plugin for proxy and auth token handling --- extension/credential/secplugin/provider.go | 227 ++++++++ .../credential/secplugin/provider_test.go | 486 ++++++++++++++++++ internal/envvars/envvars.go | 45 +- internal/secplugin/README.md | 135 +++++ internal/secplugin/README.zh-CN.md | 130 +++++ internal/secplugin/config.go | 277 ++++++++++ internal/secplugin/config_test.go | 245 +++++++++ internal/secplugin/tls_ca.go | 51 ++ internal/secplugin/tls_ca_test.go | 138 +++++ internal/util/proxy.go | 46 ++ internal/util/proxy_test.go | 65 +++ main.go | 3 +- 12 files changed, 1838 insertions(+), 10 deletions(-) create mode 100644 extension/credential/secplugin/provider.go create mode 100644 extension/credential/secplugin/provider_test.go create mode 100644 internal/secplugin/README.md create mode 100644 internal/secplugin/README.zh-CN.md create mode 100644 internal/secplugin/config.go create mode 100644 internal/secplugin/config_test.go create mode 100644 internal/secplugin/tls_ca.go create mode 100644 internal/secplugin/tls_ca_test.go diff --git a/extension/credential/secplugin/provider.go b/extension/credential/secplugin/provider.go new file mode 100644 index 0000000000..a9280a9f9b --- /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 0000000000..08fb5b2a9b --- /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 41560ec9d2..032b398342 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 0000000000..f38fcbe03c --- /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 0000000000..df2011b709 --- /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 0000000000..db660b9225 --- /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 0000000000..d5e3de085a --- /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) + // TestLoad_MissingFileReturnsNil must reset loadOnce, loadCfg, and loadErr + // because multiple tests in this package share the package-level Load() + // cache via sync.Once. + 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 0000000000..35fec8243a --- /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 0000000000..0904b5d50b --- /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 d9e251859d..845877da4b 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 f78720963d..ffef49230b 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 02469bd7a0..5bc94890c5 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() { From 6c41d127927e8332a32e6c8dc255e14b6c854d81 Mon Sep 17 00:00:00 2001 From: AlbertSun Date: Tue, 19 May 2026 22:24:36 +0800 Subject: [PATCH 2/4] feat(sec): add lark-sec-cli bootstrap install lifecycle Scaffold the lark-cli sec subsystem: the `sec` command tree (install, run, stop, status, config init) and the internal/sec package that drives it. The bootstrap manifest is embedded at build time as JSON, mapping (platform, arch, region) to download URLs. The installer resolves the right artifact for the current host, downloads with optional SHA256 verification, extracts into versions//, swaps the `current` symlink atomically (copy on Windows), and writes state.json. `sec run` enables the binary as a user-level system service (launchd / systemd-user / registry+VBS) so the OS supervises restarts. After this first install, lark-sec-cli takes over its own upgrade lifecycle. --- cmd/build.go | 2 + cmd/sec/config_init.go | 244 +++++++++++++++++++++++++++++++++ cmd/sec/factory.go | 25 ++++ cmd/sec/install.go | 62 +++++++++ cmd/sec/run.go | 118 ++++++++++++++++ cmd/sec/sec.go | 40 ++++++ cmd/sec/sec_test.go | 57 ++++++++ cmd/sec/status.go | 108 +++++++++++++++ cmd/sec/stop.go | 75 ++++++++++ internal/sec/archive.go | 138 +++++++++++++++++++ internal/sec/archive_test.go | 121 ++++++++++++++++ internal/sec/bootstrap.go | 33 +++++ internal/sec/bootstrap.json | 59 ++++++++ internal/sec/bootstrap_test.go | 58 ++++++++ internal/sec/download.go | 156 +++++++++++++++++++++ internal/sec/download_test.go | 184 +++++++++++++++++++++++++ internal/sec/install.go | 241 ++++++++++++++++++++++++++++++++ internal/sec/manifest.go | 139 +++++++++++++++++++ internal/sec/manifest_test.go | 108 +++++++++++++++ internal/sec/paths.go | 132 ++++++++++++++++++ internal/sec/paths_test.go | 51 +++++++ internal/sec/state.go | 79 +++++++++++ internal/sec/state_test.go | 48 +++++++ 23 files changed, 2278 insertions(+) create mode 100644 cmd/sec/config_init.go create mode 100644 cmd/sec/factory.go create mode 100644 cmd/sec/install.go create mode 100644 cmd/sec/run.go create mode 100644 cmd/sec/sec.go create mode 100644 cmd/sec/sec_test.go create mode 100644 cmd/sec/status.go create mode 100644 cmd/sec/stop.go create mode 100644 internal/sec/archive.go create mode 100644 internal/sec/archive_test.go create mode 100644 internal/sec/bootstrap.go create mode 100644 internal/sec/bootstrap.json create mode 100644 internal/sec/bootstrap_test.go create mode 100644 internal/sec/download.go create mode 100644 internal/sec/download_test.go create mode 100644 internal/sec/install.go create mode 100644 internal/sec/manifest.go create mode 100644 internal/sec/manifest_test.go create mode 100644 internal/sec/paths.go create mode 100644 internal/sec/paths_test.go create mode 100644 internal/sec/state.go create mode 100644 internal/sec/state_test.go diff --git a/cmd/build.go b/cmd/build.go index a748544b02..b84769aead 100644 --- a/cmd/build.go +++ b/cmd/build.go @@ -15,6 +15,7 @@ import ( cmdevent "github.com/larksuite/cli/cmd/event" "github.com/larksuite/cli/cmd/profile" "github.com/larksuite/cli/cmd/schema" + "github.com/larksuite/cli/cmd/sec" "github.com/larksuite/cli/cmd/service" cmdupdate "github.com/larksuite/cli/cmd/update" _ "github.com/larksuite/cli/events" @@ -133,6 +134,7 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B rootCmd.AddCommand(completion.NewCmdCompletion(f)) rootCmd.AddCommand(cmdupdate.NewCmdUpdate(f)) rootCmd.AddCommand(cmdevent.NewCmdEvents(f)) + rootCmd.AddCommand(sec.NewCmdSec(f)) service.RegisterServiceCommandsWithContext(ctx, rootCmd, f) shortcuts.RegisterShortcutsWithContext(ctx, rootCmd, f) diff --git a/cmd/sec/config_init.go b/cmd/sec/config_init.go new file mode 100644 index 0000000000..28e652bc59 --- /dev/null +++ b/cmd/sec/config_init.go @@ -0,0 +1,244 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sec + +import ( + "bytes" + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strconv" + "time" + + "github.com/charmbracelet/huh" + "github.com/spf13/cobra" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/output" +) + +// NewCmdSecConfig is the parent for `lark-cli sec config `. Currently +// it only carries `init`; future verbs (e.g. `show`, `reset`) plug in here. +func NewCmdSecConfig(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "config", + Short: "Manage lark-sec-cli daemon configuration", + } + cmd.AddCommand(NewCmdSecConfigInit(f, nil)) + return cmd +} + +// ConfigInitOptions holds inputs for `lark-cli sec config init`. +type ConfigInitOptions struct { + Factory *cmdutil.Factory + AppID string + AppSecret string + Brand string + Yes bool // skip the interactive form when all required values are provided +} + +// NewCmdSecConfigInit collects App ID / App Secret / Brand from the user and +// registers them with the running lark-sec-cli daemon's admin endpoint. The +// daemon stashes the secret in the OS keychain and switches into sidecar mode +// for SEC_AUTH credential isolation. +func NewCmdSecConfigInit(f *cmdutil.Factory, runF func(*ConfigInitOptions) error) *cobra.Command { + opts := &ConfigInitOptions{Factory: f} + cmd := &cobra.Command{ + Use: "init", + Short: "Register a Lark App with the running lark-sec-cli daemon", + Long: `Register an App ID / App Secret with the lark-sec-cli daemon. + +The daemon must already be running (start it with "lark-cli sec run"). The +registration POSTs to /_sec/api/v1/register-app on the local proxy port, +HMAC-signed with the daemon's proxy.key.`, + RunE: func(cmd *cobra.Command, args []string) error { + if runF != nil { + return runF(opts) + } + return runConfigInit(cmd, opts) + }, + } + cmd.Flags().StringVar(&opts.AppID, "app-id", "", "App ID (skips the prompt when set)") + cmd.Flags().StringVar(&opts.AppSecret, "app-secret", "", "App Secret (skips the prompt when set)") + cmd.Flags().StringVar(&opts.Brand, "brand", "feishu", "feishu or lark") + cmd.Flags().BoolVarP(&opts.Yes, "yes", "y", false, "skip the interactive form when all required values are provided") + return cmd +} + +// secBridge mirrors what the daemon writes to ~/.lark-cli/sec_config.json. +// It's the single contract between lark-cli and lark-sec-cli at runtime — +// we don't reach into lark-sec-cli internals, only what it chooses to publish. +type secBridge struct { + Enable bool `json:"LARKSUITE_CLI_SEC_ENABLE"` + Proxy string `json:"LARKSUITE_CLI_SEC_PROXY"` + CA string `json:"LARKSUITE_CLI_SEC_CA"` + Auth bool `json:"LARKSUITE_CLI_SEC_AUTH"` +} + +func runConfigInit(cmd *cobra.Command, opts *ConfigInitOptions) error { + bridge, err := loadBridge() + if err != nil { + return output.ErrWithHint(output.ExitValidation, "sec_bridge_missing", + fmt.Sprintf("daemon bridge file unreadable: %v", err), + "Start the daemon first: `lark-cli sec run`.") + } + if !bridge.Enable || bridge.Proxy == "" { + return output.ErrWithHint(output.ExitValidation, "sec_not_running", + "lark-sec-cli is not advertising an active proxy", + "Run `lark-cli sec run` to start it.") + } + + // The HMAC key sits next to the CA in the daemon's config dir. Deriving + // from the bridge's SEC_CA path keeps lark-cli decoupled from the daemon's + // install location — if the daemon ever moves, the bridge follows and we + // follow with it. + hmacKey, err := readHMACKey(bridge.CA) + if err != nil { + return output.Errorf(output.ExitInternal, "sec_hmac_key", "read daemon HMAC key: %v", err) + } + + if err := promptForMissing(opts); err != nil { + return err + } + + if err := registerApp(cmd.Context(), bridge.Proxy, hmacKey, opts.AppID, opts.AppSecret, opts.Brand); err != nil { + return output.Errorf(output.ExitAPI, "sec_register_app", "register-app: %v", err) + } + + output.PrintSuccess(opts.Factory.IOStreams.ErrOut, + fmt.Sprintf("registered app %s with lark-sec-cli (%s)", opts.AppID, opts.Brand)) + return nil +} + +// loadBridge reads the daemon-written sec_config.json from lark-cli's config dir. +func loadBridge() (*secBridge, error) { + path := filepath.Join(core.GetConfigDir(), "sec_config.json") + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var b secBridge + if err := json.Unmarshal(data, &b); err != nil { + return nil, fmt.Errorf("parse %s: %w", path, err) + } + return &b, nil +} + +// readHMACKey returns the daemon's proxy.key bytes. The daemon writes the key +// hex-encoded (64 ASCII chars); we hex-decode here. If the file is a raw +// 32-byte blob (older daemon variants), we use it as-is. +func readHMACKey(caPath string) ([]byte, error) { + if caPath == "" { + return nil, errors.New("sec_config.json has no LARKSUITE_CLI_SEC_CA — can't locate proxy.key") + } + keyPath := filepath.Join(filepath.Dir(caPath), "proxy.key") + raw, err := os.ReadFile(keyPath) + if err != nil { + return nil, err + } + raw = bytes.TrimSpace(raw) + if len(raw) == 64 { + if decoded, err := hex.DecodeString(string(raw)); err == nil { + return decoded, nil + } + } + return raw, nil +} + +// promptForMissing fills in any of AppID / AppSecret / Brand the user didn't +// provide via flags. --yes refuses to prompt; that's caller error if any are +// still missing at that point. +func promptForMissing(opts *ConfigInitOptions) error { + if opts.AppID != "" && opts.AppSecret != "" && opts.Brand != "" { + return nil + } + if opts.Yes { + return output.ErrValidation("--yes set but missing one of --app-id / --app-secret / --brand") + } + + groups := []*huh.Group{} + if opts.AppID == "" { + groups = append(groups, huh.NewGroup( + huh.NewInput().Title("App ID").Placeholder("cli_xxxx").Value(&opts.AppID), + )) + } + if opts.AppSecret == "" { + groups = append(groups, huh.NewGroup( + huh.NewInput().Title("App Secret").EchoMode(huh.EchoModePassword).Value(&opts.AppSecret), + )) + } + if opts.Brand == "" { + opts.Brand = "feishu" + groups = append(groups, huh.NewGroup( + huh.NewSelect[string]().Title("Brand").Options( + huh.NewOption("Feishu (cn)", "feishu"), + huh.NewOption("Lark (intl)", "lark"), + ).Value(&opts.Brand), + )) + } + if len(groups) == 0 { + return nil + } + form := huh.NewForm(groups...).WithTheme(cmdutil.ThemeFeishu()) + if err := form.Run(); err != nil { + if errors.Is(err, huh.ErrUserAborted) { + return output.ErrBare(1) + } + return err + } + return nil +} + +// registerApp POSTs to /_sec/api/v1/register-app with the daemon's HMAC scheme. +// Canonical signing input is "method\npath\nsha256hex(body)\ntimestamp", per +// lark-sec-cli/internal/proxy/admin_handler.go's verifyHMAC. +func registerApp(ctx context.Context, proxyURL string, hmacKey []byte, appID, appSecret, brand string) error { + const path = "/_sec/api/v1/register-app" + + body, err := json.Marshal(map[string]string{ + "app_id": appID, + "app_secret": appSecret, + "brand": brand, + }) + if err != nil { + return err + } + + ts := strconv.FormatInt(time.Now().Unix(), 10) + bodyHash := sha256.Sum256(body) + canonical := http.MethodPost + "\n" + path + "\n" + hex.EncodeToString(bodyHash[:]) + "\n" + ts + mac := hmac.New(sha256.New, hmacKey) + mac.Write([]byte(canonical)) + sig := hex.EncodeToString(mac.Sum(nil)) + + reqCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, proxyURL+path, bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Lark-Admin-Signature", sig) + req.Header.Set("X-Lark-Admin-Timestamp", ts) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10)) + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + return nil + } + return fmt.Errorf("status %d: %s", resp.StatusCode, string(respBody)) +} diff --git a/cmd/sec/factory.go b/cmd/sec/factory.go new file mode 100644 index 0000000000..3bcc1a4eb6 --- /dev/null +++ b/cmd/sec/factory.go @@ -0,0 +1,25 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sec + +import ( + "fmt" + + "github.com/larksuite/cli/internal/cmdutil" + intsec "github.com/larksuite/cli/internal/sec" +) + +// installer wires up an internal/sec.Installer using the Factory's HTTP client +// and the default platform paths. Every cmd/sec subcommand starts here. +func installer(f *cmdutil.Factory) (*intsec.Installer, *intsec.Paths, error) { + paths, err := intsec.DefaultPaths() + if err != nil { + return nil, nil, fmt.Errorf("resolve sec paths: %w", err) + } + httpClient, err := f.HttpClient() + if err != nil { + return nil, nil, fmt.Errorf("resolve http client: %w", err) + } + return &intsec.Installer{Paths: paths, HTTPClient: httpClient}, paths, nil +} diff --git a/cmd/sec/install.go b/cmd/sec/install.go new file mode 100644 index 0000000000..4693e03268 --- /dev/null +++ b/cmd/sec/install.go @@ -0,0 +1,62 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sec + +import ( + "github.com/spf13/cobra" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/output" + intsec "github.com/larksuite/cli/internal/sec" +) + +// InstallOptions holds inputs for `lark-cli sec install`. +type InstallOptions struct { + Factory *cmdutil.Factory + Force bool +} + +// NewCmdSecInstall performs first-time bootstrap install of lark-sec-cli from +// the embedded release manifest. After install, lark-sec-cli is in charge of +// finding and applying its own updates — this command only handles the initial +// version-on-disk step. +func NewCmdSecInstall(f *cmdutil.Factory, runF func(*InstallOptions) error) *cobra.Command { + opts := &InstallOptions{Factory: f} + cmd := &cobra.Command{ + Use: "install", + Short: "Install lark-sec-cli (first-time bootstrap)", + Long: `Install the lark-sec-cli release pinned by this lark-cli build. + +The bootstrap manifest is embedded; no external release server is consulted. +Once installed, lark-sec-cli is responsible for its own upgrade lifecycle. + +Re-running is a no-op when an install already exists. Use --force to re-pin +the install back to the version this lark-cli build ships (e.g. for repair).`, + RunE: func(cmd *cobra.Command, args []string) error { + if runF != nil { + return runF(opts) + } + return runInstall(cmd, opts) + }, + } + cmd.Flags().BoolVar(&opts.Force, "force", false, "reinstall even when an install already exists") + return cmd +} + +func runInstall(cmd *cobra.Command, opts *InstallOptions) error { + inst, paths, err := installer(opts.Factory) + if err != nil { + return output.Errorf(output.ExitInternal, "internal", "%v", err) + } + state, err := inst.Install(cmd.Context(), intsec.InstallOptions{ + Force: opts.Force, + }) + if err != nil { + return output.Errorf(output.ExitNetwork, "sec_install", "install lark-sec-cli: %v", err) + } + out := opts.Factory.IOStreams.ErrOut + output.PrintSuccess(out, + "lark-sec-cli "+state.Version+" installed (buildId="+state.BuildID+") at "+paths.BinaryPath()) + return nil +} diff --git a/cmd/sec/run.go b/cmd/sec/run.go new file mode 100644 index 0000000000..9e44e55ec5 --- /dev/null +++ b/cmd/sec/run.go @@ -0,0 +1,118 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sec + +import ( + "bytes" + "fmt" + "os/exec" + + "github.com/spf13/cobra" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/output" + intsec "github.com/larksuite/cli/internal/sec" +) + +// RunOptions holds inputs for `lark-cli sec run`. +type RunOptions struct { + Factory *cmdutil.Factory + ProxyPort int + // AutoInstall runs `sec install` first when no binary is recorded. + AutoInstall bool +} + +// NewCmdSecRun starts lark-sec-cli as a user-level system service so it +// persists across logins and gets restarted by the OS supervisor if it +// crashes. Under the hood it shells out to `lark-sec-cli service enable`, +// which is the recommended startup path per the lark-sec-cli manual: +// +// - macOS → user-level launchd plist with KeepAlive=true +// - Linux → user systemd unit with Restart=always +// - Windows → registry autostart + a VBS watchdog loop +// +// Switching to this from a detached `exec.Command(... Setsid:true)` spawn +// fixes two latent issues at once: (1) daemon logs survive past lark-cli +// exit because the service supervisor — not our terminated pipes — owns +// the daemon's stdout, and (2) the daemon's own self-upgrade module can +// now fire (it gates on running-under-supervisor). +func NewCmdSecRun(f *cmdutil.Factory, runF func(*RunOptions) error) *cobra.Command { + opts := &RunOptions{Factory: f, AutoInstall: true} + cmd := &cobra.Command{ + Use: "run", + Short: "Enable lark-sec-cli as a user system service (the daemon runs in the background)", + Long: `Install lark-sec-cli as a user-level system service so the proxy +daemon runs automatically, persists across logins, and is restarted by the +OS if it exits. The daemon writes its own log file (default: under +~/.lark-sec-cli/logs/daemon.log) so logs persist independently of this +command. + +After enabling, the daemon writes ~/.lark-cli/sec_config.json itself with +the proxy port and CA path, so subsequent lark-cli runs route through the +sidecar without any further action. + +To stop and remove the service: lark-cli sec stop.`, + RunE: func(cmd *cobra.Command, args []string) error { + if runF != nil { + return runF(opts) + } + return runRun(cmd, opts) + }, + } + cmd.Flags().IntVar(&opts.ProxyPort, "proxy-port", 0, "force lark-sec-cli to bind this port (default: dynamic)") + cmd.Flags().BoolVar(&opts.AutoInstall, "auto-install", true, "bootstrap-install lark-sec-cli first when no binary is recorded") + return cmd +} + +func runRun(cmd *cobra.Command, opts *RunOptions) error { + ctx := cmd.Context() + inst, paths, err := installer(opts.Factory) + if err != nil { + return output.Errorf(output.ExitInternal, "internal", "%v", err) + } + + // Make sure we have a binary on disk before asking it to install itself + // as a service. + state, err := intsec.LoadState(paths.StateFile()) + if err != nil { + return output.Errorf(output.ExitInternal, "internal", "load sec state: %v", err) + } + if state == nil { + if !opts.AutoInstall { + return output.ErrWithHint(output.ExitValidation, "sec_not_installed", + "lark-sec-cli is not installed", + "Run `lark-cli sec install` first, or re-run with --auto-install.") + } + state, err = inst.Install(ctx, intsec.InstallOptions{}) + if err != nil { + return output.Errorf(output.ExitNetwork, "sec_install", "auto-install lark-sec-cli: %v", err) + } + } + + args := []string{"service", "enable"} + if opts.ProxyPort > 0 { + args = append(args, fmt.Sprintf("--proxy-port=%d", opts.ProxyPort)) + } + + out := opts.Factory.IOStreams.ErrOut + fmt.Fprintf(out, "Running: %s %v\n", state.BinaryPath, args) + + c := exec.CommandContext(ctx, state.BinaryPath, args...) + var stdout, stderr bytes.Buffer + c.Stdout = &stdout + c.Stderr = &stderr + if err := c.Run(); err != nil { + return output.Errorf(output.ExitInternal, "sec_service_enable", + "`lark-sec-cli service enable` failed: %v\nstderr: %s", err, stderr.String()) + } + + // Forward the installer's stdout to the user — it contains the launchd / + // systemd unit name, the registered executable path, and a confirmation + // that the supervisor will respawn the daemon on exit. Useful diagnostic + // output that's better seen than swallowed. + fmt.Fprint(out, stdout.String()) + output.PrintSuccess(out, + "lark-sec-cli enabled as a user system service. Run `lark-cli sec status` to verify, `lark-cli sec stop` to disable.") + return nil +} diff --git a/cmd/sec/sec.go b/cmd/sec/sec.go new file mode 100644 index 0000000000..77708340da --- /dev/null +++ b/cmd/sec/sec.go @@ -0,0 +1,40 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +// Package sec exposes the `lark-cli sec` command tree that bootstraps the +// lark-sec-cli sidecar daemon: install, run, stop, status, and `config init`. +// The internal/sec package owns the implementation; this package is a thin +// Cobra wrapper that mirrors the conventions in cmd/auth. +// +// After bootstrap install, lark-sec-cli handles its own upgrade lifecycle — +// lark-cli is not in the update path, which is why there's no `sec update` +// subcommand here. +package sec + +import ( + "github.com/spf13/cobra" + + "github.com/larksuite/cli/internal/cmdutil" +) + +// NewCmdSec builds the parent `sec` command and registers all subcommands. +func NewCmdSec(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "sec", + Short: "Manage the lark-sec-cli security sidecar (install, run, status)", + Long: `Manage the lark-sec-cli security sidecar. + +lark-sec-cli is a local HTTPS proxy daemon that intercepts lark-cli's traffic, +injects BDMS risk-control signatures, and manages credentials via the OS +keychain. These subcommands handle the install and runtime lifecycle from +lark-cli's side: bootstrap-install the daemon, run it in the background, and +wire the captured environment back into lark-cli. Updates after the first +install are managed by lark-sec-cli itself.`, + } + cmd.AddCommand(NewCmdSecInstall(f, nil)) + cmd.AddCommand(NewCmdSecRun(f, nil)) + cmd.AddCommand(NewCmdSecStop(f, nil)) + cmd.AddCommand(NewCmdSecStatus(f, nil)) + cmd.AddCommand(NewCmdSecConfig(f)) + return cmd +} diff --git a/cmd/sec/sec_test.go b/cmd/sec/sec_test.go new file mode 100644 index 0000000000..a1b309f19e --- /dev/null +++ b/cmd/sec/sec_test.go @@ -0,0 +1,57 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sec + +import ( + "sort" + "testing" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" +) + +// TestNewCmdSec_HasAllSubcommands locks in the public command surface so a +// future refactor doesn't silently drop install/run/etc. The `update` verb +// was intentionally removed when lark-sec-cli took over its own upgrade +// lifecycle; if it ever needs to come back, add it here too. +func TestNewCmdSec_HasAllSubcommands(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"}) + cmd := NewCmdSec(f) + + var got []string + for _, c := range cmd.Commands() { + got = append(got, c.Name()) + } + sort.Strings(got) + want := []string{"config", "install", "run", "status", "stop"} + if len(got) != len(want) { + t.Fatalf("subcommands = %v, want %v", got, want) + } + for i, name := range want { + if got[i] != name { + t.Errorf("subcommands[%d] = %q, want %q", i, got[i], name) + } + } +} + +// TestNewCmdSecInstall_FlagParsing follows the cmd/auth/auth_test pattern: +// inject runF, parse flags, assert opts captured them. +func TestNewCmdSecInstall_FlagParsing(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"}) + var got *InstallOptions + cmd := NewCmdSecInstall(f, func(opts *InstallOptions) error { + got = opts + return nil + }) + cmd.SetArgs([]string{"--force"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute: %v", err) + } + if got == nil { + t.Fatal("runF not invoked") + } + if !got.Force { + t.Errorf("Force = false, want true") + } +} diff --git a/cmd/sec/status.go b/cmd/sec/status.go new file mode 100644 index 0000000000..95b7c61c0e --- /dev/null +++ b/cmd/sec/status.go @@ -0,0 +1,108 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sec + +import ( + "bytes" + "fmt" + "os/exec" + + "github.com/spf13/cobra" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/output" + intsec "github.com/larksuite/cli/internal/sec" +) + +// StatusOptions holds inputs for `lark-cli sec status`. +type StatusOptions struct { + Factory *cmdutil.Factory +} + +// NewCmdSecStatus shows install + runtime state. Implementation strategy: +// +// 1. Read lark-cli's local install record (state.json) — works even when the +// daemon's not installed, and gives the user a version/buildId/path +// fingerprint regardless of whether the service is up. +// 2. If the install exists, shell out to `lark-sec-cli status` for the +// live daemon view (service registration, pid liveness, proxy probe, +// sec_config.json contents). The daemon's own status command does a +// thorough check; we just pass it through. +func NewCmdSecStatus(f *cmdutil.Factory, runF func(*StatusOptions) error) *cobra.Command { + opts := &StatusOptions{Factory: f} + cmd := &cobra.Command{ + Use: "status", + Short: "Show lark-sec-cli install and runtime state", + RunE: func(cmd *cobra.Command, args []string) error { + if runF != nil { + return runF(opts) + } + return runStatus(cmd, opts) + }, + } + return cmd +} + +func runStatus(cmd *cobra.Command, opts *StatusOptions) error { + _, paths, err := installer(opts.Factory) + if err != nil { + return output.Errorf(output.ExitInternal, "internal", "%v", err) + } + out := opts.Factory.IOStreams.Out + state, err := intsec.LoadState(paths.StateFile()) + if err != nil { + return output.Errorf(output.ExitInternal, "internal", "load sec state: %v", err) + } + if state == nil { + fmt.Fprintln(out, "lark-sec-cli: not installed") + fmt.Fprintln(out, " run: lark-cli sec install") + return nil + } + fmt.Fprintf(out, "lark-sec-cli %s (buildId=%s)\n", state.Version, state.BuildID) + fmt.Fprintf(out, " binary: %s\n", state.BinaryPath) + + // Daemon-side detail via `lark-sec-cli status`. The daemon's status + // command already covers service registration + pid + proxy reachability + // + bridge file — better than re-implementing those here. + c := exec.CommandContext(cmd.Context(), state.BinaryPath, "status") + var stdout, stderr bytes.Buffer + c.Stdout = &stdout + c.Stderr = &stderr + runErr := c.Run() + fmt.Fprintln(out, " --- lark-sec-cli status ---") + if stdout.Len() > 0 { + fmt.Fprint(out, indent(stdout.String(), " ")) + } + if stderr.Len() > 0 { + fmt.Fprint(out, indent(stderr.String(), " ")) + } + // `lark-sec-cli status` exits 1 when not running — that's diagnostic + // data, not a failure of OUR command. Surface it for the user but don't + // propagate the non-zero exit upward. + _ = runErr + return nil +} + +// indent prefixes every line of s with prefix. Cheap pass-through formatter +// used to make the embedded `lark-sec-cli status` output read as a sub-block +// under our own header. +func indent(s, prefix string) string { + if s == "" { + return s + } + var buf bytes.Buffer + start := 0 + for i := 0; i < len(s); i++ { + if s[i] == '\n' { + buf.WriteString(prefix) + buf.WriteString(s[start : i+1]) + start = i + 1 + } + } + if start < len(s) { + buf.WriteString(prefix) + buf.WriteString(s[start:]) + } + return buf.String() +} diff --git a/cmd/sec/stop.go b/cmd/sec/stop.go new file mode 100644 index 0000000000..669ba97b4c --- /dev/null +++ b/cmd/sec/stop.go @@ -0,0 +1,75 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sec + +import ( + "bytes" + "fmt" + "os/exec" + + "github.com/spf13/cobra" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/output" + intsec "github.com/larksuite/cli/internal/sec" +) + +// StopOptions holds inputs for `lark-cli sec stop`. +type StopOptions struct { + Factory *cmdutil.Factory +} + +// NewCmdSecStop disables and removes the lark-sec-cli user system service. +// Counterpart to `sec run` — internally invokes `lark-sec-cli service disable`, +// which uninstalls the launchd / systemd / VBS-watchdog registration. +// +// The daemon itself wipes ~/.lark-cli/sec_config.json on shutdown (see its +// --disable-on-exit flag, default true), so subsequent lark-cli runs route +// directly to the upstream API instead of dangling through a dead local proxy. +func NewCmdSecStop(f *cmdutil.Factory, runF func(*StopOptions) error) *cobra.Command { + opts := &StopOptions{Factory: f} + cmd := &cobra.Command{ + Use: "stop", + Short: "Disable and remove the lark-sec-cli user system service", + RunE: func(cmd *cobra.Command, args []string) error { + if runF != nil { + return runF(opts) + } + return runStop(cmd, opts) + }, + } + return cmd +} + +func runStop(cmd *cobra.Command, opts *StopOptions) error { + _, paths, err := installer(opts.Factory) + if err != nil { + return output.Errorf(output.ExitInternal, "internal", "%v", err) + } + state, err := intsec.LoadState(paths.StateFile()) + if err != nil { + return output.Errorf(output.ExitInternal, "internal", "load sec state: %v", err) + } + if state == nil { + // Nothing on disk to stop — no-op. + output.PrintSuccess(opts.Factory.IOStreams.ErrOut, "lark-sec-cli not installed; nothing to stop") + return nil + } + + out := opts.Factory.IOStreams.ErrOut + args := []string{"service", "disable"} + fmt.Fprintf(out, "Running: %s %v\n", state.BinaryPath, args) + + c := exec.CommandContext(cmd.Context(), state.BinaryPath, args...) + var stdout, stderr bytes.Buffer + c.Stdout = &stdout + c.Stderr = &stderr + if err := c.Run(); err != nil { + return output.Errorf(output.ExitInternal, "sec_service_disable", + "`lark-sec-cli service disable` failed: %v\nstderr: %s", err, stderr.String()) + } + fmt.Fprint(out, stdout.String()) + output.PrintSuccess(out, "lark-sec-cli service disabled") + return nil +} diff --git a/internal/sec/archive.go b/internal/sec/archive.go new file mode 100644 index 0000000000..01f7972f70 --- /dev/null +++ b/internal/sec/archive.go @@ -0,0 +1,138 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sec + +import ( + "archive/zip" + "fmt" + "io" + "os" + "path/filepath" + "runtime" + "strings" +) + +// maxArchiveBytes is a sanity ceiling for total uncompressed size to prevent +// a malicious or corrupt zip from filling the disk. The lark-sec-cli zip is a +// single binary plus one shared library; 1 GiB is several orders of magnitude +// over the real size and well under most users' free disk. +const maxArchiveBytes = 1 << 30 + +// ExtractZip unpacks src into dst, refusing entries whose target paths would +// escape dst (zip slip). Existing files inside dst are overwritten; dst must +// already exist. +// +// Executable permission is preserved when the zip stores POSIX mode bits; +// otherwise we apply 0o755 to suspected binaries (anything named lark-sec-cli +// or *.dylib/*.so) and 0o644 to everything else. +func ExtractZip(src, dst string) error { + r, err := zip.OpenReader(src) + if err != nil { + return fmt.Errorf("open zip: %w", err) + } + defer r.Close() + + dstAbs, err := filepath.Abs(dst) + if err != nil { + return err + } + + var totalSize uint64 + for _, f := range r.File { + totalSize += f.UncompressedSize64 + if totalSize > maxArchiveBytes { + return fmt.Errorf("zip exceeds %d bytes; refusing", maxArchiveBytes) + } + if err := extractZipEntry(f, dstAbs); err != nil { + return err + } + } + return nil +} + +func extractZipEntry(f *zip.File, dstAbs string) error { + // Reject absolute paths and any traversal segments. filepath.Clean + // collapses redundant separators but does NOT resolve symlinks or strip + // leading slashes — we have to do both explicitly. + name := f.Name + if strings.ContainsRune(name, 0) { + return fmt.Errorf("zip entry name contains NUL: %q", name) + } + cleaned := filepath.Clean(name) + if filepath.IsAbs(cleaned) || strings.HasPrefix(cleaned, "..") || + strings.Contains(cleaned, string(filepath.Separator)+".."+string(filepath.Separator)) { + return fmt.Errorf("zip entry escapes destination: %q", name) + } + + target := filepath.Join(dstAbs, cleaned) + // Defense in depth: even if the checks above missed something, this rel + // check guarantees target is under dstAbs. + rel, err := filepath.Rel(dstAbs, target) + if err != nil || strings.HasPrefix(rel, "..") { + return fmt.Errorf("zip entry escapes destination: %q", name) + } + + if f.FileInfo().IsDir() { + return os.MkdirAll(target, 0o755) + } + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return err + } + + // Symlink support: zip entries can be symlinks (mode bit set). The + // lark-sec-cli artifact doesn't currently use them, but if it grows to + // (e.g. for shared library version aliases) we want graceful handling. + if f.Mode()&os.ModeSymlink != 0 { + rc, err := f.Open() + if err != nil { + return err + } + linkBytes, readErr := io.ReadAll(io.LimitReader(rc, 1024)) + rc.Close() + if readErr != nil { + return readErr + } + os.Remove(target) // os.Symlink fails if target exists + return os.Symlink(string(linkBytes), target) + } + + rc, err := f.Open() + if err != nil { + return err + } + defer rc.Close() + + mode := f.Mode().Perm() + if mode == 0 { + mode = guessMode(cleaned) + } + out, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode) + if err != nil { + return err + } + if _, err := io.Copy(out, rc); err != nil { + out.Close() + return err + } + return out.Close() +} + +// guessMode supplies executable bits for entries the zip writer didn't tag +// with POSIX mode info — typically the case for archives built on Windows. +func guessMode(name string) os.FileMode { + base := filepath.Base(name) + if base == BinaryName || base == BinaryName+".exe" { + return 0o755 + } + ext := strings.ToLower(filepath.Ext(base)) + switch ext { + case ".dylib", ".so", ".dll": + return 0o755 + } + if runtime.GOOS != "windows" && !strings.ContainsRune(base, '.') { + // Plausibly an extra unix binary shipped alongside lark-sec-cli. + return 0o755 + } + return 0o644 +} diff --git a/internal/sec/archive_test.go b/internal/sec/archive_test.go new file mode 100644 index 0000000000..308f0dfbe5 --- /dev/null +++ b/internal/sec/archive_test.go @@ -0,0 +1,121 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sec + +import ( + "archive/zip" + "bytes" + "io" + "os" + "path/filepath" + "testing" +) + +// makeZip builds an in-memory zip with the given entries, writes it to path, +// and returns nothing — convenience for table-driven tests. +type zipEntry struct { + name string + body string + mode os.FileMode + symlink string // when set, entry is a symlink with this target +} + +func makeZip(t *testing.T, path string, entries []zipEntry) { + t.Helper() + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + for _, e := range entries { + hdr := &zip.FileHeader{Name: e.name, Method: zip.Deflate} + if e.mode != 0 { + hdr.SetMode(e.mode) + } + if e.symlink != "" { + hdr.SetMode(os.ModeSymlink | 0o777) + } + w, err := zw.CreateHeader(hdr) + if err != nil { + t.Fatalf("zip header %q: %v", e.name, err) + } + body := e.body + if e.symlink != "" { + body = e.symlink + } + if _, err := io.WriteString(w, body); err != nil { + t.Fatalf("zip write %q: %v", e.name, err) + } + } + if err := zw.Close(); err != nil { + t.Fatalf("zip close: %v", err) + } + if err := os.WriteFile(path, buf.Bytes(), 0o600); err != nil { + t.Fatalf("write zip: %v", err) + } +} + +func TestExtractZip_HappyPath(t *testing.T) { + tmp := t.TempDir() + zipPath := filepath.Join(tmp, "src.zip") + makeZip(t, zipPath, []zipEntry{ + {name: "lark-sec-cli", body: "binary", mode: 0o755}, + {name: "ca.crt", body: "cert"}, + {name: "lib/libMetaSecML.dylib", body: "dylib", mode: 0o755}, + }) + dst := filepath.Join(tmp, "out") + if err := os.MkdirAll(dst, 0o755); err != nil { + t.Fatal(err) + } + if err := ExtractZip(zipPath, dst); err != nil { + t.Fatalf("ExtractZip: %v", err) + } + + for name, want := range map[string]string{ + "lark-sec-cli": "binary", + "ca.crt": "cert", + "lib/libMetaSecML.dylib": "dylib", + } { + got, err := os.ReadFile(filepath.Join(dst, name)) + if err != nil { + t.Errorf("read %s: %v", name, err) + continue + } + if string(got) != want { + t.Errorf("%s body = %q, want %q", name, got, want) + } + } + if info, err := os.Stat(filepath.Join(dst, "lark-sec-cli")); err == nil { + if info.Mode().Perm()&0o100 == 0 { + t.Errorf("lark-sec-cli not executable: mode=%v", info.Mode()) + } + } +} + +func TestExtractZip_RejectsTraversal(t *testing.T) { + tmp := t.TempDir() + zipPath := filepath.Join(tmp, "evil.zip") + makeZip(t, zipPath, []zipEntry{ + {name: "../../../etc/passwd", body: "pwned"}, + }) + dst := filepath.Join(tmp, "out") + if err := os.MkdirAll(dst, 0o755); err != nil { + t.Fatal(err) + } + if err := ExtractZip(zipPath, dst); err == nil { + t.Fatal("ExtractZip accepted zip-slip entry") + } +} + +func TestExtractZip_RejectsAbsolutePath(t *testing.T) { + tmp := t.TempDir() + zipPath := filepath.Join(tmp, "abs.zip") + makeZip(t, zipPath, []zipEntry{ + {name: "/etc/passwd", body: "pwned"}, + }) + dst := filepath.Join(tmp, "out") + if err := os.MkdirAll(dst, 0o755); err != nil { + t.Fatal(err) + } + if err := ExtractZip(zipPath, dst); err == nil { + t.Fatal("ExtractZip accepted absolute-path entry") + } +} diff --git a/internal/sec/bootstrap.go b/internal/sec/bootstrap.go new file mode 100644 index 0000000000..55d0da072d --- /dev/null +++ b/internal/sec/bootstrap.go @@ -0,0 +1,33 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sec + +import ( + _ "embed" + "encoding/json" + "fmt" +) + +// bootstrapManifestJSON is the lark-sec-cli release manifest shipped with this +// lark-cli build. It points directly at TOS so a fresh install does not depend +// on any external release-tracking service — first install is fully self-contained. +// +// Updating this file pins a new default version of lark-sec-cli for users who +// install via lark-cli. After install, lark-sec-cli is in charge of finding and +// applying its own updates; lark-cli does not consult any release server. +// +//go:embed bootstrap.json +var bootstrapManifestJSON []byte + +// LoadBootstrap parses the embedded bootstrap manifest into a Manifest value. +func LoadBootstrap() (*Manifest, error) { + var entries []Entry + if err := json.Unmarshal(bootstrapManifestJSON, &entries); err != nil { + return nil, fmt.Errorf("decode embedded bootstrap manifest: %w", err) + } + if len(entries) == 0 { + return nil, fmt.Errorf("embedded bootstrap manifest is empty") + } + return &Manifest{Entries: entries}, nil +} diff --git a/internal/sec/bootstrap.json b/internal/sec/bootstrap.json new file mode 100644 index 0000000000..e52e34976a --- /dev/null +++ b/internal/sec/bootstrap.json @@ -0,0 +1,59 @@ +[ + { + "key": 0, + "buildPlatform": "linux", + "urls": [ + { + "urls": { + "amd64": "https://lf3-cdn-tos.bytegoofy.com/obj/tron-demo/lark-sec-cli/releases/1.0.1-alpha.23/367354993/linux-amd64/linux-amd64-1.0.1-alpha.23.zip", + "arm64": "https://lf3-cdn-tos.bytegoofy.com/obj/tron-demo/lark-sec-cli/releases/1.0.1-alpha.23/367354993/linux-arm64/linux-arm64-1.0.1-alpha.23.zip" + }, + "region": "cn" + } + ], + "branch": "dev", + "version": "1.0.1-alpha.23", + "extra": { + "pipeline_id": "367354993", + "upload_date": 1778487420795 + } + }, + { + "key": 1, + "buildPlatform": "win32", + "urls": [ + { + "urls": { + "x86": "https://lf3-cdn-tos.bytegoofy.com/obj/tron-demo/lark-sec-cli/releases/1.0.1-alpha.23/367354993/windows-386/windows-386-1.0.1-alpha.23.zip", + "amd64": "https://lf3-cdn-tos.bytegoofy.com/obj/tron-demo/lark-sec-cli/releases/1.0.1-alpha.23/367354993/windows-amd64/windows-amd64-1.0.1-alpha.23.zip" + }, + "region": "cn" + } + ], + "branch": "dev", + "version": "1.0.1-alpha.23", + "extra": { + "pipeline_id": "367354993", + "upload_date": 1778487437393 + } + }, + { + "key": 2, + "buildPlatform": "darwin", + "urls": [ + { + "urls": { + "amd64": "https://lf3-cdn-tos.bytegoofy.com/obj/tron-demo/lark-sec-cli/releases/1.0.1-alpha.23/367354993/darwin-amd64/darwin-amd64-1.0.1-alpha.23.zip", + "arm64": "https://lf3-cdn-tos.bytegoofy.com/obj/tron-demo/lark-sec-cli/releases/1.0.1-alpha.23/367354993/darwin-arm64/darwin-arm64-1.0.1-alpha.23.zip" + }, + "region": "cn" + } + ], + "branch": "dev", + "version": "1.0.1-alpha.23", + "extra": { + "pipeline_id": "367354993", + "upload_date": 1778487395152 + } + } +] diff --git a/internal/sec/bootstrap_test.go b/internal/sec/bootstrap_test.go new file mode 100644 index 0000000000..41936924e0 --- /dev/null +++ b/internal/sec/bootstrap_test.go @@ -0,0 +1,58 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sec + +import ( + "runtime" + "strings" + "testing" +) + +// TestLoadBootstrap_DecodesAllPlatforms guards against the embedded +// manifest becoming malformed or losing an OS — both would break first +// install on whatever GOOS lost its entry. +func TestLoadBootstrap_DecodesAllPlatforms(t *testing.T) { + manifest, err := LoadBootstrap() + if err != nil { + t.Fatalf("LoadBootstrap: %v", err) + } + platforms := map[string]bool{} + for _, e := range manifest.Entries { + platforms[e.BuildPlatform] = true + if e.Version == "" { + t.Errorf("entry %s missing version", e.BuildPlatform) + } + if e.Extra.PipelineID == "" { + t.Errorf("entry %s missing extra.pipeline_id", e.BuildPlatform) + } + } + for _, want := range []string{"darwin", "linux", "win32"} { + if !platforms[want] { + t.Errorf("bootstrap missing platform %q", want) + } + } +} + +// TestLoadBootstrap_PickArtifactForCurrentHost ensures the embedded manifest +// resolves to a real URL for whatever platform the test runner is on, so a +// developer fixing this code locally can still smoke-test their changes. +func TestLoadBootstrap_PickArtifactForCurrentHost(t *testing.T) { + manifest, err := LoadBootstrap() + if err != nil { + t.Fatalf("LoadBootstrap: %v", err) + } + art, err := manifest.PickArtifact(runtime.GOOS, runtime.GOARCH, "cn") + if err != nil { + t.Fatalf("PickArtifact for %s/%s: %v", runtime.GOOS, runtime.GOARCH, err) + } + if !strings.HasPrefix(art.URL, "https://") { + t.Errorf("URL is not https: %q", art.URL) + } + if !strings.HasSuffix(art.URL, ".zip") { + t.Errorf("URL is not a .zip: %q", art.URL) + } + if art.BuildID == "" { + t.Error("BuildID is empty") + } +} diff --git a/internal/sec/download.go b/internal/sec/download.go new file mode 100644 index 0000000000..c1c55d60f6 --- /dev/null +++ b/internal/sec/download.go @@ -0,0 +1,156 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sec + +import ( + "context" + "crypto/md5" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "fmt" + "hash" + "io" + "net/http" + "os" +) + +// downloadMaxBytes caps the artifact size we'll accept. Comfortably over the +// real lark-sec-cli zip (~tens of MB) and well under what a malicious mirror +// could use to exhaust local disk before we noticed. +const downloadMaxBytes = 512 * 1024 * 1024 + +// DownloadOptions controls Download. +type DownloadOptions struct { + URL string + Destination string // full path to the .zip we'll create + HTTPClient *http.Client + + // ExpectedSHA256, if non-empty, is the hex SHA256 the artifact MUST + // match — verified after the full body has been streamed. Use this when + // the manifest publishes a hash for the artifact (e.g. bootstrap.json's + // `extra.sha256`). Any mismatch fails the download with the .part file + // removed. + // + // When empty (the manifest doesn't carry a hash), the only integrity + // check left is the CDN's own `Content-MD5` response header, applied + // opportunistically below. + ExpectedSHA256 string +} + +// Download streams URL to Destination. Writes to a sibling .part file and +// renames into place on success so a crashed or aborted run leaves no +// half-written zip the next run might mistake for valid. +// +// Two layers of integrity check, both opt-in: +// +// 1. ExpectedSHA256 (strong, manifest-provided): cryptographic, fails the +// download on mismatch. Use whenever the release manifest carries a hash. +// 2. CDN `Content-MD5` header (opportunistic): non-cryptographic, catches +// edge replacement or transit corruption when the upstream CDN populates +// the header. Runs unconditionally — if the header is present we honour it. +// +// Neither check defends against a malicious upstream that controls both the +// artifact AND the manifest. That class of risk has to be handled by signing +// the release pipeline, which is out of scope for the client. +func Download(ctx context.Context, opts DownloadOptions) error { + if opts.HTTPClient == nil { + return fmt.Errorf("Download: HTTPClient is required") + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, opts.URL, nil) + if err != nil { + return err + } + resp, err := opts.HTTPClient.Do(req) + if err != nil { + return fmt.Errorf("download %s: %w", opts.URL, err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("download %s: status %d", opts.URL, resp.StatusCode) + } + + tmpPath := opts.Destination + ".part" + out, err := os.OpenFile(tmpPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600) + if err != nil { + return err + } + cleanup := func() { out.Close(); os.Remove(tmpPath) } + + // Hash both ways during the single read pass. Both hashers are cheap and + // we don't know yet which check (or both) we'll actually need. + sha := sha256.New() + md := md5.New() + writer := io.MultiWriter(out, sha, md) + + n, err := io.Copy(writer, io.LimitReader(resp.Body, downloadMaxBytes+1)) + if err != nil { + cleanup() + return fmt.Errorf("download %s: %w", opts.URL, err) + } + if n > downloadMaxBytes { + cleanup() + return fmt.Errorf("download %s: exceeds %d bytes", opts.URL, downloadMaxBytes) + } + if err := out.Sync(); err != nil { + cleanup() + return err + } + if err := out.Close(); err != nil { + os.Remove(tmpPath) + return err + } + + if err := verifyChecksums(resp, opts.ExpectedSHA256, sha, md); err != nil { + os.Remove(tmpPath) + return fmt.Errorf("download %s: %w", opts.URL, err) + } + + if err := os.Rename(tmpPath, opts.Destination); err != nil { + os.Remove(tmpPath) + return err + } + return nil +} + +// verifyChecksums applies the two-layer integrity check after the body has +// been fully streamed. Returns nil when both layers (whichever apply) agree. +func verifyChecksums(resp *http.Response, expectedSHA256 string, sha, md hash.Hash) error { + if expectedSHA256 != "" { + got := hex.EncodeToString(sha.Sum(nil)) + if !equalFoldHex(got, expectedSHA256) { + return fmt.Errorf("sha256 mismatch: expected %s, got %s", expectedSHA256, got) + } + } + + if cdnMD5 := resp.Header.Get("Content-MD5"); cdnMD5 != "" { + got := base64.StdEncoding.EncodeToString(md.Sum(nil)) + if got != cdnMD5 { + return fmt.Errorf("content-md5 mismatch: cdn=%s, computed=%s", cdnMD5, got) + } + } + return nil +} + +// equalFoldHex is a non-allocating ASCII case-insensitive compare for hex +// strings. SHA256 manifests sometimes ship uppercase, sometimes lowercase. +func equalFoldHex(a, b string) bool { + if len(a) != len(b) { + return false + } + for i := 0; i < len(a); i++ { + ca, cb := a[i], b[i] + if 'A' <= ca && ca <= 'Z' { + ca += 'a' - 'A' + } + if 'A' <= cb && cb <= 'Z' { + cb += 'a' - 'A' + } + if ca != cb { + return false + } + } + return true +} diff --git a/internal/sec/download_test.go b/internal/sec/download_test.go new file mode 100644 index 0000000000..13926cd75a --- /dev/null +++ b/internal/sec/download_test.go @@ -0,0 +1,184 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sec + +import ( + "context" + "crypto/md5" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +const bodyContent = "lark-sec-cli pretend zip bytes" + +// fixtureSHA256 / fixtureMD5 are the hashes of bodyContent. +var fixtureSHA256 string +var fixtureMD5b64 string + +func init() { + sum := sha256.Sum256([]byte(bodyContent)) + fixtureSHA256 = hex.EncodeToString(sum[:]) + m := md5.Sum([]byte(bodyContent)) + fixtureMD5b64 = base64.StdEncoding.EncodeToString(m[:]) +} + +func newFixtureServer(t *testing.T, setContentMD5 bool) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if setContentMD5 { + w.Header().Set("Content-MD5", fixtureMD5b64) + } + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(bodyContent)) + })) +} + +// TestDownload_HappyPath_NoChecksum confirms that a download with no manifest +// SHA and no CDN MD5 succeeds — the integrity hooks are opt-in, not required. +func TestDownload_HappyPath_NoChecksum(t *testing.T) { + srv := newFixtureServer(t, false) + defer srv.Close() + + dst := filepath.Join(t.TempDir(), "out.zip") + err := Download(context.Background(), DownloadOptions{ + URL: srv.URL, + Destination: dst, + HTTPClient: srv.Client(), + }) + if err != nil { + t.Fatalf("Download: %v", err) + } + got, err := os.ReadFile(dst) + if err != nil { + t.Fatal(err) + } + if string(got) != bodyContent { + t.Errorf("body roundtrip mismatch") + } +} + +// TestDownload_SHA256_Match confirms the manifest-provided SHA256 path +// passes for a correct hash. Tests both cases (with and without CDN MD5) +// so the second layer doesn't interfere. +func TestDownload_SHA256_Match(t *testing.T) { + for _, withMD5 := range []bool{false, true} { + name := "noMD5" + if withMD5 { + name = "withCDNMd5" + } + t.Run(name, func(t *testing.T) { + srv := newFixtureServer(t, withMD5) + defer srv.Close() + dst := filepath.Join(t.TempDir(), "out.zip") + err := Download(context.Background(), DownloadOptions{ + URL: srv.URL, + Destination: dst, + HTTPClient: srv.Client(), + ExpectedSHA256: fixtureSHA256, + }) + if err != nil { + t.Fatalf("Download: %v", err) + } + }) + } +} + +// TestDownload_SHA256_Mismatch is the safety property: a wrong manifest hash +// rejects the download AND removes the .part file so the next run doesn't +// pick up a poisoned zip. +func TestDownload_SHA256_Mismatch(t *testing.T) { + srv := newFixtureServer(t, false) + defer srv.Close() + + dst := filepath.Join(t.TempDir(), "out.zip") + err := Download(context.Background(), DownloadOptions{ + URL: srv.URL, + Destination: dst, + HTTPClient: srv.Client(), + ExpectedSHA256: "0000000000000000000000000000000000000000000000000000000000000000", + }) + if err == nil { + t.Fatal("expected sha256 mismatch error") + } + if !strings.Contains(err.Error(), "sha256 mismatch") { + t.Errorf("error should mention sha256 mismatch: %v", err) + } + if _, statErr := os.Stat(dst); statErr == nil { + t.Errorf("dst should not exist after mismatch") + } + if _, statErr := os.Stat(dst + ".part"); statErr == nil { + t.Errorf(".part should not exist after mismatch") + } +} + +// TestDownload_ContentMD5_Mismatch confirms the opportunistic check fires +// even when no manifest SHA was provided. Catches a CDN edge that returned +// content but a stale/wrong Content-MD5 header (or a poisoned proxy). +func TestDownload_ContentMD5_Mismatch(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-MD5", "Z3JhZmFuYTpyZWFsbHk/Pz8/Pz8/PzA9PT0=") // arbitrary + _, _ = w.Write([]byte(bodyContent)) + })) + defer srv.Close() + + dst := filepath.Join(t.TempDir(), "out.zip") + err := Download(context.Background(), DownloadOptions{ + URL: srv.URL, + Destination: dst, + HTTPClient: srv.Client(), + }) + if err == nil { + t.Fatal("expected content-md5 mismatch error") + } + if !strings.Contains(err.Error(), "content-md5 mismatch") { + t.Errorf("error should mention content-md5 mismatch: %v", err) + } +} + +// TestDownload_SHA256_CaseInsensitive guards the hex compare against case +// drift in the manifest (some publishers upper-case). +func TestDownload_SHA256_CaseInsensitive(t *testing.T) { + srv := newFixtureServer(t, false) + defer srv.Close() + + dst := filepath.Join(t.TempDir(), "out.zip") + err := Download(context.Background(), DownloadOptions{ + URL: srv.URL, + Destination: dst, + HTTPClient: srv.Client(), + ExpectedSHA256: strings.ToUpper(fixtureSHA256), + }) + if err != nil { + t.Fatalf("Download (uppercase sha): %v", err) + } +} + +// TestDownload_404_NoPartFile confirms that a non-200 response leaves no +// .part file behind to confuse the next attempt. +func TestDownload_404_NoPartFile(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + dst := filepath.Join(t.TempDir(), "out.zip") + err := Download(context.Background(), DownloadOptions{ + URL: srv.URL, + Destination: dst, + HTTPClient: srv.Client(), + }) + if err == nil { + t.Fatal("expected error for 404") + } + if _, statErr := os.Stat(dst + ".part"); statErr == nil { + t.Errorf(".part should not exist after 404") + } +} diff --git a/internal/sec/install.go b/internal/sec/install.go new file mode 100644 index 0000000000..b4553a8610 --- /dev/null +++ b/internal/sec/install.go @@ -0,0 +1,241 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sec + +import ( + "context" + "errors" + "fmt" + "io" + "io/fs" + "net/http" + "os" + "path/filepath" + "runtime" + "time" +) + +// Installer orchestrates first-time install of lark-sec-cli: +// load embedded bootstrap manifest → download zip → extract into +// versions// → swap "current" → write state.json. +// +// After this first install, lark-sec-cli takes over its own updates and +// lark-cli is no longer in the update path. The installer therefore only +// knows about the bootstrap manifest — no Tron, no other release sources. +type Installer struct { + Paths *Paths + HTTPClient *http.Client +} + +// InstallOptions tunes a single Install call. +type InstallOptions struct { + // Force re-runs the pipeline even when an install already exists. Used by + // `sec install --force` for repair / re-pinning to the bundled bootstrap. + Force bool + // Region selects which region's URLs to pick from the manifest. Defaults to + // DefaultRegion ("cn"). Reserved for future brand split. + Region string +} + +// Install runs the bootstrap pipeline and returns the new State on success. +// If a usable install already exists on disk and Force is false, returns the +// existing state unchanged (no network call). +func (i *Installer) Install(ctx context.Context, opts InstallOptions) (*State, error) { + if err := i.Paths.Ensure(); err != nil { + return nil, err + } + + existing, err := LoadState(i.Paths.StateFile()) + if err != nil { + return nil, fmt.Errorf("load sec state: %w", err) + } + + // Idempotent short-circuit: nothing to do if an install is already on disk. + // Self-upgrades after bootstrap are lark-sec-cli's job, not ours — see the + // upgrade subsystem in lark-sec-cli/internal/upgrade/. + if !opts.Force && existing != nil && binaryReady(existing.BinaryPath) { + return existing, nil + } + + region := opts.Region + if region == "" { + region = DefaultRegion + } + + manifest, err := LoadBootstrap() + if err != nil { + return nil, err + } + artifact, err := manifest.PickArtifact(runtime.GOOS, runtime.GOARCH, region) + if err != nil { + return nil, err + } + + versionDir := i.Paths.VersionDir(artifact.Version) + if err := os.MkdirAll(versionDir, 0o755); err != nil { + return nil, err + } + zipPath := filepath.Join(i.Paths.VersionsDir(), artifact.Version+".zip") + + if err := Download(ctx, DownloadOptions{ + URL: artifact.URL, + Destination: zipPath, + HTTPClient: i.HTTPClient, + ExpectedSHA256: artifact.SHA256, + }); err != nil { + return nil, err + } + defer os.Remove(zipPath) // free disk; we keep the unpacked version dir + + if err := ExtractZip(zipPath, versionDir); err != nil { + return nil, err + } + + binaryPath, err := locateBinary(versionDir) + if err != nil { + return nil, err + } + // Ensure executable bit on POSIX — some zips lose it. + if runtime.GOOS != "windows" { + if info, err := os.Stat(binaryPath); err == nil { + _ = os.Chmod(binaryPath, info.Mode()|0o100|0o010|0o001) + } + } + + if err := swapCurrent(i.Paths.CurrentLink(), versionDir); err != nil { + return nil, fmt.Errorf("swap current: %w", err) + } + + state := &State{ + Version: artifact.Version, + BuildID: artifact.BuildID, + InstalledAt: time.Now().UTC(), + BinaryPath: i.Paths.BinaryPath(), + } + if err := SaveState(i.Paths.StateFile(), state); err != nil { + return nil, err + } + return state, nil +} + +// locateBinary handles two artifact layouts: flat (zip root has the binary) +// and nested (zip root is a single dir containing the binary). The bootstrap +// manifest's example payload uses nested ("linux-amd64-1.0.1-alpha.23/..."); +// we accommodate either since the wrapping dir name could change per build. +func locateBinary(versionDir string) (string, error) { + name := BinaryName + if runtime.GOOS == "windows" { + name += ".exe" + } + + flat := filepath.Join(versionDir, name) + if _, err := os.Stat(flat); err == nil { + return flat, nil + } + + var found string + walkErr := filepath.WalkDir(versionDir, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if !d.IsDir() && d.Name() == name { + found = path + return fs.SkipAll + } + return nil + }) + if walkErr != nil { + return "", walkErr + } + if found == "" { + return "", fmt.Errorf("binary %q not found under %s", name, versionDir) + } + + // Promote the binary's parent to be versionDir so "current" → versionDir + // produces a predictable layout. Move the *contents* up rather than the + // binary alone, because shared libs may sit beside it. + parent := filepath.Dir(found) + if parent != versionDir { + entries, err := os.ReadDir(parent) + if err != nil { + return "", err + } + for _, e := range entries { + if err := os.Rename(filepath.Join(parent, e.Name()), filepath.Join(versionDir, e.Name())); err != nil { + return "", err + } + } + _ = os.Remove(parent) + } + return filepath.Join(versionDir, name), nil +} + +// swapCurrent atomically points /current at versionDir. On POSIX +// we use a symlink with the standard rename-into-place trick; on Windows we +// fall back to removing the directory and copying, since junctions need +// admin / developer-mode privileges we may not have. +func swapCurrent(link, versionDir string) error { + if runtime.GOOS == "windows" { + // Remove any existing target then copy. This is non-atomic, but + // concurrent installs on the same Windows host are not a use case + // we support — `sec install` runs interactively. + if err := os.RemoveAll(link); err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + return copyDir(versionDir, link) + } + + tmp := link + ".new" + _ = os.Remove(tmp) + if err := os.Symlink(versionDir, tmp); err != nil { + return err + } + return os.Rename(tmp, link) +} + +func copyDir(src, dst string) error { + if err := os.MkdirAll(dst, 0o755); err != nil { + return err + } + return filepath.WalkDir(src, func(p string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + rel, err := filepath.Rel(src, p) + if err != nil { + return err + } + target := filepath.Join(dst, rel) + if d.IsDir() { + return os.MkdirAll(target, 0o755) + } + info, err := d.Info() + if err != nil { + return err + } + in, err := os.Open(p) + if err != nil { + return err + } + defer in.Close() + out, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, info.Mode().Perm()) + if err != nil { + return err + } + _, copyErr := io.Copy(out, in) + closeErr := out.Close() + if copyErr != nil { + return copyErr + } + return closeErr + }) +} + +func binaryReady(path string) bool { + if path == "" { + return false + } + info, err := os.Stat(path) + return err == nil && !info.IsDir() +} diff --git a/internal/sec/manifest.go b/internal/sec/manifest.go new file mode 100644 index 0000000000..2ec7b7251a --- /dev/null +++ b/internal/sec/manifest.go @@ -0,0 +1,139 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sec + +import ( + "fmt" + "runtime" +) + +// Manifest describes a lark-sec-cli release set: one Entry per build platform, +// each carrying one or more region-scoped URL maps keyed by arch. It's what we +// embed at build time as the bootstrap manifest. After bootstrap, lark-sec-cli +// queries its own release source for updates — lark-cli is uninvolved. +type Manifest struct { + Entries []Entry +} + +// Entry is one row of the bootstrap manifest, one per published platform. +type Entry struct { + Key int `json:"key"` + BuildPlatform string `json:"buildPlatform"` // "darwin" | "linux" | "win32" + URLs []RegionURLs `json:"urls"` + Branch string `json:"branch"` + Version string `json:"version"` + Extra EntryExtra `json:"extra"` +} + +// RegionURLs maps an arch ("amd64", "arm64", "x86") to its download URL, +// scoped to a region ("cn" today; reserved for future brand split). +type RegionURLs struct { + URLs map[string]string `json:"urls"` + Region string `json:"region"` +} + +// EntryExtra is metadata the release pipeline emits alongside each artifact. +// PipelineID is the build identifier lark-sec-cli will later forward to its +// own update server when checking for new versions. SHA256 (when present) is +// the hex-encoded hash of the zip artifact; the installer fails the download +// on mismatch. Manifests built before the release pipeline added the field +// leave it empty, in which case integrity falls back to the CDN's own +// Content-MD5 header. +type EntryExtra struct { + PipelineID string `json:"pipeline_id"` + UploadDate int64 `json:"upload_date"` + SHA256 string `json:"sha256,omitempty"` +} + +// Artifact is the resolved download target after platform/arch/region selection. +type Artifact struct { + URL string + Version string + BuildID string // pipeline_id — recorded in state.json so lark-sec-cli knows what it was installed at + SHA256 string // hex-encoded; empty when the manifest doesn't carry one +} + +// PickArtifact selects the right Entry for the current GOOS/GOARCH and the +// requested region. Returns a clear error explaining which combination was +// missing so users can tell whether the build was never published or just not +// for their platform. +func (m *Manifest) PickArtifact(goos, goarch, region string) (*Artifact, error) { + platform, err := platformKey(goos) + if err != nil { + return nil, err + } + arch, err := archKey(goos, goarch) + if err != nil { + return nil, err + } + + for _, e := range m.Entries { + if e.BuildPlatform != platform { + continue + } + for _, ru := range e.URLs { + if ru.Region != region { + continue + } + url, ok := ru.URLs[arch] + if !ok || url == "" { + continue + } + return &Artifact{ + URL: url, + Version: e.Version, + BuildID: e.Extra.PipelineID, + SHA256: e.Extra.SHA256, + }, nil + } + } + return nil, fmt.Errorf("no artifact for platform=%s arch=%s region=%s", platform, arch, region) +} + +// platformKey maps Go's GOOS to the manifest's buildPlatform enum. +func platformKey(goos string) (string, error) { + switch goos { + case "darwin": + return "darwin", nil + case "linux": + return "linux", nil + case "windows": + return "win32", nil + default: + return "", fmt.Errorf("unsupported GOOS: %s", goos) + } +} + +// archKey maps Go's GOARCH to the arch key the manifest uses inside RegionURLs.URLs. +// Windows 32-bit ships under "x86" while POSIX 32-bit (e.g. 386 on linux) is not +// currently published — surface that as an error rather than silently falling back. +func archKey(goos, goarch string) (string, error) { + switch goarch { + case "amd64": + return "amd64", nil + case "arm64": + return "arm64", nil + case "386": + if goos == "windows" { + return "x86", nil + } + return "", fmt.Errorf("32-bit %s is not published", goos) + default: + return "", fmt.Errorf("unsupported GOARCH: %s", goarch) + } +} + +// CurrentPlatformArch is a convenience for the install flow. +func CurrentPlatformArch() (platform, arch string, err error) { + platform, err = platformKey(runtime.GOOS) + if err != nil { + return "", "", err + } + arch, err = archKey(runtime.GOOS, runtime.GOARCH) + return platform, arch, err +} + +// DefaultRegion is the only region published today for bootstrap installs. +// Kept here for callers that still want a single source of truth. +const DefaultRegion = "cn" diff --git a/internal/sec/manifest_test.go b/internal/sec/manifest_test.go new file mode 100644 index 0000000000..1feb3a005a --- /dev/null +++ b/internal/sec/manifest_test.go @@ -0,0 +1,108 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sec + +import ( + "testing" +) + +// sampleManifest is the manifest example baked into bootstrap.json, trimmed to +// the three published platforms. PickArtifact must select the right URL for +// each GOOS/GOARCH combination. +func sampleManifest() *Manifest { + return &Manifest{Entries: []Entry{ + { + Key: 0, + BuildPlatform: "linux", + Branch: "dev", + Version: "1.0.1-alpha.23", + Extra: EntryExtra{PipelineID: "367354993"}, + URLs: []RegionURLs{{ + Region: "cn", + URLs: map[string]string{ + "amd64": "https://cdn/linux-amd64.zip", + "arm64": "https://cdn/linux-arm64.zip", + }, + }}, + }, + { + Key: 1, + BuildPlatform: "win32", + Branch: "dev", + Version: "1.0.1-alpha.23", + Extra: EntryExtra{PipelineID: "367354993"}, + URLs: []RegionURLs{{ + Region: "cn", + URLs: map[string]string{ + "x86": "https://cdn/win-386.zip", + "amd64": "https://cdn/win-amd64.zip", + }, + }}, + }, + { + Key: 2, + BuildPlatform: "darwin", + Branch: "dev", + Version: "1.0.1-alpha.23", + Extra: EntryExtra{PipelineID: "367354993"}, + URLs: []RegionURLs{{ + Region: "cn", + URLs: map[string]string{ + "amd64": "https://cdn/darwin-amd64.zip", + "arm64": "https://cdn/darwin-arm64.zip", + }, + }}, + }, + }} +} + +func TestPickArtifact_HappyPath(t *testing.T) { + m := sampleManifest() + cases := []struct { + goos, goarch string + wantURL string + }{ + {"darwin", "arm64", "https://cdn/darwin-arm64.zip"}, + {"darwin", "amd64", "https://cdn/darwin-amd64.zip"}, + {"linux", "amd64", "https://cdn/linux-amd64.zip"}, + {"linux", "arm64", "https://cdn/linux-arm64.zip"}, + {"windows", "amd64", "https://cdn/win-amd64.zip"}, + {"windows", "386", "https://cdn/win-386.zip"}, + } + for _, c := range cases { + t.Run(c.goos+"/"+c.goarch, func(t *testing.T) { + art, err := m.PickArtifact(c.goos, c.goarch, "cn") + if err != nil { + t.Fatalf("PickArtifact: %v", err) + } + if art.URL != c.wantURL { + t.Errorf("URL = %q, want %q", art.URL, c.wantURL) + } + if art.Version != "1.0.1-alpha.23" { + t.Errorf("Version = %q", art.Version) + } + if art.BuildID != "367354993" { + t.Errorf("BuildID = %q", art.BuildID) + } + }) + } +} + +func TestPickArtifact_Linux386Rejected(t *testing.T) { + if _, err := sampleManifest().PickArtifact("linux", "386", "cn"); err == nil { + t.Fatal("expected error for linux/386 (not published)") + } +} + +func TestPickArtifact_UnknownRegion(t *testing.T) { + if _, err := sampleManifest().PickArtifact("darwin", "arm64", "sg"); err == nil { + t.Fatal("expected error for region=sg (not present in fixture)") + } +} + +func TestPickArtifact_UnsupportedOS(t *testing.T) { + if _, err := sampleManifest().PickArtifact("plan9", "amd64", "cn"); err == nil { + t.Fatal("expected error for plan9") + } +} diff --git a/internal/sec/paths.go b/internal/sec/paths.go new file mode 100644 index 0000000000..7ab0fa37ab --- /dev/null +++ b/internal/sec/paths.go @@ -0,0 +1,132 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +// Package sec manages the first-time bootstrap install of the lark-sec-cli +// sidecar from lark-cli's side: download the artifact, lay it out on disk, +// record what version landed. Runtime lifecycle (start / stop / status) is +// handled by shelling out to lark-sec-cli's own `service enable / disable / +// status` commands, so we don't need pid files / env capture / log tees here. +// Updates after install are lark-sec-cli's responsibility, not lark-cli's. +package sec + +import ( + "errors" + "os" + "path/filepath" + "runtime" +) + +const ( + // envInstallDirOverride lets tests and power users redirect the entire sec + // tree (install + data) to a single root. When set, install_dir is + // and data_dir is /data — no platform-conventional lookup happens. + envInstallDirOverride = "LARKSUITE_CLI_SEC_DIR" + + // BinaryName is the executable name we expect inside the extracted artifact. + BinaryName = "lark-sec-cli" +) + +// Paths exposes the filesystem layout for the sec sidecar. All methods return +// absolute paths; nothing on disk is created — callers must call Ensure(). +type Paths struct { + install string + data string +} + +// DefaultPaths returns Paths rooted at the platform-conventional user data dir, +// or at $LARKSUITE_CLI_SEC_DIR when set. +func DefaultPaths() (*Paths, error) { + if root := os.Getenv(envInstallDirOverride); root != "" { + return &Paths{install: root, data: filepath.Join(root, "data")}, nil + } + install, data, err := platformDirs() + if err != nil { + return nil, err + } + return &Paths{install: install, data: data}, nil +} + +// platformDirs returns (install_dir, data_dir) for the current OS, applying +// per-platform conventions: +// +// macOS install = data = ~/Library/Application Support/lark-cli/sec +// Linux install = $XDG_DATA_HOME/lark-cli/sec (fallback ~/.local/share/...) +// data = $XDG_STATE_HOME/lark-cli/sec (fallback ~/.local/state/...) +// Windows install = data = %LOCALAPPDATA%\lark-cli\sec +// +// Linux splits install/data along XDG lines; macOS and Windows colocate them +// because their conventions don't distinguish "share" from "state" at the +// per-user level. +func platformDirs() (install, data string, err error) { + home, err := os.UserHomeDir() + if err != nil { + return "", "", err + } + switch runtime.GOOS { + case "darwin": + base := filepath.Join(home, "Library", "Application Support", "lark-cli", "sec") + return base, filepath.Join(base, "data"), nil + case "windows": + appData := os.Getenv("LOCALAPPDATA") + if appData == "" { + return "", "", errors.New("LOCALAPPDATA is not set") + } + base := filepath.Join(appData, "lark-cli", "sec") + return base, filepath.Join(base, "data"), nil + case "linux": + dataHome := os.Getenv("XDG_DATA_HOME") + if dataHome == "" { + dataHome = filepath.Join(home, ".local", "share") + } + stateHome := os.Getenv("XDG_STATE_HOME") + if stateHome == "" { + stateHome = filepath.Join(home, ".local", "state") + } + return filepath.Join(dataHome, "lark-cli", "sec"), + filepath.Join(stateHome, "lark-cli", "sec"), + nil + default: + base := filepath.Join(home, ".lark-cli", "sec") + return base, filepath.Join(base, "data"), nil + } +} + +// Ensure creates the directories the installer writes into. +func (p *Paths) Ensure() error { + for _, d := range []string{p.install, p.data, p.VersionsDir()} { + if err := os.MkdirAll(d, 0o700); err != nil { + return err + } + } + return nil +} + +// InstallDir is the root for binaries and version trees. +func (p *Paths) InstallDir() string { return p.install } + +// DataDir is the root for state.json (and anything else lark-cli persists +// about the install — currently just state.json). +func (p *Paths) DataDir() string { return p.data } + +// VersionsDir stores each unpacked release: versions//. +func (p *Paths) VersionsDir() string { return filepath.Join(p.install, "versions") } + +// VersionDir is the unpack target for a specific version string. +func (p *Paths) VersionDir(version string) string { + return filepath.Join(p.VersionsDir(), version) +} + +// CurrentLink points to the active version (symlink on POSIX, plain copy on Windows). +func (p *Paths) CurrentLink() string { return filepath.Join(p.install, "current") } + +// BinaryPath is the active lark-sec-cli executable (with .exe on Windows). +func (p *Paths) BinaryPath() string { + name := BinaryName + if runtime.GOOS == "windows" { + name += ".exe" + } + return filepath.Join(p.CurrentLink(), name) +} + +// StateFile records what version is installed and where its binary lives. +func (p *Paths) StateFile() string { return filepath.Join(p.data, "state.json") } diff --git a/internal/sec/paths_test.go b/internal/sec/paths_test.go new file mode 100644 index 0000000000..da4cf80aa8 --- /dev/null +++ b/internal/sec/paths_test.go @@ -0,0 +1,51 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sec + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestDefaultPaths_OverrideViaEnv(t *testing.T) { + dir := t.TempDir() + t.Setenv(envInstallDirOverride, dir) + p, err := DefaultPaths() + if err != nil { + t.Fatalf("DefaultPaths: %v", err) + } + if p.InstallDir() != dir { + t.Errorf("InstallDir = %q, want %q", p.InstallDir(), dir) + } + if p.DataDir() != filepath.Join(dir, "data") { + t.Errorf("DataDir = %q, want %s/data", p.DataDir(), dir) + } + if !strings.HasPrefix(p.StateFile(), dir) { + t.Errorf("StateFile not under override root: %q", p.StateFile()) + } +} + +func TestPaths_Ensure(t *testing.T) { + dir := t.TempDir() + t.Setenv(envInstallDirOverride, dir) + p, err := DefaultPaths() + if err != nil { + t.Fatalf("DefaultPaths: %v", err) + } + if err := p.Ensure(); err != nil { + t.Fatalf("Ensure: %v", err) + } + for _, d := range []string{p.InstallDir(), p.DataDir(), p.VersionsDir()} { + info, err := os.Stat(d) + if err != nil { + t.Errorf("missing %s: %v", d, err) + continue + } + if !info.IsDir() { + t.Errorf("%s is not a directory", d) + } + } +} diff --git a/internal/sec/state.go b/internal/sec/state.go new file mode 100644 index 0000000000..04f95569c6 --- /dev/null +++ b/internal/sec/state.go @@ -0,0 +1,79 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sec + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "time" +) + +// State is the JSON document at /state.json describing the currently +// installed lark-sec-cli artifact. It is the source of truth for what binary +// to launch. After bootstrap install lark-sec-cli may upgrade itself in +// place — when that happens this state file is informational only; the +// daemon owns its own canonical version state. +type State struct { + Version string `json:"version"` + BuildID string `json:"build_id"` + InstalledAt time.Time `json:"installed_at"` + BinaryPath string `json:"binary_path"` +} + +// LoadState reads state.json. Returns (nil, nil) when the file is absent — +// callers treat that as "not yet installed". Decode errors are surfaced +// so a corrupt file is never silently overwritten. +func LoadState(path string) (*State, error) { + data, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + if err != nil { + return nil, err + } + var s State + if err := json.Unmarshal(data, &s); err != nil { + return nil, fmt.Errorf("parse %s: %w", path, err) + } + return &s, nil +} + +// SaveState writes state.json atomically: a tmpfile next to the target is +// fsynced then renamed in, so concurrent readers either see the previous +// state or the new one — never a torn write. +func SaveState(path string, s *State) error { + data, err := json.MarshalIndent(s, "", " ") + if err != nil { + return err + } + tmp, err := os.CreateTemp(dirOf(path), ".state-*.json") + if err != nil { + return err + } + tmpName := tmp.Name() + defer os.Remove(tmpName) // no-op after a successful Rename + if _, err := tmp.Write(data); err != nil { + tmp.Close() + return err + } + if err := tmp.Sync(); err != nil { + tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + return os.Rename(tmpName, path) +} + +func dirOf(path string) string { + for i := len(path) - 1; i >= 0; i-- { + if path[i] == '/' || path[i] == '\\' { + return path[:i] + } + } + return "." +} diff --git a/internal/sec/state_test.go b/internal/sec/state_test.go new file mode 100644 index 0000000000..0881247ba2 --- /dev/null +++ b/internal/sec/state_test.go @@ -0,0 +1,48 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sec + +import ( + "path/filepath" + "testing" + "time" +) + +func TestSaveLoadState_Roundtrip(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "state.json") + + in := &State{ + Version: "1.2.3", + BuildID: "build-42", + InstalledAt: time.Date(2026, 5, 18, 12, 0, 0, 0, time.UTC), + BinaryPath: "/tmp/lark-sec-cli", + } + if err := SaveState(path, in); err != nil { + t.Fatalf("SaveState: %v", err) + } + got, err := LoadState(path) + if err != nil { + t.Fatalf("LoadState: %v", err) + } + if got == nil { + t.Fatal("LoadState returned nil") + } + if got.Version != in.Version || got.BuildID != in.BuildID || got.BinaryPath != in.BinaryPath { + t.Errorf("roundtrip mismatch: got=%+v want=%+v", got, in) + } + if !got.InstalledAt.Equal(in.InstalledAt) { + t.Errorf("InstalledAt mismatch: got=%v want=%v", got.InstalledAt, in.InstalledAt) + } +} + +func TestLoadState_AbsentFile(t *testing.T) { + got, err := LoadState(filepath.Join(t.TempDir(), "missing.json")) + if err != nil { + t.Fatalf("expected nil error for missing file, got %v", err) + } + if got != nil { + t.Errorf("expected nil state for missing file, got %+v", got) + } +} From bdb0cd14d1b10b95639c8b5a7240a9d890b09f05 Mon Sep 17 00:00:00 2001 From: AlbertSun Date: Wed, 20 May 2026 20:18:13 +0800 Subject: [PATCH 3/4] feat(sec): fetch lark-sec-cli install manifest via OAPI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the embedded bootstrap manifest with a typed OAPI call to GET /open-apis/security_plugin/v1/sec_cli/manifest, resolving the download URL per-platform/per-arch against the live release set. TAT auth flows through the existing credential chain; an x-tt-env header is injected when LARKSUITE_CLI_X_TT_ENV is set, for BOE routing. Drop the standalone `sec install` verb — `sec run --auto-install` (default on) makes it redundant. Add a persistent --verbose / -v flag on the sec parent, inherited by every subcommand, that emits step-by-step trace output on stderr. bootstrap.json and bootstrap.go remain in-tree as dead code; they will be removed in a follow-up cleanup. --- cmd/sec/config_init.go | 9 ++- cmd/sec/factory.go | 14 +++- cmd/sec/install.go | 62 ------------------ cmd/sec/run.go | 21 ++++-- cmd/sec/sec.go | 21 ++++-- cmd/sec/sec_test.go | 29 ++------ cmd/sec/status.go | 11 +++- cmd/sec/stop.go | 11 +++- cmd/sec/verbose.go | 32 +++++++++ internal/sec/install.go | 83 +++++++++++++++++++---- internal/sec/remote_manifest.go | 113 ++++++++++++++++++++++++++++++++ 11 files changed, 288 insertions(+), 118 deletions(-) delete mode 100644 cmd/sec/install.go create mode 100644 cmd/sec/verbose.go create mode 100644 internal/sec/remote_manifest.go diff --git a/cmd/sec/config_init.go b/cmd/sec/config_init.go index 28e652bc59..1af098001d 100644 --- a/cmd/sec/config_init.go +++ b/cmd/sec/config_init.go @@ -86,12 +86,17 @@ type secBridge struct { } func runConfigInit(cmd *cobra.Command, opts *ConfigInitOptions) error { + errOut := opts.Factory.IOStreams.ErrOut + trace := verboseOut(cmd, errOut) + + tracef(trace, "sec config init", "loading daemon bridge from %s/sec_config.json", core.GetConfigDir()) bridge, err := loadBridge() if err != nil { return output.ErrWithHint(output.ExitValidation, "sec_bridge_missing", fmt.Sprintf("daemon bridge file unreadable: %v", err), "Start the daemon first: `lark-cli sec run`.") } + tracef(trace, "sec config init", "bridge: enable=%t proxy=%s ca=%s auth=%t", bridge.Enable, bridge.Proxy, bridge.CA, bridge.Auth) if !bridge.Enable || bridge.Proxy == "" { return output.ErrWithHint(output.ExitValidation, "sec_not_running", "lark-sec-cli is not advertising an active proxy", @@ -102,6 +107,7 @@ func runConfigInit(cmd *cobra.Command, opts *ConfigInitOptions) error { // from the bridge's SEC_CA path keeps lark-cli decoupled from the daemon's // install location — if the daemon ever moves, the bridge follows and we // follow with it. + tracef(trace, "sec config init", "reading daemon HMAC key beside %s", bridge.CA) hmacKey, err := readHMACKey(bridge.CA) if err != nil { return output.Errorf(output.ExitInternal, "sec_hmac_key", "read daemon HMAC key: %v", err) @@ -111,11 +117,12 @@ func runConfigInit(cmd *cobra.Command, opts *ConfigInitOptions) error { return err } + tracef(trace, "sec config init", "POST %s/_sec/api/v1/register-app app_id=%s brand=%s", bridge.Proxy, opts.AppID, opts.Brand) if err := registerApp(cmd.Context(), bridge.Proxy, hmacKey, opts.AppID, opts.AppSecret, opts.Brand); err != nil { return output.Errorf(output.ExitAPI, "sec_register_app", "register-app: %v", err) } - output.PrintSuccess(opts.Factory.IOStreams.ErrOut, + output.PrintSuccess(errOut, fmt.Sprintf("registered app %s with lark-sec-cli (%s)", opts.AppID, opts.Brand)) return nil } diff --git a/cmd/sec/factory.go b/cmd/sec/factory.go index 3bcc1a4eb6..3b35eaf8c0 100644 --- a/cmd/sec/factory.go +++ b/cmd/sec/factory.go @@ -10,8 +10,12 @@ import ( intsec "github.com/larksuite/cli/internal/sec" ) -// installer wires up an internal/sec.Installer using the Factory's HTTP client -// and the default platform paths. Every cmd/sec subcommand starts here. +// installer wires up an internal/sec.Installer using the Factory's HTTP client, +// the default platform paths, and a lazy OAPI-client provider used to fetch +// the install manifest. APIClientFunc is a method value, not an eager call — +// commands that short-circuit (or that never install, like sec status / sec +// stop) avoid decrypting credentials from the keychain. Every cmd/sec +// subcommand starts here. func installer(f *cmdutil.Factory) (*intsec.Installer, *intsec.Paths, error) { paths, err := intsec.DefaultPaths() if err != nil { @@ -21,5 +25,9 @@ func installer(f *cmdutil.Factory) (*intsec.Installer, *intsec.Paths, error) { if err != nil { return nil, nil, fmt.Errorf("resolve http client: %w", err) } - return &intsec.Installer{Paths: paths, HTTPClient: httpClient}, paths, nil + return &intsec.Installer{ + Paths: paths, + HTTPClient: httpClient, + APIClientFunc: f.NewAPIClient, + }, paths, nil } diff --git a/cmd/sec/install.go b/cmd/sec/install.go deleted file mode 100644 index 4693e03268..0000000000 --- a/cmd/sec/install.go +++ /dev/null @@ -1,62 +0,0 @@ -// Copyright (c) 2026 Lark Technologies Pte. Ltd. -// SPDX-License-Identifier: MIT - -package sec - -import ( - "github.com/spf13/cobra" - - "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/output" - intsec "github.com/larksuite/cli/internal/sec" -) - -// InstallOptions holds inputs for `lark-cli sec install`. -type InstallOptions struct { - Factory *cmdutil.Factory - Force bool -} - -// NewCmdSecInstall performs first-time bootstrap install of lark-sec-cli from -// the embedded release manifest. After install, lark-sec-cli is in charge of -// finding and applying its own updates — this command only handles the initial -// version-on-disk step. -func NewCmdSecInstall(f *cmdutil.Factory, runF func(*InstallOptions) error) *cobra.Command { - opts := &InstallOptions{Factory: f} - cmd := &cobra.Command{ - Use: "install", - Short: "Install lark-sec-cli (first-time bootstrap)", - Long: `Install the lark-sec-cli release pinned by this lark-cli build. - -The bootstrap manifest is embedded; no external release server is consulted. -Once installed, lark-sec-cli is responsible for its own upgrade lifecycle. - -Re-running is a no-op when an install already exists. Use --force to re-pin -the install back to the version this lark-cli build ships (e.g. for repair).`, - RunE: func(cmd *cobra.Command, args []string) error { - if runF != nil { - return runF(opts) - } - return runInstall(cmd, opts) - }, - } - cmd.Flags().BoolVar(&opts.Force, "force", false, "reinstall even when an install already exists") - return cmd -} - -func runInstall(cmd *cobra.Command, opts *InstallOptions) error { - inst, paths, err := installer(opts.Factory) - if err != nil { - return output.Errorf(output.ExitInternal, "internal", "%v", err) - } - state, err := inst.Install(cmd.Context(), intsec.InstallOptions{ - Force: opts.Force, - }) - if err != nil { - return output.Errorf(output.ExitNetwork, "sec_install", "install lark-sec-cli: %v", err) - } - out := opts.Factory.IOStreams.ErrOut - output.PrintSuccess(out, - "lark-sec-cli "+state.Version+" installed (buildId="+state.BuildID+") at "+paths.BinaryPath()) - return nil -} diff --git a/cmd/sec/run.go b/cmd/sec/run.go index 9e44e55ec5..693516e30e 100644 --- a/cmd/sec/run.go +++ b/cmd/sec/run.go @@ -67,6 +67,10 @@ To stop and remove the service: lark-cli sec stop.`, func runRun(cmd *cobra.Command, opts *RunOptions) error { ctx := cmd.Context() + errOut := opts.Factory.IOStreams.ErrOut + trace := verboseOut(cmd, errOut) + + tracef(trace, "sec run", "constructing installer (lazy credentials)") inst, paths, err := installer(opts.Factory) if err != nil { return output.Errorf(output.ExitInternal, "internal", "%v", err) @@ -74,20 +78,24 @@ func runRun(cmd *cobra.Command, opts *RunOptions) error { // Make sure we have a binary on disk before asking it to install itself // as a service. + tracef(trace, "sec run", "loading state from %s", paths.StateFile()) state, err := intsec.LoadState(paths.StateFile()) if err != nil { return output.Errorf(output.ExitInternal, "internal", "load sec state: %v", err) } if state == nil { + tracef(trace, "sec run", "no install on disk (auto-install=%t)", opts.AutoInstall) if !opts.AutoInstall { return output.ErrWithHint(output.ExitValidation, "sec_not_installed", "lark-sec-cli is not installed", - "Run `lark-cli sec install` first, or re-run with --auto-install.") + "Re-run `lark-cli sec run` with --auto-install (default on), or remove --auto-install=false.") } - state, err = inst.Install(ctx, intsec.InstallOptions{}) + state, err = inst.Install(ctx, intsec.InstallOptions{Verbose: trace}) if err != nil { return output.Errorf(output.ExitNetwork, "sec_install", "auto-install lark-sec-cli: %v", err) } + } else { + tracef(trace, "sec run", "existing install: version=%s binary=%s", state.Version, state.BinaryPath) } args := []string{"service", "enable"} @@ -95,8 +103,8 @@ func runRun(cmd *cobra.Command, opts *RunOptions) error { args = append(args, fmt.Sprintf("--proxy-port=%d", opts.ProxyPort)) } - out := opts.Factory.IOStreams.ErrOut - fmt.Fprintf(out, "Running: %s %v\n", state.BinaryPath, args) + fmt.Fprintf(errOut, "Running: %s %v\n", state.BinaryPath, args) + tracef(trace, "sec run", "shelling out to %s %v", state.BinaryPath, args) c := exec.CommandContext(ctx, state.BinaryPath, args...) var stdout, stderr bytes.Buffer @@ -106,13 +114,14 @@ func runRun(cmd *cobra.Command, opts *RunOptions) error { return output.Errorf(output.ExitInternal, "sec_service_enable", "`lark-sec-cli service enable` failed: %v\nstderr: %s", err, stderr.String()) } + tracef(trace, "sec run", "service enable returned ok (%d bytes stdout)", stdout.Len()) // Forward the installer's stdout to the user — it contains the launchd / // systemd unit name, the registered executable path, and a confirmation // that the supervisor will respawn the daemon on exit. Useful diagnostic // output that's better seen than swallowed. - fmt.Fprint(out, stdout.String()) - output.PrintSuccess(out, + fmt.Fprint(errOut, stdout.String()) + output.PrintSuccess(errOut, "lark-sec-cli enabled as a user system service. Run `lark-cli sec status` to verify, `lark-cli sec stop` to disable.") return nil } diff --git a/cmd/sec/sec.go b/cmd/sec/sec.go index 77708340da..6fe3d74499 100644 --- a/cmd/sec/sec.go +++ b/cmd/sec/sec.go @@ -18,20 +18,29 @@ import ( ) // NewCmdSec builds the parent `sec` command and registers all subcommands. +// +// The persistent --verbose / -v flag is inherited by every subcommand: +// `sec run -v`, `sec status -v`, etc. all emit step-by-step trace output to +// stderr. +// +// There is no `sec install` subcommand — `sec run` auto-installs lark-sec-cli +// if no binary is on disk, so a separate install verb was redundant. func NewCmdSec(f *cmdutil.Factory) *cobra.Command { + var verbose bool cmd := &cobra.Command{ Use: "sec", - Short: "Manage the lark-sec-cli security sidecar (install, run, status)", + Short: "Manage the lark-sec-cli security sidecar (run, status, stop, config)", Long: `Manage the lark-sec-cli security sidecar. lark-sec-cli is a local HTTPS proxy daemon that intercepts lark-cli's traffic, injects BDMS risk-control signatures, and manages credentials via the OS -keychain. These subcommands handle the install and runtime lifecycle from -lark-cli's side: bootstrap-install the daemon, run it in the background, and -wire the captured environment back into lark-cli. Updates after the first -install are managed by lark-sec-cli itself.`, +keychain. These subcommands handle the runtime lifecycle from lark-cli's side: +start the daemon (auto-installing on first run), inspect its state, register +an app with it, and stop it. Updates after the first install are managed by +lark-sec-cli itself.`, } - cmd.AddCommand(NewCmdSecInstall(f, nil)) + cmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, + "print step-by-step pipeline output to stderr") cmd.AddCommand(NewCmdSecRun(f, nil)) cmd.AddCommand(NewCmdSecStop(f, nil)) cmd.AddCommand(NewCmdSecStatus(f, nil)) diff --git a/cmd/sec/sec_test.go b/cmd/sec/sec_test.go index a1b309f19e..9e5e4a5684 100644 --- a/cmd/sec/sec_test.go +++ b/cmd/sec/sec_test.go @@ -12,9 +12,11 @@ import ( ) // TestNewCmdSec_HasAllSubcommands locks in the public command surface so a -// future refactor doesn't silently drop install/run/etc. The `update` verb +// future refactor doesn't silently drop run/status/etc. The `update` verb // was intentionally removed when lark-sec-cli took over its own upgrade -// lifecycle; if it ever needs to come back, add it here too. +// lifecycle; if it ever needs to come back, add it here too. `install` was +// removed because `sec run --auto-install` (default on) makes a standalone +// install verb redundant. func TestNewCmdSec_HasAllSubcommands(t *testing.T) { f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"}) cmd := NewCmdSec(f) @@ -24,7 +26,7 @@ func TestNewCmdSec_HasAllSubcommands(t *testing.T) { got = append(got, c.Name()) } sort.Strings(got) - want := []string{"config", "install", "run", "status", "stop"} + want := []string{"config", "run", "status", "stop"} if len(got) != len(want) { t.Fatalf("subcommands = %v, want %v", got, want) } @@ -34,24 +36,3 @@ func TestNewCmdSec_HasAllSubcommands(t *testing.T) { } } } - -// TestNewCmdSecInstall_FlagParsing follows the cmd/auth/auth_test pattern: -// inject runF, parse flags, assert opts captured them. -func TestNewCmdSecInstall_FlagParsing(t *testing.T) { - f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"}) - var got *InstallOptions - cmd := NewCmdSecInstall(f, func(opts *InstallOptions) error { - got = opts - return nil - }) - cmd.SetArgs([]string{"--force"}) - if err := cmd.Execute(); err != nil { - t.Fatalf("Execute: %v", err) - } - if got == nil { - t.Fatal("runF not invoked") - } - if !got.Force { - t.Errorf("Force = false, want true") - } -} diff --git a/cmd/sec/status.go b/cmd/sec/status.go index 95b7c61c0e..b5123472a1 100644 --- a/cmd/sec/status.go +++ b/cmd/sec/status.go @@ -45,31 +45,38 @@ func NewCmdSecStatus(f *cmdutil.Factory, runF func(*StatusOptions) error) *cobra } func runStatus(cmd *cobra.Command, opts *StatusOptions) error { + errOut := opts.Factory.IOStreams.ErrOut + trace := verboseOut(cmd, errOut) + + tracef(trace, "sec status", "constructing installer (lazy credentials)") _, paths, err := installer(opts.Factory) if err != nil { return output.Errorf(output.ExitInternal, "internal", "%v", err) } out := opts.Factory.IOStreams.Out + tracef(trace, "sec status", "loading state from %s", paths.StateFile()) state, err := intsec.LoadState(paths.StateFile()) if err != nil { return output.Errorf(output.ExitInternal, "internal", "load sec state: %v", err) } if state == nil { fmt.Fprintln(out, "lark-sec-cli: not installed") - fmt.Fprintln(out, " run: lark-cli sec install") + fmt.Fprintln(out, " run: lark-cli sec run") return nil } - fmt.Fprintf(out, "lark-sec-cli %s (buildId=%s)\n", state.Version, state.BuildID) + fmt.Fprintf(out, "lark-sec-cli %s\n", state.Version) fmt.Fprintf(out, " binary: %s\n", state.BinaryPath) // Daemon-side detail via `lark-sec-cli status`. The daemon's status // command already covers service registration + pid + proxy reachability // + bridge file — better than re-implementing those here. + tracef(trace, "sec status", "shelling out to %s status", state.BinaryPath) c := exec.CommandContext(cmd.Context(), state.BinaryPath, "status") var stdout, stderr bytes.Buffer c.Stdout = &stdout c.Stderr = &stderr runErr := c.Run() + tracef(trace, "sec status", "daemon status exit=%v stdout=%d bytes stderr=%d bytes", runErr, stdout.Len(), stderr.Len()) fmt.Fprintln(out, " --- lark-sec-cli status ---") if stdout.Len() > 0 { fmt.Fprint(out, indent(stdout.String(), " ")) diff --git a/cmd/sec/stop.go b/cmd/sec/stop.go index 669ba97b4c..58f6a1fcae 100644 --- a/cmd/sec/stop.go +++ b/cmd/sec/stop.go @@ -43,23 +43,29 @@ func NewCmdSecStop(f *cmdutil.Factory, runF func(*StopOptions) error) *cobra.Com } func runStop(cmd *cobra.Command, opts *StopOptions) error { + out := opts.Factory.IOStreams.ErrOut + trace := verboseOut(cmd, out) + + tracef(trace, "sec stop", "constructing installer (lazy credentials)") _, paths, err := installer(opts.Factory) if err != nil { return output.Errorf(output.ExitInternal, "internal", "%v", err) } + tracef(trace, "sec stop", "loading state from %s", paths.StateFile()) state, err := intsec.LoadState(paths.StateFile()) if err != nil { return output.Errorf(output.ExitInternal, "internal", "load sec state: %v", err) } if state == nil { // Nothing on disk to stop — no-op. - output.PrintSuccess(opts.Factory.IOStreams.ErrOut, "lark-sec-cli not installed; nothing to stop") + tracef(trace, "sec stop", "no install on disk; nothing to stop") + output.PrintSuccess(out, "lark-sec-cli not installed; nothing to stop") return nil } - out := opts.Factory.IOStreams.ErrOut args := []string{"service", "disable"} fmt.Fprintf(out, "Running: %s %v\n", state.BinaryPath, args) + tracef(trace, "sec stop", "shelling out to %s %v", state.BinaryPath, args) c := exec.CommandContext(cmd.Context(), state.BinaryPath, args...) var stdout, stderr bytes.Buffer @@ -69,6 +75,7 @@ func runStop(cmd *cobra.Command, opts *StopOptions) error { return output.Errorf(output.ExitInternal, "sec_service_disable", "`lark-sec-cli service disable` failed: %v\nstderr: %s", err, stderr.String()) } + tracef(trace, "sec stop", "service disable returned ok (%d bytes stdout)", stdout.Len()) fmt.Fprint(out, stdout.String()) output.PrintSuccess(out, "lark-sec-cli service disabled") return nil diff --git a/cmd/sec/verbose.go b/cmd/sec/verbose.go new file mode 100644 index 0000000000..299e7456e2 --- /dev/null +++ b/cmd/sec/verbose.go @@ -0,0 +1,32 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sec + +import ( + "fmt" + "io" + + "github.com/spf13/cobra" +) + +// verboseOut returns the trace destination for a sec subcommand: the given +// stderr writer when the inherited --verbose / -v flag is set, otherwise nil. +// Pair with tracef — a nil destination silently drops traces, so callers can +// emit unconditionally. +func verboseOut(cmd *cobra.Command, errOut io.Writer) io.Writer { + if v, _ := cmd.Flags().GetBool("verbose"); v { + return errOut + } + return nil +} + +// tracef writes one trace line to w when w is non-nil. The prefix names the +// emitting subcommand (e.g. "sec run") so layered output from the install +// pipeline + the command itself stays distinguishable. +func tracef(w io.Writer, prefix, format string, args ...any) { + if w == nil { + return + } + fmt.Fprintf(w, "[%s] "+format+"\n", append([]any{prefix}, args...)...) +} diff --git a/internal/sec/install.go b/internal/sec/install.go index b4553a8610..223bd35206 100644 --- a/internal/sec/install.go +++ b/internal/sec/install.go @@ -14,18 +14,25 @@ import ( "path/filepath" "runtime" "time" + + "github.com/larksuite/cli/internal/client" ) // Installer orchestrates first-time install of lark-sec-cli: -// load embedded bootstrap manifest → download zip → extract into +// fetch remote manifest via OAPI → download zip → extract into // versions// → swap "current" → write state.json. // // After this first install, lark-sec-cli takes over its own updates and // lark-cli is no longer in the update path. The installer therefore only -// knows about the bootstrap manifest — no Tron, no other release sources. +// knows about the bootstrap path — no Tron, no other release sources. type Installer struct { Paths *Paths HTTPClient *http.Client + // APIClientFunc resolves the OAPI client lazily. It is invoked only when + // the install pipeline actually needs to fetch the remote manifest — + // short-circuits (and other callers of installer() that don't install, + // like sec status / sec stop) avoid keychain decryption entirely. + APIClientFunc func() (*client.APIClient, error) } // InstallOptions tunes a single Install call. @@ -36,58 +43,108 @@ type InstallOptions struct { // Region selects which region's URLs to pick from the manifest. Defaults to // DefaultRegion ("cn"). Reserved for future brand split. Region string + // Verbose, when non-nil, is the destination for step-by-step trace output. + // nil = silent (production default); typically set to stderr by `sec install -v`. + Verbose io.Writer +} + +// tracef writes one trace line to w if w is non-nil. +func tracef(w io.Writer, format string, args ...any) { + if w == nil { + return + } + fmt.Fprintf(w, "[sec install] "+format+"\n", args...) } // Install runs the bootstrap pipeline and returns the new State on success. // If a usable install already exists on disk and Force is false, returns the // existing state unchanged (no network call). func (i *Installer) Install(ctx context.Context, opts InstallOptions) (*State, error) { + v := opts.Verbose + tracef(v, "ensuring sec paths under %s", i.Paths.InstallDir()) if err := i.Paths.Ensure(); err != nil { return nil, err } + tracef(v, "loading existing state from %s", i.Paths.StateFile()) existing, err := LoadState(i.Paths.StateFile()) if err != nil { return nil, fmt.Errorf("load sec state: %w", err) } + if existing != nil { + tracef(v, "existing state: version=%s binary=%s", existing.Version, existing.BinaryPath) + } else { + tracef(v, "no existing state on disk") + } // Idempotent short-circuit: nothing to do if an install is already on disk. // Self-upgrades after bootstrap are lark-sec-cli's job, not ours — see the // upgrade subsystem in lark-sec-cli/internal/upgrade/. if !opts.Force && existing != nil && binaryReady(existing.BinaryPath) { + tracef(v, "binary exists at %s — short-circuiting (no network)", existing.BinaryPath) return existing, nil } + if opts.Force { + tracef(v, "--force set; running full install pipeline") + } else { + tracef(v, "no usable install on disk; running full install pipeline") + } region := opts.Region if region == "" { region = DefaultRegion } + tracef(v, "region=%s", region) + + if i.APIClientFunc == nil { + return nil, errors.New("sec installer: APIClientFunc is required to fetch remote manifest") + } + tracef(v, "resolving OAPI client (will decrypt credentials)") + apiClient, err := i.APIClientFunc() + if err != nil { + return nil, fmt.Errorf("resolve api client: %w", err) + } + platform, arch, err := CurrentPlatformArch() + if err != nil { + return nil, err + } + tracef(v, "detected platform=%s arch=%s", platform, arch) - manifest, err := LoadBootstrap() + tracef(v, "fetching remote manifest from %s", secCliManifestPath) + rm, err := FetchRemoteManifest(ctx, apiClient, region, platform, arch, v) if err != nil { return nil, err } - artifact, err := manifest.PickArtifact(runtime.GOOS, runtime.GOARCH, region) + tracef(v, "manifest returned %d url(s): %v", len(rm.URLs), rm.URLs) + downloadURL := rm.URLs[0] + tracef(v, "picked downloadURL=%s", downloadURL) + version, err := versionFromURL(downloadURL) if err != nil { return nil, err } + tracef(v, "parsed version=%s", version) - versionDir := i.Paths.VersionDir(artifact.Version) + versionDir := i.Paths.VersionDir(version) + tracef(v, "creating versionDir=%s", versionDir) if err := os.MkdirAll(versionDir, 0o755); err != nil { return nil, err } - zipPath := filepath.Join(i.Paths.VersionsDir(), artifact.Version+".zip") + zipPath := filepath.Join(i.Paths.VersionsDir(), version+".zip") + tracef(v, "downloading %s -> %s", downloadURL, zipPath) if err := Download(ctx, DownloadOptions{ - URL: artifact.URL, - Destination: zipPath, - HTTPClient: i.HTTPClient, - ExpectedSHA256: artifact.SHA256, + URL: downloadURL, + Destination: zipPath, + HTTPClient: i.HTTPClient, }); err != nil { return nil, err } + if info, statErr := os.Stat(zipPath); statErr == nil { + tracef(v, "downloaded %d bytes", info.Size()) + } defer os.Remove(zipPath) // free disk; we keep the unpacked version dir + tracef(v, "extracting %s -> %s", zipPath, versionDir) if err := ExtractZip(zipPath, versionDir); err != nil { return nil, err } @@ -96,6 +153,7 @@ func (i *Installer) Install(ctx context.Context, opts InstallOptions) (*State, e if err != nil { return nil, err } + tracef(v, "located binary at %s", binaryPath) // Ensure executable bit on POSIX — some zips lose it. if runtime.GOOS != "windows" { if info, err := os.Stat(binaryPath); err == nil { @@ -103,13 +161,14 @@ func (i *Installer) Install(ctx context.Context, opts InstallOptions) (*State, e } } + tracef(v, "swapping %s -> %s", i.Paths.CurrentLink(), versionDir) if err := swapCurrent(i.Paths.CurrentLink(), versionDir); err != nil { return nil, fmt.Errorf("swap current: %w", err) } + tracef(v, "writing state.json to %s", i.Paths.StateFile()) state := &State{ - Version: artifact.Version, - BuildID: artifact.BuildID, + Version: version, InstalledAt: time.Now().UTC(), BinaryPath: i.Paths.BinaryPath(), } diff --git a/internal/sec/remote_manifest.go b/internal/sec/remote_manifest.go new file mode 100644 index 0000000000..220b9050b1 --- /dev/null +++ b/internal/sec/remote_manifest.go @@ -0,0 +1,113 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sec + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "regexp" + + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + + "github.com/larksuite/cli/internal/client" + "github.com/larksuite/cli/internal/core" +) + +// secCliManifestPath is the OAPI endpoint that returns the per-platform +// download URLs for lark-sec-cli, gated by tenant_access_token. +const secCliManifestPath = "/open-apis/security_plugin/v1/sec_cli/manifest" + +// xTTEnvEnv, when set, injects an x-tt-env header on the manifest request. +// Used for BOE / sub-environment routing (e.g. value "boe_tns_api"). Unset +// in prod — the gateway treats absence as "no override". This is the only +// debug-routing knob in this file; brand/domain switching itself is handled +// at the network layer via the lark-env.sh Whistle pattern in the +// lark-cli maintainer doc. +const xTTEnvEnv = "LARKSUITE_CLI_X_TT_ENV" + +// RemoteManifest is the payload returned by GET /open-apis/security_plugin/v1/sec_cli/manifest +// for a single (region, platform, arch) combination. The server returns only +// the download URLs; version metadata is parsed from the URL itself (see +// versionFromURL). +type RemoteManifest struct { + URLs []string `json:"urls"` +} + +// FetchRemoteManifest calls the OAPI manifest endpoint with TAT (bot) auth +// and returns the typed payload for the given region/platform/arch. When the +// LARKSUITE_CLI_X_TT_ENV env var is set, its value is sent as an x-tt-env +// request header for sub-environment routing. +// +// Errors are returned as-is — there is no fallback to the embedded +// bootstrap manifest. Callers that need offline behavior must handle that +// explicitly. +func FetchRemoteManifest( + ctx context.Context, + ac *client.APIClient, + region, platform, arch string, + verbose io.Writer, +) (*RemoteManifest, error) { + req := &larkcore.ApiReq{ + HttpMethod: "GET", + ApiPath: secCliManifestPath, + QueryParams: larkcore.QueryParams{ + "region": []string{region}, + "platform": []string{platform}, + "arch": []string{arch}, + }, + } + tracef(verbose, "GET %s?region=%s&platform=%s&arch=%s as=bot", secCliManifestPath, region, platform, arch) + + var extraOpts []larkcore.RequestOptionFunc + if v := os.Getenv(xTTEnvEnv); v != "" { + h := http.Header{} + h.Set("x-tt-env", v) + extraOpts = append(extraOpts, larkcore.WithHeaders(h)) + tracef(verbose, "injecting header x-tt-env=%s (from %s)", v, xTTEnvEnv) + } + + resp, err := ac.DoSDKRequest(ctx, req, core.AsBot, extraOpts...) + if err != nil { + return nil, fmt.Errorf("sec_cli manifest request: %w", err) + } + tracef(verbose, "response status=%d body-len=%d body=%q", resp.StatusCode, len(resp.RawBody), string(resp.RawBody)) + + var env struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data *RemoteManifest `json:"data"` + } + if err := json.Unmarshal(resp.RawBody, &env); err != nil { + // Print body unconditionally on decode failure — a malformed response is + // the most common case where the caller needs to see exactly what arrived. + fmt.Fprintf(os.Stderr, "[sec_cli manifest] decode failed; status=%d len=%d body=%q\n", resp.StatusCode, len(resp.RawBody), string(resp.RawBody)) + return nil, fmt.Errorf("sec_cli manifest decode: %w", err) + } + if env.Code != 0 { + return nil, fmt.Errorf("sec_cli manifest error %d: %s", env.Code, env.Msg) + } + if env.Data == nil || len(env.Data.URLs) == 0 { + return nil, fmt.Errorf("sec_cli manifest: no urls for region=%s platform=%s arch=%s", region, platform, arch) + } + return env.Data, nil +} + +// versionFromURL extracts the release version from a download URL of the form +// .../releases////.zip +// The server-side manifest does not return version as a discrete field; +// state.json's Version needs *something* to disambiguate concurrent installs +// in versions//, so we parse it out here. +var releaseVersionRE = regexp.MustCompile(`/releases/([^/]+)/`) + +func versionFromURL(u string) (string, error) { + m := releaseVersionRE.FindStringSubmatch(u) + if len(m) < 2 || m[1] == "" { + return "", fmt.Errorf("could not parse release version from URL %q", u) + } + return m[1], nil +} From 054ff9339b29719e78641c19dd0e831a592ca651 Mon Sep 17 00:00:00 2001 From: AlbertSun Date: Fri, 22 May 2026 16:43:24 +0800 Subject: [PATCH 4/4] feat(sec): integrate enterprise cli --- internal/sec/archive.go | 8 ++++---- internal/sec/install.go | 5 +---- internal/sec/paths.go | 32 +++++++++++++++++++++++--------- 3 files changed, 28 insertions(+), 17 deletions(-) diff --git a/internal/sec/archive.go b/internal/sec/archive.go index 01f7972f70..bb0ca58747 100644 --- a/internal/sec/archive.go +++ b/internal/sec/archive.go @@ -24,8 +24,8 @@ const maxArchiveBytes = 1 << 30 // already exist. // // Executable permission is preserved when the zip stores POSIX mode bits; -// otherwise we apply 0o755 to suspected binaries (anything named lark-sec-cli -// or *.dylib/*.so) and 0o644 to everything else. +// otherwise we apply 0o755 to suspected binaries (matching BinaryName() / +// legacy names or anything *.dylib/*.so/*.dll) and 0o644 to everything else. func ExtractZip(src, dst string) error { r, err := zip.OpenReader(src) if err != nil { @@ -122,7 +122,7 @@ func extractZipEntry(f *zip.File, dstAbs string) error { // with POSIX mode info — typically the case for archives built on Windows. func guessMode(name string) os.FileMode { base := filepath.Base(name) - if base == BinaryName || base == BinaryName+".exe" { + if base == BinaryName() { return 0o755 } ext := strings.ToLower(filepath.Ext(base)) @@ -131,7 +131,7 @@ func guessMode(name string) os.FileMode { return 0o755 } if runtime.GOOS != "windows" && !strings.ContainsRune(base, '.') { - // Plausibly an extra unix binary shipped alongside lark-sec-cli. + // Plausibly an extra unix binary shipped alongside the sec-cli binary. return 0o755 } return 0o644 diff --git a/internal/sec/install.go b/internal/sec/install.go index 223bd35206..af7c07ef84 100644 --- a/internal/sec/install.go +++ b/internal/sec/install.go @@ -183,10 +183,7 @@ func (i *Installer) Install(ctx context.Context, opts InstallOptions) (*State, e // manifest's example payload uses nested ("linux-amd64-1.0.1-alpha.23/..."); // we accommodate either since the wrapping dir name could change per build. func locateBinary(versionDir string) (string, error) { - name := BinaryName - if runtime.GOOS == "windows" { - name += ".exe" - } + name := BinaryName() flat := filepath.Join(versionDir, name) if _, err := os.Stat(flat); err == nil { diff --git a/internal/sec/paths.go b/internal/sec/paths.go index 7ab0fa37ab..fa27d5aaa5 100644 --- a/internal/sec/paths.go +++ b/internal/sec/paths.go @@ -21,11 +21,28 @@ const ( // tree (install + data) to a single root. When set, install_dir is // and data_dir is /data — no platform-conventional lookup happens. envInstallDirOverride = "LARKSUITE_CLI_SEC_DIR" - - // BinaryName is the executable name we expect inside the extracted artifact. - BinaryName = "lark-sec-cli" ) +// BinaryName returns the executable basename inside the sec-cli artifact for +// the current platform: +// +// darwin → libLarkEntCli.dylib +// linux → liblarkentcli.so +// windows → lark_enterprise_cli.exe +// +// The .dylib/.so extensions on POSIX are convention only — those files are +// normal Mach-O / ELF executables, not loadable libraries. +func BinaryName() string { + switch runtime.GOOS { + case "darwin": + return "libLarkEntCli.dylib" + case "windows": + return "lark_enterprise_cli.exe" + default: + return "liblarkentcli.so" + } +} + // Paths exposes the filesystem layout for the sec sidecar. All methods return // absolute paths; nothing on disk is created — callers must call Ensure(). type Paths struct { @@ -119,13 +136,10 @@ func (p *Paths) VersionDir(version string) string { // CurrentLink points to the active version (symlink on POSIX, plain copy on Windows). func (p *Paths) CurrentLink() string { return filepath.Join(p.install, "current") } -// BinaryPath is the active lark-sec-cli executable (with .exe on Windows). +// BinaryPath is the active sec-cli executable, addressed through the +// `current` symlink so it stays valid across version swaps. func (p *Paths) BinaryPath() string { - name := BinaryName - if runtime.GOOS == "windows" { - name += ".exe" - } - return filepath.Join(p.CurrentLink(), name) + return filepath.Join(p.CurrentLink(), BinaryName()) } // StateFile records what version is installed and where its binary lives.