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..1af098001d --- /dev/null +++ b/cmd/sec/config_init.go @@ -0,0 +1,251 @@ +// 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 { + 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", + "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. + 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) + } + + if err := promptForMissing(opts); err != nil { + 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(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..3b35eaf8c0 --- /dev/null +++ b/cmd/sec/factory.go @@ -0,0 +1,33 @@ +// 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, +// 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 { + 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, + APIClientFunc: f.NewAPIClient, + }, paths, nil +} diff --git a/cmd/sec/run.go b/cmd/sec/run.go new file mode 100644 index 0000000000..693516e30e --- /dev/null +++ b/cmd/sec/run.go @@ -0,0 +1,127 @@ +// 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() + 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) + } + + // 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", + "Re-run `lark-cli sec run` with --auto-install (default on), or remove --auto-install=false.") + } + 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"} + if opts.ProxyPort > 0 { + args = append(args, fmt.Sprintf("--proxy-port=%d", opts.ProxyPort)) + } + + 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 + 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()) + } + 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(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 new file mode 100644 index 0000000000..6fe3d74499 --- /dev/null +++ b/cmd/sec/sec.go @@ -0,0 +1,49 @@ +// 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. +// +// 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 (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 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.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)) + 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..9e5e4a5684 --- /dev/null +++ b/cmd/sec/sec_test.go @@ -0,0 +1,38 @@ +// 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 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. `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) + + var got []string + for _, c := range cmd.Commands() { + got = append(got, c.Name()) + } + sort.Strings(got) + want := []string{"config", "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) + } + } +} diff --git a/cmd/sec/status.go b/cmd/sec/status.go new file mode 100644 index 0000000000..b5123472a1 --- /dev/null +++ b/cmd/sec/status.go @@ -0,0 +1,115 @@ +// 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 { + 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 run") + return nil + } + 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(), " ")) + } + 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..58f6a1fcae --- /dev/null +++ b/cmd/sec/stop.go @@ -0,0 +1,82 @@ +// 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 { + 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. + tracef(trace, "sec stop", "no install on disk; nothing to stop") + output.PrintSuccess(out, "lark-sec-cli not installed; nothing to stop") + return nil + } + + 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 + 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()) + } + 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/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/sec/archive.go b/internal/sec/archive.go new file mode 100644 index 0000000000..bb0ca58747 --- /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 (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 { + 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() { + 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 the sec-cli binary. + 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..af7c07ef84 --- /dev/null +++ b/internal/sec/install.go @@ -0,0 +1,297 @@ +// 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" + + "github.com/larksuite/cli/internal/client" +) + +// Installer orchestrates first-time install of lark-sec-cli: +// 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 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. +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 + // 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) + + tracef(v, "fetching remote manifest from %s", secCliManifestPath) + rm, err := FetchRemoteManifest(ctx, apiClient, region, platform, arch, v) + if err != nil { + return nil, err + } + 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(version) + tracef(v, "creating versionDir=%s", versionDir) + if err := os.MkdirAll(versionDir, 0o755); err != nil { + return nil, err + } + zipPath := filepath.Join(i.Paths.VersionsDir(), version+".zip") + + tracef(v, "downloading %s -> %s", downloadURL, zipPath) + if err := Download(ctx, DownloadOptions{ + 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 + } + + binaryPath, err := locateBinary(versionDir) + 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 { + _ = os.Chmod(binaryPath, info.Mode()|0o100|0o010|0o001) + } + } + + 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: version, + 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() + + 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..fa27d5aaa5 --- /dev/null +++ b/internal/sec/paths.go @@ -0,0 +1,146 @@ +// 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 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 { + 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 sec-cli executable, addressed through the +// `current` symlink so it stays valid across version swaps. +func (p *Paths) BinaryPath() string { + return filepath.Join(p.CurrentLink(), BinaryName()) +} + +// 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/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 +} 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) + } +} 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() {