diff --git a/cmd/client/main.go b/cmd/client/main.go index e48d00d..0d20b2b 100644 --- a/cmd/client/main.go +++ b/cmd/client/main.go @@ -1,4 +1,4 @@ -// Code generated by Fabrica v0.4.9 (commit: 70cad05). DO NOT EDIT. +// Code generated by Fabrica v0.4.10 (commit: 408e1ce). DO NOT EDIT. // Copyright © 2026 OpenCHAMI a Series of LF Projects, LLC // // SPDX-License-Identifier: MIT @@ -25,6 +25,7 @@ // --output, -o Output format: table, json, yaml (env: METADATA_SERVICE_OUTPUT) // --version, -v API version to request: v1, v2beta1, etc. (env: METADATA_SERVICE_VERSION) // --token JWT bearer token (env: METADATA_SERVICE_TOKEN) +// --show-token Show the full bearer token in debug logs // --config Config file path (default: ~/.metadata_service-cli.yaml) // // Configuration sources (in order of precedence): @@ -90,6 +91,7 @@ var ( output string apiVersion string bearerToken string + showToken bool logLevel client.LogLevel ) @@ -116,6 +118,7 @@ func init() { rootCmd.PersistentFlags().StringVarP(&output, "output", "o", "table", "output format: table, json, yaml") rootCmd.PersistentFlags().StringVarP(&apiVersion, "version", "v", "", "API version to request (e.g., v1, v2beta1)") rootCmd.PersistentFlags().StringVar(&bearerToken, "token", "", "JWT bearer token") + rootCmd.PersistentFlags().BoolVar(&showToken, "show-token", false, "show full bearer token in debug logs instead of a truncated value") rootCmd.PersistentFlags().VarP(&logLevel, "log-level", "l", "set verbosity of logs (e.g., info, warning, debug)") // Register shell completion functions @@ -127,6 +130,7 @@ func init() { viper.BindPFlag("output", rootCmd.PersistentFlags().Lookup("output")) viper.BindPFlag("version", rootCmd.PersistentFlags().Lookup("version")) viper.BindPFlag("token", rootCmd.PersistentFlags().Lookup("token")) + viper.BindPFlag("show-token", rootCmd.PersistentFlags().Lookup("show-token")) viper.BindPFlag("log-level", rootCmd.PersistentFlags().Lookup("log-level")) // Environment variable support @@ -171,6 +175,7 @@ func getClient() (*client.Client, error) { if err != nil { return nil, err } + c = c.WithShowToken(viper.GetBool("show-token")) // Apply version if specified version := viper.GetString("version") diff --git a/cmd/client/token_redaction_test.go b/cmd/client/token_redaction_test.go new file mode 100644 index 0000000..6167dec --- /dev/null +++ b/cmd/client/token_redaction_test.go @@ -0,0 +1,109 @@ +// SPDX-FileCopyrightText: 2026 OpenCHAMI Contributors +// +// SPDX-License-Identifier: MIT + +package main + +import ( + "io" + "net/http" + "net/http/httptest" + "os" + "strconv" + "strings" + "testing" + + "github.com/openchami/metadata-service/pkg/client" + "github.com/spf13/viper" +) + +func TestShowTokenFlagControlsClientDebugLogs(t *testing.T) { + flag := rootCmd.PersistentFlags().Lookup("show-token") + if flag == nil { + t.Fatal("--show-token flag is not registered") + } + if flag.DefValue != "false" { + t.Fatalf("--show-token default = %q, want false", flag.DefValue) + } + + const token = "cli-secret-token" + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("Authorization"); got != "Bearer "+token { + t.Errorf("Authorization header = %q, want full token", got) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"status":"ok"}`)) + })) + defer server.Close() + + originalLogLevel := logLevel + defer func() { logLevel = originalLogLevel }() + logLevel = client.LogLevelDebug + viper.Set("server", server.URL) + viper.Set("token", token) + viper.Set("version", "") + defer func() { + viper.Set("server", "http://localhost:8080") + viper.Set("token", "") + viper.Set("version", "") + _ = flag.Value.Set("false") + flag.Changed = false + }() + + for _, tt := range []struct { + name string + show bool + }{ + {name: "default redaction"}, + {name: "explicit disclosure", show: true}, + } { + t.Run(tt.name, func(t *testing.T) { + if err := flag.Value.Set(strconv.FormatBool(tt.show)); err != nil { + t.Fatalf("setting --show-token failed: %v", err) + } + flag.Changed = true + logs := captureStderr(t, func() { + if err := healthCmd.RunE(healthCmd, nil); err != nil { + t.Fatalf("health command failed: %v", err) + } + }) + + if tt.show { + if !strings.Contains(logs, token) { + t.Fatalf("full token missing with --show-token: %s", logs) + } + } else { + if strings.Contains(logs, token) { + t.Fatalf("full token leaked without --show-token: %s", logs) + } + if !strings.Contains(logs, "cli-se...") { + t.Fatalf("redacted token missing from logs: %s", logs) + } + } + }) + } +} + +func captureStderr(t *testing.T, run func()) string { + t.Helper() + + original := os.Stderr + reader, writer, err := os.Pipe() + if err != nil { + t.Fatalf("os.Pipe() failed: %v", err) + } + os.Stderr = writer + defer func() { os.Stderr = original }() + + done := make(chan string, 1) + go func() { + data, _ := io.ReadAll(reader) + done <- string(data) + }() + + run() + _ = writer.Close() + output := <-done + _ = reader.Close() + return output +} diff --git a/cmd/client/version_generated.go b/cmd/client/version_generated.go index 65d37ac..9f74a59 100644 --- a/cmd/client/version_generated.go +++ b/cmd/client/version_generated.go @@ -1,6 +1,6 @@ -// Code generated by Fabrica v0.4.9 (commit: 70cad05). DO NOT EDIT. +// Code generated by Fabrica v0.4.10 (commit: 408e1ce). DO NOT EDIT. // Template: client/version.go.tmpl -// Generated: 2026-07-22T16:56:18Z +// Generated: 2026-08-13T23:50:17Z // // SPDX-FileCopyrightText: Copyright © 2026 OpenCHAMI a Series of LF Projects, LLC // @@ -22,7 +22,7 @@ var ( ) // fabricaVersion is embedded at generation time -const fabricaVersion = "v0.4.9" +const fabricaVersion = "v0.4.10" func clientVersionString() string { if clientCommit == "none" && clientDate == "unknown" { diff --git a/cmd/server/version_generated.go b/cmd/server/version_generated.go index ee16864..21a108e 100644 --- a/cmd/server/version_generated.go +++ b/cmd/server/version_generated.go @@ -1,6 +1,6 @@ -// Code generated by Fabrica v0.4.9 (commit: 70cad05). DO NOT EDIT. +// Code generated by Fabrica v0.4.10 (commit: 408e1ce). DO NOT EDIT. // Template: server/version.go.tmpl -// Generated: 2026-07-22T16:56:17Z +// Generated: 2026-08-13T23:50:16Z // // SPDX-FileCopyrightText: Copyright © 2026 OpenCHAMI a Series of LF Projects, LLC // @@ -22,7 +22,7 @@ var ( ) // fabricaVersion is embedded at generation time -const fabricaVersion = "v0.4.9" +const fabricaVersion = "v0.4.10" func serviceVersionString() string { if serviceCommit == "none" && serviceDate == "unknown" { diff --git a/go.mod b/go.mod index 14b6dbc..a7e2402 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ go 1.26.5 require ( github.com/getkin/kin-openapi v0.144.0 github.com/go-chi/chi/v5 v5.2.4 - github.com/openchami/fabrica v0.4.9 + github.com/openchami/fabrica v0.4.10 github.com/openchami/tokensmith v0.4.1 github.com/prometheus/client_golang v1.23.2 github.com/rs/zerolog v1.34.0 @@ -66,7 +66,7 @@ require ( go.yaml.in/yaml/v2 v2.4.2 // indirect golang.org/x/net v0.55.0 // indirect golang.org/x/sys v0.46.0 // indirect - golang.org/x/text v0.38.0 // indirect + golang.org/x/text v0.40.0 // indirect golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect google.golang.org/protobuf v1.36.8 // indirect gopkg.in/ini.v1 v1.67.0 // indirect diff --git a/go.sum b/go.sum index 45e26c3..a2104b8 100644 --- a/go.sum +++ b/go.sum @@ -209,8 +209,8 @@ github.com/oasdiff/yaml3 v0.0.14 h1:aLJee3hxBK2H5wdXd9iPcIXb93Nty1Ge0pT171eHtkw= github.com/oasdiff/yaml3 v0.0.14/go.mod h1:csto2xfDjYccdUn/yw/bPjj/cYTdp6HtFA0J4TWG+gg= github.com/openchami/chi-middleware/log v0.0.0-20240812224658-b16b83c70700 h1:Gzt5f6RK39CHvY3SJudzBb/RK4tVh/S3CpJ0eQlbNdg= github.com/openchami/chi-middleware/log v0.0.0-20240812224658-b16b83c70700/go.mod h1:UuXvr2loD4MtvZeKr57W0WpBs+gm0KM1kdtcXrE8M6s= -github.com/openchami/fabrica v0.4.9 h1:OGKiID0tmA2lGpNTp0u0q5cjOMDaOhXhTO7DCWWp+jA= -github.com/openchami/fabrica v0.4.9/go.mod h1:C96/BWI20XG81scQWCoKwSvJOP+5DmAjThbojlh1AJo= +github.com/openchami/fabrica v0.4.10 h1:IQEifXLlqmTYL8sGPeyWU91TJNavl2nW7j0nOG1bxGc= +github.com/openchami/fabrica v0.4.10/go.mod h1:sYUzamU3d3/wbW4AxKpMKRTAEA/OkEHoc8Tr76NKE8c= github.com/openchami/tokensmith v0.4.1 h1:+IOmMywod4uzMFLVK/R92pfwoIBtcpq62MQ7HaDpW28= github.com/openchami/tokensmith v0.4.1/go.mod h1:L4ZCMX/vPGwXUUn9otw+UdfFTbarv+ZVO/FjhZmoOAE= github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ= @@ -432,8 +432,8 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= -golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= diff --git a/pkg/client/client_generated.go b/pkg/client/client_generated.go index 74a637c..b347926 100644 --- a/pkg/client/client_generated.go +++ b/pkg/client/client_generated.go @@ -1,4 +1,4 @@ -// Code generated by Fabrica v0.4.9 (commit: 70cad05). DO NOT EDIT. +// Code generated by Fabrica v0.4.10 (commit: 408e1ce). DO NOT EDIT. // Copyright © 2026 OpenCHAMI a Series of LF Projects, LLC // // SPDX-License-Identifier: MIT @@ -62,6 +62,7 @@ import ( "net/url" "os" "path" + "strings" "github.com/openchami/fabrica/pkg/fabrica" "github.com/rs/zerolog" @@ -77,6 +78,47 @@ type Client struct { logger zerolog.Logger version string // Optional API version for Accept/Content-Type headers bearerToken string // Optional JWT bearer token for Authorization header + showToken bool // Whether access tokens are shown in full in debug logs +} + +// tokenPrefixLen is the number of leading characters of a token to keep when +// truncating it for logs. +const tokenPrefixLen = 6 + +// RedactToken returns token unchanged if show is true. Otherwise, it +// returns a truncated form of the token that still indicates a token exists. +// Tokens that are tokenPrefixLen characters or shorter are fully masked. +func RedactToken(token string, show bool) string { + if show || token == "" { + return token + } + if len(token) <= tokenPrefixLen { + return "..." + } + return token[:tokenPrefixLen] + "..." +} + +// redactAuthHeaderValues returns a copy of Authorization header values with +// their token portions redacted unless show is true. +func redactAuthHeaderValues(vals []string, show bool) []string { + if show { + return vals + } + out := make([]string, len(vals)) + for i, v := range vals { + const bearerPrefix = "Bearer " + if strings.HasPrefix(v, bearerPrefix) { + out[i] = bearerPrefix + RedactToken(strings.TrimPrefix(v, bearerPrefix), show) + } else { + out[i] = RedactToken(v, show) + } + } + return out +} + +// isAuthorizationHeader reports whether key is the Authorization header. +func isAuthorizationHeader(key string) bool { + return http.CanonicalHeaderKey(key) == "Authorization" } // ErrorResponse represents an API error response @@ -122,6 +164,7 @@ func (c *Client) WithVersion(version string) *Client { httpClient: c.httpClient, version: version, bearerToken: c.bearerToken, + showToken: c.showToken, logger: c.logger, } } @@ -133,6 +176,20 @@ func (c *Client) WithBearerToken(token string) *Client { httpClient: c.httpClient, version: c.version, bearerToken: token, + showToken: c.showToken, + logger: c.logger, + } +} + +// WithShowToken returns a new client configured to show full access tokens in +// debug logs when show is true. Tokens are truncated by default. +func (c *Client) WithShowToken(show bool) *Client { + return &Client{ + baseURL: c.baseURL, + httpClient: c.httpClient, + version: c.version, + bearerToken: c.bearerToken, + showToken: show, logger: c.logger, } } @@ -177,7 +234,11 @@ func (c *Client) doRequest(ctx context.Context, method, endpoint string, body in if len(req.Header) > 0 { c.logger.Debug().Msg("Request headers:") for k, v := range req.Header { - c.logger.Debug().Msgf(" %s: %s", k, v) + if isAuthorizationHeader(k) { + c.logger.Debug().Msgf(" %s: %s", k, redactAuthHeaderValues(v, c.showToken)) + } else { + c.logger.Debug().Msgf(" %s: %s", k, v) + } } } else { c.logger.Debug().Msg("No headers in request") @@ -208,7 +269,11 @@ func (c *Client) doRequest(ctx context.Context, method, endpoint string, body in if len(resp.Header) > 0 { c.logger.Debug().Msg("Response headers:") for k, v := range resp.Header { - c.logger.Debug().Msgf(" %s: %s", k, v) + if isAuthorizationHeader(k) { + c.logger.Debug().Msgf(" %s: %s", k, redactAuthHeaderValues(v, c.showToken)) + } else { + c.logger.Debug().Msgf(" %s: %s", k, v) + } } } else { c.logger.Debug().Msg("No headers in response") diff --git a/pkg/client/token_redaction_test.go b/pkg/client/token_redaction_test.go new file mode 100644 index 0000000..7903301 --- /dev/null +++ b/pkg/client/token_redaction_test.go @@ -0,0 +1,144 @@ +// SPDX-FileCopyrightText: 2026 OpenCHAMI Contributors +// +// SPDX-License-Identifier: MIT + +package client + +import ( + "bytes" + "context" + "net/http" + "net/http/httptest" + "reflect" + "strings" + "testing" + + "github.com/rs/zerolog" +) + +func TestRedactToken(t *testing.T) { + tests := []struct { + name string + token string + show bool + want string + }{ + {name: "show token", token: "secret-token", show: true, want: "secret-token"}, + {name: "empty token", token: "", want: ""}, + {name: "short token", token: "short", want: "..."}, + {name: "prefix length token", token: "123456", want: "..."}, + {name: "long token", token: "1234567", want: "123456..."}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := RedactToken(tt.token, tt.show); got != tt.want { + t.Fatalf("RedactToken(%q, %t) = %q, want %q", tt.token, tt.show, got, tt.want) + } + }) + } +} + +func TestAuthorizationHeaderRedactionHelpers(t *testing.T) { + for _, key := range []string{"Authorization", "authorization", "AUTHORIZATION"} { + if !isAuthorizationHeader(key) { + t.Errorf("isAuthorizationHeader(%q) = false, want true", key) + } + } + if isAuthorizationHeader("X-Authorization") { + t.Error("isAuthorizationHeader(X-Authorization) = true, want false") + } + + values := []string{"Bearer secret-token", "Basic credentials"} + want := []string{"Bearer secret...", "Basic ..."} + if got := redactAuthHeaderValues(values, false); !reflect.DeepEqual(got, want) { + t.Fatalf("redactAuthHeaderValues() = %#v, want %#v", got, want) + } + if !reflect.DeepEqual(values, []string{"Bearer secret-token", "Basic credentials"}) { + t.Fatalf("redactAuthHeaderValues() mutated its input: %#v", values) + } + if got := redactAuthHeaderValues(values, true); !reflect.DeepEqual(got, values) { + t.Fatalf("redactAuthHeaderValues(show=true) = %#v, want %#v", got, values) + } +} + +func TestWithShowTokenAndChainingPreserveClientConfiguration(t *testing.T) { + httpClient := &http.Client{} + logger := zerolog.Nop() + c, err := NewClient("http://localhost:8080", httpClient, logger) + if err != nil { + t.Fatalf("NewClient() failed: %v", err) + } + c = c.WithVersion("v1").WithBearerToken("token-value").WithShowToken(true) + + if !c.showToken || c.version != "v1" || c.bearerToken != "token-value" || c.httpClient != httpClient { + t.Fatalf("WithShowToken() did not preserve client configuration: %#v", c) + } + if !c.WithVersion("v2").showToken { + t.Error("WithVersion() did not preserve showToken") + } + if !c.WithBearerToken("replacement").showToken { + t.Error("WithBearerToken() did not preserve showToken") + } + if c.WithShowToken(false).showToken { + t.Error("WithShowToken(false) did not disable token disclosure") + } +} + +func TestAuthorizationTokensInDebugLogs(t *testing.T) { + const requestToken = "request-secret-token" + const responseToken = "response-secret-token" + + tests := []struct { + name string + show bool + }{ + {name: "redacted by default"}, + {name: "shown when enabled", show: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var receivedAuthorization string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + receivedAuthorization = r.Header.Get("Authorization") + w.Header().Set("Authorization", "Bearer "+responseToken) + w.Header().Set("X-Test-Header", "visible-value") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{}`)) + })) + defer server.Close() + + var logs bytes.Buffer + logger := zerolog.New(&logs).Level(zerolog.DebugLevel) + c, err := NewClientWithBearerToken(server.URL, requestToken, nil, logger) + if err != nil { + t.Fatalf("NewClientWithBearerToken() failed: %v", err) + } + c = c.WithShowToken(tt.show).WithVersion("v1") + if err := c.doRequest(context.Background(), http.MethodGet, "/test", nil, nil); err != nil { + t.Fatalf("doRequest() failed: %v", err) + } + + if receivedAuthorization != "Bearer "+requestToken { + t.Fatalf("wire Authorization header = %q, want full token", receivedAuthorization) + } + output := logs.String() + if !strings.Contains(output, "visible-value") { + t.Error("non-Authorization response header was not logged") + } + if tt.show { + if !strings.Contains(output, requestToken) || !strings.Contains(output, responseToken) { + t.Fatalf("full tokens missing from logs when enabled: %s", output) + } + return + } + if strings.Contains(output, requestToken) || strings.Contains(output, responseToken) { + t.Fatalf("full token leaked into default debug logs: %s", output) + } + if !strings.Contains(output, "reques...") || !strings.Contains(output, "respon...") { + t.Fatalf("redacted tokens missing from logs: %s", output) + } + }) + } +}