From edf056eb9fc4f6b4fee03325dee819ad8ad0a57f Mon Sep 17 00:00:00 2001 From: reachableceo Date: Sat, 29 Aug 2026 00:08:38 -0500 Subject: [PATCH] Add the thin CLI: login, projects, secrets list, get Read-path commands over the library. Credentials never come from flags or arguments; get prints the bare value for $(...) plumbing and nothing else ever touches stdout; stderr carries only redacted diagnostics. Exit codes mirror keyproxy (0 ok, 1 usage/config, 2 auth or resolution failure). CLI tests drive the fake server through a real 0600 env file. --- cmd/bitwarden-go/main.go | 13 +++ internal/cli/cli.go | 187 +++++++++++++++++++++++++++++++ internal/cli/cli_test.go | 236 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 436 insertions(+) create mode 100644 cmd/bitwarden-go/main.go create mode 100644 internal/cli/cli.go create mode 100644 internal/cli/cli_test.go diff --git a/cmd/bitwarden-go/main.go b/cmd/bitwarden-go/main.go new file mode 100644 index 0000000..55712c1 --- /dev/null +++ b/cmd/bitwarden-go/main.go @@ -0,0 +1,13 @@ +// Command bitwarden-go is the Secrets Manager read CLI. All behavior +// lives in internal/cli; this file only wires stdio and exit codes. +package main + +import ( + "os" + + "git.knownelement.com/ukrrs/mopac-bitwarden-go/internal/cli" +) + +func main() { + os.Exit(cli.Run(os.Args[1:], os.Stdout, os.Stderr)) +} diff --git a/internal/cli/cli.go b/internal/cli/cli.go new file mode 100644 index 0000000..f3b8960 --- /dev/null +++ b/internal/cli/cli.go @@ -0,0 +1,187 @@ +// Package cli implements the bitwarden-go command line: a thin read-path +// front end over the library (login check, project/secret discovery, +// value fetch). Credentials come from the environment or a 0600 env file +// — never from flags or arguments. Values are printed to stdout bare +// (exec-style plumbing) and never to stderr; stderr carries only +// redacted diagnostics. +package cli + +import ( + "context" + "flag" + "fmt" + "io" + "os" + "path/filepath" + + bw "git.knownelement.com/ukrrs/mopac-bitwarden-go" + "git.knownelement.com/ukrrs/mopac-bitwarden-go/internal/config" +) + +const usage = `bitwarden-go: Bitwarden Secrets Manager read client (plain REST, no SDK) + +Usage: + bitwarden-go login [-config PATH] verify credentials (no output secrets) + bitwarden-go projects [-config PATH] list projects: " " + bitwarden-go secrets list [-config PATH] list secrets: " " + bitwarden-go get [-config PATH] print one secret value (bare, no newline) + bitwarden-go help print this usage + + is a secret name or secret uuid. Credentials come from BW_* env +vars or a 0600 env file (BW_SERVER_URL, BW_ACCESS_TOKEN, or +BW_CLIENTID+BW_CLIENTSECRET) — NEVER from flags or arguments. Tokens are +memory-only and refreshed before expiry; nothing is ever written to disk. + +Exit codes: 0 ok, 1 usage/config error, 2 auth or resolution failure. +` + +// Run executes one command; it returns the process exit code. +func Run(args []string, stdout, stderr io.Writer) int { + if len(args) == 0 { + fmt.Fprint(stderr, usage) + return 1 + } + switch args[0] { + case "help", "-h", "--help": + fmt.Fprint(stdout, usage) + return 0 + case "login": + return cmdLogin(args[1:], stdout, stderr) + case "projects": + return cmdProjects(args[1:], stdout, stderr) + case "secrets": + return cmdSecrets(args[1:], stdout, stderr) + case "get": + return cmdGet(args[1:], stdout, stderr) + default: + fmt.Fprintf(stderr, "bitwarden-go: unknown command %q\n\n%s", args[0], usage) + return 1 + } +} + +// configPath resolves -config / $BITWARDENGO_CONFIG / default location. +func configPath(explicit string) string { + if explicit != "" { + return explicit + } + if p := os.Getenv("BITWARDENGO_CONFIG"); p != "" { + return p + } + home, err := os.UserHomeDir() + if err != nil { + return "" + } + return filepath.Join(home, ".config", "bitwarden-go", "env") +} + +func newFlags(name string) *flag.FlagSet { + fs := flag.NewFlagSet(name, flag.ContinueOnError) + fs.SetOutput(io.Discard) + return fs +} + +func authenticate(stderr io.Writer, explicitConfig string) (*bw.Token, error) { + cfg, _, err := config.Load(configPath(explicitConfig)) + if err != nil { + return nil, err + } + return bw.Authenticate(context.Background(), bw.Credentials{ + BaseURL: cfg.ServerURL, + AccessToken: cfg.AccessToken, + ClientID: cfg.ClientID, + ClientSecret: cfg.ClientSecret, + }) +} + +func cmdLogin(args []string, stdout, stderr io.Writer) int { + fs := newFlags("login") + configFlag := fs.String("config", "", "credentials env file (default $BITWARDENGO_CONFIG, then ~/.config/bitwarden-go/env)") + if err := fs.Parse(args); err != nil || fs.NArg() != 0 { + fmt.Fprintln(stderr, "bitwarden-go login: no arguments expected") + return 1 + } + tok, err := authenticate(stderr, *configFlag) + if err != nil { + fmt.Fprintf(stderr, "bitwarden-go: %v\n", err) + return 2 + } + defer tok.Zero() + fmt.Fprintf(stdout, "authenticated: account %s, %s\n", tok.AccountID, tok) + return 0 +} + +func cmdProjects(args []string, stdout, stderr io.Writer) int { + fs := newFlags("projects") + configFlag := fs.String("config", "", "credentials env file") + if err := fs.Parse(args); err != nil || fs.NArg() != 0 { + fmt.Fprintln(stderr, "bitwarden-go projects: no arguments expected") + return 1 + } + tok, err := authenticate(stderr, *configFlag) + if err != nil { + fmt.Fprintf(stderr, "bitwarden-go: %v\n", err) + return 2 + } + defer tok.Zero() + projects, err := bw.ListProjects(context.Background(), tok) + if err != nil { + fmt.Fprintf(stderr, "bitwarden-go: %v\n", err) + return 2 + } + for _, p := range projects { + fmt.Fprintf(stdout, "%s %s\n", p.ID, p.Name) + } + return 0 +} + +func cmdSecrets(args []string, stdout, stderr io.Writer) int { + fs := newFlags("secrets") + configFlag := fs.String("config", "", "credentials env file") + if err := fs.Parse(args); err != nil { + fmt.Fprintln(stderr, "bitwarden-go secrets: usage: bitwarden-go secrets list") + return 1 + } + if fs.NArg() != 1 || fs.Arg(0) != "list" { + fmt.Fprintln(stderr, "bitwarden-go secrets: usage: bitwarden-go secrets list") + return 1 + } + tok, err := authenticate(stderr, *configFlag) + if err != nil { + fmt.Fprintf(stderr, "bitwarden-go: %v\n", err) + return 2 + } + defer tok.Zero() + secrets, err := bw.ListSecrets(context.Background(), tok) + if err != nil { + fmt.Fprintf(stderr, "bitwarden-go: %v\n", err) + return 2 + } + for _, s := range secrets { + fmt.Fprintf(stdout, "%s %s\n", s.ID, s.Name) + } + return 0 +} + +func cmdGet(args []string, stdout, stderr io.Writer) int { + fs := newFlags("get") + configFlag := fs.String("config", "", "credentials env file") + if err := fs.Parse(args); err != nil || fs.NArg() != 1 { + fmt.Fprintln(stderr, "bitwarden-go get: exactly one secret name or id required") + return 1 + } + key := fs.Arg(0) + tok, err := authenticate(stderr, *configFlag) + if err != nil { + fmt.Fprintf(stderr, "bitwarden-go: %v\n", err) + return 2 + } + defer tok.Zero() + value, err := bw.GetSecret(context.Background(), tok, key) + if err != nil { + fmt.Fprintf(stderr, "bitwarden-go: %v\n", err) + return 2 + } + // Bare value, no trailing newline: $(bitwarden-go get name) plumbing. + fmt.Fprint(stdout, value) + return 0 +} diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go new file mode 100644 index 0000000..cfaed04 --- /dev/null +++ b/internal/cli/cli_test.go @@ -0,0 +1,236 @@ +package cli + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "git.knownelement.com/ukrrs/mopac-bitwarden-go/internal/fakesm" +) + +// Every CLI test runs against the in-process fake Secrets Manager through +// a real 0600 env file, exercising the full path: config load -> auth -> +// decrypt -> output. stderr output is asserted secret-free. + +type env struct { + stdout, stderr bytes.Buffer + srv *fakesm.Server + _cred string +} + +func (e *env) run(t *testing.T, args ...string) int { + t.Helper() + e.stdout.Reset() + e.stderr.Reset() + return Run(args, &e.stdout, &e.stderr) +} + +// redaction asserts stderr never leaked the fake's material. stdout is +// checked too EXCEPT for `get`, whose whole purpose is printing one +// value (the per-command tests pin the exact stdout). +func (e *env) redaction(t *testing.T) { + t.Helper() + for _, forbidden := range e.forbidden() { + if strings.Contains(e.stderr.String(), forbidden) { + t.Fatalf("stderr leaks secret material (%q...): %q", forbidden[:min(8, len(forbidden))], e.stderr.String()) + } + } +} + +// redactionFull additionally asserts stdout carries no material (login, +// listings, error paths). +func (e *env) redactionFull(t *testing.T) { + t.Helper() + e.redaction(t) + for _, forbidden := range e.forbidden() { + if strings.Contains(e.stdout.String(), forbidden) { + t.Fatalf("stdout leaks secret material (%q...): %q", forbidden[:min(8, len(forbidden))], e.stdout.String()) + } + } +} + +func (e *env) forbidden() []string { + reds := []string{ + e.srv.ClientSecret, + e.srv.LastAccessToken(), + e.srv.LastRefreshToken(), + e.srv.Secrets[0].Value, + e.srv.Secrets[1].Value, + } + out := make([]string, 0, len(reds)) + for _, r := range reds { + if r != "" { + out = append(out, r) + } + } + return out +} + +func newEnv(t *testing.T) *env { + t.Helper() + srv, cred := fakesm.NewServer() + srv.Start() + t.Cleanup(srv.Close) + return &env{srv: srv, _cred: cred} +} + +// writeEnvFile writes the 0600 credential file and points BITWARDENGO_CONFIG at it. +func (e *env) writeEnvFile(t *testing.T) { + t.Helper() + t.Setenv("BITWARDENGO_CONFIG", "") + os.Unsetenv("BITWARDENGO_CONFIG") + for _, k := range []string{"BW_SERVER_URL", "BW_ACCESS_TOKEN", "BW_CLIENTID", "BW_CLIENTSECRET"} { + t.Setenv(k, "") + os.Unsetenv(k) + } + path := filepath.Join(t.TempDir(), "env") + content := fmt.Sprintf("BW_SERVER_URL=%s\nBW_ACCESS_TOKEN=%s\n", e.srv.BaseURL(), e._cred) + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv("BITWARDENGO_CONFIG", path) +} + +func TestCLILogin(t *testing.T) { + e := newEnv(t) + e.writeEnvFile(t) + code := e.run(t, "login") + e.redactionFull(t) + if code != 0 { + t.Fatalf("login exit %d, stderr=%s", code, e.stderr.String()) + } + out := e.stdout.String() + if !strings.Contains(out, "authenticated: account "+e.srv.ClientID) { + t.Fatalf("login output: %q", out) + } + if !strings.Contains(out, "expires") { + t.Fatalf("login output lacks expiry: %q", out) + } +} + +func TestCLIGet(t *testing.T) { + e := newEnv(t) + e.writeEnvFile(t) + code := e.run(t, "get", e.srv.Secrets[0].Name) + e.redaction(t) + if code != 0 { + t.Fatalf("get exit %d, stderr=%s", code, e.stderr.String()) + } + if got := e.stdout.String(); got != e.srv.Secrets[0].Value { + t.Fatalf("get output %q != value (bare, no newline enforced)", got) + } + if e.stderr.Len() != 0 { + t.Fatalf("get wrote to stderr: %q", e.stderr.String()) + } +} + +func TestCLIGetMissingSecret(t *testing.T) { + e := newEnv(t) + e.writeEnvFile(t) + code := e.run(t, "get", "does-not-exist") + e.redaction(t) + if code != 2 { + t.Fatalf("expected exit 2, got %d", code) + } + if !strings.Contains(e.stderr.String(), "secret not found") { + t.Fatalf("stderr: %q", e.stderr.String()) + } + if e.stdout.Len() != 0 { + t.Fatalf("stdout should be empty on failure: %q", e.stdout.String()) + } +} + +func TestCLIBadCredentials(t *testing.T) { + e := newEnv(t) + e.srv.RejectAuth = true + e.writeEnvFile(t) + code := e.run(t, "get", e.srv.Secrets[0].Name) + e.redaction(t) + if code != 2 { + t.Fatalf("expected exit 2, got %d", code) + } + if !strings.Contains(e.stderr.String(), "auth failed") { + t.Fatalf("stderr: %q", e.stderr.String()) + } + e.redactionFull(t) +} + +func TestCLILists(t *testing.T) { + e := newEnv(t) + e.writeEnvFile(t) + if code := e.run(t, "projects"); code != 0 { + t.Fatalf("projects exit %d: %s", code, e.stderr.String()) + } + e.redaction(t) + if !strings.Contains(e.stdout.String(), e.srv.Projects[0].ID+" harness") { + t.Fatalf("projects output: %q", e.stdout.String()) + } + if code := e.run(t, "secrets", "list"); code != 0 { + t.Fatalf("secrets list exit %d: %s", code, e.stderr.String()) + } + e.redaction(t) + out := e.stdout.String() + if !strings.Contains(out, e.srv.Secrets[0].ID+" redmine-api-key") || !strings.Contains(out, e.srv.Secrets[1].ID+" litellm-key") { + t.Fatalf("secrets list output: %q", out) + } +} + +func TestCLIMissingCredentials(t *testing.T) { + e := newEnv(t) + t.Setenv("BITWARDENGO_CONFIG", filepath.Join(t.TempDir(), "nothing-here")) + for _, k := range []string{"BW_SERVER_URL", "BW_ACCESS_TOKEN", "BW_CLIENTID", "BW_CLIENTSECRET"} { + t.Setenv(k, "") + os.Unsetenv(k) + } + code := e.run(t, "login") + if code != 2 { + t.Fatalf("expected exit 2 for auth failure path, got %d", code) + } + if !strings.Contains(e.stderr.String(), "no credentials") { + t.Fatalf("stderr: %q", e.stderr.String()) + } + e.redactionFull(t) +} + +func TestCLIRefusesLooseEnvFile(t *testing.T) { + e := newEnv(t) + for _, k := range []string{"BW_SERVER_URL", "BW_ACCESS_TOKEN", "BW_CLIENTID", "BW_CLIENTSECRET", "BITWARDENGO_CONFIG"} { + t.Setenv(k, "") + os.Unsetenv(k) + } + path := filepath.Join(t.TempDir(), "env") + _ = os.WriteFile(path, []byte("BW_ACCESS_TOKEN=x\n"), 0o644) + code := e.run(t, "login", "-config", path) + if code != 2 { + t.Fatalf("expected exit 2, got %d", code) + } + if !strings.Contains(e.stderr.String(), "insecure mode") { + t.Fatalf("stderr: %q", e.stderr.String()) + } + e.redactionFull(t) +} + +func TestCLIUsage(t *testing.T) { + e := newEnv(t) + if code := e.run(t, "help"); code != 0 || !strings.Contains(e.stdout.String(), "Usage:") { + t.Fatalf("help exit %d", code) + } + if code := e.run(t); code != 1 { + t.Fatalf("no args exit %d", code) + } + if code := e.run(t, "bogus"); code != 1 { + t.Fatalf("unknown command exit %d", code) + } + if code := e.run(t, "get"); code != 1 { + t.Fatalf("get without key exit %d", code) + } + if code := e.run(t, "secrets"); code != 1 { + t.Fatalf("bare secrets exit %d", code) + } + if code := e.run(t, "secrets", "delete"); code != 1 { + t.Fatalf("secrets delete exit %d", code) + } +}