fix: gate and redact admin logs
- commit
- 826b93ded41086a06283b1a95e209e985bdf8af9
- author
- brice · · 2026-04-25 08:55 UTC
- parent
- 8488ad1
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
10 files · +202 -13
M docs/cli/auth.md +2 -1
| @@ -139,7 +139,7 @@ Standard Go durations also work: `24h`, `30m`, `1h30m` | |||
| 139 | 139 | ||
| 140 | 140 | Registry scopes: `push`, `pull`, `push,pull` | |
| 141 | 141 | ||
| 142 | - | Admin scopes: `admin:*:*`, `admin:routes:read`, `admin:routes:write`, `admin:config:read`, `admin:config:write`, `admin:status:read`, `admin:secrets:read`, `admin:secrets:write` | |
| 142 | + | Admin scopes: `admin:*:*`, `admin:routes:read`, `admin:routes:write`, `admin:config:read`, `admin:config:write`, `admin:status:read`, `admin:logs:read`, `admin:secrets:read`, `admin:secrets:write` | |
| 143 | 143 | ||
| 144 | 144 | Combine scopes with commas: | |
| 145 | 145 | ||
| @@ -280,6 +280,7 @@ Usage: | |||
| 280 | 280 | | `admin:config:read` | Read-only config access | | |
| 281 | 281 | | `admin:config:write` | Config write access | | |
| 282 | 282 | | `admin:status:read` | Read-only status/health | | |
| 283 | + | | `admin:logs:read` | Read-only logs access | | |
| 283 | 284 | | `admin:secrets:read` | List secret keys | | |
| 284 | 285 | | `admin:secrets:write` | Set/delete secrets | | |
| 285 | 286 | ||
| @@ -139,7 +139,7 @@ Standard Go durations also work: `24h`, `30m`, `1h30m` | |||||
| 139 | 139 | ||||
| 140 | Registry scopes: `push`, `pull`, `push,pull` | 140 | Registry scopes: `push`, `pull`, `push,pull` | ||
| 141 | 141 | ||||
| 142 | - | Admin scopes: `admin:*:*`, `admin:routes:read`, `admin:routes:write`, `admin:config:read`, `admin:config:write`, `admin:status:read`, `admin:secrets:read`, `admin:secrets:write` | 142 | + | Admin scopes: `admin:*:*`, `admin:routes:read`, `admin:routes:write`, `admin:config:read`, `admin:config:write`, `admin:status:read`, `admin:logs:read`, `admin:secrets:read`, `admin:secrets:write` |
| 143 | 143 | ||||
| 144 | Combine scopes with commas: | 144 | Combine scopes with commas: | ||
| 145 | 145 | ||||
| @@ -280,6 +280,7 @@ Usage: | |||||
| 280 | | `admin:config:read` | Read-only config access | | 280 | | `admin:config:read` | Read-only config access | | ||
| 281 | | `admin:config:write` | Config write access | | 281 | | `admin:config:write` | Config write access | | ||
| 282 | | `admin:status:read` | Read-only status/health | | 282 | | `admin:status:read` | Read-only status/health | | ||
| 283 | + | | `admin:logs:read` | Read-only logs access | | |||
| 283 | | `admin:secrets:read` | List secret keys | | 284 | | `admin:secrets:read` | List secret keys | | ||
| 284 | | `admin:secrets:write` | Set/delete secrets | | 285 | | `admin:secrets:write` | Set/delete secrets | | ||
| 285 | 286 | ||||
M docs/cli/serve.md +2 -0
| @@ -235,6 +235,8 @@ gordon logs [domain] [options] | |||
| 235 | 235 | Remote targeting uses client config or an active remote by default. | |
| 236 | 236 | Use `--remote` and `--token` to override. See [CLI Overview](./index.md). | |
| 237 | 237 | ||
| 238 | + | Remote log access requires an admin token with `admin:logs:read` (or `admin:*:*`). `admin:status:read` is not sufficient for logs. | |
| 239 | + | ||
| 238 | 240 | ### Examples | |
| 239 | 241 | ||
| 240 | 242 | ```bash | |
| @@ -235,6 +235,8 @@ gordon logs [domain] [options] | |||||
| 235 | Remote targeting uses client config or an active remote by default. | 235 | Remote targeting uses client config or an active remote by default. | ||
| 236 | Use `--remote` and `--token` to override. See [CLI Overview](./index.md). | 236 | Use `--remote` and `--token` to override. See [CLI Overview](./index.md). | ||
| 237 | 237 | ||||
| 238 | + | Remote log access requires an admin token with `admin:logs:read` (or `admin:*:*`). `admin:status:read` is not sufficient for logs. | |||
| 239 | + | ||||
| 238 | ### Examples | 240 | ### Examples | ||
| 239 | 241 | ||||
| 240 | ```bash | 242 | ```bash | ||
M docs/config/auth.md +1 -0
| @@ -92,6 +92,7 @@ Admin scopes (for remote CLI): | |||
| 92 | 92 | | `admin:config:read` | Read-only config access | | |
| 93 | 93 | | `admin:config:write` | Config write access | | |
| 94 | 94 | | `admin:status:read` | Read-only status/health | | |
| 95 | + | | `admin:logs:read` | Read-only logs access | | |
| 95 | 96 | | `admin:secrets:read` | List secret keys | | |
| 96 | 97 | | `admin:secrets:write` | Set/delete secrets | | |
| 97 | 98 | ||
| @@ -92,6 +92,7 @@ Admin scopes (for remote CLI): | |||||
| 92 | | `admin:config:read` | Read-only config access | | 92 | | `admin:config:read` | Read-only config access | | ||
| 93 | | `admin:config:write` | Config write access | | 93 | | `admin:config:write` | Config write access | | ||
| 94 | | `admin:status:read` | Read-only status/health | | 94 | | `admin:status:read` | Read-only status/health | | ||
| 95 | + | | `admin:logs:read` | Read-only logs access | | |||
| 95 | | `admin:secrets:read` | List secret keys | | 96 | | `admin:secrets:read` | List secret keys | | ||
| 96 | | `admin:secrets:write` | Set/delete secrets | | 97 | | `admin:secrets:write` | Set/delete secrets | | ||
| 97 | 98 | ||||
M internal/adapters/in/cli/auth.go +2 -1
| @@ -84,13 +84,14 @@ Registry scopes (for Docker push/pull): | |||
| 84 | 84 | Admin scopes (for remote CLI access): | |
| 85 | 85 | Format: admin:<resource>:<actions> | |
| 86 | 86 | ||
| 87 | - | Resources: routes, secrets, config, status, * (all) | |
| 87 | + | Resources: routes, secrets, config, status, logs, * (all) | |
| 88 | 88 | Actions: read, write, * (all) | |
| 89 | 89 | ||
| 90 | 90 | Examples: | |
| 91 | 91 | admin:*:* Full admin access (recommended for CLI) | |
| 92 | 92 | admin:routes:read Read-only routes access | |
| 93 | 93 | admin:status:read Read-only status access | |
| 94 | + | admin:logs:read Read-only log access | |
| 94 | 95 | ||
| 95 | 96 | Repository scoping: | |
| 96 | 97 | --repo myapp Scope to specific repository | |
| @@ -84,13 +84,14 @@ Registry scopes (for Docker push/pull): | |||||
| 84 | Admin scopes (for remote CLI access): | 84 | Admin scopes (for remote CLI access): | ||
| 85 | Format: admin:<resource>:<actions> | 85 | Format: admin:<resource>:<actions> | ||
| 86 | 86 | ||||
| 87 | - | Resources: routes, secrets, config, status, * (all) | 87 | + | Resources: routes, secrets, config, status, logs, * (all) |
| 88 | Actions: read, write, * (all) | 88 | Actions: read, write, * (all) | ||
| 89 | 89 | ||||
| 90 | Examples: | 90 | Examples: | ||
| 91 | admin:*:* Full admin access (recommended for CLI) | 91 | admin:*:* Full admin access (recommended for CLI) | ||
| 92 | admin:routes:read Read-only routes access | 92 | admin:routes:read Read-only routes access | ||
| 93 | admin:status:read Read-only status access | 93 | admin:status:read Read-only status access | ||
| 94 | + | admin:logs:read Read-only log access | |||
| 94 | 95 | ||||
| 95 | Repository scoping: | 96 | Repository scoping: | ||
| 96 | --repo myapp Scope to specific repository | 97 | --repo myapp Scope to specific repository | ||
M internal/adapters/in/http/admin/handler.go +12 -7
| @@ -1319,12 +1319,15 @@ func (h *Handler) handleDeploy(w http.ResponseWriter, r *http.Request, path stri | |||
| 1319 | 1319 | log.Error().Err(err).Str("domain", deployDomain).Msg("failed to deploy container") | |
| 1320 | 1320 | var deployErr *domain.DeployFailureError | |
| 1321 | 1321 | if errors.As(err, &deployErr) { | |
| 1322 | - | h.sendJSON(w, http.StatusInternalServerError, dto.DeployErrorResponse{ | |
| 1322 | + | response := dto.DeployErrorResponse{ | |
| 1323 | 1323 | Error: deployErr.Error(), | |
| 1324 | 1324 | Cause: deployErr.Cause, | |
| 1325 | 1325 | Hint: deployErr.Hint, | |
| 1326 | - | Logs: deployErr.Logs, | |
| 1327 | - | }) | |
| 1326 | + | } | |
| 1327 | + | if HasAccess(ctx, domain.AdminResourceLogs, domain.AdminActionRead) { | |
| 1328 | + | response.Logs = domain.RedactSecretLines(deployErr.Logs) | |
| 1329 | + | } | |
| 1330 | + | h.sendJSON(w, http.StatusInternalServerError, response) | |
| 1328 | 1331 | return | |
| 1329 | 1332 | } | |
| 1330 | 1333 | h.sendError(w, http.StatusInternalServerError, "failed to deploy container") | |
| @@ -1455,8 +1458,8 @@ func (h *Handler) handleLogs(w http.ResponseWriter, r *http.Request, path string | |||
| 1455 | 1458 | log := zerowrap.FromCtx(ctx) | |
| 1456 | 1459 | ||
| 1457 | 1460 | // Check read permission | |
| 1458 | - | if !HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionRead) { | |
| 1459 | - | h.sendError(w, http.StatusForbidden, "insufficient permissions for status:read") | |
| 1461 | + | if !HasAccess(ctx, domain.AdminResourceLogs, domain.AdminActionRead) { | |
| 1462 | + | h.sendError(w, http.StatusForbidden, "insufficient permissions for logs:read") | |
| 1460 | 1463 | return | |
| 1461 | 1464 | } | |
| 1462 | 1465 | ||
| @@ -1522,7 +1525,7 @@ func (h *Handler) handleProcessLogs(w http.ResponseWriter, r *http.Request, line | |||
| 1522 | 1525 | return | |
| 1523 | 1526 | } | |
| 1524 | 1527 | ||
| 1525 | - | h.sendJSON(w, http.StatusOK, dto.ProcessLogsResponse{Lines: logLines}) | |
| 1528 | + | h.sendJSON(w, http.StatusOK, dto.ProcessLogsResponse{Lines: domain.RedactSecretLines(logLines)}) | |
| 1526 | 1529 | } | |
| 1527 | 1530 | ||
| 1528 | 1531 | // handleContainerLogs handles container logs for a specific domain. | |
| @@ -1546,7 +1549,7 @@ func (h *Handler) handleContainerLogs(w http.ResponseWriter, r *http.Request, lo | |||
| 1546 | 1549 | ||
| 1547 | 1550 | h.sendJSON(w, http.StatusOK, dto.ContainerLogsResponse{ | |
| 1548 | 1551 | Domain: logDomain, | |
| 1549 | - | Lines: logLines, | |
| 1552 | + | Lines: domain.RedactSecretLines(logLines), | |
| 1550 | 1553 | }) | |
| 1551 | 1554 | } | |
| 1552 | 1555 | ||
| @@ -1582,6 +1585,7 @@ func (h *Handler) streamProcessLogs(w http.ResponseWriter, r *http.Request, line | |||
| 1582 | 1585 | if !ok { | |
| 1583 | 1586 | return | |
| 1584 | 1587 | } | |
| 1588 | + | line = domain.RedactSecrets(line) | |
| 1585 | 1589 | // SECURITY: Normalize line endings and escape newlines in log lines to prevent SSE event injection. | |
| 1586 | 1590 | // First normalize CRLF (\r\n) and CR (\r) to LF (\n), then escape newlines. | |
| 1587 | 1591 | // Per the SSE spec, multi-line data must use separate "data:" prefixes. | |
| @@ -1628,6 +1632,7 @@ func (h *Handler) streamContainerLogs(w http.ResponseWriter, r *http.Request, lo | |||
| 1628 | 1632 | if !ok { | |
| 1629 | 1633 | return | |
| 1630 | 1634 | } | |
| 1635 | + | line = domain.RedactSecrets(line) | |
| 1631 | 1636 | // SECURITY: Normalize line endings and escape newlines in log lines to prevent SSE event injection. | |
| 1632 | 1637 | // First normalize CRLF (\r\n) and CR (\r) to LF (\n), then escape newlines. | |
| 1633 | 1638 | // Per the SSE spec, multi-line data must use separate "data:" prefixes. | |
| @@ -1319,12 +1319,15 @@ func (h *Handler) handleDeploy(w http.ResponseWriter, r *http.Request, path stri | |||||
| 1319 | log.Error().Err(err).Str("domain", deployDomain).Msg("failed to deploy container") | 1319 | log.Error().Err(err).Str("domain", deployDomain).Msg("failed to deploy container") | ||
| 1320 | var deployErr *domain.DeployFailureError | 1320 | var deployErr *domain.DeployFailureError | ||
| 1321 | if errors.As(err, &deployErr) { | 1321 | if errors.As(err, &deployErr) { | ||
| 1322 | - | h.sendJSON(w, http.StatusInternalServerError, dto.DeployErrorResponse{ | 1322 | + | response := dto.DeployErrorResponse{ |
| 1323 | Error: deployErr.Error(), | 1323 | Error: deployErr.Error(), | ||
| 1324 | Cause: deployErr.Cause, | 1324 | Cause: deployErr.Cause, | ||
| 1325 | Hint: deployErr.Hint, | 1325 | Hint: deployErr.Hint, | ||
| 1326 | - | Logs: deployErr.Logs, | 1326 | + | } |
| 1327 | - | }) | 1327 | + | if HasAccess(ctx, domain.AdminResourceLogs, domain.AdminActionRead) { |
| 1328 | + | response.Logs = domain.RedactSecretLines(deployErr.Logs) | |||
| 1329 | + | } | |||
| 1330 | + | h.sendJSON(w, http.StatusInternalServerError, response) | |||
| 1328 | return | 1331 | return | ||
| 1329 | } | 1332 | } | ||
| 1330 | h.sendError(w, http.StatusInternalServerError, "failed to deploy container") | 1333 | h.sendError(w, http.StatusInternalServerError, "failed to deploy container") | ||
| @@ -1455,8 +1458,8 @@ func (h *Handler) handleLogs(w http.ResponseWriter, r *http.Request, path string | |||||
| 1455 | log := zerowrap.FromCtx(ctx) | 1458 | log := zerowrap.FromCtx(ctx) | ||
| 1456 | 1459 | ||||
| 1457 | // Check read permission | 1460 | // Check read permission | ||
| 1458 | - | if !HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionRead) { | 1461 | + | if !HasAccess(ctx, domain.AdminResourceLogs, domain.AdminActionRead) { |
| 1459 | - | h.sendError(w, http.StatusForbidden, "insufficient permissions for status:read") | 1462 | + | h.sendError(w, http.StatusForbidden, "insufficient permissions for logs:read") |
| 1460 | return | 1463 | return | ||
| 1461 | } | 1464 | } | ||
| 1462 | 1465 | ||||
| @@ -1522,7 +1525,7 @@ func (h *Handler) handleProcessLogs(w http.ResponseWriter, r *http.Request, line | |||||
| 1522 | return | 1525 | return | ||
| 1523 | } | 1526 | } | ||
| 1524 | 1527 | ||||
| 1525 | - | h.sendJSON(w, http.StatusOK, dto.ProcessLogsResponse{Lines: logLines}) | 1528 | + | h.sendJSON(w, http.StatusOK, dto.ProcessLogsResponse{Lines: domain.RedactSecretLines(logLines)}) |
| 1526 | } | 1529 | } | ||
| 1527 | 1530 | ||||
| 1528 | // handleContainerLogs handles container logs for a specific domain. | 1531 | // handleContainerLogs handles container logs for a specific domain. | ||
| @@ -1546,7 +1549,7 @@ func (h *Handler) handleContainerLogs(w http.ResponseWriter, r *http.Request, lo | |||||
| 1546 | 1549 | ||||
| 1547 | h.sendJSON(w, http.StatusOK, dto.ContainerLogsResponse{ | 1550 | h.sendJSON(w, http.StatusOK, dto.ContainerLogsResponse{ | ||
| 1548 | Domain: logDomain, | 1551 | Domain: logDomain, | ||
| 1549 | - | Lines: logLines, | 1552 | + | Lines: domain.RedactSecretLines(logLines), |
| 1550 | }) | 1553 | }) | ||
| 1551 | } | 1554 | } | ||
| 1552 | 1555 | ||||
| @@ -1582,6 +1585,7 @@ func (h *Handler) streamProcessLogs(w http.ResponseWriter, r *http.Request, line | |||||
| 1582 | if !ok { | 1585 | if !ok { | ||
| 1583 | return | 1586 | return | ||
| 1584 | } | 1587 | } | ||
| 1588 | + | line = domain.RedactSecrets(line) | |||
| 1585 | // SECURITY: Normalize line endings and escape newlines in log lines to prevent SSE event injection. | 1589 | // SECURITY: Normalize line endings and escape newlines in log lines to prevent SSE event injection. | ||
| 1586 | // First normalize CRLF (\r\n) and CR (\r) to LF (\n), then escape newlines. | 1590 | // First normalize CRLF (\r\n) and CR (\r) to LF (\n), then escape newlines. | ||
| 1587 | // Per the SSE spec, multi-line data must use separate "data:" prefixes. | 1591 | // Per the SSE spec, multi-line data must use separate "data:" prefixes. | ||
| @@ -1628,6 +1632,7 @@ func (h *Handler) streamContainerLogs(w http.ResponseWriter, r *http.Request, lo | |||||
| 1628 | if !ok { | 1632 | if !ok { | ||
| 1629 | return | 1633 | return | ||
| 1630 | } | 1634 | } | ||
| 1635 | + | line = domain.RedactSecrets(line) | |||
| 1631 | // SECURITY: Normalize line endings and escape newlines in log lines to prevent SSE event injection. | 1636 | // SECURITY: Normalize line endings and escape newlines in log lines to prevent SSE event injection. | ||
| 1632 | // First normalize CRLF (\r\n) and CR (\r) to LF (\n), then escape newlines. | 1637 | // First normalize CRLF (\r\n) and CR (\r) to LF (\n), then escape newlines. | ||
| 1633 | // Per the SSE spec, multi-line data must use separate "data:" prefixes. | 1638 | // Per the SSE spec, multi-line data must use separate "data:" prefixes. | ||
M internal/adapters/in/http/admin/handler_test.go +73 -4
| @@ -1542,6 +1542,39 @@ func TestHandler_Restart_InvalidDomain(t *testing.T) { | |||
| 1542 | 1542 | } | |
| 1543 | 1543 | } | |
| 1544 | 1544 | ||
| 1545 | + | func TestHandler_Deploy_ConfigWriteOnlyOmitsDeployFailureLogs(t *testing.T) { | |
| 1546 | + | configSvc := inmocks.NewMockConfigService(t) | |
| 1547 | + | containerSvc := inmocks.NewMockContainerService(t) | |
| 1548 | + | secretSvc := inmocks.NewMockSecretService(t) | |
| 1549 | + | route := &domain.Route{Domain: "app.example.com", Image: "registry.local/myapp:latest"} | |
| 1550 | + | deployErr := &domain.DeployFailureError{ | |
| 1551 | + | Summary: "failed to deploy", | |
| 1552 | + | Cause: "image pull failed", | |
| 1553 | + | Hint: "push the image before retrying", | |
| 1554 | + | Logs: []string{"PASSWORD=hunter2", "TOKEN=abc123"}, | |
| 1555 | + | } | |
| 1556 | + | ||
| 1557 | + | handler := newTestHandler(t, func(d *HandlerDeps) { | |
| 1558 | + | d.ConfigSvc = configSvc | |
| 1559 | + | d.ContainerSvc = containerSvc | |
| 1560 | + | d.SecretSvc = secretSvc | |
| 1561 | + | }) | |
| 1562 | + | ||
| 1563 | + | configSvc.EXPECT().GetRoute(mock.Anything, "app.example.com").Return(route, nil).Once() | |
| 1564 | + | containerSvc.EXPECT().Deploy(mock.Anything, *route).Return(nil, deployErr).Once() | |
| 1565 | + | ||
| 1566 | + | req := httptest.NewRequest(http.MethodPost, "/admin/deploy/app.example.com", nil) | |
| 1567 | + | req = req.WithContext(ctxWithScopes("admin:config:write")) | |
| 1568 | + | rec := httptest.NewRecorder() | |
| 1569 | + | ||
| 1570 | + | handler.ServeHTTP(rec, req) | |
| 1571 | + | ||
| 1572 | + | assert.Equal(t, http.StatusInternalServerError, rec.Code) | |
| 1573 | + | assert.JSONEq(t, `{"error":"failed to deploy","cause":"image pull failed","hint":"push the image before retrying"}`, rec.Body.String()) | |
| 1574 | + | assert.NotContains(t, rec.Body.String(), "hunter2") | |
| 1575 | + | assert.NotContains(t, rec.Body.String(), "abc123") | |
| 1576 | + | } | |
| 1577 | + | ||
| 1545 | 1578 | func TestHandler_Deploy_StructuredDeployFailure(t *testing.T) { | |
| 1546 | 1579 | configSvc := inmocks.NewMockConfigService(t) | |
| 1547 | 1580 | authSvc := inmocks.NewMockAuthService(t) | |
| @@ -1553,8 +1586,8 @@ func TestHandler_Deploy_StructuredDeployFailure(t *testing.T) { | |||
| 1553 | 1586 | Cause: "image pull failed", | |
| 1554 | 1587 | Hint: "push the image before retrying", | |
| 1555 | 1588 | Logs: []string{ | |
| 1556 | - | "pulling manifest", | |
| 1557 | - | "manifest unknown", | |
| 1589 | + | "pulling manifest PASSWORD=hunter2", | |
| 1590 | + | `{"TOKEN":"abc123"}`, | |
| 1558 | 1591 | }, | |
| 1559 | 1592 | Err: errors.New("manifest unknown: internal registry detail"), | |
| 1560 | 1593 | } | |
| @@ -1566,7 +1599,7 @@ func TestHandler_Deploy_StructuredDeployFailure(t *testing.T) { | |||
| 1566 | 1599 | d.SecretSvc = secretSvc | |
| 1567 | 1600 | }) | |
| 1568 | 1601 | s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 1569 | - | handler.ServeHTTP(w, r.WithContext(ctxWithScopes("admin:config:write"))) | |
| 1602 | + | handler.ServeHTTP(w, r.WithContext(ctxWithScopes("admin:config:write", "admin:logs:read"))) | |
| 1570 | 1603 | })) | |
| 1571 | 1604 | defer s.Close() | |
| 1572 | 1605 | ||
| @@ -1582,8 +1615,10 @@ func TestHandler_Deploy_StructuredDeployFailure(t *testing.T) { | |||
| 1582 | 1615 | assert.NoError(t, err) | |
| 1583 | 1616 | ||
| 1584 | 1617 | assert.Equal(t, http.StatusInternalServerError, resp.StatusCode) | |
| 1585 | - | assert.JSONEq(t, `{"error":"failed to deploy","cause":"image pull failed","hint":"push the image before retrying","logs":["pulling manifest","manifest unknown"]}`, string(body)) | |
| 1618 | + | assert.JSONEq(t, `{"error":"failed to deploy","cause":"image pull failed","hint":"push the image before retrying","logs":["pulling manifest PASSWORD=[REDACTED]","{\"TOKEN\":\"[REDACTED]\"}"]}`, string(body)) | |
| 1586 | 1619 | assert.NotContains(t, string(body), "internal registry detail") | |
| 1620 | + | assert.NotContains(t, string(body), "hunter2") | |
| 1621 | + | assert.NotContains(t, string(body), "abc123") | |
| 1587 | 1622 | } | |
| 1588 | 1623 | ||
| 1589 | 1624 | func TestHandler_Deploy_GenericErrorStillUsesLegacyBody(t *testing.T) { | |
| @@ -2045,6 +2080,40 @@ func TestHandler_BackupsRunDomain_ChunkedBodyIsDecoded(t *testing.T) { | |||
| 2045 | 2080 | assert.Contains(t, rec.Body.String(), "completed") | |
| 2046 | 2081 | } | |
| 2047 | 2082 | ||
| 2083 | + | func TestHandler_LogsRequireLogsReadAndRedact(t *testing.T) { | |
| 2084 | + | logSvc := inmocks.NewMockLogService(t) | |
| 2085 | + | handler := newTestHandler(t, func(d *HandlerDeps) { | |
| 2086 | + | d.LogSvc = logSvc | |
| 2087 | + | }) | |
| 2088 | + | ||
| 2089 | + | t.Run("status read denied", func(t *testing.T) { | |
| 2090 | + | req := httptest.NewRequest(http.MethodGet, "/admin/logs", nil) | |
| 2091 | + | req = req.WithContext(ctxWithScopes("admin:status:read")) | |
| 2092 | + | rec := httptest.NewRecorder() | |
| 2093 | + | ||
| 2094 | + | handler.ServeHTTP(rec, req) | |
| 2095 | + | ||
| 2096 | + | assert.Equal(t, http.StatusForbidden, rec.Code) | |
| 2097 | + | assert.Contains(t, rec.Body.String(), "insufficient permissions for logs:read") | |
| 2098 | + | }) | |
| 2099 | + | ||
| 2100 | + | t.Run("logs read allowed and redacted", func(t *testing.T) { | |
| 2101 | + | logSvc.EXPECT().GetProcessLogs(mock.Anything, 50).Return([]string{"PASSWORD=hunter2", `{"API_KEY":"abc123"}`}, nil).Once() | |
| 2102 | + | ||
| 2103 | + | req := httptest.NewRequest(http.MethodGet, "/admin/logs", nil) | |
| 2104 | + | req = req.WithContext(ctxWithScopes("admin:logs:read")) | |
| 2105 | + | rec := httptest.NewRecorder() | |
| 2106 | + | ||
| 2107 | + | handler.ServeHTTP(rec, req) | |
| 2108 | + | ||
| 2109 | + | assert.Equal(t, http.StatusOK, rec.Code) | |
| 2110 | + | assert.Contains(t, rec.Body.String(), "PASSWORD=[REDACTED]") | |
| 2111 | + | assert.Contains(t, rec.Body.String(), `\"API_KEY\":\"[REDACTED]\"`) | |
| 2112 | + | assert.NotContains(t, rec.Body.String(), "hunter2") | |
| 2113 | + | assert.NotContains(t, rec.Body.String(), "abc123") | |
| 2114 | + | }) | |
| 2115 | + | } | |
| 2116 | + | ||
| 2048 | 2117 | func TestHandler_BackupsDetectDomain(t *testing.T) { | |
| 2049 | 2118 | configSvc := inmocks.NewMockConfigService(t) | |
| 2050 | 2119 | authSvc := inmocks.NewMockAuthService(t) | |
| @@ -1542,6 +1542,39 @@ func TestHandler_Restart_InvalidDomain(t *testing.T) { | |||||
| 1542 | } | 1542 | } | ||
| 1543 | } | 1543 | } | ||
| 1544 | 1544 | ||||
| 1545 | + | func TestHandler_Deploy_ConfigWriteOnlyOmitsDeployFailureLogs(t *testing.T) { | |||
| 1546 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 1547 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 1548 | + | secretSvc := inmocks.NewMockSecretService(t) | |||
| 1549 | + | route := &domain.Route{Domain: "app.example.com", Image: "registry.local/myapp:latest"} | |||
| 1550 | + | deployErr := &domain.DeployFailureError{ | |||
| 1551 | + | Summary: "failed to deploy", | |||
| 1552 | + | Cause: "image pull failed", | |||
| 1553 | + | Hint: "push the image before retrying", | |||
| 1554 | + | Logs: []string{"PASSWORD=hunter2", "TOKEN=abc123"}, | |||
| 1555 | + | } | |||
| 1556 | + | ||||
| 1557 | + | handler := newTestHandler(t, func(d *HandlerDeps) { | |||
| 1558 | + | d.ConfigSvc = configSvc | |||
| 1559 | + | d.ContainerSvc = containerSvc | |||
| 1560 | + | d.SecretSvc = secretSvc | |||
| 1561 | + | }) | |||
| 1562 | + | ||||
| 1563 | + | configSvc.EXPECT().GetRoute(mock.Anything, "app.example.com").Return(route, nil).Once() | |||
| 1564 | + | containerSvc.EXPECT().Deploy(mock.Anything, *route).Return(nil, deployErr).Once() | |||
| 1565 | + | ||||
| 1566 | + | req := httptest.NewRequest(http.MethodPost, "/admin/deploy/app.example.com", nil) | |||
| 1567 | + | req = req.WithContext(ctxWithScopes("admin:config:write")) | |||
| 1568 | + | rec := httptest.NewRecorder() | |||
| 1569 | + | ||||
| 1570 | + | handler.ServeHTTP(rec, req) | |||
| 1571 | + | ||||
| 1572 | + | assert.Equal(t, http.StatusInternalServerError, rec.Code) | |||
| 1573 | + | assert.JSONEq(t, `{"error":"failed to deploy","cause":"image pull failed","hint":"push the image before retrying"}`, rec.Body.String()) | |||
| 1574 | + | assert.NotContains(t, rec.Body.String(), "hunter2") | |||
| 1575 | + | assert.NotContains(t, rec.Body.String(), "abc123") | |||
| 1576 | + | } | |||
| 1577 | + | ||||
| 1545 | func TestHandler_Deploy_StructuredDeployFailure(t *testing.T) { | 1578 | func TestHandler_Deploy_StructuredDeployFailure(t *testing.T) { | ||
| 1546 | configSvc := inmocks.NewMockConfigService(t) | 1579 | configSvc := inmocks.NewMockConfigService(t) | ||
| 1547 | authSvc := inmocks.NewMockAuthService(t) | 1580 | authSvc := inmocks.NewMockAuthService(t) | ||
| @@ -1553,8 +1586,8 @@ func TestHandler_Deploy_StructuredDeployFailure(t *testing.T) { | |||||
| 1553 | Cause: "image pull failed", | 1586 | Cause: "image pull failed", | ||
| 1554 | Hint: "push the image before retrying", | 1587 | Hint: "push the image before retrying", | ||
| 1555 | Logs: []string{ | 1588 | Logs: []string{ | ||
| 1556 | - | "pulling manifest", | 1589 | + | "pulling manifest PASSWORD=hunter2", |
| 1557 | - | "manifest unknown", | 1590 | + | `{"TOKEN":"abc123"}`, |
| 1558 | }, | 1591 | }, | ||
| 1559 | Err: errors.New("manifest unknown: internal registry detail"), | 1592 | Err: errors.New("manifest unknown: internal registry detail"), | ||
| 1560 | } | 1593 | } | ||
| @@ -1566,7 +1599,7 @@ func TestHandler_Deploy_StructuredDeployFailure(t *testing.T) { | |||||
| 1566 | d.SecretSvc = secretSvc | 1599 | d.SecretSvc = secretSvc | ||
| 1567 | }) | 1600 | }) | ||
| 1568 | s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | 1601 | s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| 1569 | - | handler.ServeHTTP(w, r.WithContext(ctxWithScopes("admin:config:write"))) | 1602 | + | handler.ServeHTTP(w, r.WithContext(ctxWithScopes("admin:config:write", "admin:logs:read"))) |
| 1570 | })) | 1603 | })) | ||
| 1571 | defer s.Close() | 1604 | defer s.Close() | ||
| 1572 | 1605 | ||||
| @@ -1582,8 +1615,10 @@ func TestHandler_Deploy_StructuredDeployFailure(t *testing.T) { | |||||
| 1582 | assert.NoError(t, err) | 1615 | assert.NoError(t, err) | ||
| 1583 | 1616 | ||||
| 1584 | assert.Equal(t, http.StatusInternalServerError, resp.StatusCode) | 1617 | assert.Equal(t, http.StatusInternalServerError, resp.StatusCode) | ||
| 1585 | - | assert.JSONEq(t, `{"error":"failed to deploy","cause":"image pull failed","hint":"push the image before retrying","logs":["pulling manifest","manifest unknown"]}`, string(body)) | 1618 | + | assert.JSONEq(t, `{"error":"failed to deploy","cause":"image pull failed","hint":"push the image before retrying","logs":["pulling manifest PASSWORD=[REDACTED]","{\"TOKEN\":\"[REDACTED]\"}"]}`, string(body)) |
| 1586 | assert.NotContains(t, string(body), "internal registry detail") | 1619 | assert.NotContains(t, string(body), "internal registry detail") | ||
| 1620 | + | assert.NotContains(t, string(body), "hunter2") | |||
| 1621 | + | assert.NotContains(t, string(body), "abc123") | |||
| 1587 | } | 1622 | } | ||
| 1588 | 1623 | ||||
| 1589 | func TestHandler_Deploy_GenericErrorStillUsesLegacyBody(t *testing.T) { | 1624 | func TestHandler_Deploy_GenericErrorStillUsesLegacyBody(t *testing.T) { | ||
| @@ -2045,6 +2080,40 @@ func TestHandler_BackupsRunDomain_ChunkedBodyIsDecoded(t *testing.T) { | |||||
| 2045 | assert.Contains(t, rec.Body.String(), "completed") | 2080 | assert.Contains(t, rec.Body.String(), "completed") | ||
| 2046 | } | 2081 | } | ||
| 2047 | 2082 | ||||
| 2083 | + | func TestHandler_LogsRequireLogsReadAndRedact(t *testing.T) { | |||
| 2084 | + | logSvc := inmocks.NewMockLogService(t) | |||
| 2085 | + | handler := newTestHandler(t, func(d *HandlerDeps) { | |||
| 2086 | + | d.LogSvc = logSvc | |||
| 2087 | + | }) | |||
| 2088 | + | ||||
| 2089 | + | t.Run("status read denied", func(t *testing.T) { | |||
| 2090 | + | req := httptest.NewRequest(http.MethodGet, "/admin/logs", nil) | |||
| 2091 | + | req = req.WithContext(ctxWithScopes("admin:status:read")) | |||
| 2092 | + | rec := httptest.NewRecorder() | |||
| 2093 | + | ||||
| 2094 | + | handler.ServeHTTP(rec, req) | |||
| 2095 | + | ||||
| 2096 | + | assert.Equal(t, http.StatusForbidden, rec.Code) | |||
| 2097 | + | assert.Contains(t, rec.Body.String(), "insufficient permissions for logs:read") | |||
| 2098 | + | }) | |||
| 2099 | + | ||||
| 2100 | + | t.Run("logs read allowed and redacted", func(t *testing.T) { | |||
| 2101 | + | logSvc.EXPECT().GetProcessLogs(mock.Anything, 50).Return([]string{"PASSWORD=hunter2", `{"API_KEY":"abc123"}`}, nil).Once() | |||
| 2102 | + | ||||
| 2103 | + | req := httptest.NewRequest(http.MethodGet, "/admin/logs", nil) | |||
| 2104 | + | req = req.WithContext(ctxWithScopes("admin:logs:read")) | |||
| 2105 | + | rec := httptest.NewRecorder() | |||
| 2106 | + | ||||
| 2107 | + | handler.ServeHTTP(rec, req) | |||
| 2108 | + | ||||
| 2109 | + | assert.Equal(t, http.StatusOK, rec.Code) | |||
| 2110 | + | assert.Contains(t, rec.Body.String(), "PASSWORD=[REDACTED]") | |||
| 2111 | + | assert.Contains(t, rec.Body.String(), `\"API_KEY\":\"[REDACTED]\"`) | |||
| 2112 | + | assert.NotContains(t, rec.Body.String(), "hunter2") | |||
| 2113 | + | assert.NotContains(t, rec.Body.String(), "abc123") | |||
| 2114 | + | }) | |||
| 2115 | + | } | |||
| 2116 | + | ||||
| 2048 | func TestHandler_BackupsDetectDomain(t *testing.T) { | 2117 | func TestHandler_BackupsDetectDomain(t *testing.T) { | ||
| 2049 | configSvc := inmocks.NewMockConfigService(t) | 2118 | configSvc := inmocks.NewMockConfigService(t) | ||
| 2050 | authSvc := inmocks.NewMockAuthService(t) | 2119 | authSvc := inmocks.NewMockAuthService(t) | ||
M internal/domain/auth.go +6 -0
| @@ -79,6 +79,7 @@ const ( | |||
| 79 | 79 | AdminResourceSecrets = "secrets" | |
| 80 | 80 | AdminResourceConfig = "config" | |
| 81 | 81 | AdminResourceStatus = "status" | |
| 82 | + | AdminResourceLogs = "logs" | |
| 82 | 83 | AdminResourceAll = "*" | |
| 83 | 84 | ) | |
| 84 | 85 | ||
| @@ -302,6 +303,11 @@ func AdminScopeStatus(actions ...string) string { | |||
| 302 | 303 | return fmt.Sprintf("%s:%s:%s", ScopeTypeAdmin, AdminResourceStatus, strings.Join(actions, ",")) | |
| 303 | 304 | } | |
| 304 | 305 | ||
| 306 | + | // AdminScopeLogs creates an admin scope for logs with the given actions. | |
| 307 | + | func AdminScopeLogs(actions ...string) string { | |
| 308 | + | return fmt.Sprintf("%s:%s:%s", ScopeTypeAdmin, AdminResourceLogs, strings.Join(actions, ",")) | |
| 309 | + | } | |
| 310 | + | ||
| 305 | 311 | // AuthStatus represents status of an authentication session. | |
| 306 | 312 | type AuthStatus struct { | |
| 307 | 313 | Valid bool | |
| @@ -79,6 +79,7 @@ const ( | |||||
| 79 | AdminResourceSecrets = "secrets" | 79 | AdminResourceSecrets = "secrets" | ||
| 80 | AdminResourceConfig = "config" | 80 | AdminResourceConfig = "config" | ||
| 81 | AdminResourceStatus = "status" | 81 | AdminResourceStatus = "status" | ||
| 82 | + | AdminResourceLogs = "logs" | |||
| 82 | AdminResourceAll = "*" | 83 | AdminResourceAll = "*" | ||
| 83 | ) | 84 | ) | ||
| 84 | 85 | ||||
| @@ -302,6 +303,11 @@ func AdminScopeStatus(actions ...string) string { | |||||
| 302 | return fmt.Sprintf("%s:%s:%s", ScopeTypeAdmin, AdminResourceStatus, strings.Join(actions, ",")) | 303 | return fmt.Sprintf("%s:%s:%s", ScopeTypeAdmin, AdminResourceStatus, strings.Join(actions, ",")) | ||
| 303 | } | 304 | } | ||
| 304 | 305 | ||||
| 306 | + | // AdminScopeLogs creates an admin scope for logs with the given actions. | |||
| 307 | + | func AdminScopeLogs(actions ...string) string { | |||
| 308 | + | return fmt.Sprintf("%s:%s:%s", ScopeTypeAdmin, AdminResourceLogs, strings.Join(actions, ",")) | |||
| 309 | + | } | |||
| 310 | + | ||||
| 305 | // AuthStatus represents status of an authentication session. | 311 | // AuthStatus represents status of an authentication session. | ||
| 306 | type AuthStatus struct { | 312 | type AuthStatus struct { | ||
| 307 | Valid bool | 313 | Valid bool | ||
M internal/domain/auth_test.go +21 -0
| @@ -491,6 +491,27 @@ func TestScopesGrantAdminAccess(t *testing.T) { | |||
| 491 | 491 | action: "write", | |
| 492 | 492 | want: true, | |
| 493 | 493 | }, | |
| 494 | + | { | |
| 495 | + | name: "logs read grants logs resource", | |
| 496 | + | granted: []string{"admin:logs:read"}, | |
| 497 | + | resource: AdminResourceLogs, | |
| 498 | + | action: AdminActionRead, | |
| 499 | + | want: true, | |
| 500 | + | }, | |
| 501 | + | { | |
| 502 | + | name: "status read does not grant logs resource", | |
| 503 | + | granted: []string{"admin:status:read"}, | |
| 504 | + | resource: AdminResourceLogs, | |
| 505 | + | action: AdminActionRead, | |
| 506 | + | want: false, | |
| 507 | + | }, | |
| 508 | + | { | |
| 509 | + | name: "full admin wildcard grants logs resource", | |
| 510 | + | granted: []string{"admin:*:*"}, | |
| 511 | + | resource: AdminResourceLogs, | |
| 512 | + | action: AdminActionRead, | |
| 513 | + | want: true, | |
| 514 | + | }, | |
| 494 | 515 | { | |
| 495 | 516 | name: "repository scope is ignored for admin access", | |
| 496 | 517 | granted: []string{"repository:myrepo:push"}, | |
| @@ -491,6 +491,27 @@ func TestScopesGrantAdminAccess(t *testing.T) { | |||||
| 491 | action: "write", | 491 | action: "write", | ||
| 492 | want: true, | 492 | want: true, | ||
| 493 | }, | 493 | }, | ||
| 494 | + | { | |||
| 495 | + | name: "logs read grants logs resource", | |||
| 496 | + | granted: []string{"admin:logs:read"}, | |||
| 497 | + | resource: AdminResourceLogs, | |||
| 498 | + | action: AdminActionRead, | |||
| 499 | + | want: true, | |||
| 500 | + | }, | |||
| 501 | + | { | |||
| 502 | + | name: "status read does not grant logs resource", | |||
| 503 | + | granted: []string{"admin:status:read"}, | |||
| 504 | + | resource: AdminResourceLogs, | |||
| 505 | + | action: AdminActionRead, | |||
| 506 | + | want: false, | |||
| 507 | + | }, | |||
| 508 | + | { | |||
| 509 | + | name: "full admin wildcard grants logs resource", | |||
| 510 | + | granted: []string{"admin:*:*"}, | |||
| 511 | + | resource: AdminResourceLogs, | |||
| 512 | + | action: AdminActionRead, | |||
| 513 | + | want: true, | |||
| 514 | + | }, | |||
| 494 | { | 515 | { | ||
| 495 | name: "repository scope is ignored for admin access", | 516 | name: "repository scope is ignored for admin access", | ||
| 496 | granted: []string{"repository:myrepo:push"}, | 517 | granted: []string{"repository:myrepo:push"}, | ||
A internal/domain/redact.go +46 -0
| @@ -0,0 +1,46 @@ | |||
| 1 | + | package domain | |
| 2 | + | ||
| 3 | + | import "regexp" | |
| 4 | + | ||
| 5 | + | type secretRedactionPattern struct { | |
| 6 | + | re *regexp.Regexp | |
| 7 | + | repl string | |
| 8 | + | } | |
| 9 | + | ||
| 10 | + | const secretKeyPattern = `[A-Z0-9_]*(?:PASSWORD|TOKEN|SECRET|API[_-]?KEY|DATABASE[_-]?URL)[A-Z0-9_]*` //nolint:gosec // Regex pattern for secret-like key names, not a credential. | |
| 11 | + | ||
| 12 | + | var secretValuePatterns = []secretRedactionPattern{ | |
| 13 | + | { | |
| 14 | + | re: regexp.MustCompile(`(?i)(\b` + secretKeyPattern + `\b\s*=\s*)([^\s]+)`), | |
| 15 | + | repl: `${1}[REDACTED]`, | |
| 16 | + | }, | |
| 17 | + | { | |
| 18 | + | re: regexp.MustCompile(`(?i)("` + secretKeyPattern + `"\s*:\s*")([^"]*)(")`), | |
| 19 | + | repl: `${1}[REDACTED]${3}`, | |
| 20 | + | }, | |
| 21 | + | { | |
| 22 | + | re: regexp.MustCompile(`(?i)(\bAuthorization\s*:\s*Bearer\s+)([^\s]+)`), | |
| 23 | + | repl: `${1}[REDACTED]`, | |
| 24 | + | }, | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | // RedactSecrets redacts common secret values from a single log or diagnostic line. | |
| 28 | + | func RedactSecrets(line string) string { | |
| 29 | + | redacted := line | |
| 30 | + | for _, pattern := range secretValuePatterns { | |
| 31 | + | redacted = pattern.re.ReplaceAllString(redacted, pattern.repl) | |
| 32 | + | } | |
| 33 | + | return redacted | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | // RedactSecretLines redacts common secret values from log or diagnostic lines. | |
| 37 | + | func RedactSecretLines(lines []string) []string { | |
| 38 | + | if len(lines) == 0 { | |
| 39 | + | return nil | |
| 40 | + | } | |
| 41 | + | redacted := make([]string, len(lines)) | |
| 42 | + | for i, line := range lines { | |
| 43 | + | redacted[i] = RedactSecrets(line) | |
| 44 | + | } | |
| 45 | + | return redacted | |
| 46 | + | } | |
| @@ -0,0 +1,46 @@ | |||||
| 1 | + | package domain | |||
| 2 | + | ||||
| 3 | + | import "regexp" | |||
| 4 | + | ||||
| 5 | + | type secretRedactionPattern struct { | |||
| 6 | + | re *regexp.Regexp | |||
| 7 | + | repl string | |||
| 8 | + | } | |||
| 9 | + | ||||
| 10 | + | const secretKeyPattern = `[A-Z0-9_]*(?:PASSWORD|TOKEN|SECRET|API[_-]?KEY|DATABASE[_-]?URL)[A-Z0-9_]*` //nolint:gosec // Regex pattern for secret-like key names, not a credential. | |||
| 11 | + | ||||
| 12 | + | var secretValuePatterns = []secretRedactionPattern{ | |||
| 13 | + | { | |||
| 14 | + | re: regexp.MustCompile(`(?i)(\b` + secretKeyPattern + `\b\s*=\s*)([^\s]+)`), | |||
| 15 | + | repl: `${1}[REDACTED]`, | |||
| 16 | + | }, | |||
| 17 | + | { | |||
| 18 | + | re: regexp.MustCompile(`(?i)("` + secretKeyPattern + `"\s*:\s*")([^"]*)(")`), | |||
| 19 | + | repl: `${1}[REDACTED]${3}`, | |||
| 20 | + | }, | |||
| 21 | + | { | |||
| 22 | + | re: regexp.MustCompile(`(?i)(\bAuthorization\s*:\s*Bearer\s+)([^\s]+)`), | |||
| 23 | + | repl: `${1}[REDACTED]`, | |||
| 24 | + | }, | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | // RedactSecrets redacts common secret values from a single log or diagnostic line. | |||
| 28 | + | func RedactSecrets(line string) string { | |||
| 29 | + | redacted := line | |||
| 30 | + | for _, pattern := range secretValuePatterns { | |||
| 31 | + | redacted = pattern.re.ReplaceAllString(redacted, pattern.repl) | |||
| 32 | + | } | |||
| 33 | + | return redacted | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | // RedactSecretLines redacts common secret values from log or diagnostic lines. | |||
| 37 | + | func RedactSecretLines(lines []string) []string { | |||
| 38 | + | if len(lines) == 0 { | |||
| 39 | + | return nil | |||
| 40 | + | } | |||
| 41 | + | redacted := make([]string, len(lines)) | |||
| 42 | + | for i, line := range lines { | |||
| 43 | + | redacted[i] = RedactSecrets(line) | |||
| 44 | + | } | |||
| 45 | + | return redacted | |||
| 46 | + | } | |||
A internal/domain/redact_test.go +37 -0
| @@ -0,0 +1,37 @@ | |||
| 1 | + | package domain | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/stretchr/testify/assert" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | func TestRedactSecrets(t *testing.T) { | |
| 10 | + | tests := []struct { | |
| 11 | + | name string | |
| 12 | + | in string | |
| 13 | + | want string | |
| 14 | + | }{ | |
| 15 | + | {"password env", "PASSWORD=hunter2", "PASSWORD=[REDACTED]"}, | |
| 16 | + | {"token env", "TOKEN=abc123", "TOKEN=[REDACTED]"}, | |
| 17 | + | {"secret env", "SECRET=shh", "SECRET=[REDACTED]"}, | |
| 18 | + | {"api key env", "API_KEY=key-value", "API_KEY=[REDACTED]"}, | |
| 19 | + | {"database url env", "DATABASE_URL=postgres://user:pass@db/app", "DATABASE_URL=[REDACTED]"}, | |
| 20 | + | {"json password field", `{"PASSWORD":"hunter2","ok":"value"}`, `{"PASSWORD":"[REDACTED]","ok":"value"}`}, | |
| 21 | + | {"json token field with spaces", `{"TOKEN": "abc123"}`, `{"TOKEN": "[REDACTED]"}`}, | |
| 22 | + | {"case insensitive", "database_url=postgres://user:pass@db/app", "database_url=[REDACTED]"}, | |
| 23 | + | {"prefixed password env", "POSTGRES_PASSWORD=hunter2", "POSTGRES_PASSWORD=[REDACTED]"}, | |
| 24 | + | {"prefixed token env", "GORDON_TOKEN=abc123", "GORDON_TOKEN=[REDACTED]"}, | |
| 25 | + | {"secret key env", "SECRET_KEY=shh", "SECRET_KEY=[REDACTED]"}, | |
| 26 | + | {"api token env", "API_TOKEN=abc123", "API_TOKEN=[REDACTED]"}, | |
| 27 | + | {"authorization bearer", "Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.secret", "Authorization: Bearer [REDACTED]"}, | |
| 28 | + | {"json prefixed password field", `{"POSTGRES_PASSWORD":"hunter2"}`, `{"POSTGRES_PASSWORD":"[REDACTED]"}`}, | |
| 29 | + | {"json secret key field", `{"secret_key": "shh"}`, `{"secret_key": "[REDACTED]"}`}, | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | for _, tt := range tests { | |
| 33 | + | t.Run(tt.name, func(t *testing.T) { | |
| 34 | + | assert.Equal(t, tt.want, RedactSecrets(tt.in)) | |
| 35 | + | }) | |
| 36 | + | } | |
| 37 | + | } | |
| @@ -0,0 +1,37 @@ | |||||
| 1 | + | package domain | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/stretchr/testify/assert" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | func TestRedactSecrets(t *testing.T) { | |||
| 10 | + | tests := []struct { | |||
| 11 | + | name string | |||
| 12 | + | in string | |||
| 13 | + | want string | |||
| 14 | + | }{ | |||
| 15 | + | {"password env", "PASSWORD=hunter2", "PASSWORD=[REDACTED]"}, | |||
| 16 | + | {"token env", "TOKEN=abc123", "TOKEN=[REDACTED]"}, | |||
| 17 | + | {"secret env", "SECRET=shh", "SECRET=[REDACTED]"}, | |||
| 18 | + | {"api key env", "API_KEY=key-value", "API_KEY=[REDACTED]"}, | |||
| 19 | + | {"database url env", "DATABASE_URL=postgres://user:pass@db/app", "DATABASE_URL=[REDACTED]"}, | |||
| 20 | + | {"json password field", `{"PASSWORD":"hunter2","ok":"value"}`, `{"PASSWORD":"[REDACTED]","ok":"value"}`}, | |||
| 21 | + | {"json token field with spaces", `{"TOKEN": "abc123"}`, `{"TOKEN": "[REDACTED]"}`}, | |||
| 22 | + | {"case insensitive", "database_url=postgres://user:pass@db/app", "database_url=[REDACTED]"}, | |||
| 23 | + | {"prefixed password env", "POSTGRES_PASSWORD=hunter2", "POSTGRES_PASSWORD=[REDACTED]"}, | |||
| 24 | + | {"prefixed token env", "GORDON_TOKEN=abc123", "GORDON_TOKEN=[REDACTED]"}, | |||
| 25 | + | {"secret key env", "SECRET_KEY=shh", "SECRET_KEY=[REDACTED]"}, | |||
| 26 | + | {"api token env", "API_TOKEN=abc123", "API_TOKEN=[REDACTED]"}, | |||
| 27 | + | {"authorization bearer", "Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.secret", "Authorization: Bearer [REDACTED]"}, | |||
| 28 | + | {"json prefixed password field", `{"POSTGRES_PASSWORD":"hunter2"}`, `{"POSTGRES_PASSWORD":"[REDACTED]"}`}, | |||
| 29 | + | {"json secret key field", `{"secret_key": "shh"}`, `{"secret_key": "[REDACTED]"}`}, | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | for _, tt := range tests { | |||
| 33 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 34 | + | assert.Equal(t, tt.want, RedactSecrets(tt.in)) | |||
| 35 | + | }) | |||
| 36 | + | } | |||
| 37 | + | } | |||