| 1 | // SPDX-License-Identifier: GPL-3.0-or-later |
| 2 | |
| 3 | package secretresolver |
| 4 | |
| 5 | import ( |
| 6 | "bytes" |
| 7 | "context" |
| 8 | "errors" |
| 9 | "os" |
| 10 | "path/filepath" |
| 11 | "runtime" |
| 12 | "testing" |
| 13 | |
| 14 | "github.com/netdata/netdata/go/plugins/logger" |
| 15 | "github.com/stretchr/testify/assert" |
| 16 | "github.com/stretchr/testify/require" |
| 17 | ) |
| 18 | |
| 19 | func TestResolve(t *testing.T) { |
| 20 | tests := map[string]struct { |
| 21 | buildCfg func(t *testing.T) map[string]any |
| 22 | wantErrContains string |
| 23 | assertCfg func(t *testing.T, cfg map[string]any) |
| 24 | }{ |
| 25 | "env ref": { |
| 26 | buildCfg: func(t *testing.T) map[string]any { |
| 27 | t.Setenv("TEST_SECRET_USER", "admin") |
| 28 | return map[string]any{"username": "${env:TEST_SECRET_USER}"} |
| 29 | }, |
| 30 | assertCfg: func(t *testing.T, cfg map[string]any) { |
| 31 | assert.Equal(t, "admin", cfg["username"]) |
| 32 | }, |
| 33 | }, |
| 34 | "env ref trims surrounding whitespace": { |
| 35 | buildCfg: func(t *testing.T) map[string]any { |
| 36 | t.Setenv("TEST_SECRET_TRIMMED", " admin \n") |
| 37 | return map[string]any{"username": "${env:TEST_SECRET_TRIMMED}"} |
| 38 | }, |
| 39 | assertCfg: func(t *testing.T, cfg map[string]any) { |
| 40 | assert.Equal(t, "admin", cfg["username"]) |
| 41 | }, |
| 42 | }, |
| 43 | "file ref": { |
| 44 | buildCfg: func(t *testing.T) map[string]any { |
| 45 | path := filepath.Join(t.TempDir(), "secret.txt") |
| 46 | require.NoError(t, os.WriteFile(path, []byte(" s3cret\n"), 0600)) |
| 47 | return map[string]any{"password": "${file:" + path + "}"} |
| 48 | }, |
| 49 | assertCfg: func(t *testing.T, cfg map[string]any) { |
| 50 | assert.Equal(t, "s3cret", cfg["password"]) |
| 51 | }, |
| 52 | }, |
| 53 | "nested maps": { |
| 54 | buildCfg: func(t *testing.T) map[string]any { |
| 55 | t.Setenv("TEST_SECRET_HOST", "db.local") |
| 56 | return map[string]any{ |
| 57 | "database": map[string]any{ |
| 58 | "host": "${env:TEST_SECRET_HOST}", |
| 59 | "port": 5432, |
| 60 | }, |
| 61 | } |
| 62 | }, |
| 63 | assertCfg: func(t *testing.T, cfg map[string]any) { |
| 64 | inner := cfg["database"].(map[string]any) |
| 65 | assert.Equal(t, "db.local", inner["host"]) |
| 66 | assert.Equal(t, 5432, inner["port"]) |
| 67 | }, |
| 68 | }, |
| 69 | "map any any": { |
| 70 | buildCfg: func(t *testing.T) map[string]any { |
| 71 | t.Setenv("TEST_SECRET_LABEL", "prod") |
| 72 | return map[string]any{ |
| 73 | "labels": map[any]any{"env": "${env:TEST_SECRET_LABEL}"}, |
| 74 | } |
| 75 | }, |
| 76 | assertCfg: func(t *testing.T, cfg map[string]any) { |
| 77 | labels := cfg["labels"].(map[any]any) |
| 78 | assert.Equal(t, "prod", labels["env"]) |
| 79 | }, |
| 80 | }, |
| 81 | "array with strings": { |
| 82 | buildCfg: func(t *testing.T) map[string]any { |
| 83 | t.Setenv("TEST_SECRET_ITEM", "resolved") |
| 84 | return map[string]any{ |
| 85 | "items": []any{"plain", "${env:TEST_SECRET_ITEM}", 42}, |
| 86 | } |
| 87 | }, |
| 88 | assertCfg: func(t *testing.T, cfg map[string]any) { |
| 89 | items := cfg["items"].([]any) |
| 90 | assert.Equal(t, "plain", items[0]) |
| 91 | assert.Equal(t, "resolved", items[1]) |
| 92 | assert.Equal(t, 42, items[2]) |
| 93 | }, |
| 94 | }, |
| 95 | "multiple refs in one string": { |
| 96 | buildCfg: func(t *testing.T) map[string]any { |
| 97 | t.Setenv("TEST_SECRET_DB_USER", "root") |
| 98 | t.Setenv("TEST_SECRET_DB_PASS", "p@ss") |
| 99 | return map[string]any{ |
| 100 | "dsn": "${env:TEST_SECRET_DB_USER}:${env:TEST_SECRET_DB_PASS}@tcp(localhost)/db", |
| 101 | } |
| 102 | }, |
| 103 | assertCfg: func(t *testing.T, cfg map[string]any) { |
| 104 | assert.Equal(t, "root:p@ss@tcp(localhost)/db", cfg["dsn"]) |
| 105 | }, |
| 106 | }, |
| 107 | "uppercase no-scheme left alone": { |
| 108 | buildCfg: func(t *testing.T) map[string]any { |
| 109 | return map[string]any{"token": "${MY_TOKEN}"} |
| 110 | }, |
| 111 | assertCfg: func(t *testing.T, cfg map[string]any) { |
| 112 | assert.Equal(t, "${MY_TOKEN}", cfg["token"]) |
| 113 | }, |
| 114 | }, |
| 115 | "lowercase no-scheme left alone": { |
| 116 | buildCfg: func(t *testing.T) map[string]any { |
| 117 | return map[string]any{"template": "${lower_case}"} |
| 118 | }, |
| 119 | assertCfg: func(t *testing.T, cfg map[string]any) { |
| 120 | assert.Equal(t, "${lower_case}", cfg["template"]) |
| 121 | }, |
| 122 | }, |
| 123 | "unknown scheme error": { |
| 124 | buildCfg: func(t *testing.T) map[string]any { |
| 125 | return map[string]any{"val": "${nosuchprovider:secret/data/pass}"} |
| 126 | }, |
| 127 | wantErrContains: "unknown secret provider 'nosuchprovider'", |
| 128 | }, |
| 129 | "legacy remote vault syntax is rejected": { |
| 130 | buildCfg: func(t *testing.T) map[string]any { |
| 131 | return map[string]any{"val": "${vault:secret/data/pass#key}"} |
| 132 | }, |
| 133 | wantErrContains: "unknown secret provider 'vault'", |
| 134 | }, |
| 135 | "legacy remote aws syntax is rejected": { |
| 136 | buildCfg: func(t *testing.T) map[string]any { |
| 137 | return map[string]any{"val": "${aws-sm:mysecret}"} |
| 138 | }, |
| 139 | wantErrContains: "unknown secret provider 'aws-sm'", |
| 140 | }, |
| 141 | "missing env var error": { |
| 142 | buildCfg: func(t *testing.T) map[string]any { |
| 143 | return map[string]any{"password": "${env:DEFINITELY_NOT_SET_12345}"} |
| 144 | }, |
| 145 | wantErrContains: "environment variable 'DEFINITELY_NOT_SET_12345' is not set", |
| 146 | }, |
| 147 | "missing file error": { |
| 148 | buildCfg: func(t *testing.T) map[string]any { |
| 149 | path := filepath.Join(t.TempDir(), "nonexistent_secret_file") |
| 150 | return map[string]any{"secret": "${file:" + path + "}"} |
| 151 | }, |
| 152 | wantErrContains: "resolving secret '${file:", |
| 153 | }, |
| 154 | "relative file path error": { |
| 155 | buildCfg: func(t *testing.T) map[string]any { |
| 156 | return map[string]any{"secret": "${file:relative/secret.txt}"} |
| 157 | }, |
| 158 | wantErrContains: "file path must be absolute", |
| 159 | }, |
| 160 | "internal keys skipped": { |
| 161 | buildCfg: func(t *testing.T) map[string]any { |
| 162 | return map[string]any{ |
| 163 | "__source__": "${env:SHOULD_NOT_RESOLVE}", |
| 164 | "__source_type__": "${env:SHOULD_NOT_RESOLVE}", |
| 165 | "__provider__": "${env:SHOULD_NOT_RESOLVE}", |
| 166 | } |
| 167 | }, |
| 168 | assertCfg: func(t *testing.T, cfg map[string]any) { |
| 169 | assert.Equal(t, "${env:SHOULD_NOT_RESOLVE}", cfg["__source__"]) |
| 170 | assert.Equal(t, "${env:SHOULD_NOT_RESOLVE}", cfg["__source_type__"]) |
| 171 | assert.Equal(t, "${env:SHOULD_NOT_RESOLVE}", cfg["__provider__"]) |
| 172 | }, |
| 173 | }, |
| 174 | "non-string values untouched": { |
| 175 | buildCfg: func(t *testing.T) map[string]any { |
| 176 | return map[string]any{ |
| 177 | "port": 8080, |
| 178 | "enabled": true, |
| 179 | "ratio": 3.14, |
| 180 | "nothing": nil, |
| 181 | } |
| 182 | }, |
| 183 | assertCfg: func(t *testing.T, cfg map[string]any) { |
| 184 | assert.Equal(t, 8080, cfg["port"]) |
| 185 | assert.Equal(t, true, cfg["enabled"]) |
| 186 | assert.Equal(t, 3.14, cfg["ratio"]) |
| 187 | assert.Nil(t, cfg["nothing"]) |
| 188 | }, |
| 189 | }, |
| 190 | "no refs no changes": { |
| 191 | buildCfg: func(t *testing.T) map[string]any { |
| 192 | return map[string]any{"host": "localhost", "port": 3306} |
| 193 | }, |
| 194 | assertCfg: func(t *testing.T, cfg map[string]any) { |
| 195 | assert.Equal(t, "localhost", cfg["host"]) |
| 196 | assert.Equal(t, 3306, cfg["port"]) |
| 197 | }, |
| 198 | }, |
| 199 | "empty map": { |
| 200 | buildCfg: func(t *testing.T) map[string]any { return map[string]any{} }, |
| 201 | assertCfg: func(t *testing.T, cfg map[string]any) { |
| 202 | assert.Empty(t, cfg) |
| 203 | }, |
| 204 | }, |
| 205 | "missing uppercase no-scheme left alone": { |
| 206 | buildCfg: func(t *testing.T) map[string]any { |
| 207 | return map[string]any{"token": "${MISSING_SHORTHAND_VAR_12345}"} |
| 208 | }, |
| 209 | assertCfg: func(t *testing.T, cfg map[string]any) { |
| 210 | assert.Equal(t, "${MISSING_SHORTHAND_VAR_12345}", cfg["token"]) |
| 211 | }, |
| 212 | }, |
| 213 | "mixed refs and plain text": { |
| 214 | buildCfg: func(t *testing.T) map[string]any { |
| 215 | t.Setenv("TEST_SECRET_PROTO", "https") |
| 216 | return map[string]any{"url": "${env:TEST_SECRET_PROTO}://example.com/api"} |
| 217 | }, |
| 218 | assertCfg: func(t *testing.T, cfg map[string]any) { |
| 219 | assert.Equal(t, "https://example.com/api", cfg["url"]) |
| 220 | }, |
| 221 | }, |
| 222 | "deeply nested": { |
| 223 | buildCfg: func(t *testing.T) map[string]any { |
| 224 | t.Setenv("TEST_SECRET_DEEP", "found") |
| 225 | return map[string]any{ |
| 226 | "level1": map[string]any{ |
| 227 | "level2": map[string]any{"level3": "${env:TEST_SECRET_DEEP}"}, |
| 228 | }, |
| 229 | } |
| 230 | }, |
| 231 | assertCfg: func(t *testing.T, cfg map[string]any) { |
| 232 | l1 := cfg["level1"].(map[string]any) |
| 233 | l2 := l1["level2"].(map[string]any) |
| 234 | assert.Equal(t, "found", l2["level3"]) |
| 235 | }, |
| 236 | }, |
| 237 | "file ref and env ref together": { |
| 238 | buildCfg: func(t *testing.T) map[string]any { |
| 239 | t.Setenv("TEST_SECRET_FUSER", "admin") |
| 240 | path := filepath.Join(t.TempDir(), "pass.txt") |
| 241 | require.NoError(t, os.WriteFile(path, []byte("hunter2\n"), 0600)) |
| 242 | return map[string]any{ |
| 243 | "dsn": "${env:TEST_SECRET_FUSER}:${file:" + path + "}@host", |
| 244 | } |
| 245 | }, |
| 246 | assertCfg: func(t *testing.T, cfg map[string]any) { |
| 247 | assert.Equal(t, "admin:hunter2@host", cfg["dsn"]) |
| 248 | }, |
| 249 | }, |
| 250 | "internal key in nested map": { |
| 251 | buildCfg: func(t *testing.T) map[string]any { |
| 252 | return map[string]any{ |
| 253 | "sub": map[string]any{ |
| 254 | "__meta__": "${env:SHOULD_NOT_RESOLVE}", |
| 255 | "normal": "plain", |
| 256 | }, |
| 257 | } |
| 258 | }, |
| 259 | assertCfg: func(t *testing.T, cfg map[string]any) { |
| 260 | sub := cfg["sub"].(map[string]any) |
| 261 | assert.Equal(t, "${env:SHOULD_NOT_RESOLVE}", sub["__meta__"]) |
| 262 | assert.Equal(t, "plain", sub["normal"]) |
| 263 | }, |
| 264 | }, |
| 265 | "empty env name error": { |
| 266 | buildCfg: func(t *testing.T) map[string]any { |
| 267 | return map[string]any{"val": "${env:}"} |
| 268 | }, |
| 269 | wantErrContains: "environment variable '' is not set", |
| 270 | }, |
| 271 | "empty file name error": { |
| 272 | buildCfg: func(t *testing.T) map[string]any { |
| 273 | return map[string]any{"val": "${file:}"} |
| 274 | }, |
| 275 | wantErrContains: "resolving secret", |
| 276 | }, |
| 277 | "multiple refs one failure": { |
| 278 | buildCfg: func(t *testing.T) map[string]any { |
| 279 | t.Setenv("TEST_SECRET_OK", "good") |
| 280 | return map[string]any{ |
| 281 | "dsn": "${env:TEST_SECRET_OK}:${env:MISSING_VAR_12345}@host", |
| 282 | } |
| 283 | }, |
| 284 | wantErrContains: "MISSING_VAR_12345", |
| 285 | }, |
| 286 | "array in nested map": { |
| 287 | buildCfg: func(t *testing.T) map[string]any { |
| 288 | t.Setenv("TEST_SECRET_ARR", "val") |
| 289 | return map[string]any{ |
| 290 | "outer": map[string]any{ |
| 291 | "list": []any{"${env:TEST_SECRET_ARR}", "static"}, |
| 292 | }, |
| 293 | } |
| 294 | }, |
| 295 | assertCfg: func(t *testing.T, cfg map[string]any) { |
| 296 | outer := cfg["outer"].(map[string]any) |
| 297 | list := outer["list"].([]any) |
| 298 | assert.Equal(t, "val", list[0]) |
| 299 | assert.Equal(t, "static", list[1]) |
| 300 | }, |
| 301 | }, |
| 302 | } |
| 303 | |
| 304 | for name, tc := range tests { |
| 305 | t.Run(name, func(t *testing.T) { |
| 306 | resolver := New() |
| 307 | cfg := tc.buildCfg(t) |
| 308 | err := resolver.Resolve(cfg) |
| 309 | |
| 310 | if tc.wantErrContains != "" { |
| 311 | require.Error(t, err) |
| 312 | assert.Contains(t, err.Error(), tc.wantErrContains) |
| 313 | return |
| 314 | } |
| 315 | |
| 316 | require.NoError(t, err) |
| 317 | if tc.assertCfg != nil { |
| 318 | tc.assertCfg(t, cfg) |
| 319 | } |
| 320 | }) |
| 321 | } |
| 322 | } |
| 323 | |
| 324 | func TestResolveWithStoreResolver(t *testing.T) { |
| 325 | tests := map[string]struct { |
| 326 | cfg map[string]any |
| 327 | storeResolver StoreRefResolver |
| 328 | wantErrContains string |
| 329 | assertCfg func(t *testing.T, cfg map[string]any) |
| 330 | }{ |
| 331 | "store ref with resolver": { |
| 332 | cfg: map[string]any{ |
| 333 | "password": "${store:vault:vault_prod:secret/data/mysql#password}", |
| 334 | }, |
| 335 | storeResolver: func(ctx context.Context, ref, original string) (string, error) { |
| 336 | require.NotNil(t, ctx) |
| 337 | if ref == "vault:vault_prod:secret/data/mysql#password" && original == "${store:vault:vault_prod:secret/data/mysql#password}" { |
| 338 | return "resolved-secret", nil |
| 339 | } |
| 340 | return "", errors.New("unexpected ref") |
| 341 | }, |
| 342 | assertCfg: func(t *testing.T, cfg map[string]any) { |
| 343 | assert.Equal(t, "resolved-secret", cfg["password"]) |
| 344 | }, |
| 345 | }, |
| 346 | "store ref without resolver": { |
| 347 | cfg: map[string]any{ |
| 348 | "password": "${store:vault:vault_prod:secret/data/mysql#password}", |
| 349 | }, |
| 350 | wantErrContains: "secretstore resolver is not configured", |
| 351 | }, |
| 352 | "store resolver error bubbles": { |
| 353 | cfg: map[string]any{ |
| 354 | "password": "${store:vault:vault_prod:secret/data/mysql#password}", |
| 355 | }, |
| 356 | storeResolver: func(ctx context.Context, ref, original string) (string, error) { |
| 357 | require.NotNil(t, ctx) |
| 358 | return "", errors.New("store not configured") |
| 359 | }, |
| 360 | wantErrContains: "store not configured", |
| 361 | }, |
| 362 | "mixed env and store refs": { |
| 363 | cfg: map[string]any{ |
| 364 | "dsn": "${env:TEST_SR_USER}:${store:aws-sm:aws_prod:app/db#password}@host", |
| 365 | }, |
| 366 | storeResolver: func(ctx context.Context, ref, original string) (string, error) { |
| 367 | require.NotNil(t, ctx) |
| 368 | assert.Equal(t, "aws-sm:aws_prod:app/db#password", ref) |
| 369 | return "p@ss", nil |
| 370 | }, |
| 371 | assertCfg: func(t *testing.T, cfg map[string]any) { |
| 372 | assert.Equal(t, "admin:p@ss@host", cfg["dsn"]) |
| 373 | }, |
| 374 | }, |
| 375 | "store resolver receives canceled context": { |
| 376 | cfg: map[string]any{ |
| 377 | "password": "${store:vault:vault_prod:secret/data/mysql#password}", |
| 378 | }, |
| 379 | storeResolver: func(ctx context.Context, ref, original string) (string, error) { |
| 380 | <-ctx.Done() |
| 381 | return "", ctx.Err() |
| 382 | }, |
| 383 | wantErrContains: context.Canceled.Error(), |
| 384 | }, |
| 385 | } |
| 386 | |
| 387 | for name, tc := range tests { |
| 388 | t.Run(name, func(t *testing.T) { |
| 389 | if name == "mixed env and store refs" { |
| 390 | t.Setenv("TEST_SR_USER", "admin") |
| 391 | } |
| 392 | |
| 393 | resolver := New() |
| 394 | ctx := context.Background() |
| 395 | if name == "store resolver receives canceled context" { |
| 396 | var cancel context.CancelFunc |
| 397 | ctx, cancel = context.WithCancel(context.Background()) |
| 398 | cancel() |
| 399 | } |
| 400 | |
| 401 | err := resolver.ResolveWithStoreResolver(ctx, tc.cfg, tc.storeResolver) |
| 402 | |
| 403 | if tc.wantErrContains != "" { |
| 404 | require.Error(t, err) |
| 405 | assert.Contains(t, err.Error(), tc.wantErrContains) |
| 406 | return |
| 407 | } |
| 408 | |
| 409 | require.NoError(t, err) |
| 410 | if tc.assertCfg != nil { |
| 411 | tc.assertCfg(t, tc.cfg) |
| 412 | } |
| 413 | }) |
| 414 | } |
| 415 | } |
| 416 | |
| 417 | func TestResolveRefUsesProviderRegistry(t *testing.T) { |
| 418 | resolver := New() |
| 419 | |
| 420 | called := false |
| 421 | resolver.providers["stub"] = func(ctx context.Context, ref, original string) (string, error) { |
| 422 | called = true |
| 423 | require.NotNil(t, ctx) |
| 424 | assert.Equal(t, "name", ref) |
| 425 | assert.Equal(t, "${stub:name}", original) |
| 426 | return "resolved-by-stub", nil |
| 427 | } |
| 428 | |
| 429 | cfg := map[string]any{ |
| 430 | "value": "${stub:name}", |
| 431 | } |
| 432 | |
| 433 | require.NoError(t, resolver.Resolve(cfg)) |
| 434 | assert.True(t, called) |
| 435 | assert.Equal(t, "resolved-by-stub", cfg["value"]) |
| 436 | } |
| 437 | |
| 438 | func TestResolveWithStoreResolver_LogsDetailedBuiltinResolution(t *testing.T) { |
| 439 | modeFile := filepath.Join(t.TempDir(), "secret.txt") |
| 440 | require.NoError(t, os.WriteFile(modeFile, []byte("from-file\n"), 0o600)) |
| 441 | |
| 442 | tests := map[string]struct { |
| 443 | cfg map[string]any |
| 444 | onWindowsSkip bool |
| 445 | setup func(t *testing.T) |
| 446 | wantLog string |
| 447 | dontWantLogs []string |
| 448 | }{ |
| 449 | "env": { |
| 450 | cfg: map[string]any{"value": "${env:TEST_SECRET_ENV}"}, |
| 451 | setup: func(t *testing.T) { |
| 452 | t.Setenv("TEST_SECRET_ENV", "from-env") |
| 453 | }, |
| 454 | wantLog: "resolved secret via env variable 'TEST_SECRET_ENV'", |
| 455 | dontWantLogs: []string{"from-env"}, |
| 456 | }, |
| 457 | "file": { |
| 458 | cfg: map[string]any{"value": "${file:" + modeFile + "}"}, |
| 459 | wantLog: "resolved secret via file '" + modeFile + "'", |
| 460 | dontWantLogs: []string{"from-file"}, |
| 461 | }, |
| 462 | "cmd": { |
| 463 | cfg: map[string]any{"value": "${cmd:/bin/echo from-cmd}"}, |
| 464 | onWindowsSkip: true, |
| 465 | wantLog: "resolved secret via command '/bin/echo'", |
| 466 | dontWantLogs: []string{"from-cmd"}, |
| 467 | }, |
| 468 | } |
| 469 | |
| 470 | for name, tc := range tests { |
| 471 | t.Run(name, func(t *testing.T) { |
| 472 | if tc.onWindowsSkip && runtime.GOOS == "windows" { |
| 473 | t.Skip("skipping on windows") |
| 474 | } |
| 475 | if tc.setup != nil { |
| 476 | tc.setup(t) |
| 477 | } |
| 478 | |
| 479 | out := captureResolverLoggerOutput(t, func(log *logger.Logger) { |
| 480 | ctx := logger.ContextWithLogger(context.Background(), log) |
| 481 | require.NoError(t, New().ResolveWithStoreResolver(ctx, tc.cfg, nil)) |
| 482 | }) |
| 483 | |
| 484 | assert.Contains(t, out, tc.wantLog) |
| 485 | for _, s := range tc.dontWantLogs { |
| 486 | assert.NotContains(t, out, s) |
| 487 | } |
| 488 | }) |
| 489 | } |
| 490 | } |
| 491 | |
| 492 | func captureResolverLoggerOutput(t *testing.T, fn func(log *logger.Logger)) string { |
| 493 | t.Helper() |
| 494 | |
| 495 | var buf bytes.Buffer |
| 496 | fn(logger.NewWithWriter(&buf)) |
| 497 | return buf.String() |
| 498 | } |