detached 1db619a

Merge pull request #211 from bnema/fix/redact-secret-logs

fix(logging): redact secret-bearing log values
commit
1db619a195aae9859ddfe84ecfca38ec159d9d7b
author
bnema · · 2026-06-20 14:57 UTC
committer
GitHub · · 2026-06-20 14:57 UTC
merge of
77c8aa8 + 03481ac · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
8 files · +290 -15
M internal/adapters/in/http/middleware/accesslog.go +20 -2
@@ -66,7 +66,7 @@ func AccessLogger(writer out.AccessLogWriter, excludeHealthChecks bool, log zero
6666 BytesSent: rw.BytesWritten(),
6767 DurationMS: durationMS,
6868 UserAgent: r.UserAgent(),
69- Referer: r.Referer(),
69+ Referer: sanitizeLoggedReferer(r.Referer()),
7070 RequestID: requestID,
7171 Proto: r.Proto,
7272 }
@@ -87,6 +87,21 @@ func AccessLogger(writer out.AccessLogWriter, excludeHealthChecks bool, log zero
8787 }
8888 }
8989
90+func sanitizeLoggedReferer(referer string) string {
91+ if referer == "" {
92+ return ""
93+ }
94+
95+ parsed, err := url.Parse(referer)
96+ if err != nil {
97+ return "[INVALID_REFERER]"
98+ }
99+ parsed.User = nil
100+ parsed.RawQuery = sanitizeLoggedQuery(parsed.RawQuery)
101+ parsed.Fragment = ""
102+ return parsed.String()
103+}
104+
90105 func sanitizeLoggedQuery(rawQuery string) string {
91106 if rawQuery == "" {
92107 return ""
@@ -101,7 +116,10 @@ func sanitizeLoggedQuery(rawQuery string) string {
101116 key, _, found := strings.Cut(part, "=")
102117 decodedKey, err := url.QueryUnescape(key)
103118 if err != nil {
104- decodedKey = key
119+ if found {
120+ parts[i] = key + "=[REDACTED]"
121+ }
122+ continue
105123 }
106124
107125 if !isSensitiveQueryKey(decodedKey) {
@@ -66,7 +66,7 @@ func AccessLogger(writer out.AccessLogWriter, excludeHealthChecks bool, log zero
66 BytesSent: rw.BytesWritten(),66 BytesSent: rw.BytesWritten(),
67 DurationMS: durationMS,67 DurationMS: durationMS,
68 UserAgent: r.UserAgent(),68 UserAgent: r.UserAgent(),
69- Referer: r.Referer(),69+ Referer: sanitizeLoggedReferer(r.Referer()),
70 RequestID: requestID,70 RequestID: requestID,
71 Proto: r.Proto,71 Proto: r.Proto,
72 }72 }
@@ -87,6 +87,21 @@ func AccessLogger(writer out.AccessLogWriter, excludeHealthChecks bool, log zero
87 }87 }
88 }88 }
89 89
90+func sanitizeLoggedReferer(referer string) string {
91+ if referer == "" {
92+ return ""
93+ }
94+
95+ parsed, err := url.Parse(referer)
96+ if err != nil {
97+ return "[INVALID_REFERER]"
98+ }
99+ parsed.User = nil
100+ parsed.RawQuery = sanitizeLoggedQuery(parsed.RawQuery)
101+ parsed.Fragment = ""
102+ return parsed.String()
103+}
104+
90 func sanitizeLoggedQuery(rawQuery string) string {105 func sanitizeLoggedQuery(rawQuery string) string {
91 if rawQuery == "" {106 if rawQuery == "" {
92 return ""107 return ""
@@ -101,7 +116,10 @@ func sanitizeLoggedQuery(rawQuery string) string {
101 key, _, found := strings.Cut(part, "=")116 key, _, found := strings.Cut(part, "=")
102 decodedKey, err := url.QueryUnescape(key)117 decodedKey, err := url.QueryUnescape(key)
103 if err != nil {118 if err != nil {
104- decodedKey = key119+ if found {
120+ parts[i] = key + "=[REDACTED]"
121+ }
122+ continue
105 }123 }
106 124
107 if !isSensitiveQueryKey(decodedKey) {125 if !isSensitiveQueryKey(decodedKey) {
M internal/adapters/in/http/middleware/accesslog_test.go +50 -0
@@ -81,6 +81,56 @@ func TestAccessLogger_RedactsSensitiveQueryParams(t *testing.T) {
8181 assert.Equal(t, "code=[REDACTED]&state=ok&token=[REDACTED]&api_key=[REDACTED]&session_id=[REDACTED]", mock.entries[0].Query)
8282 }
8383
84+func TestAccessLogger_RedactsSensitiveReferers(t *testing.T) {
85+ tests := []struct {
86+ name string
87+ referer string
88+ want string
89+ }{
90+ {
91+ name: "query params",
92+ referer: "https://idp.example.com/callback?code=secret-code&state=ok&token=secret-token",
93+ want: "https://idp.example.com/callback?code=[REDACTED]&state=ok&token=[REDACTED]",
94+ },
95+ {
96+ name: "fragment removed",
97+ referer: "https://idp.example.com/callback?state=ok#access_token=secret-token",
98+ want: "https://idp.example.com/callback?state=ok",
99+ },
100+ {
101+ name: "userinfo removed",
102+ referer: "https://user:secret-password@idp.example.com/callback?code=secret-code",
103+ want: "https://idp.example.com/callback?code=[REDACTED]",
104+ },
105+ {
106+ name: "malformed query key redacted conservatively",
107+ referer: "https://idp.example.com/callback?token%ZZ=secret-token&state=ok",
108+ want: "https://idp.example.com/callback?token%ZZ=[REDACTED]&state=ok",
109+ },
110+ }
111+
112+ for _, tt := range tests {
113+ t.Run(tt.name, func(t *testing.T) {
114+ mock := &mockAccessLogWriter{}
115+ log := testLogger()
116+
117+ handler := AccessLogger(mock, false, log, nil)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
118+ w.WriteHeader(http.StatusOK)
119+ }))
120+
121+ req := httptest.NewRequest(http.MethodGet, "/target", nil)
122+ req.Header.Set("Referer", tt.referer)
123+ rw := httptest.NewRecorder()
124+
125+ handler.ServeHTTP(rw, req)
126+
127+ require.Len(t, mock.entries, 1)
128+ assert.Equal(t, tt.want, mock.entries[0].Referer)
129+ assert.NotContains(t, mock.entries[0].Referer, "secret")
130+ })
131+ }
132+}
133+
84134 func TestAccessLogger_RequestIDReused(t *testing.T) {
85135 mock := &mockAccessLogWriter{}
86136 log := testLogger()
@@ -81,6 +81,56 @@ func TestAccessLogger_RedactsSensitiveQueryParams(t *testing.T) {
81 assert.Equal(t, "code=[REDACTED]&state=ok&token=[REDACTED]&api_key=[REDACTED]&session_id=[REDACTED]", mock.entries[0].Query)81 assert.Equal(t, "code=[REDACTED]&state=ok&token=[REDACTED]&api_key=[REDACTED]&session_id=[REDACTED]", mock.entries[0].Query)
82 }82 }
83 83
84+func TestAccessLogger_RedactsSensitiveReferers(t *testing.T) {
85+ tests := []struct {
86+ name string
87+ referer string
88+ want string
89+ }{
90+ {
91+ name: "query params",
92+ referer: "https://idp.example.com/callback?code=secret-code&state=ok&token=secret-token",
93+ want: "https://idp.example.com/callback?code=[REDACTED]&state=ok&token=[REDACTED]",
94+ },
95+ {
96+ name: "fragment removed",
97+ referer: "https://idp.example.com/callback?state=ok#access_token=secret-token",
98+ want: "https://idp.example.com/callback?state=ok",
99+ },
100+ {
101+ name: "userinfo removed",
102+ referer: "https://user:secret-password@idp.example.com/callback?code=secret-code",
103+ want: "https://idp.example.com/callback?code=[REDACTED]",
104+ },
105+ {
106+ name: "malformed query key redacted conservatively",
107+ referer: "https://idp.example.com/callback?token%ZZ=secret-token&state=ok",
108+ want: "https://idp.example.com/callback?token%ZZ=[REDACTED]&state=ok",
109+ },
110+ }
111+
112+ for _, tt := range tests {
113+ t.Run(tt.name, func(t *testing.T) {
114+ mock := &mockAccessLogWriter{}
115+ log := testLogger()
116+
117+ handler := AccessLogger(mock, false, log, nil)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
118+ w.WriteHeader(http.StatusOK)
119+ }))
120+
121+ req := httptest.NewRequest(http.MethodGet, "/target", nil)
122+ req.Header.Set("Referer", tt.referer)
123+ rw := httptest.NewRecorder()
124+
125+ handler.ServeHTTP(rw, req)
126+
127+ require.Len(t, mock.entries, 1)
128+ assert.Equal(t, tt.want, mock.entries[0].Referer)
129+ assert.NotContains(t, mock.entries[0].Referer, "secret")
130+ })
131+ }
132+}
133+
84 func TestAccessLogger_RequestIDReused(t *testing.T) {134 func TestAccessLogger_RequestIDReused(t *testing.T) {
85 mock := &mockAccessLogWriter{}135 mock := &mockAccessLogWriter{}
86 log := testLogger()136 log := testLogger()
M internal/adapters/in/http/middleware/logging.go +1 -1
@@ -134,7 +134,7 @@ func RequestLogger(log zerowrap.Logger, trustedNets ...[]*net.IPNet) func(http.H
134134 Str("query", sanitizeLoggedQuery(r.URL.RawQuery)).
135135 Str(zerowrap.FieldHost, r.Host).
136136 Str("user_agent", r.UserAgent()).
137- Str("referer", r.Referer()).
137+ Str("referer", sanitizeLoggedReferer(r.Referer())).
138138 Str(zerowrap.FieldClientIP, clientIP).
139139 Int(zerowrap.FieldStatus, rw.StatusCode()).
140140 Int("bytes", rw.BytesWritten()).
@@ -134,7 +134,7 @@ func RequestLogger(log zerowrap.Logger, trustedNets ...[]*net.IPNet) func(http.H
134 Str("query", sanitizeLoggedQuery(r.URL.RawQuery)).134 Str("query", sanitizeLoggedQuery(r.URL.RawQuery)).
135 Str(zerowrap.FieldHost, r.Host).135 Str(zerowrap.FieldHost, r.Host).
136 Str("user_agent", r.UserAgent()).136 Str("user_agent", r.UserAgent()).
137- Str("referer", r.Referer()).137+ Str("referer", sanitizeLoggedReferer(r.Referer())).
138 Str(zerowrap.FieldClientIP, clientIP).138 Str(zerowrap.FieldClientIP, clientIP).
139 Int(zerowrap.FieldStatus, rw.StatusCode()).139 Int(zerowrap.FieldStatus, rw.StatusCode()).
140 Int("bytes", rw.BytesWritten()).140 Int("bytes", rw.BytesWritten()).
M internal/adapters/in/http/middleware/logging_test.go +61 -0
@@ -1,13 +1,74 @@
11 package middleware
22
33 import (
4+ "bytes"
5+ "encoding/json"
46 "net/http"
57 "net/http/httptest"
68 "testing"
79
10+ "github.com/bnema/zerowrap"
811 "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/require"
913 )
1014
15+func TestRequestLogger_RedactsSensitiveReferers(t *testing.T) {
16+ tests := []struct {
17+ name string
18+ referer string
19+ wantReferer string
20+ }{
21+ {
22+ name: "query params",
23+ referer: "https://idp.example.com/callback?code=secret-code&state=ok&token=secret-token",
24+ wantReferer: "https://idp.example.com/callback?code=[REDACTED]&state=ok&token=[REDACTED]",
25+ },
26+ {
27+ name: "fragment removed",
28+ referer: "https://idp.example.com/callback?state=ok#access_token=secret-token",
29+ wantReferer: "https://idp.example.com/callback?state=ok",
30+ },
31+ {
32+ name: "userinfo removed",
33+ referer: "https://user:secret-password@idp.example.com/callback?code=secret-code",
34+ wantReferer: "https://idp.example.com/callback?code=[REDACTED]",
35+ },
36+ {
37+ name: "malformed query key redacted conservatively",
38+ referer: "https://idp.example.com/callback?token%ZZ=secret-token&state=ok",
39+ wantReferer: "https://idp.example.com/callback?token%ZZ=[REDACTED]&state=ok",
40+ },
41+ }
42+
43+ for _, tt := range tests {
44+ t.Run(tt.name, func(t *testing.T) {
45+ var logs bytes.Buffer
46+ log := zerowrap.New(zerowrap.Config{Level: "info", Format: "json", Output: &logs})
47+
48+ handler := RequestLogger(log)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
49+ w.WriteHeader(http.StatusNoContent)
50+ }))
51+
52+ req := httptest.NewRequest(http.MethodGet, "/target?token=direct-secret", nil)
53+ req.Header.Set("Referer", tt.referer)
54+ rw := httptest.NewRecorder()
55+
56+ handler.ServeHTTP(rw, req)
57+
58+ logOutput := logs.String()
59+ require.NotEmpty(t, logOutput)
60+ var entry map[string]any
61+ require.NoError(t, json.Unmarshal(logs.Bytes(), &entry))
62+ assert.Equal(t, "token=[REDACTED]", entry["query"])
63+ assert.Equal(t, tt.wantReferer, entry["referer"])
64+ assert.NotContains(t, logOutput, "direct-secret")
65+ assert.NotContains(t, logOutput, "secret-code")
66+ assert.NotContains(t, logOutput, "secret-token")
67+ assert.NotContains(t, logOutput, "secret-password")
68+ })
69+ }
70+}
71+
1172 func TestPanicRecovery_RethrowsReverseProxyAbort(t *testing.T) {
1273 log := testLogger()
1374
@@ -1,13 +1,74 @@
1 package middleware1 package middleware
2 2
3 import (3 import (
4+ "bytes"
5+ "encoding/json"
4 "net/http"6 "net/http"
5 "net/http/httptest"7 "net/http/httptest"
6 "testing"8 "testing"
7 9
10+ "github.com/bnema/zerowrap"
8 "github.com/stretchr/testify/assert"11 "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/require"
9 )13 )
10 14
15+func TestRequestLogger_RedactsSensitiveReferers(t *testing.T) {
16+ tests := []struct {
17+ name string
18+ referer string
19+ wantReferer string
20+ }{
21+ {
22+ name: "query params",
23+ referer: "https://idp.example.com/callback?code=secret-code&state=ok&token=secret-token",
24+ wantReferer: "https://idp.example.com/callback?code=[REDACTED]&state=ok&token=[REDACTED]",
25+ },
26+ {
27+ name: "fragment removed",
28+ referer: "https://idp.example.com/callback?state=ok#access_token=secret-token",
29+ wantReferer: "https://idp.example.com/callback?state=ok",
30+ },
31+ {
32+ name: "userinfo removed",
33+ referer: "https://user:secret-password@idp.example.com/callback?code=secret-code",
34+ wantReferer: "https://idp.example.com/callback?code=[REDACTED]",
35+ },
36+ {
37+ name: "malformed query key redacted conservatively",
38+ referer: "https://idp.example.com/callback?token%ZZ=secret-token&state=ok",
39+ wantReferer: "https://idp.example.com/callback?token%ZZ=[REDACTED]&state=ok",
40+ },
41+ }
42+
43+ for _, tt := range tests {
44+ t.Run(tt.name, func(t *testing.T) {
45+ var logs bytes.Buffer
46+ log := zerowrap.New(zerowrap.Config{Level: "info", Format: "json", Output: &logs})
47+
48+ handler := RequestLogger(log)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
49+ w.WriteHeader(http.StatusNoContent)
50+ }))
51+
52+ req := httptest.NewRequest(http.MethodGet, "/target?token=direct-secret", nil)
53+ req.Header.Set("Referer", tt.referer)
54+ rw := httptest.NewRecorder()
55+
56+ handler.ServeHTTP(rw, req)
57+
58+ logOutput := logs.String()
59+ require.NotEmpty(t, logOutput)
60+ var entry map[string]any
61+ require.NoError(t, json.Unmarshal(logs.Bytes(), &entry))
62+ assert.Equal(t, "token=[REDACTED]", entry["query"])
63+ assert.Equal(t, tt.wantReferer, entry["referer"])
64+ assert.NotContains(t, logOutput, "direct-secret")
65+ assert.NotContains(t, logOutput, "secret-code")
66+ assert.NotContains(t, logOutput, "secret-token")
67+ assert.NotContains(t, logOutput, "secret-password")
68+ })
69+ }
70+}
71+
11 func TestPanicRecovery_RethrowsReverseProxyAbort(t *testing.T) {72 func TestPanicRecovery_RethrowsReverseProxyAbort(t *testing.T) {
12 log := testLogger()73 log := testLogger()
13 74
M internal/adapters/out/docker/runtime.go +20 -4
@@ -10,6 +10,7 @@ import (
1010 "errors"
1111 "fmt"
1212 "io"
13+ "maps"
1314 "math"
1415 "os"
1516 "path/filepath"
@@ -1427,7 +1428,10 @@ func (r *Runtime) InspectImageEnv(ctx context.Context, imageRef string) ([]strin
14271428 }
14281429
14291430 if len(envVars) > 0 {
1430- log.Debug().Strs("env_vars", envVars).Msg("found ENV directives in image")
1431+ log.Debug().
1432+ Int(zerowrap.FieldCount, len(envVars)).
1433+ Strs("env_keys", imageEnvKeys(envVars)).
1434+ Msg("found ENV directives in image")
14311435 } else {
14321436 log.Debug().Msg("no ENV directives found in image")
14331437 }
@@ -1436,6 +1440,20 @@ func (r *Runtime) InspectImageEnv(ctx context.Context, imageRef string) ([]strin
14361440 }
14371441
14381442 // GetImageLabels returns the labels defined on an image.
1443+func imageEnvKeys(envVars []string) []string {
1444+ keys := make([]string, 0, len(envVars))
1445+ for _, envVar := range envVars {
1446+ key, _, ok := strings.Cut(envVar, "=")
1447+ if !ok || key == "" {
1448+ keys = append(keys, "[malformed]")
1449+ continue
1450+ }
1451+ keys = append(keys, key)
1452+ }
1453+ sort.Strings(keys)
1454+ return keys
1455+}
1456+
14391457 func (r *Runtime) GetImageLabels(ctx context.Context, imageRef string) (map[string]string, error) {
14401458 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
14411459 zerowrap.FieldLayer: "adapter",
@@ -1492,9 +1510,7 @@ func (r *Runtime) CreateNetwork(ctx context.Context, name string, config domain.
14921510 driver = "bridge"
14931511 }
14941512 labels := make(map[string]string, len(config.Labels)+1)
1495- for key, value := range config.Labels {
1496- labels[key] = value
1497- }
1513+ maps.Copy(labels, config.Labels)
14981514 labels[domain.LabelManaged] = "true"
14991515
15001516 createOptions := network.CreateOptions{
@@ -10,6 +10,7 @@ import (
10 "errors"10 "errors"
11 "fmt"11 "fmt"
12 "io"12 "io"
13+ "maps"
13 "math"14 "math"
14 "os"15 "os"
15 "path/filepath"16 "path/filepath"
@@ -1427,7 +1428,10 @@ func (r *Runtime) InspectImageEnv(ctx context.Context, imageRef string) ([]strin
1427 }1428 }
1428 1429
1429 if len(envVars) > 0 {1430 if len(envVars) > 0 {
1430- log.Debug().Strs("env_vars", envVars).Msg("found ENV directives in image")1431+ log.Debug().
1432+ Int(zerowrap.FieldCount, len(envVars)).
1433+ Strs("env_keys", imageEnvKeys(envVars)).
1434+ Msg("found ENV directives in image")
1431 } else {1435 } else {
1432 log.Debug().Msg("no ENV directives found in image")1436 log.Debug().Msg("no ENV directives found in image")
1433 }1437 }
@@ -1436,6 +1440,20 @@ func (r *Runtime) InspectImageEnv(ctx context.Context, imageRef string) ([]strin
1436 }1440 }
1437 1441
1438 // GetImageLabels returns the labels defined on an image.1442 // GetImageLabels returns the labels defined on an image.
1443+func imageEnvKeys(envVars []string) []string {
1444+ keys := make([]string, 0, len(envVars))
1445+ for _, envVar := range envVars {
1446+ key, _, ok := strings.Cut(envVar, "=")
1447+ if !ok || key == "" {
1448+ keys = append(keys, "[malformed]")
1449+ continue
1450+ }
1451+ keys = append(keys, key)
1452+ }
1453+ sort.Strings(keys)
1454+ return keys
1455+}
1456+
1439 func (r *Runtime) GetImageLabels(ctx context.Context, imageRef string) (map[string]string, error) {1457 func (r *Runtime) GetImageLabels(ctx context.Context, imageRef string) (map[string]string, error) {
1440 ctx = zerowrap.CtxWithFields(ctx, map[string]any{1458 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1441 zerowrap.FieldLayer: "adapter",1459 zerowrap.FieldLayer: "adapter",
@@ -1492,9 +1510,7 @@ func (r *Runtime) CreateNetwork(ctx context.Context, name string, config domain.
1492 driver = "bridge"1510 driver = "bridge"
1493 }1511 }
1494 labels := make(map[string]string, len(config.Labels)+1)1512 labels := make(map[string]string, len(config.Labels)+1)
1495- for key, value := range config.Labels {1513+ maps.Copy(labels, config.Labels)
1496- labels[key] = value
1497- }
1498 labels[domain.LabelManaged] = "true"1514 labels[domain.LabelManaged] = "true"
1499 1515
1500 createOptions := network.CreateOptions{1516 createOptions := network.CreateOptions{
M internal/adapters/out/docker/runtime_test.go +47 -0
@@ -1,14 +1,61 @@
11 package docker
22
33 import (
4+ "bytes"
5+ "context"
46 "errors"
7+ "net/http"
8+ "net/http/httptest"
9+ "strings"
510 "testing"
611
12+ "github.com/bnema/zerowrap"
713 "github.com/docker/docker/api/types/container"
14+ "github.com/docker/docker/client"
815 "github.com/stretchr/testify/assert"
916 "github.com/stretchr/testify/require"
1017 )
1118
19+func TestRuntime_InspectImageEnv_RedactsValuesInDebugLog(t *testing.T) {
20+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
21+ assert.Equal(t, http.MethodGet, r.Method)
22+ assert.Contains(t, r.URL.Path, "/images/")
23+ assert.Contains(t, r.URL.Path, "/json")
24+ w.Header().Set("Content-Type", "application/json")
25+ _, _ = w.Write([]byte(`{
26+ "Config": {
27+ "Env": ["API_KEY=super-secret-key", "DATABASE_URL=postgres://user:pass@example/db", "PORT=8080", "MALFORMED_SECRET_VALUE", "=empty-key-secret"]
28+ }
29+ }`))
30+ }))
31+ defer server.Close()
32+
33+ host := strings.TrimPrefix(server.URL, "http://")
34+ cli, err := client.NewClientWithOpts(client.WithHost("tcp://"+host), client.WithVersion("1.41"), client.WithHTTPClient(server.Client()))
35+ require.NoError(t, err)
36+ runtime := NewRuntimeWithClient(cli)
37+
38+ var logs bytes.Buffer
39+ log := zerowrap.New(zerowrap.Config{Level: "debug", Format: "json", Output: &logs})
40+ ctx := zerowrap.WithCtx(context.Background(), log)
41+
42+ envVars, err := runtime.InspectImageEnv(ctx, "example/app:latest")
43+ require.NoError(t, err)
44+ assert.Equal(t, []string{"API_KEY=super-secret-key", "DATABASE_URL=postgres://user:pass@example/db", "PORT=8080", "MALFORMED_SECRET_VALUE", "=empty-key-secret"}, envVars)
45+
46+ logOutput := logs.String()
47+ assert.Contains(t, logOutput, "env_keys")
48+ assert.Contains(t, logOutput, "API_KEY")
49+ assert.Contains(t, logOutput, "DATABASE_URL")
50+ assert.Contains(t, logOutput, "PORT")
51+ assert.Contains(t, logOutput, "[malformed]")
52+ assert.NotContains(t, logOutput, "super-secret-key")
53+ assert.NotContains(t, logOutput, "postgres://user:pass@example/db")
54+ assert.NotContains(t, logOutput, "MALFORMED_SECRET_VALUE")
55+ assert.NotContains(t, logOutput, "empty-key-secret")
56+ assert.NotContains(t, logOutput, "API_KEY=super-secret-key")
57+}
58+
1259 func TestWaitForVolumeArchiveContainerIgnoresNilErrorBeforeStatus(t *testing.T) {
1360 statusCh := make(chan container.WaitResponse, 1)
1461 errCh := make(chan error, 1)
@@ -1,14 +1,61 @@
1 package docker1 package docker
2 2
3 import (3 import (
4+ "bytes"
5+ "context"
4 "errors"6 "errors"
7+ "net/http"
8+ "net/http/httptest"
9+ "strings"
5 "testing"10 "testing"
6 11
12+ "github.com/bnema/zerowrap"
7 "github.com/docker/docker/api/types/container"13 "github.com/docker/docker/api/types/container"
14+ "github.com/docker/docker/client"
8 "github.com/stretchr/testify/assert"15 "github.com/stretchr/testify/assert"
9 "github.com/stretchr/testify/require"16 "github.com/stretchr/testify/require"
10 )17 )
11 18
19+func TestRuntime_InspectImageEnv_RedactsValuesInDebugLog(t *testing.T) {
20+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
21+ assert.Equal(t, http.MethodGet, r.Method)
22+ assert.Contains(t, r.URL.Path, "/images/")
23+ assert.Contains(t, r.URL.Path, "/json")
24+ w.Header().Set("Content-Type", "application/json")
25+ _, _ = w.Write([]byte(`{
26+ "Config": {
27+ "Env": ["API_KEY=super-secret-key", "DATABASE_URL=postgres://user:pass@example/db", "PORT=8080", "MALFORMED_SECRET_VALUE", "=empty-key-secret"]
28+ }
29+ }`))
30+ }))
31+ defer server.Close()
32+
33+ host := strings.TrimPrefix(server.URL, "http://")
34+ cli, err := client.NewClientWithOpts(client.WithHost("tcp://"+host), client.WithVersion("1.41"), client.WithHTTPClient(server.Client()))
35+ require.NoError(t, err)
36+ runtime := NewRuntimeWithClient(cli)
37+
38+ var logs bytes.Buffer
39+ log := zerowrap.New(zerowrap.Config{Level: "debug", Format: "json", Output: &logs})
40+ ctx := zerowrap.WithCtx(context.Background(), log)
41+
42+ envVars, err := runtime.InspectImageEnv(ctx, "example/app:latest")
43+ require.NoError(t, err)
44+ assert.Equal(t, []string{"API_KEY=super-secret-key", "DATABASE_URL=postgres://user:pass@example/db", "PORT=8080", "MALFORMED_SECRET_VALUE", "=empty-key-secret"}, envVars)
45+
46+ logOutput := logs.String()
47+ assert.Contains(t, logOutput, "env_keys")
48+ assert.Contains(t, logOutput, "API_KEY")
49+ assert.Contains(t, logOutput, "DATABASE_URL")
50+ assert.Contains(t, logOutput, "PORT")
51+ assert.Contains(t, logOutput, "[malformed]")
52+ assert.NotContains(t, logOutput, "super-secret-key")
53+ assert.NotContains(t, logOutput, "postgres://user:pass@example/db")
54+ assert.NotContains(t, logOutput, "MALFORMED_SECRET_VALUE")
55+ assert.NotContains(t, logOutput, "empty-key-secret")
56+ assert.NotContains(t, logOutput, "API_KEY=super-secret-key")
57+}
58+
12 func TestWaitForVolumeArchiveContainerIgnoresNilErrorBeforeStatus(t *testing.T) {59 func TestWaitForVolumeArchiveContainerIgnoresNilErrorBeforeStatus(t *testing.T) {
13 statusCh := make(chan container.WaitResponse, 1)60 statusCh := make(chan container.WaitResponse, 1)
14 errCh := make(chan error, 1)61 errCh := make(chan error, 1)
M internal/adapters/out/envloader/file.go +11 -8
@@ -99,7 +99,6 @@ func (l *FileLoader) LoadEnv(ctx context.Context, domain string) ([]string, erro
9999 if len(parts) != 2 {
100100 log.Warn().
101101 Int("line", lineNum).
102- Str("content", line).
103102 Msg("invalid env file line format, skipping")
104103 continue
105104 }
@@ -118,10 +117,14 @@ func (l *FileLoader) LoadEnv(ctx context.Context, domain string) ([]string, erro
118117 // Resolve secrets if value contains secret syntax
119118 resolvedValue, err := l.resolveSecrets(ctx, value)
120119 if err != nil {
121- return nil, log.WrapErrWithFields(err, "failed to resolve secret", map[string]any{
122- "key": key,
123- "line": lineNum,
124- })
120+ return nil, log.WrapErrWithFields(
121+ fmt.Errorf("key %s on line %d: %w", key, lineNum, err),
122+ "failed to resolve secret",
123+ map[string]any{
124+ "key": key,
125+ "line": lineNum,
126+ },
127+ )
125128 }
126129
127130 envVars = append(envVars, fmt.Sprintf("%s=%s", key, resolvedValue))
@@ -210,14 +213,14 @@ func (l *FileLoader) resolveSecrets(ctx context.Context, value string) (string,
210213
211214 end := strings.Index(result[start:], "}")
212215 if end == -1 {
213- return "", fmt.Errorf("unclosed secret syntax in value: %s", value)
216+ return "", fmt.Errorf("unclosed secret syntax")
214217 }
215218 end += start
216219
217220 secretRef := result[start+2 : end]
218221 parts := strings.SplitN(secretRef, ":", 2)
219222 if len(parts) != 2 {
220- return "", fmt.Errorf("invalid secret syntax: expected 'provider:path', got '%s'", secretRef)
223+ return "", fmt.Errorf("invalid secret syntax: expected 'provider:path'")
221224 }
222225
223226 providerName := parts[0]
@@ -230,7 +233,7 @@ func (l *FileLoader) resolveSecrets(ctx context.Context, value string) (string,
230233
231234 secretValue, err := provider.GetSecret(ctx, secretPath)
232235 if err != nil {
233- return "", fmt.Errorf("failed to get secret from provider %s: %w", providerName, err)
236+ return "", fmt.Errorf("failed to get secret from provider %s", providerName)
234237 }
235238
236239 // Replace the secret reference with the actual value
@@ -99,7 +99,6 @@ func (l *FileLoader) LoadEnv(ctx context.Context, domain string) ([]string, erro
99 if len(parts) != 2 {99 if len(parts) != 2 {
100 log.Warn().100 log.Warn().
101 Int("line", lineNum).101 Int("line", lineNum).
102- Str("content", line).
103 Msg("invalid env file line format, skipping")102 Msg("invalid env file line format, skipping")
104 continue103 continue
105 }104 }
@@ -118,10 +117,14 @@ func (l *FileLoader) LoadEnv(ctx context.Context, domain string) ([]string, erro
118 // Resolve secrets if value contains secret syntax117 // Resolve secrets if value contains secret syntax
119 resolvedValue, err := l.resolveSecrets(ctx, value)118 resolvedValue, err := l.resolveSecrets(ctx, value)
120 if err != nil {119 if err != nil {
121- return nil, log.WrapErrWithFields(err, "failed to resolve secret", map[string]any{120+ return nil, log.WrapErrWithFields(
122- "key": key,121+ fmt.Errorf("key %s on line %d: %w", key, lineNum, err),
123- "line": lineNum,122+ "failed to resolve secret",
124- })123+ map[string]any{
124+ "key": key,
125+ "line": lineNum,
126+ },
127+ )
125 }128 }
126 129
127 envVars = append(envVars, fmt.Sprintf("%s=%s", key, resolvedValue))130 envVars = append(envVars, fmt.Sprintf("%s=%s", key, resolvedValue))
@@ -210,14 +213,14 @@ func (l *FileLoader) resolveSecrets(ctx context.Context, value string) (string,
210 213
211 end := strings.Index(result[start:], "}")214 end := strings.Index(result[start:], "}")
212 if end == -1 {215 if end == -1 {
213- return "", fmt.Errorf("unclosed secret syntax in value: %s", value)216+ return "", fmt.Errorf("unclosed secret syntax")
214 }217 }
215 end += start218 end += start
216 219
217 secretRef := result[start+2 : end]220 secretRef := result[start+2 : end]
218 parts := strings.SplitN(secretRef, ":", 2)221 parts := strings.SplitN(secretRef, ":", 2)
219 if len(parts) != 2 {222 if len(parts) != 2 {
220- return "", fmt.Errorf("invalid secret syntax: expected 'provider:path', got '%s'", secretRef)223+ return "", fmt.Errorf("invalid secret syntax: expected 'provider:path'")
221 }224 }
222 225
223 providerName := parts[0]226 providerName := parts[0]
@@ -230,7 +233,7 @@ func (l *FileLoader) resolveSecrets(ctx context.Context, value string) (string,
230 233
231 secretValue, err := provider.GetSecret(ctx, secretPath)234 secretValue, err := provider.GetSecret(ctx, secretPath)
232 if err != nil {235 if err != nil {
233- return "", fmt.Errorf("failed to get secret from provider %s: %w", providerName, err)236+ return "", fmt.Errorf("failed to get secret from provider %s", providerName)
234 }237 }
235 238
236 // Replace the secret reference with the actual value239 // Replace the secret reference with the actual value
M internal/adapters/out/envloader/file_test.go +80 -0
@@ -1,7 +1,9 @@
11 package envloader
22
33 import (
4+ "bytes"
45 "context"
6+ "errors"
57 "os"
68 "path/filepath"
79 "testing"
@@ -203,6 +205,84 @@ ALSO_VALID=another
203205 })
204206 }
205207
208+func TestFileLoader_LoadEnv_RedactsSecretBearingDiagnostics(t *testing.T) {
209+ ctx := context.Background()
210+
211+ t.Run("invalid line warning omits raw line content", func(t *testing.T) {
212+ var logs bytes.Buffer
213+ log := zerowrap.New(zerowrap.Config{Level: "warn", Format: "json", Output: &logs})
214+ loader, err := NewFileLoader(t.TempDir(), log)
215+ require.NoError(t, err)
216+ ctx := zerowrap.WithCtx(ctx, log)
217+
218+ envFile, err := loader.getEnvFilePath("app.example.com")
219+ require.NoError(t, err)
220+ require.NoError(t, os.WriteFile(envFile, []byte("valid line leaks super-secret-token\nVALID=value\n"), 0600))
221+
222+ envVars, err := loader.LoadEnv(ctx, "app.example.com")
223+ require.NoError(t, err)
224+ assert.Equal(t, []string{"VALID=value"}, envVars)
225+ assert.NotContains(t, logs.String(), "super-secret-token")
226+ assert.NotContains(t, logs.String(), "valid line leaks")
227+ assert.Contains(t, logs.String(), "line")
228+ })
229+
230+ t.Run("secret resolution error omits unclosed raw value", func(t *testing.T) {
231+ log := zerowrap.New(zerowrap.Config{Level: "fatal"})
232+ loader, err := NewFileLoader(t.TempDir(), log)
233+ require.NoError(t, err)
234+
235+ envFile, err := loader.getEnvFilePath("app.example.com")
236+ require.NoError(t, err)
237+ require.NoError(t, os.WriteFile(envFile, []byte("DATABASE_URL=postgres://user:${pass:db-password-super-secret\n"), 0600))
238+
239+ _, err = loader.LoadEnv(ctx, "app.example.com")
240+ require.Error(t, err)
241+ assert.Contains(t, err.Error(), "failed to resolve secret")
242+ assert.Contains(t, err.Error(), "DATABASE_URL")
243+ assert.NotContains(t, err.Error(), "postgres://user")
244+ assert.NotContains(t, err.Error(), "db-password-super-secret")
245+ })
246+
247+ t.Run("secret resolution error omits malformed closed secret ref", func(t *testing.T) {
248+ log := zerowrap.New(zerowrap.Config{Level: "fatal"})
249+ loader, err := NewFileLoader(t.TempDir(), log)
250+ require.NoError(t, err)
251+
252+ envFile, err := loader.getEnvFilePath("app.example.com")
253+ require.NoError(t, err)
254+ require.NoError(t, os.WriteFile(envFile, []byte("TOKEN=${db-password-super-secret}\n"), 0600))
255+
256+ _, err = loader.LoadEnv(ctx, "app.example.com")
257+ require.Error(t, err)
258+ assert.Contains(t, err.Error(), "failed to resolve secret")
259+ assert.Contains(t, err.Error(), "TOKEN")
260+ assert.NotContains(t, err.Error(), "db-password-super-secret")
261+ })
262+
263+ t.Run("secret resolution error omits provider error details", func(t *testing.T) {
264+ log := zerowrap.New(zerowrap.Config{Level: "fatal"})
265+ loader, err := NewFileLoader(t.TempDir(), log)
266+ require.NoError(t, err)
267+
268+ mockPass := mocks.NewMockSecretProvider(t)
269+ mockPass.EXPECT().Name().Return("pass")
270+ mockPass.EXPECT().GetSecret(ctx, "db-password-super-secret").Return("", errors.New("backend leaked db-password-super-secret"))
271+ loader.RegisterSecretProvider(mockPass)
272+
273+ envFile, err := loader.getEnvFilePath("app.example.com")
274+ require.NoError(t, err)
275+ require.NoError(t, os.WriteFile(envFile, []byte("TOKEN=${pass:db-password-super-secret}\n"), 0600))
276+
277+ _, err = loader.LoadEnv(ctx, "app.example.com")
278+ require.Error(t, err)
279+ assert.Contains(t, err.Error(), "failed to resolve secret")
280+ assert.Contains(t, err.Error(), "TOKEN")
281+ assert.Contains(t, err.Error(), "provider pass")
282+ assert.NotContains(t, err.Error(), "db-password-super-secret")
283+ })
284+}
285+
206286 func TestFileLoader_GetEnvFilePath(t *testing.T) {
207287 tmpDir := t.TempDir()
208288 log := zerowrap.New(zerowrap.Config{Level: "fatal"})
@@ -1,7 +1,9 @@
1 package envloader1 package envloader
2 2
3 import (3 import (
4+ "bytes"
4 "context"5 "context"
6+ "errors"
5 "os"7 "os"
6 "path/filepath"8 "path/filepath"
7 "testing"9 "testing"
@@ -203,6 +205,84 @@ ALSO_VALID=another
203 })205 })
204 }206 }
205 207
208+func TestFileLoader_LoadEnv_RedactsSecretBearingDiagnostics(t *testing.T) {
209+ ctx := context.Background()
210+
211+ t.Run("invalid line warning omits raw line content", func(t *testing.T) {
212+ var logs bytes.Buffer
213+ log := zerowrap.New(zerowrap.Config{Level: "warn", Format: "json", Output: &logs})
214+ loader, err := NewFileLoader(t.TempDir(), log)
215+ require.NoError(t, err)
216+ ctx := zerowrap.WithCtx(ctx, log)
217+
218+ envFile, err := loader.getEnvFilePath("app.example.com")
219+ require.NoError(t, err)
220+ require.NoError(t, os.WriteFile(envFile, []byte("valid line leaks super-secret-token\nVALID=value\n"), 0600))
221+
222+ envVars, err := loader.LoadEnv(ctx, "app.example.com")
223+ require.NoError(t, err)
224+ assert.Equal(t, []string{"VALID=value"}, envVars)
225+ assert.NotContains(t, logs.String(), "super-secret-token")
226+ assert.NotContains(t, logs.String(), "valid line leaks")
227+ assert.Contains(t, logs.String(), "line")
228+ })
229+
230+ t.Run("secret resolution error omits unclosed raw value", func(t *testing.T) {
231+ log := zerowrap.New(zerowrap.Config{Level: "fatal"})
232+ loader, err := NewFileLoader(t.TempDir(), log)
233+ require.NoError(t, err)
234+
235+ envFile, err := loader.getEnvFilePath("app.example.com")
236+ require.NoError(t, err)
237+ require.NoError(t, os.WriteFile(envFile, []byte("DATABASE_URL=postgres://user:${pass:db-password-super-secret\n"), 0600))
238+
239+ _, err = loader.LoadEnv(ctx, "app.example.com")
240+ require.Error(t, err)
241+ assert.Contains(t, err.Error(), "failed to resolve secret")
242+ assert.Contains(t, err.Error(), "DATABASE_URL")
243+ assert.NotContains(t, err.Error(), "postgres://user")
244+ assert.NotContains(t, err.Error(), "db-password-super-secret")
245+ })
246+
247+ t.Run("secret resolution error omits malformed closed secret ref", func(t *testing.T) {
248+ log := zerowrap.New(zerowrap.Config{Level: "fatal"})
249+ loader, err := NewFileLoader(t.TempDir(), log)
250+ require.NoError(t, err)
251+
252+ envFile, err := loader.getEnvFilePath("app.example.com")
253+ require.NoError(t, err)
254+ require.NoError(t, os.WriteFile(envFile, []byte("TOKEN=${db-password-super-secret}\n"), 0600))
255+
256+ _, err = loader.LoadEnv(ctx, "app.example.com")
257+ require.Error(t, err)
258+ assert.Contains(t, err.Error(), "failed to resolve secret")
259+ assert.Contains(t, err.Error(), "TOKEN")
260+ assert.NotContains(t, err.Error(), "db-password-super-secret")
261+ })
262+
263+ t.Run("secret resolution error omits provider error details", func(t *testing.T) {
264+ log := zerowrap.New(zerowrap.Config{Level: "fatal"})
265+ loader, err := NewFileLoader(t.TempDir(), log)
266+ require.NoError(t, err)
267+
268+ mockPass := mocks.NewMockSecretProvider(t)
269+ mockPass.EXPECT().Name().Return("pass")
270+ mockPass.EXPECT().GetSecret(ctx, "db-password-super-secret").Return("", errors.New("backend leaked db-password-super-secret"))
271+ loader.RegisterSecretProvider(mockPass)
272+
273+ envFile, err := loader.getEnvFilePath("app.example.com")
274+ require.NoError(t, err)
275+ require.NoError(t, os.WriteFile(envFile, []byte("TOKEN=${pass:db-password-super-secret}\n"), 0600))
276+
277+ _, err = loader.LoadEnv(ctx, "app.example.com")
278+ require.Error(t, err)
279+ assert.Contains(t, err.Error(), "failed to resolve secret")
280+ assert.Contains(t, err.Error(), "TOKEN")
281+ assert.Contains(t, err.Error(), "provider pass")
282+ assert.NotContains(t, err.Error(), "db-password-super-secret")
283+ })
284+}
285+
206 func TestFileLoader_GetEnvFilePath(t *testing.T) {286 func TestFileLoader_GetEnvFilePath(t *testing.T) {
207 tmpDir := t.TempDir()287 tmpDir := t.TempDir()
208 log := zerowrap.New(zerowrap.Config{Level: "fatal"})288 log := zerowrap.New(zerowrap.Config{Level: "fatal"})

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close