Merge pull request #211 from bnema/fix/redact-secret-logs
fix(logging): redact secret-bearing log values
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 | |||
| 66 | 66 | BytesSent: rw.BytesWritten(), | |
| 67 | 67 | DurationMS: durationMS, | |
| 68 | 68 | UserAgent: r.UserAgent(), | |
| 69 | - | Referer: r.Referer(), | |
| 69 | + | Referer: sanitizeLoggedReferer(r.Referer()), | |
| 70 | 70 | RequestID: requestID, | |
| 71 | 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 | 105 | func sanitizeLoggedQuery(rawQuery string) string { | |
| 91 | 106 | if rawQuery == "" { | |
| 92 | 107 | return "" | |
| @@ -101,7 +116,10 @@ func sanitizeLoggedQuery(rawQuery string) string { | |||
| 101 | 116 | key, _, found := strings.Cut(part, "=") | |
| 102 | 117 | decodedKey, err := url.QueryUnescape(key) | |
| 103 | 118 | if err != nil { | |
| 104 | - | decodedKey = key | |
| 119 | + | if found { | |
| 120 | + | parts[i] = key + "=[REDACTED]" | |
| 121 | + | } | |
| 122 | + | continue | |
| 105 | 123 | } | |
| 106 | 124 | ||
| 107 | 125 | 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 = key | 119 | + | 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) { | |||
| 81 | 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 | 134 | func TestAccessLogger_RequestIDReused(t *testing.T) { | |
| 85 | 135 | mock := &mockAccessLogWriter{} | |
| 86 | 136 | 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 | |||
| 134 | 134 | Str("query", sanitizeLoggedQuery(r.URL.RawQuery)). | |
| 135 | 135 | Str(zerowrap.FieldHost, r.Host). | |
| 136 | 136 | Str("user_agent", r.UserAgent()). | |
| 137 | - | Str("referer", r.Referer()). | |
| 137 | + | Str("referer", sanitizeLoggedReferer(r.Referer())). | |
| 138 | 138 | Str(zerowrap.FieldClientIP, clientIP). | |
| 139 | 139 | Int(zerowrap.FieldStatus, rw.StatusCode()). | |
| 140 | 140 | 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 @@ | |||
| 1 | 1 | package middleware | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "encoding/json" | |
| 4 | 6 | "net/http" | |
| 5 | 7 | "net/http/httptest" | |
| 6 | 8 | "testing" | |
| 7 | 9 | ||
| 10 | + | "github.com/bnema/zerowrap" | |
| 8 | 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 | 72 | func TestPanicRecovery_RethrowsReverseProxyAbort(t *testing.T) { | |
| 12 | 73 | log := testLogger() | |
| 13 | 74 | ||
| @@ -1,13 +1,74 @@ | |||||
| 1 | package middleware | 1 | 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 ( | |||
| 10 | 10 | "errors" | |
| 11 | 11 | "fmt" | |
| 12 | 12 | "io" | |
| 13 | + | "maps" | |
| 13 | 14 | "math" | |
| 14 | 15 | "os" | |
| 15 | 16 | "path/filepath" | |
| @@ -1427,7 +1428,10 @@ func (r *Runtime) InspectImageEnv(ctx context.Context, imageRef string) ([]strin | |||
| 1427 | 1428 | } | |
| 1428 | 1429 | ||
| 1429 | 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 | 1435 | } else { | |
| 1432 | 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 | 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 | 1457 | func (r *Runtime) GetImageLabels(ctx context.Context, imageRef string) (map[string]string, error) { | |
| 1440 | 1458 | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |
| 1441 | 1459 | zerowrap.FieldLayer: "adapter", | |
| @@ -1492,9 +1510,7 @@ func (r *Runtime) CreateNetwork(ctx context.Context, name string, config domain. | |||
| 1492 | 1510 | driver = "bridge" | |
| 1493 | 1511 | } | |
| 1494 | 1512 | 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) | |
| 1498 | 1514 | labels[domain.LabelManaged] = "true" | |
| 1499 | 1515 | ||
| 1500 | 1516 | 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 @@ | |||
| 1 | 1 | package docker | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "context" | |
| 4 | 6 | "errors" | |
| 7 | + | "net/http" | |
| 8 | + | "net/http/httptest" | |
| 9 | + | "strings" | |
| 5 | 10 | "testing" | |
| 6 | 11 | ||
| 12 | + | "github.com/bnema/zerowrap" | |
| 7 | 13 | "github.com/docker/docker/api/types/container" | |
| 14 | + | "github.com/docker/docker/client" | |
| 8 | 15 | "github.com/stretchr/testify/assert" | |
| 9 | 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 | 59 | func TestWaitForVolumeArchiveContainerIgnoresNilErrorBeforeStatus(t *testing.T) { | |
| 13 | 60 | statusCh := make(chan container.WaitResponse, 1) | |
| 14 | 61 | errCh := make(chan error, 1) | |
| @@ -1,14 +1,61 @@ | |||||
| 1 | package docker | 1 | 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 | |||
| 99 | 99 | if len(parts) != 2 { | |
| 100 | 100 | log.Warn(). | |
| 101 | 101 | Int("line", lineNum). | |
| 102 | - | Str("content", line). | |
| 103 | 102 | Msg("invalid env file line format, skipping") | |
| 104 | 103 | continue | |
| 105 | 104 | } | |
| @@ -118,10 +117,14 @@ func (l *FileLoader) LoadEnv(ctx context.Context, domain string) ([]string, erro | |||
| 118 | 117 | // Resolve secrets if value contains secret syntax | |
| 119 | 118 | resolvedValue, err := l.resolveSecrets(ctx, value) | |
| 120 | 119 | 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 | + | ) | |
| 125 | 128 | } | |
| 126 | 129 | ||
| 127 | 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 | 214 | end := strings.Index(result[start:], "}") | |
| 212 | 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 | 218 | end += start | |
| 216 | 219 | ||
| 217 | 220 | secretRef := result[start+2 : end] | |
| 218 | 221 | parts := strings.SplitN(secretRef, ":", 2) | |
| 219 | 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 | 226 | providerName := parts[0] | |
| @@ -230,7 +233,7 @@ func (l *FileLoader) resolveSecrets(ctx context.Context, value string) (string, | |||
| 230 | 233 | ||
| 231 | 234 | secretValue, err := provider.GetSecret(ctx, secretPath) | |
| 232 | 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 | 239 | // 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 | continue | 103 | 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 syntax | 117 | // 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 += start | 218 | 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 value | 239 | // Replace the secret reference with the actual value | ||
M internal/adapters/out/envloader/file_test.go +80 -0
| @@ -1,7 +1,9 @@ | |||
| 1 | 1 | package envloader | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "bytes" | |
| 4 | 5 | "context" | |
| 6 | + | "errors" | |
| 5 | 7 | "os" | |
| 6 | 8 | "path/filepath" | |
| 7 | 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 | 286 | func TestFileLoader_GetEnvFilePath(t *testing.T) { | |
| 207 | 287 | tmpDir := t.TempDir() | |
| 208 | 288 | log := zerowrap.New(zerowrap.Config{Level: "fatal"}) | |
| @@ -1,7 +1,9 @@ | |||||
| 1 | package envloader | 1 | 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"}) | ||