This is an automated email from the ASF dual-hosted git repository.
villebro pushed a commit to branch main
in repository
https://gitbox.apache.org/repos/asf/superset-kubernetes-operator.git
The following commit(s) were added to refs/heads/main by this push:
new eb78759 fix(controller): redact credentials in task failure messages
(#233)
eb78759 is described below
commit eb78759f894effa733e1129eac5affbf78b161e4
Author: Younsung Lee <[email protected]>
AuthorDate: Mon Jul 27 06:11:51 2026 +0900
fix(controller): redact credentials in task failure messages (#233)
* fix(controller): redact credentials in task failure messages
Lifecycle task failure output is persisted to the parent Superset
status and Kubernetes Events, and could leak credential fragments
(documented as a known limitation). Apply best-effort pattern-based
redaction before truncation: passwords in connection URI userinfo,
HTTP Authorization header and bearer token values, and common
credential assignments (password=, PGPASSWORD:, token=, etc.).
The operator never reads Secret values, so redaction is by pattern
rather than exact value; fully keyless standalone secrets remain
out of reach and the security docs say so. Redaction runs before
truncation so a credential split mid-pattern cannot leak.
Adds a FuzzRedactCredentials target (no-panic + idempotence) wired
into make fuzz.
Signed-off-by: younsl <[email protected]>
* docs(changelog): move Security section after Changed
Keep a Changelog orders sections Added, Changed, Deprecated, Removed,
Fixed, Security. The Unreleased Security entry was inserted between
Added and Changed; move it to the canonical last position.
Signed-off-by: younsl <[email protected]>
---------
Signed-off-by: younsl <[email protected]>
---
Makefile | 7 +-
docs/reference/releases.md | 4 +
docs/reference/security.md | 2 +-
internal/controller/lifecycle_job.go | 5 +-
internal/controller/lifecycle_job_test.go | 14 ++++
internal/controller/redact.go | 69 ++++++++++++++++
internal/controller/redact_fuzz_test.go | 45 +++++++++++
internal/controller/redact_test.go | 130 ++++++++++++++++++++++++++++++
8 files changed, 270 insertions(+), 6 deletions(-)
diff --git a/Makefile b/Makefile
index c72b14d..bd8f24d 100644
--- a/Makefile
+++ b/Makefile
@@ -241,9 +241,10 @@ test-integration: manifests generate fmt vet setup-envtest
## Run integration te
fuzz: ## Run all fuzz targets for a bounded duration (FUZZTIME per target,
default 30s).
@FUZZTIME=$${FUZZTIME:-30s}; \
set -e; \
- go test ./internal/config/ -run '^$$' -fuzz '^FuzzPyQuote$$'
-fuzztime $$FUZZTIME; \
- go test ./internal/config/ -run '^$$' -fuzz '^FuzzRenderConfig$$'
-fuzztime $$FUZZTIME; \
- go test ./internal/resolution/ -run '^$$' -fuzz '^FuzzMergeMaps$$'
-fuzztime $$FUZZTIME
+ go test ./internal/config/ -run '^$$' -fuzz '^FuzzPyQuote$$'
-fuzztime $$FUZZTIME; \
+ go test ./internal/config/ -run '^$$' -fuzz '^FuzzRenderConfig$$'
-fuzztime $$FUZZTIME; \
+ go test ./internal/resolution/ -run '^$$' -fuzz '^FuzzMergeMaps$$'
-fuzztime $$FUZZTIME; \
+ go test ./internal/controller/ -run '^$$' -fuzz
'^FuzzRedactCredentials$$' -fuzztime $$FUZZTIME
# E2E tests live under test/e2e/ and assume Kind is pre-installed; the manager
# image is built and side-loaded into the cluster. CertManager is installed by
diff --git a/docs/reference/releases.md b/docs/reference/releases.md
index 1dd71cd..3c6a1a4 100644
--- a/docs/reference/releases.md
+++ b/docs/reference/releases.md
@@ -37,6 +37,10 @@ This page tracks notable changes in Apache Superset
Kubernetes Operator releases
- **Breaking:** downgrade blocking is removed. Any change to the lifecycle
image tag now re-runs the migrate task (`superset db upgrade`) regardless of
direction — the operator no longer performs semver comparison or sets
`status.phase: Blocked` on a version decrease. The migrate task only runs
`superset db upgrade` (Superset's down migrations are poorly tested and often
break, so the operator never runs them), so take a database backup before an
upgrade if you may need to revert. The `d [...]
- **Breaking:** the lifecycle `clone` task is renamed to `seed`. Rename
`spec.lifecycle.clone` to `spec.lifecycle.seed` (and its `postCloneSQL` field
to `postSeedSQL`) in your Superset resources. The task Job name suffix changes
from `-clone` to `-seed`, and the lifecycle status phase from `Cloning` to
`Seeding`. Custom `seed.command` scripts must read the renamed
`SUPERSET_OPERATOR__SEED_SRC_*` environment variables.
+### Security
+
+- **Credential redaction in task failure output.** Lifecycle task failure
messages are now scrubbed with best-effort pattern-based redaction before being
persisted to the parent Superset status and Kubernetes Events: passwords
embedded in connection URIs and common credential assignments (`password=...`,
`token=...`, etc.) are masked. This mitigates the known limitation where a
failing task command could leak credential fragments into status
([@younsl](https://github.com/younsl)).
+
## 0.1.1 - 2026-06-29
### Fixed
diff --git a/docs/reference/security.md b/docs/reference/security.md
index f07ff38..dd3373c 100644
--- a/docs/reference/security.md
+++ b/docs/reference/security.md
@@ -65,7 +65,7 @@ In Staging and Production modes, secrets follow this path:
3. The operator generates `superset_config.py` that renders `SECRET_KEY =
os.environ['SUPERSET_OPERATOR__SECRET_KEY']` — the actual secret value is
resolved at Python runtime from the env var, so it never appears in the
ConfigMap
4. The operator does not need Kubernetes Secret read permissions for this flow
and never reads, logs, writes, or stores secret values in ConfigMaps, CRD
status fields, or Events
-**Task failure caveat:** When a task Job fails, the operator records a
truncated failure message (max 256 characters) in the parent Superset status
and Kubernetes Events for debugging. If the task command writes sensitive data
to its failure output (e.g., a database connection error that includes
credentials), a truncated form may appear in status. This only applies to the
task container's own output, not to operator-managed secret references.
+**Task failure caveat:** When a task Job fails, the operator records a
truncated failure message (max 256 characters) in the parent Superset status
and Kubernetes Events for debugging. Before persisting, the operator applies
best-effort pattern-based redaction to the message: passwords embedded in
connection URIs (`scheme://user:password@host`), HTTP `Authorization` header
and bearer token values, and common credential assignments (`password=...`,
`PGPASSWORD: ...`, `token=...`, etc.) ar [...]
**Scope of this guarantee:** The above applies to operator-managed secret
references (`secretKeyFrom`, `previousSecretKeyFrom`, `metastore.uriFrom`,
`metastore.passwordFrom`, `valkey.passwordFrom`,
`lifecycle.seed.source.passwordFrom`, and `websocketServer.configFrom`).
User-authored fields — raw Python in `spec.config`, component-level `config`,
`bootstrapScript`, and `podTemplate.container.env` — are trusted input and may
contain arbitrary values including secrets. Users with read acce [...]
diff --git a/internal/controller/lifecycle_job.go
b/internal/controller/lifecycle_job.go
index 6d7b145..4a88665 100644
--- a/internal/controller/lifecycle_job.go
+++ b/internal/controller/lifecycle_job.go
@@ -266,6 +266,7 @@ func (r *SupersetReconciler) handleStuckTaskPod(
if !stuck {
return lifecycleResult{}, false, nil
}
+ msg = redactCredentials(msg)
desiredHash := podSpecHash(buildInitPod(flatSpec))
if existingJob.Annotations[naming.AnnotationTaskPodSpecHash] !=
desiredHash {
@@ -563,10 +564,10 @@ func jobFailureMessage(job *batchv1.Job) string {
for _, condition := range job.Status.Conditions {
if condition.Type == batchv1.JobFailed && condition.Status ==
corev1.ConditionTrue {
if condition.Message != "" {
- return truncateFailureMessage(condition.Message)
+ return
truncateFailureMessage(redactCredentials(condition.Message))
}
if condition.Reason != "" {
- return truncateFailureMessage(condition.Reason)
+ return
truncateFailureMessage(redactCredentials(condition.Reason))
}
}
}
diff --git a/internal/controller/lifecycle_job_test.go
b/internal/controller/lifecycle_job_test.go
index 7fc1ca6..84e1e9a 100644
--- a/internal/controller/lifecycle_job_test.go
+++ b/internal/controller/lifecycle_job_test.go
@@ -389,6 +389,20 @@ func TestJobFailureMessage(t *testing.T) {
got := jobFailureMessage(failed("BackoffLimitExceeded", long))
assert.Len(t, got, maxTerminationMessageLen)
})
+ t.Run("redacts credentials in the condition message", func(t
*testing.T) {
+ got := jobFailureMessage(failed("BackoffLimitExceeded",
+ "connection to
postgresql://superset:hunter2@db:5432/superset failed"))
+ assert.Equal(t, "connection to
postgresql://superset:***@db:5432/superset failed", got)
+ })
+ t.Run("redacts before truncating so split credentials cannot leak",
func(t *testing.T) {
+ // The credential sits so that naive truncate-then-redact would
cut it
+ // mid-value, leaving a fragment that no longer matches the
pattern.
+ prefix := strings.Repeat("x", maxTerminationMessageLen-10)
+ suffix := strings.Repeat("y", 50)
+ got := jobFailureMessage(failed("BackoffLimitExceeded",
prefix+" password=supersecretvalue "+suffix))
+ assert.NotContains(t, got, "supersecret")
+ assert.Len(t, got, maxTerminationMessageLen)
+ })
}
func TestResetTaskStatusForRun(t *testing.T) {
diff --git a/internal/controller/redact.go b/internal/controller/redact.go
new file mode 100644
index 0000000..eb627bc
--- /dev/null
+++ b/internal/controller/redact.go
@@ -0,0 +1,69 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one
+or more contributor license agreements. See the NOTICE file
+distributed with this work for additional information
+regarding copyright ownership. The ASF licenses this file
+to you under the Apache License, Version 2.0 (the
+"License"); you may not use this file except in compliance
+with the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package controller
+
+import "regexp"
+
+// redactedPlaceholder replaces credential values matched by the redaction
+// patterns below.
+const redactedPlaceholder = "***"
+
+var (
+ // uriUserinfoRe matches the password component of a URI userinfo
section
+ // (scheme://user:password@host). Task failure output commonly embeds
full
+ // database connection URIs in driver error messages, e.g.
+ // "connection to postgresql://superset:hunter2@db:5432/superset
failed".
+ // The user component may be empty (redis://:password@host). The
password
+ // match is greedy up to the last @ in the whitespace-delimited token,
so a
+ // malformed password containing a raw @ is masked in full rather than
+ // leaking its tail after the first @.
+ uriUserinfoRe =
regexp.MustCompile(`([a-zA-Z][a-zA-Z0-9+.-]*://[^/@\s:]*):([^\s]+)@`)
+
+ // authorizationHeaderRe matches HTTP Authorization header values, with
or
+ // without an auth scheme: "Authorization: Bearer eyJ...",
"authorization=
+ // Basic dXNlcg==". The scheme word is kept; only the credential is
masked.
+ authorizationHeaderRe =
regexp.MustCompile(`(?i)\b(authorization\s*[=:]\s*(?:(?:bearer|basic|token|digest|negotiate)\s+)?)(\S+)`)
+
+ // bareBearerRe matches a bearer token without an Authorization prefix
+ // ("bearer eyJhbGci..."). The token must contain at least one digit so
+ // prose like "bearer authentication failed" is not mangled; real tokens
+ // (JWTs, base64, hex) virtually always contain digits.
+ bareBearerRe =
regexp.MustCompile(`(?i)\b(bearer\s+)([A-Za-z0-9._~+/=-]*[0-9][A-Za-z0-9._~+/=-]*)`)
+
+ // credentialAssignmentRe matches free-form credential assignments such
as
+ // "password=hunter2", "PGPASSWORD: hunter2", or "secret_key = hunter2".
+ // The key may carry prefixes/suffixes (DB_PASSWORD, api-key-prod).
+ credentialAssignmentRe =
regexp.MustCompile(`(?i)([A-Za-z0-9_-]*(?:password|passwd|pwd|secret|token|api[_-]?key|credential|passphrase)[A-Za-z0-9_-]*\s*[=:]\s*)(\S+)`)
+)
+
+// redactCredentials masks credential-shaped substrings in free-form task
+// output before it is persisted to status fields or Events. It is a
+// best-effort, pattern-based defense: the operator never reads Secret values,
+// so it cannot scrub by exact value. Callers must apply it BEFORE truncation —
+// truncating first could split a credential mid-pattern, leaving a fragment
+// that no longer matches but still leaks.
+func redactCredentials(msg string) string {
+ msg = uriUserinfoRe.ReplaceAllString(msg,
"${1}:"+redactedPlaceholder+"@")
+ // authorizationHeaderRe must run before bareBearerRe so "Authorization:
+ // Bearer x" keeps its scheme word instead of being matched as a bare
token.
+ msg = authorizationHeaderRe.ReplaceAllString(msg,
"${1}"+redactedPlaceholder)
+ msg = bareBearerRe.ReplaceAllString(msg, "${1}"+redactedPlaceholder)
+ msg = credentialAssignmentRe.ReplaceAllString(msg,
"${1}"+redactedPlaceholder)
+ return msg
+}
diff --git a/internal/controller/redact_fuzz_test.go
b/internal/controller/redact_fuzz_test.go
new file mode 100644
index 0000000..13bc65d
--- /dev/null
+++ b/internal/controller/redact_fuzz_test.go
@@ -0,0 +1,45 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one
+or more contributor license agreements. See the NOTICE file
+distributed with this work for additional information
+regarding copyright ownership. The ASF licenses this file
+to you under the Apache License, Version 2.0 (the
+"License"); you may not use this file except in compliance
+with the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package controller
+
+import "testing"
+
+// FuzzRedactCredentials exercises redactCredentials with arbitrary task
+// output. The properties checked are robustness (no panic on any input) and
+// idempotence: redacting already-redacted output must be a no-op, since the
+// same message can pass through the function more than once across reconciles.
+func FuzzRedactCredentials(f *testing.F) {
+ f.Add("connection to postgresql://superset:hunter2@db:5432/superset
failed")
+ f.Add("redis://:s3cr3t@valkey:6379/0")
+ f.Add("password=hunter2 PGPASSWORD: x SECRET_KEY = y token=z")
+ f.Add("Job has reached the specified backoff limit")
+ f.Add("")
+ f.Add("://:@ password= : = @")
+ f.Add("postgresql://user:p@ss@db:5432/superset")
+ f.Add("Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.p.s bearer abc123")
+ f.Add("authorization=Basic dXNlcjpwYXNz passphrase: x")
+
+ f.Fuzz(func(t *testing.T, msg string) {
+ once := redactCredentials(msg)
+ twice := redactCredentials(once)
+ if once != twice {
+ t.Errorf("redactCredentials is not idempotent:\n input:
%q\n once: %q\n twice: %q", msg, once, twice)
+ }
+ })
+}
diff --git a/internal/controller/redact_test.go
b/internal/controller/redact_test.go
new file mode 100644
index 0000000..7972470
--- /dev/null
+++ b/internal/controller/redact_test.go
@@ -0,0 +1,130 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one
+or more contributor license agreements. See the NOTICE file
+distributed with this work for additional information
+regarding copyright ownership. The ASF licenses this file
+to you under the Apache License, Version 2.0 (the
+"License"); you may not use this file except in compliance
+with the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package controller
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestRedactCredentials(t *testing.T) {
+ tests := []struct {
+ name string
+ in string
+ want string
+ }{
+ {
+ name: "plain message unchanged",
+ in: "Job has reached the specified backoff limit",
+ want: "Job has reached the specified backoff limit",
+ },
+ {
+ name: "empty message",
+ in: "",
+ want: "",
+ },
+ {
+ name: "postgres URI password",
+ in: `connection to server at
"postgresql://superset:hunter2@db:5432/superset" failed`,
+ want: `connection to server at
"postgresql://superset:***@db:5432/superset" failed`,
+ },
+ {
+ name: "redis URI with empty user",
+ in: "cannot connect to redis://:s3cr3t@valkey:6379/0",
+ want: "cannot connect to redis://:***@valkey:6379/0",
+ },
+ {
+ name: "URI without userinfo unchanged",
+ in: "GET http://superset-web-server:8088/health
failed",
+ want: "GET http://superset-web-server:8088/health
failed",
+ },
+ {
+ name: "password assignment with equals",
+ in: "FATAL: authentication failed, password=hunter2
rejected",
+ want: "FATAL: authentication failed, password=***
rejected",
+ },
+ {
+ name: "env-style assignment with colon and prefix",
+ in: "PGPASSWORD: hunter2 is invalid",
+ want: "PGPASSWORD: *** is invalid",
+ },
+ {
+ name: "secret key assignment",
+ in: "SECRET_KEY = thisIsNotSecure",
+ want: "SECRET_KEY = ***",
+ },
+ {
+ name: "token and api key assignments",
+ in: "token=abc123 api_key: xyz789",
+ want: "token=*** api_key: ***",
+ },
+ {
+ name: "multiple URIs in one message",
+ in: "tried postgresql://a:p1@h1/db then
postgresql://b:p2@h2/db",
+ want: "tried postgresql://a:***@h1/db then
postgresql://b:***@h2/db",
+ },
+ {
+ name: "URI password containing a raw at-sign is masked
in full",
+ in: "connect to
postgresql://user:p@ss@db:5432/superset failed",
+ want: "connect to
postgresql://user:***@db:5432/superset failed",
+ },
+ {
+ name: "authorization header with bearer scheme",
+ in: `request rejected: Authorization: Bearer
eyJhbGciOiJIUzI1NiJ9.payload.sig`,
+ want: `request rejected: Authorization: Bearer ***`,
+ },
+ {
+ name: "authorization assignment with basic scheme",
+ in: "authorization=Basic dXNlcjpwYXNz failed",
+ want: "authorization=Basic *** failed",
+ },
+ {
+ name: "authorization header without scheme",
+ in: "Authorization: abc123xyz",
+ want: "Authorization: ***",
+ },
+ {
+ name: "bare bearer token",
+ in: "server returned 401 for bearer
eyJhbGciOiJIUzI1NiJ9",
+ want: "server returned 401 for bearer ***",
+ },
+ {
+ name: "bearer followed by prose is not mangled",
+ in: "bearer authentication failed for request",
+ want: "bearer authentication failed for request",
+ },
+ {
+ name: "passphrase assignment",
+ in: "ssl passphrase=opensesame rejected",
+ want: "ssl passphrase=*** rejected",
+ },
+ {
+ name: "idempotent on already-redacted output",
+ in: "postgresql://superset:***@db:5432/superset
password=*** Authorization: Bearer ***",
+ want: "postgresql://superset:***@db:5432/superset
password=*** Authorization: Bearer ***",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ assert.Equal(t, tt.want, redactCredentials(tt.in))
+ })
+ }
+}