Skip to content

Commit d355cd7

Browse files
committed
feat: Add secret masking to step logs
Introduced a new feature to mask sensitive secret values within step stdout and stderr logs. This enhancement helps to prevent accidental exposure of sensitive information like passwords and API keys. When the `enable-secret-masking` feature flag is enabled, the Tekton entrypoint will collect secret values referenced in environment variables and volume mounts. These secrets are then base64 encoded and written to a temporary file. A masking writer is used to intercept and redact these secrets, replacing them with "***" before they are written to the logs. An init container is added to the pod to prepare the secret masking file for the entrypoint. Signed-off-by: Chmouel Boudjnah <chmouel@redhat.com>
1 parent 2fea6ed commit d355cd7

File tree

14 files changed

+810
-11
lines changed

14 files changed

+810
-11
lines changed

cmd/entrypoint/main.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ var (
5656
" Set to \"stopAndFail\" to declare a failure with a step error and stop executing the rest of the steps.")
5757
stepMetadataDir = flag.String("step_metadata_dir", "", "If specified, create directory to store the step metadata e.g. /tekton/steps/<step-name>/")
5858
resultExtractionMethod = flag.String("result_from", entrypoint.ResultExtractionMethodTerminationMessage, "The method using which to extract results from tasks. Default is using the termination message.")
59+
secretMaskFile = flag.String("secret_mask_file", "", "If specified, file containing base64-encoded secrets to mask in stdout/stderr (one per line)")
5960
)
6061

