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.
This commit is contained in:
@@ -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))
|
||||||
|
}
|
||||||
@@ -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: "<id> <name>"
|
||||||
|
bitwarden-go secrets list [-config PATH] list secrets: "<id> <name>"
|
||||||
|
bitwarden-go get <key> [-config PATH] print one secret value (bare, no newline)
|
||||||
|
bitwarden-go help print this usage
|
||||||
|
|
||||||
|
<key> 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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user