diff --git a/README.md b/README.md index 04b91a1..c881c65 100644 --- a/README.md +++ b/README.md @@ -425,7 +425,7 @@ slash) and `log.iostream` (`stdout` or `stderr`). Kubernetes records carry `k8s.container.name` and `k8s.pod.name`, including init containers. Direct, command, hook, and worker logs do not carry container attributes. -Before export, the worker redacts known environment values supplied to the task +Before export, the worker redacts known secret environment values supplied to the task or its hooks. Local stdout/stderr is not scrubbed. Secrets created inside tasks or resolved through Kubernetes `valueFrom`/`envFrom` are not known to the worker and cannot be redacted. Setup hooks must write generated credentials to the diff --git a/internal/tasktelemetry/log/redaction.go b/internal/tasktelemetry/log/redaction.go index e9fce74..718e34e 100644 --- a/internal/tasktelemetry/log/redaction.go +++ b/internal/tasktelemetry/log/redaction.go @@ -5,6 +5,7 @@ import ( "errors" "net/url" "sort" + "strconv" "strings" "sync" ) @@ -42,6 +43,40 @@ func (b *redactionBuffer) WriteString(s string) (int, error) { return b.Builder.WriteString(s) } +func (r *redactor) addEnv(name, value string) { + name = strings.ToUpper(name) + for _, marker := range []string{"KEY", "TOKEN", "SECRET", "PASSWORD", "PASSWD", "AUTH", "CREDENTIAL", "PRIVATE"} { + if strings.Contains(name, marker) { + r.add(value) + return + } + } + // When adding the task environment, do not mask flag values or known non-sensitive variables. + // Because the redactor matches on values alone, over-redacting makes the log output unreadable. + // For example, we shouldn't redact every occurrence of "0". + if value == "true" || value == "false" { + return + } + if _, err := strconv.ParseFloat(value, 64); err == nil { + return + } + switch name { + case "TASK_ID", "PATH", "HOME", "SHELL", "USER", "LOGNAME", "TERM", "TMPDIR", "DISPLAY": + return + } + if strings.HasPrefix(name, "OZ_") || strings.HasPrefix(name, "WARP_") { + switch name[strings.IndexByte(name, '_')+1:] { + case "RUN_ID", "EXECUTION_ID", "WORKER_BACKEND", "WORKSPACE_ROOT", "ENVIRONMENT_FILE", "DOCKER_IMAGE": + return + case "SERVER_ROOT_URL": + if endpoint, err := url.Parse(value); err == nil && endpoint.User == nil && endpoint.RawQuery == "" && endpoint.Fragment == "" { + return + } + } + } + r.add(value) +} + func (r *redactor) add(value string) { if value == "" { return diff --git a/internal/tasktelemetry/log/redaction_test.go b/internal/tasktelemetry/log/redaction_test.go new file mode 100644 index 0000000..4a7e067 --- /dev/null +++ b/internal/tasktelemetry/log/redaction_test.go @@ -0,0 +1,47 @@ +package tasklogs + +import "testing" + +func TestEnvironmentRedaction(t *testing.T) { + for _, list := range []bool{false, true} { + name := "map" + if list { + name = "list" + } + t.Run(name, func(t *testing.T) { + for _, tc := range []struct { + name, value, message, want string + }{ + {"ENABLED", "1", "2026-10-18T19:58:17Z libapparmor1", "2026-10-18T19:58:17Z libapparmor1"}, + {"DISABLED", "0", "\x1b[0m version 4.0.1", "\x1b[0m version 4.0.1"}, + {"DEBUG", "true", "debug=true", "debug=true"}, + {"RETRY_DELAY", "0.5", "delay=0.5", "delay=0.5"}, + {"TASK_ID", "run-id", "task run-id", "task run-id"}, + {"OZ_RUN_ID", "run-id", "oz-task-run-id", "oz-task-run-id"}, + {"WARP_EXECUTION_ID", "execution-id", "execution-id", "execution-id"}, + {"WARP_SERVER_ROOT_URL", "https://staging.warp.dev", "https://staging.warp.dev", "https://staging.warp.dev"}, + {"PATH", "/usr/bin", "using /usr/bin", "using /usr/bin"}, + {"WARP_API_KEY", "1234", "key=1234", "key=[REDACTED]"}, + {"password", "false", "password=false", "password=[REDACTED]"}, + {"AUTH_TOKEN", "x", "token=x", "token=[REDACTED]"}, + {"CUSTOM", "custom-value", "custom-value", "[REDACTED]"}, + {"WARP_SERVER_ROOT_URL", "https://user:password@example.com", "https://user:password@example.com", "[REDACTED]"}, + } { + t.Run(tc.name, func(t *testing.T) { + r := &Reporter{} + if list { + r.AddEnvList([]string{tc.name + "=" + tc.value}) + } else { + r.AddEnv(map[string]string{tc.name: tc.value}) + } + if got := r.redactor.redact(tc.message); got != tc.want { + t.Errorf("structured redaction = %q, want %q", got, tc.want) + } + if got := r.redactor.redactLine(tc.message); got != tc.want { + t.Errorf("raw redaction = %q, want %q", got, tc.want) + } + }) + } + }) + } +} diff --git a/internal/tasktelemetry/log/reporter.go b/internal/tasktelemetry/log/reporter.go index 635d408..749407d 100644 --- a/internal/tasktelemetry/log/reporter.go +++ b/internal/tasktelemetry/log/reporter.go @@ -73,14 +73,14 @@ func (r *Reporter) Context() context.Context { return r.ctx } -// AddEnv registers nonempty environment values for task-local credential redaction. +// AddEnv registers potentially sensitive environment values for task-local redaction. // Structured events match complete values; raw lines also match newline fragments. func (r *Reporter) AddEnv(env map[string]string) { if r == nil { return } - for _, value := range env { - r.redactor.add(value) + for name, value := range env { + r.redactor.addEnv(name, value) } } @@ -89,8 +89,8 @@ func (r *Reporter) AddEnvList(env []string) { return } for _, entry := range env { - _, value, _ := strings.Cut(entry, "=") - r.redactor.add(value) + name, value, _ := strings.Cut(entry, "=") + r.redactor.addEnv(name, value) } }