6162
const (
@@ -135,8 +136,9 @@ func main() {
135136
TerminationPath: *terminationPath,
136137
Waiter: &realWaiter{waitPollingInterval: defaultWaitPollingInterval, breakpointOnFailure: *breakpointOnFailure},
137138
Runner: &realRunner{
138-
stdoutPath: *stdoutPath,
139-
stderrPath: *stderrPath,
139+
stdoutPath: *stdoutPath,
140+
stderrPath: *stderrPath,
141+
secretMaskFile: *secretMaskFile,
140142
},
141143
PostWriter: &realPostWriter{},
142144
Results: strings.Split(*results, ","),

cmd/entrypoint/masking_writer.go

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
//go:build !windows
2+
3+
/*
4+
Copyright 2026 The Tekton Authors
5+
6+
Licensed under the Apache License, Version 2.0 (the "License");
7+
you may not use this file except in compliance with the License.
8+
You may obtain a copy of the License at
9+
10+
http://www.apache.org/licenses/LICENSE-2.0
11+
12+
Unless required by applicable law or agreed to in writing, software
13+
distributed under the License is distributed on an "AS IS" BASIS,
14+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
See the License for the specific language governing permissions and
16+
limitations under the License.
17+
*/
18+
19+
package main
20+
21+
import (
22+
"bytes"
23+
"io"
24+
)
25+
26+
// maskingWriter wraps an io.Writer and replaces secret values with "***".
27+
type maskingWriter struct {
28+
underlying io.Writer
29+
secrets [][]byte
30+
}
31+
32+
// newMaskingWriter creates a new writer that masks the given secrets in its output.
33+
// If no secrets are provided, the underlying writer is returned as-is.
34+
func newMaskingWriter(w io.Writer, secrets []string) io.Writer {
35+
if len(secrets) == 0 {
36+
return w
37+
}
38+
secretBytes := make([][]byte, 0, len(secrets))
39+
for _, s := range secrets {
40+
if len(s) >= 3 {
41+
secretBytes = append(secretBytes, []byte(s))
42+
}
43+
}
44+
if len(secretBytes) == 0 {
45+
return w
46+
}
47+
return &maskingWriter{underlying: w, secrets: secretBytes}
48+
}
49+
50+
// Write implements io.Writer. It replaces all occurrences of secrets with "***"
51+
// before writing to the underlying writer.
52+
func (m *maskingWriter) Write(p []byte) (n int, err error) {
53+
masked := p
54+
for _, secret := range m.secrets {
55+
masked = bytes.ReplaceAll(masked, secret, []byte("***"))
56+
}
57+
_, err = m.underlying.Write(masked)
58+
return len(p), err
59+
}
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
//go:build !windows
2+
3+
/*
4+
Copyright 2025 The Tekton Authors
5+
6+
Licensed under the Apache License, Version 2.0 (the "License");
7+
you may not use this file except in compliance with the License.
8+
You may obtain a copy of the License at
9+
10+
http://www.apache.org/licenses/LICENSE-2.0
11+
12+
Unless required by applicable law or agreed to in writing, software
13+
distributed under the License is distributed on an "AS IS" BASIS,
14+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
See the License for the specific language governing permissions and
16+
limitations under the License.
17+
*/
18+
19+
package main
20+
21+
import (
22+
"bytes"
23+
"os"
24+
"path/filepath"
25+
"testing"
26+
)
27+
28+
func TestMaskingWriter(t *testing.T) {
29+
testCases := []struct {
30+
name string
31+
secrets []string
32+
input string
33+
expected string
34+
}{
35+
{
36+
name: "no secrets",
37+
secrets: nil,
38+
input: "hello world",
39+
expected: "hello world",
40+
},
41+
{
42+
name: "single secret",
43+
secrets: []string{"password123"},
44+
input: "The password is password123!",
45+
expected: "The password is ***!",
46+
},
47+
{
48+
name: "multiple secrets",
49+
secrets: []string{"secret1", "secret2"},
50+
input: "secret1 and secret2 are here",
51+
expected: "*** and *** are here",
52+
},
53+
{
54+
name: "short secret skipped",
55+
secrets: []string{"ab"},
56+
input: "ab ab ab",
57+
expected: "ab ab ab",
58+
},
59+
{
60+
name: "repeated secret",
61+
secrets: []string{"token"},
62+
input: "token token token",
63+
expected: "*** *** ***",
64+
},
65+
}
66+
67+
for _, tc := range testCases {
68+
t.Run(tc.name, func(t *testing.T) {
69+
var buf bytes.Buffer
70+
w := newMaskingWriter(&buf, tc.secrets)
71+
if _, err := w.Write([]byte(tc.input)); err != nil {
72+
t.Fatalf("unexpected error: %v", err)
73+
}
74+
if got := buf.String(); got != tc.expected {
75+
t.Errorf("got %q, want %q", got, tc.expected)
76+
}
77+
})
78+
}
79+
}
80+
81+
func TestLoadSecretsForMasking(t *testing.T) {
82+
dir := t.TempDir()
83+
filePath := filepath.Join(dir, "secrets")
84+
content := "c2VjcmV0MQ==\ncGFzc3dvcmQ=\n"
85+
if err := os.WriteFile(filePath, []byte(content), 0644); err != nil {
86+
t.Fatalf("failed to write test file: %v", err)
87+
}
88+
89+
got, err := loadSecretsForMasking(filePath)
90+
if err != nil {
91+
t.Fatalf("unexpected error: %v", err)
92+
}
93+
94+
expected := map[string]bool{"secret1": true, "password": true}
95+
for _, s := range got {
96+
if !expected[s] {
97+
t.Fatalf("unexpected secret: %s", s)
98+
}
99+
delete(expected, s)
100+
}
101+
if len(expected) != 0 {
102+
t.Fatalf("missing secrets: %v", expected)
103+
}
104+
}
105+
106+
func TestLoadSecretsForMaskingInvalidLine(t *testing.T) {
107+
dir := t.TempDir()
108+
filePath := filepath.Join(dir, "secrets")
109+
if err := os.WriteFile(filePath, []byte("not-base64\n"), 0644); err != nil {
110+
t.Fatalf("failed to write test file: %v", err)
111+
}
112+
113+
if _, err := loadSecretsForMasking(filePath); err == nil {
114+
t.Fatalf("expected error")
115+
}
116+
}

cmd/entrypoint/runner.go

Lines changed: 43 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,15 @@ package main
1919

2020
import (
2121
"context"
22+
"encoding/base64"
2223
"errors"
2324
"fmt"
2425
"io"
2526
"os"
2627
"os/exec"
2728
"os/signal"
2829
"path/filepath"
30+
"strings"
2931
"sync"
3032
"syscall"
3133

@@ -42,10 +44,11 @@ const (
4244
// realRunner actually runs commands.
4345
type realRunner struct {
4446
sync.Mutex
45-
signals chan os.Signal
46-
signalsClosed bool
47-
stdoutPath string
48-
stderrPath string
47+
signals chan os.Signal
48+
signalsClosed bool
49+
stdoutPath string
50+
stderrPath string
51+
secretMaskFile string
4952
}
5053

5154
var _ entrypoint.Runner = (*realRunner)(nil)
@@ -86,6 +89,13 @@ func (rr *realRunner) Run(ctx context.Context, args ...string) error {
8689

8790
cmd := exec.CommandContext(ctx, name, args...)
8891

92+
secrets, err := loadSecretsForMasking(rr.secretMaskFile)
93+
if err != nil {
94+
return err
95+
}
96+
stdoutBase := newMaskingWriter(os.Stdout, secrets)
97+
stderrBase := newMaskingWriter(os.Stderr, secrets)
98+
8999
// if a standard output file is specified
90100
// create the log file and add to the std multi writer
91101
if rr.stdoutPath != "" {
@@ -94,19 +104,19 @@ func (rr *realRunner) Run(ctx context.Context, args ...string) error {
94104
return err
95105
}
96106
defer stdout.Close()
97-
cmd.Stdout = io.MultiWriter(os.Stdout, stdout)
107+
cmd.Stdout = io.MultiWriter(stdoutBase, newMaskingWriter(stdout, secrets))
98108
} else {
99-
cmd.Stdout = os.Stdout
109+
cmd.Stdout = stdoutBase
100110
}
101111
if rr.stderrPath != "" {
102112
stderr, err := newStdLogWriter(rr.stderrPath)
103113
if err != nil {
104114
return err
105115
}
106116
defer stderr.Close()
107-
cmd.Stderr = io.MultiWriter(os.Stderr, stderr)
117+
cmd.Stderr = io.MultiWriter(stderrBase, newMaskingWriter(stderr, secrets))
108118
} else {
109-
cmd.Stderr = os.Stderr
119+
cmd.Stderr = stderrBase
110120
}
111121

112122
// dedicated PID group used to forward signals to
@@ -170,3 +180,28 @@ func newStdLogWriter(path string) (*os.File, error) {
170180

171181
return f, nil
172182
}
183+
184+
// loadSecretsForMasking reads base64-encoded secrets from a file (one per line)
185+
// and returns them as decoded strings.
186+
func loadSecretsForMasking(filePath string) ([]string, error) {
187+
if filePath == "" {
188+
return nil, nil
189+
}
190+
data, err := os.ReadFile(filePath)
191+
if err != nil {
192+
return nil, err
193+
}
194+
var secrets []string
195+
for i, line := range strings.Split(string(data), "\n") {
196+
line = strings.TrimSpace(line)
197+
if line == "" {
198+
continue
199+
}
200+
decoded, err := base64.StdEncoding.DecodeString(line)
201+
if err != nil {
202+
return nil, fmt.Errorf("decode secret mask entry %d: %w", i+1, err)
203+
}
204+
secrets = append(secrets, string(decoded))
205+
}
206+
return secrets, nil
207+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
/*
2+
Copyright 2025 The Tekton Authors
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package subcommands
18+
19+
import (
20+
"fmt"
21+
"os"
22+
"path/filepath"
23+
)
24+
25+
// SecretMaskInitCommand is the name of the secret mask initialization command
26+
const SecretMaskInitCommand = "secret-mask-init"
27+
28+
// SecretMaskDataEnvVar is the env var containing secret mask data.
29+
const SecretMaskDataEnvVar = "TEKTON_SECRET_MASK_DATA"
30+
31+
// secretMaskInit writes the secret mask data to the specified file path.
32+
func secretMaskInit(filePath string) error {
33+
data := os.Getenv(SecretMaskDataEnvVar)
34+
if data == "" {
35+
return fmt.Errorf("environment variable %s is not set", SecretMaskDataEnvVar)
36+
}
37+
38+
if err := os.MkdirAll(filepath.Dir(filePath), 0755); err != nil {
39+
return err
40+
}
41+
return os.WriteFile(filePath, []byte(data), 0444)
42+
}

cmd/entrypoint/subcommands/subcommands.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,16 @@ func Process(args []string) error {
9292
return SubcommandError{subcommand: StepInitCommand, message: err.Error()}
9393
}
9494
return OK{message: "Setup /step directories"}
95+
case SecretMaskInitCommand:
96+
// If invoked in "secret-mask-init" mode (`entrypoint secret-mask-init <file-path>`),
97+
// write the secret mask data (from environment variable) to the specified file.
98+
if len(args) == 2 {
99+
filePath := args[1]
100+
if err := secretMaskInit(filePath); err != nil {
101+
return SubcommandError{subcommand: SecretMaskInitCommand, message: err.Error()}
102+
}
103+
return OK{message: "Initialized secret mask file"}
104+
}
95105
default:
96106
}
97107
return nil

config/config-feature-flags.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,3 +136,7 @@ data:
136136
# If set to "false", exponential backoff will be disabled.
137137
# For advanced tuning of backoff parameters, update the 'wait-exponential-backoff' ConfigMap.
138138
enable-wait-exponential-backoff: "false"
139+
# Setting this flag to "true" enables masking of secret values in step stdout/stderr.
140+
# Secret values from secretKeyRef environment variables and secret volumes will be
141+
# replaced with "***" in the logs.
142+
enable-secret-masking: "false"

docs/additional-configs.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -369,7 +369,11 @@ to run in namespaces with `restricted` pod security admission. By default, this
369369
- `set-security-context-read-only-root-filesystem`: Set this flag to `true` to enable `readOnlyRootFilesystem` in the
370370
security context for containers injected by Tekton. This makes the root filesystem of the container read-only,
371371
enhancing security. Note that this requires `set-security-context` to be enabled. By default, this flag is set
372-
to `false`. Note: This feature does not work in windows as it is not supported there, [Comparison with linux](https://kubernetes.io/docs/concepts/windows/intro/#compatibility-linux-similarities).
372+
to `false`. Note: This feature does not work in windows as it is not supported there, [Comparison with linux](https://kubernetes.io/docs/concepts/windows/intro/#compatibility-linux-similarities).
373+
374+
- `enable-secret-masking`: Set this flag to `"true"` to enable masking of secret values in step stdout/stderr.
375+
Secret values from `secretKeyRef` environment variables and secret volumes will be replaced with `***` in the logs.
376+
By default, this is set to `false`. This is an `alpha` feature.
373377

374378
### Alpha Features
375379

@@ -395,6 +399,7 @@ Features currently in "alpha" are:
395399
| [keep pod on cancel](./taskruns.md#cancelling-a-taskrun) | N/A | [v0.52.0](https://github.com/tektoncd/pipeline/releases/tag/v0.52.0) | `keep-pod-on-cancel` |
396400
| [CEL in WhenExpression](./pipelines.md#use-cel-expression-in-whenexpression) | [TEP-0145](https://github.com/tektoncd/community/blob/main/teps/0145-cel-in-whenexpression.md) | [v0.53.0](https://github.com/tektoncd/pipeline/releases/tag/v0.53.0) | `enable-cel-in-whenexpression` |
397401
| [Param Enum](./taskruns.md#parameter-enums) | [TEP-0144](https://github.com/tektoncd/community/blob/main/teps/0144-param-enum.md) | [v0.54.0](https://github.com/tektoncd/pipeline/releases/tag/v0.54.0) | `enable-param-enum` |
402+
| Secret Masking | N/A | N/A | `enable-secret-masking` |
398403

399404
### Beta Features
400405

0 commit comments

Comments
 (0)