During OpenShift CI ProwJob execution, test logs and artifacts often need to include configuration files (such as install-config.yaml) that may contain sensitive information like pullSecret, SSH keys, etc. To prevent these sensitive data from leaking into public test logs, Prow implements an automated secret censoring mechanism.
User's observation:
# pullSecret in install-config.yaml
pullSecret: >
{"auths":{"cloud.openshift.com":{"auth":"XXXXXXXXXXXX",...}}}Question: How is pullSecret automatically replaced with XXXXXXXXXXXX?
This is achieved through the Prow Sidecar Container automatic censoring mechanism:
- Sidecar Container: Prow automatically injects a sidecar container into each test Pod
- Secret Loading: Sidecar reads all sensitive information from Kubernetes Secret mount points
- File Scanning: Before uploading artifacts to GCS, scan all files
- In-place Replacement: Use efficient byte replacement algorithm to replace secrets with equal-length
XXXXXXXXXXXX - Upload Censored Files: Only censored files are uploaded to GCS
┌─────────────────────────────────────────────────────────────┐
│ Test Pod │
│ │
│ ┌──────────────────┐ ┌──────────────────┐ │
│ │ Test Container │ │ Sidecar Container│ │
│ │ │ │ │ │
│ │ 1. Run tests │ │ 4. Load secrets │ │
│ │ 2. Generate │ │ 5. Scan files │ │
│ │ artifacts │──artifacts──▶│ 6. Replace │ │
│ │ 3. Signal done │ │ secrets │ │
│ │ │ │ 7. Upload to │ │
│ │ │ │ GCS │ │
│ └──────────────────┘ └──────────────────┘ │
│ │ │ │
│ │ │ │
│ ▼ ▼ │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ /logs/artifacts │ │ Secret volumes │ │
│ │ │ │ /secrets/... │ │
│ └─────────────────┘ └─────────────────┘ │
└─────────────────────────────────────────────────────────────┘
│
│ Upload censored files
▼
┌──────────────────┐
│ GCS Bucket │
│ (Public Access) │
└──────────────────┘
File Location: pkg/steps/artifacts.go:297-301
// ci-tools calls Prow's decorate.Sidecar() when creating test Pod
containers, err := decorate.Sidecar(
spec.Spec,
prowv1.DecorationConfig{
UtilityImages: &prowv1.UtilityImages{
Sidecar: "gcr.io/k8s-prow/sidecar:latest",
},
GCSConfiguration: &prowv1.GCSConfiguration{
Bucket: bucket,
PathStrategy: prowv1.PathStrategyExplicit,
DefaultOrg: org,
DefaultRepo: repo,
},
CensoringOptions: &prowv1.CensoringOptions{
SecretDirectories: secretsToCensor,
},
},
rawEnv,
)Key Parameters:
UtilityImages.Sidecar: Sidecar container imageGCSConfiguration: Upload destination configurationCensoringOptions.SecretDirectories: List of Secret mount directories to censor
File Location: vendor/sigs.k8s.io/prow/pkg/pod-utils/decorate/podspec.go:165-232
// Sidecar() function creates sidecar container specification
func Sidecar(spec *coreapi.PodSpec, config prowapi.DecorationConfig, rawEnv map[string]string) ([]coreapi.Container, error) {
// Build sidecar container
sidecarContainer := coreapi.Container{
Name: "sidecar",
Image: config.UtilityImages.Sidecar,
Args: []string{
"--wrapper-process-log=/logs/process-log.txt",
"--entries=" + entries, // List of test containers to wait for
},
VolumeMounts: []coreapi.VolumeMount{
{
Name: "artifacts",
MountPath: "/logs/artifacts",
},
// Mount all secret directories
// ...
},
}
// Add censoring options
if config.CensoringOptions != nil {
for _, secretDir := range config.CensoringOptions.SecretDirectories {
sidecarContainer.Args = append(sidecarContainer.Args,
"--censoring-secret-dir=" + secretDir)
}
}
return []coreapi.Container{sidecarContainer}, nil
}Generated Pod Structure Example:
apiVersion: v1
kind: Pod
metadata:
name: test-pod
spec:
containers:
- name: test
image: test-image:latest
volumeMounts:
- name: artifacts
mountPath: /logs/artifacts
- name: sidecar
image: gcr.io/k8s-prow/sidecar:latest
args:
- --wrapper-process-log=/logs/process-log.txt
- --censoring-secret-dir=/secrets/pull-secret
- --censoring-secret-dir=/secrets/ssh-publickey
- --gcs-bucket=test-platform-results
- --gcs-path-strategy=explicit
volumeMounts:
- name: artifacts
mountPath: /logs/artifacts
- name: pull-secret
mountPath: /secrets/pull-secret
readOnly: true
- name: ssh-publickey
mountPath: /secrets/ssh-publickey
readOnly: true
volumes:
- name: artifacts
emptyDir: {}
- name: pull-secret
secret:
secretName: cluster-secrets-aws
- name: ssh-publickey
secret:
secretName: cluster-secrets-awsFile Location: vendor/sigs.k8s.io/prow/pkg/sidecar/run.go:164-240
// Run() is the main entry point for sidecar container
func (o Options) Run() error {
// 1. Wait for test containers to complete
waitForever := false
if err := o.waitForMarkers(!waitForever, o.Entries); err != nil {
return err
}
// 2. Censor all artifacts (replace secrets)
if err := o.censor(); err != nil {
logrus.WithError(err).Warn("Failed to censor data")
}
// 3. Upload artifacts to GCS
if o.GcsOptions.Bucket != "" && o.GcsOptions.Items != nil {
if err := o.doUpload(); err != nil {
return err
}
}
return nil
}Key Steps:
waitForMarkers(): Wait for test containers to write completion markerscensor(): Core censoring logic - Scan and replace all secretsdoUpload(): Upload censored files to GCS
File Location: vendor/sigs.k8s.io/prow/pkg/sidecar/run.go:242-293
func (o Options) waitForMarkers(waitForever bool, markers []string) error {
// Monitor test container marker files
// Each test container creates a marker file upon completion
// Sidecar waits for all markers to appear before continuing
pending := sets.NewString(markers...)
for {
for _, marker := range markers {
_, err := os.Stat(marker)
if err == nil {
pending.Delete(marker)
}
}
if pending.Len() == 0 {
// All test containers completed
return nil
}
time.Sleep(2 * time.Second)
}
}File Location: vendor/sigs.k8s.io/prow/pkg/sidecar/censor.go:45-163
// censor() function scans all artifacts and replaces secrets
func (o Options) censor() error {
// 1. Load all secrets from mounted Secret directories
secrets, err := loadSecrets(
o.CensoringOptions.SecretDirectories,
o.CensoringOptions.IniFilenames,
)
if err != nil {
return fmt.Errorf("load secrets: %w", err)
}
// 2. Create Censorer
const minLength = 3 // Only censor secrets with length >= 3
censorer := secretutil.NewCensorerWithMinLength(minLength)
censorer.RefreshBytes(secrets...)
// 3. Iterate through all artifacts files
for _, item := range o.GcsOptions.Items {
// Get local file path
localPath := item.LocalPath()
// 4. Walk through all files in directory
err := filepath.Walk(localPath, func(path string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() {
return err
}
// 5. Censor single file
if err := censorer.CensorFile(path); err != nil {
return fmt.Errorf("censor file %s: %w", path, err)
}
return nil
})
if err != nil {
return err
}
}
return nil
}File Location: vendor/sigs.k8s.io/prow/pkg/sidecar/censor.go:165-226
// loadSecrets() reads all secrets from Kubernetes Secret mount points
func loadSecrets(secretDirs []string, iniFilenames sets.String) ([][]byte, error) {
var secrets [][]byte
for _, secretDir := range secretDirs {
// Walk through Secret directory
err := filepath.Walk(secretDir, func(path string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() {
return err
}
// Read file content
content, err := ioutil.ReadFile(path)
if err != nil {
return err
}
// Kubernetes Secret values are base64 encoded
// Try to decode
if decoded, err := base64.StdEncoding.DecodeString(string(content)); err == nil {
// Save both base64 and decoded values
secrets = append(secrets, content, decoded)
} else {
secrets = append(secrets, content)
}
// For .ini files, parse and extract values
if iniFilenames.Has(filepath.Base(path)) {
cfg, err := ini.Load(path)
if err == nil {
for _, section := range cfg.Sections() {
for _, key := range section.Keys() {
value := []byte(key.Value())
secrets = append(secrets, value)
}
}
}
}
return nil
})
if err != nil {
return nil, err
}
}
return secrets, nil
}Loading Example:
Assuming the following Secret mounts:
/secrets/pull-secret/
└── .dockerconfigjson
Content: {"auths":{"cloud.openshift.com":{"auth":"dXNlcjpwYXNzd29yZA=="}}}
/secrets/ssh-publickey/
└── publickey
Content: ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQ...
loadSecrets() extracts:
secrets = [][]byte{
[]byte(`{"auths":{"cloud.openshift.com":{"auth":"dXNlcjpwYXNzd29yZA=="}}}`),
[]byte(`dXNlcjpwYXNzd29yZA==`), // base64 string
[]byte(`user:password`), // decoded value
[]byte(`ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQ...`),
}File Location: vendor/sigs.k8s.io/prow/pkg/secretutil/censor.go:92-132
// Censorer uses strings.Replacer for efficient string replacement
type Censorer struct {
replacer *strings.Replacer
minLength int
}
// RefreshBytes() builds replacement mapping
func (c *Censorer) RefreshBytes(secrets ...[]byte) {
var replacements []string
// Helper function: add a replacement rule
addReplacement := func(s string) {
if len(s) < c.minLength {
return // Skip secrets that are too short
}
// Create equal-length 'X' string as replacement value
replacements = append(replacements, s, strings.Repeat(`X`, len(s)))
}
seen := make(map[string]bool)
for _, secret := range secrets {
s := string(secret)
if seen[s] {
continue // Deduplicate
}
seen[s] = true
addReplacement(s)
// For secrets containing newlines, also add version without newlines
if strings.Contains(s, "\n") {
cleaned := strings.ReplaceAll(s, "\n", "")
addReplacement(cleaned)
}
// URL encoded version
addReplacement(url.QueryEscape(s))
}
// Create Replacer (internally uses byte replacer optimization)
c.replacer = strings.NewReplacer(replacements...)
}Replacement Example:
Input secrets:
secrets = [][]byte{
[]byte("dXNlcjpwYXNzd29yZA=="), // 20 characters
[]byte("user:password"), // 13 characters
}Generated replacement mapping:
replacements = []string{
"dXNlcjpwYXNzd29yZA==", "XXXXXXXXXXXXXXXXXXXX", // 20 X's
"user:password", "XXXXXXXXXXXXX", // 13 X's
}File Location: vendor/sigs.k8s.io/prow/pkg/secretutil/censor.go:134-180
// CensorFile() replaces all secrets in file in-place
func (c *Censorer) CensorFile(path string) error {
// 1. Open file for read-write
file, err := os.OpenFile(path, os.O_RDWR, 0)
if err != nil {
return err
}
defer file.Close()
// 2. Create byte replacer
byteReplacer := bytereplacer.New(c.replacer)
// 3. Use sliding window algorithm to process file
const bufferSize = 32 * 1024 // 32KB buffer
buffer := make([]byte, bufferSize)
var offset int64 = 0
for {
// Read a chunk of data
n, readErr := file.ReadAt(buffer[:bufferSize], offset)
if n == 0 {
break
}
// Replace secrets in buffer
replaced := byteReplacer.Replace(buffer[:n])
// Write back to file (in-place replacement)
_, err := file.WriteAt(replaced, offset)
if err != nil {
return err
}
offset += int64(n)
if readErr == io.EOF {
break
}
}
return nil
}Key Features:
- In-place replacement: Directly modifies original file without creating temporary files
- Streaming processing: Uses 32KB buffer, supports large files
- Equal-length replacement: Secrets replaced with same-length
X, maintaining file size
Prow uses a custom bytereplacer package for efficient byte-level replacement. Core algorithm:
File Location: vendor/sigs.k8s.io/prow/pkg/secretutil/bytereplacer/bytereplacer.go
// ByteReplacer is a replacer optimized for byte streams
type ByteReplacer struct {
r *strings.Replacer
}
// Replace() replaces all matches in byte slice
func (br *ByteReplacer) Replace(s []byte) []byte {
// Convert bytes to string
str := string(s)
// Use strings.Replacer for replacement
replaced := br.r.Replace(str)
// Convert back to bytes
return []byte(replaced)
}Algorithm used by strings.Replacer:
- Aho-Corasick automaton: For multi-pattern string matching
- Time complexity: O(n + m), where n is text length, m is total pattern length
- Space complexity: O(m)
Original File (/logs/artifacts/install-config.yaml):
apiVersion: v1
baseDomain: qe.devcluster.openshift.com
metadata:
name: test-cluster
pullSecret: >
{"auths":{"cloud.openshift.com":{"auth":"dXNlcjpwYXNzd29yZA==","email":"test@example.com"}}}
sshKey: |
ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC1234567890...Loaded Secrets:
secrets = [][]byte{
// From /secrets/pull-secret/.dockerconfigjson
[]byte(`{"auths":{"cloud.openshift.com":{"auth":"dXNlcjpwYXNzd29yZA==","email":"test@example.com"}}}`),
[]byte(`dXNlcjpwYXNzd29yZA==`),
[]byte(`user:password`),
// From /secrets/ssh-publickey/publickey
[]byte(`ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC1234567890...`),
}Generated Replacement Rules:
replacements = []string{
`{"auths":{"cloud.openshift.com":{"auth":"dXNlcjpwYXNzd29yZA==","email":"test@example.com"}}}`,
`XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX`, // equal length
`dXNlcjpwYXNzd29yZA==`,
`XXXXXXXXXXXXXXXXXXXX`,
`user:password`,
`XXXXXXXXXXXXX`,
`ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC1234567890...`,
`XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX...`, // equal length
}Censored File:
apiVersion: v1
baseDomain: qe.devcluster.openshift.com
metadata:
name: test-cluster
pullSecret: >
XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
sshKey: |
XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX...Test Container Sidecar Container GCS
│ │ │
│ │ 1. Start │
│ │ 2. Wait for test complete │
│ │ │
│ 3. Execute tests │ │
│ 4. Generate artifacts │ │
│ └─> install-config.yaml │ │
│ └─> test-results.xml │ │
│ │ │
│ 5. Write completion marker │ │
│ └─> /logs/marker-file │ │
│ │ │
│ │ 6. Detect marker │
│ │ 7. Load secrets │
│ │ ├─ /secrets/pull-secret │
│ │ └─ /secrets/ssh-key │
│ │ │
│ │ 8. Scan /logs/artifacts │
│ │ └─ install-config.yaml │
│ │ │
│ │ 9. Replace secrets │
│ │ pullSecret: > XXX... │
│ │ sshKey: | XXX... │
│ │ │
│ │ 10. Upload to GCS ────────>│
│ │ │
│ │ 11. Complete │
│ │ │
File Location: pkg/steps/artifacts.go:223-250
// In ci-tools, steps define which Secrets need to be censored
func (s *artifactStep) Run(ctx context.Context) error {
// Collect Secret directories to censor
var secretsToCensor []string
// 1. Get secrets from step configuration
for _, secret := range s.config.Secrets {
secretsToCensor = append(secretsToCensor,
fmt.Sprintf("/secrets/%s", secret.Name))
}
// 2. Get from global configuration
if s.clusterClaim != nil {
secretsToCensor = append(secretsToCensor,
"/secrets/cluster-profile")
}
// 3. Pass to Prow decorate
decoration := prowv1.DecorationConfig{
CensoringOptions: &prowv1.CensoringOptions{
SecretDirectories: secretsToCensor,
},
}
// Call Prow's Sidecar() to inject sidecar container
containers, err := decorate.Sidecar(podSpec, decoration, env)
// ...
}Example: ci-operator/step-registry/cucushift/installer/rehearse/aws/cases/zone-consistency/cucushift-installer-rehearse-aws-cases-zone-consistency-ref.yaml
ref:
as: cucushift-installer-rehearse-aws-cases-zone-consistency
from: upi-installer
commands: cucushift-installer-rehearse-aws-cases-zone-consistency-commands.sh
credentials:
- namespace: test-credentials
name: cluster-secrets-aws
mount_path: /secrets/cluster-profile
env:
- name: BASE_DOMAIN
default: "qe.devcluster.openshift.com"This causes:
- Secret
cluster-secrets-awsis mounted to/secrets/cluster-profile - ci-tools automatically adds
/secrets/cluster-profiletosecretsToCensorlist - Sidecar container scans all files in this directory and extracts secrets
Some secrets may contain newlines (like multi-line SSH keys):
// In RefreshBytes()
if strings.Contains(s, "\n") {
// Add original version (with newlines)
addReplacement(s)
// Add cleaned version (without newlines)
cleaned := strings.ReplaceAll(s, "\n", "")
addReplacement(cleaned)
}Reason: Some tools may remove newlines before using the secret, need to censor both forms.
Kubernetes Secret values are typically base64 encoded:
// In loadSecrets()
content, _ := ioutil.ReadFile(path)
// Try base64 decode
if decoded, err := base64.StdEncoding.DecodeString(string(content)); err == nil {
// Save both forms
secrets = append(secrets, content, decoded)
} else {
secrets = append(secrets, content)
}Effect: Censors both base64 encoded and decoded secrets.
For example:
- Original secret:
user:password - Base64 encoded:
dXNlcjpwYXNzd29yZA== - Both will be replaced with
X
Challenge: What if a secret spans across the 32KB buffer boundary?
Solution: Prow's bytereplacer handles this internally through a backtracking mechanism to ensure no cross-boundary matches are missed.
Example:
Buffer 1: ...xxxxx dXNlcjpw
Buffer 2: YXNzd29yZA== yyyyy...
^───────^
Cross-boundary secret
bytereplacer will:
- Detect that buffer 1 end might be the start of a secret
- Keep overlapping data at the end
- When processing buffer 2, consider the overlap region
- Correctly match and replace the complete secret
const minLength = 3
censorer := secretutil.NewCensorerWithMinLength(minLength)Reason:
- Very short "secrets" (like
a,1) might be common characters, causing false positives - Only censor secrets with length >= 3, balancing security and usability
strings.Replacer internally uses Aho-Corasick algorithm for multi-pattern matching:
Advantages:
- Single scan: Only need to traverse text once to find all patterns
- O(n + m) complexity: n is text length, m is total pattern length
- Handles many patterns: Even with hundreds of secrets, performance remains excellent
Comparison with naive approach:
// Naive approach: O(n * k), k is number of secrets
for _, secret := range secrets {
content = strings.ReplaceAll(content, secret, "XXX")
}
// Aho-Corasick: O(n + m)
replacer.Replace(content)// Modify original file directly, no temporary files
file.WriteAt(replaced, offset)Advantages:
- Save disk space (no need for double space)
- Reduce I/O operations
- Maintain inode and file permissions
const bufferSize = 32 * 1024 // 32KB
buffer := make([]byte, bufferSize)Advantages:
- Support files of any size
- Constant memory usage (32KB)
- Suitable for processing large log files
Test Container Sidecar
│ │
│ Generate artifacts │
│ ↓ │
│ /logs/artifacts/ │
│ (may contain plaintext) │
│ │
│ Completion marker ──────> │
│ │ Censor ✓
│ │ Upload ✓
│ │
Key: Secret replacement happens before upload, ensuring no plaintext in public GCS storage.
Risk: In Pod's /logs/artifacts directory, plaintext secrets exist temporarily.
Mitigation:
- Pod's ephemeral storage is destroyed immediately upon Pod deletion
- Only containers within the same Pod can access
- Kubernetes RBAC restricts Pod access
Scenarios that may be missed:
- Undeclared Secrets: If step uses a Secret but doesn't declare it in
credentials, it won't be censored - Dynamically generated secrets: Secrets dynamically generated by test code (not from Kubernetes Secret) won't be censored
- Encoding variants: Non-standard encodings (like hex, custom encoding) may be missed
Best Practices:
- All sensitive information must come from Kubernetes Secret
- Explicitly declare all Secrets in step's
credentials - Avoid hardcoding secrets in code
Prow Sidecar's secret censoring mechanism is implemented through the following steps:
- Container Injection: ci-tools automatically injects sidecar container when creating test Pod
- Secret Collection: Sidecar reads secrets from all mounted Secret directories
- Wait for Completion: Wait for test containers to finish execution
- File Scanning: Iterate through all artifacts files
- Efficient Replacement: Use Aho-Corasick algorithm for in-place replacement of secrets with equal-length
X - Secure Upload: Only censored files are uploaded to public GCS
| Component | File | Function |
|---|---|---|
| ci-tools | pkg/steps/artifacts.go:297-301 |
Call Prow to inject sidecar |
| Prow | pkg/pod-utils/decorate/podspec.go:165-232 |
Generate sidecar container spec |
| Sidecar | pkg/sidecar/run.go:164-240 |
Main flow: wait, censor, upload |
| Censor | pkg/sidecar/censor.go:45-163 |
Load secrets, scan files |
| Replacer | pkg/secretutil/censor.go:92-180 |
Core replacement algorithm |
- High Performance: Aho-Corasick algorithm + byte stream processing + in-place replacement
- Comprehensive: Automatically handles base64, URL encoding, multi-line variants
- Transparent: Developers don't need to worry about censoring logic, it's automatic
- Secure: Censoring happens before upload, ensuring no plaintext in public storage
Question: How is pullSecret automatically replaced with XXXXXXXXXXXX?
Answer:
- ci-tools automatically injects sidecar container and passes Secret mount paths
- After test completion, sidecar reads secrets from
/secrets/pull-secret - Scans
/logs/artifacts/install-config.yaml - Uses Aho-Corasick algorithm to find secret strings in file
- In-place replacement with equal-length
XXXXXXXXXXXX - Uploads censored file to GCS
Result: The pullSecret: > XXXXXXXXXXXX users see in CI logs is the automatically censored version. The original plaintext was never uploaded to public storage.
vendor/sigs.k8s.io/prow/pkg/sidecar/run.go- Sidecar main flowvendor/sigs.k8s.io/prow/pkg/sidecar/censor.go- Censoring logicvendor/sigs.k8s.io/prow/pkg/secretutil/censor.go- Core replacervendor/sigs.k8s.io/prow/pkg/pod-utils/decorate/podspec.go- Container injectionpkg/steps/artifacts.go- ci-tools integration point
Document Generated: 2026-01-30 Author: Claude Code Based On: ci-tools project source code analysis Prow Version: vendored in ci-tools (kubernetes/test-infra)