From 98bac84ab82097ddb22c67b7984ba0b81dcec58b Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Mon, 20 Apr 2026 11:36:07 +0200 Subject: [PATCH] feat: add --debug tracing for bitwarden calls --- bootstrap/bootstrap.go | 36 +++++++++++++++- bootstrap/bootstrap_test.go | 71 ++++++++++++++++++++++++++++++++ cli/doctor.go | 2 + docs/bootstrap-cli.md | 2 + docs/secrets.md | 4 ++ secretstore/bitwarden.go | 77 ++++++++++++++++++++++++++++++++++- secretstore/bitwarden_test.go | 65 +++++++++++++++++++++++++++++ secretstore/manifest_open.go | 2 + secretstore/runtime.go | 2 + secretstore/store.go | 1 + 10 files changed, 259 insertions(+), 3 deletions(-) diff --git a/bootstrap/bootstrap.go b/bootstrap/bootstrap.go index ea2ff2f..533952f 100644 --- a/bootstrap/bootstrap.go +++ b/bootstrap/bootstrap.go @@ -18,6 +18,8 @@ const ( CommandVersion = "version" CommandDoctor = "doctor" + bitwardenDebugEnvName = "MCP_FRAMEWORK_BITWARDEN_DEBUG" + ConfigSubcommandShow = "show" ConfigSubcommandTest = "test" ConfigSubcommandDelete = "delete" @@ -118,7 +120,13 @@ func Run(ctx context.Context, opts Options) error { return ErrBinaryNameRequired } - command, commandArgs, showHelp := parseArgs(expandAliases(normalized.Args, normalized.Aliases)) + resolvedArgs := expandAliases(normalized.Args, normalized.Aliases) + resolvedArgs, debugEnabled := extractGlobalDebugFlag(resolvedArgs) + if debugEnabled { + _ = os.Setenv(bitwardenDebugEnvName, "1") + } + + command, commandArgs, showHelp := parseArgs(resolvedArgs) if showHelp { return printHelp(normalized, command, commandArgs) } @@ -252,6 +260,25 @@ func parseArgs(args []string) (command string, commandArgs []string, showHelp bo return command, commandArgs, false } +func extractGlobalDebugFlag(args []string) ([]string, bool) { + args = trimArgs(args) + if len(args) == 0 { + return nil, false + } + + filtered := make([]string, 0, len(args)) + debugEnabled := false + for _, arg := range args { + if strings.TrimSpace(arg) == "--debug" { + debugEnabled = true + continue + } + filtered = append(filtered, arg) + } + + return filtered, debugEnabled +} + func expandAliases(args []string, aliases map[string][]string) []string { args = trimArgs(args) if len(args) == 0 || len(aliases) == 0 { @@ -512,6 +539,13 @@ func printGlobalHelp(opts Options) error { } } + if _, err := fmt.Fprintln(opts.Stdout, "\nOptions globales:"); err != nil { + return err + } + if _, err := fmt.Fprintln(opts.Stdout, " --debug Active le debug des appels Bitwarden."); err != nil { + return err + } + _, err := fmt.Fprintf(opts.Stdout, "\nAide detaillee: %s help \n", opts.BinaryName) return err } diff --git a/bootstrap/bootstrap_test.go b/bootstrap/bootstrap_test.go index 02644e3..5e1f899 100644 --- a/bootstrap/bootstrap_test.go +++ b/bootstrap/bootstrap_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "errors" + "os" "slices" "strings" "testing" @@ -482,3 +483,73 @@ func TestRunPrintsDoctorAliasInGlobalHelpWhenEnabled(t *testing.T) { t.Fatalf("global help output missing default doctor alias details: %q", text) } } + +func TestRunAcceptsGlobalDebugFlagAndRoutesCommand(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + var got Invocation + + t.Setenv("MCP_FRAMEWORK_BITWARDEN_DEBUG", "") + + err := Run(context.Background(), Options{ + BinaryName: "my-mcp", + Args: []string{"--debug", "setup", "--profile", "prod"}, + Stdout: &stdout, + Stderr: &stderr, + Hooks: Hooks{ + Setup: func(_ context.Context, inv Invocation) error { + got = inv + return nil + }, + }, + }) + if err != nil { + t.Fatalf("Run error = %v", err) + } + + if got.Command != CommandSetup { + t.Fatalf("invocation command = %q, want %q", got.Command, CommandSetup) + } + wantArgs := []string{"--profile", "prod"} + if !slices.Equal(got.Args, wantArgs) { + t.Fatalf("invocation args = %v, want %v", got.Args, wantArgs) + } + if os.Getenv("MCP_FRAMEWORK_BITWARDEN_DEBUG") != "1" { + t.Fatalf("MCP_FRAMEWORK_BITWARDEN_DEBUG = %q, want %q", os.Getenv("MCP_FRAMEWORK_BITWARDEN_DEBUG"), "1") + } +} + +func TestRunAcceptsGlobalDebugFlagAfterCommand(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + var got Invocation + + t.Setenv("MCP_FRAMEWORK_BITWARDEN_DEBUG", "") + + err := Run(context.Background(), Options{ + BinaryName: "my-mcp", + Args: []string{"setup", "--debug", "--profile", "prod"}, + Stdout: &stdout, + Stderr: &stderr, + Hooks: Hooks{ + Setup: func(_ context.Context, inv Invocation) error { + got = inv + return nil + }, + }, + }) + if err != nil { + t.Fatalf("Run error = %v", err) + } + + if got.Command != CommandSetup { + t.Fatalf("invocation command = %q, want %q", got.Command, CommandSetup) + } + wantArgs := []string{"--profile", "prod"} + if !slices.Equal(got.Args, wantArgs) { + t.Fatalf("invocation args = %v, want %v", got.Args, wantArgs) + } + if os.Getenv("MCP_FRAMEWORK_BITWARDEN_DEBUG") != "1" { + t.Fatalf("MCP_FRAMEWORK_BITWARDEN_DEBUG = %q, want %q", os.Getenv("MCP_FRAMEWORK_BITWARDEN_DEBUG"), "1") + } +} diff --git a/cli/doctor.go b/cli/doctor.go index 293806e..10a508b 100644 --- a/cli/doctor.go +++ b/cli/doctor.go @@ -65,6 +65,7 @@ type DoctorOptions struct { type BitwardenDoctorOptions struct { Command string + Debug bool Shell string LookupEnv func(string) (string, bool) } @@ -238,6 +239,7 @@ func BitwardenReadyCheck(options BitwardenDoctorOptions) DoctorCheck { return func(context.Context) DoctorResult { err := checkBitwardenReady(secretstore.Options{ BitwardenCommand: strings.TrimSpace(options.Command), + BitwardenDebug: options.Debug, Shell: strings.TrimSpace(options.Shell), LookupEnv: options.LookupEnv, }) diff --git a/docs/bootstrap-cli.md b/docs/bootstrap-cli.md index 3bce155..74695ff 100644 --- a/docs/bootstrap-cli.md +++ b/docs/bootstrap-cli.md @@ -43,3 +43,5 @@ Si `Hooks.Version` n'est pas fourni, la sous-commande `version` affiche automati Sans arguments, `bootstrap.Run` affiche l'aide globale (pas de lancement implicite de `mcp`). La commande `config` impose une sous-commande (`show`, `test`, et optionnellement `delete`). Les alias déclarés (ou `EnableDoctorAlias`) sont affichés dans l'aide globale. +Le flag global `--debug` est supporté et active le debug des appels Bitwarden +(`MCP_FRAMEWORK_BITWARDEN_DEBUG=1`). diff --git a/docs/secrets.md b/docs/secrets.md index 24046d1..c1f5ee2 100644 --- a/docs/secrets.md +++ b/docs/secrets.md @@ -164,6 +164,10 @@ fmt.Println(report.Remediation) // action recommandée ## Debug Bitwarden en 60 secondes +Tu peux activer les traces d'appels Bitwarden avec le flag CLI global `--debug` +(via `bootstrap`) ou en exportant `MCP_FRAMEWORK_BITWARDEN_DEBUG=1`. +Les commandes `bw` exécutées seront affichées (avec redaction des payloads sensibles). + 1. Vérifier l'état de session : ```bash diff --git a/secretstore/bitwarden.go b/secretstore/bitwarden.go index acb7f27..4d0b6f6 100644 --- a/secretstore/bitwarden.go +++ b/secretstore/bitwarden.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "os" "os/exec" "path/filepath" @@ -17,6 +18,7 @@ import ( const ( defaultBitwardenCommand = "bw" + bitwardenDebugEnvName = "MCP_FRAMEWORK_BITWARDEN_DEBUG" bitwardenSessionEnvName = "BW_SESSION" bitwardenSecretFieldName = "mcp-secret" bitwardenServiceFieldName = "mcp-service" @@ -34,10 +36,12 @@ type bitwardenRunner func(command string, stdin []byte, args ...string) ([]byte, var runBitwardenCLI bitwardenRunner = executeBitwardenCLI var bitwardenLoaderActive atomic.Bool +var bitwardenDebugOutput io.Writer = os.Stderr type bitwardenStore struct { command string serviceName string + debug bool } type bitwardenListItem struct { @@ -54,10 +58,12 @@ func newBitwardenStore(options Options, policy BackendPolicy, serviceName string if command == "" { command = defaultBitwardenCommand } + debugEnabled := isBitwardenDebugEnabled(options.BitwardenDebug) store := &bitwardenStore{ command: command, serviceName: serviceName, + debug: debugEnabled, } if _, err := store.execute("verify bitwarden CLI availability", nil, "--version"); err != nil { @@ -80,6 +86,7 @@ func newBitwardenStore(options Options, policy BackendPolicy, serviceName string if err := EnsureBitwardenReady(Options{ BitwardenCommand: command, + BitwardenDebug: debugEnabled, LookupEnv: options.LookupEnv, Shell: options.Shell, }); err != nil { @@ -99,6 +106,7 @@ func EnsureBitwardenReady(options Options) error { if command == "" { command = defaultBitwardenCommand } + debugEnabled := isBitwardenDebugEnabled(options.BitwardenDebug) unlockCommand := bitwardenUnlockRemediation(command, options.Shell) lookupEnv := options.LookupEnv @@ -106,7 +114,7 @@ func EnsureBitwardenReady(options Options) error { lookupEnv = os.LookupEnv } - output, err := runBitwardenCLI(command, nil, "status") + output, err := runBitwardenCommand(command, debugEnabled, nil, "status") if err != nil { return fmt.Errorf("check bitwarden CLI status: %w", err) } @@ -438,7 +446,7 @@ func (s *bitwardenStore) encodePayload(payload map[string]any) (string, error) { } func (s *bitwardenStore) execute(operation string, stdin []byte, args ...string) ([]byte, error) { - output, err := runBitwardenCLI(s.command, stdin, args...) + output, err := runBitwardenCommand(s.command, s.debug, stdin, args...) if err != nil { return nil, fmt.Errorf("%s: %w", operation, err) } @@ -521,6 +529,71 @@ func bitwardenItemMatchesMarkers(payload map[string]any, serviceName, secretName strings.TrimSpace(markedSecretName) == strings.TrimSpace(secretName) } +func runBitwardenCommand(command string, debug bool, stdin []byte, args ...string) ([]byte, error) { + if debug { + logBitwardenCommand(command, args...) + } + return runBitwardenCLI(command, stdin, args...) +} + +func isBitwardenDebugEnabled(explicit bool) bool { + if explicit { + return true + } + + raw, ok := os.LookupEnv(bitwardenDebugEnvName) + if !ok { + return false + } + + switch strings.ToLower(strings.TrimSpace(raw)) { + case "1", "true", "yes", "y", "on": + return true + default: + return false + } +} + +func logBitwardenCommand(command string, args ...string) { + writer := bitwardenDebugOutput + if writer == nil { + return + } + + renderedArgs := sanitizeBitwardenDebugArgs(args) + if len(renderedArgs) == 0 { + _, _ = fmt.Fprintf(writer, "[bitwarden debug] %s\n", strings.TrimSpace(command)) + return + } + + _, _ = fmt.Fprintf( + writer, + "[bitwarden debug] %s %s\n", + strings.TrimSpace(command), + strings.Join(renderedArgs, " "), + ) +} + +func sanitizeBitwardenDebugArgs(args []string) []string { + if len(args) == 0 { + return nil + } + + rendered := make([]string, len(args)) + for idx, arg := range args { + rendered[idx] = strings.TrimSpace(arg) + } + + if len(rendered) >= 3 && rendered[0] == "create" && rendered[1] == "item" { + rendered[2] = "" + } + if len(rendered) >= 4 && rendered[0] == "edit" && rendered[1] == "item" { + rendered[3] = "" + } + + return rendered +} + func executeBitwardenCLI(command string, stdin []byte, args ...string) ([]byte, error) { stopLoader := startBitwardenLoader() defer stopLoader() diff --git a/secretstore/bitwarden_test.go b/secretstore/bitwarden_test.go index 9a3e7e6..b71e832 100644 --- a/secretstore/bitwarden_test.go +++ b/secretstore/bitwarden_test.go @@ -1,10 +1,12 @@ package secretstore import ( + "bytes" "encoding/base64" "encoding/json" "errors" "fmt" + "io" "os/exec" "path/filepath" "regexp" @@ -367,6 +369,59 @@ func TestExecuteBitwardenCLIClassifiesLoginError(t *testing.T) { } } +func TestOpenBitwardenCLIDebugFromEnvPrintsBitwardenCalls(t *testing.T) { + withBitwardenSession(t) + fakeCLI := newFakeBitwardenCLI("bw") + withBitwardenRunner(t, fakeCLI.run) + t.Setenv("MCP_FRAMEWORK_BITWARDEN_DEBUG", "1") + + var logs bytes.Buffer + withBitwardenDebugOutput(t, &logs) + + _, err := Open(Options{ + ServiceName: "email-mcp", + BackendPolicy: BackendBitwardenCLI, + }) + if err != nil { + t.Fatalf("Open returned error: %v", err) + } + + text := logs.String() + if !strings.Contains(text, "bw --version") { + t.Fatalf("debug logs = %q, want command bw --version", text) + } + if !strings.Contains(text, "bw status") { + t.Fatalf("debug logs = %q, want command bw status", text) + } +} + +func TestBitwardenDebugRedactsSensitivePayloadArguments(t *testing.T) { + withBitwardenSession(t) + fakeCLI := newFakeBitwardenCLI("bw") + withBitwardenRunner(t, fakeCLI.run) + + var logs bytes.Buffer + withBitwardenDebugOutput(t, &logs) + + store, err := Open(Options{ + ServiceName: "graylog-mcp", + BackendPolicy: BackendBitwardenCLI, + BitwardenDebug: true, + }) + if err != nil { + t.Fatalf("Open returned error: %v", err) + } + + if err := store.SetSecret("api-token", "API token", "secret-v1"); err != nil { + t.Fatalf("SetSecret returned error: %v", err) + } + + text := logs.String() + if !strings.Contains(text, "bw create item ") { + t.Fatalf("debug logs = %q, want redacted create payload", text) + } +} + func TestBitwardenLoaderFrameUsesSingleLineRewriteAndMessage(t *testing.T) { frame := bitwardenLoaderFrame(0) if !strings.HasPrefix(frame, "\r\033[2K") { @@ -455,6 +510,16 @@ func withBitwardenRunner( }) } +func withBitwardenDebugOutput(t *testing.T, writer io.Writer) { + t.Helper() + + previous := bitwardenDebugOutput + bitwardenDebugOutput = writer + t.Cleanup(func() { + bitwardenDebugOutput = previous + }) +} + type fakeBitwardenCLI struct { command string itemsByID map[string]fakeBitwardenItem diff --git a/secretstore/manifest_open.go b/secretstore/manifest_open.go index 9c95653..f8009d4 100644 --- a/secretstore/manifest_open.go +++ b/secretstore/manifest_open.go @@ -20,6 +20,7 @@ type OpenFromManifestOptions struct { KWalletAppID string KWalletFolder string BitwardenCommand string + BitwardenDebug bool Shell string ManifestLoader ManifestLoader ExecutableResolver ExecutableResolver @@ -38,6 +39,7 @@ func OpenFromManifest(options OpenFromManifestOptions) (Store, error) { KWalletAppID: options.KWalletAppID, KWalletFolder: options.KWalletFolder, BitwardenCommand: strings.TrimSpace(options.BitwardenCommand), + BitwardenDebug: options.BitwardenDebug, Shell: strings.TrimSpace(options.Shell), }) } diff --git a/secretstore/runtime.go b/secretstore/runtime.go index ec724dc..4d342d7 100644 --- a/secretstore/runtime.go +++ b/secretstore/runtime.go @@ -14,6 +14,7 @@ type DescribeRuntimeOptions struct { KWalletAppID string KWalletFolder string BitwardenCommand string + BitwardenDebug bool Shell string ManifestLoader ManifestLoader ExecutableResolver ExecutableResolver @@ -73,6 +74,7 @@ func DescribeRuntime(options DescribeRuntimeOptions) (RuntimeDescription, error) KWalletAppID: options.KWalletAppID, KWalletFolder: options.KWalletFolder, BitwardenCommand: options.BitwardenCommand, + BitwardenDebug: options.BitwardenDebug, Shell: options.Shell, }) if openErr != nil { diff --git a/secretstore/store.go b/secretstore/store.go index 7b1b787..5d8d2a9 100644 --- a/secretstore/store.go +++ b/secretstore/store.go @@ -37,6 +37,7 @@ type Options struct { KWalletAppID string KWalletFolder string BitwardenCommand string + BitwardenDebug bool Shell string }